فهرست منبع

feat(studio): support custom API format and non-stream transport

Ma 6 ماه پیش
والد
کامیت
c65b9489fe

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

@@ -128,7 +128,7 @@ describe("loadProjectConfig local provider auth", () => {
       language: "zh",
       language: "zh",
       llm: {
       llm: {
         services: [
         services: [
-          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9 },
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
         ],
         ],
         defaultModel: "corp-chat",
         defaultModel: "corp-chat",
       },
       },
@@ -149,6 +149,8 @@ describe("loadProjectConfig local provider auth", () => {
     expect(config.llm.model).toBe("corp-chat");
     expect(config.llm.model).toBe("corp-chat");
     expect(config.llm.apiKey).toBe("sk-corp");
     expect(config.llm.apiKey).toBe("sk-corp");
     expect(config.llm.temperature).toBe(0.9);
     expect(config.llm.temperature).toBe(0.9);
+    expect(config.llm.apiFormat).toBe("responses");
+    expect(config.llm.stream).toBe(false);
   });
   });
 
 
   it("keeps Studio config active when llm.configSource is studio", async () => {
   it("keeps Studio config active when llm.configSource is studio", async () => {

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

@@ -10,12 +10,16 @@ import {
 // We intercept streamSimple so tests don't hit the network.
 // We intercept streamSimple so tests don't hit the network.
 
 
 const mockStreamSimple = vi.fn();
 const mockStreamSimple = vi.fn();
+const mockCompleteSimple = vi.fn();
+const mockComplete = vi.fn();
 
 
 vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
 vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
   const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
   const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
   return {
   return {
     ...original,
     ...original,
     streamSimple: (...args: unknown[]) => mockStreamSimple(...args),
     streamSimple: (...args: unknown[]) => mockStreamSimple(...args),
+    completeSimple: (...args: unknown[]) => mockCompleteSimple(...args),
+    complete: (...args: unknown[]) => mockComplete(...args),
   };
   };
 });
 });
 
 
@@ -135,6 +139,8 @@ async function captureError(task: Promise<unknown>): Promise<Error> {
 describe("chatCompletion via pi-ai", () => {
 describe("chatCompletion via pi-ai", () => {
   beforeEach(() => {
   beforeEach(() => {
     mockStreamSimple.mockReset();
     mockStreamSimple.mockReset();
+    mockCompleteSimple.mockReset();
+    mockComplete.mockReset();
   });
   });
 
 
   it("returns text content from a successful stream", async () => {
   it("returns text content from a successful stream", async () => {
@@ -239,6 +245,17 @@ describe("chatCompletion via pi-ai", () => {
 
 
     expect(deltas).toEqual(["a", "b", "c"]);
     expect(deltas).toEqual(["a", "b", "c"]);
   });
   });
+
+  it("uses completeSimple when client.stream is false", async () => {
+    mockCompleteSimple.mockResolvedValue(makeAssistantMessage("offline hello"));
+
+    const client = makeClient(0.7, { stream: false });
+    const result = await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }]);
+
+    expect(result.content).toBe("offline hello");
+    expect(mockCompleteSimple).toHaveBeenCalledOnce();
+    expect(mockStreamSimple).not.toHaveBeenCalled();
+  });
 });
 });
 
 
 describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
 describe("chatCompletion fixed-temperature clamp (thinking models)", () => {

+ 20 - 0
packages/core/src/__tests__/service-resolver.test.ts

@@ -119,4 +119,24 @@ describe("resolveServiceModel", () => {
     expect(result.apiKey).toBe("sk-corp");
     expect(result.apiKey).toBe("sk-corp");
     expect(result.model.id).toBe("gpt-4o");
     expect(result.model.id).toBe("gpt-4o");
   });
   });
+
+  it("resolves custom service with responses api format", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }),
+    );
+
+    const result = await resolveServiceModel(
+      "custom:内网GPT",
+      "gpt-5.4",
+      root,
+      "https://llm.internal.corp/v1",
+      "responses",
+    );
+
+    expect(result.apiKey).toBe("sk-corp");
+    expect(result.model.id).toBe("gpt-5.4");
+    expect(result.model.api).toBe("openai-responses");
+  });
 });
 });

