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

fix(llm): support local model creation flows

Ma 6 месяцев назад
Родитель
Сommit
deceab8b84

+ 91 - 0
packages/core/src/__tests__/effective-llm-config.test.ts

@@ -374,6 +374,97 @@ describe("resolveEffectiveLLMConfig", () => {
     })).rejects.toThrow(/模型.*kimi-k2\.5.*不属于.*google/);
   });
 
+  it("CLI env 指向 Ollama 时允许用户本地安装的动态模型", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "google",
+      services: [{ service: "google" }, { service: "ollama" }],
+      defaultModel: "gemini-2.5-flash",
+    });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: {
+        global: {
+          INKOS_LLM_SERVICE: "ollama",
+          INKOS_LLM_PROVIDER: "openai",
+          INKOS_LLM_BASE_URL: "http://127.0.0.1:11434/v1",
+          INKOS_LLM_MODEL: "qwen3.6:35b-a3b",
+        },
+        project: {},
+        process: {},
+      },
+      requireApiKey: false,
+    });
+
+    expect(result.llm.service).toBe("ollama");
+    expect(result.llm.provider).toBe("openai");
+    expect(result.llm.baseUrl).toBe("http://127.0.0.1:11434/v1");
+    expect(result.llm.model).toBe("qwen3.6:35b-a3b");
+    expect(result.llm.apiKey).toBe("");
+  });
+
+  it("CLI 使用 Studio Ollama 配置时保留不在内置 bank 的默认模型", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "ollama",
+      services: [{ service: "ollama", apiFormat: "chat", stream: true }],
+      defaultModel: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+    });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: { global: {}, project: {}, process: {} },
+      requireApiKey: false,
+    });
+
+    expect(result.llm.service).toBe("ollama");
+    expect(result.llm.baseUrl).toBe("http://localhost:11434/v1");
+    expect(result.llm.model).toBe("Qwen3.6-35B-A3B-APEX-I-Mini.gguf");
+  });
+
+  it("CLI 建书路径使用 Studio Ollama 配置时不要求 API key", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "ollama",
+      services: [{ service: "ollama", apiFormat: "chat", stream: false }],
+      defaultModel: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+    });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: { global: {}, project: {}, process: {} },
+    });
+
+    expect(result.llm.service).toBe("ollama");
+    expect(result.llm.baseUrl).toBe("http://localhost:11434/v1");
+    expect(result.llm.model).toBe("Qwen3.6-35B-A3B-APEX-I-Mini.gguf");
+    expect(result.llm.apiKey).toBe("");
+  });
+
+  it("Studio 建书路径使用 Studio Ollama 配置时不要求 API key", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "ollama",
+      services: [{ service: "ollama", apiFormat: "chat", stream: false }],
+      defaultModel: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+    });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "studio",
+      projectRoot: root,
+      envLayers: { global: {}, project: {}, process: {} },
+    });
+
+    expect(result.llm.service).toBe("ollama");
+    expect(result.llm.baseUrl).toBe("http://localhost:11434/v1");
+    expect(result.llm.model).toBe("Qwen3.6-35B-A3B-APEX-I-Mini.gguf");
+    expect(result.llm.apiKey).toBe("");
+  });
+
   it("从 provider bank 应用 service transport 默认值", async () => {
     await writeProject({
       configSource: "studio",

+ 16 - 0
packages/core/src/__tests__/list-models.test.ts

@@ -44,6 +44,22 @@ describe("listModelsForService (B8)", () => {
     expect(models.some((m) => m.id === "my-proxy-model")).toBe(true);
   });
 
+  it("ollama 无 apiKey 时也探测本地 /models 并保留本地动态模型", async () => {
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({ data: [{ id: "qwen3.6:35b-a3b" }] }),
+    } as any);
+    global.fetch = fetchMock as unknown as typeof fetch;
+
+    const models = await listModelsForService("ollama");
+
+    expect(fetchMock).toHaveBeenCalledWith(
+      "http://localhost:11434/v1/models",
+      expect.any(Object),
+    );
+    expect(models.some((m) => m.id === "qwen3.6:35b-a3b")).toBe(true);
+  });
+
   it("R4:env 补丁已删除 — INKOS_LLM_MODEL 不再污染跨 service 菜单", async () => {
     process.env.INKOS_LLM_MODEL = "my-secret-model";
     const models = await listModelsForService("anthropic");

+ 49 - 2
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -797,13 +797,14 @@ describe("PipelineRunner", () => {
     );
 
     try {
-      await runner.writeDraft(bookId, "Ignore the guild chase and bring focus back to mentor conflict.");
+      const chapterContext = "Ignore the guild chase and bring focus back to mentor conflict.";
+      await runner.writeDraft(bookId, chapterContext);
 
       expect(planChapter).toHaveBeenCalledTimes(1);
       expect(composeChapter).toHaveBeenCalledTimes(1);
 
       const writeInput = writeChapter.mock.calls[0]?.[0];
-      expect(writeInput?.externalContext).toBeUndefined();
+      expect(writeInput?.externalContext).toBe(chapterContext);
       expect(writeInput?.chapterIntent).toContain("# Chapter Intent");
       expect(writeInput?.chapterMemo).toEqual(expect.objectContaining({
         chapter: 1,
@@ -1435,6 +1436,7 @@ describe("PipelineRunner", () => {
       expect(composeChapter).toHaveBeenCalledTimes(1);
       const writeInput = writeChapter.mock.calls[0]?.[0];
       expect(writeInput?.chapterIntent).toContain("# Chapter Intent");
+      expect(writeInput?.externalContext).toBeUndefined();
       expect(writeInput?.chapterMemo).toEqual(expect.objectContaining({
         chapter: 1,
       }));
@@ -1444,6 +1446,51 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("passes configured writeNextChapter context through planner and governed writer input", async () => {
+    const chapterContext = "本章标题:雨夜账本\n必须围绕账本失窃后的当面对质展开。";
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "v2",
+      externalContext: chapterContext,
+    });
+
+    await Promise.all([
+      writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
+    ]);
+
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
+    vi.spyOn(ComposerModule, "composeGovernedChapter");
+    const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        title: "雨夜账本",
+        content: "Governed pipeline draft.",
+        wordCount: "Governed pipeline draft.".length,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+
+    try {
+      await runner.writeNextChapter(bookId, 220);
+
+      expect(planChapter.mock.calls[0]?.[0].externalContext).toBe(chapterContext);
+      const writeInput = writeChapter.mock.calls[0]?.[0];
+      expect(writeInput?.externalContext).toBe(chapterContext);
+      expect(writeInput?.chapterMemo?.goal).toBe(chapterContext);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("re-plans instead of reusing a persisted invalid intent artifact in v2 mode", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "v2",

+ 21 - 0
packages/core/src/__tests__/planner.test.ts

@@ -161,6 +161,27 @@ describe("PlannerAgent.planChapter memo generation", () => {
     expect(options).not.toHaveProperty("maxTokens");
   });
 
+  it("passes per-chapter user context into the memo prompt as a high-priority instruction", async () => {
+    const chatSpy = vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
+      content: validMemoRaw(1),
+      usage: ZERO_USAGE,
+    } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
+
+    await makePlanner().planChapter({
+      book: makeBook(),
+      bookDir,
+      chapterNumber: 1,
+      externalContext: "本章标题:雨夜账本\n必须围绕账本失窃后的当面对质展开。",
+    });
+
+    const callArgs = chatSpy.mock.calls[0]!;
+    const messages = callArgs[2] as ReadonlyArray<{ role: string; content: string }>;
+    const userMsg = messages.find((m) => m.role === "user");
+    expect(userMsg?.content).toContain("本章用户指令");
+    expect(userMsg?.content).toContain("本章标题:雨夜账本");
+    expect(userMsg?.content).toContain("当面对质");
+  });
+
   it("retries when the first response is malformed and succeeds on retry", async () => {
     const chatSpy = vi.spyOn(llmProvider, "chatCompletion")
       .mockResolvedValueOnce({

+ 94 - 0
packages/core/src/__tests__/provider.test.ts

@@ -300,6 +300,37 @@ describe("chatCompletion via pi-ai", () => {
     vi.unstubAllGlobals();
   });
 
+  it("does not leave a stream monitor timer after native non-stream chat", async () => {
+    vi.useFakeTimers();
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        choices: [{ message: { content: "你好!" } }],
+        usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
+      }),
+    });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const client = makeClient(0.7, {
+      service: "custom",
+      stream: false,
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "https://gateway.example/v1",
+      },
+    });
+    const result = await chatCompletion(client, "gpt-5.4", [{ role: "user", content: "nihao" }], {
+      onStreamProgress: vi.fn(),
+    });
+
+    expect(result.content).toBe("你好!");
+    expect(vi.getTimerCount()).toBe(0);
+
+    vi.unstubAllGlobals();
+    vi.useRealTimers();
+  });
+
   it("attaches a proxy dispatcher for custom openai-compatible chat when proxyUrl is configured", async () => {
     const fetchMock = vi.fn().mockResolvedValue({
       ok: true,
@@ -468,6 +499,68 @@ describe("chatCompletion via pi-ai", () => {
     vi.unstubAllGlobals();
   });
 
+  it("uses native fetch transport for local Ollama without an API key", async () => {
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        choices: [{ message: { content: "本地 Ollama 可用" } }],
+        usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 },
+      }),
+    });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const client = makeClient(0.7, {
+      service: "ollama",
+      configSource: "env",
+      stream: false,
+      _apiKey: "",
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "ollama",
+        baseUrl: "http://127.0.0.1:11434/v1",
+      },
+    });
+    const result = await chatCompletion(client, "Qwen3.6-35B-A3B-APEX-I-Mini.gguf", [
+      { role: "user", content: "ping" },
+    ]);
+
+    expect(result.content).toBe("本地 Ollama 可用");
+    expect(fetchMock).toHaveBeenCalledOnce();
+    expect(mockCompleteSimple).not.toHaveBeenCalled();
+
+    vi.unstubAllGlobals();
+  });
+
+  it("uses native fetch transport for local custom OpenAI-compatible endpoints without an API key", async () => {
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        choices: [{ message: { content: "本地自定义端点可用" } }],
+        usage: { prompt_tokens: 5, completion_tokens: 6, total_tokens: 11 },
+      }),
+    });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const client = makeClient(0.7, {
+      service: "custom",
+      configSource: "env",
+      stream: false,
+      _apiKey: "",
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "http://127.0.0.1:11434/v1",
+      },
+    });
+    const result = await chatCompletion(client, "local-qwen", [{ role: "user", content: "ping" }]);
+
+    expect(result.content).toBe("本地自定义端点可用");
+    expect(fetchMock).toHaveBeenCalledOnce();
+    expect(mockCompleteSimple).not.toHaveBeenCalled();
+
+    vi.unstubAllGlobals();
+  });
+
   it("uses native fetch transport for custom anthropic-compatible non-stream chat", async () => {
     const fetchMock = vi.fn().mockResolvedValue({
       ok: true,
@@ -656,6 +749,7 @@ describe("createLLMClient per-call maxTokens not capped (v2.0.0)", () => {
       provider: "openai",
       baseUrl: "http://localhost:0",
       model: "test-model",
+      apiKey: "test-key",
     }));
 
     mockStreamSimple.mockReset();

+ 27 - 0
packages/core/src/__tests__/service-resolver.test.ts

@@ -143,6 +143,33 @@ describe("resolveServiceModel", () => {
     ).rejects.toThrow(/API key/i);
   });
 
+  it("resolves Ollama local models without an API key", async () => {
+    const result = await resolveServiceModel(
+      "ollama",
+      "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+      root,
+    );
+
+    expect(result.apiKey).toBe("");
+    expect(result.model.id).toBe("Qwen3.6-35B-A3B-APEX-I-Mini.gguf");
+    expect(result.model.provider).toBe("ollama");
+    expect(result.model.baseUrl).toBe("http://localhost:11434/v1");
+  });
+
+  it("resolves local custom OpenAI-compatible services without an API key", async () => {
+    const result = await resolveServiceModel(
+      "custom:LocalProxy",
+      "gpt-5.5",
+      root,
+      "http://127.0.0.1:4567/v1",
+    );
+
+    expect(result.apiKey).toBe("");
+    expect(result.model.id).toBe("gpt-5.5");
+    expect(result.model.api).toBe("openai-completions");
+    expect(result.model.baseUrl).toBe("http://127.0.0.1:4567/v1");
+  });
+
   it("resolves custom service with baseUrl", async () => {
     await mkdir(join(root, ".inkos"), { recursive: true });
     await writeFile(

+ 64 - 0
packages/core/src/__tests__/writer.test.ts

@@ -37,6 +37,70 @@ describe("WriterAgent", () => {
     vi.restoreAllMocks();
   });
 
+  it("renders per-chapter user context in governed creative prompts", () => {
+    const agent = new WriterAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: "/tmp/inkos-writer-context-test",
+    });
+
+    const prompt = (agent as unknown as {
+      buildGovernedUserPrompt(params: {
+        readonly chapterNumber: number;
+        readonly chapterMemo: {
+          readonly chapter: number;
+          readonly goal: string;
+          readonly isGoldenOpening: boolean;
+          readonly body: string;
+          readonly threadRefs: readonly string[];
+        };
+        readonly contextPackage: { readonly chapter: number; readonly selectedContext: readonly [] };
+        readonly ruleStack: {
+          readonly layers: readonly [];
+          readonly sections: { readonly hard: readonly string[]; readonly soft: readonly string[]; readonly diagnostic: readonly string[] };
+          readonly overrideEdges: readonly [];
+          readonly activeOverrides: readonly [];
+        };
+        readonly lengthSpec: ReturnType<typeof buildLengthSpec>;
+        readonly language?: "zh" | "en";
+        readonly externalContext?: string;
+      }): string;
+    }).buildGovernedUserPrompt({
+      chapterNumber: 7,
+      chapterMemo: {
+        chapter: 7,
+        goal: "推进账本线",
+        isGoldenOpening: false,
+        body: "## 当前任务\n围绕账本线推进。",
+        threadRefs: [],
+      },
+      contextPackage: { chapter: 7, selectedContext: [] },
+      ruleStack: {
+        layers: [],
+        sections: { hard: [], soft: [], diagnostic: [] },
+        overrideEdges: [],
+        activeOverrides: [],
+      },
+      lengthSpec: buildLengthSpec(1200, "zh"),
+      language: "zh",
+      externalContext: "本章标题:雨夜账本\n必须围绕账本失窃后的当面对质展开。",
+    });
+
+    expect(prompt).toContain("本章用户指令");
+    expect(prompt).toContain("本章标题:雨夜账本");
+    expect(prompt).toContain("当面对质");
+  });
+
   it("uses compact summary context plus selected long-range evidence during governed settlement", async () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-writer-test-"));
     const bookDir = join(root, "book");

+ 20 - 0
packages/core/src/agents/planner-prompts.ts

@@ -210,6 +210,7 @@ defer:
 export const PLANNER_MEMO_USER_TEMPLATE_EN = `# Chapter {{chapterNumber}} memo request
 
 {{brief_block}}
+{{chapter_context_block}}
 
 ## Last screen of previous chapter (excerpt)
 {{previous_chapter_ending_excerpt}}
@@ -258,6 +259,7 @@ export function getPlannerMemoUserTemplate(language: "zh" | "en" = "zh"): string
 export const PLANNER_MEMO_USER_TEMPLATE = `# 第 {{chapterNumber}} 章 memo 请求
 
 {{brief_block}}
+{{chapter_context_block}}
 
 ## 上一章最后一屏(原文节选)
 {{previous_chapter_ending_excerpt}}
@@ -303,6 +305,7 @@ export interface PlannerUserMessageInput {
   readonly isGoldenOpening: boolean;
   readonly bookRulesRelevant: string;
   readonly brief?: string;
+  readonly chapterContext?: string;
   readonly language?: "zh" | "en";
 }
 
@@ -313,10 +316,12 @@ export function buildPlannerUserMessage(input: PlannerUserMessageInput): string
   const noText = language === "en" ? "no" : "否";
 
   const briefBlock = buildBriefBlock(input.brief ?? "", language);
+  const chapterContextBlock = buildChapterContextBlock(input.chapterContext ?? "", language);
 
   const filled = template
     .replaceAll("{{chapterNumber}}", String(input.chapterNumber))
     .replaceAll("{{brief_block}}", briefBlock)
+    .replaceAll("{{chapter_context_block}}", chapterContextBlock)
     .replaceAll("{{previous_chapter_ending_excerpt}}", input.previousChapterEndingExcerpt)
     .replaceAll("{{recent_summaries}}", input.recentSummaries)
     .replaceAll("{{current_arc_prose}}", input.currentArcProse)
@@ -354,6 +359,21 @@ ${trimmed}
 brief 是用户的直接指令。本章规划时,必须优先兑现 brief 里写明的核心设定(主角设定、世界前提、开场机制、样本章回钩子等)。**不要把 brief 里的核心设定推迟到后面的章节**——该在前几章落地的必须落地。`;
 }
 
+function buildChapterContextBlock(chapterContext: string, language: "zh" | "en"): string {
+  const trimmed = chapterContext.trim();
+  if (!trimmed) return "";
+  if (language === "en") {
+    return `## Per-chapter user instruction (highest priority for this chapter)
+${trimmed}
+
+This is the user's direct instruction for the current chapter. The memo must obey it before the outline fallback. If the user specifies a chapter title, preserve that title exactly in the memo so the writer can use it as CHAPTER_TITLE. If it conflicts with the volume outline, reconcile by keeping continuity but following this chapter instruction.`;
+  }
+  return `## 本章用户指令(本章最高优先级)
+${trimmed}
+
+这是用户对当前章节的直接指令。memo 必须优先遵守它,再参考卷纲兜底。如果用户指定了章节标题,必须在 memo 中原样保留该标题,供写手作为 CHAPTER_TITLE 使用。若它与卷纲不完全一致,保持连续性,但以本章用户指令为准。`;
+}
+
 // ---------------------------------------------------------------------------
 // 黄金三章 prose guidance — Phase 6.5
 // Single conditional append (chapterNumber <= 3). No new schema, no new

+ 3 - 0
packages/core/src/agents/planner.ts

@@ -139,6 +139,7 @@ export class PlannerAgent extends BaseAgent {
       chapterSummariesRaw: seedMaterials.chapterSummariesRaw,
       previousEndingExcerpt: seedMaterials.previousEndingExcerpt,
       brief: seedMaterials.brief,
+      chapterContext: input.externalContext,
       recyclableHooks: memorySelection.recyclableHooks,
       // Phase hotfix 4: thread book language through so the planner uses
       // English prompts (system + user template + golden opening guidance)
@@ -185,6 +186,7 @@ export class PlannerAgent extends BaseAgent {
     readonly chapterSummariesRaw: string;
     readonly previousEndingExcerpt?: string;
     readonly brief?: string;
+    readonly chapterContext?: string;
     readonly recyclableHooks?: ReadonlyArray<StoredHook>;
     readonly language?: "zh" | "en";
   }): Promise<ChapterMemo> {
@@ -229,6 +231,7 @@ export class PlannerAgent extends BaseAgent {
       isGoldenOpening: input.isGoldenOpening,
       bookRulesRelevant: bookRulesRaw.trim().length > 0 ? bookRulesRaw.trim() : noBookRules,
       brief: input.brief ?? "",
+      chapterContext: input.chapterContext ?? "",
       language,
     });
 

+ 22 - 0
packages/core/src/agents/writer.ts

@@ -212,6 +212,7 @@ export class WriterAgent extends BaseAgent {
           chapterIntentData: input.chapterIntentData,
           contextPackage: input.contextPackage,
           ruleStack: input.ruleStack,
+          externalContext: input.externalContext,
           lengthSpec: resolvedLengthSpec,
           language: book.language ?? genreProfile.language,
           varianceBrief: englishVarianceBrief?.text,
@@ -814,6 +815,7 @@ ${lengthRequirementBlock}
     readonly chapterIntentData?: ChapterIntent;
     readonly contextPackage: ContextPackage;
     readonly ruleStack: RuleStack;
+    readonly externalContext?: string;
     readonly lengthSpec: LengthSpec;
     readonly language?: "zh" | "en";
     readonly varianceBrief?: string;
@@ -836,11 +838,14 @@ ${lengthRequirementBlock}
     const selectedEvidenceBlock = params.selectedEvidenceBlock
       ? `\n${sanitizeNarrativeEvidenceBlock(params.selectedEvidenceBlock, language)}\n`
       : "";
+    const chapterContextBlock = this.buildChapterContextBlock(params.externalContext, language);
     const briefNarrative = renderMemoAsNarrativeBlock(params.chapterMemo, params.chapterIntentData, language);
 
     if (params.language === "en") {
       return `Write chapter ${params.chapterNumber}.
 
+${chapterContextBlock}
+
 ${briefNarrative}
 
 ## Selected Context
@@ -860,6 +865,8 @@ ${lengthRequirementBlock}
 
     return `请续写第${params.chapterNumber}章。
 
+${chapterContextBlock}
+
 ${briefNarrative}
 
 ## 已选上下文
@@ -877,6 +884,21 @@ ${lengthRequirementBlock}
 - 只需输出 PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT 三个区块`;
   }
 
+  private buildChapterContextBlock(externalContext: string | undefined, language: "zh" | "en"): string {
+    const trimmed = externalContext?.trim();
+    if (!trimmed) return "";
+    if (language === "en") {
+      return `## Per-chapter user instruction (highest priority)
+${trimmed}
+
+Obey this direct instruction for the current chapter. If it specifies a chapter title, use that title exactly in CHAPTER_TITLE. Keep continuity, but do not replace this instruction with the outline fallback.`;
+    }
+    return `## 本章用户指令(最高优先级)
+${trimmed}
+
+这是用户对当前章节的直接指令。若其中指定章节标题,CHAPTER_TITLE 必须原样使用该标题。保持连续性,但不要用卷纲兜底替换这条指令。`;
+  }
+
   private joinGovernedEvidenceBlocks(blocks: ReturnType<typeof buildGovernedMemoryEvidenceBlocks> | undefined): string | undefined {
     if (!blocks) {
       return undefined;

+ 25 - 5
packages/core/src/llm/provider.ts

@@ -18,6 +18,7 @@ import { resolveServicePreset } from "./service-presets.js";
 import { getEndpoint } from "./providers/index.js";
 import { lookupModel } from "./providers/lookup.js";
 import { fetchWithProxy } from "../utils/proxy-fetch.js";
+import { isApiKeyOptionalForEndpoint } from "../utils/llm-endpoint-auth.js";
 
 
 // === Streaming Monitor Types ===
@@ -183,6 +184,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   else if (inkosProvider?.id === "zhipu") piProvider = "zai";
   else if (inkosProvider?.id === "openrouter") piProvider = "openrouter";
   else if (inkosProvider?.id === "githubCopilot") piProvider = "githubCopilot";
+  else if (inkosProvider?.id === "ollama") piProvider = "ollama";
   else if (inkosProvider?.api === "anthropic-messages") piProvider = "anthropic";
   else piProvider = provider;
 
@@ -466,9 +468,26 @@ async function withTransientLLMRetry<T>(
 }
 
 function shouldUseNativeCustomTransport(client: LLMClient): boolean {
-  return client.configSource === "studio"
-    && client.service === "custom"
-    && (client.provider === "openai" || client.provider === "anthropic");
+  if (client.service === "custom") {
+    if (
+      client.configSource === "studio"
+      && (client.provider === "openai" || client.provider === "anthropic")
+    ) {
+      return true;
+    }
+    return client.provider === "openai" && shouldUseNativeLocalOpenAICompatibleTransport(client);
+  }
+  return client.service === "ollama"
+    && client.provider === "openai"
+    && shouldUseNativeLocalOpenAICompatibleTransport(client);
+}
+
+function shouldUseNativeLocalOpenAICompatibleTransport(client: LLMClient): boolean {
+  return !client._apiKey
+    && isApiKeyOptionalForEndpoint({
+      provider: client.provider,
+      baseUrl: client._piModel?.baseUrl,
+    });
 }
 
 function buildCustomHeaders(client: LLMClient): Record<string, string> {
@@ -645,7 +664,6 @@ async function chatCompletionViaCustomAnthropicCompatible(
 ): Promise<LLMResponse> {
   const baseUrl = client._piModel?.baseUrl ?? "";
   const errorCtx = { baseUrl, model };
-  const monitor = createStreamMonitor(onStreamProgress);
   const extra = stripReservedKeys(resolved.extra);
   const payload: Record<string, unknown> = {
     model,
@@ -697,6 +715,7 @@ async function chatCompletionViaCustomAnthropicCompatible(
   let buffer = "";
   let content = "";
   let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
+  const monitor = createStreamMonitor(onStreamProgress);
 
   try {
     while (true) {
@@ -752,7 +771,6 @@ async function chatCompletionViaCustomOpenAICompatible(
   const baseUrl = client._piModel?.baseUrl ?? "";
   const headers = buildCustomHeaders(client);
   const errorCtx = { baseUrl, model };
-  const monitor = createStreamMonitor(onStreamProgress);
   const extra = stripReservedKeys(resolved.extra);
 
   if (client.apiFormat === "responses") {
@@ -799,6 +817,7 @@ async function chatCompletionViaCustomOpenAICompatible(
     let buffer = "";
     let content = "";
     let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
+    const monitor = createStreamMonitor(onStreamProgress);
 
     try {
       while (true) {
@@ -898,6 +917,7 @@ async function chatCompletionViaCustomOpenAICompatible(
   let content = "";
   let reasoningContent = "";
   let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
+  const monitor = createStreamMonitor(onStreamProgress);
 
   try {
     while (true) {

+ 2 - 2
packages/core/src/llm/providers/probe.ts

@@ -16,11 +16,11 @@ export async function probeModelsFromUpstream(
   apiKey: string,
   timeoutMs = 10_000,
 ): Promise<ReadonlyArray<ProbedModel>> {
-  if (!baseUrl || !apiKey) return [];
+  if (!baseUrl) return [];
   try {
     const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
     const res = await fetchWithProxy(modelsUrl, {
-      headers: { Authorization: `Bearer ${apiKey}` },
+      headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
       signal: AbortSignal.timeout(timeoutMs),
     });
     if (!res.ok) return [];

+ 5 - 2
packages/core/src/llm/service-presets.ts

@@ -1,5 +1,6 @@
 import { getEndpoint } from "./providers/index.js";
 import { probeModelsFromUpstream } from "./providers/probe.js";
+import { isApiKeyOptionalForEndpoint } from "../utils/llm-endpoint-auth.js";
 
 export interface ServicePreset {
   readonly providerFamily: "openai" | "anthropic";
@@ -168,8 +169,10 @@ export async function listModelsForService(
 
   // 1) 先试 live /models probe
   const probeBaseUrl = liveBaseUrl || provider?.modelsBaseUrl || provider?.baseUrl || resolveServiceModelsBaseUrl(service);
-  if (apiKey && probeBaseUrl) {
-    const probed = await probeModelsFromUpstream(probeBaseUrl, apiKey, 10_000);
+  const providerFamily = preset?.providerFamily ?? (provider?.api.startsWith("anthropic") ? "anthropic" : "openai");
+  const canProbeWithoutApiKey = isApiKeyOptionalForEndpoint({ provider: providerFamily, baseUrl: probeBaseUrl });
+  if ((apiKey || canProbeWithoutApiKey) && probeBaseUrl) {
+    const probed = await probeModelsFromUpstream(probeBaseUrl, apiKey ?? "", 10_000);
     if (probed.length > 0) {
       const { lookupModel } = await import("./providers/lookup.js");
       for (const m of probed) {

+ 12 - 10
packages/core/src/llm/service-resolver.ts

@@ -4,6 +4,7 @@ import { resolveServicePiProvider, resolveServicePreset } from "./service-preset
 import { getServiceApiKey } from "./secrets.js";
 import { getEndpoint } from "./providers/index.js";
 import type { InkosEndpoint } from "./providers/types.js";
+import { isApiKeyOptionalForEndpoint } from "../utils/llm-endpoint-auth.js";
 
 export interface ResolvedModel {
   model: Model<Api>;
@@ -31,19 +32,11 @@ export async function resolveServiceModel(
   customBaseUrl?: string,
   customApiFormat?: "chat" | "responses",
 ): Promise<ResolvedModel> {
-  // Resolve API key
-  const apiKey = await getServiceApiKey(projectRoot, service);
-  if (!apiKey) {
-    throw new Error(
-      `API key not found for service "${service}". Add it in .inkos/secrets.json or set the environment variable.`,
-    );
-  }
-
   // Determine pi-ai provider
   const baseService = service.startsWith("custom:") ? "custom" : service;
   const preset = resolveServicePreset(baseService);
   const endpoint = getEndpoint(baseService);
-  const piProvider = resolveServicePiProvider(baseService) ?? "openai";
+  const piProvider = baseService === "ollama" ? "ollama" : resolveServicePiProvider(baseService) ?? "openai";
   const apiType = service.startsWith("custom:")
     ? (customApiFormat === "responses" ? "openai-responses" : "openai-completions")
     : (preset?.api ?? "openai-completions");
@@ -62,6 +55,15 @@ export async function resolveServiceModel(
     );
   }
 
+  // Resolve API key after baseUrl/provider are known so local/self-hosted endpoints
+  // such as Ollama can be used without forcing a fake secret.
+  const apiKey = await getServiceApiKey(projectRoot, service);
+  if (!apiKey && !isApiKeyOptionalForEndpoint({ provider: preset?.providerFamily, baseUrl: effectiveBaseUrl })) {
+    throw new Error(
+      `API key not found for service "${service}". Add it in .inkos/secrets.json or set the environment variable.`,
+    );
+  }
+
   const model: Model<Api> = {
     id: modelId,
     name: piModel?.name ?? modelId,
@@ -78,7 +80,7 @@ export async function resolveServiceModel(
 
   return {
     model,
-    apiKey,
+    apiKey: apiKey ?? "",
     writingTemperature: preset?.writingTemperature,
     temperatureRange: preset?.temperatureRange,
     temperatureHint: preset?.temperatureHint,

+ 9 - 3
packages/core/src/pipeline/runner.ts

@@ -1471,7 +1471,7 @@ export class PipelineRunner {
   async writeNextChapter(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
     const releaseLock = await this.state.acquireBookLock(bookId);
     try {
-      return await this._writeNextChapterLocked(bookId, wordCount, temperatureOverride);
+      return await this._writeNextChapterLocked(bookId, wordCount, temperatureOverride, this.config.externalContext);
     } finally {
       await releaseLock();
     }
@@ -1495,7 +1495,12 @@ export class PipelineRunner {
     }
   }
 
-  private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
+  private async _writeNextChapterLocked(
+    bookId: string,
+    wordCount?: number,
+    temperatureOverride?: number,
+    externalContext?: string,
+  ): Promise<ChapterPipelineResult> {
     await this.state.ensureControlDocuments(bookId);
     const book = await this.state.loadBookConfig(bookId);
     const bookDir = this.state.bookDir(bookId);
@@ -1507,7 +1512,7 @@ export class PipelineRunner {
       book,
       bookDir,
       chapterNumber,
-      this.config.externalContext,
+      externalContext,
     );
     const reducedControlInput = writeInput.chapterIntent && writeInput.contextPackage && writeInput.ruleStack
       ? {
@@ -2737,6 +2742,7 @@ ${matrix}`,
     );
 
     return {
+      externalContext,
       chapterIntent: plan.intentMarkdown,
       chapterMemo: plan.memo,
       chapterIntentData: plan.intent,

+ 5 - 0
packages/core/src/utils/effective-llm-config.ts

@@ -458,11 +458,16 @@ function assertModelBelongsToService(entry: ServiceConfigEntry | undefined, mode
 }
 
 function modelBelongsToService(service: string, model: string): boolean {
+  if (serviceAllowsUnlistedModels(service)) return true;
   const endpoint = getEndpoint(service);
   if (!endpoint) return true;
   return endpoint.models.some((knownModel) => knownModel.id.toLowerCase() === model.toLowerCase());
 }
 
+function serviceAllowsUnlistedModels(service: string): boolean {
+  return service === "ollama";
+}
+
 function serviceEntryKey(entry: ServiceConfigEntry): string {
   return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
 }

+ 212 - 2
packages/studio/src/api/server.test.ts

@@ -47,6 +47,7 @@ const SERVICE_PRESETS_MOCK: Record<string, ServicePresetMock> = {
   minimax: { providerFamily: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", modelsBaseUrl: "https://api.minimaxi.com/anthropic", knownModels: [] as string[] },
   bailian: { providerFamily: "anthropic", baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", modelsBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", knownModels: [] as string[] },
   google: { providerFamily: "openai", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", modelsBaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", knownModels: [] as string[] },
+  ollama: { providerFamily: "openai", baseUrl: "http://localhost:11434/v1", modelsBaseUrl: "http://localhost:11434/v1", knownModels: [] as string[] },
   custom: { providerFamily: "openai", baseUrl: "", knownModels: [] as string[] },
 };
 const resolveServicePresetMock = vi.fn((service: string) => SERVICE_PRESETS_MOCK[service]);
@@ -62,9 +63,10 @@ const listModelsForServiceMock = vi.fn(async (service: string, apiKey?: string,
     return preset.knownModels.map((id) => ({ id, name: id, reasoning: false, contextWindow: 0 }));
   }
   const modelsBaseUrl = liveBaseUrl ?? resolveServiceModelsBaseUrlMock(service);
-  if (!apiKey || !modelsBaseUrl) return [];
+  const allowsNoKey = Boolean(modelsBaseUrl?.startsWith("http://localhost") || modelsBaseUrl?.startsWith("http://127.0.0.1"));
+  if ((!apiKey && !allowsNoKey) || !modelsBaseUrl) return [];
   const res = await fetch(`${modelsBaseUrl.replace(/\/$/, "")}/models`, {
-    headers: { Authorization: `Bearer ${apiKey}` },
+    headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
     signal: AbortSignal.timeout(10_000),
   });
   if (!res.ok) return [];
@@ -97,6 +99,7 @@ const endpointMocks = [
     group,
     ...(id === "google" ? { checkModel: "gemini-2.5-flash" } : {}),
     ...(id === "minimax" ? { checkModel: "MiniMax-M2.7" } : {}),
+    ...(id === "ollama" ? { checkModel: "llama3.2:3b" } : {}),
     models: [
       { id: `${id}-model`, maxOutput: 4096, contextWindowTokens: 32768, enabled: true },
       { id: `${id}-disabled`, maxOutput: 4096, contextWindowTokens: 32768, enabled: false },
@@ -224,6 +227,7 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
     resolveServiceProviderFamily: resolveServiceProviderFamilyMock,
     resolveServiceModelsBaseUrl: resolveServiceModelsBaseUrlMock,
     resolveServiceModel: resolveServiceModelMock,
+    isApiKeyOptionalForEndpoint: actual.isApiKeyOptionalForEndpoint,
     loadSecrets: loadSecretsMock,
     saveSecrets: saveSecretsMock,
     getServiceApiKey: getServiceApiKeyMock,
@@ -837,6 +841,29 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("returns Ollama live models without a saved API key", async () => {
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({ data: [{ id: "qwen3.6:35b-a3b" }] }),
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/ollama/models?refresh=1");
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toEqual({
+      models: [
+        { id: "qwen3.6:35b-a3b", name: "qwen3.6:35b-a3b" },
+      ],
+    });
+    expect(fetchMock).toHaveBeenCalledWith(
+      "http://localhost:11434/v1/models",
+      expect.objectContaining({ headers: {} }),
+    );
+  });
+
   it("merges service config patches instead of overwriting existing services", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,
@@ -1277,6 +1304,55 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(chatCompletionMock.mock.calls.map((call) => call[1])).not.toContain("MiniMax-M2.7");
   });
 
+  it("uses discovered Ollama models without requiring an API key or the built-in check model", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "ollama", apiFormat: "chat", stream: true },
+        ],
+        defaultModel: "llama3.2:3b",
+      },
+    }, null, 2), "utf-8");
+
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({ data: [{ id: "qwen3.6:35b-a3b" }] }),
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
+    createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
+    chatCompletionMock.mockImplementation(async (_client: any, model: string) => {
+      if (model === "qwen3.6:35b-a3b") {
+        return {
+          content: "pong",
+          usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
+        };
+      }
+      throw new Error(`unexpected model: ${model}`);
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/ollama/test", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        apiKey: "",
+        apiFormat: "chat",
+        stream: true,
+      }),
+    });
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      ok: true,
+      selectedModel: "qwen3.6:35b-a3b",
+      models: [{ id: "qwen3.6:35b-a3b", name: "qwen3.6:35b-a3b" }],
+    });
+    expect(chatCompletionMock.mock.calls.map((call) => call[1])).not.toContain("llama3.2:3b");
+  });
+
   it("does not fall back to the global default model when a bank endpoint probe fails", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,
@@ -1642,6 +1718,49 @@ describe("createStudioServer daemon lifecycle", () => {
     }));
   });
 
