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.
This commit is contained in:
can1357
2026-08-08 06:32:00 +02:00
parent a0efb56b6b
commit 5fa578fa3b
10 changed files with 118 additions and 244 deletions
+26 -12
View File
@@ -38,16 +38,18 @@ type ModelsEndpointValidation = {
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. */
authUrl: string;
/** Instructions shown with the onAuth callback. */
instructions: 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> {
@@ -56,15 +58,24 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
throw new AIError.OnPromptRequiredError(config.providerLabel);
}
options.onAuth?.({
url: config.authUrl,
instructions: config.instructions,
});
if (config.authUrl && config.instructions) {
options.onAuth?.({
url: config.authUrl,
instructions: config.instructions,
});
}
const apiKey = await options.onPrompt({
message: config.promptMessage,
placeholder: config.placeholder,
});
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();
@@ -72,6 +83,9 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
const trimmed = apiKey.trim();
if (!trimmed) {
if (config.emptyKeyFallback !== undefined) {
return config.emptyKeyFallback;
}
throw new AIError.ApiKeyRequiredError();
}
@@ -1,5 +1,5 @@
import * as AIError from "../error";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
const AUTH_URL = "https://developers.cloudflare.com/ai-gateway/configuration/authentication/";
@@ -10,33 +10,14 @@ const AUTH_URL = "https://developers.cloudflare.com/ai-gateway/configuration/aut
* Opens browser to Cloudflare AI Gateway authentication docs and prompts for a gateway token/API key.
* Returns the API key directly (not OAuthCredentials - this isn't OAuth).
*/
export async function loginCloudflareAiGateway(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new AIError.OnPromptRequiredError("Cloudflare AI Gateway");
}
options.onAuth?.({
url: AUTH_URL,
instructions:
"Copy your Cloudflare AI Gateway token/API key. Configure account/gateway base URL in models config.",
});
const apiKey = await options.onPrompt({
message: "Paste your Cloudflare AI Gateway token/API key",
placeholder: "cf-aig-...",
});
if (options.signal?.aborted) {
throw new AIError.LoginCancelledError();
}
const trimmed = apiKey.trim();
if (!trimmed) {
throw new AIError.ApiKeyRequiredError();
}
return trimmed;
}
export const loginCloudflareAiGateway = createApiKeyLogin({
providerLabel: "Cloudflare AI Gateway",
authUrl: AUTH_URL,
instructions: "Copy your Cloudflare AI Gateway token/API key. Configure account/gateway base URL in models config.",
promptMessage: "Paste your Cloudflare AI Gateway token/API key",
placeholder: "cf-aig-...",
validation: null,
});
export const cloudflareAiGatewayProvider = {
id: "cloudflare-ai-gateway",
+11 -29
View File
@@ -1,5 +1,5 @@
import * as AIError from "../error";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
const AUTH_URL = "https://kagi.com/settings/api";
@@ -10,33 +10,15 @@ const AUTH_URL = "https://kagi.com/settings/api";
* Opens browser to API settings and prompts user to paste their API key.
* Returns the API key directly (not OAuthCredentials - this isn't OAuth).
*/
export async function loginKagi(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new AIError.OnPromptRequiredError("Kagi");
}
options.onAuth?.({
url: AUTH_URL,
instructions:
"Copy your Kagi Search API key from Kagi API settings. Search API access is beta-only; if unavailable, email support@kagi.com.",
});
const apiKey = await options.onPrompt({
message: "Paste your Kagi API key",
placeholder: "KG_...",
});
if (options.signal?.aborted) {
throw new AIError.LoginCancelledError();
}
const trimmed = apiKey.trim();
if (!trimmed) {
throw new AIError.ApiKeyRequiredError();
}
return trimmed;
}
export const loginKagi = createApiKeyLogin({
providerLabel: "Kagi",
authUrl: AUTH_URL,
instructions:
"Copy your Kagi Search API key from Kagi API settings. Search API access is beta-only; if unavailable, email support@kagi.com.",
promptMessage: "Paste your Kagi API key",
placeholder: "KG_...",
validation: null,
});
export const kagiProvider = {
id: "kagi",
+11 -29
View File
@@ -1,5 +1,5 @@
import * as AIError from "../error";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
const AUTH_URL = "https://docs.litellm.ai/docs/proxy/deploy";
@@ -10,33 +10,15 @@ const AUTH_URL = "https://docs.litellm.ai/docs/proxy/deploy";
* Opens browser to LiteLLM setup docs, prompts user to paste their API key.
* Returns the API key directly (not OAuthCredentials - this isn't OAuth).
*/
export async function loginLiteLLM(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new AIError.OnPromptRequiredError("LiteLLM");
}
options.onAuth?.({
url: AUTH_URL,
instructions:
"Run LiteLLM proxy (default http://localhost:4000/v1; set LITELLM_BASE_URL to customize it), then copy your master key or virtual key",
});
const apiKey = await options.onPrompt({
message: "Paste your LiteLLM API key (master key or virtual key)",
placeholder: "sk-...",
});
if (options.signal?.aborted) {
throw new AIError.LoginCancelledError();
}
const trimmed = apiKey.trim();
if (!trimmed) {
throw new AIError.ApiKeyRequiredError();
}
return trimmed;
}
export const loginLiteLLM = createApiKeyLogin({
providerLabel: "LiteLLM",
authUrl: AUTH_URL,
instructions:
"Run LiteLLM proxy (default http://localhost:4000/v1; set LITELLM_BASE_URL to customize it), then copy your master key or virtual key",
promptMessage: "Paste your LiteLLM API key (master key or virtual key)",
placeholder: "sk-...",
validation: null,
});
export const litellmProvider = {
id: "litellm",
+11 -21
View File
@@ -1,5 +1,5 @@
import * as AIError from "../error";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
const PROVIDER_ID = "llama.cpp";
@@ -7,25 +7,15 @@ const AUTH_URL = "https://github.com/ggml-org/llama.cpp#quick-start";
const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8080";
const DEFAULT_LOCAL_TOKEN = "llama-cpp-local";
export async function loginLlamaCpp(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new AIError.OnPromptRequiredError(PROVIDER_ID);
}
options.onAuth?.({
url: AUTH_URL,
instructions: `Paste your llama.cpp API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}; set LLAMA_CPP_BASE_URL to customize).`,
});
const apiKey = await options.onPrompt({
message: "Paste your llama.cpp API key (optional for local no-auth)",
placeholder: DEFAULT_LOCAL_TOKEN,
allowEmpty: true,
});
if (options.signal?.aborted) {
throw new AIError.LoginCancelledError();
}
const trimmed = apiKey.trim();
return trimmed || DEFAULT_LOCAL_TOKEN;
}
export const loginLlamaCpp = createApiKeyLogin({
providerLabel: PROVIDER_ID,
authUrl: AUTH_URL,
instructions: `Paste your llama.cpp API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}; set LLAMA_CPP_BASE_URL to customize).`,
promptMessage: "Paste your llama.cpp API key (optional for local no-auth)",
placeholder: DEFAULT_LOCAL_TOKEN,
validation: null,
emptyKeyFallback: DEFAULT_LOCAL_TOKEN,
});
export const llamaCppProvider = {
id: PROVIDER_ID,
+9 -20
View File
@@ -1,28 +1,17 @@
import * as AIError from "../error";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
const PROVIDER_ID = "lm-studio";
export const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
export async function loginLmStudio(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new AIError.OnPromptRequiredError(PROVIDER_ID);
}
const apiKey = await options.onPrompt({
message: "Optional: Paste LM Studio API key (to customize endpoint URL, set LM_STUDIO_BASE_URL env var)",
placeholder: DEFAULT_LOCAL_TOKEN,
allowEmpty: true,
});
if (options.signal?.aborted) {
throw new AIError.LoginCancelledError();
}
const trimmed = apiKey.trim();
return trimmed || DEFAULT_LOCAL_TOKEN;
}
export const loginLmStudio = createApiKeyLogin({
providerLabel: PROVIDER_ID,
promptMessage: "Optional: Paste LM Studio API key (to customize endpoint URL, set LM_STUDIO_BASE_URL env var)",
placeholder: DEFAULT_LOCAL_TOKEN,
validation: null,
emptyKeyFallback: DEFAULT_LOCAL_TOKEN,
});
export const lmStudioProvider = {
id: "lm-studio",
+10 -28
View File
@@ -1,5 +1,5 @@
import * as AIError from "../error";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
const AUTH_URL = "https://platform.parallel.ai/settings?tab=api-keys";
@@ -10,32 +10,14 @@ const AUTH_URL = "https://platform.parallel.ai/settings?tab=api-keys";
* Opens browser to the API keys page, prompts the user to paste their API key,
* and returns the API key directly.
*/
export async function loginParallel(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new AIError.OnPromptRequiredError("Parallel");
}
options.onAuth?.({
url: AUTH_URL,
instructions: "Copy your Parallel API key from the Parallel settings page.",
});
const apiKey = await options.onPrompt({
message: "Paste your Parallel API key",
placeholder: "sk_...",
});
if (options.signal?.aborted) {
throw new AIError.LoginCancelledError();
}
const trimmed = apiKey.trim();
if (!trimmed) {
throw new AIError.ApiKeyRequiredError();
}
return trimmed;
}
export const loginParallel = createApiKeyLogin({
providerLabel: "Parallel",
authUrl: AUTH_URL,
instructions: "Copy your Parallel API key from the Parallel settings page.",
promptMessage: "Paste your Parallel API key",
placeholder: "sk_...",
validation: null,
});
export const parallelProvider = {
id: "parallel",
+9 -27
View File
@@ -1,4 +1,4 @@
import * as AIError from "../error";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
@@ -10,32 +10,14 @@ const AUTH_URL = "https://app.tavily.com/home";
* Opens browser to API keys page and prompts user to paste their API key.
* Returns the API key directly (not OAuthCredentials - this isn't OAuth).
*/
export async function loginTavily(options: OAuthLoginCallbacks): Promise<string> {
if (!options.onPrompt) {
throw new AIError.OnPromptRequiredError("Tavily");
}
options.onAuth?.({
url: AUTH_URL,
instructions: "Copy your Tavily API key from the API Keys page.",
});
const apiKey = await options.onPrompt({
message: "Paste your Tavily API key",
placeholder: "tvly-...",
});
if (options.signal?.aborted) {
throw new AIError.LoginCancelledError();
}
const trimmed = apiKey.trim();
if (!trimmed) {
throw new AIError.ApiKeyRequiredError();
}
return trimmed;
}
export const loginTavily = createApiKeyLogin({
providerLabel: "Tavily",
authUrl: AUTH_URL,
instructions: "Copy your Tavily API key from the API Keys page.",
promptMessage: "Paste your Tavily API key",
placeholder: "tvly-...",
validation: null,
});
export const tavilyProvider = {
id: "tavily",
+10 -28
View File
@@ -1,35 +1,17 @@
import * as AIError from "../error";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
const AUTH_URL = "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway%2Fapi-keys&title=AI+Gateway+API+Keys";
export async function loginVercelAiGateway(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new AIError.OnPromptRequiredError("Vercel AI Gateway");
}
options.onAuth?.({
url: AUTH_URL,
instructions: "Copy your Vercel AI Gateway API key from the Vercel dashboard",
});
const apiKey = await options.onPrompt({
message: "Paste your Vercel AI Gateway API key",
placeholder: "vck_...",
});
if (options.signal?.aborted) {
throw new AIError.LoginCancelledError();
}
const trimmed = apiKey.trim();
if (!trimmed) {
throw new AIError.ApiKeyRequiredError();
}
return trimmed;
}
export const loginVercelAiGateway = createApiKeyLogin({
providerLabel: "Vercel AI Gateway",
authUrl: AUTH_URL,
instructions: "Copy your Vercel AI Gateway API key from the Vercel dashboard",
promptMessage: "Paste your Vercel AI Gateway API key",
placeholder: "vck_...",
validation: null,
});
export const vercelAiGatewayProvider = {
id: "vercel-ai-gateway",
+11 -21
View File
@@ -1,5 +1,5 @@
import * as AIError from "../error";
import type { OAuthController, OAuthLoginCallbacks, OAuthProvider } from "./oauth/types";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks, OAuthProvider } from "./oauth/types";
import type { ProviderDefinition } from "./types";
const PROVIDER_ID: OAuthProvider = "vllm";
@@ -7,25 +7,15 @@ const AUTH_URL = "https://docs.vllm.ai/en/latest/serving/openai_compatible_serve
const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8000/v1";
const DEFAULT_LOCAL_TOKEN = "vllm-local";
export async function loginVllm(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new AIError.OnPromptRequiredError(PROVIDER_ID);
}
options.onAuth?.({
url: AUTH_URL,
instructions: `Paste your vLLM API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}).`,
});
const apiKey = await options.onPrompt({
message: "Paste your vLLM API key (optional for local no-auth)",
placeholder: DEFAULT_LOCAL_TOKEN,
allowEmpty: true,
});
if (options.signal?.aborted) {
throw new AIError.LoginCancelledError();
}
const trimmed = apiKey.trim();
return trimmed || DEFAULT_LOCAL_TOKEN;
}
export const loginVllm = createApiKeyLogin({
providerLabel: PROVIDER_ID,
authUrl: AUTH_URL,
instructions: `Paste your vLLM API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}).`,
promptMessage: "Paste your vLLM API key (optional for local no-auth)",
placeholder: DEFAULT_LOCAL_TOKEN,
validation: null,
emptyKeyFallback: DEFAULT_LOCAL_TOKEN,
});
export const vllmProvider = {
id: "vllm",