diff --git a/src/main/services/provider-auth-profiles.ts b/src/main/services/provider-auth-profiles.ts index 2f6e0b61..f523f8b8 100644 --- a/src/main/services/provider-auth-profiles.ts +++ b/src/main/services/provider-auth-profiles.ts @@ -8,8 +8,8 @@ export type ProviderAuthProfile = | { type: "azure_openai"; baseUrl: string; deployment: string; apiKey: string } | { type: "aws_bedrock"; region: string; accessKeyId: string; secretAccessKey: string; sessionToken: string | null } | { type: "vertex_ai"; projectId: string; region: string; serviceAccountJson: string } - | { type: "openai_compat"; baseUrl: string; apiKey: string } - | { type: "anthropic_compat"; baseUrl: string; apiKey: string }; + | { type: "openai_compat"; baseUrl: string; apiKey: string | null } + | { type: "anthropic_compat"; baseUrl: string; apiKey: string | null }; export function parseApiKey(secret: string): ProviderAuthProfile { return { type: "api_key", apiKey: nonEmptySecret(secret, "API key") }; @@ -75,12 +75,12 @@ export function parseVertexProfile(secret: string): ProviderAuthProfile { export function parseOpenAiCompatProfile(secret: string): ProviderAuthProfile { const parsed = parseCloudSecret(secret); - return { type: "openai_compat", baseUrl: normalizedHttpsUrl(requiredString(parsed.baseUrl, "Base URL"), "Base URL"), apiKey: requiredString(parsed.secret, "API key") }; + return { type: "openai_compat", baseUrl: customEndpointUrl(requiredString(parsed.baseUrl, "Base URL")), apiKey: optionalSecret(parsed.secret) }; } export function parseAnthropicCompatProfile(secret: string): ProviderAuthProfile { const parsed = parseCloudSecret(secret); - return { type: "anthropic_compat", baseUrl: normalizedHttpsUrl(requiredString(parsed.baseUrl, "Base URL"), "Base URL"), apiKey: requiredString(parsed.secret, "API key") }; + return { type: "anthropic_compat", baseUrl: customEndpointUrl(requiredString(parsed.baseUrl, "Base URL")), apiKey: optionalSecret(parsed.secret) }; } export function requireOpenAiCompatProfile(profile: ProviderAuthProfile): Extract { @@ -159,6 +159,35 @@ function nonEmptySecret(secret: string, label: string): string { function normalizedHttpsUrl(value: string, label: string): string { const url = new URL(value); if (url.protocol !== "https:") throw new Error(`${label} must use HTTPS.`); + return normalizedUrl(url, label); +} + +/** A custom endpoint may use plain HTTP only on this computer or the local network, where no key or prompt crosses the internet. */ +function customEndpointUrl(value: string): string { + const url = new URL(value); + const plainLocal = url.protocol === "http:" && isLocalNetworkHost(url.hostname); + if (url.protocol !== "https:" && !plainLocal) throw new Error("Base URL must use HTTPS unless it is on this computer or the local network."); + return normalizedUrl(url, "Base URL"); +} + +/** Loopback, private IPv4, IPv6 loopback or unique-local, and mDNS `.local` names. */ +function isLocalNetworkHost(hostname: string): boolean { + const host = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true; + if (host === "::1" || /^f[cd][0-9a-f]{2}:/.test(host)) return true; + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false; + const [a, b] = host.split(".").map(Number); + return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +/** Local servers often need no key; an empty one means none, anything else must be text. */ +function optionalSecret(value: unknown): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") throw new Error("API key must be text."); + return value.trim() || null; +} + +function normalizedUrl(url: URL, label: string): string { if (url.username || url.password) throw new Error(`${label} must not include credentials.`); if (url.search || url.hash) throw new Error(`${label} must not include query strings or fragments.`); return url.toString().replace(/\/+$/, ""); diff --git a/src/main/services/provider-tool-turns/credential-resolver.ts b/src/main/services/provider-tool-turns/credential-resolver.ts index 9c954ff9..db1a0e41 100644 --- a/src/main/services/provider-tool-turns/credential-resolver.ts +++ b/src/main/services/provider-tool-turns/credential-resolver.ts @@ -76,11 +76,11 @@ function resolveCloudCredential(providerKey: string, secret: string): TurnCreden switch (providerKey) { case "custom-openai": { const profile = requireOpenAiCompatProfile(parseOpenAiCompatProfile(secret)); - return { apiKey: profile.apiKey, baseUrl: profile.baseUrl }; + return { baseUrl: profile.baseUrl, ...(profile.apiKey ? { apiKey: profile.apiKey } : {}) }; } case "custom-anthropic": { const profile = requireAnthropicCompatProfile(parseAnthropicCompatProfile(secret)); - return { apiKey: profile.apiKey, baseUrl: profile.baseUrl }; + return { baseUrl: profile.baseUrl, ...(profile.apiKey ? { apiKey: profile.apiKey } : {}) }; } case "azure-openai": { const profile = requireAzureProfile(parseAzureProfile(secret)); diff --git a/src/main/services/provider-tool-turns/turns.ts b/src/main/services/provider-tool-turns/turns.ts index 39cc5339..64cafd91 100644 --- a/src/main/services/provider-tool-turns/turns.ts +++ b/src/main/services/provider-tool-turns/turns.ts @@ -187,7 +187,9 @@ export async function runCustomOpenAiTurn(input: ProviderToolTurnInput): Promise stream: false, // No output cap: whatever serves a custom endpoint decides which cap field, if any, it accepts. }; - const response = await postJson(`${input.credential.baseUrl}/chat/completions`, { authorization: `Bearer ${requireApiKey(input.credential, "Custom OpenAI-compatible provider")}` }, body, WHOLE_RESPONSE_WAIT); + // A local server may need no key; send none rather than an empty one. + const auth: Record = input.credential.apiKey ? { authorization: `Bearer ${input.credential.apiKey}` } : {}; + const response = await postJson(`${input.credential.baseUrl}/chat/completions`, auth, body, WHOLE_RESPONSE_WAIT); const parsed = parseChatChoice(response.data); return { ...parsed, rawResponseJson: JSON.stringify(response.data), rateLimitState: response.rateLimitState }; } @@ -203,7 +205,8 @@ export async function runCustomAnthropicTurn(input: ProviderToolTurnInput): Prom messages: toAnthropicMessages(input.messages), ...(input.instructions ? { system: input.instructions } : {}), }; - const response = await postJson(`${input.credential.baseUrl}/v1/messages`, { "x-api-key": requireApiKey(input.credential, "Custom Anthropic-compatible provider"), "anthropic-version": "2023-06-01" }, body, WHOLE_RESPONSE_WAIT); + const auth: Record = input.credential.apiKey ? { "x-api-key": input.credential.apiKey } : {}; + const response = await postJson(`${input.credential.baseUrl}/v1/messages`, { ...auth, "anthropic-version": "2023-06-01" }, body, WHOLE_RESPONSE_WAIT); const parsed = parseAnthropicContent(response.data); return { ...parsed, rawResponseJson: JSON.stringify(response.data), rateLimitState: response.rateLimitState }; } diff --git a/src/renderer/src/app/Onboarding.tsx b/src/renderer/src/app/Onboarding.tsx index fdf07c8f..7017907a 100644 --- a/src/renderer/src/app/Onboarding.tsx +++ b/src/renderer/src/app/Onboarding.tsx @@ -384,8 +384,9 @@ function validateProvider(draft: Draft, providers: ProviderCatalogEntry[]): stri const selected = providers.find((entry) => entry.key === draft.providerKey); if (!selected) return t("Provider is no longer available."); if (selected.customEndpoint && !draft.providerBaseUrl.trim()) return t("Base URL is required."); - if (!draft.providerSecret.trim()) { - return selected.authType === "api_key" || selected.customEndpoint ? t("API key is required.") : t("Access token is required."); + // A custom endpoint may be a local server that takes no key. + if (!draft.providerSecret.trim() && !selected.customEndpoint) { + return selected.authType === "api_key" ? t("API key is required.") : t("Access token is required."); } if (!draft.providerModel.trim()) return t("Model is required."); return null; diff --git a/src/renderer/src/app/OnboardingProviderStep.tsx b/src/renderer/src/app/OnboardingProviderStep.tsx index 9829311a..704f4faa 100644 --- a/src/renderer/src/app/OnboardingProviderStep.tsx +++ b/src/renderer/src/app/OnboardingProviderStep.tsx @@ -78,7 +78,7 @@ function ProviderCredentialFields({ const hasCredential = isOAuthLike ? Boolean(draft.providerSecret.trim()) : isCustom - ? Boolean(draft.providerBaseUrl.trim() && draft.providerSecret.trim()) + ? Boolean(draft.providerBaseUrl.trim()) : Boolean(draft.providerSecret.trim()); const loadModels = async (secret: string) => { @@ -133,7 +133,7 @@ function ProviderCredentialFields({ onChange={(event) => onChange({ providerBaseUrl: event.target.value })} /> - + = { "superseded": "已被取代", "stalled": "已停滞", "Resume": "恢复", + "API key (if the server asks for one)": "API 密钥(服务需要时填写)", "Checking...": "检查中...", "Check for agent work": "检查能否胜任 Agent 工作", "This model follows instructions and calls tools.": "该模型能遵循指令并调用工具。", diff --git a/src/renderer/src/pages/settings/ProviderConnectionDialog.tsx b/src/renderer/src/pages/settings/ProviderConnectionDialog.tsx index 866aa0cc..4a7fa34d 100644 --- a/src/renderer/src/pages/settings/ProviderConnectionDialog.tsx +++ b/src/renderer/src/pages/settings/ProviderConnectionDialog.tsx @@ -88,7 +88,7 @@ function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pr onChange={(event) => setBaseUrl(event.target.value)} /> - + ); diff --git a/src/shared/provider-catalog.ts b/src/shared/provider-catalog.ts index 6075bb79..4c2227e5 100644 --- a/src/shared/provider-catalog.ts +++ b/src/shared/provider-catalog.ts @@ -162,7 +162,7 @@ const RAW_CATALOG: Omit[] defaultModel: null, capabilityTags: ["custom", "openai-compatible"], signInUrl: null, - credentialHelp: "Set the base URL (ending in /v1) and an API key for any OpenAI Chat Completions–compatible endpoint, then enter the model id.", + credentialHelp: "Set the base URL (ending in /v1) of any OpenAI Chat Completions–compatible endpoint, then enter the model id. A server on this computer or your local network may use http:// and needs a key only if it asks for one.", customEndpoint: "openai", }, { @@ -172,7 +172,7 @@ const RAW_CATALOG: Omit[] defaultModel: null, capabilityTags: ["custom", "anthropic-compatible"], signInUrl: null, - credentialHelp: "Set the base URL and an API key for any Anthropic Messages–compatible endpoint, then enter the model id.", + credentialHelp: "Set the base URL of any Anthropic Messages–compatible endpoint, then enter the model id. A server on this computer or your local network may use http:// and needs a key only if it asks for one.", customEndpoint: "anthropic", }, ]; diff --git a/tests/contract/provider-contracts.test.ts b/tests/contract/provider-contracts.test.ts index 821487d9..b2c6fb4a 100644 --- a/tests/contract/provider-contracts.test.ts +++ b/tests/contract/provider-contracts.test.ts @@ -92,7 +92,7 @@ describe("provider contracts", () => { expect(() => providerAdapters[providerKey].parseAuthProfile(JSON.stringify({ baseUrl: "http://api.example.com/v1", secret: "sk-custom", - }))).toThrow("Base URL must use HTTPS."); + }))).toThrow("Base URL must use HTTPS unless it is on this computer or the local network."); expect(() => providerAdapters[providerKey].parseAuthProfile(JSON.stringify({ baseUrl: "https://user:token@api.example.com/v1", secret: "sk-custom", diff --git a/tests/unit/custom-endpoint-local.test.ts b/tests/unit/custom-endpoint-local.test.ts new file mode 100644 index 00000000..4be5f0ce --- /dev/null +++ b/tests/unit/custom-endpoint-local.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { parseAnthropicCompatProfile, parseOpenAiCompatProfile } from "../../src/main/services/provider-auth-profiles"; +import { runCustomAnthropicTurn, runCustomOpenAiTurn } from "../../src/main/services/provider-tool-turns/turns"; + +const profile = (baseUrl: string, secret = "") => parseOpenAiCompatProfile(JSON.stringify({ baseUrl, secret })); + +afterEach(() => vi.unstubAllGlobals()); + +describe("custom endpoints on this computer or the local network", () => { + test("plain HTTP is allowed for loopback, private networks and .local names, and the key may be empty", () => { + for (const url of ["http://localhost:11434/v1", "http://127.0.0.1:1234/v1", "http://[::1]:8080/v1", "http://10.0.0.5/v1", "http://172.20.1.1:8000/v1", "http://192.168.1.20:1234/v1", "http://studio.local:8080/v1"]) { + expect(profile(url)).toEqual({ type: "openai_compat", baseUrl: url.replace(/\/$/, ""), apiKey: null }); + } + expect(parseAnthropicCompatProfile(JSON.stringify({ baseUrl: "http://localhost:8080", secret: "" }))).toEqual({ type: "anthropic_compat", baseUrl: "http://localhost:8080", apiKey: null }); + expect(profile("https://api.example.com/v1", " sk-1 ")).toEqual({ type: "openai_compat", baseUrl: "https://api.example.com/v1", apiKey: "sk-1" }); + }); + + test("plain HTTP to anywhere else is refused, since the key and prompts would cross the internet in the clear", () => { + for (const url of ["http://api.example.com/v1", "http://172.32.0.1/v1", "http://8.8.8.8/v1", "http://localhost.example.com/v1"]) { + expect(() => profile(url)).toThrow("Base URL must use HTTPS unless it is on this computer or the local network."); + } + }); + + test("a turn without a key sends no auth header; with one it does", async () => { + const headers: Array> = []; + vi.stubGlobal("fetch", async (url: string, init: RequestInit) => { + headers.push(init.headers as Record); + const body = String(url).endsWith("/messages") + ? { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } + : { choices: [{ message: { content: "ok" }, finish_reason: "stop" }] }; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + }); + const input = { model: "llama3", instructions: null, messages: [{ role: "user" as const, text: "hi" }], tools: [] }; + await runCustomOpenAiTurn({ ...input, credential: { baseUrl: "http://localhost:11434/v1" } }); + await runCustomOpenAiTurn({ ...input, credential: { baseUrl: "http://localhost:11434/v1", apiKey: "sk-1" } }); + await runCustomAnthropicTurn({ ...input, credential: { baseUrl: "http://localhost:8080" } }); + expect(headers.map((sent) => sent.authorization ?? sent["x-api-key"] ?? null)).toEqual([null, "Bearer sk-1", null]); + }); +}); diff --git a/tests/unit/provider-service.test.ts b/tests/unit/provider-service.test.ts index faa582d7..0468cdb3 100644 --- a/tests/unit/provider-service.test.ts +++ b/tests/unit/provider-service.test.ts @@ -623,7 +623,7 @@ describe("provider service", () => { providerId: customOpenAiProviderId, secret: JSON.stringify({ baseUrl: "http://api.example.com/v1", secret: "key" }), model: "gpt-5", - }))).toThrow("Base URL must use HTTPS."); + }))).toThrow("Base URL must use HTTPS unless it is on this computer or the local network."); connection.close(); }); });