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

feat(studio): 接入全量服务与聚合模型接口

fanghanjun пре 6 месеци
родитељ
комит
97f8197b95
3 измењених фајлова са 191 додато и 19 уклоњено
  1. 2 0
      packages/core/src/index.ts
  2. 128 3
      packages/studio/src/api/server.test.ts
  3. 61 16
      packages/studio/src/api/server.ts

+ 2 - 0
packages/core/src/index.ts

@@ -188,6 +188,8 @@ export {
 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";
+export { getAllEndpoints, getEndpoint, type InkosEndpoint, type InkosModel, type EndpointGroup } from "./llm/providers/index.js";
+export { probeModelsFromUpstream, type ProbedModel } from "./llm/providers/probe.js";
 
 // Agents
 export { BaseAgent, type AgentContext } from "./agents/base.js";

+ 128 - 3
packages/studio/src/api/server.test.ts

@@ -53,13 +53,13 @@ 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 listModelsForServiceMock = vi.fn(async (service: string, apiKey?: string, liveBaseUrl?: string) => {
   const preset = resolveServicePresetMock(service);
-  if (!preset || service === "custom") return [];
+  if (!preset) return [];
   if (preset.knownModels.length > 0) {
     return preset.knownModels.map((id) => ({ id, name: id, reasoning: false, contextWindow: 0 }));
   }
-  const modelsBaseUrl = resolveServiceModelsBaseUrlMock(service);
+  const modelsBaseUrl = liveBaseUrl ?? resolveServiceModelsBaseUrlMock(service);
   if (!apiKey || !modelsBaseUrl) return [];
   const res = await fetch(`${modelsBaseUrl.replace(/\/$/, "")}/models`, {
     headers: { Authorization: `Bearer ${apiKey}` },
@@ -74,6 +74,36 @@ const listModelsForServiceMock = vi.fn(async (service: string, apiKey?: string)
     contextWindow: 0,
   }));
 });
+const endpointIdsByGroup = {
+  overseas: ["anthropic", "google", "mistral", "openai", "xai"],
+  china: [
+    "ai360", "baichuan", "bailian", "deepseek", "hunyuan", "internlm", "longcat",
+    "minimax", "moonshot", "sensenova", "spark", "stepfun", "tencentcloud",
+    "volcengine", "wenxin", "xiaomimimo", "zeroone", "zhipu",
+  ],
+  aggregator: ["giteeai", "infiniai", "modelscope", "newapi", "openrouter", "ppio", "qiniu", "siliconcloud"],
+  local: ["githubCopilot", "ollama"],
+  codingPlan: [
+    "astronCodingPlan", "bailianCodingPlan", "glmCodingPlan", "kimiCodingPlan",
+    "minimaxCodingPlan", "opencodeCodingPlan", "volcengineCodingPlan",
+  ],
+} as const;
+const endpointMocks = [
+  ...Object.entries(endpointIdsByGroup).flatMap(([group, ids]) => ids.map((id) => ({
+    id,
+    label: id,
+    group,
+    models: [
+      { id: `${id}-model`, maxOutput: 4096, contextWindowTokens: 32768, enabled: true },
+      { id: `${id}-disabled`, maxOutput: 4096, contextWindowTokens: 32768, enabled: false },
+    ],
+  }))),
+  { id: "custom", label: "自定义端点", models: [] },
+];
+const getAllEndpointsMock = vi.fn(() => endpointMocks);
+const probeModelsFromUpstreamMock = vi.fn(async () => [
+  { id: "custom-model", name: "custom-model", contextWindow: 0 },
+]);
 
 const logger = {
   child: () => logger,
@@ -189,6 +219,8 @@ vi.mock("@actalk/inkos-core", () => {
     saveSecrets: saveSecretsMock,
     getServiceApiKey: getServiceApiKeyMock,
     listModelsForService: listModelsForServiceMock,
+    getAllEndpoints: getAllEndpointsMock,
+    probeModelsFromUpstream: probeModelsFromUpstreamMock,
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
   };
 });
@@ -355,6 +387,8 @@ describe("createStudioServer daemon lifecycle", () => {
     resolveServiceProviderFamilyMock.mockClear();
     resolveServiceModelsBaseUrlMock.mockClear();
     listModelsForServiceMock.mockClear();
+    getAllEndpointsMock.mockClear();
+    probeModelsFromUpstreamMock.mockClear();
     // Default BookSession for agent tests
     const defaultBookSession = {
       sessionId: "agent-session-1",
@@ -646,6 +680,97 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("returns all bank services with group fields and custom services", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1" },
+        ],
+      },
+    }, null, 2), "utf-8");
+    loadSecretsMock.mockResolvedValue({
+      services: {
+        moonshot: { apiKey: "sk-moonshot" },
+        "custom:内网GPT": { apiKey: "sk-corp" },
+      },
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const res = await app.request("http://localhost/api/v1/services");
+    expect(res.status).toBe(200);
+    const body = await res.json() as { services: Array<{ service: string; group?: string; connected: boolean }> };
+    const bank = body.services.filter((s) => !s.service.startsWith("custom"));
+    expect(bank.length).toBe(40);
+    expect(bank.every((s) => typeof s.group === "string")).toBe(true);
+    expect(bank.filter((s) => s.group === "overseas")).toHaveLength(5);
+    expect(bank.filter((s) => s.group === "china")).toHaveLength(18);
+    expect(bank.filter((s) => s.group === "aggregator")).toHaveLength(8);
+    expect(bank.filter((s) => s.group === "local")).toHaveLength(2);
+    expect(bank.filter((s) => s.group === "codingPlan")).toHaveLength(7);
+    expect(body.services.find((s) => s.service === "moonshot")?.connected).toBe(true);
+    expect(body.services.find((s) => s.service === "custom:内网GPT")).toMatchObject({
+      connected: true,
+    });
+  });
+
+  it("returns connected bank model groups from the local bank", async () => {
+    loadSecretsMock.mockResolvedValue({
+      services: {
+        moonshot: { apiKey: "sk-moonshot" },
+      },
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/models");
+    expect(response.status).toBe(200);
+    const body = await response.json() as { groups: Array<{ service: string; models: Array<{ id: string }> }> };
+    expect(body.groups.map((g) => g.service)).toEqual(["moonshot"]);
+    expect(body.groups[0]?.models).toEqual([
+      { id: "moonshot-model", name: "moonshot-model", maxOutput: 4096, contextWindow: 32768 },
+    ]);
+  });
+
+  it("returns custom model groups through the slow probe path", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1" },
+        ],
+      },
+    }, null, 2), "utf-8");
+    loadSecretsMock.mockResolvedValue({
+      services: {
+        "custom:内网GPT": { apiKey: "sk-corp" },
+      },
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/models/custom");
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toEqual({
+      groups: [
+        {
+          service: "custom:内网GPT",
+          label: "内网GPT",
+          models: [{ id: "custom-model", name: "custom-model", contextWindow: 0 }],
+        },
+      ],
+    });
+    expect(probeModelsFromUpstreamMock).toHaveBeenCalledWith(
+      "https://llm.internal.corp/v1",
+      "sk-corp",
+      10_000,
+    );
+  });
+
   it("merges service config patches instead of overwriting existing services", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,

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

@@ -32,6 +32,8 @@ import {
   saveSecrets,
   getServiceApiKey,
   listModelsForService,
+  getAllEndpoints,
+  probeModelsFromUpstream,
   chatCompletion,
   buildExportArtifact,
   GLOBAL_ENV_PATH,
@@ -919,21 +921,15 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
   app.get("/api/v1/services", async (c) => {
     const secrets = await loadSecrets(root);
-
-    const SERVICE_KEYS = [
-      "openai", "anthropic", "deepseek", "moonshot", "minimax",
-      "bailian", "zhipu", "siliconflow", "ppio", "openrouter", "ollama",
-    ];
-
-    // Fast: only check connection status from secrets, no external API calls
-    const services = SERVICE_KEYS.map((key) => {
-      const preset = resolveServicePreset(key);
-      return {
-        service: key,
-        label: preset?.label ?? key,
-        connected: Boolean(secrets.services[key]?.apiKey),
-      };
-    });
+    const endpoints = getAllEndpoints().filter((ep) => ep.id !== "custom");
+
+    // Fast: only check connection status from secrets, no external API calls.
+    const services = endpoints.map((ep) => ({
+      service: ep.id,
+      label: ep.label,
+      group: ep.group,
+      connected: Boolean(secrets.services[ep.id]?.apiKey),
+    }));
 
     // Add custom services from inkos.json
     try {
@@ -944,6 +940,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           services.push({
             service: secretKey,
             label: svc.name ?? "Custom",
+            group: undefined,
             connected: Boolean(secrets.services[secretKey]?.apiKey),
           });
         }
@@ -1070,6 +1067,54 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     });
   });
 
