From 2e922226ecc0315b85fb473524243d6e5e6c0891 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Fri, 15 May 2026 00:16:56 -0700 Subject: [PATCH] feat: add embeddings support for runtime API keys --- CHANGELOG.md | 1 + shared/hooks/use-api-keys.ts | 3 + src/auth/api-key-pool.ts | 68 ++++++- src/index.ts | 18 +- src/proxy/upstream-router-bootstrap.ts | 15 ++ src/routes/api-keys.ts | 4 + src/routes/chat.ts | 43 +++-- src/routes/embeddings.ts | 182 ++++++++++++++++++ tests/unit/auth/api-key-pool.test.ts | 62 +++++- .../proxy/upstream-router-bootstrap.test.ts | 46 +++++ tests/unit/routes/api-keys.test.ts | 28 ++- tests/unit/routes/embeddings.test.ts | 170 ++++++++++++++++ .../unit/routes/upstream-auth-bypass.test.ts | 31 ++- web/src/components/ApiKeyManager.test.tsx | 57 ++++++ web/src/components/ApiKeyManager.tsx | 62 +++++- 15 files changed, 750 insertions(+), 40 deletions(-) create mode 100644 src/proxy/upstream-router-bootstrap.ts create mode 100644 src/routes/embeddings.ts create mode 100644 tests/unit/proxy/upstream-router-bootstrap.test.ts create mode 100644 tests/unit/routes/embeddings.test.ts create mode 100644 web/src/components/ApiKeyManager.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 26130d94..73d42f64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Added +- 第三方 API key 增加 capability 分层与 OpenAI-compatible embeddings 代理:旧 key 自动按 `chat` 迁移,新增/导入/导出支持 `capabilities`,`/v1/embeddings` 只使用显式标记 `embeddings` 的 OpenAI/OpenRouter/custom key 并直连上游 `/embeddings`;Dashboard API Keys 表单新增 Chat / Embeddings 复选框和手动模型输入,避免静态 catalog 卡住 embedding/custom model。启动时无持久 key 也会创建 runtime router,后续在面板添加的 key 无需重启即可参与 chat 直连;chat 直连模型在配置 `proxy_api_key` 时补齐代理 key 校验。新增单元、前端表单和真实 OpenRouter embeddings 3 连 E2E 验证(`src/auth/api-key-pool.ts`、`src/routes/api-keys.ts`、`src/routes/embeddings.ts`、`src/proxy/upstream-router-bootstrap.ts`、`src/routes/chat.ts`、`web/src/components/ApiKeyManager.tsx`、`tests/unit/routes/embeddings.test.ts`、`web/src/components/ApiKeyManager.test.tsx`) - 支持 Dashboard 配置模型映射与本地自定义模型目录:`data/local.yaml` 可把客户端模型名映射到 Codex 模型、带 provider 前缀的第三方模型或已有 `model_routing` 目标;Dashboard → Settings → 模型映射可直接增删 alias 并热加载后端;`model.custom_models` 可把自定义 Codex-compatible ID 加入 `/v1/models/catalog` 并支持 `-fast` / `-high` 等后缀。ModelStore 会用本地 alias 覆盖静态 `config/models.yaml` alias,UpstreamRouter 会在内置 Claude/Gemini 自动路由前解析 alias,并在直连 provider 请求中把 outgoing `model` 改写为映射目标。新增 schema / model-store / upstream-router / route direct guard / Dashboard 组件测试覆盖配置默认值、静态 alias 覆盖、custom catalog、provider target 路由、四类直连接口(Chat / Messages / Responses / Gemini)的目标模型透传和 UI 持久化(`src/config-schema.ts`、`src/models/model-store.ts`、`src/proxy/upstream-router.ts`、`src/routes/admin/settings.ts`、`src/routes/chat.ts`、`src/routes/messages.ts`、`src/routes/responses.ts`、`src/routes/gemini.ts`、`web/src/components/ModelAliasSettings.tsx`、`tests/unit/config-schema.test.ts`、`tests/unit/models/model-store.test.ts`、`tests/unit/proxy/upstream-router.test.ts`、`tests/unit/routes/general-settings.test.ts`、`tests/unit/routes/upstream-auth-bypass.test.ts`、`web/src/components/ModelAliasSettings.test.tsx`) - Stream-close 事件结构化落盘到 Errors tab + 审计 log:`premature stream close` / `stream-client-abort` / `stream-client-disconnect` / `stream-error` 此前只走 `console.warn` 进 `dev-YYYY-MM-DD.log`,需要 grep 才能定位,且生产模式没有 tee;新增 `src/logs/stream-close-event.ts` 把这些事件同时写到 `data/error-log.jsonl`(Errors tab 按签名分组 + 角标计数)和 `logStore`(`/admin/logs` 审计流)。覆盖 7 个调用点:`proxy-handler.ts` 两处 client abort + 一处 `UpstreamPrematureCloseError`(带 eventCount / hadReasoning / responseId / variantHash)、`response-processor.ts` 两处(`client-write-failed` 带 writtenChunks/Bytes/lastSentEvent;`upstream-error` 带 upstreamStatus)、`responses.ts` 两处 `streamPassthrough` 内部 EOF(rid / accountEntryId / variantHash 通过 `FormatAdapter.streamTranslator` 的 `streamContext` option 由 `response-processor` 透传,其它 adapter 兼容性接收并忽略)。顺手修 `error-log.ts:readAppVersion` 在 config 未加载时崩溃(unit-test 路径会撞到),改为 try/catch 兜底回退 "unknown"。新增 `tests/unit/logs/stream-close-event.test.ts` 6 个单测覆盖 4 种 kind + 缺失 rid 兜底 + numeric upstreamStatus → audit status 透传 + direct upstream provider/path;Errors tab 展开分组时会显示 sample context。下次复现 premature close 直接看 Errors tab 按 `StreamUpstreamPrematureClose` 分组拉 rid + account + closeCode,不用再 grep dev 日志(`src/logs/stream-close-event.ts`、`src/logs/error-log.ts`、`src/routes/shared/proxy-handler.ts`、`src/routes/shared/response-processor.ts`、`src/routes/responses.ts`、`tests/unit/logs/stream-close-event.test.ts`) - Opt-in 上游请求/响应 dumper:新增 `src/utils/debug-dump.ts`,环境变量 `CODEX_PROXY_DEBUG_DUMP=1` 启用时把每次上游请求 + 流式 chunk + 终止状态 + 错误写入 `/tmp/codex-proxy-dump-.jsonl`(一行一事件);未启用时所有 hook 是 `if (debugDumpEnabled())` 守护下的纯 boolean check,零开销。在 `src/routes/shared/proxy-handler.ts` 加 1 个 hook(`request`,含 rid/tag/entryId/conv/implicitResumeActive/resumeReason/payload),在 `src/routes/shared/response-processor.ts` 加 3 个 hook(`upstream-chunk` 截断到 16KB、`stream-finish` 含 chunks/bytes/sawTerminal、`stream-error` 含 status/msg/body 截断到 4KB)。**privacy 警告**:dump 文件包含完整 request payload(含用户 prompt)和上游响应,路径在启动时打印一次提示 sensitive 性质。日常排查"账号轮换重试风暴" / "premature stream close" 等偶发错误时 opt-in 启用,问题复现后再 opt-out diff --git a/shared/hooks/use-api-keys.ts b/shared/hooks/use-api-keys.ts index 04bcc4c6..f471a14d 100644 --- a/shared/hooks/use-api-keys.ts +++ b/shared/hooks/use-api-keys.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "preact/hooks"; export type ApiKeyProvider = "anthropic" | "openai" | "gemini" | "openrouter" | "custom"; +export type ApiKeyCapability = "chat" | "embeddings"; export interface ApiKeyEntry { id: string; @@ -9,6 +10,7 @@ export interface ApiKeyEntry { apiKey: string; // masked baseUrl: string; label: string | null; + capabilities: ApiKeyCapability[]; status: "active" | "disabled" | "error"; addedAt: string; lastUsedAt: string | null; @@ -72,6 +74,7 @@ export function useApiKeys() { apiKey: string; baseUrl?: string; label?: string | null; + capabilities?: ApiKeyCapability[]; }): Promise<{ ok: boolean; error?: string }> => { try { const resp = await fetch("/auth/api-keys", { diff --git a/src/auth/api-key-pool.ts b/src/auth/api-key-pool.ts index 53fa5391..1b4825aa 100644 --- a/src/auth/api-key-pool.ts +++ b/src/auth/api-key-pool.ts @@ -22,6 +22,8 @@ import { isBuiltinProvider, PROVIDER_CATALOG } from "./api-key-catalog.js"; // ── Types ────────────────────────────────────────────────────────── export type ApiKeyStatus = "active" | "disabled" | "error"; +export const API_KEY_CAPABILITIES = ["chat", "embeddings"] as const; +export type ApiKeyCapability = typeof API_KEY_CAPABILITIES[number]; export interface ApiKeyEntry { id: string; @@ -30,17 +32,22 @@ export interface ApiKeyEntry { apiKey: string; baseUrl: string; label: string | null; + capabilities: ApiKeyCapability[]; status: ApiKeyStatus; addedAt: string; lastUsedAt: string | null; } +export type PersistedApiKeyEntry = Omit & { + capabilities?: ApiKeyCapability[]; +}; + interface ApiKeysFile { - keys: ApiKeyEntry[]; + keys: PersistedApiKeyEntry[]; } export interface ApiKeyPersistence { - load(): ApiKeyEntry[]; + load(): PersistedApiKeyEntry[]; save(keys: ApiKeyEntry[]): void; } @@ -52,7 +59,7 @@ function getApiKeysFile(): string { export function createFsApiKeyPersistence(): ApiKeyPersistence { return { - load(): ApiKeyEntry[] { + load(): PersistedApiKeyEntry[] { try { const file = getApiKeysFile(); if (!existsSync(file)) return []; @@ -87,7 +94,7 @@ export class ApiKeyPool { constructor(persistence?: ApiKeyPersistence) { this.persistence = persistence ?? createFsApiKeyPersistence(); - this.entries = this.persistence.load(); + this.entries = this.persistence.load().map(normalizeEntry); } // ── Query ────────────────────────────────────────────────────── @@ -102,7 +109,25 @@ export class ApiKeyPool { /** Get all active entries for a given model (exact match). */ getByModel(model: string): ApiKeyEntry[] { - return this.entries.filter((e) => e.model === model && e.status === "active"); + return this.getByModelAndCapability(model, "chat"); + } + + /** Get all active entries for a given model and declared capability. */ + getByModelAndCapability(model: string, capability: ApiKeyCapability): ApiKeyEntry[] { + return this.entries.filter((e) => + e.model === model && + e.status === "active" && + e.capabilities.includes(capability), + ); + } + + /** Pick and mark the least recently used active entry for a model/capability. */ + acquireByModelAndCapability(model: string, capability: ApiKeyCapability): ApiKeyEntry | undefined { + const entries = this.getByModelAndCapability(model, capability); + if (entries.length === 0) return undefined; + const entry = pickLeastRecentlyUsed(entries); + this.markUsed(entry.id); + return entry; } /** Get all active entries for a given provider. */ @@ -128,6 +153,7 @@ export class ApiKeyPool { apiKey: string; baseUrl?: string; label?: string | null; + capabilities?: ApiKeyCapability[]; }): ApiKeyEntry { const baseUrl = input.baseUrl ?? (isBuiltinProvider(input.provider) ? PROVIDER_CATALOG[input.provider].defaultBaseUrl : ""); @@ -139,6 +165,7 @@ export class ApiKeyPool { apiKey: input.apiKey, baseUrl, label: input.label ?? null, + capabilities: normalizeCapabilities(input.capabilities), status: "active", addedAt: new Date().toISOString(), lastUsedAt: null, @@ -187,6 +214,7 @@ export class ApiKeyPool { apiKey: string; baseUrl?: string; label?: string | null; + capabilities?: ApiKeyCapability[]; }>): { added: number; failed: number; errors: string[] } { let added = 0; const errors: string[] = []; @@ -218,6 +246,7 @@ export class ApiKeyPool { apiKey: string; baseUrl: string; label: string | null; + capabilities: ApiKeyCapability[]; }> { return this.entries.map((e) => ({ provider: e.provider, @@ -225,6 +254,7 @@ export class ApiKeyPool { apiKey: e.apiKey, baseUrl: e.baseUrl, label: e.label, + capabilities: e.capabilities, })); } @@ -243,3 +273,31 @@ function maskKey(key: string): string { if (key.length <= 8) return "****"; return key.slice(0, 4) + "****" + key.slice(-4); } + +function isApiKeyCapability(value: unknown): value is ApiKeyCapability { + return value === "chat" || value === "embeddings"; +} + +function normalizeCapabilities(value: unknown): ApiKeyCapability[] { + if (!Array.isArray(value)) return ["chat"]; + const capabilities = value.filter(isApiKeyCapability); + const deduped = [...new Set(capabilities)]; + return deduped.length > 0 ? deduped : ["chat"]; +} + +function normalizeEntry(entry: PersistedApiKeyEntry): ApiKeyEntry { + return { + ...entry, + capabilities: normalizeCapabilities(entry.capabilities), + }; +} + +function pickLeastRecentlyUsed(entries: ApiKeyEntry[]): ApiKeyEntry { + let best = entries[0]; + for (let i = 1; i < entries.length; i++) { + const entry = entries[i]; + if (!entry.lastUsedAt) return entry; + if (!best.lastUsedAt || entry.lastUsedAt < best.lastUsedAt) best = entry; + } + return best; +} diff --git a/src/index.ts b/src/index.ts index a7fe9a6c..cfbe74b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,13 +38,13 @@ import { startQuotaRefresh, stopQuotaRefresh } from "./auth/usage-refresher.js"; import { UsageStatsStore } from "./auth/usage-stats.js"; import { startSessionCleanup, stopSessionCleanup } from "./auth/dashboard-session.js"; import { createDashboardAuthRoutes } from "./routes/dashboard-login.js"; -import { UpstreamRouter } from "./proxy/upstream-router.js"; import { OpenAIUpstream } from "./proxy/openai-upstream.js"; import { AnthropicUpstream } from "./proxy/anthropic-upstream.js"; import { GeminiUpstream } from "./proxy/gemini-upstream.js"; import { ApiKeyPool } from "./auth/api-key-pool.js"; import { createApiKeyRoutes } from "./routes/api-keys.js"; -import { createAdapterForEntry } from "./proxy/adapter-factory.js"; +import { createEmbeddingsRoutes } from "./routes/embeddings.js"; +import { createRuntimeUpstreamRouter } from "./proxy/upstream-router-bootstrap.js"; import { startOllamaBridge, stopOllamaBridge } from "./ollama/server.js"; import { createOfficialAgentRoutes } from "./routes/official-agent.js"; import { installUncaughtErrorHandlers } from "./logs/error-log.js"; @@ -160,16 +160,8 @@ export async function startServer(options?: StartOptions): Promise // Initialize API key pool for runtime-managed third-party keys const apiKeyPool = new ApiKeyPool(); const hasApiKeys = apiKeyPool.getAll().length > 0; - - const upstreamRouter = (adapters.size > 0 || hasApiKeys) - ? new UpstreamRouter(adapters, cfg.model_routing, "codex") - : undefined; - - // Attach API key pool to router for dynamic model resolution - if (upstreamRouter) { - upstreamRouter.setApiKeyPool(apiKeyPool, createAdapterForEntry); - if (hasApiKeys) console.log(`[Init] API key pool: ${apiKeyPool.getAll().length} key(s) loaded`); - } + const upstreamRouter = createRuntimeUpstreamRouter(adapters, cfg.model_routing, apiKeyPool); + if (hasApiKeys) console.log(`[Init] API key pool: ${apiKeyPool.getAll().length} key(s) loaded`); // Mount routes const authRoutes = createAuthRoutes(accountPool, refreshScheduler); @@ -179,6 +171,7 @@ export async function startServer(options?: StartOptions): Promise const geminiRoutes = createGeminiRoutes(accountPool, cookieJar, proxyPool, upstreamRouter); const responsesRoutes = createResponsesRoutes(accountPool, cookieJar, proxyPool, upstreamRouter); const apiKeyRoutes = createApiKeyRoutes(apiKeyPool); + const embeddingsRoutes = createEmbeddingsRoutes(accountPool, apiKeyPool); const proxyRoutes = createProxyRoutes(proxyPool, accountPool); const usageStats = new UsageStatsStore(); usageStats.recoverBaseline(accountPool); @@ -188,6 +181,7 @@ export async function startServer(options?: StartOptions): Promise app.route("/", authRoutes); app.route("/", accountRoutes); app.route("/", apiKeyRoutes); + app.route("/", embeddingsRoutes); app.route("/", chatRoutes); app.route("/", messagesRoutes); app.route("/", geminiRoutes); diff --git a/src/proxy/upstream-router-bootstrap.ts b/src/proxy/upstream-router-bootstrap.ts new file mode 100644 index 00000000..e9979301 --- /dev/null +++ b/src/proxy/upstream-router-bootstrap.ts @@ -0,0 +1,15 @@ +import type { ApiKeyPool } from "../auth/api-key-pool.js"; +import type { UpstreamAdapter } from "./upstream-adapter.js"; +import { createAdapterForEntry } from "./adapter-factory.js"; +import { UpstreamRouter, type AdapterFactory } from "./upstream-router.js"; + +export function createRuntimeUpstreamRouter( + adapters: Map, + modelRouting: Record, + apiKeyPool: ApiKeyPool, + adapterFactory: AdapterFactory = createAdapterForEntry, +): UpstreamRouter { + const router = new UpstreamRouter(adapters, modelRouting, "codex"); + router.setApiKeyPool(apiKeyPool, adapterFactory); + return router; +} diff --git a/src/routes/api-keys.ts b/src/routes/api-keys.ts index efc1317e..690ca15d 100644 --- a/src/routes/api-keys.ts +++ b/src/routes/api-keys.ts @@ -6,11 +6,13 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { z } from "zod"; +import { API_KEY_CAPABILITIES } from "../auth/api-key-pool.js"; import type { ApiKeyEntry, ApiKeyPool } from "../auth/api-key-pool.js"; import { PROVIDER_CATALOG } from "../auth/api-key-catalog.js"; const VALID_PROVIDERS = ["anthropic", "openai", "gemini", "openrouter", "custom"] as const; const ModelsSchema = z.array(z.string().trim().min(1)).min(1).transform((models) => [...new Set(models)]); +const CapabilitiesSchema = z.array(z.enum(API_KEY_CAPABILITIES)).min(1).transform((capabilities) => [...new Set(capabilities)]).optional(); const ApiKeyBindingSchema = z.object({ provider: z.enum(VALID_PROVIDERS), @@ -18,6 +20,7 @@ const ApiKeyBindingSchema = z.object({ apiKey: z.string().min(1), baseUrl: z.string().url().optional(), label: z.string().max(64).nullable().optional(), + capabilities: CapabilitiesSchema, }).refine( (d) => d.provider !== "custom" || Boolean(d.baseUrl), { message: "baseUrl is required for custom providers" }, @@ -78,6 +81,7 @@ function addEntries(pool: ApiKeyPool, items: ApiKeyBindingInput[]): { apiKey: item.apiKey, baseUrl: item.baseUrl, label: item.label, + capabilities: item.capabilities, })); } catch (err) { errors.push(err instanceof Error ? err.message : String(err)); diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 6a34ba8b..947924a1 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import type { Context } from "hono"; import { ChatCompletionRequestSchema } from "../types/openai.js"; import type { AccountPool } from "../auth/account-pool.js"; import type { CookieJar } from "../proxy/cookie-jar.js"; @@ -72,6 +73,27 @@ function formatModelNotFound(model: string) { }; } +function checkProxyApiKey(c: Context, accountPool: AccountPool) { + const config = getConfig(); + if (!config.server.proxy_api_key) return null; + + const authHeader = c.req.header("Authorization"); + const providedKey = authHeader?.replace("Bearer ", ""); + if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) { + c.status(401); + return c.json({ + error: { + message: "Invalid proxy API key", + type: "invalid_request_error", + param: null, + code: "invalid_api_key", + }, + }); + } + + return null; +} + export function createChatRoutes( accountPool: AccountPool, cookieJar?: CookieJar, @@ -145,6 +167,9 @@ export function createChatRoutes( }); if (routeMatch.kind === "api-key" || routeMatch.kind === "adapter") { + const authError = checkProxyApiKey(c, accountPool); + if (authError) return authError; + const directModel = routeMatch.resolvedModel ?? req.model; const directReq = { ...proxyReq, @@ -172,22 +197,8 @@ export function createChatRoutes( return handleProxyRequest({ c, accountPool, cookieJar, req: proxyReq, fmt, proxyPool }); } - const config = getConfig(); - if (config.server.proxy_api_key) { - const authHeader = c.req.header("Authorization"); - const providedKey = authHeader?.replace("Bearer ", ""); - if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) { - c.status(401); - return c.json({ - error: { - message: "Invalid proxy API key", - type: "invalid_request_error", - param: null, - code: "invalid_api_key", - }, - }); - } - } + const authError = checkProxyApiKey(c, accountPool); + if (authError) return authError; return handleProxyRequest({ c, accountPool, cookieJar, req: proxyReq, fmt, proxyPool }); }); diff --git a/src/routes/embeddings.ts b/src/routes/embeddings.ts new file mode 100644 index 00000000..cceb4969 --- /dev/null +++ b/src/routes/embeddings.ts @@ -0,0 +1,182 @@ +/** + * OpenAI-compatible embeddings route. + * + * Embeddings are only supported through runtime API keys whose capabilities + * explicitly include "embeddings". Codex accounts are not used for this path. + */ + +import { Hono } from "hono"; +import type { Context } from "hono"; +import { z } from "zod"; +import type { AccountPool } from "../auth/account-pool.js"; +import type { ApiKeyEntry, ApiKeyPool } from "../auth/api-key-pool.js"; +import type { ApiKeyProvider } from "../auth/api-key-catalog.js"; +import { getConfig } from "../config.js"; +import { withFetchDispatcher } from "../proxy/fetch-dispatcher.js"; + +const EmbeddingInputSchema = z.union([ + z.string(), + z.array(z.string()).min(1), + z.array(z.number()).min(1), + z.array(z.array(z.number()).min(1)).min(1), +]); + +const EmbeddingsRequestSchema = z.object({ + model: z.string().trim().min(1), + input: EmbeddingInputSchema, + encoding_format: z.enum(["float", "base64"]).optional(), + dimensions: z.number().int().positive().optional(), + user: z.string().optional(), +}).passthrough(); + +type EmbeddingsRequest = z.infer; + +function openAIError(message: string, code: string, param: string | null = null) { + return { + error: { + message, + type: "invalid_request_error", + param, + code, + }, + }; +} + +function invalidProxyApiKeyResponse(c: Context): Response { + c.status(401); + return c.json(openAIError("Invalid proxy API key", "invalid_api_key")); +} + +function extractBearerToken(header: string | undefined): string | null { + if (!header?.startsWith("Bearer ")) return null; + return header.slice("Bearer ".length); +} + +function checkProxyApiKey(c: Context, accountPool: AccountPool): Response | null { + const config = getConfig(); + if (!config.server.proxy_api_key) return null; + + const providedKey = c.req.header("x-api-key") ?? extractBearerToken(c.req.header("Authorization")); + if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) { + return invalidProxyApiKeyResponse(c); + } + + return null; +} + +function supportsOpenAIEmbeddings(provider: ApiKeyProvider): boolean { + return provider === "openai" || provider === "openrouter" || provider === "custom"; +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +function modelCandidates(model: string): string[] { + const trimmed = model.trim(); + const colonIdx = trimmed.indexOf(":"); + if (colonIdx <= 0) return [trimmed]; + + const providerPrefix = trimmed.slice(0, colonIdx); + if ( + providerPrefix === "openai" || + providerPrefix === "openrouter" || + providerPrefix === "custom" || + providerPrefix === "anthropic" || + providerPrefix === "gemini" + ) { + return [trimmed, trimmed.slice(colonIdx + 1)]; + } + + return [trimmed]; +} + +function resolveEmbeddingEntry(pool: ApiKeyPool, model: string): { entry: ApiKeyEntry; upstreamModel: string } | null { + for (const candidate of modelCandidates(model)) { + const entry = pool.acquireByModelAndCapability(candidate, "embeddings"); + if (entry) return { entry, upstreamModel: candidate }; + } + return null; +} + +async function parseEmbeddingsRequest(c: Context): Promise< + { ok: true; data: EmbeddingsRequest } | { ok: false; response: Response } +> { + let body: unknown; + try { + body = await c.req.json(); + } catch { + c.status(400); + return { ok: false, response: c.json(openAIError("Malformed JSON request body", "invalid_json")) }; + } + + const parsed = EmbeddingsRequestSchema.safeParse(body); + if (!parsed.success) { + c.status(400); + return { + ok: false, + response: c.json(openAIError(`Invalid request: ${parsed.error.message}`, "invalid_request")), + }; + } + + return { ok: true, data: parsed.data }; +} + +function buildUpstreamRequestBody(req: EmbeddingsRequest, upstreamModel: string): EmbeddingsRequest { + return { + ...req, + model: upstreamModel, + }; +} + +function copyResponseHeaders(upstream: Response): Headers { + const headers = new Headers(); + const contentType = upstream.headers.get("Content-Type") ?? upstream.headers.get("content-type"); + headers.set("Content-Type", contentType ?? "application/json"); + return headers; +} + +export function createEmbeddingsRoutes(accountPool: AccountPool, apiKeyPool: ApiKeyPool): Hono { + const app = new Hono(); + + app.post("/v1/embeddings", async (c) => { + const authError = checkProxyApiKey(c, accountPool); + if (authError) return authError; + + const parsed = await parseEmbeddingsRequest(c); + if (!parsed.ok) return parsed.response; + + const resolved = resolveEmbeddingEntry(apiKeyPool, parsed.data.model); + if (!resolved) { + c.status(404); + return c.json(openAIError(`Model '${parsed.data.model}' not found for embeddings`, "model_not_found", "model")); + } + + if (!supportsOpenAIEmbeddings(resolved.entry.provider)) { + c.status(400); + return c.json(openAIError( + `Provider '${resolved.entry.provider}' does not support OpenAI-compatible embeddings`, + "unsupported_provider", + "model", + )); + } + + const upstream = await fetch(`${normalizeBaseUrl(resolved.entry.baseUrl)}/embeddings`, withFetchDispatcher({ + method: "POST", + headers: { + "Authorization": `Bearer ${resolved.entry.apiKey}`, + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify(buildUpstreamRequestBody(parsed.data, resolved.upstreamModel)), + signal: c.req.raw.signal, + })); + + return new Response(await upstream.text(), { + status: upstream.status, + headers: copyResponseHeaders(upstream), + }); + }); + + return app; +} diff --git a/tests/unit/auth/api-key-pool.test.ts b/tests/unit/auth/api-key-pool.test.ts index 691bd916..6d1e9f86 100644 --- a/tests/unit/auth/api-key-pool.test.ts +++ b/tests/unit/auth/api-key-pool.test.ts @@ -36,6 +36,7 @@ describe("ApiKeyPool", () => { expect(entry.status).toBe("active"); expect(entry.label).toBeNull(); expect(entry.lastUsedAt).toBeNull(); + expect(entry.capabilities).toEqual(["chat"]); }); it("uses default baseUrl for builtin providers", () => { @@ -88,6 +89,58 @@ describe("ApiKeyPool", () => { expect(results[0].id).toBe(e1.id); }); + it("filters active entries by model and capability", () => { + const chat = pool.add({ + provider: "openai", + model: "text-embedding-3-small", + apiKey: "chat-key", + capabilities: ["chat"], + }); + const embeddings = pool.add({ + provider: "openai", + model: "text-embedding-3-small", + apiKey: "embedding-key", + capabilities: ["embeddings"], + }); + const disabled = pool.add({ + provider: "openai", + model: "text-embedding-3-small", + apiKey: "disabled-key", + capabilities: ["embeddings"], + }); + pool.setStatus(disabled.id, "disabled"); + + expect(pool.getByModelAndCapability("text-embedding-3-small", "chat").map((entry) => entry.id)).toEqual([chat.id]); + expect(pool.getByModelAndCapability("text-embedding-3-small", "embeddings").map((entry) => entry.id)).toEqual([embeddings.id]); + }); + + it("loads legacy persisted entries without capabilities as chat-only", () => { + const persistence = createMemoryPersistence(); + const pool1 = new ApiKeyPool(persistence); + pool1.add({ provider: "openai", model: "gpt-5.4", apiKey: "k1" }); + + const legacyEntry = pool1.getAll()[0]; + const legacyPersistence: ApiKeyPersistence = { + load: () => [{ + id: legacyEntry.id, + provider: legacyEntry.provider, + model: legacyEntry.model, + apiKey: legacyEntry.apiKey, + baseUrl: legacyEntry.baseUrl, + label: legacyEntry.label, + status: legacyEntry.status, + addedAt: legacyEntry.addedAt, + lastUsedAt: legacyEntry.lastUsedAt, + }], + save: () => {}, + }; + + const pool2 = new ApiKeyPool(legacyPersistence); + expect(pool2.getAll()[0].capabilities).toEqual(["chat"]); + expect(pool2.getByModelAndCapability("gpt-5.4", "chat")).toHaveLength(1); + expect(pool2.getByModelAndCapability("gpt-5.4", "embeddings")).toHaveLength(0); + }); + it("getByProvider returns active entries for that provider", () => { pool.add({ provider: "anthropic", model: "claude-opus-4-6", apiKey: "k1" }); pool.add({ provider: "anthropic", model: "claude-sonnet-4-6", apiKey: "k2" }); @@ -180,7 +233,13 @@ describe("ApiKeyPool", () => { }); it("exportForReimport returns all keys in importable format", () => { - pool.add({ provider: "anthropic", model: "claude-opus-4-6", apiKey: "k1", label: "Prod" }); + pool.add({ + provider: "anthropic", + model: "claude-opus-4-6", + apiKey: "k1", + label: "Prod", + capabilities: ["chat", "embeddings"], + }); const exported = pool.exportForReimport(); expect(exported).toHaveLength(1); expect(exported[0]).toEqual({ @@ -189,6 +248,7 @@ describe("ApiKeyPool", () => { apiKey: "k1", baseUrl: "https://api.anthropic.com/v1", label: "Prod", + capabilities: ["chat", "embeddings"], }); }); diff --git a/tests/unit/proxy/upstream-router-bootstrap.test.ts b/tests/unit/proxy/upstream-router-bootstrap.test.ts new file mode 100644 index 00000000..35745eef --- /dev/null +++ b/tests/unit/proxy/upstream-router-bootstrap.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { ApiKeyPool } from "@src/auth/api-key-pool.js"; +import type { ApiKeyEntry, ApiKeyPersistence } from "@src/auth/api-key-pool.js"; +import type { UpstreamAdapter } from "@src/proxy/upstream-adapter.js"; +import type { CodexResponsesRequest, CodexSSEEvent } from "@src/proxy/codex-types.js"; +import { createRuntimeUpstreamRouter } from "@src/proxy/upstream-router-bootstrap.js"; + +function createMemoryPersistence(): ApiKeyPersistence { + let stored: ApiKeyEntry[] = []; + return { + load: () => [...stored], + save: (keys) => { + stored = [...keys]; + }, + }; +} + +function mockAdapter(tag: string): UpstreamAdapter { + return { + tag, + createResponse: (_req: CodexResponsesRequest) => Promise.resolve(new Response()), + async *parseStream(): AsyncGenerator { /* no events */ }, + }; +} + +describe("createRuntimeUpstreamRouter", () => { + it("creates a router even when startup has no configured adapters or persisted API keys", () => { + const pool = new ApiKeyPool(createMemoryPersistence()); + const router = createRuntimeUpstreamRouter(new Map(), {}, pool, (entry) => mockAdapter(`dynamic-${entry.model}`)); + + expect(router.resolveMatch("late-runtime-model").kind).toBe("not-found"); + + pool.add({ + provider: "custom", + model: "late-runtime-model", + apiKey: "secret", + baseUrl: "https://example.com/v1", + }); + + const match = router.resolveMatch("late-runtime-model"); + expect(match.kind).toBe("api-key"); + if (match.kind === "api-key") { + expect(match.adapter.tag).toBe("dynamic-late-runtime-model"); + } + }); +}); diff --git a/tests/unit/routes/api-keys.test.ts b/tests/unit/routes/api-keys.test.ts index d63246ec..a40fe57d 100644 --- a/tests/unit/routes/api-keys.test.ts +++ b/tests/unit/routes/api-keys.test.ts @@ -44,6 +44,23 @@ describe("api key routes", () => { expect(body.keys).toHaveLength(2); expect(body.keys[0].apiKey).toBe("sk-1****cdef"); expect(pool.getAll().map((entry) => entry.model)).toEqual(["gpt-5.4", "gpt-5.4-mini"]); + expect(pool.getAll().map((entry) => entry.capabilities)).toEqual([["chat"], ["chat"]]); + }); + + it("stores explicit capabilities for selected models", async () => { + const res = await app.request("/auth/api-keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "openai", + models: ["text-embedding-3-small"], + apiKey: "sk-embedding", + capabilities: ["embeddings"], + }), + }); + + expect(res.status).toBe(200); + expect(pool.getAll()[0].capabilities).toEqual(["embeddings"]); }); it("requires baseUrl for custom provider keys", async () => { @@ -79,6 +96,7 @@ describe("api key routes", () => { models: ["custom-a"], apiKey: "custom-key", baseUrl: "https://example.com/v1", + capabilities: ["chat", "embeddings"], }, ], }), @@ -92,10 +110,17 @@ describe("api key routes", () => { "claude-sonnet-4-6", "custom-a", ]); + expect(pool.getAll()[2].capabilities).toEqual(["chat", "embeddings"]); }); it("exports stored single-model entries as importable multi-model entries", async () => { - pool.add({ provider: "openai", model: "gpt-5.4", apiKey: "sk-openai", label: "A" }); + pool.add({ + provider: "openai", + model: "gpt-5.4", + apiKey: "sk-openai", + label: "A", + capabilities: ["chat", "embeddings"], + }); const res = await app.request("/auth/api-keys/export"); @@ -108,6 +133,7 @@ describe("api key routes", () => { apiKey: "sk-openai", baseUrl: "https://api.openai.com/v1", label: "A", + capabilities: ["chat", "embeddings"], }, ]); }); diff --git a/tests/unit/routes/embeddings.test.ts b/tests/unit/routes/embeddings.test.ts new file mode 100644 index 00000000..99981fbd --- /dev/null +++ b/tests/unit/routes/embeddings.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for OpenAI-compatible embeddings proxying through runtime API keys. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiKeyPool } from "@src/auth/api-key-pool.js"; +import type { ApiKeyEntry, ApiKeyPersistence } from "@src/auth/api-key-pool.js"; +import type { AccountPool } from "@src/auth/account-pool.js"; +import { createEmbeddingsRoutes } from "@src/routes/embeddings.js"; + +const mockConfig = { + server: { proxy_api_key: null as string | null }, + tls: { proxy_url: null as string | null }, +}; + +vi.mock("@src/config.js", () => ({ + getConfig: vi.fn(() => mockConfig), +})); + +function createMemoryPersistence(): ApiKeyPersistence { + let stored: ApiKeyEntry[] = []; + return { + load: () => [...stored], + save: (keys) => { + stored = [...keys]; + }, + }; +} + +function createAccountPool(validateProxyApiKey = true): AccountPool { + return { + validateProxyApiKey: vi.fn(() => validateProxyApiKey), + } as unknown as AccountPool; +} + +describe("embeddings routes", () => { + let pool: ApiKeyPool; + let fetchMock: ReturnType Promise>>; + + beforeEach(() => { + mockConfig.server.proxy_api_key = null; + pool = new ApiKeyPool(createMemoryPersistence()); + fetchMock = vi.fn(async () => new Response(JSON.stringify({ + object: "list", + data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2] }], + model: "text-embedding-3-small", + usage: { prompt_tokens: 1, total_tokens: 1 }, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("proxies embeddings to OpenAI-compatible runtime API keys", async () => { + pool.add({ + provider: "custom", + model: "text-embedding-3-small", + apiKey: "upstream-secret", + baseUrl: "https://embeddings.example.com/v1/", + capabilities: ["embeddings"], + }); + + const app = createEmbeddingsRoutes(createAccountPool(), pool); + const res = await app.request("/v1/embeddings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "text-embedding-3-small", + input: "hello", + encoding_format: "float", + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data[0].embedding).toEqual([0.1, 0.2]); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe("https://embeddings.example.com/v1/embeddings"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer upstream-secret"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + model: "text-embedding-3-small", + input: "hello", + encoding_format: "float", + }); + expect(pool.getAll()[0].lastUsedAt).toBeTruthy(); + }); + + it("requires the proxy API key when configured", async () => { + mockConfig.server.proxy_api_key = "proxy-secret"; + pool.add({ + provider: "openai", + model: "text-embedding-3-small", + apiKey: "upstream-secret", + capabilities: ["embeddings"], + }); + + const app = createEmbeddingsRoutes(createAccountPool(false), pool); + const res = await app.request("/v1/embeddings", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer wrong-key", + }, + body: JSON.stringify({ model: "text-embedding-3-small", input: "hello" }), + }); + + expect(res.status).toBe(401); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not use chat-only keys for embeddings", async () => { + pool.add({ + provider: "openai", + model: "text-embedding-3-small", + apiKey: "chat-only-secret", + }); + + const app = createEmbeddingsRoutes(createAccountPool(), pool); + const res = await app.request("/v1/embeddings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "text-embedding-3-small", input: "hello" }), + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error.code).toBe("model_not_found"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects embeddings-capable keys on providers without OpenAI-compatible embeddings", async () => { + pool.add({ + provider: "anthropic", + model: "claude-opus-4-6", + apiKey: "sk-ant", + capabilities: ["embeddings"], + }); + + const app = createEmbeddingsRoutes(createAccountPool(), pool); + const res = await app.request("/v1/embeddings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "claude-opus-4-6", input: "hello" }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe("unsupported_provider"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects malformed embedding requests", async () => { + const app = createEmbeddingsRoutes(createAccountPool(), pool); + const res = await app.request("/v1/embeddings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "text-embedding-3-small" }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe("invalid_request"); + }); +}); diff --git a/tests/unit/routes/upstream-auth-bypass.test.ts b/tests/unit/routes/upstream-auth-bypass.test.ts index b80aa5ce..bc6f5a5c 100644 --- a/tests/unit/routes/upstream-auth-bypass.test.ts +++ b/tests/unit/routes/upstream-auth-bypass.test.ts @@ -423,7 +423,7 @@ describe("upstream direct routing without Codex auth", () => { pool.destroy(); }); - it("bypasses proxy api key validation for configured direct upstream models", async () => { + it("requires proxy api key validation for chat direct upstream models", async () => { mockConfig.server.proxy_api_key = "proxy-secret"; const pool = new AccountPool(); const app = createChatRoutes(pool, undefined, undefined, { @@ -444,8 +444,35 @@ describe("upstream direct routing without Codex auth", () => { }), }); + expect(res.status).toBe(401); + expect(mockHandleDirectRequest).toHaveBeenCalledTimes(0); + pool.destroy(); + }); + + it("allows chat direct upstream models when proxy api key is valid", async () => { + mockConfig.server.proxy_api_key = "proxy-secret"; + const pool = new AccountPool(); + const adapter = createSentinelAdapter("custom-upstream"); + const app = createChatRoutes(pool, undefined, undefined, { + resolveMatch: vi.fn(() => ({ kind: "api-key", adapter, entry: { model: "deepseek-chat" } })), + hasApiKeyModel: vi.fn(() => true), + resolve: vi.fn(() => adapter), + } as never); + + const res = await app.request("/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer proxy-secret", + }, + body: JSON.stringify({ + model: "deepseek-chat", + messages: [{ role: "user", content: "hello" }], + }), + }); + expect(res.status).toBe(200); - expect(mockHandleDirectRequest).toHaveBeenCalledTimes(1); + expectDirectOptions({ adapter, model: "deepseek-chat", formatTag: "Chat" }); pool.destroy(); }); diff --git a/web/src/components/ApiKeyManager.test.tsx b/web/src/components/ApiKeyManager.test.tsx new file mode 100644 index 00000000..7cc0771e --- /dev/null +++ b/web/src/components/ApiKeyManager.test.tsx @@ -0,0 +1,57 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact"; +import { AddKeyForm } from "./ApiKeyManager"; +import type { ApiKeyCapability, ApiKeyProvider, CatalogModel } from "../../../shared/hooks/use-api-keys"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("AddKeyForm", () => { + it("submits manual embedding models with embeddings capability", async () => { + const onAdd = vi.fn(async (_input: { + provider: ApiKeyProvider; + models: string[]; + apiKey: string; + baseUrl?: string; + label?: string; + capabilities?: ApiKeyCapability[]; + }) => ({ ok: true })); + const fetchCustomModels = vi.fn(async (_input: { provider: "custom"; apiKey: string; baseUrl: string }) => ({ + ok: true as const, + models: [] as CatalogModel[], + })); + + render( + , + ); + + fireEvent.change(screen.getByRole("combobox"), { target: { value: "openai" } }); + fireEvent.input(screen.getByPlaceholderText("sk-..."), { target: { value: "sk-test" } }); + fireEvent.input(screen.getByPlaceholderText("manual-model-1, manual-model-2"), { + target: { value: "text-embedding-3-small" }, + }); + fireEvent.click(screen.getByLabelText("Chat")); + fireEvent.click(screen.getByLabelText("Embeddings")); + fireEvent.click(screen.getByText("Add Key")); + + await waitFor(() => expect(onAdd).toHaveBeenCalledTimes(1)); + expect(onAdd).toHaveBeenCalledWith({ + provider: "openai", + models: ["text-embedding-3-small"], + apiKey: "sk-test", + baseUrl: undefined, + label: undefined, + capabilities: ["embeddings"], + }); + }); +}); diff --git a/web/src/components/ApiKeyManager.tsx b/web/src/components/ApiKeyManager.tsx index c50db013..f623c573 100644 --- a/web/src/components/ApiKeyManager.tsx +++ b/web/src/components/ApiKeyManager.tsx @@ -5,7 +5,7 @@ import { useState, useCallback, useMemo, useRef } from "preact/hooks"; import { useApiKeys } from "../../../shared/hooks/use-api-keys"; -import type { ApiKeyProvider, ApiKeyEntry, CatalogModel } from "../../../shared/hooks/use-api-keys"; +import type { ApiKeyCapability, ApiKeyProvider, ApiKeyEntry, CatalogModel } from "../../../shared/hooks/use-api-keys"; const CUSTOM_MODELS_HINT = "请先输入key和url,将会获取模型列表"; const CUSTOM_MODELS_FALLBACK_HINT = "模型列表获取失败,请手动输入模型名"; @@ -18,6 +18,11 @@ const PROVIDER_OPTIONS: Array<{ value: ApiKeyProvider; label: string }> = [ { value: "custom", label: "Custom" }, ]; +const CAPABILITY_OPTIONS: Array<{ value: ApiKeyCapability; label: string }> = [ + { value: "chat", label: "Chat" }, + { value: "embeddings", label: "Embeddings" }, +]; + type CustomModelStatus = "idle" | "loading" | "loaded" | "fallback"; function normalizeCustomModelInput(value: string): string[] { @@ -46,7 +51,14 @@ function renderModelChecklist(models: CatalogModel[], selectedModelSet: Set Promise<{ ok: boolean; error?: string }>; + onAdd: (input: { + provider: ApiKeyProvider; + models: string[]; + apiKey: string; + baseUrl?: string; + label?: string; + capabilities?: ApiKeyCapability[]; + }) => Promise<{ ok: boolean; error?: string }>; catalog: Record }>; fetchCustomModels: (input: { provider: "custom"; apiKey: string; baseUrl: string }) => Promise<{ ok: true; models: CatalogModel[] } | { ok: false; error: string }>; }) { @@ -56,6 +68,7 @@ function AddKeyForm({ onAdd, catalog, fetchCustomModels }: { const [baseUrl, setBaseUrl] = useState(""); const [label, setLabel] = useState(""); const [manualModelsInput, setManualModelsInput] = useState(""); + const [capabilities, setCapabilities] = useState(["chat"]); const [customModels, setCustomModels] = useState([]); const [customModelStatus, setCustomModelStatus] = useState("idle"); const [customModelMessage, setCustomModelMessage] = useState(CUSTOM_MODELS_HINT); @@ -67,6 +80,7 @@ function AddKeyForm({ onAdd, catalog, fetchCustomModels }: { const isCustom = provider === "custom"; const providerCatalog = !isCustom ? catalog[provider]?.models ?? [] : []; const selectedModelSet = useMemo(() => new Set(selectedModels), [selectedModels]); + const selectedCapabilitySet = useMemo(() => new Set(capabilities), [capabilities]); const resetCustomModels = useCallback((status: CustomModelStatus = "idle", message = CUSTOM_MODELS_HINT) => { setCustomModels([]); @@ -81,6 +95,12 @@ function AddKeyForm({ onAdd, catalog, fetchCustomModels }: { : [...prev, modelId]); }; + const handleCapabilityToggle = (capability: ApiKeyCapability) => { + setCapabilities((prev) => prev.includes(capability) + ? prev.filter((item) => item !== capability) + : [...prev, capability]); + }; + const triggerCustomModelFetch = useCallback(async () => { if (!isCustom) return; @@ -136,7 +156,7 @@ function AddKeyForm({ onAdd, catalog, fetchCustomModels }: { const normalizedManualModels = normalizeCustomModelInput(manualModelsInput); const models = isCustom && customModelStatus === "fallback" ? normalizedManualModels - : selectedModels; + : [...new Set([...selectedModels, ...normalizedManualModels])]; if (models.length === 0 || !normalizedApiKey) { setError(isCustom && customModelStatus === "fallback" @@ -148,6 +168,10 @@ function AddKeyForm({ onAdd, catalog, fetchCustomModels }: { setError("Base URL is required for custom providers"); return; } + if (capabilities.length === 0) { + setError("Select at least one capability"); + return; + } setAdding(true); const result = await onAdd({ @@ -156,6 +180,7 @@ function AddKeyForm({ onAdd, catalog, fetchCustomModels }: { apiKey: normalizedApiKey, baseUrl: isCustom ? normalizedBaseUrl : undefined, label: label.trim() || undefined, + capabilities, }); setAdding(false); if (result.ok) { @@ -164,6 +189,7 @@ function AddKeyForm({ onAdd, catalog, fetchCustomModels }: { setBaseUrl(""); setLabel(""); setManualModelsInput(""); + setCapabilities(["chat"]); resetCustomModels(); } else { setError(result.error || "Failed to add key"); @@ -185,6 +211,7 @@ function AddKeyForm({ onAdd, catalog, fetchCustomModels }: { setApiKey(""); setLabel(""); setManualModelsInput(""); + setCapabilities(["chat"]); latestResolvedSignatureRef.current = ""; resetCustomModels(); }} @@ -234,6 +261,31 @@ function AddKeyForm({ onAdd, catalog, fetchCustomModels }: { )} )} + {(!isCustom || customModelStatus === "loaded") && ( + setManualModelsInput((e.target as HTMLInputElement).value)} + placeholder="manual-model-1, manual-model-2" + class="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-border-dark bg-slate-50 dark:bg-bg-dark text-slate-800 dark:text-text-main" + /> + )} + + +
+ +
+ {CAPABILITY_OPTIONS.map((option) => ( + + ))} +
{isCustom && ( @@ -314,6 +366,10 @@ function KeyRow({ entry, onDelete, onToggle }: { )} + + {entry.capabilities.join(", ")} + +