/** * Shared factory for API-key-paste "login" flows. * * Several providers (Cerebras, Synthetic, Moonshot, Together, NanoGPT, ZenMux) * don't actually implement OAuth — they just ask the user to paste an API key, * optionally validate it, and return the trimmed key. */ import * as AIError from "../error"; import { validateAnthropicCompatibleApiKey, validateApiKeyAgainstModelsEndpoint, validateOpenAICompatibleApiKey, } from "./api-key-validation"; import type { OAuthController } from "./oauth/types"; type ChatCompletionsValidation = { kind: "chat-completions"; provider: string; baseUrl: string; model: string; }; type AnthropicMessagesValidation = { kind: "anthropic-messages"; provider: string; baseUrl: string; model: string; }; type ModelsEndpointValidation = { kind: "models-endpoint"; provider: string; modelsUrl: string | (() => string); headers?: Record | (() => Record | undefined); }; export type ApiKeyLoginConfig = { /** Display name used in error messages, e.g. "Cerebras", "NanoGPT". */ providerLabel: string; /** URL opened in browser for the user to grab their key, or omitted to skip onAuth. */ authUrl?: string; /** Instructions shown with the onAuth callback, or omitted to skip onAuth. */ instructions?: string; /** Prompt message shown when asking for the key paste. */ promptMessage: string; /** Placeholder string for the prompt (e.g. "sk-...", "csk-..."). */ placeholder: string; /** Validation strategy, or `null` to skip validation. */ validation: ChatCompletionsValidation | AnthropicMessagesValidation | ModelsEndpointValidation | null; /** Value returned for an empty key; also allows an empty prompt response. */ emptyKeyFallback?: string; }; export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthController) => Promise { return async function login(options: OAuthController): Promise { if (!options.onPrompt) { throw new AIError.OnPromptRequiredError(config.providerLabel); } if (config.authUrl && config.instructions) { options.onAuth?.({ url: config.authUrl, instructions: config.instructions, }); } const apiKey = config.emptyKeyFallback === undefined ? await options.onPrompt({ message: config.promptMessage, placeholder: config.placeholder, }) : await options.onPrompt({ message: config.promptMessage, placeholder: config.placeholder, allowEmpty: true, }); if (options.signal?.aborted) { throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { if (config.emptyKeyFallback !== undefined) { return config.emptyKeyFallback; } throw new AIError.ApiKeyRequiredError(); } if (config.validation) { options.onProgress?.("Validating API key..."); if (config.validation.kind === "chat-completions") { await validateOpenAICompatibleApiKey({ provider: config.validation.provider, apiKey: trimmed, baseUrl: config.validation.baseUrl, model: config.validation.model, signal: options.signal, fetch: options.fetch, }); } else if (config.validation.kind === "anthropic-messages") { await validateAnthropicCompatibleApiKey({ provider: config.validation.provider, apiKey: trimmed, baseUrl: config.validation.baseUrl, model: config.validation.model, signal: options.signal, fetch: options.fetch, }); } else { await validateApiKeyAgainstModelsEndpoint({ provider: config.validation.provider, apiKey: trimmed, modelsUrl: typeof config.validation.modelsUrl === "function" ? config.validation.modelsUrl() : config.validation.modelsUrl, headers: config.validation.headers, signal: options.signal, fetch: options.fetch, }); } } return trimmed; }; }