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: "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<ProviderAuthProfile, { type: "openai_compat" }> {
|
||||
@@ -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(/\/+$/, "");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<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);
|
||||
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<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);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data), rateLimitState: response.rateLimitState };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user