+  it("creates books with Studio Ollama config without requiring an API key", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        configSource: "studio",
+        service: "ollama",
+        provider: "openai",
+        baseUrl: "http://localhost:11434/v1",
+        model: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+        apiKey: "",
+        services: [{ service: "ollama", apiFormat: "chat", stream: false }],
+        defaultModel: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+        apiFormat: "chat",
+        stream: false,
+      },
+    }, null, 2), "utf-8");
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/books/create", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        title: "Local Book",
+        genre: "urban",
+        platform: "qidian",
+        language: "zh",
+      }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(loadProjectConfigMock).toHaveBeenCalledWith(root, { consumer: "studio" });
+    expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
+      service: "ollama",
+      model: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+      apiKey: "",
+    }));
+    expect(pipelineConfigs.at(-1)).toMatchObject({
+      model: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+    });
+  });
+
   it("passes one-off brief into revise requests through pipeline config", async () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
@@ -2097,6 +2216,97 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("lets the Studio agent creation path use explicit Ollama models without an API key", async () => {
+    const ollamaModel = {
+      id: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+      name: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+      api: "openai-completions",
+      provider: "ollama",
+      baseUrl: "http://localhost:11434/v1",
+      reasoning: false,
+      input: ["text"],
+      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+      contextWindow: 0,
+      maxTokens: 16384,
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        configSource: "studio",
+        service: "ollama",
+        provider: "openai",
+        baseUrl: "http://localhost:11434/v1",
+        model: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+        apiKey: "",
+        services: [
+          { service: "ollama", apiFormat: "chat", stream: false },
+        ],
+        defaultModel: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+        apiFormat: "chat",
+        stream: false,
+      },
+    }, null, 2), "utf-8");
+    loadBookSessionMock.mockResolvedValueOnce({
+      sessionId: "agent-session-1",
+      bookId: null,
+      title: null,
+      messages: [],
+      events: [],
+      draftRounds: [],
+      createdAt: 1,
+      updatedAt: 1,
+    });
+    createLLMClientMock.mockImplementation(((cfg: any) => ({
+      _piModel: {
+        ...ollamaModel,
+        id: cfg.model,
+        name: cfg.model,
+        provider: cfg.service === "ollama" ? "ollama" : "openai",
+        baseUrl: cfg.baseUrl || "http://localhost:11434/v1",
+      },
+      _apiKey: cfg.apiKey ?? "",
+    })) as any);
+    resolveServiceModelMock.mockResolvedValue({
+      model: ollamaModel,
+      apiKey: "",
+    });
+    runAgentSessionMock.mockResolvedValueOnce({
+      responseText: "收到。",
+      messages: [
+        { role: "user", content: "/create" },
+        { role: "assistant", content: "收到。" },
+      ],
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        instruction: "/create",
+        service: "ollama",
+        model: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+        sessionId: "agent-session-1",
+      }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
+      service: "ollama",
+      model: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+      apiKey: "",
+    }));
+    expect(pipelineConfigs.at(-1)).toMatchObject({
+      client: expect.objectContaining({ _apiKey: "" }),
+      model: "Qwen3.6-35B-A3B-APEX-I-Mini.gguf",
+    });
+    const agentConfig = runAgentSessionMock.mock.calls.at(-1)?.[0] as Record<string, unknown>;
+    expect(agentConfig.model).toBe(ollamaModel);
+    expect(agentConfig.apiKey).toBe("");
+  });
+
   it("rejects explicit non-text models before running the agent", async () => {
     resolveServiceModelMock.mockResolvedValue({
       model: { id: "gemini-3.1-flash-image-preview", provider: "google", api: "openai-completions" },

+ 24 - 12
packages/studio/src/api/server.ts

@@ -30,6 +30,7 @@ import {
   loadSecrets,
   saveSecrets,
   listModelsForService,
+  isApiKeyOptionalForEndpoint,
   getAllEndpoints,
   probeModelsFromUpstream,
   fetchWithProxy,
@@ -668,7 +669,8 @@ async function probeServiceCapabilities(args: {
     endpoint?.checkModel
     ?? preset?.knownModels?.[0]
     ?? endpoint?.models.find((model) => model.enabled !== false)?.id;
-  const useEndpointCheckModel = !isCustomServiceId(args.service) && Boolean(endpoint?.checkModel);
+  const useDynamicLocalModels = baseService === "ollama";
+  const useEndpointCheckModel = !useDynamicLocalModels && !isCustomServiceId(args.service) && Boolean(endpoint?.checkModel);
   const configService = typeof llm.service === "string" ? llm.service : undefined;
   const configModel = !useEndpointCheckModel && configService === args.service
     ? typeof llm.defaultModel === "string"
@@ -679,7 +681,7 @@ async function probeServiceCapabilities(args: {
     : undefined;
   const useCustomFallbacks = isCustomServiceId(args.service);
   const modelCandidates = buildModelCandidates({
-    preferredModel: args.preferredModel ?? serviceFirstModel,
+    preferredModel: args.preferredModel ?? (useDynamicLocalModels ? discoveredModels[0]?.id ?? serviceFirstModel : serviceFirstModel),
     configModel,
     envModel: useCustomFallbacks ? envModel : undefined,
     discoveredModels: useEndpointCheckModel ? [] : discoveredModels,
@@ -1317,21 +1319,26 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       stream?: boolean;
     }>();
 
-    if (!apiKey?.trim()) {
-      return c.json({ ok: false, error: "API Key 不能为空" }, 400);
-    }
-
     const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service, baseUrl);
     if (!resolvedBaseUrl) {
       return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
     }
 
+    const baseService = isCustomServiceId(service) ? "custom" : service;
+    const apiKeyOptional = isApiKeyOptionalForEndpoint({
+      provider: resolveServiceProviderFamily(baseService) ?? "openai",
+      baseUrl: resolvedBaseUrl,
+    });
+    if (!apiKey?.trim() && !apiKeyOptional) {
+      return c.json({ ok: false, error: "API Key 不能为空" }, 400);
+    }
+
     const rawConfig = await loadRawConfig(root).catch(() => ({} as Record<string, unknown>));
     const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
     const probe = await probeServiceCapabilities({
       root,
       service,
-      apiKey: apiKey.trim(),
+      apiKey: apiKey?.trim() ?? "",
       baseUrl: resolvedBaseUrl,
       preferredApiFormat: apiFormat,
       preferredStream: stream,
@@ -1449,10 +1456,15 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     const secrets = await loadSecrets(root);
     const apiKey = c.req.query("apiKey") || secrets.services[service]?.apiKey || "";
 
-    // No key = no models
-    if (!apiKey) return c.json({ models: [] });
-
     const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
+    const baseService = isCustomServiceId(service) ? "custom" : service;
+    const apiKeyOptional = isApiKeyOptionalForEndpoint({
+      provider: resolveServiceProviderFamily(baseService) ?? "openai",
+      baseUrl: resolvedBaseUrl,
+    });
+
+    // No key = no models, except local/self-hosted endpoints such as Ollama.
+    if (!apiKey && !apiKeyOptional) return c.json({ models: [] });
 
     // Cache by service + resolved baseUrl + apiKey fingerprint; valid for 10 min unless ?refresh=1
     const cacheKey = `${service}::${resolvedBaseUrl ?? ""}::${apiKey.slice(-8)}`;
@@ -1878,12 +1890,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       // Create pipeline with resolved model (so sub_agent tools use the frontend-selected model)
       // Don't spread config.llm — its baseUrl/provider belong to the old service.
       // Let createLLMClient resolve baseUrl from the service preset.
-      const pipelineClient = (reqService && reqModel && resolvedApiKey)
+      const pipelineClient = (reqService && reqModel && resolvedModel)
         ? createLLMClient({
             ...config.llm,
             service: configuredEntry?.service ?? reqService,
             model: reqModel,
-            apiKey: resolvedApiKey,
+            apiKey: resolvedApiKey ?? "",
             ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
             ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
             baseUrl: configuredEntry?.baseUrl ?? "",