Kaynağa Gözat

fix: support proxy dispatch for llm requests

Ma 6 ay önce
ebeveyn
işleme
9d2bf88bc9

+ 1 - 1
packages/cli/src/commands/config.ts

@@ -24,7 +24,7 @@ configCommand
 
 
       const KNOWN_KEYS = new Set([
       const KNOWN_KEYS = new Set([
         "llm.provider", "llm.baseUrl", "llm.model", "llm.temperature",
         "llm.provider", "llm.baseUrl", "llm.model", "llm.temperature",
-        "llm.thinkingBudget", "llm.apiFormat", "llm.stream",
+        "llm.thinkingBudget", "llm.proxyUrl", "llm.apiFormat", "llm.stream",
         "inputGovernanceMode",
         "inputGovernanceMode",
         "foundation.reviewRetries",
         "foundation.reviewRetries",
         "daemon.schedule.radarCron", "daemon.schedule.writeCron",
         "daemon.schedule.radarCron", "daemon.schedule.writeCron",

+ 5 - 3
packages/cli/src/commands/doctor.ts

@@ -2,6 +2,7 @@ import { Command } from "commander";
 import { readFile } from "node:fs/promises";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import { findProjectRoot, log, logError, GLOBAL_ENV_PATH } from "../utils.js";
 import { findProjectRoot, log, logError, GLOBAL_ENV_PATH } from "../utils.js";
+import { fetchWithProxy } from "@actalk/inkos-core";
 import {
 import {
   ensureNodeRuntimePinFiles,
   ensureNodeRuntimePinFiles,
   evaluateSqliteMemorySupport,
   evaluateSqliteMemorySupport,
@@ -74,13 +75,14 @@ export function resolveDoctorModelsBaseUrl(
 async function fetchDoctorModels(
 async function fetchDoctorModels(
   modelsBaseUrl: string,
   modelsBaseUrl: string,
   apiKey: string,
   apiKey: string,
+  proxyUrl?: string,
 ): Promise<Array<{ id: string; name: string }>> {
 ): Promise<Array<{ id: string; name: string }>> {
   const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   try {
   try {
-    const res = await fetch(modelsUrl, {
+    const res = await fetchWithProxy(modelsUrl, {
       headers: { Authorization: `Bearer ${apiKey}` },
       headers: { Authorization: `Bearer ${apiKey}` },
       signal: AbortSignal.timeout(10_000),
       signal: AbortSignal.timeout(10_000),
-    });
+    }, proxyUrl);
     if (!res.ok) return [];
     if (!res.ok) return [];
     const json = await res.json() as { data?: Array<{ id: string }> };
     const json = await res.json() as { data?: Array<{ id: string }> };
     return (json.data ?? []).map((model) => ({ id: model.id, name: model.id }));
     return (json.data ?? []).map((model) => ({ id: model.id, name: model.id }));
@@ -284,7 +286,7 @@ export const doctorCommand = new Command("doctor")
           resolveServiceModelsBaseUrl,
           resolveServiceModelsBaseUrl,
         );
         );
         const discoveredModels = (llmConfig.apiKey && modelsBaseUrl)
         const discoveredModels = (llmConfig.apiKey && modelsBaseUrl)
-          ? await fetchDoctorModels(modelsBaseUrl, llmConfig.apiKey)
+          ? await fetchDoctorModels(modelsBaseUrl, llmConfig.apiKey, llmConfig.proxyUrl)
           : [];
           : [];
         const modelCandidates = (llmConfig.provider === "openai" || discoveredModels.length > 0)
         const modelCandidates = (llmConfig.provider === "openai" || discoveredModels.length > 0)
           ? buildDoctorModelCandidates(llmConfig.model, discoveredModels)
           ? buildDoctorModelCandidates(llmConfig.model, discoveredModels)

+ 1 - 0
packages/core/package.json

@@ -50,6 +50,7 @@
     "dotenv": "^16.4.0",
     "dotenv": "^16.4.0",
     "epub-gen-memory": "^1.0.10",
     "epub-gen-memory": "^1.0.10",
     "js-yaml": "^4.1.1",
     "js-yaml": "^4.1.1",
+    "undici": "6.21.3",
     "zod": "^3.25.76"
     "zod": "^3.25.76"
   },
   },
   "devDependencies": {
   "devDependencies": {

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

@@ -288,6 +288,38 @@ describe("chatCompletion via pi-ai", () => {
     vi.unstubAllGlobals();
     vi.unstubAllGlobals();
   });
   });
 
 
+  it("attaches a proxy dispatcher for custom openai-compatible chat when proxyUrl is configured", async () => {
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        choices: [{ message: { content: "proxied" } }],
+        usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
+      }),
+    });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const client = makeClient(0.7, {
+      service: "custom",
+      stream: false,
+      proxyUrl: "http://127.0.0.1:9910",
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "https://gateway.example/v1",
+      },
+    });
+    const result = await chatCompletion(client, "gpt-5.4", [{ role: "user", content: "nihao" }]);
+
+    expect(result.content).toBe("proxied");
+    expect(fetchMock).toHaveBeenCalledOnce();
+    expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+      method: "POST",
+      dispatcher: expect.any(Object),
+    });
+
+    vi.unstubAllGlobals();
+  });
+
   it("uses reasoning_content for custom openai-compatible non-stream responses that omit content", async () => {
   it("uses reasoning_content for custom openai-compatible non-stream responses that omit content", async () => {
     const fetchMock = vi.fn().mockResolvedValue({
     const fetchMock = vi.fn().mockResolvedValue({
       ok: true,
       ok: true,

+ 75 - 0
packages/core/src/__tests__/proxy-fetch.test.ts

@@ -0,0 +1,75 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const proxyAgentMock = vi.fn((url: string) => ({ kind: "proxy-agent", url }));
+
+vi.mock("undici", () => ({
+  ProxyAgent: proxyAgentMock,
+}));
+
+describe("proxy fetch helpers", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+    vi.clearAllMocks();
+  });
+
+  it("prefers explicit llm proxyUrl over environment proxy variables", async () => {
+    const { fetchWithProxy, resolveProxyUrl } = await import("../utils/proxy-fetch.js");
+    const fetchMock = vi.fn().mockResolvedValue({ ok: true });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const env = {
+      INKOS_LLM_PROXY_URL: "http://inkos-env-proxy:9910",
+      HTTPS_PROXY: "http://standard-proxy:9910",
+    };
+
+    expect(resolveProxyUrl("http://explicit-proxy:9910", env)).toBe("http://explicit-proxy:9910");
+    await fetchWithProxy("https://api.example/v1/chat/completions", { method: "POST" }, "http://explicit-proxy:9910", env);
+
+    expect(proxyAgentMock).toHaveBeenCalledWith("http://explicit-proxy:9910");
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://api.example/v1/chat/completions",
+      expect.objectContaining({
+        method: "POST",
+        dispatcher: { kind: "proxy-agent", url: "http://explicit-proxy:9910" },
+      }),
+    );
+  });
+
+  it("uses INKOS_LLM_PROXY_URL before standard HTTPS_PROXY/HTTP_PROXY env vars", async () => {
+    const { fetchWithProxy, resolveProxyUrl } = await import("../utils/proxy-fetch.js");
+    const fetchMock = vi.fn().mockResolvedValue({ ok: true });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const env = {
+      INKOS_LLM_PROXY_URL: "http://inkos-proxy:9910",
+      HTTPS_PROXY: "http://standard-proxy:9910",
+      HTTP_PROXY: "http://http-proxy:9910",
+    };
+
+    expect(resolveProxyUrl(undefined, env)).toBe("http://inkos-proxy:9910");
+    await fetchWithProxy("https://api.example/v1/models", {}, undefined, env);
+
+    expect(proxyAgentMock).toHaveBeenCalledWith("http://inkos-proxy:9910");
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://api.example/v1/models",
+      expect.objectContaining({
+        dispatcher: { kind: "proxy-agent", url: "http://inkos-proxy:9910" },
+      }),
+    );
+  });
+
+  it("does not attach a dispatcher when no proxy is configured", async () => {
+    const { fetchWithProxy, resolveProxyUrl } = await import("../utils/proxy-fetch.js");
+    const fetchMock = vi.fn().mockResolvedValue({ ok: true });
+    vi.stubGlobal("fetch", fetchMock);
+
+    expect(resolveProxyUrl(undefined, {})).toBeUndefined();
+    await fetchWithProxy("https://api.example/v1/models", { headers: { Authorization: "Bearer test" } }, undefined, {});
+
+    expect(proxyAgentMock).not.toHaveBeenCalled();
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://api.example/v1/models",
+      { headers: { Authorization: "Bearer test" } },
+    );
+  });
+});

+ 16 - 0
packages/core/src/__tests__/verify-service.test.ts

@@ -47,6 +47,22 @@ describe("verifyService (B9)", () => {
     expect(result.probe.error).toContain("ECONNREFUSED");
     expect(result.probe.error).toContain("ECONNREFUSED");
   });
   });
 
 
