Преглед изворни кода

fix: 修复百炼模型源污染

fanghanjun пре 6 месеци
родитељ
комит
e0b0dbdfe6

+ 29 - 1
packages/core/src/__tests__/list-models.test.ts

@@ -1,5 +1,5 @@
 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { listModelsForService } from "../llm/service-presets.js";
+import { listModelsForService, resolveServiceModelsBaseUrl } from "../llm/service-presets.js";
 
 
 describe("listModelsForService (B8)", () => {
 describe("listModelsForService (B8)", () => {
   const originalEnv = process.env.INKOS_LLM_MODEL;
   const originalEnv = process.env.INKOS_LLM_MODEL;
@@ -58,6 +58,34 @@ describe("listModelsForService (B8)", () => {
     expect(models.some((m) => m.id === "claude-sonnet-4-6")).toBe(true);
     expect(models.some((m) => m.id === "claude-sonnet-4-6")).toBe(true);
   });
   });
 
 
+  it("bailian 不用 OpenAI 兼容 /models 污染 Anthropic 通道模型列表", async () => {
+    const fetchMock = vi.fn().mockImplementation(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: "kimi-k2.6" }, { id: "deepseek-v3.2" }] }),
+        };
+      }
+      return {
+        ok: false,
+        json: async () => ({ data: [] }),
+      };
+    });
+    global.fetch = fetchMock as unknown as typeof fetch;
+
+    const models = await listModelsForService("bailian", "sk-test");
+
+    expect(resolveServiceModelsBaseUrl("bailian")).toBe("https://dashscope.aliyuncs.com/apps/anthropic");
+    expect(fetchMock).not.toHaveBeenCalledWith(
+      "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
+      expect.any(Object),
+    );
+    expect(models.some((m) => m.id === "qwen-turbo")).toBe(true);
+    expect(models.some((m) => m.id === "kimi-k2.6")).toBe(false);
+    expect(models.some((m) => m.id === "deepseek-v3.2")).toBe(false);
+  });
+
   it("未知 service 返回空数组", async () => {
   it("未知 service 返回空数组", async () => {
     const models = await listModelsForService("nonexistent-xyz");
     const models = await listModelsForService("nonexistent-xyz");
     expect(models).toEqual([]);
     expect(models).toEqual([]);

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

@@ -35,7 +35,6 @@ export const SERVICE_PRESETS: Record<string, ServicePreset> = {
     providerFamily: "anthropic",
     providerFamily: "anthropic",
     api: "anthropic-messages",
     api: "anthropic-messages",
     baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
     baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
-    modelsBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
     label: "百炼 (通义千问)",
     label: "百炼 (通义千问)",
     temperatureRange: [0, 2],
     temperatureRange: [0, 2],
     defaultTemperature: 0.7,
     defaultTemperature: 0.7,
@@ -75,8 +74,8 @@ export function resolveServicePreset(service: string): ServicePreset | undefined
     ...(legacy?.knownModels ? { knownModels: legacy.knownModels } : {}),
     ...(legacy?.knownModels ? { knownModels: legacy.knownModels } : {}),
     // piProvider 字段已从 InkosEndpoint 移除(走 provider-to-pi-ai adapter),这里只保留 legacy fallback
     // piProvider 字段已从 InkosEndpoint 移除(走 provider-to-pi-ai adapter),这里只保留 legacy fallback
     ...(legacy?.piProvider ? { piProvider: legacy.piProvider } : {}),
     ...(legacy?.piProvider ? { piProvider: legacy.piProvider } : {}),
-    ...(provider?.modelsBaseUrl ?? legacy?.modelsBaseUrl
-      ? { modelsBaseUrl: provider?.modelsBaseUrl ?? legacy?.modelsBaseUrl }
+    ...((provider ? provider.modelsBaseUrl : legacy?.modelsBaseUrl)
+      ? { modelsBaseUrl: provider ? provider.modelsBaseUrl : legacy?.modelsBaseUrl }
       : {}),
       : {}),
   };
   };
 }
 }

+ 27 - 7
packages/studio/src/api/server.test.ts

@@ -1322,7 +1322,7 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(json.error).not.toContain("Moonshot");
     expect(json.error).not.toContain("Moonshot");
   });
   });
 
 
-  it("uses the preset models baseUrl when listing Bailian models", async () => {
+  it("does not return OpenAI-compatible Bailian models from the Anthropic channel connection test", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,
       ...projectConfig,
       llm: {
       llm: {
@@ -1333,13 +1333,24 @@ describe("createStudioServer daemon lifecycle", () => {
       },
       },
     }, null, 2), "utf-8");
     }, null, 2), "utf-8");
     loadSecretsMock.mockResolvedValue({ services: { bailian: { apiKey: "sk-bailian" } } });
     loadSecretsMock.mockResolvedValue({ services: { bailian: { apiKey: "sk-bailian" } } });
