ef231c4910
closes #6021
142 lines
4.7 KiB
TypeScript
142 lines
4.7 KiB
TypeScript
import type { Api, ImagesApi, ImagesModel, Model, ProviderEnv } from "../types.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;
|
|
}
|
|
|
|
export class ModelsError extends Error {
|
|
readonly code: ModelsErrorCode;
|
|
|
|
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
|
|
super(message, options);
|
|
this.name = "ModelsError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
/** Model shape auth resolution receives: chat or image-generation models. */
|
|
export type AuthModel = Model<Api> | ImagesModel<ImagesApi>;
|
|
|
|
/**
|
|
* 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 },
|
|
model: AuthModel,
|
|
credentials: CredentialStore,
|
|
authContext: AuthContext,
|
|
overrides?: AuthResolutionOverrides,
|
|
): Promise<AuthResult | undefined> {
|
|
const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext;
|
|
|
|
if (overrides?.apiKey !== undefined && provider.auth.apiKey) {
|
|
return resolveApiKey(requestAuthContext, provider.auth.apiKey, model, {
|
|
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);
|
|
}
|
|
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, model, credential);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Ambient (env vars, AWS profiles, ADC files).
|
|
return provider.auth.apiKey ? resolveApiKey(requestAuthContext, provider.auth.apiKey, model, undefined) : undefined;
|
|
}
|
|
|
|
function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext {
|
|
return {
|
|
env: async (name) => env[name] || (await base.env(name)),
|
|
fileExists: (path) => base.fileExists(path),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* OAuth resolution with double-checked locking (same pattern as today's
|
|
* AuthStorage): valid tokens cost zero locks; expired tokens 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,
|
|
): Promise<AuthResult | undefined> {
|
|
let credential = stored;
|
|
|
|
if (Date.now() >= credential.expires) {
|
|
// 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 (Date.now() < current.expires) 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;
|
|
}
|
|
|
|
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,
|
|
model: AuthModel,
|
|
credential: ApiKeyCredential | undefined,
|
|
): Promise<AuthResult | undefined> {
|
|
try {
|
|
return await apiKey.resolve({ model, ctx: authContext, credential });
|
|
} catch (error) {
|
|
throw new ModelsError("auth", `API key auth failed for provider ${model.provider}`, { cause: error });
|
|
}
|
|
}
|
|
|
|
async function readCredential(credentials: CredentialStore, providerId: string): Promise<Credential | undefined> {
|
|
try {
|
|
return await credentials.read(providerId);
|
|
} catch (error) {
|
|
throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
|
|
}
|
|
}
|