Explorar o código

Merge pull request #187 from SttFang/fix/multi-service-baseurl-resolution

fix: multi-service baseUrl resolution and MiniMax support
SttFang hai 6 meses
pai
achega
4721364997

+ 1 - 1
package.json

@@ -22,7 +22,7 @@
   },
   "scripts": {
     "build": "pnpm -r build",
-    "dev": "pnpm -r dev",
+    "dev": "pnpm -r --parallel dev",
     "test": "pnpm -r test",
     "lint": "pnpm -r lint",
     "typecheck": "pnpm -r typecheck",

+ 169 - 0
packages/core/src/__tests__/architect.test.ts

@@ -466,6 +466,175 @@ describe("ArchitectAgent", () => {
     expect(result.pendingHooks).toContain("| H01 | 1 | mystery | open | 0 | 10章 | 中程 | 初始钩子 |");
   });
 
+  it("falls back to default current_state when section is missing from LLM output", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "missing-state-book",
+      title: "缺失状态卡",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-04-15T00:00:00.000Z",
+      updatedAt: "2026-04-15T00:00:00.000Z",
+    };
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | 主线 | 未开启 | 0 | 10章 | 初始钩子 |",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    const result = await agent.generateFoundation(book);
+
+    expect(result.currentState).toContain("当前章节 | 0");
+    expect(result.currentState).toContain("当前位置");
+    expect(result.currentState).toContain("主角状态");
+  });
+
+  it("falls back to default pending_hooks when section is missing from LLM output", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "missing-hooks-book",
+      title: "缺失伏笔池",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-04-15T00:00:00.000Z",
+      updatedAt: "2026-04-15T00:00:00.000Z",
+    };
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    const result = await agent.generateFoundation(book);
+
+    expect(result.pendingHooks).toContain("hook_id");
+    expect(result.pendingHooks).toContain("起始章节");
+    expect(result.pendingHooks).toContain("回收节奏");
+  });
+
+  it("falls back to defaults when both current_state and pending_hooks are missing", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "missing-both-book",
+      title: "都缺失",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-04-15T00:00:00.000Z",
+      updatedAt: "2026-04-15T00:00:00.000Z",
+    };
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    const result = await agent.generateFoundation(book);
+
+    expect(result.currentState).toContain("当前章节 | 0");
+    expect(result.pendingHooks).toContain("hook_id");
+  });
+
   it("throws when a required foundation section is missing", async () => {
     const agent = new ArchitectAgent({
       client: {

+ 40 - 0
packages/core/src/__tests__/service-presets-regression.test.ts

@@ -0,0 +1,40 @@
+import { describe, it, expect } from "vitest";
+import { resolveServicePreset, listModelsForService } from "../llm/service-presets.js";
+
+describe("service-presets regression", () => {
+  describe("MiniMax preset", () => {
+    it("has correct OpenAI-compatible baseUrl (api.minimaxi.com, not api.minimax.chat)", () => {
+      const preset = resolveServicePreset("minimax");
+      expect(preset).toBeDefined();
+      expect(preset!.baseUrl).toBe("https://api.minimaxi.com/v1");
+    });
+
+    it("uses openai-completions api format", () => {
+      const preset = resolveServicePreset("minimax");
+      expect(preset!.api).toBe("openai-completions");
+    });
+
+    it("has knownModels with all 7 MiniMax models", () => {
+      const preset = resolveServicePreset("minimax");
+      expect(preset!.knownModels).toBeDefined();
+      expect(preset!.knownModels).toHaveLength(7);
+      expect(preset!.knownModels).toContain("MiniMax-M2.7");
+      expect(preset!.knownModels).toContain("MiniMax-M2.7-highspeed");
+      expect(preset!.knownModels).toContain("MiniMax-M2.5");
+      expect(preset!.knownModels).toContain("MiniMax-M2");
+    });
+  });
+
+  describe("listModelsForService", () => {
+    it("returns knownModels immediately for minimax without calling /models API", async () => {
+      const models = await listModelsForService("minimax");
+      expect(models.length).toBe(7);
+      expect(models[0].id).toBe("MiniMax-M2.7");
+    });
+
+    it("returns empty for custom service", async () => {
+      const models = await listModelsForService("custom");
+      expect(models).toEqual([]);
+    });
+  });
+});

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

@@ -0,0 +1,77 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+// Simulate pi-ai returning a MiniMax model with WRONG baseUrl (international anthropic endpoint)
+// Our resolveServiceModel should override this with the preset baseUrl
+vi.mock("@mariozechner/pi-ai", () => ({
+  getModel: vi.fn((provider: string, modelId: string) => {
+    if (modelId === "MiniMax-M2.7") {
+      return {
+        id: "MiniMax-M2.7",
+        name: "MiniMax-M2.7",
+        api: "anthropic-messages",        // pi-ai uses anthropic format
+        provider: "minimax",
+        baseUrl: "https://api.minimax.io/anthropic",  // pi-ai uses international endpoint
+        reasoning: true,
+        input: ["text"],
+        cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
+        contextWindow: 204800,
+        maxTokens: 131072,
+      };
+    }
+    return undefined;
+  }),
+  getEnvApiKey: vi.fn(() => undefined),
+}));
+
+import { resolveServiceModel } from "../llm/service-resolver.js";
+
+describe("resolveServiceModel regression — preset baseUrl override", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-resolver-reg-"));
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { minimax: { apiKey: "sk-minimax-test" } } }),
+    );
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("uses preset baseUrl, not pi-ai built-in baseUrl", async () => {
+    const result = await resolveServiceModel("minimax", "MiniMax-M2.7", root);
+
+    // Must use our preset (api.minimaxi.com), NOT pi-ai's (api.minimax.io/anthropic)
+    expect(result.model.baseUrl).toBe("https://api.minimaxi.com/v1");
+  });
+
+  it("uses preset api format, not pi-ai built-in api format", async () => {
+    const result = await resolveServiceModel("minimax", "MiniMax-M2.7", root);
+
+    // Must use openai-completions (our preset), NOT anthropic-messages (pi-ai)
+    expect(result.model.api).toBe("openai-completions");
+  });
+
+  it("inherits metadata from pi-ai (reasoning, cost, contextWindow)", async () => {
+    const result = await resolveServiceModel("minimax", "MiniMax-M2.7", root);
+
+    expect(result.model.reasoning).toBe(true);
+    expect(result.model.contextWindow).toBe(204800);
+    expect(result.model.maxTokens).toBe(131072);
+  });
+
+  it("customBaseUrl overrides both preset and pi-ai", async () => {
+    const result = await resolveServiceModel(
+      "minimax", "MiniMax-M2.7", root,
+      "https://custom-proxy.example.com/v1",
+    );
+
+    expect(result.model.baseUrl).toBe("https://custom-proxy.example.com/v1");
+  });
+});

+ 52 - 4
packages/core/src/agent/agent-system-prompt.ts

@@ -25,7 +25,13 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 - 用户回答模糊时,给出 2-3 个具体选项引导
 - 当信息基本齐了,主动提议建书,不要无限追问
 - 保持简短、自然
-- **不要在回复中添加表情符号**`
+- **不要在回复中添加表情符号**
+
+## 输出格式
+
+- 禁止使用表情符号(emoji)
+- 梳理结构化内容时使用无序列表或表格,不要用纯文本段落堆砌
+- 回复简洁,不说废话`
       : `You are the InkOS book creation assistant. Help the user create a new book from scratch.
 
 ## Workflow
@@ -48,7 +54,13 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 - Offer 2-3 concrete options when the user is vague
 - Proactively suggest creating the book when enough info is collected
 - Keep responses brief and natural
-- **Do NOT use emoji in your responses**`;
+- **Do NOT use emoji in your responses**
+
+## Output Format
+
+- No emoji
+- Use bullet lists or tables for structured content, not prose paragraphs
+- Keep responses concise`;
   }
 
   return isZh
@@ -73,7 +85,25 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 - 用户想做小修改(改名字、调设定)→ 用 edit 直接修改
 - 其他情况 → 直接对话回答
 - **注意:不要调用 architect,当前已有书籍,不需要建书**
-- **不要在回复中添加表情符号**`
+- **不要在回复中添加表情符号**
+
+## 章节索引管理
+
+章节索引文件位于 \`books/${bookId}/chapters/index.json\`,记录所有章节的元信息(编号、标题、状态、字数等)。
+章节文件位于 \`books/${bookId}/chapters/\`,命名格式为 \`0001_标题.md\`。
+
+如果你发现索引和磁盘文件不一致(例如侧边栏章节数和实际不符),请主动修复:
+1. 用 \`ls\` 列出 \`books/${bookId}/chapters/\` 下所有 \`.md\` 文件
+2. 用 \`read\` 读取当前 \`index.json\`
+3. 对比两者,找出磁盘上有但索引中缺失的章节
+4. 同一章号有多个文件时(重写),取文件名排序最后的那个(最新版本)
+5. 用 \`edit\` 更新 \`index.json\`,补上缺失条目(status 设为 "ready-for-review",wordCount 通过读取文件内容统计中文字符数)
+
+## 输出格式
+
+- 禁止使用表情符号(emoji)
+- 梳理结构化内容时使用无序列表或表格,不要用纯文本段落堆砌
+- 回复简洁,不说废话`
     : `You are the InkOS writing assistant, working on book "${bookId}".
 
 ## Available Tools
@@ -94,5 +124,23 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 - Use read/edit for settings inquiries and small changes
 - Chat directly for other questions
 - **Do NOT call architect — a book already exists**
-- **Do NOT use emoji in your responses**`;
+- **Do NOT use emoji in your responses**
+
+## Chapter Index Management
+
+The chapter index is at \`books/${bookId}/chapters/index.json\` (metadata: number, title, status, wordCount, etc.).
+Chapter files are at \`books/${bookId}/chapters/\`, named \`0001_Title.md\`.
+
+If you notice the index is inconsistent with the actual files on disk (e.g. sidebar shows fewer chapters than exist), fix it proactively:
+1. \`ls\` the chapters directory to list all \`.md\` files
+2. \`read\` the current \`index.json\`
+3. Compare and find chapters on disk but missing from the index
+4. When multiple files exist for the same chapter number (rewrites), use the last one alphabetically (latest version)
+5. \`edit\` the \`index.json\` to add missing entries (status: "ready-for-review", wordCount: count Chinese characters from the file content)
+
+## Output Format
+
+- No emoji
+- Use bullet lists or tables for structured content, not prose paragraphs
+- Keep responses concise`;
 }