+    const bailianEndpoint = endpointMocks.find((ep) => ep.id === "bailian");
+    expect(bailianEndpoint).toBeDefined();
+    Object.assign(bailianEndpoint!, {
+      checkModel: "qwen-max",
+      api: "anthropic-messages",
+      baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
+      models: [
+        { id: "qwen-max", maxOutput: 8192, contextWindowTokens: 131072, enabled: true },
+        { id: "kimi-k2.5", maxOutput: 32768, contextWindowTokens: 262144, enabled: true },
+      ],
+    });
 
 
     const fetchMock = vi.fn(async (input: string | URL | Request) => {
     const fetchMock = vi.fn(async (input: string | URL | Request) => {
       const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
       const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
       if (url === "https://dashscope.aliyuncs.com/compatible-mode/v1/models") {
       if (url === "https://dashscope.aliyuncs.com/compatible-mode/v1/models") {
         return {
         return {
           ok: true,
           ok: true,
-          json: async () => ({ data: [{ id: "qwen-max" }] }),
+          json: async () => ({ data: [{ id: "kimi-k2.6" }, { id: "deepseek-v3.2" }] }),
           text: async (): Promise<string> => "",
           text: async (): Promise<string> => "",
         };
         };
       }
       }
@@ -1364,13 +1375,22 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
 
-    const response = await app.request("http://localhost/api/v1/services/bailian/models");
+    const response = await app.request("http://localhost/api/v1/services/bailian/test", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        apiKey: "sk-bailian",
+        apiFormat: "chat",
+        stream: false,
+      }),
+    });
 
 
     expect(response.status).toBe(200);
     expect(response.status).toBe(200);
-    await expect(response.json()).resolves.toMatchObject({
-      models: [{ id: "qwen-max", name: "qwen-max" }],
-    });
-    expect(fetchMock).toHaveBeenCalledWith(
+    const body = await response.json() as { models: Array<{ id: string }> };
+    expect(body.models.map((m) => m.id)).toEqual(["qwen-max", "kimi-k2.5"]);
+    expect(body.models.some((m) => m.id === "kimi-k2.6")).toBe(false);
+    expect(body.models.some((m) => m.id === "deepseek-v3.2")).toBe(false);
+    expect(fetchMock).not.toHaveBeenCalledWith(
       "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
       "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
       expect.any(Object),
       expect.any(Object),
     );
     );

+ 10 - 2
packages/studio/src/api/server.ts

@@ -471,9 +471,12 @@ async function fetchModelsFromServiceBaseUrl(
   baseUrl: string,
   baseUrl: string,
   apiKey: string,
   apiKey: string,
 ): Promise<{ models: Array<{ id: string; name: string }>; error?: string; authFailed?: boolean }> {
 ): Promise<{ models: Array<{ id: string; name: string }>; error?: string; authFailed?: boolean }> {
+  const endpoint = isCustomServiceId(serviceId)
+    ? undefined
+    : getAllEndpoints().find((ep) => ep.id === serviceId);
   const modelsBaseUrl = isCustomServiceId(serviceId)
   const modelsBaseUrl = isCustomServiceId(serviceId)
     ? baseUrl
     ? baseUrl
-    : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl;
+    : endpoint?.modelsBaseUrl ?? (endpoint ? baseUrl : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl);
   const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   try {
   try {
     const res = await fetch(modelsUrl, {
     const res = await fetch(modelsUrl, {
@@ -583,7 +586,12 @@ async function probeServiceCapabilities(args: {
         await chatCompletion(client, model, [{ role: "user", content: "ping" }], { maxTokens: 2048 });
         await chatCompletion(client, model, [{ role: "user", content: "ping" }], { maxTokens: 2048 });
         const models = discoveredModels.length > 0
         const models = discoveredModels.length > 0
           ? discoveredModels
           ? discoveredModels
-          : preset?.knownModels?.map((id) => ({ id, name: id })) ?? [{ id: model, name: model }];
+          : endpoint?.models
+            .filter((m) => m.enabled !== false)
+            .filter((m) => isTextChatModelId(m.id))
+            .map((m) => ({ id: m.id, name: m.id }))
+            ?? preset?.knownModels?.map((id) => ({ id, name: id }))
+            ?? [{ id: model, name: model }];
         return {
         return {
           ok: true,
           ok: true,
           models,
           models,