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

fix(core,cli): restore provider compatibility and windows studio launch

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

+ 25 - 0
packages/cli/src/__tests__/doctor.test.ts

@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { buildDoctorModelCandidates } from "../commands/doctor.js";
+
+describe("doctor model candidate probing", () => {
+  it("keeps the configured model first, then tries discovered models without duplicates", () => {
+    const candidates = buildDoctorModelCandidates("google/gemma-4-26b-4b", [
+      { id: "google/gemma-4-26b-4b", name: "google/gemma-4-26b-4b" },
+      { id: "google/gemma-4-27b-it", name: "google/gemma-4-27b-it" },
+      { id: "gemini-2.5-flash", name: "gemini-2.5-flash" },
+    ]);
+
+    expect(candidates[0]).toBe("google/gemma-4-26b-4b");
+    expect(candidates[1]).toBe("google/gemma-4-27b-it");
+    expect(candidates[2]).toBe("gemini-2.5-flash");
+    expect(new Set(candidates).size).toBe(candidates.length);
+  });
+
+  it("falls back to common compatibility candidates when /models is unavailable", () => {
+    const candidates = buildDoctorModelCandidates("missing-model", []);
+    expect(candidates[0]).toBe("missing-model");
+    expect(candidates).toContain("gpt-5.4");
+    expect(candidates).toContain("MiniMax-M2.7");
+    expect(candidates).toContain("gemini-2.5-flash");
+  });
+});

+ 6 - 0
packages/cli/src/__tests__/studio-runtime.test.ts

@@ -56,6 +56,12 @@ describe("studio runtime resolution", () => {
     });
   });
 