+  app.get("/api/v1/services/models", async (c) => {
+    const secrets = await loadSecrets(root);
+    const endpoints = getAllEndpoints()
+      .filter((ep) => ep.id !== "custom" && Boolean(secrets.services[ep.id]?.apiKey));
+
+    const groups = endpoints.map((ep) => ({
+      service: ep.id,
+      label: ep.label,
+      models: ep.models
+        .filter((m) => m.enabled !== false)
+        .map((m) => ({
+          id: m.id,
+          name: m.id,
+          ...(typeof m.maxOutput === "number" ? { maxOutput: m.maxOutput } : {}),
+          ...(m.contextWindowTokens > 0 ? { contextWindow: m.contextWindowTokens } : {}),
+        })),
+    }));
+
+    return c.json({ groups });
+  });
+
+  app.get("/api/v1/services/models/custom", async (c) => {
+    const secrets = await loadSecrets(root);
+    let config: Record<string, unknown> = {};
+    try {
+      config = await loadRawConfig(root);
+    } catch {
+      // no config file
+    }
+
+    const customs = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services)
+      .filter((s) => s.service === "custom")
+      .map((s) => ({
+        id: `custom:${s.name ?? "Custom"}`,
+        baseUrl: s.baseUrl ?? "",
+        label: s.name ?? "Custom",
+      }))
+      .filter((s) => s.baseUrl && Boolean(secrets.services[s.id]?.apiKey));
+
+    const groups = await Promise.all(customs.map(async (s) => ({
+      service: s.id,
+      label: s.label,
+      models: await probeModelsFromUpstream(s.baseUrl, secrets.services[s.id].apiKey, 10_000),
+    })));
+
+    return c.json({ groups });
+  });
+
   app.get("/api/v1/services/:service/models", async (c) => {
     const service = c.req.param("service");
     const refresh = c.req.query("refresh") === "1";
@@ -1093,7 +1138,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     const enriched = await listModelsForService(
       isCustomServiceId(service) ? "custom" : service,
       apiKey,
-      resolvedBaseUrl ?? undefined,
+      isCustomServiceId(service) ? resolvedBaseUrl ?? undefined : undefined,
     );
     const models = enriched.map((m) => ({
       id: m.id,