+ 51 - 2
packages/core/src/llm/provider.ts

@@ -2,6 +2,8 @@ import type { LLMConfig } from "../models/project.js";
 import {
 import {
   streamSimple as piStreamSimple,
   streamSimple as piStreamSimple,
   stream as piStream,
   stream as piStream,
+  completeSimple as piCompleteSimple,
+  complete as piComplete,
 } from "@mariozechner/pi-ai";
 } from "@mariozechner/pi-ai";
 import type {
 import type {
   Api as PiApi,
   Api as PiApi,
@@ -138,7 +140,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   // --- Build pi-ai Model object ---
   // --- Build pi-ai Model object ---
   const serviceName = config.service ?? "custom";
   const serviceName = config.service ?? "custom";
   const preset = resolveServicePreset(serviceName);
   const preset = resolveServicePreset(serviceName);
-  const piApi = (preset?.api ?? "openai-completions") as PiApi;
+  const piApi = resolvePiApi(serviceName, config.apiFormat, preset?.api) as PiApi;
   const baseUrl = config.baseUrl || preset?.baseUrl || "";
   const baseUrl = config.baseUrl || preset?.baseUrl || "";
   const extraHeaders = config.headers ?? parseEnvHeaders();
   const extraHeaders = config.headers ?? parseEnvHeaders();
 
 
@@ -167,6 +169,17 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   };
   };
 }
 }
 
 