+  it("uses a file URL for the tsx loader on Windows absolute paths", async () => {
+    const { toNodeImportSpecifier } = await import("../commands/studio.js");
+    expect(toNodeImportSpecifier("D:\\repo\\packages\\studio\\node_modules\\tsx\\dist\\loader.mjs"))
+      .toBe("file:///D:/repo/packages/studio/node_modules/tsx/dist/loader.mjs");
+  });
+
   it("finds monorepo packages/studio sources from a project directory", async () => {
     accessMock.mockImplementation(async (path: string) => {
       if (path === tsSourceEntry) {

+ 69 - 14
packages/cli/src/commands/doctor.ts

@@ -35,6 +35,49 @@ function buildDoctorProbePlans(
   return plans;
 }
 
+export function buildDoctorModelCandidates(
+  preferredModel: string | undefined,
+  discoveredModels: Array<{ id: string; name: string }>,
+): string[] {
+  const seen = new Set<string>();
+  const candidates: string[] = [];
+  const push = (value: string | undefined | null) => {
+    if (!value || value.trim().length === 0) return;
+    const model = value.trim();
+    if (seen.has(model)) return;
+    seen.add(model);
+    candidates.push(model);
+  };
+
+  push(preferredModel);
+  for (const model of discoveredModels) push(model.id);
+  push("gpt-5.4");
+  push("gpt-4o");
+  push("claude-sonnet-4-6");
+  push("MiniMax-M2.7");
+  push("kimi-k2.5");
+  push("gemini-2.5-flash");
+  return candidates;
+}
+
+async function fetchDoctorModels(
+  baseUrl: string,
+  apiKey: string,
+): Promise<Array<{ id: string; name: string }>> {
+  const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
+  try {
+    const res = await fetch(modelsUrl, {
+      headers: { Authorization: `Bearer ${apiKey}` },
+      signal: AbortSignal.timeout(10_000),
+    });
+    if (!res.ok) return [];
+    const json = await res.json() as { data?: Array<{ id: string }> };
+    return (json.data ?? []).map((model) => ({ id: model.id, name: model.id }));
+  } catch {
+    return [];
+  }
+}
+
 export const doctorCommand = new Command("doctor")
   .description("Check environment and project health")
   .option("--repair-node-runtime", "Write .nvmrc and .node-version pinned to Node 22 for this project")
@@ -220,26 +263,38 @@ export const doctorCommand = new Command("doctor")
         let connected = false;
         let detectedDetail = "";
         let lastError = "Unknown error";
+        const discoveredModels = (llmConfig.provider === "openai" && llmConfig.apiKey && llmConfig.baseUrl)
+          ? await fetchDoctorModels(llmConfig.baseUrl, llmConfig.apiKey)
+          : [];
+        const modelCandidates = llmConfig.provider === "openai"
+          ? buildDoctorModelCandidates(llmConfig.model, discoveredModels)
+          : [llmConfig.model];
         const plans = llmConfig.provider === "openai"
           ? buildDoctorProbePlans(llmConfig.apiFormat, llmConfig.stream)
           : [{ apiFormat: (llmConfig.apiFormat ?? "chat") as "chat" | "responses", stream: llmConfig.stream ?? true }];
 
-        for (const plan of plans) {
-          try {
-            const client = createLLMClient({
-              ...llmConfig,
-              apiFormat: plan.apiFormat,
-              stream: plan.stream,
-            });
-            const response = await chatCompletion(client, llmConfig.model, [
-              { role: "user", content: "Say OK" },
-            ], { maxTokens: 16 });
+        for (const model of modelCandidates) {
+          for (const plan of plans) {
+            try {
+              const client = createLLMClient({
+                ...llmConfig,
+                model,
+                apiFormat: plan.apiFormat,
+                stream: plan.stream,
+              });
+              const response = await chatCompletion(client, model, [
+                { role: "user", content: "Say OK" },
+              ], { maxTokens: 16 });
 
-            connected = true;
-            detectedDetail = `OK (model: ${llmConfig.model}, apiFormat=${plan.apiFormat}, stream=${plan.stream}, tokens: ${response.usage.totalTokens})`;
+              connected = true;
+              detectedDetail = `OK (model: ${model}, apiFormat=${plan.apiFormat}, stream=${plan.stream}, tokens: ${response.usage.totalTokens})`;
+              break;
+            } catch (error) {
+              lastError = error instanceof Error ? error.message : String(error);
+            }
+          }
+          if (connected) {
             break;
-          } catch (error) {
-            lastError = error instanceof Error ? error.message : String(error);
           }
         }
 

+ 9 - 2
packages/cli/src/commands/studio.ts

@@ -3,7 +3,7 @@ import { findProjectRoot, log, logError } from "../utils.js";
 import { spawn } from "node:child_process";
 import { dirname, join } from "node:path";
 import { access } from "node:fs/promises";
-import { fileURLToPath } from "node:url";
+import { fileURLToPath, pathToFileURL } from "node:url";
 import { ensureProjectDirectoryInitialized } from "../project-bootstrap.js";
 
 export interface StudioLaunchSpec {
@@ -40,6 +40,13 @@ async function firstAccessiblePath(paths: readonly string[]): Promise<string | u
 
 const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
 
+export function toNodeImportSpecifier(path: string): string {
+  if (/^[A-Za-z]:[\\/]/.test(path)) {
+    return `file:///${path.replace(/\\/g, "/")}`;
+  }
+  return path;
+}
+
 export function resolveBrowserLaunch(
   platform: NodeJS.Platform,
   url: string,
@@ -68,7 +75,7 @@ export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpe
       return {
         studioEntry: sourceEntry,
         command: "node",
-        args: ["--import", localTsxLoader, sourceEntry, root],
+        args: ["--import", toNodeImportSpecifier(localTsxLoader), sourceEntry, root],
       };
     }
 

+ 63 - 43
packages/core/src/__tests__/architect.test.ts

@@ -466,7 +466,7 @@ 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 () => {
+  it("throws when a required foundation section is missing", async () => {
     const agent = new ArchitectAgent({
       client: {
         provider: "openai",
@@ -484,16 +484,16 @@ describe("ArchitectAgent", () => {
     });
 
     const book: BookConfig = {
-      id: "missing-state-book",
-      title: "缺失状态卡",
+      id: "broken-book",
+      title: "Broken Book",
       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",
+      createdAt: "2026-03-29T00:00:00.000Z",
+      updatedAt: "2026-03-29T00:00:00.000Z",
     };
 
     vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
@@ -505,27 +505,19 @@ describe("ArchitectAgent", () => {
           "=== SECTION: volume_outline ===",
           "# 卷纲",
           "",
-          "=== SECTION: book_rules ===",
-          "---",
-          "version: \"1.0\"",
-          "---",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
           "",
           "=== 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("主角状态");
+    await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
   });
 
-  it("falls back to default pending_hooks when section is missing from LLM output", async () => {
+  it("passes maxTokens 16384 when generating foundation", async () => {
     const agent = new ArchitectAgent({
       client: {
         provider: "openai",
@@ -543,19 +535,19 @@ describe("ArchitectAgent", () => {
     });
 
     const book: BookConfig = {
-      id: "missing-hooks-book",
-      title: "缺失伏笔池",
+      id: "max-tokens-book",
+      title: "Max Tokens Book",
       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",
+      createdAt: "2026-03-29T00:00:00.000Z",
+      updatedAt: "2026-03-29T00:00:00.000Z",
     };
 
-    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
       .mockResolvedValue({
         content: [
           "=== SECTION: story_bible ===",
@@ -571,18 +563,24 @@ describe("ArchitectAgent", () => {
           "",
           "=== SECTION: current_state ===",
           "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
         ].join("\n"),
         usage: ZERO_USAGE,
       });
 
-    const result = await agent.generateFoundation(book);
+    await agent.generateFoundation(book);
 
-    expect(result.pendingHooks).toContain("hook_id");
-    expect(result.pendingHooks).toContain("起始章节");
-    expect(result.pendingHooks).toContain("回收节奏");
+    expect(chatSpy).toHaveBeenCalledWith(
+      expect.any(Array),
+      expect.objectContaining({ temperature: 0.8, maxTokens: 16384 }),
+    );
   });
 
-  it("falls back to defaults when both current_state and pending_hooks are missing", async () => {
+  it("passes maxTokens 16384 when generating foundation from import", async () => {
     const agent = new ArchitectAgent({
       client: {
         provider: "openai",
@@ -600,19 +598,19 @@ describe("ArchitectAgent", () => {
     });
 
     const book: BookConfig = {
-      id: "missing-both-book",
-      title: "都缺失",
+      id: "import-max-tokens-book",
+      title: "Import Max Tokens Book",
       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",
+      createdAt: "2026-03-29T00:00:00.000Z",
+      updatedAt: "2026-03-29T00:00:00.000Z",
     };
 
-    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
       .mockResolvedValue({
         content: [
           "=== SECTION: story_bible ===",
@@ -625,17 +623,27 @@ describe("ArchitectAgent", () => {
           "---",
           "version: \"1.0\"",
           "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
         ].join("\n"),
         usage: ZERO_USAGE,
       });
 
-    const result = await agent.generateFoundation(book);
+    await agent.generateFoundationFromImport(book, "第一章正文");
 
-    expect(result.currentState).toContain("当前章节 | 0");
-    expect(result.pendingHooks).toContain("hook_id");
+    expect(chatSpy).toHaveBeenCalledWith(
+      expect.any(Array),
+      expect.objectContaining({ temperature: 0.5, maxTokens: 16384 }),
+    );
   });
 
-  it("throws when a required foundation section is missing", async () => {
+  it("passes maxTokens 16384 when generating fanfic foundation", async () => {
     const agent = new ArchitectAgent({
       client: {
         provider: "openai",
@@ -653,10 +661,10 @@ describe("ArchitectAgent", () => {
     });
 
     const book: BookConfig = {
-      id: "broken-book",
-      title: "Broken Book",
+      id: "fanfic-max-tokens-book",
+      title: "Fanfic Max Tokens Book",
       platform: "other",
-      genre: "other",
+      genre: "fanfic",
       status: "active",
       targetChapters: 20,
       chapterWordCount: 2200,
@@ -665,7 +673,7 @@ describe("ArchitectAgent", () => {
       updatedAt: "2026-03-29T00:00:00.000Z",
     };
 
-    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
       .mockResolvedValue({
         content: [
           "=== SECTION: story_bible ===",
@@ -674,15 +682,27 @@ describe("ArchitectAgent", () => {
           "=== SECTION: volume_outline ===",
           "# 卷纲",
           "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
           "=== SECTION: current_state ===",
           "# 当前状态",
           "",
           "=== SECTION: pending_hooks ===",
-          "# 伏笔池",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
         ].join("\n"),
         usage: ZERO_USAGE,
       });
 
-    await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
+    await agent.generateFanficFoundation(book, "正典文本", "canon");
+
+    expect(chatSpy).toHaveBeenCalledWith(
+      expect.any(Array),
+      expect.objectContaining({ temperature: 0.7, maxTokens: 16384 }),
+    );
   });
 });

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

@@ -111,6 +111,7 @@ function makeClient(temperature = 0.7, extra: Partial<LLMClient> = {}): LLMClien
   return {
     provider: "openai",
     service: "openai",
+    configSource: "studio",
     apiFormat: "chat",
     stream: true,
     _piModel: MOCK_PI_MODEL,
@@ -287,6 +288,31 @@ describe("chatCompletion via pi-ai", () => {
     vi.unstubAllGlobals();
   });
 
+  it("keeps legacy env custom openai-compatible chat on pi-ai path", async () => {
+    const fetchMock = vi.fn();
+    vi.stubGlobal("fetch", fetchMock);
+    mockCompleteSimple.mockResolvedValue(makeAssistantMessage("legacy ok"));
+
+    const client = makeClient(0.7, {
+      service: "custom",
+      configSource: "env",
+      stream: false,
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
+      },
+    });
+
+    const result = await chatCompletion(client, "gemma-4", [{ role: "user", content: "ping" }]);
+
+    expect(result.content).toBe("legacy ok");
+    expect(mockCompleteSimple).toHaveBeenCalledOnce();
+    expect(fetchMock).not.toHaveBeenCalled();
+
+    vi.unstubAllGlobals();
+  });
+
   it("uses native fetch transport for custom anthropic-compatible non-stream chat", async () => {
     const fetchMock = vi.fn().mockResolvedValue({
       ok: true,

+ 32 - 1
packages/core/src/__tests__/service-resolver.test.ts

@@ -5,12 +5,26 @@ import { join } from "node:path";
 import { tmpdir } from "node:os";
 
 // Models that exist in pi-ai's built-in registry (simulated)
-const KNOWN_MODELS = new Set(["gpt-4o", "kimi-k2.5"]);
+const KNOWN_MODELS = new Set(["gpt-4o", "kimi-k2.5", "MiniMax-M2.7"]);
 
 // Mock pi-ai's getModel — returns undefined for models not in registry (like the real implementation)
 vi.mock("@mariozechner/pi-ai", () => ({
   getModel: vi.fn((provider: string, modelId: string) => {
     if (!KNOWN_MODELS.has(modelId)) return undefined;
+    if (modelId === "MiniMax-M2.7") {
+      return {
+        id: modelId,
+        name: modelId,
+        api: "anthropic-messages",
+        provider: "minimax",
+        baseUrl: "https://api.minimax.io/anthropic",
+        reasoning: true,
+        input: ["text"],
+        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+        contextWindow: 204800,
+        maxTokens: 131072,
+      };
+    }
     return {
       id: modelId,
       name: modelId,
@@ -139,4 +153,21 @@ describe("resolveServiceModel", () => {
     expect(result.model.id).toBe("gpt-5.4");
     expect(result.model.api).toBe("openai-responses");
   });
+
+  it("overrides MiniMax pi-ai metadata with our preset baseUrl and api", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { minimax: { apiKey: "sk-minimax" } } }),
+    );
+
+    const result = await resolveServiceModel("minimax", "MiniMax-M2.7", root);
+
+    expect(result.apiKey).toBe("sk-minimax");
+    expect(result.model.baseUrl).toBe("https://api.minimax.io/v1");
+    expect(result.model.api).toBe("openai-completions");
+    expect(result.model.reasoning).toBe(true);
+    expect(result.model.contextWindow).toBe(204800);
+    expect(result.model.maxTokens).toBe(131072);
+  });
 });

+ 6 - 22
packages/core/src/agents/architect.ts

@@ -14,19 +14,6 @@ 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";
@@ -292,7 +279,7 @@ ${finalRequirementsPrompt}`;
     const response = await this.chat([
       { role: "system", content: langPrefix + systemPrompt },
       { role: "user", content: userMessage },
-    ], { temperature: 0.8 });
+    ], { temperature: 0.8, maxTokens: 16384 });
 
     return this.parseSections(response.content);
   }
@@ -689,7 +676,7 @@ ${keyPrinciplesPrompt}`;
         role: "user",
         content: userMessage,
       },
-    ], { temperature: 0.5 });
+    ], { temperature: 0.5, maxTokens: 16384 });
 
     return this.parseSections(response.content);
   }
@@ -778,7 +765,7 @@ prohibitions:
         role: "user",
         content: `请为标题为"${book.title}"的${fanficMode}模式同人小说生成基础设定。目标${book.targetChapters}章,每章${book.chapterWordCount}字。`,
       },
-    ], { temperature: 0.7 });
+    ], { temperature: 0.7, maxTokens: 16384 });
 
     return this.parseSections(response.content);
   }
@@ -817,12 +804,9 @@ ${trimmed}\n`;
       parsedSections.set(normalizedName, content.slice(start, end).trim());
     }
 
-    const extract = (name: string, fallback?: string): string => {
+    const extract = (name: 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") {
@@ -835,8 +819,8 @@ ${trimmed}\n`;
       storyBible: extract("story_bible"),
       volumeOutline: extract("volume_outline"),
       bookRules: extract("book_rules"),
-      currentState: extract("current_state", DEFAULT_CURRENT_STATE),
-      pendingHooks: extract("pending_hooks", DEFAULT_PENDING_HOOKS),
+      currentState: extract("current_state"),
+      pendingHooks: extract("pending_hooks"),
     };
   }
 

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

@@ -86,6 +86,7 @@ export interface LLMMessage {
 export interface LLMClient {
   readonly provider: "openai" | "anthropic";
   readonly service?: string;
+  readonly configSource?: LLMConfig["configSource"];
   readonly apiFormat: "chat" | "responses";
   readonly stream: boolean;
   readonly _piModel?: PiModel<PiApi>;
@@ -164,6 +165,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   return {
     provider,
     service: serviceName,
+    configSource: config.configSource,
     apiFormat,
     stream,
     _piModel: piModel,
@@ -308,7 +310,9 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
 }
 
 function shouldUseNativeCustomTransport(client: LLMClient): boolean {
-  return client.service === "custom" && (client.provider === "openai" || client.provider === "anthropic");
+  return client.configSource === "studio"
+    && client.service === "custom"
+    && (client.provider === "openai" || client.provider === "anthropic");
 }
 
 function buildCustomHeaders(client: LLMClient): Record<string, string> {

+ 1 - 0
packages/core/src/llm/service-presets.ts

@@ -15,6 +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" },
+<<<<<<< HEAD
   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 },

+ 2 - 8
packages/core/src/llm/service-resolver.ts

@@ -30,20 +30,14 @@ export async function resolveServiceModel(
   const baseService = service.startsWith("custom:") ? "custom" : service;
   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 ?? "";
+  const configuredBaseUrl = customBaseUrl ?? preset?.baseUrl ?? "";
 
   // Get pi-ai Model — may return undefined for model IDs not in the built-in registry
   const piModel = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
-
-  // 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 || "";
+  const effectiveBaseUrl = configuredBaseUrl || piModel?.baseUrl || "";
   if (!effectiveBaseUrl) {
     throw new Error(
       `Cannot resolve model "${modelId}" for service "${service}": no baseUrl available.`,