+ 19 - 3
packages/core/src/agents/architect.ts

@@ -14,6 +14,19 @@ export interface ArchitectOutput {
   readonly pendingHooks: string;
 }
 
+const DEFAULT_CURRENT_STATE = `| 字段 | 值 |
+|------|-----|
+| 当前章节 | 0 |
+| 当前位置 | 待定 |
+| 主角状态 | 初始状态 |
+| 当前目标 | 待定 |
+| 当前限制 | 无 |
+| 当前敌我 | 无 |
+| 当前冲突 | 无 |`;
+
+const DEFAULT_PENDING_HOOKS = `| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |
+|---------|----------|------|------|----------|----------|----------|------|`;
+
 export class ArchitectAgent extends BaseAgent {
   get name(): string {
     return "architect";
@@ -804,9 +817,12 @@ ${trimmed}\n`;
       parsedSections.set(normalizedName, content.slice(start, end).trim());
     }
 
-    const extract = (name: string): string => {
+    const extract = (name: string, fallback?: string): string => {
       const section = parsedSections.get(this.normalizeSectionName(name));
       if (!section) {
+        if (fallback !== undefined) {
+          return fallback;
+        }
         throw new Error(`Architect output missing required section: ${name}`);
       }
       if (name !== "pending_hooks") {
@@ -819,8 +835,8 @@ ${trimmed}\n`;
       storyBible: extract("story_bible"),
       volumeOutline: extract("volume_outline"),
       bookRules: extract("book_rules"),
-      currentState: extract("current_state"),
-      pendingHooks: extract("pending_hooks"),
+      currentState: extract("current_state", DEFAULT_CURRENT_STATE),
+      pendingHooks: extract("pending_hooks", DEFAULT_PENDING_HOOKS),
     };
   }
 