+function resolvePiApi(
+  serviceName: string,
+  apiFormat: LLMConfig["apiFormat"] | undefined,
+  presetApi: PiApi | undefined,
+): PiApi {
+  if (serviceName === "custom") {
+    return apiFormat === "responses" ? "openai-responses" : "openai-completions";
+  }
+  return (presetApi ?? "openai-completions") as PiApi;
+}
+
 function parseEnvHeaders(): Record<string, string> | undefined {
 function parseEnvHeaders(): Record<string, string> | undefined {
   const raw = process.env.INKOS_LLM_HEADERS;
   const raw = process.env.INKOS_LLM_HEADERS;
   if (!raw) return undefined;
   if (!raw) return undefined;
@@ -475,6 +488,27 @@ async function chatCompletionViaPiAi(
     headers: piModel.headers,
     headers: piModel.headers,
   };
   };
 
 
+  if (!client.stream) {
+    const response = await piCompleteSimple(piModel, context, streamOpts);
+    const content = response.content
+      .filter((block): block is { type: "text"; text: string } => block.type === "text")
+      .map((block) => block.text)
+      .join("");
+    if (!content) {
+      const diag = `usage=${response.usage.input}+${response.usage.output}`;
+      console.warn(`[inkos] LLM 非流式响应无文本内容 (${diag})`);
+      throw new Error(`LLM returned empty response (${diag})`);
+    }
+    return {
+      content,
+      usage: {
+        promptTokens: response.usage.input,
+        completionTokens: response.usage.output,
+        totalTokens: response.usage.totalTokens,
+      },
+    };
+  }
+
   const eventStream = piStreamSimple(piModel, context, streamOpts);
   const eventStream = piStreamSimple(piModel, context, streamOpts);
   const chunks: string[] = [];
   const chunks: string[] = [];
   const monitor = createStreamMonitor(onStreamProgress);
   const monitor = createStreamMonitor(onStreamProgress);
@@ -493,7 +527,6 @@ async function chatCompletionViaPiAi(
         inputTokens = msg.usage.input;
         inputTokens = msg.usage.input;
         outputTokens = msg.usage.output;
         outputTokens = msg.usage.output;
         if (event.type === "error" && msg.errorMessage) {
         if (event.type === "error" && msg.errorMessage) {
-          // Check if we have partial content worth salvaging
           const partial = chunks.join("");
           const partial = chunks.join("");
           if (partial.length >= MIN_SALVAGEABLE_CHARS) {
           if (partial.length >= MIN_SALVAGEABLE_CHARS) {
             throw new PartialResponseError(partial, new Error(msg.errorMessage));
             throw new PartialResponseError(partial, new Error(msg.errorMessage));
@@ -548,6 +581,22 @@ async function chatWithToolsViaPiAi(
     headers: piModel.headers,
     headers: piModel.headers,
   };
   };
 
 
+  if (!client.stream) {
+    const response = await piComplete(piModel, context, streamOpts);
+    const content = response.content
+      .filter((block): block is { type: "text"; text: string } => block.type === "text")
+      .map((block) => block.text)
+      .join("");
+    const toolCalls = response.content
+      .filter((block): block is PiToolCall => block.type === "toolCall")
+      .map((block) => ({
+        id: block.id,
+        name: block.name,
+        arguments: JSON.stringify(block.arguments),
+      }));
+    return { content, toolCalls };
+  }
+
   const eventStream = piStream(piModel, context, streamOpts);
   const eventStream = piStream(piModel, context, streamOpts);
   let content = "";
   let content = "";
   const toolCalls: ToolCall[] = [];
   const toolCalls: ToolCall[] = [];

+ 4 - 1
packages/core/src/llm/service-resolver.ts

@@ -16,6 +16,7 @@ export async function resolveServiceModel(
   modelId: string,
   modelId: string,
   projectRoot: string,
   projectRoot: string,
   customBaseUrl?: string,
   customBaseUrl?: string,
+  customApiFormat?: "chat" | "responses",
 ): Promise<ResolvedModel> {
 ): Promise<ResolvedModel> {
   // Resolve API key
   // Resolve API key
   const apiKey = await getServiceApiKey(projectRoot, service);
   const apiKey = await getServiceApiKey(projectRoot, service);
@@ -35,7 +36,9 @@ export async function resolveServiceModel(
 
 
   if (!model) {
   if (!model) {
     // Construct a Model object from service preset for models not in pi-ai's registry
     // Construct a Model object from service preset for models not in pi-ai's registry
-    const apiType = preset?.api ?? "openai-completions";
+    const apiType = service.startsWith("custom:")
+      ? (customApiFormat === "responses" ? "openai-responses" : "openai-completions")
+      : (preset?.api ?? "openai-completions");
     const baseUrl = customBaseUrl ?? preset?.baseUrl ?? "";
     const baseUrl = customBaseUrl ?? preset?.baseUrl ?? "";
     if (!baseUrl) {
     if (!baseUrl) {
       throw new Error(
       throw new Error(

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

@@ -6,6 +6,8 @@ const LLMServiceEntrySchema = z.object({
   baseUrl: z.string().url().optional(),
   baseUrl: z.string().url().optional(),
   temperature: z.number().min(0).max(2).optional(),
   temperature: z.number().min(0).max(2).optional(),
   maxTokens: z.number().int().min(1).optional(),
   maxTokens: z.number().int().min(1).optional(),
+  apiFormat: z.enum(["chat", "responses"]).optional(),
+  stream: z.boolean().optional(),
 });
 });
 
 
 export const LLMConfigSchema = z.object({
 export const LLMConfigSchema = z.object({

+ 18 - 0
packages/core/src/utils/config-loader.ts

@@ -14,6 +14,8 @@ interface ServiceConfigEntry {
   readonly baseUrl?: string;
   readonly baseUrl?: string;
   readonly temperature?: number;
   readonly temperature?: number;
   readonly maxTokens?: number;
   readonly maxTokens?: number;
+  readonly apiFormat?: "chat" | "responses";
+  readonly stream?: boolean;
 }
 }
 
 
 type LLMConfigSource = "env" | "studio";
 type LLMConfigSource = "env" | "studio";
@@ -115,6 +117,14 @@ export async function loadProjectConfig(
         llm.maxTokens = selectedEntry.maxTokens;
         llm.maxTokens = selectedEntry.maxTokens;
       }
       }
 
 
+      if (selectedEntry.apiFormat !== undefined) {
+        llm.apiFormat = selectedEntry.apiFormat;
+      }
+
+      if (selectedEntry.stream !== undefined) {
+        llm.stream = selectedEntry.stream;
+      }
+
       if (selectedServiceId && (configSource !== "env" || !env.INKOS_LLM_API_KEY)) {
       if (selectedServiceId && (configSource !== "env" || !env.INKOS_LLM_API_KEY)) {
         const secretApiKey = await getServiceApiKey(root, selectedServiceId);
         const secretApiKey = await getServiceApiKey(root, selectedServiceId);
         if (secretApiKey) {
         if (secretApiKey) {
@@ -201,6 +211,8 @@ function normalizeServiceEntries(raw: unknown): ServiceConfigEntry[] {
         ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
         ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
         ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
         ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
         ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
         ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
+        ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
+        ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
       }));
       }));
   }
   }
 
 
@@ -221,6 +233,8 @@ function normalizeServiceEntryFromPatch(serviceId: string, value: Record<string,
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
       ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
       ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+      ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
+      ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
     };
     };
   }
   }
 
 
@@ -231,6 +245,8 @@ function normalizeServiceEntryFromPatch(serviceId: string, value: Record<string,
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
       ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
       ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+      ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
+      ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
     };
     };
   }
   }
 
 
@@ -238,6 +254,8 @@ function normalizeServiceEntryFromPatch(serviceId: string, value: Record<string,
     service: serviceId,
     service: serviceId,
     ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
     ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
     ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
     ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+    ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
+    ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
   };
   };
 }
 }
 
 

+ 6 - 4
packages/studio/src/api/server.test.ts

@@ -520,8 +520,8 @@ describe("createStudioServer daemon lifecycle", () => {
       ...projectConfig,
       ...projectConfig,
       llm: {
       llm: {
         services: [
         services: [
-          { service: "moonshot", temperature: 1, maxTokens: 4096 },
-          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9 },
+          { service: "moonshot", temperature: 1, maxTokens: 4096, apiFormat: "chat", stream: true },
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
         ],
         ],
         defaultModel: "kimi-k2.5",
         defaultModel: "kimi-k2.5",
       },
       },
@@ -538,6 +538,8 @@ describe("createStudioServer daemon lifecycle", () => {
           moonshot: {
           moonshot: {
             temperature: 0.5,
             temperature: 0.5,
             maxTokens: 2048,
             maxTokens: 2048,
+            apiFormat: "responses",
+            stream: false,
           },
           },
         },
         },
       }),
       }),
