Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
8.0 KiB
TypeScript
174 lines
8.0 KiB
TypeScript
import type { JsonRecord } from "./provider-http";
|
|
import { isRecord } from "./provider-http";
|
|
import { parseStoredOAuth } from "./oauth/stored-oauth";
|
|
|
|
export type ProviderAuthProfile =
|
|
| { type: "api_key"; apiKey: string }
|
|
| { type: "access_token"; accessToken: string }
|
|
| { type: "account_token"; accessToken: string }
|
|
| { 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 | null }
|
|
| { type: "anthropic_compat"; baseUrl: string; apiKey: string | null };
|
|
|
|
export function parseApiKey(secret: string): ProviderAuthProfile {
|
|
return { type: "api_key", apiKey: nonEmptySecret(secret, "API key") };
|
|
}
|
|
|
|
/** A sign-in provider's parser: it accepts only what a run can use and renew (see parseStoredOAuth). */
|
|
export function oauthProfileParser(providerKey: string, type: "access_token" | "account_token"): (secret: string) => ProviderAuthProfile {
|
|
return (secret) => ({ type, accessToken: parseStoredOAuth(providerKey, secret).access });
|
|
}
|
|
|
|
export function parseAzureProfile(secret: string): ProviderAuthProfile {
|
|
const parsed = parseCloudSecret(secret);
|
|
const baseUrl = requiredString(parsed.baseUrl, "Azure base URL").replace(/\/+$/, "");
|
|
const deployment = requiredString(parsed.deployment, "Azure deployment");
|
|
return { type: "azure_openai", baseUrl: normalizedHttpsUrl(baseUrl, "Azure base URL"), deployment, apiKey: requiredString(parsed.secret, "Azure API key") };
|
|
}
|
|
|
|
export function parseBedrockProfile(secret: string): ProviderAuthProfile {
|
|
const parsed = parseCloudSecret(secret);
|
|
const credential = parseCredentialJson(requiredString(parsed.secret, "AWS credential"));
|
|
return {
|
|
type: "aws_bedrock",
|
|
region: requiredString(parsed.region, "AWS region"),
|
|
accessKeyId: requiredString(credential.accessKeyId, "AWS access key id"),
|
|
secretAccessKey: requiredString(credential.secretAccessKey, "AWS secret access key"),
|
|
sessionToken: typeof credential.sessionToken === "string" && credential.sessionToken.trim() ? credential.sessionToken.trim() : null,
|
|
};
|
|
}
|
|
|
|
export function parseVertexProfile(secret: string): ProviderAuthProfile {
|
|
const parsed = parseCloudSecret(secret);
|
|
const serviceAccountJson = requiredString(parsed.secret, "Vertex service account JSON");
|
|
parseCredentialJson(serviceAccountJson);
|
|
return {
|
|
type: "vertex_ai",
|
|
projectId: requiredString(parsed.projectId, "Vertex project id"),
|
|
region: requiredString(parsed.region, "Vertex region"),
|
|
serviceAccountJson,
|
|
};
|
|
}
|
|
|
|
export function parseOpenAiCompatProfile(secret: string): ProviderAuthProfile {
|
|
const parsed = parseCloudSecret(secret);
|
|
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: customEndpointUrl(requiredString(parsed.baseUrl, "Base URL")), apiKey: optionalSecret(parsed.secret) };
|
|
}
|
|
|
|
export function requireOpenAiCompatProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "openai_compat" }> {
|
|
if (profile.type !== "openai_compat") throw new Error("OpenAI-compatible profile is required.");
|
|
return profile;
|
|
}
|
|
|
|
export function requireAnthropicCompatProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "anthropic_compat" }> {
|
|
if (profile.type !== "anthropic_compat") throw new Error("Anthropic-compatible profile is required.");
|
|
return profile;
|
|
}
|
|
|
|
export function requireApiKeyProfile(profile: ProviderAuthProfile, label: string): Extract<ProviderAuthProfile, { type: "api_key" }> {
|
|
if (profile.type !== "api_key") throw new Error(`${label} API key profile is required.`);
|
|
return profile;
|
|
}
|
|
|
|
export function requireAccessTokenProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "access_token" }> {
|
|
if (profile.type !== "access_token") throw new Error("Access token profile is required.");
|
|
return profile;
|
|
}
|
|
|
|
export function requireAccountTokenProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "account_token" }> {
|
|
if (profile.type !== "account_token") throw new Error("Account token profile is required.");
|
|
return profile;
|
|
}
|
|
|
|
export function requireAzureProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "azure_openai" }> {
|
|
if (profile.type !== "azure_openai") throw new Error("Azure OpenAI profile is required.");
|
|
return profile;
|
|
}
|
|
|
|
export function requireBedrockProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "aws_bedrock" }> {
|
|
if (profile.type !== "aws_bedrock") throw new Error("AWS Bedrock profile is required.");
|
|
return profile;
|
|
}
|
|
|
|
export function requireVertexProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "vertex_ai" }> {
|
|
if (profile.type !== "vertex_ai") throw new Error("Vertex AI profile is required.");
|
|
return profile;
|
|
}
|
|
|
|
function parseCloudSecret(secret: string): JsonRecord {
|
|
try {
|
|
const parsed = JSON.parse(secret) as unknown;
|
|
if (!isRecord(parsed)) throw new Error("Cloud credential profile is invalid.");
|
|
return parsed;
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) throw new Error("Cloud credential profile must be JSON.");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function parseCredentialJson(value: string): JsonRecord {
|
|
try {
|
|
const parsed = JSON.parse(value) as unknown;
|
|
if (!isRecord(parsed)) throw new Error("Credential JSON is invalid.");
|
|
return parsed;
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) throw new Error("Credential must be JSON.");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function requiredString(value: unknown, label: string): string {
|
|
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required.`);
|
|
return value.trim();
|
|
}
|
|
|
|
function nonEmptySecret(secret: string, label: string): string {
|
|
const value = secret.trim();
|
|
if (!value) throw new Error(`${label} is required.`);
|
|
return value;
|
|
}
|
|
|
|
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(/\/+$/, "");
|
|
}
|