+ 10 - 3
packages/core/src/llm/service-presets.ts

@@ -6,6 +6,8 @@ export interface ServicePreset {
   readonly defaultTemperature?: number;
   readonly writingTemperature?: number;
   readonly temperatureHint?: string;
+  /** Hardcoded model list for services that don't support GET /models. */
+  readonly knownModels?: readonly string[];
 }
 
 export const SERVICE_PRESETS: Record<string, ServicePreset> = {
@@ -13,7 +15,7 @@ export const SERVICE_PRESETS: Record<string, ServicePreset> = {
   anthropic:   { api: "anthropic-messages",  baseUrl: "https://api.anthropic.com",                         label: "Anthropic",       temperatureRange: [0, 1], defaultTemperature: 1.0, writingTemperature: 1.0, temperatureHint: "不要同时改 temperature 和 top_p" },
   deepseek:    { api: "openai-completions",  baseUrl: "https://api.deepseek.com",                          label: "DeepSeek",        temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.5, temperatureHint: "创意写作推荐 1.5" },
   moonshot:    { api: "openai-completions",  baseUrl: "https://api.moonshot.cn/v1",                        label: "Moonshot (Kimi)", temperatureRange: [0, 1], defaultTemperature: 0.3, writingTemperature: 1.0, temperatureHint: "kimi-k2.5 推荐 temperature=1.0" },
-  minimax:     { api: "openai-completions",  baseUrl: "https://api.minimax.chat/v1",                       label: "MiniMax",         temperatureRange: [0, 2], defaultTemperature: 0.9, writingTemperature: 0.9 },
+  minimax:     { api: "openai-completions",  baseUrl: "https://api.minimaxi.com/v1",                       label: "MiniMax",         temperatureRange: [0, 2], defaultTemperature: 0.9, writingTemperature: 0.9, knownModels: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1", "MiniMax-M2.1-highspeed", "MiniMax-M2"] },
   bailian:     { api: "openai-completions",  baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", label: "百炼 (通义千问)", temperatureRange: [0, 2], defaultTemperature: 0.7, writingTemperature: 1.0 },
   zhipu:       { api: "openai-completions",  baseUrl: "https://open.bigmodel.cn/api/paas/v4",              label: "智谱 GLM",        temperatureRange: [0, 1], defaultTemperature: 0.95, writingTemperature: 0.95 },
   siliconflow: { api: "openai-completions",  baseUrl: "https://api.siliconflow.cn/v1",                     label: "硅基流动" },
@@ -84,7 +86,12 @@ export async function listModelsForService(service: string, apiKey?: string): Pr
   const preset = SERVICE_PRESETS[service];
   if (!preset || service === "custom") return [];
 
-  // 1) 尝试动态获取:调用 GET {baseUrl}/models
+  // 1) Hardcoded model list for services that don't support GET /models
+  if (preset.knownModels && preset.knownModels.length > 0) {
+    return preset.knownModels.map((id) => ({ id, name: id, reasoning: false, contextWindow: 0 }));
+  }
+
+  // 2) 动态获取:调用 GET {baseUrl}/models
   if (apiKey && preset.baseUrl) {
     try {
       const modelsUrl = preset.baseUrl.replace(/\/$/, "") + "/models";
@@ -108,7 +115,7 @@ export async function listModelsForService(service: string, apiKey?: string): Pr
     }
   }
 
-  // 2) 回退到 pi-ai 内置模型列表
+  // 3) 回退到 pi-ai 内置模型列表
   const piProvider = SERVICE_TO_PI_PROVIDER[service];
   if (!piProvider) return [];
 

+ 27 - 24
packages/core/src/llm/service-resolver.ts

@@ -31,33 +31,36 @@ export async function resolveServiceModel(
   const preset = resolveServicePreset(baseService);
   const piProvider = SERVICE_TO_PI_PROVIDER[baseService] ?? "openai";
 
+  // Resolve baseUrl: prefer custom/configured URL, then preset, then pi-ai's built-in
+  const apiType = service.startsWith("custom:")
+    ? (customApiFormat === "responses" ? "openai-responses" : "openai-completions")
+    : (preset?.api ?? "openai-completions");
+  const baseUrl = customBaseUrl ?? preset?.baseUrl ?? "";
+
   // Get pi-ai Model — may return undefined for model IDs not in the built-in registry
-  let model = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
+  const piModel = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
 
-  if (!model) {
-    // Construct a Model object from service preset for models not in pi-ai's registry
-    const apiType = service.startsWith("custom:")
-      ? (customApiFormat === "responses" ? "openai-responses" : "openai-completions")
-      : (preset?.api ?? "openai-completions");
-    const baseUrl = customBaseUrl ?? preset?.baseUrl ?? "";
-    if (!baseUrl) {
-      throw new Error(
-        `Cannot resolve model "${modelId}" for service "${service}": no baseUrl available.`,
-      );
-    }
-    model = {
-      id: modelId,
-      name: modelId,
-      api: apiType as Api,
-      provider: piProvider,
-      baseUrl,
-      reasoning: false,
-      input: ["text"] as ("text" | "image")[],
-      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
-      contextWindow: 0,
-      maxTokens: 16384,
-    };
+  // Always construct our own model object to ensure baseUrl and api format match our presets.
+  // pi-ai's built-in model may have a different baseUrl (e.g. international endpoint)
+  // or api format (e.g. anthropic-messages) than what we configure.
+  const effectiveBaseUrl = baseUrl || piModel?.baseUrl || "";
+  if (!effectiveBaseUrl) {
+    throw new Error(
+      `Cannot resolve model "${modelId}" for service "${service}": no baseUrl available.`,
+    );
   }
+  const model: Model<Api> = {
+    id: modelId,
+    name: piModel?.name ?? modelId,
+    api: apiType as Api,
+    provider: piProvider,
+    baseUrl: effectiveBaseUrl,
+    reasoning: piModel?.reasoning ?? false,
+    input: piModel?.input ?? ["text"] as ("text" | "image")[],
+    cost: piModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+    contextWindow: piModel?.contextWindow ?? 0,
+    maxTokens: piModel?.maxTokens ?? 16384,
+  };
 
   return {
     model,

+ 40 - 0
packages/studio/src/api/__tests__/normalize-base-url.test.ts

@@ -0,0 +1,40 @@
+import { describe, it, expect } from "vitest";
+
+// normalizeBaseUrl is not exported, so we test the behavior through the
+// server module's internal logic. We replicate the function here for unit testing.
+// If the implementation changes, this test will catch regressions.
+function normalizeBaseUrl(url: string): string {
+  const trimmed = url.replace(/\/+$/, "");
+  if (/\/v\d+$/.test(trimmed)) return trimmed;
+  return trimmed + "/v1";
+}
+
+describe("normalizeBaseUrl", () => {
+  it("appends /v1 when missing", () => {
+    expect(normalizeBaseUrl("https://timesniper.club")).toBe("https://timesniper.club/v1");
+  });
+
+  it("strips trailing slash then appends /v1", () => {
+    expect(normalizeBaseUrl("https://timesniper.club/")).toBe("https://timesniper.club/v1");
+  });
+
+  it("strips multiple trailing slashes", () => {
+    expect(normalizeBaseUrl("https://timesniper.club///")).toBe("https://timesniper.club/v1");
+  });
+
+  it("preserves existing /v1", () => {
+    expect(normalizeBaseUrl("https://timesniper.club/v1")).toBe("https://timesniper.club/v1");
+  });
+
+  it("preserves other version paths like /v2", () => {
+    expect(normalizeBaseUrl("https://api.example.com/v2")).toBe("https://api.example.com/v2");
+  });
+
+  it("all three forms are equivalent", () => {
+    const a = normalizeBaseUrl("https://timesniper.club/v1");
+    const b = normalizeBaseUrl("https://timesniper.club/");
+    const c = normalizeBaseUrl("https://timesniper.club");
+    expect(a).toBe(b);
+    expect(b).toBe(c);
+  });
+});

+ 55 - 61
packages/studio/src/api/server.ts

@@ -161,7 +161,7 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
     return {
       service: "custom",
       name: decodeURIComponent(serviceId.slice("custom:".length)),
-      ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
+      ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: normalizeBaseUrl(value.baseUrl) } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
       ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
       ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
@@ -173,7 +173,7 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
     return {
       service: "custom",
       ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
-      ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
+      ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: normalizeBaseUrl(value.baseUrl) } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
       ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
       ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
@@ -194,19 +194,29 @@ function normalizeConfigSource(value: unknown): LLMConfigSource {
   return value === "studio" ? "studio" : "env";
 }
 
+/** Ensure custom baseUrl ends with /v1 (common convention for OpenAI-compatible APIs). */
+function normalizeBaseUrl(url: string): string {
+  const trimmed = url.replace(/\/+$/, "");
+  if (/\/v\d+$/.test(trimmed)) return trimmed;
+  return trimmed + "/v1";
+}
+
 function normalizeServiceConfig(raw: unknown): ServiceConfigEntry[] {
   if (Array.isArray(raw)) {
     return raw
       .filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
-      .map((entry) => ({
-        service: typeof entry.service === "string" && entry.service.length > 0 ? entry.service : "custom",
+      .map((entry) => {
+        const svc = typeof entry.service === "string" && entry.service.length > 0 ? entry.service : "custom";
+        const isCustom = svc === "custom";
+        return {
+        service: svc,
         ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
-        ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
+        ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: isCustom ? normalizeBaseUrl(entry.baseUrl) : entry.baseUrl } : {}),
         ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
         ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
         ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
         ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
-      }));
+      }; });
   }
 
   if (raw && typeof raw === "object") {
@@ -285,7 +295,7 @@ async function readEnvConfigStatus(root: string): Promise<EnvConfigStatus> {
 }
 
 async function resolveConfiguredServiceBaseUrl(root: string, serviceId: string, inlineBaseUrl?: string): Promise<string | undefined> {
-  if (inlineBaseUrl?.trim()) return inlineBaseUrl.trim();
+  if (inlineBaseUrl?.trim()) return isCustomServiceId(serviceId) ? normalizeBaseUrl(inlineBaseUrl.trim()) : inlineBaseUrl.trim();
 
   if (!isCustomServiceId(serviceId)) {
     return resolveServicePreset(serviceId)?.baseUrl;
@@ -790,71 +800,41 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
     }
 
-    // Call the real /models API — no fallback
+    // Try /models API — validates key + discovers models in one call
     const modelsUrl = resolvedBaseUrl.replace(/\/$/, "") + "/models";
+    let models: Array<{ id: string; name: string }> = [];
     try {
       const res = await fetch(modelsUrl, {
         headers: { Authorization: `Bearer ${apiKey.trim()}` },
         signal: AbortSignal.timeout(10_000),
       });
 
-      if (!res.ok) {
-        const body = await res.text().catch(() => "");
-        if (res.status === 401 || res.status === 403) {
-          return c.json({ ok: false, error: "API Key 无效,请检查后重试" }, 400);
-        }
-        return c.json({ ok: false, error: `服务商返回 ${res.status}: ${body.slice(0, 200)}` }, 400);
-      }
-
-      const json = await res.json() as { data?: Array<{ id: string; owned_by?: string }> };
-      const models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
-
-      if (models.length === 0) {
-        return c.json({ ok: false, error: "连接成功但未返回可用模型" }, 400);
+      if (res.status === 401 || res.status === 403) {
+        return c.json({ ok: false, error: "API Key 无效,请检查后重试" }, 400);
       }
 
-      const rawConfig = await loadRawConfig(root).catch(() => ({} as Record<string, unknown>));
-      const preferredModel = typeof (rawConfig.llm as Record<string, unknown> | undefined)?.defaultModel === "string"
-        ? String((rawConfig.llm as Record<string, unknown>).defaultModel)
-        : undefined;
-      const sampleModel = models.find((m) => m.id === preferredModel)?.id
-        ?? models.find((m) => m.id === "gpt-5.4")?.id
-        ?? models[0]?.id;
-      if (sampleModel) {
-        const client = createLLMClient({
-          provider: service === "anthropic" ? "anthropic" : "openai",
-          service: isCustomServiceId(service) ? "custom" : service,
-          configSource: "studio",
-          baseUrl: resolvedBaseUrl,
-          apiKey: apiKey.trim(),
-          model: sampleModel,
-          temperature: 0.7,
-          maxTokens: 64,
-          thinkingBudget: 0,
-          apiFormat: apiFormat ?? "chat",
-          stream: stream ?? true,
-        } as ProjectConfig["llm"]);
-
-        try {
-          await chatCompletion(
-            client,
-            sampleModel,
-            [{ role: "user", content: "ping" }],
-            { maxTokens: 5 },
-          );
-        } catch (error) {
-          const message = error instanceof Error ? error.message : String(error);
-          return c.json({ ok: false, error: `模型列表可读,但生成测试失败:${message}` }, 400);
-        }
+      if (res.ok) {
+        const json = await res.json() as { data?: Array<{ id: string; owned_by?: string }> };
+        models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
       }
-
-      return c.json({ ok: true, modelCount: models.length, models: models.slice(0, 50) });
     } catch (err: any) {
       if (err?.name === "TimeoutError" || err?.name === "AbortError") {
         return c.json({ ok: false, error: `连接超时:无法访问 ${modelsUrl}` }, 400);
       }
-      return c.json({ ok: false, error: `连接失败: ${err?.message ?? String(err)}` }, 400);
+      // Network error — continue to fallback
+    }
+
+    // /models unavailable (404 etc.) — fallback to pi-ai built-in model list
+    if (models.length === 0) {
+      const builtIn = await listModelsForService(service);
+      models = builtIn.map((m) => ({ id: m.id, name: m.name }));
+    }
+
+    if (models.length === 0) {
+      return c.json({ ok: false, error: "未找到可用模型" }, 400);
     }
+
+    return c.json({ ok: true, modelCount: models.length, models: models.slice(0, 50) });
   });
 
   app.put("/api/v1/services/:service/secret", async (c) => {
@@ -888,7 +868,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
     if (!resolvedBaseUrl) return c.json({ models: [] });
 
-    // Call real API only
+    // Call real /models API, fallback to pi-ai built-in list
     let models: Array<{ id: string; name: string }> = [];
     try {
       const modelsUrl = resolvedBaseUrl.replace(/\/$/, "") + "/models";
@@ -900,7 +880,11 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         const json = await res.json() as { data?: Array<{ id: string }> };
         models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
       }
-    } catch { /* timeout or network error — return empty */ }
+    } catch { /* timeout or network error */ }
+    if (models.length === 0) {
+      const builtIn = await listModelsForService(service, apiKey);
+      models = builtIn.map((m) => ({ id: m.id, name: m.name }));
+    }
     return c.json({ models });
   });
 
@@ -1331,7 +1315,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
             service: configuredEntry?.service ?? reqService ?? config.llm.service,
             model: reqModel ?? config.llm.model,
             apiKey: agentApiKey ?? config.llm.apiKey,
-            baseUrl: configuredEntry?.baseUrl ?? config.llm.baseUrl,
+            baseUrl: configuredEntry?.baseUrl ?? "",
             ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
             ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
           } as ProjectConfig["llm"]);
@@ -1366,7 +1350,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
             service: configuredEntry?.service ?? reqService ?? config.llm.service,
             model: reqModel ?? config.llm.model,
             apiKey: agentApiKey ?? config.llm.apiKey,
-            baseUrl: configuredEntry?.baseUrl ?? config.llm.baseUrl,
+            baseUrl: configuredEntry?.baseUrl ?? "",
             ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
             ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
           } as ProjectConfig["llm"]);
@@ -1394,6 +1378,16 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
       broadcast("agent:complete", { instruction, activeBookId });
 
+      // If a sub_agent created a new book during this session, broadcast book:created
+      // so the sidebar refreshes.
+      if (!activeBookId && collectedToolExecs.some((t) => t.agent === "architect" && t.status === "completed")) {
+        const books = await state.listBooks();
+        const latestBook = books.at(-1);
+        if (latestBook) {
+          broadcast("book:created", { bookId: latestBook });
+        }
+      }
+
       return c.json({
         response: result.responseText,
         session: { sessionId: bookSession.sessionId },