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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user