Files
oh-my-pi/packages/ai/src/registry/api-key-login.ts
T
can1357 5fa578fa3b refactor(ai): migrated hand-rolled api-key logins to shared factory
- Nine providers reimplemented createApiKeyLogin's exact prompt/trim/abort flow
  verbatim; they now call the existing helper instead.
- Extended ApiKeyLoginConfig with optional authUrl/instructions and an
  emptyKeyFallback so the local-server family (llama.cpp, vLLM, LM Studio)
  collapses onto the same helper without changing observable behavior.
- Left ollama, ollama-cloud, nvidia, qwen-portal, github-copilot and both
  alibaba flows hand-written: their error classes, messages or callback
  ordering differ, so migrating them would change observable behavior.
2026-08-08 06:32:00 +02:00

130 lines
3.8 KiB
TypeScript

/**
* 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<string, string> | (() => Record<string, string> | 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<string> {
return async function login(options: OAuthController): Promise<string> {
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;
};
}