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

fix(core,studio,cli): unify service routing through presets

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

+ 11 - 1
packages/cli/src/__tests__/doctor.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
-import { buildDoctorModelCandidates } from "../commands/doctor.js";
+import { buildDoctorModelCandidates, resolveDoctorModelsBaseUrl } from "../commands/doctor.js";
 
 describe("doctor model candidate probing", () => {
   it("keeps the configured model first, then tries discovered models without duplicates", () => {
@@ -22,4 +22,14 @@ describe("doctor model candidate probing", () => {
     expect(candidates).toContain("MiniMax-M2.7");
     expect(candidates).toContain("gemini-2.5-flash");
   });
+
+  it("uses the service-specific models endpoint when the preset defines one", () => {
+    const modelsBaseUrl = resolveDoctorModelsBaseUrl(
+      "bailian",
+      "https://dashscope.aliyuncs.com/apps/anthropic",
+      (service) => service === "bailian" ? "https://dashscope.aliyuncs.com/compatible-mode/v1" : undefined,
+    );
+
+    expect(modelsBaseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
+  });
 });

+ 22 - 6
packages/cli/src/commands/doctor.ts

@@ -60,11 +60,22 @@ export function buildDoctorModelCandidates(
   return candidates;
 }
 
-async function fetchDoctorModels(
+export function resolveDoctorModelsBaseUrl(
+  service: string | undefined,
   baseUrl: string,
+  resolveServiceModelsBaseUrl: (service: string) => string | undefined,
+): string {
+  if (!service || service.length === 0) {
+    return baseUrl;
+  }
+  return resolveServiceModelsBaseUrl(service) ?? baseUrl;
+}
+
+async function fetchDoctorModels(
+  modelsBaseUrl: string,
   apiKey: string,
 ): Promise<Array<{ id: string; name: string }>> {
-  const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
+  const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   try {
     const res = await fetch(modelsUrl, {
       headers: { Authorization: `Bearer ${apiKey}` },
@@ -219,7 +230,7 @@ export const doctorCommand = new Command("doctor")
 
     // 6. API connectivity test
     try {
-      const { createLLMClient, chatCompletion, LLMConfigSchema, isApiKeyOptionalForEndpoint } = await import("@actalk/inkos-core");
+      const { createLLMClient, chatCompletion, LLMConfigSchema, isApiKeyOptionalForEndpoint, resolveServiceModelsBaseUrl } = await import("@actalk/inkos-core");
       const { loadConfig } = await import("../utils.js");
 
       let llmConfig;
@@ -263,10 +274,15 @@ 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 modelsBaseUrl = resolveDoctorModelsBaseUrl(
+          typeof llmConfig.service === "string" ? llmConfig.service : undefined,
+          llmConfig.baseUrl,
+          resolveServiceModelsBaseUrl,
+        );
+        const discoveredModels = (llmConfig.apiKey && modelsBaseUrl)
+          ? await fetchDoctorModels(modelsBaseUrl, llmConfig.apiKey)
           : [];
-        const modelCandidates = llmConfig.provider === "openai"
+        const modelCandidates = (llmConfig.provider === "openai" || discoveredModels.length > 0)
           ? buildDoctorModelCandidates(llmConfig.model, discoveredModels)
           : [llmConfig.model];
         const plans = llmConfig.provider === "openai"

+ 35 - 0
packages/core/src/__tests__/config-loader.test.ts

@@ -115,6 +115,41 @@ describe("loadProjectConfig local provider auth", () => {
     expect(config.llm.maxTokens).toBe(4096);
   });
 
+  it("derives provider/baseUrl from the MiniMax preset single source of truth", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-minimax-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "minimax-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        services: [
+          { service: "minimax", temperature: 0.9, maxTokens: 4096 },
+        ],
+        defaultModel: "MiniMax-M2.7",
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { minimax: { apiKey: "sk-minimax" } } }, null, 2),
+      "utf-8",
+    );
+
+    const config = await loadProjectConfig(root);
+
+    expect(config.llm.service).toBe("minimax");
+    expect(config.llm.provider).toBe("anthropic");
+    expect(config.llm.baseUrl).toBe("https://api.minimaxi.com/anthropic");
+    expect(config.llm.model).toBe("MiniMax-M2.7");
+    expect(config.llm.apiKey).toBe("sk-minimax");
+  });
+
   it("loads custom service config using custom secret key and entry baseUrl", async () => {
     root = await mkdtemp(join(tmpdir(), "inkos-config-loader-custom-"));
     for (const key of ENV_KEYS) {

+ 6 - 5
packages/core/src/__tests__/service-resolver.test.ts

@@ -11,13 +11,13 @@ const KNOWN_MODELS = new Set(["gpt-4o", "kimi-k2.5", "MiniMax-M2.7"]);
 vi.mock("@mariozechner/pi-ai", () => ({
   getModel: vi.fn((provider: string, modelId: string) => {
     if (!KNOWN_MODELS.has(modelId)) return undefined;
-    if (modelId === "MiniMax-M2.7") {
+    if (modelId === "MiniMax-M2.7" && provider === "anthropic") {
       return {
         id: modelId,
         name: modelId,
         api: "anthropic-messages",
-        provider: "minimax",
-        baseUrl: "https://api.minimax.io/anthropic",
+        provider: "anthropic",
+        baseUrl: "https://api.minimaxi.com/anthropic",
         reasoning: true,
         input: ["text"],
         cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -154,7 +154,7 @@ describe("resolveServiceModel", () => {
     expect(result.model.api).toBe("openai-responses");
   });
 
-  it("overrides MiniMax pi-ai metadata with our preset baseUrl and api", async () => {
+  it("resolves MiniMax using the preset provider family and anthropic endpoint", async () => {
     await mkdir(join(root, ".inkos"), { recursive: true });
     await writeFile(
       join(root, ".inkos", "secrets.json"),
@@ -165,7 +165,8 @@ describe("resolveServiceModel", () => {
 
     expect(result.apiKey).toBe("sk-minimax");
     expect(result.model.baseUrl).toBe("https://api.minimaxi.com/anthropic");
-    expect(result.model.api).toBe("openai-completions");
+    expect(result.model.api).toBe("anthropic-messages");
+    expect(result.model.provider).toBe("anthropic");
     expect(result.model.reasoning).toBe(true);
     expect(result.model.contextWindow).toBe(204800);
     expect(result.model.maxTokens).toBe(131072);

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

@@ -162,7 +162,19 @@ export * from "./agent/index.js";
 
 // LLM
 export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";
-export { SERVICE_PRESETS, SERVICE_TO_PI_PROVIDER, resolveServicePreset, guessServiceFromBaseUrl, listModelsForService, listServicesWithModelCount, type ServicePreset, type ModelInfo } from "./llm/service-presets.js";
+export {
+  SERVICE_PRESETS,
+  SERVICE_TO_PI_PROVIDER,
+  resolveServicePreset,
+  resolveServiceProviderFamily,
+  resolveServicePiProvider,
+  resolveServiceModelsBaseUrl,
+  guessServiceFromBaseUrl,
+  listModelsForService,
+  listServicesWithModelCount,
+  type ServicePreset,
+  type ModelInfo,
+} from "./llm/service-presets.js";
 export { resolveServiceModel, type ResolvedModel } from "./llm/service-resolver.js";
 export { loadSecrets, saveSecrets, getServiceApiKey, type SecretsFile } from "./llm/secrets.js";
 export { migrateConfig, type MigrationResult } from "./llm/config-migration.js";

+ 60 - 42
packages/core/src/llm/service-presets.ts

@@ -1,4 +1,5 @@
 export interface ServicePreset {
+  readonly providerFamily: "openai" | "anthropic";
   readonly api: string;
   readonly baseUrl: string;
   readonly label: string;
@@ -6,29 +7,66 @@ 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[];
+  readonly piProvider?: string;
+  readonly modelsBaseUrl?: string;
 }
 
 export const SERVICE_PRESETS: Record<string, ServicePreset> = {
-  openai:      { api: "openai-responses",   baseUrl: "https://api.openai.com/v1",                         label: "OpenAI",          temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.0 },
-  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.minimaxi.com/anthropic",               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: "硅基流动" },
-  ppio:        { api: "openai-completions",  baseUrl: "https://api.ppinfra.com/v3/openai",                 label: "PPIO" },
-  openrouter:  { api: "openai-responses",    baseUrl: "https://openrouter.ai/api/v1",                      label: "OpenRouter" },
-  ollama:      { api: "openai-completions",  baseUrl: "http://localhost:11434/v1",                         label: "Ollama (本地)" },
-  custom:      { api: "openai-completions",  baseUrl: "",                                                   label: "自定义端点" },
+  openai:      { providerFamily: "openai",    api: "openai-responses",   baseUrl: "https://api.openai.com/v1",                          label: "OpenAI",          temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.0 },
+  anthropic:   { providerFamily: "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:    { providerFamily: "openai",    api: "openai-completions", baseUrl: "https://api.deepseek.com",                           label: "DeepSeek",        temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.5, temperatureHint: "创意写作推荐 1.5" },
+  moonshot:    { providerFamily: "openai",    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:     {
+    providerFamily: "anthropic",
+    api: "anthropic-messages",
+    baseUrl: "https://api.minimaxi.com/anthropic",
+    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"],
+    piProvider: "anthropic",
+  },
+  bailian:     {
+    providerFamily: "anthropic",
+    api: "anthropic-messages",
+    baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
+    modelsBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
+    label: "百炼 (通义千问)",
+    temperatureRange: [0, 2],
+    defaultTemperature: 0.7,
+    writingTemperature: 1.0,
+    piProvider: "anthropic",
+  },
+  zhipu:       { providerFamily: "openai",    api: "openai-completions", baseUrl: "https://open.bigmodel.cn/api/paas/v4",               label: "智谱 GLM",        temperatureRange: [0, 1], defaultTemperature: 0.95, writingTemperature: 0.95, piProvider: "zai" },
+  siliconflow: { providerFamily: "openai",    api: "openai-completions", baseUrl: "https://api.siliconflow.cn/v1",                      label: "硅基流动" },
+  ppio:        { providerFamily: "openai",    api: "openai-completions", baseUrl: "https://api.ppinfra.com/v3/openai",                  label: "PPIO" },
+  openrouter:  { providerFamily: "openai",    api: "openai-responses",   baseUrl: "https://openrouter.ai/api/v1",                       label: "OpenRouter",      piProvider: "openrouter" },
+  ollama:      { providerFamily: "openai",    api: "openai-completions", baseUrl: "http://localhost:11434/v1",                          label: "Ollama (本地)" },
+  custom:      { providerFamily: "openai",    api: "openai-completions", baseUrl: "",                                                    label: "自定义端点" },
 };
 
 export function resolveServicePreset(service: string): ServicePreset | undefined {
   return SERVICE_PRESETS[service];
 }
 
+export function resolveServiceProviderFamily(service: string): "openai" | "anthropic" | undefined {
+  return resolveServicePreset(service)?.providerFamily;
+}
+
+export function resolveServicePiProvider(service: string): string | undefined {
+  const preset = resolveServicePreset(service);
+  if (!preset) return undefined;
+  return preset.piProvider ?? preset.providerFamily;
+}
+
+export function resolveServiceModelsBaseUrl(service: string): string | undefined {
+  const preset = resolveServicePreset(service);
+  if (!preset) return undefined;
+  return preset.modelsBaseUrl ?? preset.baseUrl;
+}
+
 const DEFAULT_TEMPERATURE_RANGE: [number, number] = [0, 2];
 
 export function clampTemperature(service: string, temperature: number): number {
@@ -54,20 +92,11 @@ export function guessServiceFromBaseUrl(baseUrl: string): string {
   return "custom";
 }
 
-// pi-ai service → pi-ai provider 映射
-export const SERVICE_TO_PI_PROVIDER: Record<string, string> = {
-  openai: "openai",
-  anthropic: "anthropic",
-  deepseek: "openai",         // OpenAI 兼容,pi-ai 无独立 provider
-  moonshot: "openai",         // Moonshot API (api.moonshot.cn) 是 OpenAI 兼容,不是 kimi-coding (api.kimi.com)
-  minimax: "minimax",
-  bailian: "openai",          // 百炼走 OpenAI 兼容
-  zhipu: "zai",               // pi-ai 有 zai provider
-  siliconflow: "openai",      // OpenAI 兼容
-  ppio: "openai",             // OpenAI 兼容
-  openrouter: "openrouter",
-  ollama: "openai",           // OpenAI 兼容
-};
+export const SERVICE_TO_PI_PROVIDER: Record<string, string> = Object.fromEntries(
+  Object.entries(SERVICE_PRESETS)
+    .filter(([service]) => service !== "custom")
+    .map(([service, preset]) => [service, preset.piProvider ?? preset.providerFamily]),
+) as Record<string, string>;
 
 export interface ModelInfo {
   readonly id: string;
@@ -76,25 +105,18 @@ export interface ModelInfo {
   readonly contextWindow: number;
 }
 
-/**
- * 动态获取某个 service 下可用的模型列表。
- * 优先调用服务商的 GET /models API(OpenAI 兼容),回退到 pi-ai 内置模型列表。
- *
- * @param apiKey 用户配置的 API key,用于认证 /models 请求
- */
 export async function listModelsForService(service: string, apiKey?: string): Promise<ReadonlyArray<ModelInfo>> {
   const preset = SERVICE_PRESETS[service];
   if (!preset || service === "custom") return [];
 
-  // 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) {
+  const modelsBaseUrl = resolveServiceModelsBaseUrl(service);
+  if (apiKey && modelsBaseUrl) {
     try {
-      const modelsUrl = preset.baseUrl.replace(/\/$/, "") + "/models";
+      const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
       const res = await fetch(modelsUrl, {
         headers: { Authorization: `Bearer ${apiKey}` },
         signal: AbortSignal.timeout(10_000),
@@ -111,11 +133,10 @@ export async function listModelsForService(service: string, apiKey?: string): Pr
         }
       }
     } catch {
-      // /models 不可用,回退
+      // /models unavailable, fall through
     }
   }
 
-  // 3) 回退到 pi-ai 内置模型列表
   const piProvider = SERVICE_TO_PI_PROVIDER[service];
   if (!piProvider) return [];
 
@@ -133,9 +154,6 @@ export async function listModelsForService(service: string, apiKey?: string): Pr
   }
 }
 
-/**
- * 获取所有 service 及其可用模型数。
- */
 export async function listServicesWithModelCount(): Promise<ReadonlyArray<{ service: string; label: string; modelCount: number }>> {
   const result: { service: string; label: string; modelCount: number }[] = [];
   for (const [key, preset] of Object.entries(SERVICE_PRESETS)) {

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

@@ -1,6 +1,6 @@
 import { getModel } from "@mariozechner/pi-ai";
 import type { Model, Api } from "@mariozechner/pi-ai";
-import { resolveServicePreset, SERVICE_TO_PI_PROVIDER } from "./service-presets.js";
+import { resolveServicePiProvider, resolveServicePreset } from "./service-presets.js";
 import { getServiceApiKey } from "./secrets.js";
 
 export interface ResolvedModel {
@@ -29,7 +29,7 @@ export async function resolveServiceModel(
   // Determine pi-ai provider
   const baseService = service.startsWith("custom:") ? "custom" : service;
   const preset = resolveServicePreset(baseService);
-  const piProvider = SERVICE_TO_PI_PROVIDER[baseService] ?? "openai";
+  const piProvider = resolveServicePiProvider(baseService) ?? "openai";
   const apiType = service.startsWith("custom:")
     ? (customApiFormat === "responses" ? "openai-responses" : "openai-completions")
     : (preset?.api ?? "openai-completions");

+ 2 - 3
packages/core/src/utils/config-loader.ts

@@ -3,7 +3,7 @@ import { homedir } from "node:os";
 import { join } from "node:path";
 import { ProjectConfigSchema, type ProjectConfig } from "../models/project.js";
 import { getServiceApiKey } from "../llm/secrets.js";
-import { resolveServicePreset } from "../llm/service-presets.js";
+import { resolveServicePreset, resolveServiceProviderFamily } from "../llm/service-presets.js";
 
 export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
 export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
@@ -280,9 +280,8 @@ function serviceEntryKey(entry: ServiceConfigEntry): string {
 }
 
 function deriveProviderFromService(service: string): "anthropic" | "openai" | "custom" {
-  if (service === "anthropic") return "anthropic";
   if (service === "custom") return "custom";
-  return "openai";
+  return resolveServiceProviderFamily(service) ?? "openai";
 }
 
 function isPrivateIpv4(hostname: string): boolean {

+ 155 - 0
packages/studio/src/api/server.test.ts

@@ -31,6 +31,46 @@ const resolveServiceModelMock = vi.fn();
 const loadSecretsMock = vi.fn();
 const saveSecretsMock = vi.fn();
 const getServiceApiKeyMock = vi.fn();
+type ServicePresetMock = {
+  providerFamily: "openai" | "anthropic";
+  baseUrl: string;
+  modelsBaseUrl: string;
+  knownModels: string[];
+};
+const SERVICE_PRESETS_MOCK: Record<string, ServicePresetMock> = {
+  openai: { providerFamily: "openai", baseUrl: "https://api.openai.com/v1", modelsBaseUrl: "https://api.openai.com/v1", knownModels: [] as string[] },
+  anthropic: { providerFamily: "anthropic", baseUrl: "https://api.anthropic.com", modelsBaseUrl: "https://api.anthropic.com", knownModels: [] as string[] },
+  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[] },
+  custom: { providerFamily: "openai", baseUrl: "", modelsBaseUrl: "", knownModels: [] as string[] },
+};
+const resolveServicePresetMock = vi.fn((service: string) => SERVICE_PRESETS_MOCK[service]);
+const resolveServiceProviderFamilyMock = vi.fn((service: string) => resolveServicePresetMock(service)?.providerFamily);
+const resolveServiceModelsBaseUrlMock = vi.fn((service: string) => {
+  const preset = SERVICE_PRESETS_MOCK[service];
+  return preset?.modelsBaseUrl ?? preset?.baseUrl;
+});
+const listModelsForServiceMock = vi.fn(async (service: string, apiKey?: string) => {
+  const preset = resolveServicePresetMock(service);
+  if (!preset || service === "custom") return [];
+  if (preset.knownModels.length > 0) {
+    return preset.knownModels.map((id) => ({ id, name: id, reasoning: false, contextWindow: 0 }));
+  }
+  const modelsBaseUrl = resolveServiceModelsBaseUrlMock(service);
+  if (!apiKey || !modelsBaseUrl) return [];
+  const res = await fetch(`${modelsBaseUrl.replace(/\/$/, "")}/models`, {
+    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,
+    reasoning: false,
+    contextWindow: 0,
+  }));
+});
 
 const logger = {
   child: () => logger,
@@ -127,10 +167,14 @@ vi.mock("@actalk/inkos-core", () => {
     loadBookSession: loadBookSessionMock,
     persistBookSession: persistBookSessionMock,
     appendBookSessionMessage: appendBookSessionMessageMock,
+    resolveServicePreset: resolveServicePresetMock,
+    resolveServiceProviderFamily: resolveServiceProviderFamilyMock,
+    resolveServiceModelsBaseUrl: resolveServiceModelsBaseUrlMock,
     resolveServiceModel: resolveServiceModelMock,
     loadSecrets: loadSecretsMock,
     saveSecrets: saveSecretsMock,
     getServiceApiKey: getServiceApiKeyMock,
+    listModelsForService: listModelsForServiceMock,
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
   };
 });
@@ -290,6 +334,10 @@ describe("createStudioServer daemon lifecycle", () => {
     loadSecretsMock.mockReset();
     saveSecretsMock.mockReset();
     getServiceApiKeyMock.mockReset();
+    resolveServicePresetMock.mockClear();
+    resolveServiceProviderFamilyMock.mockClear();
+    resolveServiceModelsBaseUrlMock.mockClear();
+    listModelsForServiceMock.mockClear();
     // Default BookSession for agent tests
     const defaultBookSession = {
       sessionId: "agent-session-1",
@@ -819,6 +867,113 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("uses the MiniMax preset provider family during service probe", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "minimax", apiFormat: "chat", stream: false },
+        ],
+        defaultModel: "MiniMax-M2.7",
+      },
+    }, null, 2), "utf-8");
+
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 404,
+      text: async () => "404 page not found",
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
+    createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
+    chatCompletionMock.mockImplementation(async (client: any, model: string) => {
+      if (client.provider === "anthropic" && client.baseUrl === "https://api.minimaxi.com/anthropic" && model === "MiniMax-M2.7") {
+        return {
+          content: "pong",
+          usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
+        };
+      }
+      throw new Error(`unexpected probe route: ${client.provider} ${client.baseUrl} ${model}`);
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/minimax/test", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        apiKey: "sk-minimax",
+        apiFormat: "chat",
+        stream: false,
+      }),
+    });
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      ok: true,
+      selectedModel: "MiniMax-M2.7",
+      detected: {
+        apiFormat: "chat",
+        stream: false,
+        baseUrl: "https://api.minimaxi.com/anthropic",
+      },
+    });
+  });
+
+  it("uses the preset models baseUrl when listing Bailian models", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "bailian", apiFormat: "chat", stream: false },
+        ],
+        defaultModel: "qwen-max",
+      },
+    }, null, 2), "utf-8");
+    getServiceApiKeyMock.mockResolvedValue("sk-bailian");
+
+    const fetchMock = vi.fn(async (input: string | URL | Request) => {
+      const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
+      if (url === "https://dashscope.aliyuncs.com/compatible-mode/v1/models") {
+        return {
+          ok: true,
+          json: async () => ({ data: [{ id: "qwen-max" }] }),
+          text: async (): Promise<string> => "",
+        };
+      }
+      return {
+        ok: false,
+        status: 404,
+        text: async () => "404 page not found",
+      };
+    });
+    vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+    createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
+    chatCompletionMock.mockImplementation(async (client: any, model: string) => {
+      if (client.provider === "anthropic" && client.baseUrl === "https://dashscope.aliyuncs.com/apps/anthropic" && model === "qwen-max") {
+        return {
+          content: "pong",
+          usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
+        };
+      }
+      throw new Error(`unexpected bailian route: ${client.provider} ${client.baseUrl} ${model}`);
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/bailian/models");
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      models: [{ id: "qwen-max", name: "qwen-max" }],
+    });
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
+      expect.any(Object),
+    );
+  });
+
   it("returns stored service secret for detail page rehydration", async () => {
     loadSecretsMock.mockResolvedValue({
       services: {

+ 11 - 4
packages/studio/src/api/server.ts

@@ -21,6 +21,8 @@ import {
   runAgentSession,
   buildAgentSystemPrompt,
   resolveServicePreset,
+  resolveServiceProviderFamily,
+  resolveServiceModelsBaseUrl,
   resolveServiceModel,
   loadSecrets,
   saveSecrets,
@@ -378,10 +380,14 @@ function buildModelCandidates(args: {
 }
 
 async function fetchModelsFromServiceBaseUrl(
+  serviceId: string,
   baseUrl: string,
   apiKey: string,
 ): Promise<{ models: Array<{ id: string; name: string }>; error?: string }> {
-  const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
+  const modelsBaseUrl = isCustomServiceId(serviceId)
+    ? baseUrl
+    : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl;
+  const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   try {
     const res = await fetch(modelsUrl, {
       headers: { Authorization: `Bearer ${apiKey}` },
@@ -421,7 +427,8 @@ async function probeServiceCapabilities(args: {
       ? envConfig.global.model
       : null;
 
-  const modelsResponse = await fetchModelsFromServiceBaseUrl(args.baseUrl, args.apiKey);
+  const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
+  const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey);
   const discoveredModels = modelsResponse.models;
   const modelCandidates = buildModelCandidates({
     preferredModel: args.preferredModel,
@@ -443,8 +450,8 @@ async function probeServiceCapabilities(args: {
   for (const model of modelCandidates) {
     for (const plan of buildProbePlans(args.preferredApiFormat, args.preferredStream)) {
       const client = createLLMClient({
-        provider: args.service === "anthropic" ? "anthropic" : "openai",
-        service: isCustomServiceId(args.service) ? "custom" : args.service,
+        provider: resolveServiceProviderFamily(baseService) ?? "openai",
+        service: baseService,
         configSource: "studio",
         baseUrl: args.baseUrl,
         apiKey: args.apiKey.trim(),