import type { ProviderEnv } from "../types.ts"; import { formatThrownValue } from "../utils/diagnostics.ts"; import type { ApiKeyAuth, ApiKeyCredential, AuthContext, AuthResult, Credential, CredentialStore, OAuthAuth, OAuthCredential, ProviderAuth, } from "./types.ts"; export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth"; export interface AuthResolutionOverrides { apiKey?: string; env?: ProviderEnv; /** Require this much remaining OAuth-token validity; defaults to five minutes. */ minOAuthValidityMs?: number; } export class ModelsError extends Error { readonly code: ModelsErrorCode; constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) { super(withCauseDetail(message, options?.cause), options); this.name = "ModelsError"; this.code = code; } } /** Callers surface `error.message` only, so keep the underlying reason in it. */ function withCauseDetail(message: string, cause: unknown): string { if (cause === undefined || cause === null) return message; const detail = formatThrownValue(cause).trim(); if (!detail || message.includes(detail)) return message; return `${message}: ${detail}`; } /** * Auth resolution shared by the `Models` and `ImagesModels` collections. * A stored credential owns the provider: ambient/env is consulted only when * nothing is stored. No silent env fallback after a failed refresh or for a * credential type without a matching handler. */ export async function resolveProviderAuth( provider: { id: string; auth: ProviderAuth }, credentials: CredentialStore, authContext: AuthContext, overrides?: AuthResolutionOverrides, ): Promise { const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext; if (overrides?.apiKey !== undefined && provider.auth.apiKey) { return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, { type: "api_key", key: overrides.apiKey, env: overrides.env, }); } const stored = await readCredential(credentials, provider.id); if (stored) { if (stored.type === "oauth" && provider.auth.oauth) { return resolveStoredOAuth( credentials, provider.id, provider.auth.oauth, stored, overrides?.minOAuthValidityMs, ); } if (stored.type === "api_key" && provider.auth.apiKey) { const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored; return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, credential); } return undefined; } // Ambient (env vars, AWS profiles, ADC files). return provider.auth.apiKey ? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined) : undefined; } function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext { return { env: async (name) => env[name] || (await base.env(name)), fileExists: (path) => base.fileExists(path), }; } const DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1000; /** * OAuth resolution with double-checked locking: tokens with less than five * minutes remaining lock, re-check expiry under the lock, refresh once * globally, and persist the rotated credential before release. */ async function resolveStoredOAuth( credentials: CredentialStore, providerId: string, oauth: OAuthAuth, stored: OAuthCredential, minOAuthValidityMs?: number, ): Promise { const minimumValidityMs = Math.max(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS, minOAuthValidityMs ?? 0); const expiresSoon = (credential: OAuthCredential) => Date.now() + minimumValidityMs >= credential.expires; let credential = stored; if (expiresSoon(credential)) { // Optimistic check said expired; the authoritative check runs under the lock. let post: Credential | undefined; try { post = await credentials.modify(providerId, async (current) => { if (current?.type !== "oauth") return undefined; // logged out meanwhile if (!expiresSoon(current)) return undefined; // another process/request refreshed try { return await oauth.refresh(current); } catch (error) { throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error }); } }); } catch (error) { if (error instanceof ModelsError) throw error; throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error }); } if (post?.type !== "oauth") return undefined; // logged out meanwhile credential = post; // The normal five-minute window triggers a refresh but does not impose a // provider contract. Explicit callers (such as bearer-token export) do // require the requested minimum after the refresh. if (minOAuthValidityMs !== undefined && expiresSoon(credential)) { throw new ModelsError("oauth", `OAuth refresh returned a token that expires too soon for ${providerId}`); } } try { return { auth: await oauth.toAuth(credential), source: "OAuth" }; } catch (error) { throw new ModelsError("oauth", `OAuth auth derivation failed for ${providerId}`, { cause: error }); } } async function resolveApiKey( authContext: AuthContext, apiKey: ApiKeyAuth, providerId: string, credential: ApiKeyCredential | undefined, ): Promise { try { return await apiKey.resolve({ ctx: authContext, credential }); } catch (error) { throw new ModelsError("auth", `API key auth failed for provider ${providerId}`, { cause: error }); } } async function readCredential(credentials: CredentialStore, providerId: string): Promise { try { return await credentials.read(providerId); } catch (error) { throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error }); } }