@@ -547,8 +549,8 @@ describe("createStudioServer daemon lifecycle", () => {
 
 
     const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
     const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
     expect(raw.llm.services).toEqual([
     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 },
+      { service: "moonshot", temperature: 0.5, maxTokens: 2048, apiFormat: "responses", stream: false },
+      { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
     ]);
     ]);
   });
   });
 
 

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

@@ -120,6 +120,8 @@ interface ServiceConfigEntry {
   baseUrl?: string;
   baseUrl?: string;
   temperature?: number;
   temperature?: number;
   maxTokens?: number;
   maxTokens?: number;
+  apiFormat?: "chat" | "responses";
+  stream?: boolean;
 }
 }
 
 
 type LLMConfigSource = "env" | "studio";
 type LLMConfigSource = "env" | "studio";
@@ -160,6 +162,8 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
       ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
       ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+      ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
+      ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
     };
     };
   }
   }
 
 
@@ -170,6 +174,8 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
       ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
       ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+      ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
+      ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
     };
     };
   }
   }
 
 
@@ -177,6 +183,8 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
     service: serviceId,
     service: serviceId,
     ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
     ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
     ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
     ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+    ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
+    ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
   };
   };
 }
 }
 
 
@@ -194,6 +202,8 @@ function normalizeServiceConfig(raw: unknown): ServiceConfigEntry[] {
         ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
         ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
         ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
         ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
         ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
         ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
+        ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
+        ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
       }));
       }));
   }
   }
 
 
@@ -289,6 +299,16 @@ async function resolveConfiguredServiceBaseUrl(root: string, serviceId: string,
   }
   }
 }
 }
 
 
