From e805999bc3c041b074d057cc15856b12edb6dd67 Mon Sep 17 00:00:00 2001 From: yuwei5380 Date: Sun, 15 Mar 2026 11:19:38 +0800 Subject: [PATCH 1/2] feat: improve proxy auth compatibility and request logging --- README.md | 5 +- README_EN.md | 5 +- config/default.yaml | 10 +- config/models.yaml | 45 +++- docker-compose.yml | 6 +- package-lock.json | 4 +- src/proxy/codex-api.ts | 24 +- src/routes/chat.ts | 4 +- src/routes/gemini.ts | 7 +- src/routes/messages.ts | 9 +- src/routes/responses.ts | 28 ++- src/routes/shared/proxy-auth.test.ts | 47 ++++ src/routes/shared/proxy-auth.ts | 87 ++++++++ src/routes/shared/proxy-handler.ts | 43 ++-- src/utils/llm-interaction-log.ts | 317 +++++++++++++++++++++++++++ 15 files changed, 598 insertions(+), 43 deletions(-) create mode 100644 src/routes/shared/proxy-auth.test.ts create mode 100644 src/routes/shared/proxy-auth.ts create mode 100644 src/utils/llm-interaction-log.ts diff --git a/README.md b/README.md index 8074244..fbb2025 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,9 @@ curl http://localhost:8080/v1/chat/completions \ | 模型 ID | 别名 | 推理等级 | 说明 | |---------|------|---------|------| +| `gpt-5.4` | `gpt-5.4-codex` | none / low / medium / high / xhigh | 最新旗舰模型 | +| `gpt-5.3-codex` | — | low / medium / high | agentic 编程模型 | +| `gpt-5.3-codex-spark` | — | minimal / low | 超低延迟编程模型 | | `gpt-5.2-codex` | `codex` | low / medium / high / xhigh | 前沿 agentic 编程模型(默认) | | `gpt-5.2` | — | low / medium / high / xhigh | 专业工作 + 长时间代理 | | `gpt-5.1-codex-max` | — | low / medium / high / xhigh | 扩展上下文 / 深度推理 | @@ -265,7 +268,7 @@ curl http://localhost:8080/v1/chat/completions \ > **模型名后缀**:在任意模型名后追加 `-fast` 启用 Fast 模式,追加 `-high`/`-low` 等切换推理等级。 > 例如:`codex-fast`、`gpt-5.2-codex-high-fast`。 > -> **注意**:`gpt-5.4`、`gpt-5.3-codex` 系列已从 free 账号移除,plus 及以上账号仍可使用。 +> **可用性说明**:`gpt-5.4`、`gpt-5.3-codex`、`gpt-5.3-codex-spark` 属于计划受限模型,是否可用取决于当前账号对应的后端模型目录。`gpt-5.4-codex` 作为兼容别名会映射到 `gpt-5.4`。 > 模型列表由后端动态获取,会自动同步最新可用模型。 ## 🔗 客户端接入 (Client Setup) diff --git a/README_EN.md b/README_EN.md index b6d5c60..9b87b18 100644 --- a/README_EN.md +++ b/README_EN.md @@ -170,6 +170,9 @@ curl http://localhost:8080/v1/chat/completions \ | Model ID | Alias | Reasoning Efforts | Description | |----------|-------|-------------------|-------------| +| `gpt-5.4` | `gpt-5.4-codex` | none / low / medium / high / xhigh | Latest flagship model | +| `gpt-5.3-codex` | — | low / medium / high | Agentic coding model | +| `gpt-5.3-codex-spark` | — | minimal / low | Ultra-fast coding model | | `gpt-5.2-codex` | `codex` | low / medium / high / xhigh | Frontier agentic coding model (default) | | `gpt-5.2` | — | low / medium / high / xhigh | Professional work & long-running agents | | `gpt-5.1-codex-max` | — | low / medium / high / xhigh | Extended context / deepest reasoning | @@ -185,7 +188,7 @@ curl http://localhost:8080/v1/chat/completions \ > **Model name suffixes**: Append `-fast` to any model name to enable Fast mode, or `-high`/`-low` etc. to change reasoning effort. > Examples: `codex-fast`, `gpt-5.2-codex-high-fast`. > -> **Note**: `gpt-5.4` and `gpt-5.3-codex` families have been removed for free accounts. Plus and above accounts retain access. +> **Availability**: `gpt-5.4`, `gpt-5.3-codex`, and `gpt-5.3-codex-spark` are plan-restricted and depend on the backend catalog for the current account. `gpt-5.4-codex` is accepted as a compatibility alias of `gpt-5.4`. > Models are dynamically fetched from the backend and will automatically sync the latest available catalog. ## 🔗 Client Setup diff --git a/config/default.yaml b/config/default.yaml index f6a7fd7..472ed4c 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -9,8 +9,8 @@ client: arch: arm64 chromium_version: "144" model: - default: gpt-5.2-codex - default_reasoning_effort: medium + default: gpt-5.4 + default_reasoning_effort: xhigh default_service_tier: null suppress_desktop_directives: true auth: @@ -23,9 +23,9 @@ auth: oauth_auth_endpoint: https://auth.openai.com/oauth/authorize oauth_token_endpoint: https://auth.openai.com/oauth/token server: - host: "::" - port: 8080 - proxy_api_key: pwd + host: "0.0.0.0" + port: 18080 + proxy_api_key: "sk-2bf2321a5c234a9b3e5acec615fb53081e41f86e861373cf4c1eca4237c8825e" session: ttl_minutes: 60 cleanup_interval_minutes: 5 diff --git a/config/models.yaml b/config/models.yaml index 2e7a477..91ba141 100644 --- a/config/models.yaml +++ b/config/models.yaml @@ -7,9 +7,51 @@ # Dynamic fetch merges with static; backend entries win for shared IDs. # Models endpoint now requires ?client_version= query parameter. # -# Last updated: 2026-03-10 (backend removed gpt-5.4, gpt-5.3-codex family) +# Last updated: 2026-03-15 (restored plan-restricted gpt-5.4 / gpt-5.3-codex entries for compatibility) models: + # ── GPT-5.4 ────────────────────────────────────────────────────────── + - id: gpt-5.4 + displayName: GPT-5.4 + description: Latest flagship model for agentic, coding, and professional workflows + isDefault: false + supportedReasoningEfforts: + - { reasoningEffort: none, description: "No reasoning" } + - { reasoningEffort: low, description: "Fastest responses" } + - { reasoningEffort: medium, description: "Balanced speed and quality" } + - { reasoningEffort: high, description: "Deepest reasoning" } + - { reasoningEffort: xhigh, description: "Extended deep reasoning" } + defaultReasoningEffort: medium + inputModalities: [text, image] + supportsPersonality: true + upgrade: null + + # ── GPT-5.3 Codex family ────────────────────────────────────────── + - id: gpt-5.3-codex + displayName: GPT-5.3 Codex + description: Agentic coding model for long-running tasks + isDefault: false + supportedReasoningEfforts: + - { reasoningEffort: low, description: "Fastest responses" } + - { reasoningEffort: medium, description: "Balanced speed and quality" } + - { reasoningEffort: high, description: "Deepest reasoning" } + defaultReasoningEffort: medium + inputModalities: [text] + supportsPersonality: false + upgrade: null + + - id: gpt-5.3-codex-spark + displayName: GPT-5.3 Codex Spark + description: Ultra-fast real-time coding model + isDefault: false + supportedReasoningEfforts: + - { reasoningEffort: minimal, description: "Minimal reasoning" } + - { reasoningEffort: low, description: "Fastest responses" } + defaultReasoningEffort: low + inputModalities: [text] + supportsPersonality: false + upgrade: null + # ── GPT-5.2 Codex (current flagship) ──────────────────────────────── - id: gpt-5.2-codex displayName: GPT-5.2 Codex @@ -162,3 +204,4 @@ models: aliases: codex: "gpt-5.2-codex" + gpt-5.4-codex: "gpt-5.4" diff --git a/docker-compose.yml b/docker-compose.yml index 564cf24..0c2867b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,12 @@ services: codex-proxy: - image: ghcr.io/icebear0828/codex-proxy:latest + image: yuwei/codex-proxy:latest # To build from source instead: comment out 'image' above, uncomment 'build' below # build: . extra_hosts: - "host.docker.internal:host-gateway" ports: - - "${PORT:-8080}:8080" + - "${PORT:-18080}:18080" - "1455:1455" volumes: - ./data:/app/data @@ -16,7 +16,7 @@ services: - .env environment: - NODE_ENV=production - - PORT=8080 + - PORT=18080 # -- Automatic updates (uncomment to enable) -- # watchtower: diff --git a/package-lock.json b/package-lock.json index f3423b7..9e42b3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-proxy", - "version": "1.0.39", + "version": "1.0.45", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-proxy", - "version": "1.0.39", + "version": "1.0.45", "hasInstallScript": true, "dependencies": { "@hono/node-server": "^1.0.0", diff --git a/src/proxy/codex-api.ts b/src/proxy/codex-api.ts index 233cade..49f03d0 100644 --- a/src/proxy/codex-api.ts +++ b/src/proxy/codex-api.ts @@ -17,6 +17,10 @@ import { } from "../fingerprint/manager.js"; import type { CookieJar } from "./cookie-jar.js"; import type { BackendModelEntry } from "../models/model-store.js"; +import { + createCodexStreamLogger, + type LlmInteractionContext, +} from "../utils/llm-interaction-log.js"; let _firstModelFetchLogged = false; @@ -69,6 +73,7 @@ export class CodexApi { private cookieJar: CookieJar | null; private entryId: string | null; private proxyUrl: string | null | undefined; + private interactionContext: LlmInteractionContext | null = null; constructor( token: string, @@ -88,6 +93,10 @@ export class CodexApi { this.token = token; } + setInteractionContext(context: LlmInteractionContext | null): void { + this.interactionContext = context; + } + /** Build headers with cookies injected. */ private applyHeaders(headers: Record): Record { if (this.cookieJar && this.entryId) { @@ -321,6 +330,10 @@ export class CodexApi { throw new Error("Response body is null — cannot stream"); } + const interactionLogger = this.interactionContext + ? createCodexStreamLogger(this.interactionContext) + : null; + const reader = response.body .pipeThrough(new TextDecoderStream()) .getReader(); @@ -328,6 +341,7 @@ export class CodexApi { const MAX_SSE_BUFFER = 10 * 1024 * 1024; // 10MB let buffer = ""; let yieldedAny = false; + let streamError: unknown; try { while (true) { const { done, value } = await reader.read(); @@ -345,6 +359,7 @@ export class CodexApi { const evt = this.parseSSEBlock(part); if (evt) { yieldedAny = true; + interactionLogger?.observe(evt); yield evt; } } @@ -355,6 +370,7 @@ export class CodexApi { const evt = this.parseSSEBlock(buffer); if (evt) { yieldedAny = true; + interactionLogger?.observe(evt); yield evt; } } @@ -373,12 +389,18 @@ export class CodexApi { ?? (typeof errObj?.message === "string" ? errObj.message : null) ?? errorMessage; } catch { /* use raw text */ } - yield { + const evt = { event: "error", data: { error: { type: "error", code: "non_sse_response", message: errorMessage } }, }; + interactionLogger?.observe(evt); + yield evt; } + } catch (err) { + streamError = err; + throw err; } finally { + interactionLogger?.finish({ error: streamError }); reader.releaseLock(); } } diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 2288fa8..12c78cc 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -14,6 +14,7 @@ import { handleProxyRequest, type FormatAdapter, } from "./shared/proxy-handler.js"; +import { extractProxyApiKey, OPENAI_PROXY_KEY_SOURCES } from "./shared/proxy-auth.js"; function makeOpenAIFormat(wantReasoning: boolean): FormatAdapter { return { @@ -75,8 +76,7 @@ export function createChatRoutes( // Optional proxy API key check const config = getConfig(); if (config.server.proxy_api_key) { - const authHeader = c.req.header("Authorization"); - const providedKey = authHeader?.replace("Bearer ", ""); + const providedKey = extractProxyApiKey(c.req, OPENAI_PROXY_KEY_SOURCES); if ( !providedKey || !accountPool.validateProxyApiKey(providedKey) diff --git a/src/routes/gemini.ts b/src/routes/gemini.ts index dd91b94..c34dfce 100644 --- a/src/routes/gemini.ts +++ b/src/routes/gemini.ts @@ -25,6 +25,7 @@ import { handleProxyRequest, type FormatAdapter, } from "./shared/proxy-handler.js"; +import { extractProxyApiKey, GEMINI_PROXY_KEY_SOURCES } from "./shared/proxy-auth.js"; function makeError( code: number, @@ -113,11 +114,7 @@ export function createGeminiRoutes( // API key check: query param ?key= or header x-goog-api-key const config = getConfig(); if (config.server.proxy_api_key) { - const queryKey = c.req.query("key"); - const headerKey = c.req.header("x-goog-api-key"); - const authHeader = c.req.header("Authorization"); - const bearerKey = authHeader?.replace("Bearer ", ""); - const providedKey = queryKey ?? headerKey ?? bearerKey; + const providedKey = extractProxyApiKey(c.req, GEMINI_PROXY_KEY_SOURCES); if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) { c.status(401); diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 24df84c..552cbba 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -21,6 +21,10 @@ import { handleProxyRequest, type FormatAdapter, } from "./shared/proxy-handler.js"; +import { + ANTHROPIC_PROXY_KEY_SOURCES, + extractProxyApiKey, +} from "./shared/proxy-auth.js"; function makeError( type: AnthropicErrorType, @@ -66,10 +70,7 @@ export function createMessagesRoutes( // Optional proxy API key check (x-api-key or Bearer token) const config = getConfig(); if (config.server.proxy_api_key) { - const xApiKey = c.req.header("x-api-key"); - const authHeader = c.req.header("Authorization"); - const bearerKey = authHeader?.replace("Bearer ", ""); - const providedKey = xApiKey ?? bearerKey; + const providedKey = extractProxyApiKey(c.req, ANTHROPIC_PROXY_KEY_SOURCES); if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) { c.status(401); diff --git a/src/routes/responses.ts b/src/routes/responses.ts index 0cace6e..c3104da 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -19,6 +19,7 @@ import { handleProxyRequest, type FormatAdapter, } from "./shared/proxy-handler.js"; +import { extractProxyApiKey, OPENAI_PROXY_KEY_SOURCES } from "./shared/proxy-auth.js"; // ── Helpers ──────────────────────────────────────────────────────── @@ -169,8 +170,7 @@ export function createResponsesRoutes( // Optional proxy API key check const config = getConfig(); if (config.server.proxy_api_key) { - const authHeader = c.req.header("Authorization"); - const providedKey = authHeader?.replace("Bearer ", ""); + const providedKey = extractProxyApiKey(c.req, OPENAI_PROXY_KEY_SOURCES); if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) { c.status(401); return c.json({ @@ -200,18 +200,36 @@ export function createResponsesRoutes( }); } - if (!isRecord(body) || typeof body.instructions !== "string") { + + if (!isRecord(body)) { + c.status(400); + return c.json({ + type: "error", + error: { + type: "invalid_request_error", + code: "invalid_request", + message: "Request body must be a JSON object", + }, + }); + } + + if (body.instructions !== undefined && typeof body.instructions !== "string") { c.status(400); return c.json({ type: "error", error: { type: "invalid_request_error", code: "invalid_request", - message: "Missing required field: instructions (string)", + message: "Invalid field: instructions must be a string", }, }); } + const instructions = + typeof body.instructions === "string" + ? body.instructions + : "You are a helpful assistant."; + // Resolve model (suffix parsing extracts service_tier and reasoning_effort) const rawModel = typeof body.model === "string" ? body.model : "codex"; const parsed = parseModelName(rawModel); @@ -224,7 +242,7 @@ export function createResponsesRoutes( // When client sends stream:false, the proxy collects SSE events and returns assembled JSON. const codexRequest: CodexResponsesRequest = { model: modelId, - instructions: body.instructions, + instructions, input: Array.isArray(body.input) ? (body.input as CodexInputItem[]) : [], stream: true, store: false, diff --git a/src/routes/shared/proxy-auth.test.ts b/src/routes/shared/proxy-auth.test.ts new file mode 100644 index 0000000..65bb1ef --- /dev/null +++ b/src/routes/shared/proxy-auth.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + extractProxyApiKey, + OPENAI_PROXY_KEY_SOURCES, + GEMINI_PROXY_KEY_SOURCES, +} from "./proxy-auth.js"; + +function makeRequest(headers: Record = {}, query: Record = {}) { + const normalizedHeaders = new Map( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + + return { + header(name: string) { + return normalizedHeaders.get(name.toLowerCase()); + }, + query(name: string) { + return query[name]; + }, + }; +} + +describe("extractProxyApiKey", () => { + it("extracts Bearer tokens case-insensitively for OpenAI-compatible routes", () => { + const req = makeRequest({ Authorization: "bearer openclaw-key" }); + + expect(extractProxyApiKey(req, OPENAI_PROXY_KEY_SOURCES)).toBe("openclaw-key"); + }); + + it("falls back to x-api-key for OpenAI-compatible routes", () => { + const req = makeRequest({ "x-api-key": "openclaw-key" }); + + expect(extractProxyApiKey(req, OPENAI_PROXY_KEY_SOURCES)).toBe("openclaw-key"); + }); + + it("supports api-key headers for OpenAI-compatible routes", () => { + const req = makeRequest({ "api-key": "openclaw-key" }); + + expect(extractProxyApiKey(req, OPENAI_PROXY_KEY_SOURCES)).toBe("openclaw-key"); + }); + + it("preserves Gemini query param compatibility", () => { + const req = makeRequest({}, { key: "gemini-key" }); + + expect(extractProxyApiKey(req, GEMINI_PROXY_KEY_SOURCES)).toBe("gemini-key"); + }); +}); diff --git a/src/routes/shared/proxy-auth.ts b/src/routes/shared/proxy-auth.ts new file mode 100644 index 0000000..fa6c147 --- /dev/null +++ b/src/routes/shared/proxy-auth.ts @@ -0,0 +1,87 @@ +export interface ProxyApiKeyRequestLike { + header(name: string): string | undefined; + query(name: string): string | undefined; +} + +export type ProxyApiKeySource = + | "authorization" + | "x-api-key" + | "api-key" + | "x-goog-api-key" + | "key" + | "api_key"; + +export const OPENAI_PROXY_KEY_SOURCES: ProxyApiKeySource[] = [ + "authorization", + "x-api-key", + "api-key", +]; + +export const ANTHROPIC_PROXY_KEY_SOURCES: ProxyApiKeySource[] = [ + "x-api-key", + "authorization", + "api-key", +]; + +export const GEMINI_PROXY_KEY_SOURCES: ProxyApiKeySource[] = [ + "key", + "x-goog-api-key", + "authorization", + "x-api-key", + "api-key", +]; + +function extractAuthorizationToken(value: string | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + + const match = /^Bearer\s+(.+)$/i.exec(trimmed); + if (match) { + const token = match[1]?.trim(); + return token || null; + } + + return trimmed; +} + +export function extractProxyApiKey( + req: ProxyApiKeyRequestLike, + sources: readonly ProxyApiKeySource[], +): string | null { + for (const source of sources) { + switch (source) { + case "authorization": { + const token = extractAuthorizationToken(req.header("Authorization")); + if (token) return token; + break; + } + case "x-api-key": { + const token = req.header("x-api-key")?.trim(); + if (token) return token; + break; + } + case "api-key": { + const token = req.header("api-key")?.trim(); + if (token) return token; + break; + } + case "x-goog-api-key": { + const token = req.header("x-goog-api-key")?.trim(); + if (token) return token; + break; + } + case "key": { + const token = req.query("key")?.trim(); + if (token) return token; + break; + } + case "api_key": { + const token = req.query("api_key")?.trim(); + if (token) return token; + break; + } + } + } + + return null; +} diff --git a/src/routes/shared/proxy-handler.ts b/src/routes/shared/proxy-handler.ts index e848d7a..e6e3626 100644 --- a/src/routes/shared/proxy-handler.ts +++ b/src/routes/shared/proxy-handler.ts @@ -16,6 +16,10 @@ import type { AccountPool } from "../../auth/account-pool.js"; import type { CookieJar } from "../../proxy/cookie-jar.js"; import type { ProxyPool } from "../../proxy/proxy-pool.js"; import { withRetry } from "../../utils/retry.js"; +import { + logCodexRequest, + type LlmInteractionContext, +} from "../../utils/llm-interaction-log.js"; /** Data prepared by each route after parsing and translating the request. */ export interface ProxyRequest { @@ -95,6 +99,8 @@ export async function handleProxyRequest( fmt: FormatAdapter, proxyPool?: ProxyPool, ): Promise { + const requestId = c.get("requestId"); + const rid = typeof requestId === "string" ? requestId : "-"; // 1. Acquire account (model-aware) const acquired = accountPool.acquire({ model: req.codexRequest.model }); if (!acquired) { @@ -105,30 +111,42 @@ export async function handleProxyRequest( const { entryId, token, accountId } = acquired; const proxyUrl = proxyPool?.resolveProxyUrl(entryId); let codexApi = new CodexApi(token, accountId, cookieJar, entryId, proxyUrl); + const getInteractionContext = (activeEntryId: string): LlmInteractionContext => ({ + rid, + tag: fmt.tag, + entryId: activeEntryId, + clientModel: req.model, + upstreamModel: req.codexRequest.model, + isStreaming: req.isStreaming, + }); + const bindInteractionContext = (api: CodexApi, activeEntryId: string): void => { + api.setInteractionContext(getInteractionContext(activeEntryId)); + }; + const sendCodexRequest = async (api: CodexApi, activeEntryId: string): Promise => { + bindInteractionContext(api, activeEntryId); + logCodexRequest(getInteractionContext(activeEntryId), req.codexRequest); + return withRetry( + () => api.createResponse(req.codexRequest, abortController.signal), + { tag: fmt.tag }, + ); + }; // Tracks which account the outer catch should release (updated by retry loop) let activeEntryId = entryId; // Track tried accounts for model retry exclusion const triedEntryIds: string[] = [entryId]; let modelRetried = false; - console.log( - `[${fmt.tag}] Account ${entryId} | Codex request:`, - JSON.stringify(req.codexRequest).slice(0, 300), - ); - let usageInfo: { input_tokens: number; output_tokens: number; cached_tokens?: number; reasoning_tokens?: number } | undefined; // P0-2: AbortController to kill curl when client disconnects const abortController = new AbortController(); c.req.raw.signal.addEventListener("abort", () => abortController.abort(), { once: true }); + bindInteractionContext(codexApi, activeEntryId); for (;;) { // model retry loop (max 1 retry) try { // 3. Retry + send to Codex - const rawResponse = await withRetry( - () => codexApi.createResponse(req.codexRequest, abortController.signal), - { tag: fmt.tag }, - ); + const rawResponse = await sendCodexRequest(codexApi, activeEntryId); // 4. Stream or collect if (req.isStreaming) { @@ -201,10 +219,7 @@ export async function handleProxyRequest( const retryProxyUrl = proxyPool?.resolveProxyUrl(newAcquired.entryId); currentCodexApi = new CodexApi(newAcquired.token, newAcquired.accountId, cookieJar, newAcquired.entryId, retryProxyUrl); try { - currentRawResponse = await withRetry( - () => currentCodexApi.createResponse(req.codexRequest, abortController.signal), - { tag: fmt.tag }, - ); + currentRawResponse = await sendCodexRequest(currentCodexApi, currentEntryId); } catch (retryErr) { accountPool.release(currentEntryId); if (retryErr instanceof CodexApiError) { @@ -259,6 +274,7 @@ export async function handleProxyRequest( triedEntryIds.push(retry.entryId); const retryProxyUrl = proxyPool?.resolveProxyUrl(retry.entryId); codexApi = new CodexApi(retry.token, retry.accountId, cookieJar, retry.entryId, retryProxyUrl); + bindInteractionContext(codexApi, activeEntryId); console.log(`[${fmt.tag}] Retrying with account ${retry.entryId}`); continue; // re-enter model retry loop } @@ -292,6 +308,7 @@ export async function handleProxyRequest( triedEntryIds.push(retry.entryId); const retryProxyUrl = proxyPool?.resolveProxyUrl(retry.entryId); codexApi = new CodexApi(retry.token, retry.accountId, cookieJar, retry.entryId, retryProxyUrl); + bindInteractionContext(codexApi, activeEntryId); console.log(`[${fmt.tag}] 429 fallback → account ${retry.entryId}`); continue; } diff --git a/src/utils/llm-interaction-log.ts b/src/utils/llm-interaction-log.ts new file mode 100644 index 0000000..2a369f0 --- /dev/null +++ b/src/utils/llm-interaction-log.ts @@ -0,0 +1,317 @@ +import type { CodexResponsesRequest, CodexSSEEvent } from "../proxy/codex-api.js"; +import { log } from "./logger.js"; + +export interface LlmInteractionContext { + rid: string; + tag: string; + entryId: string; + clientModel: string; + upstreamModel: string; + isStreaming: boolean; +} + +interface UsageInfo { + input_tokens?: number; + output_tokens?: number; + cached_tokens?: number; + reasoning_tokens?: number; +} + +interface ToolCallInfo { + name: string; + arguments: string; +} + +interface StreamFinishOptions { + error?: unknown; +} + +const MAX_LOG_STRING = 1600; +const MAX_LOG_ARRAY_ITEMS = 12; +const MAX_LOG_OBJECT_KEYS = 40; + +function truncateString(value: string, max = MAX_LOG_STRING): string { + if (value.length <= max) return value; + return `${value.slice(0, max)}... [truncated ${value.length - max} chars]`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function truncateForLog(value: unknown, seen = new WeakSet()): unknown { + if (typeof value === "string") return truncateString(value); + if (typeof value !== "object" || value === null) return value; + if (seen.has(value)) return "[Circular]"; + seen.add(value); + + if (Array.isArray(value)) { + const items = value + .slice(0, MAX_LOG_ARRAY_ITEMS) + .map((item) => truncateForLog(item, seen)); + if (value.length > MAX_LOG_ARRAY_ITEMS) { + items.push(`[+${value.length - MAX_LOG_ARRAY_ITEMS} more items]`); + } + return items; + } + + const entries = Object.entries(value); + const out: Record = {}; + for (const [key, entryValue] of entries.slice(0, MAX_LOG_OBJECT_KEYS)) { + out[key] = truncateForLog(entryValue, seen); + } + if (entries.length > MAX_LOG_OBJECT_KEYS) { + out.__truncated_keys = entries.length - MAX_LOG_OBJECT_KEYS; + } + return out; +} + +function summarizeContent(content: unknown): string { + if (typeof content === "string") return truncateString(content); + if (!Array.isArray(content)) return ""; + + const parts: string[] = []; + for (const part of content) { + if (!isRecord(part) || typeof part.type !== "string") continue; + if (part.type === "input_text" && typeof part.text === "string") { + parts.push(part.text); + continue; + } + if (part.type === "input_image") { + const url = typeof part.image_url === "string" ? truncateString(part.image_url, 120) : ""; + parts.push(`[image] ${url}`); + } + } + return truncateString(parts.join("\n")); +} + +function summarizeInput(request: CodexResponsesRequest): string[] { + return request.input.slice(0, 8).map((item) => { + if ("role" in item) { + return `${item.role}: ${summarizeContent(item.content)}`; + } + if (item.type === "function_call") { + return `tool_call ${item.name}(${truncateString(item.arguments)})`; + } + if (item.type === "function_call_output") { + return `tool_result ${item.call_id}: ${truncateString(item.output)}`; + } + return truncateString(JSON.stringify(item)); + }); +} + +function extractUsage(response: Record): UsageInfo | undefined { + const usage = isRecord(response.usage) ? response.usage : null; + if (!usage) return undefined; + + const result: UsageInfo = {}; + if (typeof usage.input_tokens === "number") result.input_tokens = usage.input_tokens; + if (typeof usage.output_tokens === "number") result.output_tokens = usage.output_tokens; + + const inputDetails = isRecord(usage.input_tokens_details) ? usage.input_tokens_details : null; + if (inputDetails && typeof inputDetails.cached_tokens === "number") { + result.cached_tokens = inputDetails.cached_tokens; + } + + const outputDetails = isRecord(usage.output_tokens_details) ? usage.output_tokens_details : null; + if (outputDetails && typeof outputDetails.reasoning_tokens === "number") { + result.reasoning_tokens = outputDetails.reasoning_tokens; + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +export function logCodexRequest( + context: LlmInteractionContext, + request: CodexResponsesRequest, +): void { + const toolNames = Array.isArray(request.tools) + ? request.tools + .filter(isRecord) + .map((tool) => (typeof tool.name === "string" ? tool.name : typeof tool.type === "string" ? tool.type : "unknown")) + : []; + + log.info("[LLM] Upstream request", { + rid: context.rid, + route: context.tag, + entryId: context.entryId, + clientModel: context.clientModel, + upstreamModel: context.upstreamModel, + isStreaming: context.isStreaming, + instructions: truncateString(request.instructions), + inputPreview: summarizeInput(request), + toolNames, + payload: truncateForLog(request), + }); +} + +export function createCodexStreamLogger(context: LlmInteractionContext): { + observe: (event: CodexSSEEvent) => void; + finish: (options?: StreamFinishOptions) => void; +} { + const eventCounts: Record = {}; + const itemIdToCallId = new Map(); + const toolCalls = new Map(); + let responseId: string | null = null; + let usage: UsageInfo | undefined; + let text = ""; + let reasoning = ""; + let terminalState = "stream_closed"; + let finished = false; + let terminalError: { code?: string; message?: string } | undefined; + + const resolveToolCall = (id: string): ToolCallInfo => { + const existing = toolCalls.get(id); + if (existing) return existing; + const created = { name: "", arguments: "" }; + toolCalls.set(id, created); + return created; + }; + + return { + observe: (event) => { + eventCounts[event.event] = (eventCounts[event.event] ?? 0) + 1; + const data = event.data; + + switch (event.event) { + case "response.created": + case "response.in_progress": + case "response.queued": + case "response.incomplete": + case "response.completed": + case "response.failed": { + if (isRecord(data) && isRecord(data.response)) { + if (typeof data.response.id === "string") responseId = data.response.id; + usage = extractUsage(data.response) ?? usage; + } + if (event.event === "response.completed") terminalState = "completed"; + if (event.event === "response.incomplete") terminalState = "incomplete"; + if (event.event === "response.failed") { + terminalState = "failed"; + const error = isRecord(data) && isRecord(data.error) ? data.error : null; + terminalError = error + ? { + code: typeof error.code === "string" ? error.code : undefined, + message: typeof error.message === "string" ? error.message : truncateString(JSON.stringify(error)), + } + : undefined; + } + break; + } + + case "response.output_text.delta": + if (isRecord(data) && typeof data.delta === "string") text += data.delta; + break; + + case "response.output_text.done": + if (isRecord(data) && typeof data.text === "string" && data.text.length >= text.length) { + text = data.text; + } + break; + + case "response.reasoning_summary_text.delta": + if (isRecord(data) && typeof data.delta === "string") reasoning += data.delta; + break; + + case "response.reasoning_summary_text.done": + if (isRecord(data) && typeof data.text === "string" && data.text.length >= reasoning.length) { + reasoning = data.text; + } + break; + + case "response.output_item.added": + if ( + isRecord(data) + && isRecord(data.item) + && data.item.type === "function_call" + && typeof data.item.call_id === "string" + ) { + if (typeof data.item.id === "string") { + itemIdToCallId.set(data.item.id, data.item.call_id); + } + const toolCall = resolveToolCall(data.item.call_id); + toolCall.name = typeof data.item.name === "string" ? data.item.name : toolCall.name; + } + break; + + case "response.function_call_arguments.delta": + if (isRecord(data) && typeof data.delta === "string") { + const toolId = + typeof data.call_id === "string" + ? data.call_id + : typeof data.item_id === "string" + ? (itemIdToCallId.get(data.item_id) ?? data.item_id) + : ""; + if (toolId) { + resolveToolCall(toolId).arguments += data.delta; + } + } + break; + + case "response.function_call_arguments.done": + if (isRecord(data)) { + const toolId = + typeof data.call_id === "string" + ? data.call_id + : typeof data.item_id === "string" + ? (itemIdToCallId.get(data.item_id) ?? data.item_id) + : ""; + if (toolId) { + const toolCall = resolveToolCall(toolId); + if (typeof data.name === "string") toolCall.name = data.name; + if (typeof data.arguments === "string") toolCall.arguments = data.arguments; + } + } + break; + + case "error": + terminalState = "error"; + if (isRecord(data) && isRecord(data.error)) { + terminalError = { + code: typeof data.error.code === "string" ? data.error.code : undefined, + message: typeof data.error.message === "string" ? data.error.message : truncateString(JSON.stringify(data.error)), + }; + } + break; + } + }, + + finish: (options) => { + if (finished) return; + finished = true; + + const errorMessage = options?.error instanceof Error + ? options.error.message + : options?.error != null + ? String(options.error) + : undefined; + const status = errorMessage ? "stream_error" : terminalState; + const extra = { + rid: context.rid, + route: context.tag, + entryId: context.entryId, + clientModel: context.clientModel, + upstreamModel: context.upstreamModel, + isStreaming: context.isStreaming, + status, + responseId, + usage, + eventCounts, + outputText: text ? truncateString(text) : undefined, + reasoningSummary: reasoning ? truncateString(reasoning) : undefined, + toolCalls: [...toolCalls.entries()].map(([callId, info]) => ({ + callId, + name: info.name || undefined, + arguments: info.arguments ? truncateString(info.arguments) : undefined, + })), + error: terminalError ?? (errorMessage ? { message: errorMessage } : undefined), + }; + + if (status === "completed") { + log.info("[LLM] Upstream response", extra); + } else { + log.warn("[LLM] Upstream response", extra); + } + }, + }; +} From ba18bc15ae5437e279c8c539c2f7075630eecef3 Mon Sep 17 00:00:00 2001 From: yuwei5380 Date: Sun, 15 Mar 2026 11:45:12 +0800 Subject: [PATCH 2/2] feat: add basic auth for management endpoints --- README.md | 17 ++++++ README_EN.md | 25 +++++++-- config/default.yaml | 2 + src/config.ts | 10 ++++ src/index.ts | 2 + src/middleware/admin-basic-auth.test.ts | 55 ++++++++++++++++++ src/middleware/admin-basic-auth.ts | 74 +++++++++++++++++++++++++ 7 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/middleware/admin-basic-auth.test.ts create mode 100644 src/middleware/admin-basic-auth.ts diff --git a/README.md b/README.md index fbb2025..8c65126 100644 --- a/README.md +++ b/README.md @@ -407,11 +407,28 @@ server: - **自动生成**:设为 `null`,代理会根据账号信息自动生成一个 `codex-proxy-` 前缀的哈希密钥 - 当前密钥始终显示在控制面板(`http://localhost:8080`)的 API Configuration 区域 +### 管理端 Basic Auth + +为除 `/v1/*`、`/v1beta/*` 和 `/health` 之外的管理端页面与接口增加 HTTP Basic Authentication: + +```yaml +server: + admin_basic_auth_username: admin + admin_basic_auth_password: change-me +``` + +- 启用后,控制面板页面、`/auth/*`、`/admin/*`、`/api/*`、`/debug/*` 等管理端接口会要求 Basic Auth +- `/v1/*` 与 `/v1beta/*` 客户端协议接口不受影响,便于 OpenAI / Anthropic / Gemini 客户端继续直接接入 +- `/health` 保持免认证,便于反向代理和容器健康检查 +- 也可通过环境变量覆盖:`ADMIN_BASIC_AUTH_USERNAME`、`ADMIN_BASIC_AUTH_PASSWORD` + ### 环境变量覆盖 | 环境变量 | 覆盖配置 | |---------|---------| | `PORT` | `server.port` | +| `ADMIN_BASIC_AUTH_USERNAME` | `server.admin_basic_auth_username` | +| `ADMIN_BASIC_AUTH_PASSWORD` | `server.admin_basic_auth_password` | | `CODEX_PLATFORM` | `client.platform` | | `CODEX_ARCH` | `client.arch` | | `HTTPS_PROXY` | `tls.proxy_url` | diff --git a/README_EN.md b/README_EN.md index 9b87b18..019dcee 100644 --- a/README_EN.md +++ b/README_EN.md @@ -290,19 +290,36 @@ All configuration is in `config/default.yaml`: | Section | Key Settings | Description | |---------|-------------|-------------| -| `server` | `host`, `port`, `proxy_api_key` | Listen address and API key | +| `server` | `host`, `port`, `proxy_api_key`, `admin_basic_auth_*` | Listen address, API key, and management Basic Auth | | `api` | `base_url`, `timeout_seconds` | Upstream API URL and timeout | -| `client_identity` | `app_version`, `build_number` | Codex Desktop version to impersonate | +| `client` | `originator`, `app_version`, `build_number`, `platform`, `arch`, `chromium_version` | Codex Desktop / Chromium identity to impersonate | | `model` | `default`, `default_reasoning_effort`, `default_service_tier` | Default model, reasoning effort and speed mode | | `auth` | `rotation_strategy`, `rate_limit_backoff_seconds` | Rotation strategy and rate limit backoff | +### Management Basic Auth + +Add HTTP Basic Authentication for all management pages and endpoints except `/v1/*`, `/v1beta/*`, and `/health`: + +```yaml +server: + admin_basic_auth_username: admin + admin_basic_auth_password: change-me +``` + +- When enabled, the dashboard UI and management endpoints such as `/auth/*`, `/admin/*`, `/api/*`, and `/debug/*` require Basic Auth +- `/v1/*` and `/v1beta/*` remain unchanged so OpenAI / Anthropic / Gemini clients can continue using the proxy directly +- `/health` remains unauthenticated for reverse proxy and container health checks +- You can also override these values with `ADMIN_BASIC_AUTH_USERNAME` and `ADMIN_BASIC_AUTH_PASSWORD` + ### Environment Variable Overrides | Variable | Overrides | |----------|-----------| | `PORT` | `server.port` | -| `CODEX_PLATFORM` | `client_identity.platform` | -| `CODEX_ARCH` | `client_identity.arch` | +| `ADMIN_BASIC_AUTH_USERNAME` | `server.admin_basic_auth_username` | +| `ADMIN_BASIC_AUTH_PASSWORD` | `server.admin_basic_auth_password` | +| `CODEX_PLATFORM` | `client.platform` | +| `CODEX_ARCH` | `client.arch` | ## 📡 API Endpoints diff --git a/config/default.yaml b/config/default.yaml index 472ed4c..6b84262 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -26,6 +26,8 @@ server: host: "0.0.0.0" port: 18080 proxy_api_key: "sk-2bf2321a5c234a9b3e5acec615fb53081e41f86e861373cf4c1eca4237c8825e" + admin_basic_auth_username: "admin" + admin_basic_auth_password: "f4c1eca4237c8825e" session: ttl_minutes: 60 cleanup_interval_minutes: 5 diff --git a/src/config.ts b/src/config.ts index aa952e3..8e3e73f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,8 @@ const ConfigSchema = z.object({ host: z.string().default("0.0.0.0"), port: z.number().min(1).max(65535).default(8080), proxy_api_key: z.string().nullable().default(null), + admin_basic_auth_username: z.string().nullable().default(null), + admin_basic_auth_password: z.string().nullable().default(null), }), session: z.object({ ttl_minutes: z.number().min(1).default(60), @@ -89,6 +91,14 @@ function applyEnvOverrides(raw: Record): Record).port = parsed; } } + if (process.env.ADMIN_BASIC_AUTH_USERNAME !== undefined) { + (raw.server as Record).admin_basic_auth_username = + process.env.ADMIN_BASIC_AUTH_USERNAME; + } + if (process.env.ADMIN_BASIC_AUTH_PASSWORD !== undefined) { + (raw.server as Record).admin_basic_auth_password = + process.env.ADMIN_BASIC_AUTH_PASSWORD; + } const proxyEnv = process.env.HTTPS_PROXY || process.env.https_proxy; if (proxyEnv) { if (!raw.tls) raw.tls = {}; diff --git a/src/index.ts b/src/index.ts index acf792d..a6eaf69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { RefreshScheduler } from "./auth/refresh-scheduler.js"; import { requestId } from "./middleware/request-id.js"; import { logger } from "./middleware/logger.js"; import { errorHandler } from "./middleware/error-handler.js"; +import { adminBasicAuth } from "./middleware/admin-basic-auth.js"; import { createAuthRoutes } from "./routes/auth.js"; import { createAccountRoutes } from "./routes/accounts.js"; import { createChatRoutes } from "./routes/chat.js"; @@ -67,6 +68,7 @@ export async function startServer(options?: StartOptions): Promise app.use("*", requestId); app.use("*", logger); app.use("*", errorHandler); + app.use("*", adminBasicAuth); // Mount routes const authRoutes = createAuthRoutes(accountPool, refreshScheduler); diff --git a/src/middleware/admin-basic-auth.test.ts b/src/middleware/admin-basic-auth.test.ts new file mode 100644 index 0000000..c835fbe --- /dev/null +++ b/src/middleware/admin-basic-auth.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + isProtectedManagementPath, + parseBasicAuthHeader, +} from "./admin-basic-auth.js"; + +describe("isProtectedManagementPath", () => { + it("bypasses OpenAI-compatible API routes", () => { + expect(isProtectedManagementPath("/v1/chat/completions")).toBe(false); + expect(isProtectedManagementPath("/v1/models")).toBe(false); + }); + + it("bypasses Gemini-compatible API routes", () => { + expect(isProtectedManagementPath("/v1beta/models")).toBe(false); + expect(isProtectedManagementPath("/v1beta/models/gpt-5.4:generateContent")).toBe(false); + }); + + it("keeps health checks public", () => { + expect(isProtectedManagementPath("/health")).toBe(false); + }); + + it("protects dashboard and management endpoints", () => { + expect(isProtectedManagementPath("/")).toBe(true); + expect(isProtectedManagementPath("/auth/status")).toBe(true); + expect(isProtectedManagementPath("/admin/settings")).toBe(true); + expect(isProtectedManagementPath("/api/proxies")).toBe(true); + expect(isProtectedManagementPath("/debug/models")).toBe(true); + }); +}); + +describe("parseBasicAuthHeader", () => { + it("parses valid Basic credentials", () => { + const header = `Basic ${Buffer.from("admin:secret").toString("base64")}`; + + expect(parseBasicAuthHeader(header)).toEqual({ + username: "admin", + password: "secret", + }); + }); + + it("handles scheme case-insensitively", () => { + const header = `basic ${Buffer.from("admin:secret").toString("base64")}`; + + expect(parseBasicAuthHeader(header)).toEqual({ + username: "admin", + password: "secret", + }); + }); + + it("rejects malformed values", () => { + expect(parseBasicAuthHeader(undefined)).toBeNull(); + expect(parseBasicAuthHeader("Bearer token")).toBeNull(); + expect(parseBasicAuthHeader("Basic invalid")).toBeNull(); + }); +}); diff --git a/src/middleware/admin-basic-auth.ts b/src/middleware/admin-basic-auth.ts new file mode 100644 index 0000000..7e4bc2b --- /dev/null +++ b/src/middleware/admin-basic-auth.ts @@ -0,0 +1,74 @@ +import { timingSafeEqual } from "crypto"; +import type { Context, Next } from "hono"; +import { getConfig } from "../config.js"; + +interface BasicAuthCredentials { + username: string; + password: string; +} + +function secureEquals(left: string, right: string): boolean { + const leftBuf = Buffer.from(left, "utf8"); + const rightBuf = Buffer.from(right, "utf8"); + if (leftBuf.length !== rightBuf.length) return false; + return timingSafeEqual(leftBuf, rightBuf); +} + +export function isProtectedManagementPath(path: string): boolean { + if (path === "/health") return false; + if (path === "/v1" || path.startsWith("/v1/")) return false; + if (path === "/v1beta" || path.startsWith("/v1beta/")) return false; + return true; +} + +export function parseBasicAuthHeader( + authorization: string | undefined, +): BasicAuthCredentials | null { + const raw = authorization?.trim(); + if (!raw) return null; + + const match = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(raw); + if (!match) return null; + + let decoded = ""; + try { + decoded = Buffer.from(match[1], "base64").toString("utf8"); + } catch { + return null; + } + + const separator = decoded.indexOf(":"); + if (separator < 0) return null; + + return { + username: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; +} + +export async function adminBasicAuth(c: Context, next: Next): Promise { + if (!isProtectedManagementPath(c.req.path)) { + await next(); + return; + } + + const { admin_basic_auth_username, admin_basic_auth_password } = getConfig().server; + if (admin_basic_auth_username === null || admin_basic_auth_password === null) { + await next(); + return; + } + + const credentials = parseBasicAuthHeader(c.req.header("Authorization")); + const authenticated = + credentials !== null && + secureEquals(credentials.username, admin_basic_auth_username) && + secureEquals(credentials.password, admin_basic_auth_password); + + if (authenticated) { + await next(); + return; + } + + c.header("WWW-Authenticate", 'Basic realm="Codex Proxy Admin", charset="UTF-8"'); + return c.text("Unauthorized", 401); +}