Просмотр исходного кода

feat: multi-provider agent routing

Extend modelOverrides to accept per-agent provider config:
  "auditor": {
    "model": "gemini-2.5-flash",
    "baseUrl": "https://www.packyapi.com/v1",
    "apiKeyEnv": "PACKYAPI_KEY",
    "stream": false
  }

- AgentLLMOverrideSchema: model + optional baseUrl/provider/apiKeyEnv/stream
- PipelineRunner.resolveOverride(): creates per-agent LLMClient when
  baseUrl differs from default, with client caching
- buildPipelineConfig() helper in CLI utils — all commands now pass
  defaultLLMConfig and modelOverrides consistently
- config set-model gains --base-url, --provider, --api-key-env, --no-stream
- config show-models displays provider routing info
- Backward compatible: string overrides ("auditor": "gpt-4o") still work
Ma 7 месяцев назад
Родитель
Сommit
931e461478

+ 2 - 7
packages/cli/src/commands/audit.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { PipelineRunner } from "@actalk/inkos-core";
-import { loadConfig, createClient, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const auditCommand = new Command("audit")
   .description("Audit a chapter for continuity issues")
@@ -10,7 +10,6 @@ export const auditCommand = new Command("audit")
   .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
     try {
       const config = await loadConfig();
-      const client = createClient(config);
       const root = findProjectRoot();
 
       // If first arg looks like a number, treat it as chapter (auto-detect book)
@@ -24,11 +23,7 @@ export const auditCommand = new Command("audit")
         chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
       }
 
-      const pipeline = new PipelineRunner({
-        client,
-        model: config.llm.model,
-        projectRoot: root,
-      });
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
 
       if (!opts.json) log(`Auditing "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"}...`);
 

+ 2 - 8
packages/cli/src/commands/book.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { PipelineRunner, StateManager, type BookConfig } from "@actalk/inkos-core";
-import { loadConfig, createClient, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
 
 export const bookCommand = new Command("book")
   .description("Manage books");
@@ -19,7 +19,6 @@ bookCommand
   .action(async (opts) => {
     try {
       const config = await loadConfig();
-      const client = createClient(config);
       const root = findProjectRoot();
 
       const bookId = opts.title
@@ -45,12 +44,7 @@ bookCommand
 
       const context = await resolveContext(opts);
 
-      const pipeline = new PipelineRunner({
-        client,
-        model: config.llm.model,
-        projectRoot: root,
-        ...(context ? { externalContext: context } : {}),
-      });
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: context }));
 
       await pipeline.initBook(book);
 

+ 33 - 8
packages/cli/src/commands/config.ts

@@ -116,10 +116,15 @@ const KNOWN_AGENTS = ["writer", "auditor", "reviser", "architect", "radar", "cha
 
 configCommand
   .command("set-model")
-  .description("Set model override for a specific agent")
+  .description("Set model override for a specific agent (with optional provider routing)")
   .argument("<agent>", `Agent name (${KNOWN_AGENTS.join(", ")})`)
-  .argument("<model>", "Model name (e.g., gpt-4o, claude-sonnet-4-20250514)")
-  .action(async (agent: string, model: string) => {
+  .argument("<model>", "Model name (e.g., gpt-4o, gemini-2.5-flash)")
+  .option("--base-url <url>", "API base URL (for different provider)")
+  .option("--provider <provider>", "Provider type (openai / anthropic / custom)")
+  .option("--api-key-env <envVar>", "Env variable name for API key (e.g., PACKYAPI_KEY)")
+  .option("--stream", "Enable streaming (default)")
+  .option("--no-stream", "Disable streaming")
+  .action(async (agent: string, model: string, opts: { baseUrl?: string; provider?: string; apiKeyEnv?: string; stream?: boolean }) => {
     if (!KNOWN_AGENTS.includes(agent as typeof KNOWN_AGENTS[number])) {
       logError(`Unknown agent "${agent}". Valid agents: ${KNOWN_AGENTS.join(", ")}`);
       process.exit(1);
@@ -132,9 +137,21 @@ configCommand
       const raw = await readFile(configPath, "utf-8");
       const config = JSON.parse(raw);
       const overrides = config.modelOverrides ?? {};
-      config.modelOverrides = { ...overrides, [agent]: model };
+
+      const hasProviderOpts = opts.baseUrl || opts.provider || opts.apiKeyEnv || opts.stream === false;
+      if (hasProviderOpts) {
+        const override: Record<string, unknown> = { model };
+        if (opts.baseUrl) override.baseUrl = opts.baseUrl;
+        if (opts.provider) override.provider = opts.provider;
+        if (opts.apiKeyEnv) override.apiKeyEnv = opts.apiKeyEnv;
+        if (opts.stream === false) override.stream = false;
+        config.modelOverrides = { ...overrides, [agent]: override };
+      } else {
+        config.modelOverrides = { ...overrides, [agent]: model };
+      }
+
       await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
-      log(`Model override: ${agent} → ${model}`);
+      log(`Model override: ${agent} → ${model}${opts.baseUrl ? ` (${opts.baseUrl})` : ""}`);
     } catch (e) {
       logError(`Failed to update config: ${e}`);
       process.exit(1);
@@ -179,7 +196,7 @@ configCommand
       const raw = await readFile(configPath, "utf-8");
       const config = JSON.parse(raw);
       const defaultModel = config.llm?.model ?? "(not set)";
-      const overrides: Record<string, string> = config.modelOverrides ?? {};
+      const overrides: Record<string, unknown> = config.modelOverrides ?? {};
 
       if (opts.json) {
         log(JSON.stringify({ defaultModel, overrides }, null, 2));
@@ -192,8 +209,16 @@ configCommand
         return;
       }
       log("Agent overrides:");
-      for (const [agent, model] of Object.entries(overrides)) {
-        log(`  ${agent} → ${model}`);
+      for (const [agent, value] of Object.entries(overrides)) {
+        if (typeof value === "string") {
+          log(`  ${agent} → ${value}`);
+        } else {
+          const o = value as Record<string, unknown>;
+          const parts = [o.model as string];
+          if (o.baseUrl) parts.push(`@ ${o.baseUrl}`);
+          if (o.stream === false) parts.push("[no-stream]");
+          log(`  ${agent} → ${parts.join(" ")}`);
+        }
       }
       log("");
       const usingDefault = KNOWN_AGENTS.filter((a) => !(a in overrides));

+ 3 - 8
packages/cli/src/commands/daemon.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
-import { Scheduler, createLLMClient } from "@actalk/inkos-core";
-import { loadConfig, findProjectRoot, log, logError } from "../utils.js";
+import { Scheduler } from "@actalk/inkos-core";
+import { loadConfig, findProjectRoot, buildPipelineConfig, log, logError } from "../utils.js";
 import { writeFile, readFile, unlink } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -11,7 +11,6 @@ export const upCommand = new Command("up")
   .action(async () => {
     try {
       const config = await loadConfig();
-      const client = createLLMClient(config.llm);
       const root = findProjectRoot();
 
       // Check if already running
@@ -34,11 +33,7 @@ export const upCommand = new Command("up")
       await writeFile(pidPath, String(process.pid), "utf-8");
 
       const scheduler = new Scheduler({
-        client,
-        model: config.llm.model,
-        projectRoot: root,
-        notifyChannels: config.notify,
-        modelOverrides: config.modelOverrides,
+        ...buildPipelineConfig(config, root),
         radarCron: config.daemon.schedule.radarCron,
         writeCron: config.daemon.schedule.writeCron,
         maxConcurrentBooks: config.daemon.maxConcurrentBooks,

+ 2 - 7
packages/cli/src/commands/draft.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { PipelineRunner } from "@actalk/inkos-core";
-import { loadConfig, createClient, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
 
 export const draftCommand = new Command("draft")
   .description("Write a draft chapter (no audit/revise)")
@@ -12,16 +12,11 @@ export const draftCommand = new Command("draft")
   .action(async (bookIdArg: string | undefined, opts) => {
     try {
       const config = await loadConfig();
-      const client = createClient(config);
       const root = findProjectRoot();
       const bookId = await resolveBookId(bookIdArg, root);
       const context = await resolveContext(opts);
 
-      const pipeline = new PipelineRunner({
-        client,
-        model: config.llm.model,
-        projectRoot: root,
-      });
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
 
       const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
 

+ 3 - 13
packages/cli/src/commands/import.ts

@@ -2,7 +2,7 @@ import { Command } from "commander";
 import { PipelineRunner, splitChapters } from "@actalk/inkos-core";
 import { readFile, readdir, stat } from "node:fs/promises";
 import { join, resolve } from "node:path";
-import { loadConfig, createClient, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const importCommand = new Command("import")
   .description("Import external data into a book");
@@ -18,13 +18,8 @@ importCommand
       const root = findProjectRoot();
       const targetBookId = await resolveBookId(targetBookIdArg, root);
       const config = await loadConfig();
-      const client = createClient(config);
 
-      const pipeline = new PipelineRunner({
-        client,
-        model: config.llm.model,
-        projectRoot: root,
-      });
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
 
       if (!opts.json) log(`Importing canon from "${opts.from}" into "${targetBookId}"...`);
 
@@ -63,7 +58,6 @@ importCommand
       const root = findProjectRoot();
       const bookId = await resolveBookId(bookIdArg, root);
       const config = await loadConfig();
-      const client = createClient(config);
 
       const fromPath = resolve(opts.from);
       const fromStat = await stat(fromPath);
@@ -108,11 +102,7 @@ importCommand
         }
       }
 
-      const pipeline = new PipelineRunner({
-        client,
-        model: config.llm.model,
-        projectRoot: root,
-      });
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
 
       const result = await pipeline.importChapters({
         bookId,

+ 2 - 7
packages/cli/src/commands/radar.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { PipelineRunner } from "@actalk/inkos-core";
-import { loadConfig, createClient, findProjectRoot, log, logError } from "../utils.js";
+import { loadConfig, buildPipelineConfig, findProjectRoot, log, logError } from "../utils.js";
 import { writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -14,14 +14,9 @@ radarCommand
   .action(async (opts) => {
     try {
       const config = await loadConfig();
-      const client = createClient(config);
       const root = findProjectRoot();
 
-      const pipeline = new PipelineRunner({
-        client,
-        model: config.llm.model,
-        projectRoot: root,
-      });
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
 
       if (!opts.json) log("Scanning market...");
 

+ 2 - 7
packages/cli/src/commands/revise.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { PipelineRunner, type ReviseMode } from "@actalk/inkos-core";
-import { loadConfig, createClient, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const reviseCommand = new Command("revise")
   .description("Revise a chapter based on audit issues")
@@ -11,7 +11,6 @@ export const reviseCommand = new Command("revise")
   .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
     try {
       const config = await loadConfig();
-      const client = createClient(config);
       const root = findProjectRoot();
 
       let bookId: string;
@@ -24,11 +23,7 @@ export const reviseCommand = new Command("revise")
         chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
       }
 
-      const pipeline = new PipelineRunner({
-        client,
-        model: config.llm.model,
-        projectRoot: root,
-      });
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
 
       const mode = opts.mode as ReviseMode;
       if (!opts.json) log(`Revising "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"} [mode: ${mode}]...`);

+ 2 - 7
packages/cli/src/commands/style.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { StateManager, analyzeStyle, PipelineRunner } from "@actalk/inkos-core";
-import { loadConfig, createClient, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 import { readFile, writeFile, mkdir } from "node:fs/promises";
 import { join, resolve } from "node:path";
 
@@ -73,12 +73,7 @@ styleCommand
       if (!opts.statsOnly) {
         if (!opts.json) log("Generating qualitative style guide via LLM...");
         const config = await loadConfig();
-        const client = createClient(config);
-        const pipeline = new PipelineRunner({
-          client,
-          model: config.llm.model,
-          projectRoot: root,
-        });
+        const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
         await pipeline.generateStyleGuide(bookId, text, opts.name ?? file);
         if (!opts.json) log("Style guide (style_guide.md) generated.");
       }

+ 3 - 16
packages/cli/src/commands/write.ts

@@ -3,7 +3,7 @@ import { PipelineRunner, StateManager } from "@actalk/inkos-core";
 import { readdir, unlink } from "node:fs/promises";
 import { join } from "node:path";
 import { createInterface } from "node:readline";
-import { loadConfig, createClient, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
 
 export const writeCommand = new Command("write")
   .description("Write chapters");
@@ -20,18 +20,11 @@ writeCommand
   .action(async (bookIdArg: string | undefined, opts) => {
     try {
       const config = await loadConfig();
-      const client = createClient(config);
       const root = findProjectRoot();
       const bookId = await resolveBookId(bookIdArg, root);
       const context = await resolveContext(opts);
 
-      const pipeline = new PipelineRunner({
-        client,
-        model: config.llm.model,
-        projectRoot: root,
-        notifyChannels: config.notify,
-        ...(context ? { externalContext: context } : {}),
-      });
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: context }));
 
       const count = parseInt(opts.count, 10);
       const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
@@ -88,7 +81,6 @@ writeCommand
   .action(async (args: ReadonlyArray<string>, opts) => {
     try {
       const config = await loadConfig();
-      const client = createClient(config);
       const root = findProjectRoot();
 
       let bookId: string;
@@ -159,12 +151,7 @@ writeCommand
 
       const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
 
-      const pipeline = new PipelineRunner({
-        client,
-        model: config.llm.model,
-        projectRoot: root,
-        notifyChannels: config.notify,
-      });
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
 
       const result = await pipeline.writeNextChapter(bookId, wordCount);
 

+ 18 - 1
packages/cli/src/utils.ts

@@ -2,7 +2,7 @@ import { readFile, access } from "node:fs/promises";
 import { homedir } from "node:os";
 import { join, resolve } from "node:path";
 import { config as loadEnv } from "dotenv";
-import { createLLMClient, StateManager, type ProjectConfig, ProjectConfigSchema } from "@actalk/inkos-core";
+import { createLLMClient, StateManager, type ProjectConfig, ProjectConfigSchema, type PipelineConfig } from "@actalk/inkos-core";
 
 export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
 export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
@@ -87,6 +87,23 @@ export function createClient(config: ProjectConfig) {
   return createLLMClient(config.llm);
 }
 
+export function buildPipelineConfig(
+  config: ProjectConfig,
+  root: string,
+  extra?: Partial<Pick<PipelineConfig, "notifyChannels" | "radarSources" | "externalContext">>,
+): PipelineConfig {
+  return {
+    client: createLLMClient(config.llm),
+    model: config.llm.model,
+    projectRoot: root,
+    defaultLLMConfig: config.llm,
+    modelOverrides: config.modelOverrides,
+    notifyChannels: extra?.notifyChannels ?? config.notify,
+    radarSources: extra?.radarSources,
+    externalContext: extra?.externalContext,
+  };
+}
+
 export function log(message: string): void {
   process.stdout.write(`${message}\n`);
 }

+ 1 - 1
packages/core/src/index.ts

@@ -1,7 +1,7 @@
 // Models
 export { type BookConfig, type Platform, type Genre, type BookStatus, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema } from "./models/book.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
-export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, ProjectConfigSchema, LLMConfigSchema, DetectionConfigSchema, QualityGatesSchema } from "./models/project.js";
+export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type AgentLLMOverride, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema } from "./models/project.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js";
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js";

+ 13 - 1
packages/core/src/models/project.ts

@@ -58,13 +58,25 @@ export const QualityGatesSchema = z.object({
 
 export type QualityGates = z.infer<typeof QualityGatesSchema>;
 
+export const AgentLLMOverrideSchema = z.object({
+  model: z.string().min(1),
+  provider: z.enum(["anthropic", "openai", "custom"]).optional(),
+  baseUrl: z.string().url().optional(),
+  apiKeyEnv: z.string().optional(),
+  stream: z.boolean().optional(),
+});
+
+export type AgentLLMOverride = z.infer<typeof AgentLLMOverrideSchema>;
+
+const ModelOverrideValueSchema = z.union([z.string(), AgentLLMOverrideSchema]);
+
 export const ProjectConfigSchema = z.object({
   name: z.string().min(1),
   version: z.literal("0.1.0"),
   llm: LLMConfigSchema,
   notify: z.array(NotifyChannelSchema).default([]),
   detection: DetectionConfigSchema.optional(),
-  modelOverrides: z.record(z.string(), z.string()).optional(),
+  modelOverrides: z.record(z.string(), ModelOverrideValueSchema).optional(),
   daemon: z.object({
     schedule: z.object({
       radarCron: z.string().default("0 */6 * * *"),

+ 41 - 7
packages/core/src/pipeline/runner.ts

@@ -1,8 +1,8 @@
 import type { LLMClient } from "../llm/provider.js";
-import { chatCompletion } from "../llm/provider.js";
+import { chatCompletion, createLLMClient } from "../llm/provider.js";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
-import type { NotifyChannel } from "../models/project.js";
+import type { NotifyChannel, LLMConfig, AgentLLMOverride } from "../models/project.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import { ArchitectAgent } from "../agents/architect.js";
 import { WriterAgent } from "../agents/writer.js";
@@ -27,10 +27,11 @@ export interface PipelineConfig {
   readonly client: LLMClient;
   readonly model: string;
   readonly projectRoot: string;
+  readonly defaultLLMConfig?: LLMConfig;
   readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
   readonly radarSources?: ReadonlyArray<RadarSource>;
   readonly externalContext?: string;
-  readonly modelOverrides?: Record<string, string>;
+  readonly modelOverrides?: Record<string, string | AgentLLMOverride>;
 }
 
 export interface ChapterPipelineResult {
@@ -93,6 +94,7 @@ export interface ImportChaptersResult {
 export class PipelineRunner {
   private readonly state: StateManager;
   private readonly config: PipelineConfig;
+  private readonly agentClients = new Map<string, LLMClient>();
 
   constructor(config: PipelineConfig) {
     this.config = config;
@@ -108,14 +110,46 @@ export class PipelineRunner {
     };
   }
 
-  private modelFor(agentName: string): string {
-    return this.config.modelOverrides?.[agentName] ?? this.config.model;
+  private resolveOverride(agentName: string): { model: string; client: LLMClient } {
+    const override = this.config.modelOverrides?.[agentName];
+    if (!override) {
+      return { model: this.config.model, client: this.config.client };
+    }
+    if (typeof override === "string") {
+      return { model: override, client: this.config.client };
+    }
+    // Full override — needs its own client if baseUrl differs
+    if (!override.baseUrl) {
+      return { model: override.model, client: this.config.client };
+    }
+    const cacheKey = `${override.baseUrl}:${override.provider ?? "custom"}`;
+    let client = this.agentClients.get(cacheKey);
+    if (!client) {
+      const base = this.config.defaultLLMConfig;
+      const apiKey = override.apiKeyEnv
+        ? process.env[override.apiKeyEnv] ?? ""
+        : base?.apiKey ?? "";
+      client = createLLMClient({
+        provider: override.provider ?? base?.provider ?? "custom",
+        baseUrl: override.baseUrl,
+        apiKey,
+        model: override.model,
+        temperature: base?.temperature ?? 0.7,
+        maxTokens: base?.maxTokens ?? 8192,
+        thinkingBudget: base?.thinkingBudget ?? 0,
+        apiFormat: base?.apiFormat ?? "chat",
+        stream: override.stream ?? base?.stream ?? true,
+      });
+      this.agentClients.set(cacheKey, client);
+    }
+    return { model: override.model, client };
   }
 
   private agentCtxFor(agent: string, bookId?: string): AgentContext {
+    const { model, client } = this.resolveOverride(agent);
     return {
-      client: this.config.client,
-      model: this.modelFor(agent),
+      client,
+      model,
       projectRoot: this.config.projectRoot,
       bookId,
     };