Explorar o código

fix(studio): unify service config contract and custom provider flow

Ma hai 6 meses
pai
achega
6efd460906

+ 74 - 1
packages/core/src/__tests__/config-loader.test.ts

@@ -1,4 +1,4 @@
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { afterEach, describe, expect, it } from "vitest";
@@ -77,4 +77,77 @@ describe("loadProjectConfig local provider auth", () => {
     await writeFile(join(root, ".env"), "", "utf-8");
     await expect(loadProjectConfig(root)).rejects.toThrow(/INKOS_LLM_API_KEY not set/i);
   });
+
+  it("loads service-based config using defaultModel and project secrets", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-services-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "service-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        services: [
+          { service: "moonshot", temperature: 1, maxTokens: 4096 },
+        ],
+        defaultModel: "kimi-k2.5",
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { moonshot: { apiKey: "sk-moon" } } }, null, 2),
+      "utf-8",
+    );
+
+    const config = await loadProjectConfig(root);
+
+    expect(config.llm.service).toBe("moonshot");
+    expect(config.llm.provider).toBe("openai");
+    expect(config.llm.baseUrl).toBe("https://api.moonshot.cn/v1");
+    expect(config.llm.model).toBe("kimi-k2.5");
+    expect(config.llm.apiKey).toBe("sk-moon");
+    expect(config.llm.temperature).toBe(1);
+    expect(config.llm.maxTokens).toBe(4096);
+  });
+
+  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) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "custom-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        services: [
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9 },
+        ],
+        defaultModel: "corp-chat",
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }, null, 2),
+      "utf-8",
+    );
+
+    const config = await loadProjectConfig(root);
+
+    expect(config.llm.service).toBe("custom");
+    expect(config.llm.provider).toBe("custom");
+    expect(config.llm.baseUrl).toBe("https://llm.internal.corp/v1");
+    expect(config.llm.model).toBe("corp-chat");
+    expect(config.llm.apiKey).toBe("sk-corp");
+    expect(config.llm.temperature).toBe(0.9);
+  });
 });

+ 10 - 0
packages/core/src/models/project.ts

@@ -1,5 +1,13 @@
 import { z } from "zod";
 