+async function resolveConfiguredServiceEntry(root: string, serviceId: string): Promise<ServiceConfigEntry | undefined> {
+  try {
+    const config = await loadRawConfig(root);
+    const services = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services);
+    return services.find((entry) => serviceConfigKey(entry) === serviceId);
+  } catch {
+    return undefined;
+  }
+}
+
 // --- Server factory ---
 // --- Server factory ---
 
 
 export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 export function createStudioServer(initialConfig: ProjectConfig, root: string) {
@@ -1061,11 +1081,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       if (reqService && reqModel) {
       if (reqService && reqModel) {
         // 1. Frontend explicitly selected a service+model — fail loudly if no key
         // 1. Frontend explicitly selected a service+model — fail loudly if no key
         try {
         try {
+          const configuredEntry = await resolveConfiguredServiceEntry(root, reqService);
           const resolved = await resolveServiceModel(
           const resolved = await resolveServiceModel(
             reqService,
             reqService,
             reqModel,
             reqModel,
             root,
             root,
             await resolveConfiguredServiceBaseUrl(root, reqService),
             await resolveConfiguredServiceBaseUrl(root, reqService),
+            configuredEntry?.apiFormat,
           );
           );
           resolvedModel = resolved.model;
           resolvedModel = resolved.model;
           resolvedApiKey = resolved.apiKey;
           resolvedApiKey = resolved.apiKey;
@@ -1094,6 +1116,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
               defaultModel,
               defaultModel,
               root,
               root,
               firstService.baseUrl,
               firstService.baseUrl,
+              firstService.apiFormat,
             );
             );
             resolvedModel = resolved.model;
             resolvedModel = resolved.model;
             resolvedApiKey = resolved.apiKey;
             resolvedApiKey = resolved.apiKey;
@@ -1109,11 +1132,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
             try {
             try {
               const models = await listModelsForService(svcName, svcData.apiKey);
               const models = await listModelsForService(svcName, svcData.apiKey);
               if (models.length > 0) {
               if (models.length > 0) {
+                const configuredEntry = await resolveConfiguredServiceEntry(root, svcName);
                 const resolved = await resolveServiceModel(
                 const resolved = await resolveServiceModel(
                   svcName,
                   svcName,
                   models[0].id,
                   models[0].id,
                   root,
                   root,
                   await resolveConfiguredServiceBaseUrl(root, svcName),
                   await resolveConfiguredServiceBaseUrl(root, svcName),
+                  configuredEntry?.apiFormat,
                 );
                 );
                 resolvedModel = resolved.model;
                 resolvedModel = resolved.model;
                 resolvedApiKey = resolved.apiKey;
                 resolvedApiKey = resolved.apiKey;
@@ -1134,6 +1159,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
 
       const model = resolvedModel!;
       const model = resolvedModel!;
       const agentApiKey = resolvedApiKey;
       const agentApiKey = resolvedApiKey;
+      const configuredEntry = reqService ? await resolveConfiguredServiceEntry(root, reqService) : undefined;
 
 
       // Create pipeline with resolved model (so sub_agent tools use the frontend-selected model)
       // Create pipeline with resolved model (so sub_agent tools use the frontend-selected model)
       // Don't spread config.llm — its baseUrl/provider belong to the old service.
       // Don't spread config.llm — its baseUrl/provider belong to the old service.
@@ -1141,10 +1167,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       const pipelineClient = (reqService && reqModel && resolvedApiKey)
       const pipelineClient = (reqService && reqModel && resolvedApiKey)
         ? createLLMClient({
         ? createLLMClient({
             ...config.llm,
             ...config.llm,
-            service: reqService,
+            service: configuredEntry?.service ?? reqService,
             model: reqModel,
             model: reqModel,
             apiKey: resolvedApiKey,
             apiKey: resolvedApiKey,
-            baseUrl: "",  // let createLLMClient resolve from service preset
+            ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
+            ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
+            baseUrl: configuredEntry?.baseUrl ?? "",
           } as any)
           } as any)
         : client;
         : client;
       const pipeline = new PipelineRunner(await buildPipelineConfig({
       const pipeline = new PipelineRunner(await buildPipelineConfig({

+ 31 - 1
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -55,6 +55,8 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   const [baseUrl, setBaseUrl] = useState("");
   const [baseUrl, setBaseUrl] = useState("");
   const [temperature, setTemperature] = useState("0.7");
   const [temperature, setTemperature] = useState("0.7");
   const [maxTokens, setMaxTokens] = useState("4096");
   const [maxTokens, setMaxTokens] = useState("4096");
+  const [apiFormat, setApiFormat] = useState<"chat" | "responses">("chat");
+  const [stream, setStream] = useState(true);
 
 
   // -- Unified connection status --
   // -- Unified connection status --
   const [status, setStatus] = useState<ConnectionStatus>({ state: "idle" });
   const [status, setStatus] = useState<ConnectionStatus>({ state: "idle" });
@@ -78,6 +80,8 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         }
         }
         if (typeof matched.temperature === "number") setTemperature(String(matched.temperature));
         if (typeof matched.temperature === "number") setTemperature(String(matched.temperature));
         if (typeof matched.maxTokens === "number") setMaxTokens(String(matched.maxTokens));
         if (typeof matched.maxTokens === "number") setMaxTokens(String(matched.maxTokens));
+        if (matched.apiFormat === "chat" || matched.apiFormat === "responses") setApiFormat(matched.apiFormat);
+        if (typeof matched.stream === "boolean") setStream(matched.stream);
       })
       })
       .catch(() => {});
       .catch(() => {});
     return () => { cancelled = true; };
     return () => { cancelled = true; };
@@ -172,6 +176,8 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
               service: isCustom ? "custom" : serviceId,
               service: isCustom ? "custom" : serviceId,
               temperature: parseFloat(temperature),
               temperature: parseFloat(temperature),
               maxTokens: parseInt(maxTokens, 10),
               maxTokens: parseInt(maxTokens, 10),
+              apiFormat,
+              stream,
               ...(isCustom ? { name: resolvedCustomName, baseUrl: baseUrl.trim() } : {}),
               ...(isCustom ? { name: resolvedCustomName, baseUrl: baseUrl.trim() } : {}),
             },
             },
           ],
           ],
@@ -226,7 +232,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
       <div className="space-y-5">
       <div className="space-y-5">
         {/* Custom fields */}
         {/* Custom fields */}
         {isCustom && (
         {isCustom && (
-          <div className="grid grid-cols-2 gap-4">
+        <div className="grid grid-cols-2 gap-4">
             <Field label="服务名称">
             <Field label="服务名称">
               <input type="text" value={customName} onChange={(e) => setCustomName(e.target.value)}
               <input type="text" value={customName} onChange={(e) => setCustomName(e.target.value)}
                 placeholder="例如:本地 Ollama" className="w-full rounded-lg border border-border/60 bg-background px-3 py-2 text-sm" />
                 placeholder="例如:本地 Ollama" className="w-full rounded-lg border border-border/60 bg-background px-3 py-2 text-sm" />
@@ -277,6 +283,30 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
           )}
           )}
         </div>
         </div>
 
 
+        <div className="grid grid-cols-2 gap-4">
+          <Field label="协议类型">
+            <select
+              value={apiFormat}
+              onChange={(e) => setApiFormat(e.target.value as "chat" | "responses")}
+              className="w-full rounded-lg border border-border/60 bg-background px-3 py-2 text-sm"
+            >
+              <option value="chat">Chat / Completions</option>
+              <option value="responses">Responses</option>
+            </select>
+          </Field>
+
+          <Field label="流式响应">
+            <label className="flex h-10 items-center gap-2 rounded-lg border border-border/60 bg-background px-3 text-sm">
+              <input
+                type="checkbox"
+                checked={stream}
+                onChange={(e) => setStream(e.target.checked)}
+              />
+              <span>{stream ? "开启" : "关闭"}</span>
+            </label>
+          </Field>
+        </div>
+
         {/* Models */}
         {/* Models */}
         {models.length > 0 && (
         {models.length > 0 && (
           <div className="space-y-2">
           <div className="space-y-2">