Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<startupMs>.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
Expand Down
3 changes: 3 additions & 0 deletions shared/hooks/use-api-keys.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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", {
Expand Down
68 changes: 63 additions & 5 deletions src/auth/api-key-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ApiKeyEntry, "capabilities"> & {
capabilities?: ApiKeyCapability[];
};

interface ApiKeysFile {
keys: ApiKeyEntry[];
keys: PersistedApiKeyEntry[];
}

export interface ApiKeyPersistence {
load(): ApiKeyEntry[];
load(): PersistedApiKeyEntry[];
save(keys: ApiKeyEntry[]): void;
}

Expand All @@ -52,7 +59,7 @@ function getApiKeysFile(): string {

export function createFsApiKeyPersistence(): ApiKeyPersistence {
return {
load(): ApiKeyEntry[] {
load(): PersistedApiKeyEntry[] {
try {
const file = getApiKeysFile();
if (!existsSync(file)) return [];
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────
Expand All @@ -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. */
Expand All @@ -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 : "");
Expand All @@ -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,
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -218,13 +246,15 @@ export class ApiKeyPool {
apiKey: string;
baseUrl: string;
label: string | null;
capabilities: ApiKeyCapability[];
}> {
return this.entries.map((e) => ({
provider: e.provider,
model: e.model,
apiKey: e.apiKey,
baseUrl: e.baseUrl,
label: e.label,
capabilities: e.capabilities,
}));
}

Expand All @@ -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;
}
18 changes: 6 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -160,16 +160,8 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
// 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);
Expand All @@ -179,6 +171,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
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);
Expand All @@ -188,6 +181,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
app.route("/", authRoutes);
app.route("/", accountRoutes);
app.route("/", apiKeyRoutes);
app.route("/", embeddingsRoutes);
app.route("/", chatRoutes);
app.route("/", messagesRoutes);
app.route("/", geminiRoutes);
Expand Down
15 changes: 15 additions & 0 deletions src/proxy/upstream-router-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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<string, UpstreamAdapter>,
modelRouting: Record<string, string>,
apiKeyPool: ApiKeyPool,
adapterFactory: AdapterFactory = createAdapterForEntry,
): UpstreamRouter {
const router = new UpstreamRouter(adapters, modelRouting, "codex");
router.setApiKeyPool(apiKeyPool, adapterFactory);
return router;
}
4 changes: 4 additions & 0 deletions src/routes/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@
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),
models: ModelsSchema,
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" },
Expand Down Expand Up @@ -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));
Expand Down
43 changes: 27 additions & 16 deletions src/routes/chat.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
});
Expand Down
Loading
Loading