Q85: custom endpoints accept local HTTP servers and keyless access
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9599d0aecd
commit
da0b94ce59
11 files changed
+90
-17
No files matched your search
@@ -8,8 +8,8 @@ export type ProviderAuthProfile =
|
|||||||
| { type: "azure_openai"; baseUrl: string; deployment: string; apiKey: string }
|
| { type: "azure_openai"; baseUrl: string; deployment: string; apiKey: string }
|
||||||
| { type: "aws_bedrock"; region: string; accessKeyId: string; secretAccessKey: string; sessionToken: string | null }
|
| { type: "aws_bedrock"; region: string; accessKeyId: string; secretAccessKey: string; sessionToken: string | null }
|
||||||
| { type: "vertex_ai"; projectId: string; region: string; serviceAccountJson: string }
|
| { type: "vertex_ai"; projectId: string; region: string; serviceAccountJson: string }
|
||||||
| { type: "openai_compat"; baseUrl: string; apiKey: string }
|
| { type: "openai_compat"; baseUrl: string; apiKey: string | null }
|
||||||
| { type: "anthropic_compat"; baseUrl: string; apiKey: string };
|
| { type: "anthropic_compat"; baseUrl: string; apiKey: string | null };
|
||||||
|
|
||||||
export function parseApiKey(secret: string): ProviderAuthProfile {
|
export function parseApiKey(secret: string): ProviderAuthProfile {
|
||||||
return { type: "api_key", apiKey: nonEmptySecret(secret, "API key") };
|
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 {
|
export function parseOpenAiCompatProfile(secret: string): ProviderAuthProfile {
|
||||||
const parsed = parseCloudSecret(secret);
|
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 {
|
export function parseAnthropicCompatProfile(secret: string): ProviderAuthProfile {
|
||||||
const parsed = parseCloudSecret(secret);
|
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<ProviderAuthProfile, { type: "openai_compat" }> {
|
export function requireOpenAiCompatProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "openai_compat" }> {
|
||||||
@@ -159,6 +159,35 @@ function nonEmptySecret(secret: string, label: string): string {
|
|||||||
function normalizedHttpsUrl(value: string, label: string): string {
|
function normalizedHttpsUrl(value: string, label: string): string {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
if (url.protocol !== "https:") throw new Error(`${label} must use HTTPS.`);
|
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.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.`);
|
if (url.search || url.hash) throw new Error(`${label} must not include query strings or fragments.`);
|
||||||
return url.toString().replace(/\/+$/, "");
|
return url.toString().replace(/\/+$/, "");
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ function resolveCloudCredential(providerKey: string, secret: string): TurnCreden
|
|||||||
switch (providerKey) {
|
switch (providerKey) {
|
||||||
case "custom-openai": {
|
case "custom-openai": {
|
||||||
const profile = requireOpenAiCompatProfile(parseOpenAiCompatProfile(secret));
|
const profile = requireOpenAiCompatProfile(parseOpenAiCompatProfile(secret));
|
||||||
return { apiKey: profile.apiKey, baseUrl: profile.baseUrl };
|
return { baseUrl: profile.baseUrl, ...(profile.apiKey ? { apiKey: profile.apiKey } : {}) };
|
||||||
}
|
}
|
||||||
case "custom-anthropic": {
|
case "custom-anthropic": {
|
||||||
const profile = requireAnthropicCompatProfile(parseAnthropicCompatProfile(secret));
|
const profile = requireAnthropicCompatProfile(parseAnthropicCompatProfile(secret));
|
||||||
return { apiKey: profile.apiKey, baseUrl: profile.baseUrl };
|
return { baseUrl: profile.baseUrl, ...(profile.apiKey ? { apiKey: profile.apiKey } : {}) };
|
||||||
}
|
}
|
||||||
case "azure-openai": {
|
case "azure-openai": {
|
||||||
const profile = requireAzureProfile(parseAzureProfile(secret));
|
const profile = requireAzureProfile(parseAzureProfile(secret));
|
||||||
|
|||||||
@@ -187,7 +187,9 @@ export async function runCustomOpenAiTurn(input: ProviderToolTurnInput): Promise
|
|||||||
stream: false,
|
stream: false,
|
||||||
// No output cap: whatever serves a custom endpoint decides which cap field, if any, it accepts.
|
// 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<string, string> = 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);
|
const parsed = parseChatChoice(response.data);
|
||||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data), rateLimitState: response.rateLimitState };
|
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),
|
messages: toAnthropicMessages(input.messages),
|
||||||
...(input.instructions ? { system: input.instructions } : {}),
|
...(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<string, string> = 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);
|
const parsed = parseAnthropicContent(response.data);
|
||||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data), rateLimitState: response.rateLimitState };
|
return { ...parsed, rawResponseJson: JSON.stringify(response.data), rateLimitState: response.rateLimitState };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,8 +384,9 @@ function validateProvider(draft: Draft, providers: ProviderCatalogEntry[]): stri
|
|||||||
const selected = providers.find((entry) => entry.key === draft.providerKey);
|
const selected = providers.find((entry) => entry.key === draft.providerKey);
|
||||||
if (!selected) return t("Provider is no longer available.");
|
if (!selected) return t("Provider is no longer available.");
|
||||||
if (selected.customEndpoint && !draft.providerBaseUrl.trim()) return t("Base URL is required.");
|
if (selected.customEndpoint && !draft.providerBaseUrl.trim()) return t("Base URL is required.");
|
||||||
if (!draft.providerSecret.trim()) {
|
// A custom endpoint may be a local server that takes no key.
|
||||||
return selected.authType === "api_key" || selected.customEndpoint ? t("API key is required.") : t("Access token is required.");
|
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.");
|
if (!draft.providerModel.trim()) return t("Model is required.");
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function ProviderCredentialFields({
|
|||||||
const hasCredential = isOAuthLike
|
const hasCredential = isOAuthLike
|
||||||
? Boolean(draft.providerSecret.trim())
|
? Boolean(draft.providerSecret.trim())
|
||||||
: isCustom
|
: isCustom
|
||||||
? Boolean(draft.providerBaseUrl.trim() && draft.providerSecret.trim())
|
? Boolean(draft.providerBaseUrl.trim())
|
||||||
: Boolean(draft.providerSecret.trim());
|
: Boolean(draft.providerSecret.trim());
|
||||||
|
|
||||||
const loadModels = async (secret: string) => {
|
const loadModels = async (secret: string) => {
|
||||||
@@ -133,7 +133,7 @@ function ProviderCredentialFields({
|
|||||||
onChange={(event) => onChange({ providerBaseUrl: event.target.value })}
|
onChange={(event) => onChange({ providerBaseUrl: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t("API key")}>
|
<Field label={t("API key (if the server asks for one)")}>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const zh: Record<string, string> = {
|
|||||||
"superseded": "已被取代",
|
"superseded": "已被取代",
|
||||||
"stalled": "已停滞",
|
"stalled": "已停滞",
|
||||||
"Resume": "恢复",
|
"Resume": "恢复",
|
||||||
|
"API key (if the server asks for one)": "API 密钥(服务需要时填写)",
|
||||||
"Checking...": "检查中...",
|
"Checking...": "检查中...",
|
||||||
"Check for agent work": "检查能否胜任 Agent 工作",
|
"Check for agent work": "检查能否胜任 Agent 工作",
|
||||||
"This model follows instructions and calls tools.": "该模型能遵循指令并调用工具。",
|
"This model follows instructions and calls tools.": "该模型能遵循指令并调用工具。",
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pr
|
|||||||
onChange={(event) => setBaseUrl(event.target.value)}
|
onChange={(event) => setBaseUrl(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t("API key")}><input className="input" name="secret" type="password" required /></Field>
|
<Field label={t("API key (if the server asks for one)")}><input className="input" name="secret" type="password" /></Field>
|
||||||
<ModelPicker entry={entry} provider={provider} />
|
<ModelPicker entry={entry} provider={provider} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ const RAW_CATALOG: Omit<ProviderCatalogEntry, "models" | "supportsLiveModels">[]
|
|||||||
defaultModel: null,
|
defaultModel: null,
|
||||||
capabilityTags: ["custom", "openai-compatible"],
|
capabilityTags: ["custom", "openai-compatible"],
|
||||||
signInUrl: null,
|
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",
|
customEndpoint: "openai",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -172,7 +172,7 @@ const RAW_CATALOG: Omit<ProviderCatalogEntry, "models" | "supportsLiveModels">[]
|
|||||||
defaultModel: null,
|
defaultModel: null,
|
||||||
capabilityTags: ["custom", "anthropic-compatible"],
|
capabilityTags: ["custom", "anthropic-compatible"],
|
||||||
signInUrl: null,
|
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",
|
customEndpoint: "anthropic",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe("provider contracts", () => {
|
|||||||
expect(() => providerAdapters[providerKey].parseAuthProfile(JSON.stringify({
|
expect(() => providerAdapters[providerKey].parseAuthProfile(JSON.stringify({
|
||||||
baseUrl: "http://api.example.com/v1",
|
baseUrl: "http://api.example.com/v1",
|
||||||
secret: "sk-custom",
|
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({
|
expect(() => providerAdapters[providerKey].parseAuthProfile(JSON.stringify({
|
||||||
baseUrl: "https://user:token@api.example.com/v1",
|
baseUrl: "https://user:token@api.example.com/v1",
|
||||||
secret: "sk-custom",
|
secret: "sk-custom",
|
||||||
|
|||||||
@@ -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<Record<string, string>> = [];
|
||||||
|
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
|
||||||
|
headers.push(init.headers as Record<string, string>);
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -623,7 +623,7 @@ describe("provider service", () => {
|
|||||||
providerId: customOpenAiProviderId,
|
providerId: customOpenAiProviderId,
|
||||||
secret: JSON.stringify({ baseUrl: "http://api.example.com/v1", secret: "key" }),
|
secret: JSON.stringify({ baseUrl: "http://api.example.com/v1", secret: "key" }),
|
||||||
model: "gpt-5",
|
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();
|
connection.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user