+const LLMServiceEntrySchema = z.object({
+  service: z.string().min(1),
+  name: z.string().min(1).optional(),
+  baseUrl: z.string().url().optional(),
+  temperature: z.number().min(0).max(2).optional(),
+  maxTokens: z.number().int().min(1).optional(),
+});
+
 export const LLMConfigSchema = z.object({
   provider: z.enum(["anthropic", "openai", "custom"]),
   service: z.string().default("custom"),
@@ -13,6 +21,8 @@ export const LLMConfigSchema = z.object({
   headers: z.record(z.string()).optional(),
   apiFormat: z.enum(["chat", "responses"]).default("chat"),
   stream: z.boolean().default(true),
+  services: z.array(LLMServiceEntrySchema).optional(),
+  defaultModel: z.string().min(1).optional(),
 });
 
 export type LLMConfig = z.infer<typeof LLMConfigSchema>;

+ 121 - 1
packages/core/src/utils/config-loader.ts

@@ -2,10 +2,20 @@ import { readFile, access } from "node:fs/promises";
 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";
 
 export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
 export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
 
+interface ServiceConfigEntry {
+  readonly service: string;
+  readonly name?: string;
+  readonly baseUrl?: string;
+  readonly temperature?: number;
+  readonly maxTokens?: number;
+}
+
 export function isApiKeyOptionalForEndpoint(params: {
   readonly provider?: string | undefined;
   readonly baseUrl?: string | undefined;
@@ -70,6 +80,46 @@ export async function loadProjectConfig(
   // .env overrides inkos.json for LLM settings
   const env = process.env;
   const llm = (config.llm ?? {}) as Record<string, unknown>;
+
+  const normalizedServices = normalizeServiceEntries(llm.services);
+  if (normalizedServices.length > 0) {
+    llm.services = normalizedServices;
+
+    const selectedEntry = selectServiceEntry(normalizedServices, llm.service);
+    const selectedServiceId = selectedEntry ? serviceEntryKey(selectedEntry) : undefined;
+
+    if (selectedEntry) {
+      llm.service = selectedEntry.service;
+
+      if (!(typeof llm.model === "string" && llm.model.length > 0) && typeof llm.defaultModel === "string" && llm.defaultModel.length > 0) {
+        llm.model = llm.defaultModel;
+      }
+
+      if (!(typeof llm.baseUrl === "string" && llm.baseUrl.length > 0)) {
+        llm.baseUrl = selectedEntry.baseUrl ?? resolveServicePreset(selectedEntry.service)?.baseUrl ?? "";
+      }
+
+      if (!(typeof llm.provider === "string" && llm.provider.length > 0)) {
+        llm.provider = deriveProviderFromService(selectedEntry.service);
+      }
+
+      if (llm.temperature === undefined && selectedEntry.temperature !== undefined) {
+        llm.temperature = selectedEntry.temperature;
+      }
+
+      if (llm.maxTokens === undefined && selectedEntry.maxTokens !== undefined) {
+        llm.maxTokens = selectedEntry.maxTokens;
+      }
+
+      if (selectedServiceId && !env.INKOS_LLM_API_KEY) {
+        const secretApiKey = await getServiceApiKey(root, selectedServiceId);
+        if (secretApiKey) {
+          llm.apiKey = secretApiKey;
+        }
+      }
+    }
+  }
+
   if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
   if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
   if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
@@ -101,7 +151,7 @@ export async function loadProjectConfig(
   if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
 
   // API key ONLY from env — never stored in inkos.json
-  const apiKey = env.INKOS_LLM_API_KEY;
+  const apiKey = env.INKOS_LLM_API_KEY || (typeof llm.apiKey === "string" ? llm.apiKey : "");
   const provider = typeof llm.provider === "string" ? llm.provider : undefined;
   const baseUrl = typeof llm.baseUrl === "string" ? llm.baseUrl : undefined;
   const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
@@ -127,6 +177,76 @@ export async function loadProjectConfig(
   return ProjectConfigSchema.parse(config);
 }
 
+function normalizeServiceEntries(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",
+        ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
+        ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
+        ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
+        ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
+      }));
+  }
+
+  if (raw && typeof raw === "object") {
+    return Object.entries(raw as Record<string, unknown>)
+      .filter(([, value]) => value && typeof value === "object")
+      .map(([serviceId, value]) => normalizeServiceEntryFromPatch(serviceId, value as Record<string, unknown>));
+  }
+
+  return [];
+}
+
+function normalizeServiceEntryFromPatch(serviceId: string, value: Record<string, unknown>): ServiceConfigEntry {
+  if (serviceId.startsWith("custom:")) {
+    return {
+      service: "custom",
+      name: decodeURIComponent(serviceId.slice("custom:".length)),
+      ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
+      ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
+      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+    };
+  }
+
+  if (serviceId === "custom") {
+    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.temperature === "number" ? { temperature: value.temperature } : {}),
+      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+    };
+  }
+
+  return {
+    service: serviceId,
+    ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
+    ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+  };
+}
+
+function selectServiceEntry(
+  services: readonly ServiceConfigEntry[],
+  configuredService: unknown,
+): ServiceConfigEntry | undefined {
+  if (typeof configuredService === "string" && configuredService.length > 0) {
+    return services.find((entry) => entry.service === configuredService || serviceEntryKey(entry) === configuredService) ?? services[0];
+  }
+  return services[0];
+}
+
+function serviceEntryKey(entry: ServiceConfigEntry): string {
+  return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
+}
+
+function deriveProviderFromService(service: string): "anthropic" | "openai" | "custom" {
+  if (service === "anthropic") return "anthropic";
+  if (service === "custom") return "custom";
+  return "openai";
+}
+
 function isPrivateIpv4(hostname: string): boolean {
   const parts = hostname.split(".").map((segment) => Number.parseInt(segment, 10));
   if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {

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

@@ -514,6 +514,93 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("merges service config patches instead of overwriting existing services", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "moonshot", temperature: 1, maxTokens: 4096 },
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9 },
+        ],
+        defaultModel: "kimi-k2.5",
+      },
+    }, null, 2), "utf-8");
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const save = await app.request("http://localhost/api/v1/services/config", {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        services: {
+          moonshot: {
+            temperature: 0.5,
+            maxTokens: 2048,
+          },
+        },
+      }),
+    });
+
+    expect(save.status).toBe(200);
+
+    const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
+    expect(raw.llm.services).toEqual([
+      { service: "moonshot", temperature: 0.5, maxTokens: 2048 },
+      { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9 },
+    ]);
+  });
+
+  it("tests and lists models for custom services using baseUrl and stored config", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1" },
+        ],
+        defaultModel: "corp-chat",
+      },
+    }, null, 2), "utf-8");
+    loadSecretsMock.mockResolvedValue({
+      services: {
+        "custom:内网GPT": { apiKey: "sk-corp" },
+      },
+    });
+    getServiceApiKeyMock.mockResolvedValue("sk-corp");
+
+    const fetchMock = vi.fn()
+      .mockResolvedValueOnce({
+        ok: true,
+        json: async () => ({ data: [{ id: "corp-chat" }] }),
+        text: async () => "",
+      })
+      .mockResolvedValueOnce({
+        ok: true,
+        json: async () => ({ data: [{ id: "corp-chat" }] }),
+      });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const testResponse = await app.request("http://localhost/api/v1/services/custom%3A%E5%86%85%E7%BD%91GPT/test", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ apiKey: "sk-corp", baseUrl: "https://llm.internal.corp/v1" }),
+    });
+    expect(testResponse.status).toBe(200);
+    await expect(testResponse.json()).resolves.toMatchObject({
+      ok: true,
+      models: [{ id: "corp-chat", name: "corp-chat" }],
+    });
+
+    const modelsResponse = await app.request("http://localhost/api/v1/services/custom%3A%E5%86%85%E7%BD%91GPT/models");
+    expect(modelsResponse.status).toBe(200);
+    await expect(modelsResponse.json()).resolves.toMatchObject({
+      models: [{ id: "corp-chat", name: "corp-chat" }],
+    });
+  });
+
   it("rejects create requests when a complete book with the same id already exists", async () => {
     await mkdir(join(root, "books", "existing-book", "story"), { recursive: true });
     await writeFile(join(root, "books", "existing-book", "book.json"), JSON.stringify({ id: "existing-book" }), "utf-8");

+ 147 - 34
packages/studio/src/api/server.ts

@@ -19,9 +19,12 @@ import {
   persistBookSession,
   appendBookSessionMessage,
   runAgentSession,
+  resolveServicePreset,
   resolveServiceModel,
   loadSecrets,
   saveSecrets,
+  getServiceApiKey,
+  listModelsForService,
   type ResolvedModel,
   type PipelineConfig,
   type ProjectConfig,
@@ -110,12 +113,113 @@ type EventHandler = (event: string, data: unknown) => void;
 const subscribers = new Set<EventHandler>();
 const bookCreateStatus = new Map<string, { status: "creating" | "error"; error?: string }>();
 
+interface ServiceConfigEntry {
+  service: string;
+  name?: string;
+  baseUrl?: string;
+  temperature?: number;
+  maxTokens?: number;
+}
+
 function broadcast(event: string, data: unknown): void {
   for (const handler of subscribers) {
     handler(event, data);
   }
 }
 
+function isCustomServiceId(serviceId: string): boolean {
+  return serviceId === "custom" || serviceId.startsWith("custom:");
+}
+
+function serviceConfigKey(entry: ServiceConfigEntry): string {
+  return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
+}
+
+function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>): ServiceConfigEntry {
+  if (serviceId.startsWith("custom:")) {
+    return {
+      service: "custom",
+      name: decodeURIComponent(serviceId.slice("custom:".length)),
+      ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
+      ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
+      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+    };
+  }
+
+  if (serviceId === "custom") {
+    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.temperature === "number" ? { temperature: value.temperature } : {}),
+      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+    };
+  }
+
+  return {
+    service: serviceId,
+    ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
+    ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+  };
+}
+
+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",
+        ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
+        ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
+        ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
+        ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
+      }));
+  }
+
+  if (raw && typeof raw === "object") {
+    return Object.entries(raw as Record<string, unknown>)
+      .filter(([, value]) => value && typeof value === "object")
+      .map(([serviceId, value]) => normalizeServiceEntry(serviceId, value as Record<string, unknown>));
+  }
+
+  return [];
+}
+
+function mergeServiceConfig(existing: ServiceConfigEntry[], updates: ServiceConfigEntry[]): ServiceConfigEntry[] {
+  const merged = new Map(existing.map((entry) => [serviceConfigKey(entry), entry]));
+  for (const update of updates) {
+    merged.set(serviceConfigKey(update), update);
+  }
+  return [...merged.values()];
+}
+
+async function loadRawConfig(root: string): Promise<Record<string, unknown>> {
+  const configPath = join(root, "inkos.json");
+  const raw = await readFile(configPath, "utf-8");
+  return JSON.parse(raw) as Record<string, unknown>;
+}
+
+async function saveRawConfig(root: string, config: Record<string, unknown>): Promise<void> {
+  await writeFile(join(root, "inkos.json"), JSON.stringify(config, null, 2), "utf-8");
+}
+
+async function resolveConfiguredServiceBaseUrl(root: string, serviceId: string, inlineBaseUrl?: string): Promise<string | undefined> {
+  if (inlineBaseUrl?.trim()) return inlineBaseUrl.trim();
+
+  if (!isCustomServiceId(serviceId)) {
+    return resolveServicePreset(serviceId)?.baseUrl;
+  }
+
+  try {
+    const config = await loadRawConfig(root);
+    const services = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services);
+    const matched = services.find((entry) => serviceConfigKey(entry) === serviceId);
+    return matched?.baseUrl;
+  } catch {
+    return undefined;
+  }
+}
+
 // --- Server factory ---
 
 export function createStudioServer(initialConfig: ProjectConfig, root: string) {
@@ -507,7 +611,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   // --- Model discovery ---
 
   app.get("/api/v1/services", async (c) => {
-    const { resolveServicePreset } = await import("@actalk/inkos-core");
     const secrets = await loadSecrets(root);
 
     const SERVICE_KEYS = [
@@ -527,10 +630,8 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
     // Add custom services from inkos.json
     try {
-      const configPath = join(root, "inkos.json");
-      const raw = await readFile(configPath, "utf-8");
-      const config = JSON.parse(raw);
-      for (const svc of config.llm?.services ?? []) {
+      const config = await loadRawConfig(root);
+      for (const svc of normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services)) {
         if (svc.service === "custom") {
           const secretKey = `custom:${svc.name}`;
           services.push({
@@ -546,43 +647,42 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   });
 
   app.get("/api/v1/services/config", async (c) => {
-    const configPath = join(root, "inkos.json");
-    const raw = await readFile(configPath, "utf-8");
-    const config = JSON.parse(raw);
-    const services = config.llm?.services ?? [];
-    return c.json({ services, defaultModel: config.llm?.defaultModel ?? null });
+    const config = await loadRawConfig(root);
+    const llm = (config.llm as Record<string, unknown> | undefined) ?? {};
+    const services = normalizeServiceConfig(llm.services);
+    return c.json({ services, defaultModel: llm.defaultModel ?? null });
   });
 
   app.put("/api/v1/services/config", async (c) => {
-    const body = await c.req.json<{ services: any[]; defaultModel?: string }>();
-    const configPath = join(root, "inkos.json");
-    const raw = await readFile(configPath, "utf-8");
-    const config = JSON.parse(raw);
+    const body = await c.req.json<{ services: unknown; defaultModel?: string }>();
+    const config = await loadRawConfig(root);
     config.llm = config.llm ?? {};
-    config.llm.services = body.services;
+    const llm = config.llm as Record<string, unknown>;
+    const existingServices = normalizeServiceConfig(llm.services);
+    const incomingServices = normalizeServiceConfig(body.services);
+    llm.services = mergeServiceConfig(existingServices, incomingServices);
     if (body.defaultModel !== undefined) {
-      config.llm.defaultModel = body.defaultModel;
+      llm.defaultModel = body.defaultModel;
     }
-    await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
+    await saveRawConfig(root, config);
     return c.json({ ok: true });
   });
 
   app.post("/api/v1/services/:service/test", async (c) => {
-    const { resolveServicePreset } = await import("@actalk/inkos-core");
     const service = c.req.param("service");
-    const { apiKey } = await c.req.json<{ apiKey: string }>();
+    const { apiKey, baseUrl } = await c.req.json<{ apiKey: string; baseUrl?: string }>();
 
     if (!apiKey?.trim()) {
       return c.json({ ok: false, error: "API Key 不能为空" }, 400);
     }
 
-    const preset = resolveServicePreset(service);
-    if (!preset?.baseUrl) {
+    const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service, baseUrl);
+    if (!resolvedBaseUrl) {
       return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
     }
 
     // Call the real /models API — no fallback
-    const modelsUrl = preset.baseUrl.replace(/\/$/, "") + "/models";
+    const modelsUrl = resolvedBaseUrl.replace(/\/$/, "") + "/models";
     try {
       const res = await fetch(modelsUrl, {
         headers: { Authorization: `Bearer ${apiKey.trim()}` },
@@ -627,20 +727,19 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   });
 
   app.get("/api/v1/services/:service/models", async (c) => {
-    const { resolveServicePreset, getServiceApiKey } = await import("@actalk/inkos-core");
     const service = c.req.param("service");
     const apiKey = c.req.query("apiKey") || await getServiceApiKey(root, service);
 
     // No key = no models (don't fallback to built-in list)
     if (!apiKey) return c.json({ models: [] });
 
-    const preset = resolveServicePreset(service);
-    if (!preset?.baseUrl) return c.json({ models: [] });
+    const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
+    if (!resolvedBaseUrl) return c.json({ models: [] });
 
     // Call real API only
     let models: Array<{ id: string; name: string }> = [];
     try {
-      const modelsUrl = preset.baseUrl.replace(/\/$/, "") + "/models";
+      const modelsUrl = resolvedBaseUrl.replace(/\/$/, "") + "/models";
       const res = await fetch(modelsUrl, {
         headers: { Authorization: `Bearer ${apiKey}` },
         signal: AbortSignal.timeout(10_000),
@@ -882,7 +981,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       if (reqService && reqModel) {
         // 1. Frontend explicitly selected a service+model — fail loudly if no key
         try {
-          const resolved = await resolveServiceModel(reqService, reqModel, root);
+          const resolved = await resolveServiceModel(
+            reqService,
+            reqModel,
+            root,
+            await resolveConfiguredServiceBaseUrl(root, reqService),
+          );
           resolvedModel = resolved.model;
           resolvedApiKey = resolved.apiKey;
         } catch (e: any) {
@@ -901,11 +1005,16 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         // 2. Try defaultModel from new config format
         const rawConfig = config.llm as unknown as Record<string, unknown>;
         const defaultModel = rawConfig.defaultModel as string | undefined;
-        const servicesArr = Array.isArray(rawConfig.services) ? rawConfig.services : [];
-        const firstService = (servicesArr as Array<{ service: string }>)[0];
+        const servicesArr = normalizeServiceConfig(rawConfig.services);
+        const firstService = servicesArr[0];
         if (firstService?.service && defaultModel) {
           try {
-            const resolved = await resolveServiceModel(firstService.service, defaultModel, root);
+            const resolved = await resolveServiceModel(
+              serviceConfigKey(firstService),
+              defaultModel,
+              root,
+              firstService.baseUrl,
+            );
             resolvedModel = resolved.model;
             resolvedApiKey = resolved.apiKey;
           } catch { /* fall through */ }
@@ -916,12 +1025,16 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         // 3. Try first connected service from secrets
         const secrets = await loadSecrets(root);
         for (const [svcName, svcData] of Object.entries(secrets.services)) {
-          if (svcData?.apiKey && !svcName.startsWith("custom:")) {
+          if (svcData?.apiKey) {
             try {
-              const { listModelsForService: listModels } = await import("@actalk/inkos-core");
-              const models = await listModels(svcName, svcData.apiKey);
+              const models = await listModelsForService(svcName, svcData.apiKey);
               if (models.length > 0) {
-                const resolved = await resolveServiceModel(svcName, models[0].id, root);
+                const resolved = await resolveServiceModel(
+                  svcName,
+                  models[0].id,
+                  root,
+                  await resolveConfiguredServiceBaseUrl(root, svcName),
+                );
                 resolvedModel = resolved.model;
                 resolvedApiKey = resolved.apiKey;
                 break;

+ 57 - 14
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -44,7 +44,8 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   useEffect(() => { void fetchServices(); }, [fetchServices]);
 
   const svc = services.find((s) => s.service === serviceId);
-  const isCustom = serviceId === "custom";
+  const isCustom = serviceId === "custom" || serviceId.startsWith("custom:");
+  const persistedCustomName = serviceId.startsWith("custom:") ? decodeURIComponent(serviceId.slice("custom:".length)) : "";
 
   // -- Local form state --
   const [apiKey, setApiKey] = useState("");
@@ -57,6 +58,30 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   // -- Unified connection status --
   const [status, setStatus] = useState<ConnectionStatus>({ state: "idle" });
 
+  useEffect(() => {
+    let cancelled = false;
+    void fetchJson<{ services: Array<Record<string, unknown>> }>("/services/config")
+      .then((data) => {
+        if (cancelled) return;
+        const matched = (data.services ?? []).find((entry) => {
+          if (typeof entry.service !== "string") return false;
+          if (serviceId.startsWith("custom:")) {
+            return entry.service === "custom" && `custom:${String(entry.name ?? "")}` === serviceId;
+          }
+          return entry.service === serviceId;
+        });
+        if (!matched) return;
+        if (isCustom) {
+          setCustomName(String(matched.name ?? persistedCustomName));
+          setBaseUrl(String(matched.baseUrl ?? ""));
+        }
+        if (typeof matched.temperature === "number") setTemperature(String(matched.temperature));
+        if (typeof matched.maxTokens === "number") setMaxTokens(String(matched.maxTokens));
+      })
+      .catch(() => {});
+    return () => { cancelled = true; };
+  }, [isCustom, persistedCustomName, serviceId]);
+
   // Load models on mount if connected
   useEffect(() => {
     if (svc?.connected) {
@@ -72,7 +97,9 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     }
   }, [svc?.connected, serviceId]);
 
-  const label = isCustom ? (customName || "自定义服务") : (svc?.label ?? serviceId);
+  const resolvedCustomName = persistedCustomName || customName.trim() || "Custom";
+  const effectiveServiceId = isCustom ? `custom:${resolvedCustomName}` : serviceId;
+  const label = isCustom ? (customName || persistedCustomName || "自定义服务") : (svc?.label ?? serviceId);
 
   if (loading) return <DetailSkeleton />;
 
@@ -88,20 +115,31 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
       setStatus({ state: "error", message: "请先输入 API Key" });
       return;
     }
+    if (isCustom && !baseUrl.trim()) {
+      setStatus({ state: "error", message: "请先填写 Base URL" });
+      return;
+    }
     setApiKey(trimmedKey);
     setStatus({ state: "testing" });
     try {
       const result = await fetchJson<{ ok: boolean; models?: ModelInfo[]; modelCount?: number; error?: string }>(
-        `/services/${encodeURIComponent(serviceId)}/test`,
-        { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey: trimmedKey }) },
+        `/services/${encodeURIComponent(effectiveServiceId)}/test`,
+        {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({
+            apiKey: trimmedKey,
+            ...(isCustom ? { baseUrl: baseUrl.trim() } : {}),
+          }),
+        },
       );
       if (result.ok) {
         const models = result.models ?? [];
         setStatus({ state: "connected", models });
-        setStoreModels(serviceId, models); // Write to global store
+        setStoreModels(effectiveServiceId, models); // Write to global store
       } else {
         setStatus({ state: "error", message: result.error ?? "连接失败" });
-        clearStoreModels(serviceId);
+        clearStoreModels(effectiveServiceId);
       }
     } catch (e) {
       setStatus({ state: "error", message: e instanceof Error ? e.message : "连接失败" });
@@ -111,10 +149,14 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   const handleSave = async () => {
     const trimmedKey = apiKey.trim();
     setApiKey(trimmedKey);
+    if (isCustom && !baseUrl.trim()) {
+      setStatus({ state: "error", message: "请先填写 Base URL" });
+      return;
+    }
     setStatus({ state: "saving" });
     try {
       // Save key (empty = delete)
-      await fetchJson(`/services/${encodeURIComponent(serviceId)}/secret`, {
+      await fetchJson(`/services/${encodeURIComponent(effectiveServiceId)}/secret`, {
         method: "PUT",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ apiKey: trimmedKey }),
@@ -124,13 +166,14 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         method: "PUT",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({
-          services: {
-            [serviceId]: {
+          services: [
+            {
+              service: isCustom ? "custom" : serviceId,
               temperature: parseFloat(temperature),
               maxTokens: parseInt(maxTokens, 10),
-              ...(isCustom ? { name: customName, baseUrl } : {}),
+              ...(isCustom ? { name: resolvedCustomName, baseUrl: baseUrl.trim() } : {}),
             },
-          },
+          ],
         }),
       });
       setStatus({ state: "saved" });
@@ -139,19 +182,19 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
       setTimeout(() => {
         if (trimmedKey) {
           setStatus({ state: "testing" });
-          fetchJson<{ models: ModelInfo[] }>(`/services/${encodeURIComponent(serviceId)}/models`)
+          fetchJson<{ models: ModelInfo[] }>(`/services/${encodeURIComponent(effectiveServiceId)}/models`)
             .then((data) => {
               const m = data.models ?? [];
               if (m.length > 0) {
                 setStatus({ state: "connected", models: m });
-                setStoreModels(serviceId, m); // Cache for chat picker
+                setStoreModels(effectiveServiceId, m); // Cache for chat picker
               } else {
                 setStatus({ state: "idle" });
               }
             })
             .catch(() => setStatus({ state: "idle" }));
         } else {
-          clearStoreModels(serviceId);
+          clearStoreModels(effectiveServiceId);
           setStatus({ state: "idle" });
         }
       }, 1500);