+  it("probe 使用显式 proxyUrl 连接 /models", async () => {
+    const fetchMock = vi.fn()
+      .mockResolvedValueOnce({
+        ok: true,
+        json: async () => ({ data: [{ id: "gpt-4o" }] }),
+      } as any)
+      .mockRejectedValue(new Error("test: no real chat backend"));
+    global.fetch = fetchMock as typeof fetch;
+
+    const result = await verifyService("openai", "sk-test", { proxyUrl: "http://127.0.0.1:9910" });
+    expect(result.probe.ok).toBe(true);
+    expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+      dispatcher: expect.any(Object),
+    });
+  });
+
   it("provider 没 checkModel(custom baseUrl 空)→ chat 字段返回 null,不发 chat 请求", async () => {
   it("provider 没 checkModel(custom baseUrl 空)→ chat 字段返回 null,不发 chat 请求", async () => {
     // custom 没 checkModel,verifyService 跳过 chat step
     // custom 没 checkModel,verifyService 跳过 chat step
     const result = await verifyService("custom", "sk-x");
     const result = await verifyService("custom", "sk-x");

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

@@ -78,6 +78,11 @@ export {
   gatherPlanningMaterials,
   gatherPlanningMaterials,
   type PlanningMaterials,
   type PlanningMaterials,
 } from "./utils/planning-materials.js";
 } from "./utils/planning-materials.js";
+export {
+  buildProxyFetchInit,
+  fetchWithProxy,
+  resolveProxyUrl,
+} from "./utils/proxy-fetch.js";
 export { assertSafeBookId, deriveBookIdFromTitle, isSafeBookId } from "./utils/book-id.js";
 export { assertSafeBookId, deriveBookIdFromTitle, isSafeBookId } from "./utils/book-id.js";
 export { safeChildPath } from "./utils/path-safety.js";
 export { safeChildPath } from "./utils/path-safety.js";
 export {
 export {

+ 9 - 6
packages/core/src/llm/provider.ts

@@ -17,6 +17,7 @@ import type {
 import { resolveServicePreset } from "./service-presets.js";
 import { resolveServicePreset } from "./service-presets.js";
 import { getEndpoint } from "./providers/index.js";
 import { getEndpoint } from "./providers/index.js";
 import { lookupModel } from "./providers/lookup.js";
 import { lookupModel } from "./providers/lookup.js";
+import { fetchWithProxy } from "../utils/proxy-fetch.js";
 
 
 
 
 // === Streaming Monitor Types ===
 // === Streaming Monitor Types ===
@@ -99,6 +100,7 @@ export interface LLMClient {
   readonly configSource?: LLMConfig["configSource"];
   readonly configSource?: LLMConfig["configSource"];
   readonly apiFormat: "chat" | "responses";
   readonly apiFormat: "chat" | "responses";
   readonly stream: boolean;
   readonly stream: boolean;
+  readonly proxyUrl?: string;
   readonly _piModel?: PiModel<PiApi>;
   readonly _piModel?: PiModel<PiApi>;
   readonly _apiKey?: string;
   readonly _apiKey?: string;
   readonly defaults: {
   readonly defaults: {
@@ -208,6 +210,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
     configSource: config.configSource,
     configSource: config.configSource,
     apiFormat,
     apiFormat,
     stream,
     stream,
+    proxyUrl: config.proxyUrl,
     _piModel: piModel,
     _piModel: piModel,
     _apiKey: config.apiKey,
     _apiKey: config.apiKey,
     defaults,
     defaults,
@@ -589,7 +592,7 @@ async function chatCompletionViaCustomAnthropicCompatible(
   const system = joinSystemPrompt(messages);
   const system = joinSystemPrompt(messages);
   if (system) payload.system = system;
   if (system) payload.system = system;
 
 
-  const response = await fetch(`${baseUrl.replace(/\/$/, "")}/messages`, {
+  const response = await fetchWithProxy(`${baseUrl.replace(/\/$/, "")}/messages`, {
     method: "POST",
     method: "POST",
     headers: {
     headers: {
       "User-Agent": INKOS_USER_AGENT,
       "User-Agent": INKOS_USER_AGENT,
@@ -600,7 +603,7 @@ async function chatCompletionViaCustomAnthropicCompatible(
       ...(client._piModel?.headers ?? {}),
       ...(client._piModel?.headers ?? {}),
     },
     },
     body: JSON.stringify(payload),
     body: JSON.stringify(payload),
-  });
+  }, client.proxyUrl);
 
 
   if (!response.ok) {
   if (!response.ok) {
     throw wrapLLMError(new Error(await readErrorResponse(response)), errorCtx);
     throw wrapLLMError(new Error(await readErrorResponse(response)), errorCtx);
@@ -699,11 +702,11 @@ async function chatCompletionViaCustomOpenAICompatible(
     const instructions = joinSystemPrompt(messages);
     const instructions = joinSystemPrompt(messages);
     if (instructions) payload.instructions = instructions;
     if (instructions) payload.instructions = instructions;
 
 
-    const response = await fetch(`${baseUrl.replace(/\/$/, "")}/responses`, {
+    const response = await fetchWithProxy(`${baseUrl.replace(/\/$/, "")}/responses`, {
       method: "POST",
       method: "POST",
       headers,
       headers,
       body: JSON.stringify(payload),
       body: JSON.stringify(payload),
-    });
+    }, client.proxyUrl);
     if (!response.ok) {
     if (!response.ok) {
       throw wrapLLMError(new Error(await readErrorResponse(response)), errorCtx);
       throw wrapLLMError(new Error(await readErrorResponse(response)), errorCtx);
     }
     }
@@ -785,11 +788,11 @@ async function chatCompletionViaCustomOpenAICompatible(
     payload.stream_options = { include_usage: true };
     payload.stream_options = { include_usage: true };
   }
   }
 
 
-  const response = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
+  const response = await fetchWithProxy(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
     method: "POST",
     method: "POST",
     headers,
     headers,
     body: JSON.stringify(payload),
     body: JSON.stringify(payload),
-  });
+  }, client.proxyUrl);
   if (!response.ok) {
   if (!response.ok) {
     const detail = await readErrorResponse(response);
     const detail = await readErrorResponse(response);
     if (allowSystemRoleFallback && hasSystemMessages(messages) && isSystemRoleUnsupportedErrorText(detail)) {
     if (allowSystemRoleFallback && hasSystemMessages(messages) && isSystemRoleUnsupportedErrorText(detail)) {

+ 3 - 1
packages/core/src/llm/providers/probe.ts

@@ -1,3 +1,5 @@
+import { fetchWithProxy } from "../../utils/proxy-fetch.js";
+
 /**
 /**
  * 通用 OpenAI 兼容 /models 探针。
  * 通用 OpenAI 兼容 /models 探针。
  * 任何失败(网络错、超时、非 JSON、非 2xx)一律返回空数组,不抛异常。
  * 任何失败(网络错、超时、非 JSON、非 2xx)一律返回空数组,不抛异常。
@@ -17,7 +19,7 @@ export async function probeModelsFromUpstream(
   if (!baseUrl || !apiKey) return [];
   if (!baseUrl || !apiKey) return [];
   try {
   try {
     const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
     const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
-    const res = await fetch(modelsUrl, {
+    const res = await fetchWithProxy(modelsUrl, {
       headers: { Authorization: `Bearer ${apiKey}` },
       headers: { Authorization: `Bearer ${apiKey}` },
       signal: AbortSignal.timeout(timeoutMs),
       signal: AbortSignal.timeout(timeoutMs),
     });
     });

+ 7 - 4
packages/core/src/llm/providers/verify.ts

@@ -1,5 +1,6 @@
 import { getEndpoint } from "./index.js";
 import { getEndpoint } from "./index.js";
 import { resolveServiceModelsBaseUrl } from "../service-presets.js";
 import { resolveServiceModelsBaseUrl } from "../service-presets.js";
+import { fetchWithProxy } from "../../utils/proxy-fetch.js";
 
 
 export interface VerifyResult {
 export interface VerifyResult {
   readonly recommendedTransport?: {
   readonly recommendedTransport?: {
@@ -26,6 +27,7 @@ async function probe(
   service: string,
   service: string,
   apiKey: string,
   apiKey: string,
   baseUrl?: string,
   baseUrl?: string,
+  proxyUrl?: string,
 ): Promise<VerifyResult["probe"]> {
 ): Promise<VerifyResult["probe"]> {
   const provider = getEndpoint(service);
   const provider = getEndpoint(service);
   const probeBaseUrl = baseUrl || provider?.modelsBaseUrl || provider?.baseUrl || resolveServiceModelsBaseUrl(service);
   const probeBaseUrl = baseUrl || provider?.modelsBaseUrl || provider?.baseUrl || resolveServiceModelsBaseUrl(service);
@@ -34,10 +36,10 @@ async function probe(
   }
   }
   try {
   try {
     const url = probeBaseUrl.replace(/\/$/, "") + "/models";
     const url = probeBaseUrl.replace(/\/$/, "") + "/models";
-    const res = await fetch(url, {
+    const res = await fetchWithProxy(url, {
       headers: { Authorization: `Bearer ${apiKey}` },
       headers: { Authorization: `Bearer ${apiKey}` },
       signal: AbortSignal.timeout(10_000),
       signal: AbortSignal.timeout(10_000),
-    });
+    }, proxyUrl);
     if (!res.ok) {
     if (!res.ok) {
       return { ok: false, models: 0, error: `HTTP ${res.status} ${res.statusText}` };
       return { ok: false, models: 0, error: `HTTP ${res.status} ${res.statusText}` };
     }
     }
@@ -61,9 +63,9 @@ async function probe(
 export async function verifyService(
 export async function verifyService(
   service: string,
   service: string,
   apiKey: string,
   apiKey: string,
-  opts?: { checkModel?: string; baseUrl?: string },
+  opts?: { checkModel?: string; baseUrl?: string; proxyUrl?: string },
 ): Promise<VerifyResult> {
 ): Promise<VerifyResult> {
-  const probeResult = await probe(service, apiKey, opts?.baseUrl);
+  const probeResult = await probe(service, apiKey, opts?.baseUrl, opts?.proxyUrl);
 
 
   const provider = getEndpoint(service);
   const provider = getEndpoint(service);
   const recommendedTransport = provider?.transportDefaults;
   const recommendedTransport = provider?.transportDefaults;
@@ -82,6 +84,7 @@ export async function verifyService(
       model: checkModel,
       model: checkModel,
       apiKey,
       apiKey,
       baseUrl: opts?.baseUrl ?? provider?.baseUrl ?? "",
       baseUrl: opts?.baseUrl ?? provider?.baseUrl ?? "",
+      proxyUrl: opts?.proxyUrl,
       configSource: "studio",
       configSource: "studio",
       stream: false,
       stream: false,
     }));
     }));

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

@@ -20,6 +20,7 @@ export const LLMConfigSchema = z.object({
   baseUrl: z.string().url(),
   baseUrl: z.string().url(),
   apiKey: z.string().default(""),
   apiKey: z.string().default(""),
   model: z.string().min(1),
   model: z.string().min(1),
+  proxyUrl: z.string().url().optional(),
   temperature: z.number().min(0).max(2).default(0.7),
   temperature: z.number().min(0).max(2).default(0.7),
   thinkingBudget: z.number().int().min(0).default(0),
   thinkingBudget: z.number().int().min(0).default(0),
   extra: z.record(z.unknown()).optional(),
   extra: z.record(z.unknown()).optional(),

+ 1 - 0
packages/core/src/utils/effective-llm-config.ts

@@ -327,6 +327,7 @@ function applyCommonEnv(
 ): void {
 ): void {
   if (env.INKOS_LLM_TEMPERATURE) llm.temperature = Number.parseFloat(env.INKOS_LLM_TEMPERATURE);
   if (env.INKOS_LLM_TEMPERATURE) llm.temperature = Number.parseFloat(env.INKOS_LLM_TEMPERATURE);
   if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = Number.parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
   if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = Number.parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
+  if (env.INKOS_LLM_PROXY_URL) llm.proxyUrl = env.INKOS_LLM_PROXY_URL;
   if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
   if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
   if (env.INKOS_LLM_STREAM) llm.stream = parseBoolean(env.INKOS_LLM_STREAM);
   if (env.INKOS_LLM_STREAM) llm.stream = parseBoolean(env.INKOS_LLM_STREAM);
   if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
   if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;

+ 44 - 0
packages/core/src/utils/proxy-fetch.ts

@@ -0,0 +1,44 @@
+import { ProxyAgent } from "undici";
+
+type ProxyEnv = Record<string, string | undefined>;
+type FetchInitWithDispatcher = RequestInit & { dispatcher?: unknown };
+
+export function resolveProxyUrl(explicitProxyUrl?: string, env: ProxyEnv = process.env): string | undefined {
+  const candidate = [
+    explicitProxyUrl,
+    env.INKOS_LLM_PROXY_URL,
+    env.HTTPS_PROXY,
+    env.https_proxy,
+    env.HTTP_PROXY,
+    env.http_proxy,
+  ].find((value) => typeof value === "string" && value.trim().length > 0)?.trim();
+
+  if (!candidate) return undefined;
+  const parsed = new URL(candidate);
+  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+    throw new Error(`Unsupported proxy protocol: ${parsed.protocol}`);
+  }
+  return candidate;
+}
+
+export function buildProxyFetchInit(
+  init: RequestInit = {},
+  explicitProxyUrl?: string,
+  env: ProxyEnv = process.env,
+): FetchInitWithDispatcher {
+  const proxyUrl = resolveProxyUrl(explicitProxyUrl, env);
+  if (!proxyUrl) return init;
+  return {
+    ...init,
+    dispatcher: new ProxyAgent(proxyUrl),
+  };
+}
+
+export function fetchWithProxy(
+  input: Parameters<typeof fetch>[0],
+  init: RequestInit = {},
+  explicitProxyUrl?: string,
+  env: ProxyEnv = process.env,
+): ReturnType<typeof fetch> {
+  return fetch(input, buildProxyFetchInit(init, explicitProxyUrl, env));
+}

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

@@ -32,6 +32,7 @@ import {
   listModelsForService,
   listModelsForService,
   getAllEndpoints,
   getAllEndpoints,
   probeModelsFromUpstream,
   probeModelsFromUpstream,
+  fetchWithProxy,
   chatCompletion,
   chatCompletion,
   buildExportArtifact,
   buildExportArtifact,
   GLOBAL_ENV_PATH,
   GLOBAL_ENV_PATH,
@@ -538,6 +539,7 @@ async function fetchModelsFromServiceBaseUrl(
   serviceId: string,
   serviceId: string,
   baseUrl: string,
   baseUrl: string,
   apiKey: string,
   apiKey: string,
+  proxyUrl?: 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)
   const endpoint = isCustomServiceId(serviceId)
     ? undefined
     ? undefined
@@ -547,10 +549,10 @@ async function fetchModelsFromServiceBaseUrl(
     : endpoint?.modelsBaseUrl ?? (endpoint ? 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 fetchWithProxy(modelsUrl, {
       headers: { Authorization: `Bearer ${apiKey}` },
       headers: { Authorization: `Bearer ${apiKey}` },
       signal: AbortSignal.timeout(10_000),
       signal: AbortSignal.timeout(10_000),
-    });
+    }, proxyUrl);
     if (!res.ok) {
     if (!res.ok) {
       const body = await res.text().catch(() => "");
       const body = await res.text().catch(() => "");
       return {
       return {
@@ -579,6 +581,7 @@ async function probeServiceCapabilities(args: {
   preferredApiFormat?: "chat" | "responses";
   preferredApiFormat?: "chat" | "responses";
   preferredStream?: boolean;
   preferredStream?: boolean;
   preferredModel?: string;
   preferredModel?: string;
+  proxyUrl?: string;
 }): Promise<ServiceProbeResult> {
 }): Promise<ServiceProbeResult> {
   const rawConfig = await loadRawConfig(args.root).catch(() => ({} as Record<string, unknown>));
   const rawConfig = await loadRawConfig(args.root).catch(() => ({} as Record<string, unknown>));
   const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
   const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
@@ -590,7 +593,7 @@ async function probeServiceCapabilities(args: {
       : null;
       : null;
 
 
   const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
   const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
-  const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey);
+  const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey, args.proxyUrl);
   if (modelsResponse.authFailed) {
   if (modelsResponse.authFailed) {
     return {
     return {
       ok: false,
       ok: false,
@@ -646,6 +649,7 @@ async function probeServiceCapabilities(args: {
         temperature: 0.7,
         temperature: 0.7,
         maxTokens: 2048,
         maxTokens: 2048,
         thinkingBudget: 0,
         thinkingBudget: 0,
+        proxyUrl: args.proxyUrl,
         apiFormat: plan.apiFormat,
         apiFormat: plan.apiFormat,
         stream: plan.stream,
         stream: plan.stream,
       } as ProjectConfig["llm"]);
       } as ProjectConfig["llm"]);
@@ -1262,6 +1266,8 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
       return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
     }
     }
 
 
+    const rawConfig = await loadRawConfig(root).catch(() => ({} as Record<string, unknown>));
+    const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
     const probe = await probeServiceCapabilities({
     const probe = await probeServiceCapabilities({
       root,
       root,
       service,
       service,
@@ -1269,6 +1275,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       baseUrl: resolvedBaseUrl,
       baseUrl: resolvedBaseUrl,
       preferredApiFormat: apiFormat,
       preferredApiFormat: apiFormat,
       preferredStream: stream,
       preferredStream: stream,
+      proxyUrl: typeof llm.proxyUrl === "string" ? llm.proxyUrl : undefined,
     });
     });
 
 
     // B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
     // B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
@@ -2788,6 +2795,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         preferredApiFormat: currentConfig.llm.apiFormat,
         preferredApiFormat: currentConfig.llm.apiFormat,
         preferredStream: currentConfig.llm.stream,
         preferredStream: currentConfig.llm.stream,
         preferredModel: currentConfig.llm.model,
         preferredModel: currentConfig.llm.model,
+        proxyUrl: currentConfig.llm.proxyUrl,
       });
       });
       checks.llmConnected = probe.ok;
       checks.llmConnected = probe.ok;
     } catch { /* ignore */ }
     } catch { /* ignore */ }

+ 9 - 0
pnpm-lock.yaml

@@ -84,6 +84,9 @@ importers:
       js-yaml:
       js-yaml:
         specifier: ^4.1.1
         specifier: ^4.1.1
         version: 4.1.1
         version: 4.1.1
+      undici:
+        specifier: 6.21.3
+        version: 6.21.3
       zod:
       zod:
         specifier: ^3.25.76
         specifier: ^3.25.76
         version: 3.25.76
         version: 3.25.76
@@ -4557,6 +4560,10 @@ packages:
   undici-types@6.21.0:
   undici-types@6.21.0:
     resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
     resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
 
 
+  undici@6.21.3:
+    resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
+    engines: {node: '>=18.17'}
+
   undici@7.25.0:
   undici@7.25.0:
     resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
     resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
     engines: {node: '>=20.18.1'}
     engines: {node: '>=20.18.1'}
@@ -9973,6 +9980,8 @@ snapshots:
 
 
   undici-types@6.21.0: {}
   undici-types@6.21.0: {}
 
 
+  undici@6.21.3: {}
+
   undici@7.25.0: {}
   undici@7.25.0: {}
 
 
   unicode-emoji-modifier-base@1.0.0: {}
   unicode-emoji-modifier-base@1.0.0: {}