From e97d53093e604331253392c76e00f29e2e3e8517 Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 12:49:05 +0800 Subject: [PATCH 01/14] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bf5d7a0..5d53582 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ config.yaml *.log .env +.worktrees/ From c154026c30a6dd037c01c115b4a8481b43811eb6 Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 12:51:36 +0800 Subject: [PATCH 02/14] docs: add codex dual-provider design and plan --- .../2026-03-30-codex-dual-provider-design.md | 238 +++++++++++ docs/plans/2026-03-30-codex-dual-provider.md | 393 ++++++++++++++++++ 2 files changed, 631 insertions(+) create mode 100644 docs/plans/2026-03-30-codex-dual-provider-design.md create mode 100644 docs/plans/2026-03-30-codex-dual-provider.md diff --git a/docs/plans/2026-03-30-codex-dual-provider-design.md b/docs/plans/2026-03-30-codex-dual-provider-design.md new file mode 100644 index 0000000..d2bb4dd --- /dev/null +++ b/docs/plans/2026-03-30-codex-dual-provider-design.md @@ -0,0 +1,238 @@ +# Codex Dual-Provider Design + +## Goal + +将 `auth2api` 从“单 Claude OAuth 账号代理”扩展为“单实例、单端口、双 provider 代理”,同时向本机脚本暴露: + +- `POST /v1/chat/completions` +- `POST /v1/responses` +- `GET /v1/models` + +其中: + +- `claude-*` 模型走现有 Claude OAuth 链路 +- `gpt-*`、`o*`、`codex-*` 模型走本机 `~/.codex/auth.json` 对应的 Codex OAuth 链路 + +## Non-Goals + +- 不新增 Codex 登录流程 +- 不优先兼容所有第三方 SDK 的边角行为 +- 不默认把服务暴露到局域网,仍保持 `127.0.0.1` +- 不把 Claude 与 Codex 的上游协议强行统一成同一套内部格式 + +## Chosen Approach + +采用 “单 HTTP 层 + provider 抽象 + 按模型自动路由” 的结构。 + +保留现有服务入口和大部分 Claude 逻辑,但把具体上游调用封装到 provider 层。HTTP 层只关心两件事: + +1. 当前请求属于哪个 provider +2. 该 provider 如何处理 `chat/completions`、`responses`、`models`、`status` + +Codex 侧使用 `responses` 作为内部主语义,因为它更接近 Codex 上游形态。`chat/completions` 只是兼容层,先转换成 provider 内部请求,再复用同一条 Codex 主链。 + +## High-Level Architecture + +### 1. HTTP Layer + +现有 [src/server.ts](/Users/wy/auth2api/.worktrees/feature-codex-dual-provider/src/server.ts) 继续保留: + +- API key 鉴权 +- 限流 +- CORS +- 路由注册 + +但不再直接把路由绑死到 Claude handler,而是注入一个 `ProviderRouter`。 + +### 2. Provider Layer + +新增 provider 抽象: + +- `ClaudeProvider` +- `CodexProvider` +- `ProviderRouter` + +统一接口建议包含: + +- `supportsModel(model: string): boolean` +- `listModels(): ProviderModel[]` +- `getStatus(): ProviderStatus` +- `handleChatCompletions(req, res): Promise` +- `handleResponses(req, res): Promise` + +Claude provider 主要是对现有代码做封装,不改变其上游行为。 + +Codex provider 负责: + +- 读取 `~/.codex/auth.json` +- 将外部请求转换为 Codex 上游请求 +- 调用 Codex 上游 +- 将结果映射回 OpenAI 风格接口 + +### 3. Auth / Session Layer + +Claude 继续使用现有 [src/accounts/manager.ts](/Users/wy/auth2api/.worktrees/feature-codex-dual-provider/src/accounts/manager.ts)。 + +Codex 新增 `CodexAuthStore` / `CodexSessionManager`: + +- 只读 `~/.codex/auth.json` +- 读取字段: + - `auth_mode` + - `tokens.access_token` + - `tokens.refresh_token` + - `tokens.account_id` + - `last_refresh` +- 以文件 `mtime` 作为热重载依据 +- 不自行做 OAuth login +- 第一版不主动实现 refresh 流程,只在请求前取最新文件内容 + +## Request Flow + +### Chat Completions + +1. 客户端请求 `/v1/chat/completions` +2. `server.ts` 完成 API key 校验 +3. `ProviderRouter` 根据 `model` 选择 Claude 或 Codex +4. Claude: + - 走现有 `openai -> claude messages -> claude response -> openai` 链路 +5. Codex: + - `chat/completions` 请求先转换为 Codex 内部 canonical request + - 复用 Codex `responses` 主链 + - 输出 OpenAI chat completion 格式 + +### Responses + +1. 客户端请求 `/v1/responses` +2. `ProviderRouter` 根据 `model` 选择 provider +3. Claude: + - 继续走现有 [src/proxy/responses.ts](/Users/wy/auth2api/.worktrees/feature-codex-dual-provider/src/proxy/responses.ts) 思路 +4. Codex: + - 直接按 `responses` 语义构造上游请求 + - 普通响应和流式响应分别映射回 OpenAI Responses API 格式 + +### Models + +`/v1/models` 改为 provider 聚合: + +- Claude 模型:保留现有列表 +- Codex 模型:第一版使用配置或静态白名单 +- 对外返回两者并集 + +### Admin + +`/admin/accounts` 升级为 provider 视角状态: + +- `claude`: 当前账户、冷却、刷新、统计 +- `codex`: `auth.json` 是否存在、最后加载时间、最近错误、是否可用 + +## Codex-Specific Design + +### Upstream + +根据当前本机信息,Codex 上游目标应抽象为: + +- `https://chatgpt.com/backend-api/codex/responses` + +该地址与头部要求可能变化,因此应集中在单独模块,例如: + +- `src/providers/codex/upstream.ts` + +避免在 HTTP 层、转换层和状态层散落硬编码。 + +### Token Handling + +Codex provider 的第一版策略: + +1. 启动时读取 `~/.codex/auth.json` +2. 每次请求前检查文件 `mtime` +3. 若文件变化则重新加载 +4. 若上游返回 `401` + - 强制重读一次 `auth.json` + - 仅重试一次 +5. 仍失败则把 provider 状态标记为 `unavailable` + +这保证了用户重新执行 `codex login` 后,代理可自动恢复,而不用重启进程。 + +### Streaming + +Codex 流式返回格式与 Claude SSE 可能不同,因此必须单独实现: + +- `Codex responses stream -> OpenAI responses SSE` +- `Codex responses stream -> OpenAI chat completion SSE` + +不要复用 Claude 的 `claudeStreamEventToOpenai(...)`。 + +## Config Changes + +建议在 [src/config.ts](/Users/wy/auth2api/.worktrees/feature-codex-dual-provider/src/config.ts) 中新增: + +```yaml +codex: + enabled: true + auth-file: "~/.codex/auth.json" + models: + - "gpt-5.4" + - "gpt-5.4-mini" + - "codex-mini-latest" +``` + +说明: + +- `enabled` 控制是否启用 Codex provider +- `auth-file` 允许未来从非默认位置读取 +- `models` 先走白名单,避免动态探测带来的不确定性 + +## Error Handling + +### Claude + +保持现有行为: + +- 网络错误重试 +- 429/5xx 冷却 +- 401 refresh token 后重试 + +### Codex + +第一版错误处理策略: + +- `auth.json` 缺失或字段不完整:provider 不可用,但 Claude 正常工作 +- 401:重载 auth 文件并重试一次 +- 429:返回明确的 rate limit 错误 +- 5xx / 网络错误:短重试后返回 upstream error + +关键原则: + +- Claude provider 故障不影响 Codex provider +- Codex provider 故障不影响 Claude provider + +## Testing Strategy + +### Unit + +- model 路由 +- `auth.json` 解析 +- chat -> codex canonical request 转换 +- responses -> codex canonical request 转换 +- codex response -> openai response/chat 映射 + +### Integration + +- mock Codex 上游普通响应 +- mock Codex 上游流式响应 +- 验证 `/v1/models` 聚合 +- 验证 `/admin/accounts` provider 状态 + +### Regression + +- 保证现有 Claude smoke tests 全部通过 +- 追加 Codex smoke case,不破坏已有行为 + +## Implementation Order + +1. 引入 provider 抽象与模型路由 +2. 把现有 Claude 逻辑封装成 Claude provider +3. 接入 Codex auth store 与 provider status +4. 先实现 Codex `/v1/responses` +5. 再实现 Codex `/v1/chat/completions` +6. 最后补 `/v1/models`、`/admin/accounts`、测试与文档 diff --git a/docs/plans/2026-03-30-codex-dual-provider.md b/docs/plans/2026-03-30-codex-dual-provider.md new file mode 100644 index 0000000..d5413c6 --- /dev/null +++ b/docs/plans/2026-03-30-codex-dual-provider.md @@ -0,0 +1,393 @@ +# Codex Dual-Provider Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extend `auth2api` into a single-instance, single-port proxy that serves both Claude OAuth and Codex OAuth models via `/v1/chat/completions`, `/v1/responses`, and `/v1/models`. + +**Architecture:** Introduce a provider abstraction and route requests by model name. Keep Claude on the existing proxy path, add a dedicated Codex provider that reads `~/.codex/auth.json`, and aggregate provider status/models at the server layer. + +**Tech Stack:** TypeScript, Node.js, Express, local JSON auth files, mocked upstream HTTP in `node:test` + `tsx` + +### Task 1: Add provider abstraction and model router + +**Files:** +- Create: `src/providers/types.ts` +- Create: `src/providers/router.ts` +- Test: `tests/provider-router.test.ts` + +**Step 1: Write the failing test** + +Create `tests/provider-router.test.ts` covering: +- `claude-sonnet-4-6` routes to `claude` +- `gpt-5.4` routes to `codex` +- `codex-mini-latest` routes to `codex` +- unknown model returns `null` or throws a controlled routing error + +**Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/provider-router.test.ts` +Expected: FAIL because router/types do not exist + +**Step 3: Write minimal implementation** + +Add: +- provider type definitions in `src/providers/types.ts` +- model-prefix based router in `src/providers/router.ts` + +**Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/provider-router.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/providers/types.ts src/providers/router.ts tests/provider-router.test.ts +git commit -m "feat: add provider model router" +``` + +### Task 2: Extract current Claude logic behind a Claude provider + +**Files:** +- Create: `src/providers/claude.ts` +- Modify: `src/server.ts` +- Modify: `src/proxy/handler.ts` +- Modify: `src/proxy/responses.ts` +- Modify: `src/proxy/passthrough.ts` +- Test: `tests/smoke.test.ts` + +**Step 1: Write the failing test** + +Add a smoke-level assertion that `/v1/chat/completions` still works after routing through a provider object rather than direct handler imports. + +**Step 2: Run test to verify it fails** + +Run: `npm run test:smoke` +Expected: FAIL after temporary server wiring change or missing provider contract + +**Step 3: Write minimal implementation** + +Create `ClaudeProvider` as a thin wrapper around existing handler constructors and expose: +- `supportsModel` +- `listModels` +- `getStatus` +- `handleChatCompletions` +- `handleResponses` +- `handleMessages` +- `handleCountTokens` + +Update `src/server.ts` to use this provider for Claude routes. + +**Step 4: Run test to verify it passes** + +Run: `npm run test:smoke` +Expected: PASS with all existing Claude smoke cases unchanged + +**Step 5: Commit** + +```bash +git add src/providers/claude.ts src/server.ts src/proxy/handler.ts src/proxy/responses.ts src/proxy/passthrough.ts tests/smoke.test.ts +git commit -m "refactor: wrap claude flow in provider abstraction" +``` + +### Task 3: Add Codex config and auth-file loader + +**Files:** +- Modify: `src/config.ts` +- Create: `src/providers/codex-auth.ts` +- Test: `tests/codex-auth.test.ts` + +**Step 1: Write the failing test** + +Create `tests/codex-auth.test.ts` covering: +- parses `~/.codex/auth.json`-shaped data +- rejects missing `tokens.access_token` +- reloads when file mtime changes + +**Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/codex-auth.test.ts` +Expected: FAIL because config/auth loader does not exist + +**Step 3: Write minimal implementation** + +Add `codex` config section: +- `enabled` +- `auth-file` +- `models` + +Implement `CodexAuthStore` that: +- resolves auth path +- loads/parses JSON +- caches by mtime +- exposes `getAccessToken()` and status metadata + +**Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/codex-auth.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/config.ts src/providers/codex-auth.ts tests/codex-auth.test.ts +git commit -m "feat: add codex auth file loader" +``` + +### Task 4: Implement Codex provider status and model listing + +**Files:** +- Create: `src/providers/codex.ts` +- Modify: `src/server.ts` +- Test: `tests/codex-provider-status.test.ts` + +**Step 1: Write the failing test** + +Create `tests/codex-provider-status.test.ts` covering: +- configured Codex models appear in provider model list +- missing auth file marks provider unavailable +- `/v1/models` returns Claude + Codex model union +- `/admin/accounts` includes both `claude` and `codex` sections + +**Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/codex-provider-status.test.ts` +Expected: FAIL because Codex provider and server aggregation do not exist + +**Step 3: Write minimal implementation** + +Implement: +- `CodexProvider.listModels()` +- `CodexProvider.getStatus()` +- server-side model aggregation +- provider-aware admin payload + +Keep request handling stubbed or not-yet-implemented if needed, but return controlled errors. + +**Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/codex-provider-status.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/providers/codex.ts src/server.ts tests/codex-provider-status.test.ts +git commit -m "feat: expose codex status and model listing" +``` + +### Task 5: Implement Codex upstream client for `/v1/responses` + +**Files:** +- Create: `src/providers/codex-upstream.ts` +- Create: `src/providers/codex-responses.ts` +- Modify: `src/providers/codex.ts` +- Test: `tests/codex-responses.test.ts` + +**Step 1: Write the failing test** + +Create `tests/codex-responses.test.ts` covering: +- `/v1/responses` with `gpt-5.4` routes to Codex upstream +- bearer token comes from mocked `auth.json` +- non-streaming response maps back to OpenAI Responses API format + +**Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/codex-responses.test.ts` +Expected: FAIL because Codex request/response bridge is missing + +**Step 3: Write minimal implementation** + +Implement: +- upstream request helper targeting Codex responses endpoint +- minimal request translation from external Responses API to Codex upstream body +- minimal non-streaming response mapping back to OpenAI Responses object + +**Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/codex-responses.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/providers/codex-upstream.ts src/providers/codex-responses.ts src/providers/codex.ts tests/codex-responses.test.ts +git commit -m "feat: add codex responses provider path" +``` + +### Task 6: Add Codex streaming support for `/v1/responses` + +**Files:** +- Modify: `src/providers/codex-upstream.ts` +- Modify: `src/providers/codex-responses.ts` +- Test: `tests/codex-responses-stream.test.ts` + +**Step 1: Write the failing test** + +Create `tests/codex-responses-stream.test.ts` covering: +- streamed upstream events are emitted as OpenAI Responses SSE +- stream closes cleanly with final done event + +**Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/codex-responses-stream.test.ts` +Expected: FAIL because streaming bridge is missing + +**Step 3: Write minimal implementation** + +Add Codex-specific SSE parsing and event mapping. Do not reuse Claude streaming translator. + +**Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/codex-responses-stream.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/providers/codex-upstream.ts src/providers/codex-responses.ts tests/codex-responses-stream.test.ts +git commit -m "feat: add codex responses streaming bridge" +``` + +### Task 7: Add `/v1/chat/completions` compatibility for Codex + +**Files:** +- Create: `src/providers/codex-chat.ts` +- Modify: `src/providers/codex.ts` +- Test: `tests/codex-chat.test.ts` + +**Step 1: Write the failing test** + +Create `tests/codex-chat.test.ts` covering: +- chat request with `gpt-5.4` routes to Codex provider +- messages are converted into Codex canonical request +- non-streaming response maps back to OpenAI chat completion + +**Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/codex-chat.test.ts` +Expected: FAIL because chat compatibility layer is missing + +**Step 3: Write minimal implementation** + +Implement chat-to-canonical translation and canonical-to-chat response mapping using the existing Codex responses path internally. + +**Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/codex-chat.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/providers/codex-chat.ts src/providers/codex.ts tests/codex-chat.test.ts +git commit -m "feat: add codex chat completions compatibility" +``` + +### Task 8: Add Codex streaming support for `/v1/chat/completions` + +**Files:** +- Modify: `src/providers/codex-chat.ts` +- Test: `tests/codex-chat-stream.test.ts` + +**Step 1: Write the failing test** + +Create `tests/codex-chat-stream.test.ts` covering: +- streamed Codex upstream events map to OpenAI chat completion SSE chunks +- final chunk ends with `[DONE]` + +**Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/codex-chat-stream.test.ts` +Expected: FAIL because streaming chunk translation is missing + +**Step 3: Write minimal implementation** + +Implement Codex-specific streaming adapter for chat completions, reusing canonical Codex response stream where practical. + +**Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/codex-chat-stream.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/providers/codex-chat.ts tests/codex-chat-stream.test.ts +git commit -m "feat: add codex chat streaming compatibility" +``` + +### Task 9: Wire provider routing into the HTTP server + +**Files:** +- Modify: `src/server.ts` +- Modify: `src/index.ts` +- Test: `tests/smoke.test.ts` + +**Step 1: Write the failing test** + +Extend `tests/smoke.test.ts` with cases showing: +- `claude-*` requests still route to Claude +- `gpt-*` requests route to Codex +- missing Codex auth file only affects Codex models + +**Step 2: Run test to verify it fails** + +Run: `npm run test:smoke` +Expected: FAIL because final server routing is incomplete + +**Step 3: Write minimal implementation** + +Finish server composition: +- instantiate Claude and Codex providers +- route `chat/completions` and `responses` by model +- keep `/v1/messages` and `/v1/messages/count_tokens` Claude-only + +**Step 4: Run test to verify it passes** + +Run: `npm run test:smoke` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/server.ts src/index.ts tests/smoke.test.ts +git commit -m "feat: route http requests across claude and codex providers" +``` + +### Task 10: Final verification and docs + +**Files:** +- Modify: `README.md` +- Modify: `README_CN.md` +- Test: `tests/smoke.test.ts` + +**Step 1: Update docs** + +Document: +- Codex auth source is `~/.codex/auth.json` +- dual-provider model routing +- Claude-only native endpoints +- new config keys + +**Step 2: Run full verification** + +Run: +- `npm run test:smoke` +- `npx tsc --noEmit` + +Expected: +- all tests PASS +- TypeScript compile PASS + +**Step 3: Review final diff** + +Run: `git diff --stat main...HEAD` +Expected: only provider/config/server/docs/test changes relevant to this feature + +**Step 4: Commit** + +```bash +git add README.md README_CN.md +git commit -m "docs: describe dual claude and codex providers" +``` From 0f2460bcdb63b37c53c162824e3516b32ef21b18 Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 13:39:04 +0800 Subject: [PATCH 03/14] feat: add provider model router --- src/providers/router.ts | 22 ++++++++++++++++++++++ src/providers/types.ts | 1 + tests/provider-router.test.ts | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 src/providers/router.ts create mode 100644 src/providers/types.ts create mode 100644 tests/provider-router.test.ts diff --git a/src/providers/router.ts b/src/providers/router.ts new file mode 100644 index 0000000..3be4a6c --- /dev/null +++ b/src/providers/router.ts @@ -0,0 +1,22 @@ +import { ProviderName } from "./types"; + +const CLAUDE_PREFIXES = ["claude-"]; +const CODEX_PREFIXES = ["gpt-", "codex-"]; + +export function resolveProviderFromModel(model: string): ProviderName | null { + const normalized = model.trim().toLowerCase(); + + if (CLAUDE_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "claude"; + } + + if (CODEX_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "codex"; + } + + if (/^o\d/.test(normalized)) { + return "codex"; + } + + return null; +} diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..c0e3385 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1 @@ +export type ProviderName = "claude" | "codex"; diff --git a/tests/provider-router.test.ts b/tests/provider-router.test.ts new file mode 100644 index 0000000..440007d --- /dev/null +++ b/tests/provider-router.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveProviderFromModel } from "../src/providers/router"; + +test("claude-sonnet-4-6 routes to claude", () => { + assert.equal(resolveProviderFromModel("claude-sonnet-4-6"), "claude"); +}); + +test("gpt-5.4 routes to codex", () => { + assert.equal(resolveProviderFromModel("gpt-5.4"), "codex"); +}); + +test("codex-mini-latest routes to codex", () => { + assert.equal(resolveProviderFromModel("codex-mini-latest"), "codex"); +}); + +test("unknown model returns null", () => { + assert.equal(resolveProviderFromModel("not-a-real-model"), null); +}); From 0d0d5e484a3a4c971aa5b14600f94369202ade80 Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 13:44:55 +0800 Subject: [PATCH 04/14] refactor: wrap claude flow in provider abstraction --- src/providers/claude.ts | 75 +++++++++++++++++++++++++++++++++++++++++ src/providers/types.ts | 24 +++++++++++++ src/server.ts | 29 +++++----------- tests/smoke.test.ts | 1 + 4 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 src/providers/claude.ts diff --git a/src/providers/claude.ts b/src/providers/claude.ts new file mode 100644 index 0000000..41057a0 --- /dev/null +++ b/src/providers/claude.ts @@ -0,0 +1,75 @@ +import express from "express"; +import { AccountManager } from "../accounts/manager"; +import { Config } from "../config"; +import { createChatCompletionsHandler } from "../proxy/handler"; +import { createMessagesHandler, createCountTokensHandler } from "../proxy/passthrough"; +import { createResponsesHandler } from "../proxy/responses"; +import { resolveProviderFromModel } from "./router"; +import { Provider, ProviderModel, ProviderStatus } from "./types"; + +const CLAUDE_MODELS = [ + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-haiku-4-5-20251001", + "claude-haiku-4-5", + "opus", + "sonnet", + "haiku", +] as const; + +export class ClaudeProvider implements Provider { + readonly name = "claude" as const; + + private readonly chatHandler: express.RequestHandler; + private readonly responsesHandler: express.RequestHandler; + private readonly messagesHandler: express.RequestHandler; + private readonly countTokensHandler: express.RequestHandler; + + constructor( + private readonly config: Config, + private readonly manager: AccountManager + ) { + this.chatHandler = createChatCompletionsHandler(this.config, this.manager); + this.responsesHandler = createResponsesHandler(this.config, this.manager); + this.messagesHandler = createMessagesHandler(this.config, this.manager); + this.countTokensHandler = createCountTokensHandler(this.config, this.manager); + } + + supportsModel(model: string): boolean { + return resolveProviderFromModel(model) === this.name; + } + + listModels(): ProviderModel[] { + return CLAUDE_MODELS.map((id) => ({ + id, + ownedBy: "anthropic", + })); + } + + getStatus(): ProviderStatus { + return { + name: this.name, + available: this.manager.accountCount > 0, + details: { + accounts: this.manager.getSnapshots(), + accountCount: this.manager.accountCount, + }, + }; + } + + handleChatCompletions(): express.RequestHandler { + return this.chatHandler; + } + + handleResponses(): express.RequestHandler { + return this.responsesHandler; + } + + handleMessages(): express.RequestHandler { + return this.messagesHandler; + } + + handleCountTokens(): express.RequestHandler { + return this.countTokensHandler; + } +} diff --git a/src/providers/types.ts b/src/providers/types.ts index c0e3385..dc21d45 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -1 +1,25 @@ +import express from "express"; + export type ProviderName = "claude" | "codex"; + +export interface ProviderModel { + id: string; + ownedBy: string; +} + +export interface ProviderStatus { + name: ProviderName; + available: boolean; + details?: unknown; +} + +export interface Provider { + name: ProviderName; + supportsModel(model: string): boolean; + listModels(): ProviderModel[]; + getStatus(): ProviderStatus; + handleChatCompletions(): express.RequestHandler; + handleResponses(): express.RequestHandler; + handleMessages?(): express.RequestHandler; + handleCountTokens?(): express.RequestHandler; +} diff --git a/src/server.ts b/src/server.ts index 92495fc..fd4726a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,19 +3,7 @@ import express from "express"; import { Config, isDebugLevel } from "./config"; import { AccountManager } from "./accounts/manager"; import { extractApiKey } from "./api-key"; -import { createChatCompletionsHandler } from "./proxy/handler"; -import { createMessagesHandler, createCountTokensHandler } from "./proxy/passthrough"; -import { createResponsesHandler } from "./proxy/responses"; - -const SUPPORTED_MODELS = [ - "claude-opus-4-6", - "claude-sonnet-4-6", - "claude-haiku-4-5-20251001", - "claude-haiku-4-5", - "opus", - "sonnet", - "haiku", -] as const; +import { ClaudeProvider } from "./providers/claude"; // Timing-safe API key comparison function safeCompare(a: string, b: string): boolean { @@ -57,6 +45,7 @@ cleanupTimer.unref(); export function createServer(config: Config, manager: AccountManager): express.Application { const app = express(); + const claudeProvider = new ClaudeProvider(config, manager); app.use(express.json({ limit: config["body-limit"] })); @@ -119,21 +108,21 @@ export function createServer(config: Config, manager: AccountManager): express.A app.use("/admin", requireApiKey); // Routes — OpenAI compatible - app.post("/v1/chat/completions", createChatCompletionsHandler(config, manager)); - app.post("/v1/responses", createResponsesHandler(config, manager)); + app.post("/v1/chat/completions", claudeProvider.handleChatCompletions()); + app.post("/v1/responses", claudeProvider.handleResponses()); // Routes — Claude native passthrough - app.post("/v1/messages/count_tokens", createCountTokensHandler(config, manager)); - app.post("/v1/messages", createMessagesHandler(config, manager)); + app.post("/v1/messages/count_tokens", claudeProvider.handleCountTokens()); + app.post("/v1/messages", claudeProvider.handleMessages()); app.get("/v1/models", (_req, res) => { res.json({ object: "list", - data: SUPPORTED_MODELS.map((id) => ({ - id, + data: claudeProvider.listModels().map((model) => ({ + id: model.id, object: "model", created: Math.floor(Date.now() / 1000), - owned_by: "anthropic", + owned_by: model.ownedBy, })), }); }); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 268646d..a88d8d2 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -153,6 +153,7 @@ test("accepts x-api-key auth and serves models/admin state", async (t) => { assert.equal(modelsResp.status, 200); assert.ok(Array.isArray(modelsResp.body.data)); assert.ok(modelsResp.body.data.length > 0); + assert.equal(modelsResp.body.data.some((model: { id: string }) => model.id === "claude-sonnet-4-6"), true); const adminResp = await requestJson({ server, From 768dcc539b39039ec156583841b6965a381dd5f3 Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 14:00:06 +0800 Subject: [PATCH 05/14] feat: add codex auth file loader --- src/config.ts | 13 +++++ src/providers/codex-auth.ts | 75 +++++++++++++++++++++++++ tests/codex-auth.test.ts | 108 ++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 src/providers/codex-auth.ts create mode 100644 tests/codex-auth.test.ts diff --git a/src/config.ts b/src/config.ts index b3b11f2..c7eb7ca 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,12 @@ export interface TimeoutConfig { "count-tokens-ms": number; } +export interface CodexConfig { + enabled: boolean; + "auth-file": string; + models: string[]; +} + export type DebugMode = "off" | "errors" | "verbose"; export interface Config { @@ -26,6 +32,7 @@ export interface Config { "body-limit": string; cloaking: CloakingConfig; timeouts: TimeoutConfig; + codex: CodexConfig; debug: DebugMode; } @@ -46,6 +53,11 @@ const DEFAULT_CONFIG: Config = { "stream-messages-ms": 600000, "count-tokens-ms": 30000, }, + codex: { + enabled: true, + "auth-file": "~/.codex/auth.json", + models: [], + }, debug: "off", }; @@ -87,6 +99,7 @@ export function loadConfig(configPath?: string): Config { ...parsed, cloaking: { ...DEFAULT_CONFIG.cloaking, ...(parsed.cloaking || {}) }, timeouts: { ...DEFAULT_CONFIG.timeouts, ...(parsed.timeouts || {}) }, + codex: { ...DEFAULT_CONFIG.codex, ...(parsed.codex || {}) }, }; } diff --git a/src/providers/codex-auth.ts b/src/providers/codex-auth.ts new file mode 100644 index 0000000..63e62fb --- /dev/null +++ b/src/providers/codex-auth.ts @@ -0,0 +1,75 @@ +import fs from "fs"; +import path from "path"; + +export interface CodexAuthSnapshot { + available: boolean; + authMode: string; + accessToken: string; + refreshToken: string; + accountId: string; + lastRefresh: string | null; + path: string; + mtimeMs: number; +} + +export class CodexAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "CodexAuthError"; + } +} + +function resolveAuthFile(filePath: string): string { + if (filePath.startsWith("~")) { + return path.join(process.env.HOME || "/root", filePath.slice(1)); + } + return path.resolve(filePath); +} + +export class CodexAuthStore { + private readonly authFilePath: string; + private cachedMtimeMs: number | null = null; + private cachedSnapshot: CodexAuthSnapshot | null = null; + + constructor(authFilePath: string) { + this.authFilePath = resolveAuthFile(authFilePath); + } + + load(): CodexAuthSnapshot { + if (!fs.existsSync(this.authFilePath)) { + throw new CodexAuthError(`Codex auth file not found: ${this.authFilePath}`); + } + + const stat = fs.statSync(this.authFilePath); + if (this.cachedSnapshot && this.cachedMtimeMs === stat.mtimeMs) { + return this.cachedSnapshot; + } + + let parsed: any; + try { + parsed = JSON.parse(fs.readFileSync(this.authFilePath, "utf-8")); + } catch (err: any) { + throw new CodexAuthError(`Failed to parse Codex auth file ${this.authFilePath}: ${err.message}`); + } + + const accessToken = parsed?.tokens?.access_token; + if (!accessToken) { + throw new CodexAuthError("Codex auth file missing tokens.access_token"); + } + + const snapshot: CodexAuthSnapshot = { + available: true, + authMode: parsed?.auth_mode || "unknown", + accessToken, + refreshToken: parsed?.tokens?.refresh_token || "", + accountId: parsed?.tokens?.account_id || "", + lastRefresh: typeof parsed?.last_refresh === "string" ? parsed.last_refresh : null, + path: this.authFilePath, + mtimeMs: stat.mtimeMs, + }; + + this.cachedMtimeMs = stat.mtimeMs; + this.cachedSnapshot = snapshot; + return snapshot; + } +} diff --git a/tests/codex-auth.test.ts b/tests/codex-auth.test.ts new file mode 100644 index 0000000..b8f760f --- /dev/null +++ b/tests/codex-auth.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { loadConfig, resolveAuthDir } from "../src/config"; +import { CodexAuthStore } from "../src/providers/codex-auth"; + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); +} + +test("loadConfig provides default codex config", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-config-")); + const configPath = path.join(tmpDir, "config.yaml"); + + const config = loadConfig(configPath); + + assert.equal(config.codex.enabled, true); + assert.equal(config.codex["auth-file"], "~/.codex/auth.json"); + assert.deepEqual(config.codex.models, []); +}); + +test("CodexAuthStore reads token data from auth.json", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-auth-")); + const authDir = path.join(tmpDir, ".codex"); + const authFile = path.join(authDir, "auth.json"); + writeJson(authFile, { + auth_mode: "oauth", + tokens: { + access_token: "codex-access-token", + refresh_token: "codex-refresh-token", + account_id: "acct_123", + }, + last_refresh: "2026-03-30T00:00:00.000Z", + }); + + const store = new CodexAuthStore(authFile); + const snapshot = store.load(); + + assert.equal(snapshot.available, true); + assert.equal(snapshot.accessToken, "codex-access-token"); + assert.equal(snapshot.refreshToken, "codex-refresh-token"); + assert.equal(snapshot.accountId, "acct_123"); + assert.equal(snapshot.authMode, "oauth"); +}); + +test("CodexAuthStore rejects auth.json without access token", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-auth-missing-")); + const authFile = path.join(tmpDir, "auth.json"); + writeJson(authFile, { + auth_mode: "oauth", + tokens: { + refresh_token: "codex-refresh-token", + account_id: "acct_123", + }, + }); + + const store = new CodexAuthStore(authFile); + + assert.throws(() => store.load(), /access_token/i); +}); + +test("CodexAuthStore rejects missing auth.json with a controlled error", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-auth-absent-")); + const authFile = path.join(tmpDir, "auth.json"); + const store = new CodexAuthStore(authFile); + + assert.throws(() => store.load(), /not found/i); +}); + +test("CodexAuthStore reloads when auth.json mtime changes", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-auth-reload-")); + const authFile = path.join(tmpDir, "auth.json"); + writeJson(authFile, { + auth_mode: "oauth", + tokens: { + access_token: "first-token", + refresh_token: "first-refresh", + account_id: "acct_123", + }, + }); + + const store = new CodexAuthStore(authFile); + const first = store.load(); + assert.equal(first.accessToken, "first-token"); + + await new Promise((resolve) => setTimeout(resolve, 20)); + writeJson(authFile, { + auth_mode: "oauth", + tokens: { + access_token: "second-token", + refresh_token: "second-refresh", + account_id: "acct_123", + }, + }); + + const second = store.load(); + assert.equal(second.accessToken, "second-token"); + assert.equal(second.refreshToken, "second-refresh"); +}); + +test("resolveAuthDir still resolves home-based paths", () => { + const resolved = resolveAuthDir("~/.codex"); + assert.ok(resolved.includes(".codex")); +}); From 18818ccaeba1c785bb7337e47ade8ca1aae4dab0 Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 14:04:37 +0800 Subject: [PATCH 06/14] feat: expose codex status and model listing --- src/providers/codex.ts | 89 ++++++++++++++ src/server.ts | 9 +- tests/codex-provider-status.test.ts | 179 ++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/providers/codex.ts create mode 100644 tests/codex-provider-status.test.ts diff --git a/src/providers/codex.ts b/src/providers/codex.ts new file mode 100644 index 0000000..16ca788 --- /dev/null +++ b/src/providers/codex.ts @@ -0,0 +1,89 @@ +import express from "express"; +import { Config } from "../config"; +import { CodexAuthError, CodexAuthStore } from "./codex-auth"; +import { resolveProviderFromModel } from "./router"; +import { Provider, ProviderModel, ProviderStatus } from "./types"; + +const DEFAULT_CODEX_CONFIG = { + enabled: true, + "auth-file": "~/.codex/auth.json", + models: [] as string[], +}; + +function notImplementedHandler(message: string): express.RequestHandler { + return (_req, res) => { + res.status(501).json({ error: { message } }); + }; +} + +export class CodexProvider implements Provider { + readonly name = "codex" as const; + + private readonly authStore: CodexAuthStore; + private readonly codexConfig; + + constructor(private readonly config: Config) { + this.codexConfig = this.config.codex || DEFAULT_CODEX_CONFIG; + this.authStore = new CodexAuthStore(this.codexConfig["auth-file"]); + } + + supportsModel(model: string): boolean { + return resolveProviderFromModel(model) === this.name; + } + + listModels(): ProviderModel[] { + if (!this.codexConfig.enabled) { + return []; + } + + return this.codexConfig.models.map((id) => ({ + id, + ownedBy: "openai", + })); + } + + getStatus(): ProviderStatus { + if (!this.codexConfig.enabled) { + return { + name: this.name, + available: false, + details: { enabled: false }, + }; + } + + try { + const snapshot = this.authStore.load(); + return { + name: this.name, + available: true, + details: { + enabled: true, + authMode: snapshot.authMode, + accountId: snapshot.accountId, + lastRefresh: snapshot.lastRefresh, + path: snapshot.path, + }, + }; + } catch (error) { + if (error instanceof CodexAuthError) { + return { + name: this.name, + available: false, + details: { + enabled: true, + error: error.message, + }, + }; + } + throw error; + } + } + + handleChatCompletions(): express.RequestHandler { + return notImplementedHandler("Codex chat completions not implemented"); + } + + handleResponses(): express.RequestHandler { + return notImplementedHandler("Codex responses not implemented"); + } +} diff --git a/src/server.ts b/src/server.ts index fd4726a..a1e1c80 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import { Config, isDebugLevel } from "./config"; import { AccountManager } from "./accounts/manager"; import { extractApiKey } from "./api-key"; import { ClaudeProvider } from "./providers/claude"; +import { CodexProvider } from "./providers/codex"; // Timing-safe API key comparison function safeCompare(a: string, b: string): boolean { @@ -46,6 +47,7 @@ cleanupTimer.unref(); export function createServer(config: Config, manager: AccountManager): express.Application { const app = express(); const claudeProvider = new ClaudeProvider(config, manager); + const codexProvider = new CodexProvider(config); app.use(express.json({ limit: config["body-limit"] })); @@ -116,9 +118,10 @@ export function createServer(config: Config, manager: AccountManager): express.A app.post("/v1/messages", claudeProvider.handleMessages()); app.get("/v1/models", (_req, res) => { + const models = [...claudeProvider.listModels(), ...codexProvider.listModels()]; res.json({ object: "list", - data: claudeProvider.listModels().map((model) => ({ + data: models.map((model) => ({ id: model.id, object: "model", created: Math.floor(Date.now() / 1000), @@ -133,9 +136,13 @@ export function createServer(config: Config, manager: AccountManager): express.A }); app.get("/admin/accounts", (_req, res) => { + const claudeStatus = claudeProvider.getStatus(); + const codexStatus = codexProvider.getStatus(); res.json({ accounts: manager.getSnapshots(), account_count: manager.accountCount, + claude: claudeStatus, + codex: codexStatus, generated_at: new Date().toISOString(), }); }); diff --git a/tests/codex-provider-status.test.ts b/tests/codex-provider-status.test.ts new file mode 100644 index 0000000..09d19b5 --- /dev/null +++ b/tests/codex-provider-status.test.ts @@ -0,0 +1,179 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; +import { AddressInfo } from "node:net"; +import { createServer as createHttpServer } from "node:http"; + +import { AccountManager } from "../src/accounts/manager"; +import { Config } from "../src/config"; +import { createServer } from "../src/server"; +import { saveToken } from "../src/auth/token-storage"; +import { TokenData } from "../src/auth/types"; +import { CodexProvider } from "../src/providers/codex"; + +function makeConfig(authDir: string, codexAuthFile: string): Config { + return { + host: "127.0.0.1", + port: 0, + "auth-dir": authDir, + "api-keys": ["test-key"], + "body-limit": "200mb", + cloaking: { + mode: "never", + "strict-mode": false, + "sensitive-words": [], + "cache-user-id": false, + }, + timeouts: { + "messages-ms": 120000, + "stream-messages-ms": 600000, + "count-tokens-ms": 30000, + }, + codex: { + enabled: true, + "auth-file": codexAuthFile, + models: ["gpt-5.4", "codex-mini-latest"], + }, + debug: "off", + }; +} + +function makeToken(overrides: Partial = {}): TokenData { + return { + accessToken: "access-token", + refreshToken: "refresh-token", + email: "test@example.com", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + ...overrides, + }; +} + +function makeManager(authDir: string, tokens: TokenData[]): AccountManager { + for (const token of tokens) { + saveToken(authDir, token); + } + const manager = new AccountManager(authDir); + manager.load(); + return manager; +} + +async function startApp(config: Config, manager: AccountManager): Promise { + const app = createServer(config, manager); + const server = createHttpServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return server; +} + +async function stopApp(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function serverAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Server is not listening on a TCP port"); + } + return address; +} + +async function requestJson(options: { + server: http.Server; + method: string; + path: string; + headers?: Record; +}): Promise<{ status: number; body: any }> { + const address = serverAddress(options.server); + + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port: address.port, + method: options.method, + path: options.path, + headers: options.headers || {}, + }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + resolve({ + status: res.statusCode || 0, + body: data ? JSON.parse(data) : null, + }); + }); + } + ); + + req.on("error", reject); + req.end(); + }); +} + +test("CodexProvider lists configured models", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-provider-")); + const config = makeConfig(tmpDir, path.join(tmpDir, ".codex", "auth.json")); + const provider = new CodexProvider(config); + + assert.deepEqual( + provider.listModels().map((model) => model.id), + ["gpt-5.4", "codex-mini-latest"] + ); +}); + +test("CodexProvider reports unavailable when auth.json is missing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-provider-")); + const config = makeConfig(tmpDir, path.join(tmpDir, ".codex", "auth.json")); + const provider = new CodexProvider(config); + + const status = provider.getStatus(); + + assert.equal(status.name, "codex"); + assert.equal(status.available, false); +}); + +test("server exposes Claude and Codex models and provider status", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-server-")); + const config = makeConfig(authDir, path.join(authDir, ".codex", "auth.json")); + const manager = makeManager(authDir, [makeToken()]); + const server = await startApp(config, manager); + + t.after(async () => { + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const modelsResp = await requestJson({ + server, + method: "GET", + path: "/v1/models", + headers: { Authorization: "Bearer test-key" }, + }); + + assert.equal(modelsResp.status, 200); + assert.ok(modelsResp.body.data.some((model: any) => model.id === "claude-sonnet-4-6")); + assert.ok(modelsResp.body.data.some((model: any) => model.id === "gpt-5.4")); + + const adminResp = await requestJson({ + server, + method: "GET", + path: "/admin/accounts", + headers: { Authorization: "Bearer test-key" }, + }); + + assert.equal(adminResp.status, 200); + assert.equal(adminResp.body.claude.name, "claude"); + assert.equal(adminResp.body.codex.name, "codex"); + assert.equal(adminResp.body.codex.available, false); +}); From a7f6ed4541d3a9cf3584be7121c428f5bceae94d Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 14:13:17 +0800 Subject: [PATCH 07/14] feat: add codex responses provider path --- src/providers/codex-responses.ts | 92 +++++++++++ src/providers/codex-upstream.ts | 18 +++ src/providers/codex.ts | 5 +- tests/codex-responses.test.ts | 266 +++++++++++++++++++++++++++++++ 4 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 src/providers/codex-responses.ts create mode 100644 src/providers/codex-upstream.ts create mode 100644 tests/codex-responses.test.ts diff --git a/src/providers/codex-responses.ts b/src/providers/codex-responses.ts new file mode 100644 index 0000000..12d76fe --- /dev/null +++ b/src/providers/codex-responses.ts @@ -0,0 +1,92 @@ +import express from "express"; +import { v4 as uuidv4 } from "uuid"; +import { CodexAuthError, CodexAuthStore } from "./codex-auth"; +import { callCodexResponses } from "./codex-upstream"; + +function normalizeOutput(upstream: any): any[] { + if (Array.isArray(upstream?.output)) { + return upstream.output; + } + if (Array.isArray(upstream?.content)) { + return [{ + type: "message", + id: `msg_${uuidv4().replace(/-/g, "")}`, + role: "assistant", + status: "completed", + content: upstream.content, + }]; + } + return []; +} + +function normalizeUsage(upstream: any): { + input_tokens: number; + output_tokens: number; + total_tokens: number; +} { + const inputTokens = upstream?.usage?.input_tokens || 0; + const outputTokens = upstream?.usage?.output_tokens || 0; + return { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: upstream?.usage?.total_tokens || inputTokens + outputTokens, + }; +} + +function normalizeResponse(upstream: any, model: string): any { + return { + id: upstream?.id || `resp_${uuidv4().replace(/-/g, "")}`, + object: upstream?.object || "response", + created_at: upstream?.created_at || Math.floor(Date.now() / 1000), + status: upstream?.status || "completed", + model: upstream?.model || model, + output: normalizeOutput(upstream), + usage: normalizeUsage(upstream), + }; +} + +function authErrorResponse(message: string): { status: number; body: { error: { message: string } } } { + return { + status: 503, + body: { error: { message } }, + }; +} + +export function createCodexResponsesHandler(authStore: CodexAuthStore): express.RequestHandler { + return async (req, res): Promise => { + try { + const body = req.body || {}; + if (body.stream) { + res.status(501).json({ error: { message: "Codex streaming not implemented yet" } }); + return; + } + + let snapshot; + try { + snapshot = authStore.load(); + } catch (error) { + if (error instanceof CodexAuthError) { + const authError = authErrorResponse(error.message); + res.status(authError.status).json(authError.body); + return; + } + throw error; + } + + const upstreamResp = await callCodexResponses(snapshot.accessToken, body); + if (!upstreamResp.ok) { + const text = await upstreamResp.text().catch(() => ""); + res.status(upstreamResp.status).json({ + error: { message: text || "Codex upstream request failed" }, + }); + return; + } + + const upstreamJson = await upstreamResp.json(); + res.json(normalizeResponse(upstreamJson, body.model || upstreamJson?.model || "gpt-5.4")); + } catch (error: any) { + res.status(500).json({ error: { message: error?.message || "Internal server error" } }); + } + }; +} + diff --git a/src/providers/codex-upstream.ts b/src/providers/codex-upstream.ts new file mode 100644 index 0000000..ca66fa1 --- /dev/null +++ b/src/providers/codex-upstream.ts @@ -0,0 +1,18 @@ +const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; + +function buildHeaders(accessToken: string): Record { + return { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }; +} + +export async function callCodexResponses(accessToken: string, body: unknown): Promise { + return fetch(CODEX_RESPONSES_URL, { + method: "POST", + headers: buildHeaders(accessToken), + body: JSON.stringify(body), + }); +} + diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 16ca788..8c8586a 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -1,6 +1,7 @@ import express from "express"; import { Config } from "../config"; import { CodexAuthError, CodexAuthStore } from "./codex-auth"; +import { createCodexResponsesHandler } from "./codex-responses"; import { resolveProviderFromModel } from "./router"; import { Provider, ProviderModel, ProviderStatus } from "./types"; @@ -21,10 +22,12 @@ export class CodexProvider implements Provider { private readonly authStore: CodexAuthStore; private readonly codexConfig; + private readonly responsesHandler: express.RequestHandler; constructor(private readonly config: Config) { this.codexConfig = this.config.codex || DEFAULT_CODEX_CONFIG; this.authStore = new CodexAuthStore(this.codexConfig["auth-file"]); + this.responsesHandler = createCodexResponsesHandler(this.authStore); } supportsModel(model: string): boolean { @@ -84,6 +87,6 @@ export class CodexProvider implements Provider { } handleResponses(): express.RequestHandler { - return notImplementedHandler("Codex responses not implemented"); + return this.responsesHandler; } } diff --git a/tests/codex-responses.test.ts b/tests/codex-responses.test.ts new file mode 100644 index 0000000..ead728e --- /dev/null +++ b/tests/codex-responses.test.ts @@ -0,0 +1,266 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; +import { AddressInfo } from "node:net"; +import { createServer as createHttpServer } from "node:http"; + +import express from "express"; +import { CodexProvider } from "../src/providers/codex"; +import { Config } from "../src/config"; + +function makeConfig(authDir: string, codexAuthFile: string): Config { + return { + host: "127.0.0.1", + port: 0, + "auth-dir": authDir, + "api-keys": ["test-key"], + "body-limit": "200mb", + cloaking: { + mode: "never", + "strict-mode": false, + "sensitive-words": [], + "cache-user-id": false, + }, + timeouts: { + "messages-ms": 120000, + "stream-messages-ms": 600000, + "count-tokens-ms": 30000, + }, + codex: { + enabled: true, + "auth-file": codexAuthFile, + models: ["gpt-5.4"], + }, + debug: "off", + }; +} + +function writeAuth(filePath: string, accessToken: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + auth_mode: "oauth", + tokens: { + access_token: accessToken, + refresh_token: "refresh-token", + account_id: "acct_123", + }, + last_refresh: "2026-03-30T00:00:00.000Z", + }, null, 2) + ); +} + +async function startApp(handler: express.RequestHandler): Promise { + const app = express(); + app.use(express.json({ limit: "1mb" })); + app.post("/v1/responses", handler); + const server = createHttpServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return server; +} + +async function stopApp(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function serverAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Server is not listening on a TCP port"); + } + return address; +} + +async function requestJson(options: { + server: http.Server; + method: string; + path: string; + headers?: Record; + body?: unknown; +}): Promise<{ status: number; body: any }> { + const address = serverAddress(options.server); + const payload = options.body ? JSON.stringify(options.body) : undefined; + + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port: address.port, + method: options.method, + path: options.path, + headers: { + ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload).toString() } : {}), + ...(options.headers || {}), + }, + }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + resolve({ + status: res.statusCode || 0, + body: data ? JSON.parse(data) : null, + }); + }); + } + ); + + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +test("Codex responses handler sends bearer token and maps response", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-responses-")); + const authFile = path.join(authDir, ".codex", "auth.json"); + writeAuth(authFile, "codex-access-token"); + const provider = new CodexProvider(makeConfig(authDir, authFile)); + const calls: Array<{ url: string; auth?: string; body: any }> = []; + + const restoreFetch = global.fetch; + global.fetch = (async (input, init) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + calls.push({ + url: String(input), + auth: init?.headers && (init.headers as Record).Authorization, + body, + }); + + return new Response( + JSON.stringify({ + id: "resp_123", + object: "response", + created_at: 1711756800, + status: "completed", + model: "gpt-5.4", + output: [{ + type: "message", + id: "msg_123", + role: "assistant", + status: "completed", + content: [ + { type: "output_text", text: "hello from codex", annotations: [] }, + ], + }], + usage: { + input_tokens: 12, + output_tokens: 8, + total_tokens: 20, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + const server = await startApp(provider.handleResponses()); + + t.after(async () => { + global.fetch = restoreFetch; + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/responses", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + input: [{ role: "user", content: "hello" }], + }, + }); + + assert.equal(calls[0]?.url, "https://chatgpt.com/backend-api/codex/responses"); + assert.equal(calls[0]?.auth, "Bearer codex-access-token"); + assert.equal(calls[0]?.body.model, "gpt-5.4"); + assert.equal(resp.status, 200); + assert.equal(resp.body.object, "response"); + assert.equal(resp.body.model, "gpt-5.4"); + assert.equal(resp.body.output[0].content[0].text, "hello from codex"); + assert.equal(resp.body.usage.total_tokens, 20); +}); + +test("Codex responses handler returns controlled error when auth file is missing", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-responses-missing-")); + const authFile = path.join(authDir, ".codex", "auth.json"); + const provider = new CodexProvider(makeConfig(authDir, authFile)); + + const restoreFetch = global.fetch; + global.fetch = (async () => { + throw new Error("Upstream should not be called when auth is missing"); + }) as typeof fetch; + + const server = await startApp(provider.handleResponses()); + + t.after(async () => { + global.fetch = restoreFetch; + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/responses", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + input: [{ role: "user", content: "hello" }], + }, + }); + + assert.equal(resp.status, 503); + assert.match(String(resp.body.error.message), /auth/i); +}); + +test("Codex responses handler returns controlled error when auth file is malformed", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-responses-malformed-")); + const authFile = path.join(authDir, ".codex", "auth.json"); + fs.mkdirSync(path.dirname(authFile), { recursive: true }); + fs.writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "oauth", + tokens: { + refresh_token: "refresh-token", + account_id: "acct_123", + }, + }, null, 2) + ); + + const provider = new CodexProvider(makeConfig(authDir, authFile)); + const server = await startApp(provider.handleResponses()); + + t.after(async () => { + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/responses", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + input: [{ role: "user", content: "hello" }], + }, + }); + + assert.equal(resp.status, 503); + assert.match(String(resp.body.error.message), /access_token/i); +}); From 00b27e387f5366479b8648a1e7b1507eb2be556f Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 14:24:12 +0800 Subject: [PATCH 08/14] feat: add codex responses streaming bridge --- src/providers/codex-responses.ts | 48 ++++++- src/providers/codex-upstream.ts | 9 +- tests/codex-responses-stream.test.ts | 198 +++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 tests/codex-responses-stream.test.ts diff --git a/src/providers/codex-responses.ts b/src/providers/codex-responses.ts index 12d76fe..043a9c2 100644 --- a/src/providers/codex-responses.ts +++ b/src/providers/codex-responses.ts @@ -52,14 +52,46 @@ function authErrorResponse(message: string): { status: number; body: { error: { }; } +async function streamCodexResponses(upstreamResp: Response, res: express.Response): Promise { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + + const reader = upstreamResp.body?.getReader(); + if (!reader) { + res.end(); + return; + } + + const decoder = new TextDecoder(); + let clientDisconnected = false; + res.on("close", () => { + clientDisconnected = true; + reader.cancel().catch(() => {}); + }); + + try { + while (!clientDisconnected) { + const { done, value } = await reader.read(); + if (done) break; + if (!clientDisconnected && value) { + res.write(decoder.decode(value, { stream: true })); + } + } + } finally { + if (!clientDisconnected) { + res.end(); + } + } +} + export function createCodexResponsesHandler(authStore: CodexAuthStore): express.RequestHandler { return async (req, res): Promise => { try { const body = req.body || {}; - if (body.stream) { - res.status(501).json({ error: { message: "Codex streaming not implemented yet" } }); - return; - } + const stream = !!body.stream; let snapshot; try { @@ -73,7 +105,7 @@ export function createCodexResponsesHandler(authStore: CodexAuthStore): express. throw error; } - const upstreamResp = await callCodexResponses(snapshot.accessToken, body); + const upstreamResp = await callCodexResponses(snapshot.accessToken, body, stream); if (!upstreamResp.ok) { const text = await upstreamResp.text().catch(() => ""); res.status(upstreamResp.status).json({ @@ -82,6 +114,11 @@ export function createCodexResponsesHandler(authStore: CodexAuthStore): express. return; } + if (stream) { + await streamCodexResponses(upstreamResp, res); + return; + } + const upstreamJson = await upstreamResp.json(); res.json(normalizeResponse(upstreamJson, body.model || upstreamJson?.model || "gpt-5.4")); } catch (error: any) { @@ -89,4 +126,3 @@ export function createCodexResponsesHandler(authStore: CodexAuthStore): express. } }; } - diff --git a/src/providers/codex-upstream.ts b/src/providers/codex-upstream.ts index ca66fa1..c29de97 100644 --- a/src/providers/codex-upstream.ts +++ b/src/providers/codex-upstream.ts @@ -1,18 +1,17 @@ const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; -function buildHeaders(accessToken: string): Record { +function buildHeaders(accessToken: string, stream: boolean): Record { return { "Content-Type": "application/json", - Accept: "application/json", + Accept: stream ? "text/event-stream" : "application/json", Authorization: `Bearer ${accessToken}`, }; } -export async function callCodexResponses(accessToken: string, body: unknown): Promise { +export async function callCodexResponses(accessToken: string, body: unknown, stream = false): Promise { return fetch(CODEX_RESPONSES_URL, { method: "POST", - headers: buildHeaders(accessToken), + headers: buildHeaders(accessToken, stream), body: JSON.stringify(body), }); } - diff --git a/tests/codex-responses-stream.test.ts b/tests/codex-responses-stream.test.ts new file mode 100644 index 0000000..7d89943 --- /dev/null +++ b/tests/codex-responses-stream.test.ts @@ -0,0 +1,198 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; +import { AddressInfo } from "node:net"; +import { createServer as createHttpServer } from "node:http"; + +import express from "express"; +import { CodexProvider } from "../src/providers/codex"; +import { Config } from "../src/config"; + +function makeConfig(authDir: string, codexAuthFile: string): Config { + return { + host: "127.0.0.1", + port: 0, + "auth-dir": authDir, + "api-keys": ["test-key"], + "body-limit": "200mb", + cloaking: { + mode: "never", + "strict-mode": false, + "sensitive-words": [], + "cache-user-id": false, + }, + timeouts: { + "messages-ms": 120000, + "stream-messages-ms": 600000, + "count-tokens-ms": 30000, + }, + codex: { + enabled: true, + "auth-file": codexAuthFile, + models: ["gpt-5.4"], + }, + debug: "off", + }; +} + +function writeAuth(filePath: string, accessToken: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + auth_mode: "oauth", + tokens: { + access_token: accessToken, + refresh_token: "refresh-token", + account_id: "acct_123", + }, + last_refresh: "2026-03-30T00:00:00.000Z", + }, null, 2) + ); +} + +async function startApp(handler: express.RequestHandler): Promise { + const app = express(); + app.use(express.json({ limit: "1mb" })); + app.post("/v1/responses", handler); + const server = createHttpServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return server; +} + +async function stopApp(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function serverAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Server is not listening on a TCP port"); + } + return address; +} + +async function requestRaw(options: { + server: http.Server; + method: string; + path: string; + headers?: Record; + body?: unknown; +}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + const address = serverAddress(options.server); + const payload = options.body ? JSON.stringify(options.body) : undefined; + + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port: address.port, + method: options.method, + path: options.path, + headers: { + ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload).toString() } : {}), + ...(options.headers || {}), + }, + }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + resolve({ + status: res.statusCode || 0, + headers: res.headers, + body: data, + }); + }); + } + ); + + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +function makeStreamResponse(chunks: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +test("Codex responses handler streams upstream SSE events through to the client", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-responses-stream-")); + const authFile = path.join(authDir, ".codex", "auth.json"); + writeAuth(authFile, "codex-access-token"); + const provider = new CodexProvider(makeConfig(authDir, authFile)); + const calls: Array<{ url: string; auth?: string; accept?: string; body: any }> = []; + + const restoreFetch = global.fetch; + global.fetch = (async (input, init) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + const headers = init?.headers as Record | undefined; + calls.push({ + url: String(input), + auth: headers?.Authorization, + accept: headers?.Accept, + body, + }); + + return makeStreamResponse([ + "event: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":1}\n\n", + "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":2,\"delta\":\"hello\"}\n\n", + "event: response.done\ndata: {\"type\":\"response.done\",\"sequence_number\":3}\n\n", + ]); + }) as typeof fetch; + + const server = await startApp(provider.handleResponses()); + + t.after(async () => { + global.fetch = restoreFetch; + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestRaw({ + server, + method: "POST", + path: "/v1/responses", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + stream: true, + input: [{ role: "user", content: "hello" }], + }, + }); + + assert.equal(calls[0]?.url, "https://chatgpt.com/backend-api/codex/responses"); + assert.equal(calls[0]?.auth, "Bearer codex-access-token"); + assert.equal(calls[0]?.accept, "text/event-stream"); + assert.equal(calls[0]?.body.stream, true); + assert.equal(resp.status, 200); + assert.match(String(resp.headers["content-type"]), /text\/event-stream/); + assert.match(resp.body, /event: response.created/); + assert.match(resp.body, /"delta":"hello"/); + assert.match(resp.body, /event: response.done/); +}); From 9ede7c0938852a7b8b19095c43b043fd80d7daf9 Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 14:27:57 +0800 Subject: [PATCH 09/14] feat: add codex chat completions compatibility --- src/providers/codex-chat.ts | 152 ++++++++++++++++++++ src/providers/codex.ts | 5 +- tests/codex-chat.test.ts | 274 ++++++++++++++++++++++++++++++++++++ 3 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 src/providers/codex-chat.ts create mode 100644 tests/codex-chat.test.ts diff --git a/src/providers/codex-chat.ts b/src/providers/codex-chat.ts new file mode 100644 index 0000000..7d4661a --- /dev/null +++ b/src/providers/codex-chat.ts @@ -0,0 +1,152 @@ +import express from "express"; +import { v4 as uuidv4 } from "uuid"; +import { CodexAuthError, CodexAuthStore } from "./codex-auth"; +import { callCodexResponses } from "./codex-upstream"; + +function authErrorResponse(message: string): { status: number; body: { error: { message: string } } } { + return { + status: 503, + body: { error: { message } }, + }; +} + +function normalizeMessageContent(content: unknown): string | null { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + const text = content + .map((part: any) => { + if (part?.type === "text" || part?.type === "input_text" || part?.type === "output_text") { + return typeof part.text === "string" ? part.text : ""; + } + return ""; + }) + .join(""); + return text || null; + } + + return null; +} + +function normalizeOutputText(upstream: any): string { + if (Array.isArray(upstream?.output)) { + for (const item of upstream.output) { + if (item?.role !== "assistant" && item?.type !== "message") { + continue; + } + const text = normalizeMessageContent(item?.content); + if (text) { + return text; + } + } + } + + if (typeof upstream?.content === "string") { + return upstream.content; + } + + if (Array.isArray(upstream?.content)) { + const text = normalizeMessageContent(upstream.content); + if (text) { + return text; + } + } + + return ""; +} + +function normalizeUsage(upstream: any): { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} { + const promptTokens = upstream?.usage?.input_tokens || 0; + const completionTokens = upstream?.usage?.output_tokens || 0; + return { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: upstream?.usage?.total_tokens || promptTokens + completionTokens, + }; +} + +function normalizeResponse(upstream: any, model: string): any { + const completionText = normalizeOutputText(upstream); + return { + id: `chatcmpl-${uuidv4()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: upstream?.model || model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: completionText, + }, + finish_reason: upstream?.status === "incomplete" ? "length" : "stop", + }, + ], + usage: normalizeUsage(upstream), + }; +} + +function canonicalizeChatRequest(body: any): any { + const input = Array.isArray(body?.messages) + ? body.messages.map((message: any) => ({ + role: message.role, + content: message.content, + })) + : []; + + return { + model: body?.model || "gpt-5.4", + input, + stream: false, + }; +} + +export function createCodexChatCompletionsHandler(authStore: CodexAuthStore): express.RequestHandler { + return async (req, res): Promise => { + try { + const body = req.body || {}; + if (!Array.isArray(body.messages)) { + res.status(400).json({ error: { message: "messages is required" } }); + return; + } + + if (body.stream) { + res.status(501).json({ error: { message: "Codex chat streaming not implemented" } }); + return; + } + + let snapshot; + try { + snapshot = authStore.load(); + } catch (error) { + if (error instanceof CodexAuthError) { + const authError = authErrorResponse(error.message); + res.status(authError.status).json(authError.body); + return; + } + throw error; + } + + const canonicalRequest = canonicalizeChatRequest(body); + const upstreamResp = await callCodexResponses(snapshot.accessToken, canonicalRequest, false); + if (!upstreamResp.ok) { + const text = await upstreamResp.text().catch(() => ""); + res.status(upstreamResp.status).json({ + error: { message: text || "Codex upstream request failed" }, + }); + return; + } + + const upstreamJson = await upstreamResp.json(); + res.json(normalizeResponse(upstreamJson, canonicalRequest.model)); + } catch (error: any) { + res.status(500).json({ error: { message: error?.message || "Internal server error" } }); + } + }; +} diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 8c8586a..ed008ed 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -1,6 +1,7 @@ import express from "express"; import { Config } from "../config"; import { CodexAuthError, CodexAuthStore } from "./codex-auth"; +import { createCodexChatCompletionsHandler } from "./codex-chat"; import { createCodexResponsesHandler } from "./codex-responses"; import { resolveProviderFromModel } from "./router"; import { Provider, ProviderModel, ProviderStatus } from "./types"; @@ -22,11 +23,13 @@ export class CodexProvider implements Provider { private readonly authStore: CodexAuthStore; private readonly codexConfig; + private readonly chatHandler: express.RequestHandler; private readonly responsesHandler: express.RequestHandler; constructor(private readonly config: Config) { this.codexConfig = this.config.codex || DEFAULT_CODEX_CONFIG; this.authStore = new CodexAuthStore(this.codexConfig["auth-file"]); + this.chatHandler = createCodexChatCompletionsHandler(this.authStore); this.responsesHandler = createCodexResponsesHandler(this.authStore); } @@ -83,7 +86,7 @@ export class CodexProvider implements Provider { } handleChatCompletions(): express.RequestHandler { - return notImplementedHandler("Codex chat completions not implemented"); + return this.chatHandler; } handleResponses(): express.RequestHandler { diff --git a/tests/codex-chat.test.ts b/tests/codex-chat.test.ts new file mode 100644 index 0000000..b859072 --- /dev/null +++ b/tests/codex-chat.test.ts @@ -0,0 +1,274 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; +import { AddressInfo } from "node:net"; +import { createServer as createHttpServer } from "node:http"; + +import express from "express"; +import { Config } from "../src/config"; +import { CodexProvider } from "../src/providers/codex"; + +function makeConfig(authDir: string, codexAuthFile: string): Config { + return { + host: "127.0.0.1", + port: 0, + "auth-dir": authDir, + "api-keys": ["test-key"], + "body-limit": "200mb", + cloaking: { + mode: "never", + "strict-mode": false, + "sensitive-words": [], + "cache-user-id": false, + }, + timeouts: { + "messages-ms": 120000, + "stream-messages-ms": 600000, + "count-tokens-ms": 30000, + }, + codex: { + enabled: true, + "auth-file": codexAuthFile, + models: ["gpt-5.4"], + }, + debug: "off", + }; +} + +function writeAuth(filePath: string, accessToken: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + auth_mode: "oauth", + tokens: { + access_token: accessToken, + refresh_token: "refresh-token", + account_id: "acct_123", + }, + last_refresh: "2026-03-30T00:00:00.000Z", + }, null, 2) + ); +} + +async function startApp(handler: express.RequestHandler): Promise { + const app = express(); + app.use(express.json({ limit: "1mb" })); + app.post("/v1/chat/completions", handler); + const server = createHttpServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return server; +} + +async function stopApp(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function serverAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Server is not listening on a TCP port"); + } + return address; +} + +async function requestJson(options: { + server: http.Server; + method: string; + path: string; + headers?: Record; + body?: unknown; +}): Promise<{ status: number; body: any }> { + const address = serverAddress(options.server); + const payload = options.body ? JSON.stringify(options.body) : undefined; + + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port: address.port, + method: options.method, + path: options.path, + headers: { + ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload).toString() } : {}), + ...(options.headers || {}), + }, + }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + resolve({ + status: res.statusCode || 0, + body: data ? JSON.parse(data) : null, + }); + }); + } + ); + + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +test("Codex chat completions sends bearer token upstream and canonicalizes chat messages", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-chat-")); + const authFile = path.join(authDir, ".codex", "auth.json"); + writeAuth(authFile, "codex-access-token"); + const provider = new CodexProvider(makeConfig(authDir, authFile)); + const calls: Array<{ url: string; auth?: string; body: any }> = []; + + const restoreFetch = global.fetch; + global.fetch = (async (input, init) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + calls.push({ + url: String(input), + auth: init?.headers && (init.headers as Record).Authorization, + body, + }); + + return new Response( + JSON.stringify({ + id: "resp_123", + object: "response", + created_at: 1711756800, + status: "completed", + model: "gpt-5.4", + output: [{ + type: "message", + id: "msg_123", + role: "assistant", + status: "completed", + content: [ + { type: "output_text", text: "hello from codex", annotations: [] }, + ], + }], + usage: { + input_tokens: 12, + output_tokens: 8, + total_tokens: 20, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + const server = await startApp(provider.handleChatCompletions()); + + t.after(async () => { + global.fetch = restoreFetch; + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/chat/completions", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + messages: [{ role: "user", content: "hello" }], + stream: false, + }, + }); + + assert.equal(calls[0]?.url, "https://chatgpt.com/backend-api/codex/responses"); + assert.equal(calls[0]?.auth, "Bearer codex-access-token"); + assert.deepEqual(calls[0]?.body, { + model: "gpt-5.4", + input: [{ role: "user", content: "hello" }], + stream: false, + }); + assert.equal(resp.status, 200); + assert.equal(resp.body.object, "chat.completion"); + assert.equal(resp.body.model, "gpt-5.4"); + assert.equal(resp.body.choices[0].message.role, "assistant"); + assert.equal(resp.body.choices[0].message.content, "hello from codex"); + assert.equal(resp.body.usage.total_tokens, 20); +}); + +test("Codex chat completions returns controlled error when auth file is missing", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-chat-missing-")); + const authFile = path.join(authDir, ".codex", "auth.json"); + const provider = new CodexProvider(makeConfig(authDir, authFile)); + + const restoreFetch = global.fetch; + global.fetch = (async () => { + throw new Error("Upstream should not be called when auth is missing"); + }) as typeof fetch; + + const server = await startApp(provider.handleChatCompletions()); + + t.after(async () => { + global.fetch = restoreFetch; + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/chat/completions", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + messages: [{ role: "user", content: "hello" }], + stream: false, + }, + }); + + assert.equal(resp.status, 503); + assert.match(String(resp.body.error.message), /auth/i); +}); + +test("Codex chat completions returns controlled error when auth file is malformed", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-chat-malformed-")); + const authFile = path.join(authDir, ".codex", "auth.json"); + fs.mkdirSync(path.dirname(authFile), { recursive: true }); + fs.writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "oauth", + tokens: { + refresh_token: "refresh-token", + account_id: "acct_123", + }, + }, null, 2) + ); + + const provider = new CodexProvider(makeConfig(authDir, authFile)); + const server = await startApp(provider.handleChatCompletions()); + + t.after(async () => { + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/chat/completions", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + messages: [{ role: "user", content: "hello" }], + stream: false, + }, + }); + + assert.equal(resp.status, 503); + assert.match(String(resp.body.error.message), /access_token/i); +}); From c2ac5acd3b290a7eb200c46a18128b26e6ae33b6 Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 14:31:27 +0800 Subject: [PATCH 10/14] feat: add codex chat streaming compatibility --- src/providers/codex-chat.ts | 188 +++++++++++++++++++++++++++-- tests/codex-chat-stream.test.ts | 203 ++++++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+), 9 deletions(-) create mode 100644 tests/codex-chat-stream.test.ts diff --git a/src/providers/codex-chat.ts b/src/providers/codex-chat.ts index 7d4661a..9567217 100644 --- a/src/providers/codex-chat.ts +++ b/src/providers/codex-chat.ts @@ -71,6 +71,44 @@ function normalizeUsage(upstream: any): { }; } +function emitChatChunk( + model: string, + delta: Record, + finishReason: string | null = null, + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } +): string { + const chunk: any = { + id: `chatcmpl-${uuidv4()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta, + finish_reason: finishReason, + }, + ], + }; + + if (usage) { + chunk.usage = usage; + } + + return JSON.stringify(chunk); +} + +function writeSseData(res: express.Response, data: string): void { + res.write(`data: ${data}\n\n`); +} + +function mapFinishReason(status: any): string { + if (status === "incomplete") { + return "length"; + } + return "stop"; +} + function normalizeResponse(upstream: any, model: string): any { const completionText = normalizeOutputText(upstream); return { @@ -92,7 +130,7 @@ function normalizeResponse(upstream: any, model: string): any { }; } -function canonicalizeChatRequest(body: any): any { +function canonicalizeChatRequest(body: any, stream: boolean): any { const input = Array.isArray(body?.messages) ? body.messages.map((message: any) => ({ role: message.role, @@ -103,10 +141,141 @@ function canonicalizeChatRequest(body: any): any { return { model: body?.model || "gpt-5.4", input, - stream: false, + stream, }; } +async function streamCodexChatResponses(upstreamResp: Response, res: express.Response, model: string): Promise { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + + const reader = upstreamResp.body?.getReader(); + if (!reader) { + writeSseData(res, "[DONE]"); + res.end(); + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + let currentEvent = ""; + let sentRoleChunk = false; + let sentFinalChunk = false; + let sentDone = false; + let clientDisconnected = false; + let pendingUsage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined; + let pendingFinishReason: string | null = null; + + res.on("close", () => { + clientDisconnected = true; + reader.cancel().catch(() => {}); + }); + + try { + while (!clientDisconnected) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const rawLine of lines) { + if (clientDisconnected) break; + + const line = rawLine.trimEnd(); + if (!line) { + currentEvent = ""; + continue; + } + + if (line.startsWith("event:")) { + currentEvent = line.slice(6).trim(); + continue; + } + + if (!line.startsWith("data:")) { + continue; + } + + const dataStr = line.slice(5).trimStart(); + if (dataStr === "[DONE]") { + continue; + } + + let data: any; + try { + data = JSON.parse(dataStr); + } catch { + continue; + } + + if (currentEvent === "response.created") { + if (!sentRoleChunk) { + writeSseData(res, emitChatChunk(model, { role: "assistant" })); + sentRoleChunk = true; + } + continue; + } + + if (currentEvent === "response.output_text.delta") { + if (!sentRoleChunk) { + writeSseData(res, emitChatChunk(model, { role: "assistant" })); + sentRoleChunk = true; + } + const delta = typeof data?.delta === "string" ? data.delta : ""; + if (delta) { + writeSseData(res, emitChatChunk(model, { content: delta })); + } + continue; + } + + if (currentEvent === "response.completed") { + const upstreamResponse = data?.response || data; + const usage = upstreamResponse?.usage || data?.usage; + pendingUsage = usage + ? { + prompt_tokens: usage.input_tokens || 0, + completion_tokens: usage.output_tokens || 0, + total_tokens: usage.total_tokens || (usage.input_tokens || 0) + (usage.output_tokens || 0), + } + : pendingUsage; + pendingFinishReason = mapFinishReason(upstreamResponse?.status || data?.status); + if (!sentFinalChunk) { + writeSseData( + res, + emitChatChunk(model, {}, pendingFinishReason || "stop", pendingUsage) + ); + sentFinalChunk = true; + } + continue; + } + + if (currentEvent === "response.done") { + if (!sentDone) { + writeSseData(res, "[DONE]"); + sentDone = true; + } + continue; + } + } + } + } finally { + if (!clientDisconnected) { + if (!sentFinalChunk) { + writeSseData(res, emitChatChunk(model, {}, pendingFinishReason || "stop", pendingUsage)); + } + if (!sentDone) { + writeSseData(res, "[DONE]"); + } + res.end(); + } + } +} + export function createCodexChatCompletionsHandler(authStore: CodexAuthStore): express.RequestHandler { return async (req, res): Promise => { try { @@ -116,11 +285,6 @@ export function createCodexChatCompletionsHandler(authStore: CodexAuthStore): ex return; } - if (body.stream) { - res.status(501).json({ error: { message: "Codex chat streaming not implemented" } }); - return; - } - let snapshot; try { snapshot = authStore.load(); @@ -133,8 +297,9 @@ export function createCodexChatCompletionsHandler(authStore: CodexAuthStore): ex throw error; } - const canonicalRequest = canonicalizeChatRequest(body); - const upstreamResp = await callCodexResponses(snapshot.accessToken, canonicalRequest, false); + const stream = !!body.stream; + const canonicalRequest = canonicalizeChatRequest(body, stream); + const upstreamResp = await callCodexResponses(snapshot.accessToken, canonicalRequest, stream); if (!upstreamResp.ok) { const text = await upstreamResp.text().catch(() => ""); res.status(upstreamResp.status).json({ @@ -143,6 +308,11 @@ export function createCodexChatCompletionsHandler(authStore: CodexAuthStore): ex return; } + if (stream) { + await streamCodexChatResponses(upstreamResp, res, canonicalRequest.model); + return; + } + const upstreamJson = await upstreamResp.json(); res.json(normalizeResponse(upstreamJson, canonicalRequest.model)); } catch (error: any) { diff --git a/tests/codex-chat-stream.test.ts b/tests/codex-chat-stream.test.ts new file mode 100644 index 0000000..89e2071 --- /dev/null +++ b/tests/codex-chat-stream.test.ts @@ -0,0 +1,203 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; +import { AddressInfo } from "node:net"; +import { createServer as createHttpServer } from "node:http"; + +import express from "express"; +import { Config } from "../src/config"; +import { CodexProvider } from "../src/providers/codex"; + +function makeConfig(authDir: string, codexAuthFile: string): Config { + return { + host: "127.0.0.1", + port: 0, + "auth-dir": authDir, + "api-keys": ["test-key"], + "body-limit": "200mb", + cloaking: { + mode: "never", + "strict-mode": false, + "sensitive-words": [], + "cache-user-id": false, + }, + timeouts: { + "messages-ms": 120000, + "stream-messages-ms": 600000, + "count-tokens-ms": 30000, + }, + codex: { + enabled: true, + "auth-file": codexAuthFile, + models: ["gpt-5.4"], + }, + debug: "off", + }; +} + +function writeAuth(filePath: string, accessToken: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + auth_mode: "oauth", + tokens: { + access_token: accessToken, + refresh_token: "refresh-token", + account_id: "acct_123", + }, + last_refresh: "2026-03-30T00:00:00.000Z", + }, null, 2) + ); +} + +async function startApp(handler: express.RequestHandler): Promise { + const app = express(); + app.use(express.json({ limit: "1mb" })); + app.post("/v1/chat/completions", handler); + const server = createHttpServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return server; +} + +async function stopApp(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function serverAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Server is not listening on a TCP port"); + } + return address; +} + +async function requestRaw(options: { + server: http.Server; + method: string; + path: string; + headers?: Record; + body?: unknown; +}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + const address = serverAddress(options.server); + const payload = options.body ? JSON.stringify(options.body) : undefined; + + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port: address.port, + method: options.method, + path: options.path, + headers: { + ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload).toString() } : {}), + ...(options.headers || {}), + }, + }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + resolve({ + status: res.statusCode || 0, + headers: res.headers, + body: data, + }); + }); + } + ); + + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +function makeStreamResponse(chunks: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +test("Codex chat completions streams upstream SSE events as OpenAI chat chunks", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-chat-stream-")); + const authFile = path.join(authDir, ".codex", "auth.json"); + writeAuth(authFile, "codex-access-token"); + const provider = new CodexProvider(makeConfig(authDir, authFile)); + const calls: Array<{ url: string; auth?: string; accept?: string; body: any }> = []; + + const restoreFetch = global.fetch; + global.fetch = (async (input, init) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + const headers = init?.headers as Record | undefined; + calls.push({ + url: String(input), + auth: headers?.Authorization, + accept: headers?.Accept, + body, + }); + + return makeStreamResponse([ + "event: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":1}\n\n", + "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":2,\"delta\":\"hello\"}\n\n", + "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":3,\"delta\":\" world\"}\n\n", + "event: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":4,\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":8}}}\n\n", + "event: response.done\ndata: {\"type\":\"response.done\",\"sequence_number\":5}\n\n", + ]); + }) as typeof fetch; + + const server = await startApp(provider.handleChatCompletions()); + + t.after(async () => { + global.fetch = restoreFetch; + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestRaw({ + server, + method: "POST", + path: "/v1/chat/completions", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + messages: [{ role: "user", content: "hello" }], + stream: true, + }, + }); + + assert.equal(calls[0]?.url, "https://chatgpt.com/backend-api/codex/responses"); + assert.equal(calls[0]?.auth, "Bearer codex-access-token"); + assert.equal(calls[0]?.accept, "text/event-stream"); + assert.equal(calls[0]?.body.stream, true); + assert.equal(resp.status, 200); + assert.match(String(resp.headers["content-type"]), /text\/event-stream/); + assert.match(resp.body, /"object":"chat\.completion\.chunk"/); + assert.match(resp.body, /"content":"hello"/); + assert.match(resp.body, /"content":" world"/); + assert.match(resp.body, /"finish_reason":"stop"/); + assert.match(resp.body, /"usage":\{"prompt_tokens":12,"completion_tokens":8,"total_tokens":20\}/); + assert.match(resp.body, /data: \[DONE\]/); +}); From ee3b4b5901444ac2229350ad0924d03308d55c59 Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 14:44:34 +0800 Subject: [PATCH 11/14] feat: route http requests across claude and codex providers --- src/index.ts | 5 +- src/providers/router.ts | 7 ++ src/server.ts | 24 +++++- src/startup.ts | 12 +++ tests/provider-router.test.ts | 14 ++++ tests/smoke.test.ts | 137 ++++++++++++++++++++++++++++++++++ tests/startup.test.ts | 107 ++++++++++++++++++++++++++ 7 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 src/startup.ts create mode 100644 tests/startup.test.ts diff --git a/src/index.ts b/src/index.ts index 43d39e8..5ac5484 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { generatePKCECodes } from "./auth/pkce"; import { generateAuthURL, exchangeCodeForTokens } from "./auth/oauth"; import { waitForCallback } from "./auth/callback-server"; import { createServer } from "./server"; +import { canStartServer } from "./startup"; function prompt(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -73,8 +74,8 @@ async function startServer(): Promise { const manager = new AccountManager(authDir); manager.load(); - if (manager.accountCount === 0) { - console.log("No account found. Run with --login to add your account first."); + if (!canStartServer(config, manager)) { + console.log("No provider auth found. Run with --login for Claude or ensure Codex auth exists first."); process.exit(1); } diff --git a/src/providers/router.ts b/src/providers/router.ts index 3be4a6c..2949c3f 100644 --- a/src/providers/router.ts +++ b/src/providers/router.ts @@ -4,7 +4,14 @@ const CLAUDE_PREFIXES = ["claude-"]; const CODEX_PREFIXES = ["gpt-", "codex-"]; export function resolveProviderFromModel(model: string): ProviderName | null { + if (typeof model !== "string") { + return null; + } + const normalized = model.trim().toLowerCase(); + if (!normalized) { + return null; + } if (CLAUDE_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { return "claude"; diff --git a/src/server.ts b/src/server.ts index a1e1c80..5074f2f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,7 @@ import { AccountManager } from "./accounts/manager"; import { extractApiKey } from "./api-key"; import { ClaudeProvider } from "./providers/claude"; import { CodexProvider } from "./providers/codex"; +import { resolveProviderFromModel } from "./providers/router"; // Timing-safe API key comparison function safeCompare(a: string, b: string): boolean { @@ -48,6 +49,19 @@ export function createServer(config: Config, manager: AccountManager): express.A const app = express(); const claudeProvider = new ClaudeProvider(config, manager); const codexProvider = new CodexProvider(config); + const routeByModel = + ( + claudeHandler: express.RequestHandler, + codexHandler: express.RequestHandler + ): express.RequestHandler => + (req, res, next) => { + const provider = resolveProviderFromModel(req.body?.model); + if (provider === "codex") { + codexHandler(req, res, next); + return; + } + claudeHandler(req, res, next); + }; app.use(express.json({ limit: config["body-limit"] })); @@ -110,8 +124,14 @@ export function createServer(config: Config, manager: AccountManager): express.A app.use("/admin", requireApiKey); // Routes — OpenAI compatible - app.post("/v1/chat/completions", claudeProvider.handleChatCompletions()); - app.post("/v1/responses", claudeProvider.handleResponses()); + app.post( + "/v1/chat/completions", + routeByModel(claudeProvider.handleChatCompletions(), codexProvider.handleChatCompletions()) + ); + app.post( + "/v1/responses", + routeByModel(claudeProvider.handleResponses(), codexProvider.handleResponses()) + ); // Routes — Claude native passthrough app.post("/v1/messages/count_tokens", claudeProvider.handleCountTokens()); diff --git a/src/startup.ts b/src/startup.ts new file mode 100644 index 0000000..35f48a8 --- /dev/null +++ b/src/startup.ts @@ -0,0 +1,12 @@ +import { AccountManager } from "./accounts/manager"; +import { Config } from "./config"; +import { CodexProvider } from "./providers/codex"; + +export function canStartServer(config: Config, manager: AccountManager): boolean { + if (manager.accountCount > 0) { + return true; + } + + const codexProvider = new CodexProvider(config); + return codexProvider.getStatus().available; +} diff --git a/tests/provider-router.test.ts b/tests/provider-router.test.ts index 440007d..285acee 100644 --- a/tests/provider-router.test.ts +++ b/tests/provider-router.test.ts @@ -15,6 +15,20 @@ test("codex-mini-latest routes to codex", () => { assert.equal(resolveProviderFromModel("codex-mini-latest"), "codex"); }); +test("o3 routes to codex", () => { + assert.equal(resolveProviderFromModel("o3"), "codex"); +}); + +test("o4-mini routes to codex", () => { + assert.equal(resolveProviderFromModel("o4-mini"), "codex"); +}); + +test("invalid model input returns null instead of throwing", () => { + assert.equal(resolveProviderFromModel(undefined as unknown as string), null); + assert.equal(resolveProviderFromModel(null as unknown as string), null); + assert.equal(resolveProviderFromModel({ trim: "nope" } as unknown as string), null); +}); + test("unknown model returns null", () => { assert.equal(resolveProviderFromModel("not-a-real-model"), null); }); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index a88d8d2..75da199 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -14,6 +14,7 @@ import { saveToken } from "../src/auth/token-storage"; import { TokenData } from "../src/auth/types"; const TOKEN_URL = "https://api.anthropic.com/v1/oauth/token"; +const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; function makeConfig(authDir: string): Config { return { @@ -34,6 +35,11 @@ function makeConfig(authDir: string): Config { "count-tokens-ms": 30000, }, debug: "off", + codex: { + enabled: true, + "auth-file": path.join(authDir, "codex-auth.json"), + models: ["gpt-5.4", "o3", "codex-mini-latest"], + }, }; } @@ -56,6 +62,21 @@ function makeManager(authDir: string, tokens: TokenData[]): AccountManager { return manager; } +function writeCodexAuth(authDir: string): void { + fs.writeFileSync( + path.join(authDir, "codex-auth.json"), + JSON.stringify({ + auth_mode: "oauth", + last_refresh: new Date().toISOString(), + tokens: { + access_token: "codex-access-token", + refresh_token: "codex-refresh-token", + account_id: "acct_codex", + }, + }) + ); +} + async function startApp(config: Config, manager: AccountManager): Promise { const app = createServer(config, manager); const server = createHttpServer(app); @@ -212,6 +233,65 @@ test("proxies a non-stream chat completion through Claude OAuth token", async (t assert.equal(resp.body.usage.total_tokens, 17); }); +test("routes OpenAI responses requests to Codex based on model", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); + writeCodexAuth(authDir); + const manager = makeManager(authDir, [makeToken()]); + const restoreFetch = withMockedFetch(async (input, init) => { + const url = String(input); + assert.equal(url, CODEX_RESPONSES_URL); + assert.equal(init?.method, "POST"); + assert.equal(init?.headers && (init.headers as Record).Authorization, "Bearer codex-access-token"); + + const parsedBody = JSON.parse(String(init?.body || "{}")); + assert.equal(parsedBody.model, "gpt-5.4"); + assert.equal(parsedBody.input[0].content, "hello codex"); + + return new Response( + JSON.stringify({ + id: "resp_codex", + object: "response", + model: "gpt-5.4", + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hello from codex" }], + }, + ], + usage: { input_tokens: 7, output_tokens: 4, total_tokens: 11 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }); + const server = await startApp(makeConfig(authDir), manager); + + t.after(async () => { + restoreFetch(); + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/responses", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + input: [{ role: "user", content: "hello codex" }], + stream: false, + }, + }); + + assert.equal(resp.status, 200); + assert.equal(resp.body.object, "response"); + assert.equal(resp.body.model, "gpt-5.4"); + assert.equal(resp.body.output[0].content[0].text, "hello from codex"); + assert.equal(resp.body.usage.total_tokens, 11); +}); + test("refreshes the OAuth token after an upstream 401 and retries successfully", async (t) => { const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); const manager = makeManager(authDir, [makeToken()]); @@ -322,6 +402,63 @@ test("returns rate limited when the configured account is cooled down", async (t assert.equal(resp.body.error.message, "Rate limited on the configured account"); }); +test("missing Codex auth only breaks Codex models and still allows Claude models", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); + const manager = makeManager(authDir, [makeToken()]); + const restoreFetch = withMockedFetch(async (input, init) => { + const url = String(input); + assert.equal(url, "https://api.anthropic.com/v1/messages?beta=true"); + assert.equal(init?.headers && (init.headers as Record).Authorization, "Bearer access-token"); + + return new Response( + JSON.stringify({ + id: "msg_claude_ok", + content: [{ type: "text", text: "claude still works" }], + stop_reason: "end_turn", + usage: { input_tokens: 5, output_tokens: 3 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }); + const server = await startApp(makeConfig(authDir), manager); + + t.after(async () => { + restoreFetch(); + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const codexResp = await requestJson({ + server, + method: "POST", + path: "/v1/chat/completions", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + messages: [{ role: "user", content: "hi codex" }], + stream: false, + }, + }); + + assert.equal(codexResp.status, 503); + assert.match(codexResp.body.error.message, /Codex auth file not found/); + + const claudeResp = await requestJson({ + server, + method: "POST", + path: "/v1/chat/completions", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "claude-sonnet-4", + messages: [{ role: "user", content: "hi claude" }], + stream: false, + }, + }); + + assert.equal(claudeResp.status, 200); + assert.equal(claudeResp.body.choices[0].message.content, "claude still works"); +}); + test("rejects loading multiple accounts in single-account mode", (t) => { const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); t.after(() => { diff --git a/tests/startup.test.ts b/tests/startup.test.ts new file mode 100644 index 0000000..17e309b --- /dev/null +++ b/tests/startup.test.ts @@ -0,0 +1,107 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { AccountManager } from "../src/accounts/manager"; +import { Config } from "../src/config"; +import { canStartServer } from "../src/startup"; +import { saveToken } from "../src/auth/token-storage"; +import { TokenData } from "../src/auth/types"; + +function makeConfig(authDir: string): Config { + return { + host: "127.0.0.1", + port: 0, + "auth-dir": authDir, + "api-keys": ["test-key"], + "body-limit": "200mb", + cloaking: { + mode: "never", + "strict-mode": false, + "sensitive-words": [], + "cache-user-id": false, + }, + timeouts: { + "messages-ms": 120000, + "stream-messages-ms": 600000, + "count-tokens-ms": 30000, + }, + debug: "off", + codex: { + enabled: true, + "auth-file": path.join(authDir, "codex-auth.json"), + models: ["gpt-5.4"], + }, + }; +} + +function writeClaudeToken(authDir: string): void { + const token: TokenData = { + accessToken: "access-token", + refreshToken: "refresh-token", + email: "test@example.com", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }; + saveToken(authDir, token); +} + +function writeCodexAuth(authDir: string): void { + fs.writeFileSync( + path.join(authDir, "codex-auth.json"), + JSON.stringify({ + auth_mode: "oauth", + last_refresh: new Date().toISOString(), + tokens: { + access_token: "codex-access-token", + refresh_token: "codex-refresh-token", + account_id: "acct_codex", + }, + }) + ); +} + +function loadManager(authDir: string): AccountManager { + const manager = new AccountManager(authDir); + manager.load(); + return manager; +} + +test("allows startup when Claude is missing but Codex auth is available", () => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-startup-")); + + try { + writeCodexAuth(authDir); + const manager = loadManager(authDir); + + assert.equal(canStartServer(makeConfig(authDir), manager), true); + } finally { + fs.rmSync(authDir, { recursive: true, force: true }); + } +}); + +test("rejects startup when neither Claude nor Codex auth is available", () => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-startup-")); + + try { + const manager = loadManager(authDir); + + assert.equal(canStartServer(makeConfig(authDir), manager), false); + } finally { + fs.rmSync(authDir, { recursive: true, force: true }); + } +}); + +test("allows startup when Claude auth is available even if Codex auth is missing", () => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-startup-")); + + try { + writeClaudeToken(authDir); + const manager = loadManager(authDir); + + assert.equal(canStartServer(makeConfig(authDir), manager), true); + } finally { + fs.rmSync(authDir, { recursive: true, force: true }); + } +}); From 4b3cf5b49b41a1c7865056adf92c4e2a81258acb Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 14:45:49 +0800 Subject: [PATCH 12/14] docs: describe dual claude and codex providers --- README.md | 63 +++++++++++++++++++++++++++++++++------------ README_CN.md | 63 +++++++++++++++++++++++++++++++++------------ config.example.yaml | 11 +++++++- 3 files changed, 104 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index dd7a991..5795be7 100644 --- a/README.md +++ b/README.md @@ -2,31 +2,34 @@ [中文](./README_CN.md) -A lightweight single-account Claude OAuth to API proxy for Claude Code and OpenAI-compatible clients. +A lightweight dual-provider API proxy for Claude Code and OpenAI-compatible clients. auth2api is intentionally small and focused: -- one Claude OAuth account +- one Claude OAuth account at most +- one local Codex login reused from `~/.codex/auth.json` - one local or self-hosted proxy -- one simple goal: turn Claude OAuth access into a usable API endpoint +- one simple goal: turn local Claude/Codex auth into usable API endpoints -It is not trying to be a multi-provider gateway or a large routing platform. If you want a compact, understandable proxy that is easy to run and modify, auth2api is built for that use case. +It is still intentionally not a multi-account pool or a large routing platform. If you want a compact, understandable proxy that is easy to run and modify, auth2api is built for that use case. ## Features - **Lightweight by design** — small codebase, single-account architecture, minimal moving parts -- **Claude OAuth to API** — use one Claude OAuth login as an API-backed proxy account +- **Claude + Codex** — serves Claude OAuth and local Codex auth from one process - **OpenAI-compatible API** — supports `/v1/chat/completions`, `/v1/responses`, and `/v1/models` +- **Model-based routing** — `claude-*` stays on Claude, `gpt-*` / `o*` / `codex-*` route to Codex - **Claude native passthrough** — supports `/v1/messages` and `/v1/messages/count_tokens` - **Claude Code friendly** — works with both `Authorization: Bearer` and `x-api-key` - **Streaming, tools, images, and reasoning** — covers the main Claude usage patterns without a large framework -- **Single-account health handling** — cooldown, retry, token refresh, and `/admin/accounts` status +- **Provider-aware status** — Claude account health plus Codex auth status in `/admin/accounts` - **Basic safety defaults** — timing-safe API key validation, per-IP rate limiting, localhost-only browser CORS ## Requirements - Node.js 20+ -- A Claude account (Claude Max subscription recommended) +- A Claude account if you want Claude models (Claude Max subscription recommended) +- A local Codex login if you want Codex models (`~/.codex/auth.json`) ## Installation @@ -39,6 +42,8 @@ npm run build ## Login +Claude models still use auth2api's built-in OAuth login flow. Codex models do not have a separate login flow here — auth2api reuses the local Codex session from `~/.codex/auth.json`. + ### Auto mode (requires local browser) ```bash @@ -63,6 +68,11 @@ node dist/index.js The server starts on `http://127.0.0.1:8317` by default. On first run, an API key is auto-generated and saved to `config.yaml`. +The process can start with either provider: + +- Claude available via `node dist/index.js --login` +- Codex available via an existing `~/.codex/auth.json` + If the configured Claude account is temporarily cooled down after upstream rate limiting, auth2api now returns `429 Rate limited on the configured account` instead of a generic `503`. ## Configuration @@ -73,7 +83,7 @@ Copy `config.example.yaml` to `config.yaml` and edit as needed: host: "" # bind address, empty = 127.0.0.1 port: 8317 -auth-dir: "~/.auth2api" # where OAuth tokens are stored +auth-dir: "~/.auth2api" # where Claude OAuth tokens are stored api-keys: - "your-api-key-here" # clients use this to authenticate @@ -96,12 +106,22 @@ timeouts: messages-ms: 120000 stream-messages-ms: 600000 count-tokens-ms: 30000 + +codex: + enabled: true + auth-file: "~/.codex/auth.json" + models: + - "gpt-5.4" + - "o3" + - "codex-mini-latest" ``` By default, streaming upstream requests are allowed to run for 10 minutes before auth2api aborts them. The default request body limit is `200mb`, which is more suitable for large Claude Code contexts than the previous fixed `20mb`. +Codex models exposed by `/v1/models` come from `codex.models`. Claude models are built in. + `debug` now supports three levels: - `off`: no extra logs - `errors`: log upstream/network failures and upstream error bodies @@ -122,8 +142,15 @@ curl http://127.0.0.1:8317/v1/chat/completions \ }' ``` +`/v1/chat/completions` and `/v1/responses` route automatically by `model`: + +- `claude-*` -> Claude provider +- `gpt-*`, `o*`, `codex-*` -> Codex provider + ### Available models +Claude models built into auth2api: + | Model ID | Description | |----------|-------------| | `claude-opus-4-6` | Claude Opus 4.6 | @@ -137,16 +164,18 @@ Short convenience aliases accepted by auth2api: - `sonnet` -> `claude-sonnet-4-6` - `haiku` -> `claude-haiku-4-5-20251001` +Codex models are configured explicitly in `config.yaml` under `codex.models`. They are returned by `/v1/models` and routed by prefix. + ### Endpoints | Endpoint | Description | |----------|-------------| -| `POST /v1/chat/completions` | OpenAI-compatible chat | -| `POST /v1/responses` | OpenAI Responses API compatibility | -| `POST /v1/messages` | Claude native passthrough | -| `POST /v1/messages/count_tokens` | Claude token counting | +| `POST /v1/chat/completions` | OpenAI-compatible chat, routed by model | +| `POST /v1/responses` | OpenAI Responses API compatibility, routed by model | +| `POST /v1/messages` | Claude native passthrough, Claude-only | +| `POST /v1/messages/count_tokens` | Claude token counting, Claude-only | | `GET /v1/models` | List available models | -| `GET /admin/accounts` | Account health/status (API key required) | +| `GET /admin/accounts` | Claude + Codex provider status (API key required) | | `GET /health` | Health check | ## Docker @@ -183,12 +212,14 @@ Claude Code uses the native `/v1/messages` endpoint which auth2api passes throug ## Single-account mode -This proxy supports exactly one Claude OAuth account at a time. +Claude token storage remains single-account mode: - Running `--login` again refreshes the stored token for the same account. - If a different account is already stored, auth2api refuses to overwrite it and asks you to remove the existing token first. - If more than one token file exists in the auth directory, auth2api exits with an error until you clean up the extra files. +Codex auth is separate: auth2api only reads the local `~/.codex/auth.json` file and does not manage Codex login itself. + ## Admin status Use `/admin/accounts` with your configured API key to inspect the current account state: @@ -198,11 +229,11 @@ curl http://127.0.0.1:8317/admin/accounts \ -H "Authorization: Bearer " ``` -The response includes account availability, cooldown, failure counters, last refresh time, and basic request statistics. +The response includes legacy Claude account snapshots plus separate `claude` and `codex` provider sections so you can see provider availability independently. ## Smoke tests -A minimal automated smoke test suite is included and uses mocked upstream responses, so it does not call the real Claude service: +A minimal automated smoke test suite is included and uses mocked upstream responses, so it does not call the real Claude or Codex services: ```bash npm run test:smoke diff --git a/README_CN.md b/README_CN.md index be48da9..6a84420 100644 --- a/README_CN.md +++ b/README_CN.md @@ -2,31 +2,34 @@ [English](./README.md) -一个轻量级、单账号的 Claude OAuth 转 API 代理,适合 Claude Code 和 OpenAI 兼容客户端。 +一个轻量级的双 provider API 代理,适合 Claude Code 和 OpenAI 兼容客户端。 auth2api 的定位很克制,也很明确: -- 一个 Claude OAuth 账号 +- 最多一个 Claude OAuth 账号 +- 一个直接复用 `~/.codex/auth.json` 的本地 Codex 登录态 - 一个本地或自托管代理 -- 一个目标:把 Claude OAuth 登录态变成可调用的 API +- 一个目标:把本地 Claude/Codex 登录态变成可调用的 API -它并不试图做成多 provider 网关,也不是大型路由平台。如果你想要的是一个体积小、容易理解、方便自己改的代理,auth2api 就是为这个场景准备的。 +它依然不打算做成多账号池,也不是大型路由平台。如果你想要的是一个体积小、容易理解、方便自己改的代理,auth2api 就是为这个场景准备的。 ## 功能特性 - **轻量优先**:代码量小、单账号架构、依赖和运行逻辑都尽量简单 -- **Claude OAuth 转 API**:把一个 Claude OAuth 登录账号作为 API 代理账号使用 +- **Claude + Codex**:一个进程同时服务 Claude OAuth 和本地 Codex 登录态 - **OpenAI 兼容 API**:支持 `/v1/chat/completions`、`/v1/responses`、`/v1/models` +- **按模型自动路由**:`claude-*` 走 Claude,`gpt-*` / `o*` / `codex-*` 走 Codex - **Claude 原生透传**:支持 `/v1/messages` 与 `/v1/messages/count_tokens` - **适配 Claude Code**:兼容 `Authorization: Bearer` 和 `x-api-key` - **覆盖核心能力**:支持流式、工具调用、图片与 reasoning,而不引入大型框架 -- **单账号健康管理**:内置 cooldown、重试、token 刷新和 `/admin/accounts` 状态查看 +- **Provider 级状态查看**:`/admin/accounts` 同时暴露 Claude 和 Codex 的可用状态 - **默认安全设置**:timing-safe API key 校验、每 IP 限流、仅允许 localhost 浏览器 CORS ## 运行要求 - Node.js 20+ -- 一个 Claude 账号(推荐 Claude Max) +- 如果要用 Claude 模型,需要一个 Claude 账号(推荐 Claude Max) +- 如果要用 Codex 模型,需要本机已有 Codex 登录态(`~/.codex/auth.json`) ## 安装 @@ -39,6 +42,8 @@ npm run build ## 登录 +Claude 模型仍然使用 auth2api 内置的 OAuth 登录流程。Codex 模型没有单独的登录流程,auth2api 直接复用本机 `~/.codex/auth.json`。 + ### 自动模式(需要本地浏览器) ```bash @@ -63,6 +68,11 @@ node dist/index.js 默认监听地址为 `http://127.0.0.1:8317`。首次启动时,如果 `config.yaml` 中没有配置 API key,会自动生成并写入该文件。 +只要任一 provider 可用,进程就可以启动: + +- Claude 可用:先执行 `node dist/index.js --login` +- Codex 可用:本机已有 `~/.codex/auth.json` + 如果上游因为限流导致当前账号进入 cooldown,auth2api 会返回 `429 Rate limited on the configured account`,而不是通用的 `503`。 ## 配置 @@ -73,7 +83,7 @@ node dist/index.js host: "" # 绑定地址,空字符串表示 127.0.0.1 port: 8317 -auth-dir: "~/.auth2api" # OAuth token 存储目录 +auth-dir: "~/.auth2api" # Claude OAuth token 存储目录 api-keys: - "your-api-key-here" # 客户端使用这个 key 访问代理 @@ -96,12 +106,22 @@ timeouts: messages-ms: 120000 stream-messages-ms: 600000 count-tokens-ms: 30000 + +codex: + enabled: true + auth-file: "~/.codex/auth.json" + models: + - "gpt-5.4" + - "o3" + - "codex-mini-latest" ``` 默认情况下,流式上游请求会允许持续 10 分钟后才会被 auth2api 主动中断。 默认请求体大小限制现在是 `200mb`,比之前固定的 `20mb` 更适合 Claude Code 的大上下文使用场景。 +`/v1/models` 里的 Codex 模型来自 `codex.models` 配置;Claude 模型则是内置列表。 + `debug` 现在支持三级日志: - `off`:不输出额外调试日志 - `errors`:记录上游/网络失败信息和上游错误响应正文 @@ -122,8 +142,15 @@ curl http://127.0.0.1:8317/v1/chat/completions \ }' ``` +`/v1/chat/completions` 和 `/v1/responses` 会按 `model` 自动分流: + +- `claude-*` -> Claude provider +- `gpt-*`、`o*`、`codex-*` -> Codex provider + ### 支持的模型 +auth2api 内置的 Claude 模型: + | 模型 ID | 说明 | |--------|------| | `claude-opus-4-6` | Claude Opus 4.6 | @@ -137,16 +164,18 @@ auth2api 额外支持以下便捷别名: - `sonnet` -> `claude-sonnet-4-6` - `haiku` -> `claude-haiku-4-5-20251001` +Codex 模型通过 `config.yaml` 里的 `codex.models` 显式配置,并由 `/v1/models` 返回。 + ### 接口列表 | Endpoint | 说明 | |----------|------| -| `POST /v1/chat/completions` | OpenAI 兼容聊天接口 | -| `POST /v1/responses` | OpenAI Responses API 兼容接口 | -| `POST /v1/messages` | Claude 原生消息透传 | -| `POST /v1/messages/count_tokens` | Claude token 计数 | +| `POST /v1/chat/completions` | OpenAI 兼容聊天接口,按模型自动路由 | +| `POST /v1/responses` | OpenAI Responses API 兼容接口,按模型自动路由 | +| `POST /v1/messages` | Claude 原生消息透传,仅 Claude | +| `POST /v1/messages/count_tokens` | Claude token 计数,仅 Claude | | `GET /v1/models` | 列出可用模型 | -| `GET /admin/accounts` | 查看账号健康状态(需要 API key) | +| `GET /admin/accounts` | 查看 Claude + Codex provider 状态(需要 API key) | | `GET /health` | 健康检查 | ## Docker @@ -183,12 +212,14 @@ Claude Code 使用的是原生 `/v1/messages` 接口,auth2api 会直接透传 ## 单账号模式 -当前版本仅支持一个 Claude OAuth 账号。 +Claude token 存储仍然保持单账号模式: - 再次执行 `--login` 时,如果还是同一个账号,会更新已保存的 token - 如果本地已保存的是另一个账号,auth2api 会拒绝覆盖,并要求你先删除旧 token - 如果 token 目录中存在多个 token 文件,auth2api 会直接报错并退出,直到你清理多余文件 +Codex 认证是独立的:auth2api 只读取本机 `~/.codex/auth.json`,不负责 Codex 登录管理。 + ## 管理状态 你可以通过 `/admin/accounts` 查看当前账号状态: @@ -198,11 +229,11 @@ curl http://127.0.0.1:8317/admin/accounts \ -H "Authorization: Bearer " ``` -返回内容包含账号是否可用、cooldown 截止时间、失败计数、最近刷新时间以及基础请求统计。 +返回内容包含旧版 Claude 账号快照,以及拆开的 `claude`、`codex` provider 状态,便于分别判断哪一侧不可用。 ## Smoke 测试 -仓库内置了一套最小自动化 smoke test,并使用 mocked upstream response,因此不会调用真实 Claude 服务: +仓库内置了一套最小自动化 smoke test,并使用 mocked upstream response,因此不会调用真实 Claude 或 Codex 服务: ```bash npm run test:smoke diff --git a/config.example.yaml b/config.example.yaml index 4e9f5f3..d87692e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,7 +1,7 @@ host: "" port: 8317 -# Directory to store OAuth tokens +# Directory to store Claude OAuth tokens auth-dir: "~/.auth2api" # API keys for client authentication (clients use these to access this proxy) @@ -24,6 +24,15 @@ timeouts: stream-messages-ms: 600000 # stream /v1/messages timeout, suitable for Claude Code long tasks count-tokens-ms: 30000 # /v1/messages/count_tokens timeout +# Codex provider settings (reuses local Codex login state) +codex: + enabled: true + auth-file: "~/.codex/auth.json" + models: + - "gpt-5.4" + - "o3" + - "codex-mini-latest" + # Debug logging level: # off = no extra logs # errors = upstream/network failure details From 4822e7fcbde81b67f56424f6eac86099092ce633 Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 15:03:20 +0800 Subject: [PATCH 13/14] fix: enforce codex routing configuration --- README.md | 2 +- README_CN.md | 2 +- src/providers/claude.ts | 12 ++- src/providers/codex.ts | 33 ++++++- src/server.ts | 27 +++++- tests/codex-provider-status.test.ts | 26 +++++ tests/smoke.test.ts | 142 ++++++++++++++++++++++++++++ tests/startup.test.ts | 15 +++ 8 files changed, 248 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5795be7..f64ce22 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Short convenience aliases accepted by auth2api: - `sonnet` -> `claude-sonnet-4-6` - `haiku` -> `claude-haiku-4-5-20251001` -Codex models are configured explicitly in `config.yaml` under `codex.models`. They are returned by `/v1/models` and routed by prefix. +Codex models are configured explicitly in `config.yaml` under `codex.models`. Only models listed there are returned by `/v1/models` and accepted at runtime. ### Endpoints diff --git a/README_CN.md b/README_CN.md index 6a84420..e13c369 100644 --- a/README_CN.md +++ b/README_CN.md @@ -164,7 +164,7 @@ auth2api 额外支持以下便捷别名: - `sonnet` -> `claude-sonnet-4-6` - `haiku` -> `claude-haiku-4-5-20251001` -Codex 模型通过 `config.yaml` 里的 `codex.models` 显式配置,并由 `/v1/models` 返回。 +Codex 模型通过 `config.yaml` 里的 `codex.models` 显式配置;只有列在其中的模型才会由 `/v1/models` 返回并在运行时被接受。 ### 接口列表 diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 41057a0..bb11514 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -4,7 +4,6 @@ import { Config } from "../config"; import { createChatCompletionsHandler } from "../proxy/handler"; import { createMessagesHandler, createCountTokensHandler } from "../proxy/passthrough"; import { createResponsesHandler } from "../proxy/responses"; -import { resolveProviderFromModel } from "./router"; import { Provider, ProviderModel, ProviderStatus } from "./types"; const CLAUDE_MODELS = [ @@ -36,7 +35,16 @@ export class ClaudeProvider implements Provider { } supportsModel(model: string): boolean { - return resolveProviderFromModel(model) === this.name; + if (typeof model !== "string") { + return false; + } + + const normalized = model.trim().toLowerCase(); + if (!normalized) { + return false; + } + + return normalized.startsWith("claude-") || CLAUDE_MODELS.includes(normalized as (typeof CLAUDE_MODELS)[number]); } listModels(): ProviderModel[] { diff --git a/src/providers/codex.ts b/src/providers/codex.ts index ed008ed..29ae949 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -12,10 +12,8 @@ const DEFAULT_CODEX_CONFIG = { models: [] as string[], }; -function notImplementedHandler(message: string): express.RequestHandler { - return (_req, res) => { - res.status(501).json({ error: { message } }); - }; +function normalizeModelId(model: string): string { + return model.trim().toLowerCase(); } export class CodexProvider implements Provider { @@ -34,7 +32,18 @@ export class CodexProvider implements Provider { } supportsModel(model: string): boolean { - return resolveProviderFromModel(model) === this.name; + if (!this.codexConfig.enabled || typeof model !== "string") { + return false; + } + + const normalized = normalizeModelId(model); + if (!normalized || resolveProviderFromModel(normalized) !== this.name) { + return false; + } + + return this.codexConfig.models + .map((id) => normalizeModelId(id)) + .includes(normalized); } listModels(): ProviderModel[] { @@ -57,6 +66,18 @@ export class CodexProvider implements Provider { }; } + if (this.codexConfig.models.length === 0) { + return { + name: this.name, + available: false, + details: { + enabled: true, + configured: false, + error: "No Codex models configured", + }, + }; + } + try { const snapshot = this.authStore.load(); return { @@ -64,6 +85,7 @@ export class CodexProvider implements Provider { available: true, details: { enabled: true, + configured: true, authMode: snapshot.authMode, accountId: snapshot.accountId, lastRefresh: snapshot.lastRefresh, @@ -77,6 +99,7 @@ export class CodexProvider implements Provider { available: false, details: { enabled: true, + configured: true, error: error.message, }, }; diff --git a/src/server.ts b/src/server.ts index 5074f2f..a0d6ad3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -55,12 +55,35 @@ export function createServer(config: Config, manager: AccountManager): express.A codexHandler: express.RequestHandler ): express.RequestHandler => (req, res, next) => { - const provider = resolveProviderFromModel(req.body?.model); + const model = req.body?.model; + const provider = resolveProviderFromModel(model); + if (provider === "codex") { + if (!codexProvider.supportsModel(model)) { + res.status(400).json({ error: { message: `Unsupported model: ${String(model)}` } }); + return; + } codexHandler(req, res, next); return; } - claudeHandler(req, res, next); + + if (provider === "claude") { + claudeHandler(req, res, next); + return; + } + + if (claudeProvider.supportsModel(model)) { + claudeHandler(req, res, next); + return; + } + + if (codexProvider.supportsModel(model)) { + codexHandler(req, res, next); + return; + } + + res.status(400).json({ error: { message: `Unsupported model: ${String(model)}` } }); + return; }; app.use(express.json({ limit: config["body-limit"] })); diff --git a/tests/codex-provider-status.test.ts b/tests/codex-provider-status.test.ts index 09d19b5..7120760 100644 --- a/tests/codex-provider-status.test.ts +++ b/tests/codex-provider-status.test.ts @@ -143,6 +143,32 @@ test("CodexProvider reports unavailable when auth.json is missing", () => { assert.equal(status.available, false); }); +test("CodexProvider supports only configured models when enabled", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-provider-")); + const config = makeConfig(tmpDir, path.join(tmpDir, ".codex", "auth.json")); + const provider = new CodexProvider(config); + + assert.equal(provider.supportsModel("gpt-5.4"), true); + assert.equal(provider.supportsModel("o3"), false); +}); + +test("CodexProvider rejects all models when disabled", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-provider-")); + const config = { + ...makeConfig(tmpDir, path.join(tmpDir, ".codex", "auth.json")), + codex: { + enabled: false, + "auth-file": path.join(tmpDir, ".codex", "auth.json"), + models: ["gpt-5.4"], + }, + }; + const provider = new CodexProvider(config); + + assert.equal(provider.supportsModel("gpt-5.4"), false); + assert.deepEqual(provider.listModels(), []); + assert.equal(provider.getStatus().available, false); +}); + test("server exposes Claude and Codex models and provider status", async (t) => { const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-server-")); const config = makeConfig(authDir, path.join(authDir, ".codex", "auth.json")); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 75da199..1ac4fa0 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -43,6 +43,13 @@ function makeConfig(authDir: string): Config { }; } +function makeConfigWithCodex(authDir: string, codex: Config["codex"]): Config { + return { + ...makeConfig(authDir), + codex, + }; +} + function makeToken(overrides: Partial = {}): TokenData { return { accessToken: "access-token", @@ -292,6 +299,65 @@ test("routes OpenAI responses requests to Codex based on model", async (t) => { assert.equal(resp.body.usage.total_tokens, 11); }); +test("routes OpenAI chat completions requests to Codex based on model", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); + writeCodexAuth(authDir); + const manager = makeManager(authDir, [makeToken()]); + const restoreFetch = withMockedFetch(async (input, init) => { + const url = String(input); + assert.equal(url, CODEX_RESPONSES_URL); + assert.equal(init?.method, "POST"); + assert.equal(init?.headers && (init.headers as Record).Authorization, "Bearer codex-access-token"); + + const parsedBody = JSON.parse(String(init?.body || "{}")); + assert.equal(parsedBody.model, "gpt-5.4"); + assert.equal(parsedBody.input[0].content, "hello from codex chat"); + + return new Response( + JSON.stringify({ + id: "resp_codex_chat", + object: "response", + model: "gpt-5.4", + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hello from codex chat" }], + }, + ], + usage: { input_tokens: 6, output_tokens: 5, total_tokens: 11 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }); + const server = await startApp(makeConfig(authDir), manager); + + t.after(async () => { + restoreFetch(); + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/chat/completions", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + messages: [{ role: "user", content: "hello from codex chat" }], + stream: false, + }, + }); + + assert.equal(resp.status, 200); + assert.equal(resp.body.object, "chat.completion"); + assert.equal(resp.body.model, "gpt-5.4"); + assert.equal(resp.body.choices[0].message.content, "hello from codex chat"); + assert.equal(resp.body.usage.total_tokens, 11); +}); + test("refreshes the OAuth token after an upstream 401 and retries successfully", async (t) => { const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); const manager = makeManager(authDir, [makeToken()]); @@ -459,6 +525,82 @@ test("missing Codex auth only breaks Codex models and still allows Claude models assert.equal(claudeResp.body.choices[0].message.content, "claude still works"); }); +test("disabled Codex provider rejects Codex models without falling back to Claude", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); + writeCodexAuth(authDir); + const manager = makeManager(authDir, [makeToken()]); + const restoreFetch = withMockedFetch(async () => { + throw new Error("Upstream should not be called when Codex provider is disabled"); + }); + const server = await startApp( + makeConfigWithCodex(authDir, { + enabled: false, + "auth-file": path.join(authDir, "codex-auth.json"), + models: ["gpt-5.4"], + }), + manager + ); + + t.after(async () => { + restoreFetch(); + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/chat/completions", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + messages: [{ role: "user", content: "disabled codex" }], + stream: false, + }, + }); + + assert.equal(resp.status, 400); + assert.equal(resp.body.error.message, "Unsupported model: gpt-5.4"); +}); + +test("Codex models not listed in config are rejected", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); + writeCodexAuth(authDir); + const manager = makeManager(authDir, [makeToken()]); + const restoreFetch = withMockedFetch(async () => { + throw new Error("Upstream should not be called for disallowed Codex models"); + }); + const server = await startApp( + makeConfigWithCodex(authDir, { + enabled: true, + "auth-file": path.join(authDir, "codex-auth.json"), + models: ["gpt-5.4"], + }), + manager + ); + + t.after(async () => { + restoreFetch(); + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/responses", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "o3", + input: [{ role: "user", content: "not configured" }], + stream: false, + }, + }); + + assert.equal(resp.status, 400); + assert.equal(resp.body.error.message, "Unsupported model: o3"); +}); + test("rejects loading multiple accounts in single-account mode", (t) => { const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); t.after(() => { diff --git a/tests/startup.test.ts b/tests/startup.test.ts index 17e309b..7b3feed 100644 --- a/tests/startup.test.ts +++ b/tests/startup.test.ts @@ -105,3 +105,18 @@ test("allows startup when Claude auth is available even if Codex auth is missing fs.rmSync(authDir, { recursive: true, force: true }); } }); + +test("rejects startup when Codex auth exists but no Codex models are configured", () => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-startup-")); + + try { + writeCodexAuth(authDir); + const manager = loadManager(authDir); + const config = makeConfig(authDir); + config.codex.models = []; + + assert.equal(canStartServer(config, manager), false); + } finally { + fs.rmSync(authDir, { recursive: true, force: true }); + } +}); From 459baf9a16f659d44f816f5f43f37aafdbd9f72b Mon Sep 17 00:00:00 2001 From: wangyan Date: Mon, 30 Mar 2026 16:13:57 +0800 Subject: [PATCH 14/14] fix: finalize codex upstream compatibility --- README.md | 46 +++++++++++++ README_CN.md | 46 +++++++++++++ src/providers/codex-chat.ts | 14 ++-- src/providers/codex-request.ts | 33 +++++++++ src/providers/codex-responses.ts | 16 +++-- src/providers/codex-sse.ts | 112 +++++++++++++++++++++++++++++++ tests/codex-chat.test.ts | 108 +++++++++++++++++++++-------- tests/codex-responses.test.ts | 105 ++++++++++++++++++++++------- tests/codex-sse.test.ts | 33 +++++++++ 9 files changed, 451 insertions(+), 62 deletions(-) create mode 100644 src/providers/codex-request.ts create mode 100644 src/providers/codex-sse.ts create mode 100644 tests/codex-sse.test.ts diff --git a/README.md b/README.md index f64ce22..ef105e3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,17 @@ npm install npm run build ``` +## Quick start + +1. Copy `config.example.yaml` to `config.yaml`. +2. Set at least one API key under `api-keys`. +3. Pick one or both providers: + - Codex only: make sure `~/.codex/auth.json` already exists, then set `codex.models`. + - Claude: run `node dist/index.js --login` once. +4. Start the server with `node dist/index.js`. + +If you start with Codex only, Claude routes such as `/v1/messages` remain unavailable until you complete the Claude login flow. + ## Login Claude models still use auth2api's built-in OAuth login flow. Codex models do not have a separate login flow here — auth2api reuses the local Codex session from `~/.codex/auth.json`. @@ -73,6 +84,8 @@ The process can start with either provider: - Claude available via `node dist/index.js --login` - Codex available via an existing `~/.codex/auth.json` +If neither a Claude token nor a usable Codex configuration is available, auth2api exits at startup instead of serving partial misconfiguration. + If the configured Claude account is temporarily cooled down after upstream rate limiting, auth2api now returns `429 Rate limited on the configured account` instead of a generic `503`. ## Configuration @@ -122,6 +135,12 @@ The default request body limit is `200mb`, which is more suitable for large Clau Codex models exposed by `/v1/models` come from `codex.models`. Claude models are built in. +Important routing semantics: + +- `codex.enabled: false` disables all Codex routing. +- `codex.models` is both the `/v1/models` output and the runtime allowlist for Codex requests. +- A `gpt-*`, `o*`, or `codex-*` request for a model not listed in `codex.models` returns `400 Unsupported model`. + `debug` now supports three levels: - `off`: no extra logs - `errors`: log upstream/network failures and upstream error bodies @@ -147,6 +166,21 @@ curl http://127.0.0.1:8317/v1/chat/completions \ - `claude-*` -> Claude provider - `gpt-*`, `o*`, `codex-*` -> Codex provider +Unsupported or disallowed models return `400 Unsupported model`. + +Example Codex request: + +```bash +curl http://127.0.0.1:8317/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.4", + "messages": [{"role": "user", "content": "Summarize this repo."}], + "stream": false + }' +``` + ### Available models Claude models built into auth2api: @@ -198,6 +232,12 @@ Or with docker-compose: docker-compose up -d ``` +Container notes: + +- If you want Claude login persistence in Docker, set `auth-dir: "/data"` in `config.yaml`. +- If you want Codex models in Docker, mount the host auth file to the same path configured by `codex.auth-file`, for example `-v ~/.codex/auth.json:/root/.codex/auth.json:ro`. +- If you change the in-container path, update `codex.auth-file` to match. + ## Use with Claude Code Set `ANTHROPIC_BASE_URL` to point Claude Code at auth2api: @@ -231,6 +271,12 @@ curl http://127.0.0.1:8317/admin/accounts \ The response includes legacy Claude account snapshots plus separate `claude` and `codex` provider sections so you can see provider availability independently. +Useful fields: + +- `claude.available`: whether a Claude account is currently usable +- `codex.available`: whether Codex auth plus model configuration is usable +- `codex.details.error`: why Codex is currently unavailable, for example missing auth file or empty model configuration + ## Smoke tests A minimal automated smoke test suite is included and uses mocked upstream responses, so it does not call the real Claude or Codex services: diff --git a/README_CN.md b/README_CN.md index e13c369..1cee3f1 100644 --- a/README_CN.md +++ b/README_CN.md @@ -40,6 +40,17 @@ npm install npm run build ``` +## 快速开始 + +1. 复制 `config.example.yaml` 为 `config.yaml`。 +2. 在 `api-keys` 里至少配置一个 API key。 +3. 选择一个或两个 provider: + - 只用 Codex:确保本机已经有 `~/.codex/auth.json`,然后配置 `codex.models`。 + - 使用 Claude:先执行一次 `node dist/index.js --login`。 +4. 执行 `node dist/index.js` 启动服务。 + +如果你一开始只启用了 Codex,那么像 `/v1/messages` 这类 Claude 原生接口在完成 Claude 登录前仍然不可用。 + ## 登录 Claude 模型仍然使用 auth2api 内置的 OAuth 登录流程。Codex 模型没有单独的登录流程,auth2api 直接复用本机 `~/.codex/auth.json`。 @@ -73,6 +84,8 @@ node dist/index.js - Claude 可用:先执行 `node dist/index.js --login` - Codex 可用:本机已有 `~/.codex/auth.json` +如果 Claude token 不存在,且 Codex 配置或认证也不可用,auth2api 会在启动时直接退出,而不是带着错误配置继续提供服务。 + 如果上游因为限流导致当前账号进入 cooldown,auth2api 会返回 `429 Rate limited on the configured account`,而不是通用的 `503`。 ## 配置 @@ -122,6 +135,12 @@ codex: `/v1/models` 里的 Codex 模型来自 `codex.models` 配置;Claude 模型则是内置列表。 +几个关键语义: + +- `codex.enabled: false` 会彻底关闭 Codex 路由。 +- `codex.models` 既决定 `/v1/models` 的输出,也决定运行时允许访问的 Codex 模型白名单。 +- 如果请求的是 `gpt-*`、`o*`、`codex-*`,但模型不在 `codex.models` 里,会直接返回 `400 Unsupported model`。 + `debug` 现在支持三级日志: - `off`:不输出额外调试日志 - `errors`:记录上游/网络失败信息和上游错误响应正文 @@ -147,6 +166,21 @@ curl http://127.0.0.1:8317/v1/chat/completions \ - `claude-*` -> Claude provider - `gpt-*`、`o*`、`codex-*` -> Codex provider +不支持或未被允许的模型会返回 `400 Unsupported model`。 + +Codex 请求示例: + +```bash +curl http://127.0.0.1:8317/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.4", + "messages": [{"role": "user", "content": "Summarize this repo."}], + "stream": false + }' +``` + ### 支持的模型 auth2api 内置的 Claude 模型: @@ -198,6 +232,12 @@ docker run -d \ docker-compose up -d ``` +容器使用注意: + +- 如果你希望 Claude 登录态持久化,建议在 `config.yaml` 里把 `auth-dir` 设成 `"/data"`。 +- 如果你要在 Docker 里使用 Codex,需要把宿主机的 auth 文件挂载到容器内与 `codex.auth-file` 一致的路径,例如 `-v ~/.codex/auth.json:/root/.codex/auth.json:ro`。 +- 如果你改了容器内路径,记得同步修改 `codex.auth-file`。 + ## 与 Claude Code 配合使用 将 `ANTHROPIC_BASE_URL` 指向 auth2api: @@ -231,6 +271,12 @@ curl http://127.0.0.1:8317/admin/accounts \ 返回内容包含旧版 Claude 账号快照,以及拆开的 `claude`、`codex` provider 状态,便于分别判断哪一侧不可用。 +常用字段: + +- `claude.available`:当前 Claude 账号是否可用 +- `codex.available`:Codex 认证和模型配置是否可用 +- `codex.details.error`:Codex 当前不可用的具体原因,例如认证文件缺失或模型配置为空 + ## Smoke 测试 仓库内置了一套最小自动化 smoke test,并使用 mocked upstream response,因此不会调用真实 Claude 或 Codex 服务: diff --git a/src/providers/codex-chat.ts b/src/providers/codex-chat.ts index 9567217..4c46097 100644 --- a/src/providers/codex-chat.ts +++ b/src/providers/codex-chat.ts @@ -1,6 +1,8 @@ import express from "express"; import { v4 as uuidv4 } from "uuid"; import { CodexAuthError, CodexAuthStore } from "./codex-auth"; +import { normalizeCodexRequestBody } from "./codex-request"; +import { collectCodexResponseFromSse } from "./codex-sse"; import { callCodexResponses } from "./codex-upstream"; function authErrorResponse(message: string): { status: number; body: { error: { message: string } } } { @@ -138,11 +140,11 @@ function canonicalizeChatRequest(body: any, stream: boolean): any { })) : []; - return { + return normalizeCodexRequestBody({ model: body?.model || "gpt-5.4", input, stream, - }; + }); } async function streamCodexChatResponses(upstreamResp: Response, res: express.Response, model: string): Promise { @@ -299,7 +301,11 @@ export function createCodexChatCompletionsHandler(authStore: CodexAuthStore): ex const stream = !!body.stream; const canonicalRequest = canonicalizeChatRequest(body, stream); - const upstreamResp = await callCodexResponses(snapshot.accessToken, canonicalRequest, stream); + const upstreamRequest = { + ...canonicalRequest, + stream: true, + }; + const upstreamResp = await callCodexResponses(snapshot.accessToken, upstreamRequest, true); if (!upstreamResp.ok) { const text = await upstreamResp.text().catch(() => ""); res.status(upstreamResp.status).json({ @@ -313,7 +319,7 @@ export function createCodexChatCompletionsHandler(authStore: CodexAuthStore): ex return; } - const upstreamJson = await upstreamResp.json(); + const upstreamJson = await collectCodexResponseFromSse(upstreamResp); res.json(normalizeResponse(upstreamJson, canonicalRequest.model)); } catch (error: any) { res.status(500).json({ error: { message: error?.message || "Internal server error" } }); diff --git a/src/providers/codex-request.ts b/src/providers/codex-request.ts new file mode 100644 index 0000000..6838c6f --- /dev/null +++ b/src/providers/codex-request.ts @@ -0,0 +1,33 @@ +function normalizeRole(role: unknown): unknown { + if (role === "system") { + return "developer"; + } + return role; +} + +function normalizeInputItem(item: unknown): unknown { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return item; + } + + return { + ...item, + role: normalizeRole((item as { role?: unknown }).role), + }; +} + +export function normalizeCodexRequestBody(body: any): any { + const normalized = body && typeof body === "object" ? { ...body } : {}; + + if (typeof normalized.instructions !== "string") { + normalized.instructions = ""; + } + + normalized.store = false; + + if (Array.isArray(normalized.input)) { + normalized.input = normalized.input.map((item: unknown) => normalizeInputItem(item)); + } + + return normalized; +} diff --git a/src/providers/codex-responses.ts b/src/providers/codex-responses.ts index 043a9c2..9f22755 100644 --- a/src/providers/codex-responses.ts +++ b/src/providers/codex-responses.ts @@ -1,6 +1,8 @@ import express from "express"; import { v4 as uuidv4 } from "uuid"; import { CodexAuthError, CodexAuthStore } from "./codex-auth"; +import { normalizeCodexRequestBody } from "./codex-request"; +import { collectCodexResponseFromSse } from "./codex-sse"; import { callCodexResponses } from "./codex-upstream"; function normalizeOutput(upstream: any): any[] { @@ -90,8 +92,12 @@ async function streamCodexResponses(upstreamResp: Response, res: express.Respons export function createCodexResponsesHandler(authStore: CodexAuthStore): express.RequestHandler { return async (req, res): Promise => { try { - const body = req.body || {}; - const stream = !!body.stream; + const body = normalizeCodexRequestBody(req.body || {}); + const clientStream = !!body.stream; + const upstreamBody = { + ...body, + stream: true, + }; let snapshot; try { @@ -105,7 +111,7 @@ export function createCodexResponsesHandler(authStore: CodexAuthStore): express. throw error; } - const upstreamResp = await callCodexResponses(snapshot.accessToken, body, stream); + const upstreamResp = await callCodexResponses(snapshot.accessToken, upstreamBody, true); if (!upstreamResp.ok) { const text = await upstreamResp.text().catch(() => ""); res.status(upstreamResp.status).json({ @@ -114,12 +120,12 @@ export function createCodexResponsesHandler(authStore: CodexAuthStore): express. return; } - if (stream) { + if (clientStream) { await streamCodexResponses(upstreamResp, res); return; } - const upstreamJson = await upstreamResp.json(); + const upstreamJson = await collectCodexResponseFromSse(upstreamResp); res.json(normalizeResponse(upstreamJson, body.model || upstreamJson?.model || "gpt-5.4")); } catch (error: any) { res.status(500).json({ error: { message: error?.message || "Internal server error" } }); diff --git a/src/providers/codex-sse.ts b/src/providers/codex-sse.ts new file mode 100644 index 0000000..1b52223 --- /dev/null +++ b/src/providers/codex-sse.ts @@ -0,0 +1,112 @@ +function mergeResponseSnapshot(current: any, nextValue: any): any { + if (!nextValue || typeof nextValue !== "object" || Array.isArray(nextValue)) { + return current; + } + + return { + ...current, + ...nextValue, + usage: nextValue.usage || current?.usage, + output: nextValue.output || current?.output, + content: nextValue.content || current?.content, + }; +} + +function buildOutputFromText(outputText: string, status: string): any[] { + if (!outputText) { + return []; + } + + return [{ + type: "message", + role: "assistant", + status, + content: [ + { + type: "output_text", + text: outputText, + annotations: [], + }, + ], + }]; +} + +export async function collectCodexResponseFromSse(upstreamResp: Response): Promise { + const contentType = upstreamResp.headers.get("content-type") || ""; + if (/application\/json/i.test(contentType)) { + return upstreamResp.json(); + } + + const reader = upstreamResp.body?.getReader(); + if (!reader) { + throw new Error("Codex upstream response body is unavailable"); + } + + const decoder = new TextDecoder(); + let buffer = ""; + let currentEvent = ""; + let response: any = {}; + let outputText = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + if (!line) { + currentEvent = ""; + continue; + } + + if (line.startsWith("event:")) { + currentEvent = line.slice(6).trim(); + continue; + } + + if (!line.startsWith("data:")) { + continue; + } + + const dataStr = line.slice(5).trimStart(); + if (!dataStr || dataStr === "[DONE]") { + continue; + } + + let data: any; + try { + data = JSON.parse(dataStr); + } catch { + continue; + } + + if (currentEvent === "response.created") { + response = mergeResponseSnapshot(response, data?.response || data); + continue; + } + + if (currentEvent === "response.output_text.delta") { + if (typeof data?.delta === "string") { + outputText += data.delta; + } + continue; + } + + if (currentEvent === "response.completed") { + response = mergeResponseSnapshot(response, data?.response || data); + } + } + } + + if (!response.output && !response.content) { + response.output = buildOutputFromText(outputText, response.status || "completed"); + } + + return response; +} diff --git a/tests/codex-chat.test.ts b/tests/codex-chat.test.ts index b859072..8fde075 100644 --- a/tests/codex-chat.test.ts +++ b/tests/codex-chat.test.ts @@ -123,46 +123,47 @@ async function requestJson(options: { }); } +function makeStreamResponse(chunks: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + test("Codex chat completions sends bearer token upstream and canonicalizes chat messages", async (t) => { const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-chat-")); const authFile = path.join(authDir, ".codex", "auth.json"); writeAuth(authFile, "codex-access-token"); const provider = new CodexProvider(makeConfig(authDir, authFile)); - const calls: Array<{ url: string; auth?: string; body: any }> = []; + const calls: Array<{ url: string; auth?: string; accept?: string; body: any }> = []; const restoreFetch = global.fetch; global.fetch = (async (input, init) => { const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + const headers = init?.headers as Record | undefined; calls.push({ url: String(input), - auth: init?.headers && (init.headers as Record).Authorization, + auth: headers?.Authorization, + accept: headers?.Accept, body, }); - return new Response( - JSON.stringify({ - id: "resp_123", - object: "response", - created_at: 1711756800, - status: "completed", - model: "gpt-5.4", - output: [{ - type: "message", - id: "msg_123", - role: "assistant", - status: "completed", - content: [ - { type: "output_text", text: "hello from codex", annotations: [] }, - ], - }], - usage: { - input_tokens: 12, - output_tokens: 8, - total_tokens: 20, - }, - }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); + return makeStreamResponse([ + "event: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":1}\n\n", + "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":2,\"delta\":\"hello from codex\"}\n\n", + "event: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":3,\"response\":{\"status\":\"completed\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":12,\"output_tokens\":8,\"total_tokens\":20}}}\n\n", + "event: response.done\ndata: {\"type\":\"response.done\",\"sequence_number\":4}\n\n", + ]); }) as typeof fetch; const server = await startApp(provider.handleChatCompletions()); @@ -187,10 +188,13 @@ test("Codex chat completions sends bearer token upstream and canonicalizes chat assert.equal(calls[0]?.url, "https://chatgpt.com/backend-api/codex/responses"); assert.equal(calls[0]?.auth, "Bearer codex-access-token"); + assert.equal(calls[0]?.accept, "text/event-stream"); assert.deepEqual(calls[0]?.body, { model: "gpt-5.4", + instructions: "", + store: false, input: [{ role: "user", content: "hello" }], - stream: false, + stream: true, }); assert.equal(resp.status, 200); assert.equal(resp.body.object, "chat.completion"); @@ -200,6 +204,56 @@ test("Codex chat completions sends bearer token upstream and canonicalizes chat assert.equal(resp.body.usage.total_tokens, 20); }); +test("Codex chat completions converts system messages to developer role", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-chat-system-")); + const authFile = path.join(authDir, ".codex", "auth.json"); + writeAuth(authFile, "codex-access-token"); + const provider = new CodexProvider(makeConfig(authDir, authFile)); + const calls: Array<{ body: any }> = []; + + const restoreFetch = global.fetch; + global.fetch = (async (_input, init) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + calls.push({ body }); + + return makeStreamResponse([ + "event: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":1}\n\n", + "event: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":2,\"response\":{\"status\":\"completed\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n", + "event: response.done\ndata: {\"type\":\"response.done\",\"sequence_number\":3}\n\n", + ]); + }) as typeof fetch; + + const server = await startApp(provider.handleChatCompletions()); + + t.after(async () => { + global.fetch = restoreFetch; + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/chat/completions", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + messages: [ + { role: "system", content: "be precise" }, + { role: "user", content: "hello" }, + ], + stream: false, + }, + }); + + assert.equal(resp.status, 200); + assert.equal(calls[0]?.body.instructions, ""); + assert.equal(calls[0]?.body.store, false); + assert.equal(calls[0]?.body.stream, true); + assert.equal(calls[0]?.body.input[0].role, "developer"); + assert.equal(calls[0]?.body.input[1].role, "user"); +}); + test("Codex chat completions returns controlled error when auth file is missing", async (t) => { const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-chat-missing-")); const authFile = path.join(authDir, ".codex", "auth.json"); diff --git a/tests/codex-responses.test.ts b/tests/codex-responses.test.ts index ead728e..9dd5f7f 100644 --- a/tests/codex-responses.test.ts +++ b/tests/codex-responses.test.ts @@ -123,46 +123,47 @@ async function requestJson(options: { }); } +function makeStreamResponse(chunks: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + test("Codex responses handler sends bearer token and maps response", async (t) => { const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-responses-")); const authFile = path.join(authDir, ".codex", "auth.json"); writeAuth(authFile, "codex-access-token"); const provider = new CodexProvider(makeConfig(authDir, authFile)); - const calls: Array<{ url: string; auth?: string; body: any }> = []; + const calls: Array<{ url: string; auth?: string; accept?: string; body: any }> = []; const restoreFetch = global.fetch; global.fetch = (async (input, init) => { const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + const headers = init?.headers as Record | undefined; calls.push({ url: String(input), - auth: init?.headers && (init.headers as Record).Authorization, + auth: headers?.Authorization, + accept: headers?.Accept, body, }); - return new Response( - JSON.stringify({ - id: "resp_123", - object: "response", - created_at: 1711756800, - status: "completed", - model: "gpt-5.4", - output: [{ - type: "message", - id: "msg_123", - role: "assistant", - status: "completed", - content: [ - { type: "output_text", text: "hello from codex", annotations: [] }, - ], - }], - usage: { - input_tokens: 12, - output_tokens: 8, - total_tokens: 20, - }, - }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); + return makeStreamResponse([ + "event: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":1,\"response\":{\"id\":\"resp_123\",\"object\":\"response\",\"created_at\":1711756800,\"status\":\"in_progress\",\"model\":\"gpt-5.4\"}}\n\n", + "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":2,\"delta\":\"hello from codex\"}\n\n", + "event: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":3,\"response\":{\"id\":\"resp_123\",\"object\":\"response\",\"created_at\":1711756800,\"status\":\"completed\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":12,\"output_tokens\":8,\"total_tokens\":20}}}\n\n", + "event: response.done\ndata: {\"type\":\"response.done\",\"sequence_number\":4}\n\n", + ]); }) as typeof fetch; const server = await startApp(provider.handleResponses()); @@ -186,7 +187,11 @@ test("Codex responses handler sends bearer token and maps response", async (t) = assert.equal(calls[0]?.url, "https://chatgpt.com/backend-api/codex/responses"); assert.equal(calls[0]?.auth, "Bearer codex-access-token"); + assert.equal(calls[0]?.accept, "text/event-stream"); assert.equal(calls[0]?.body.model, "gpt-5.4"); + assert.equal(calls[0]?.body.instructions, ""); + assert.equal(calls[0]?.body.store, false); + assert.equal(calls[0]?.body.stream, true); assert.equal(resp.status, 200); assert.equal(resp.body.object, "response"); assert.equal(resp.body.model, "gpt-5.4"); @@ -194,6 +199,54 @@ test("Codex responses handler sends bearer token and maps response", async (t) = assert.equal(resp.body.usage.total_tokens, 20); }); +test("Codex responses handler converts system input role to developer", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-responses-system-")); + const authFile = path.join(authDir, ".codex", "auth.json"); + writeAuth(authFile, "codex-access-token"); + const provider = new CodexProvider(makeConfig(authDir, authFile)); + const calls: Array<{ body: any }> = []; + + const restoreFetch = global.fetch; + global.fetch = (async (_input, init) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + calls.push({ body }); + + return makeStreamResponse([ + "event: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":1}\n\n", + "event: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":2,\"response\":{\"status\":\"completed\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n", + "event: response.done\ndata: {\"type\":\"response.done\",\"sequence_number\":3}\n\n", + ]); + }) as typeof fetch; + + const server = await startApp(provider.handleResponses()); + + t.after(async () => { + global.fetch = restoreFetch; + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/responses", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-5.4", + input: [ + { role: "system", content: "be precise" }, + { role: "user", content: "hello" }, + ], + }, + }); + + assert.equal(resp.status, 200); + assert.equal(calls[0]?.body.store, false); + assert.equal(calls[0]?.body.stream, true); + assert.equal(calls[0]?.body.input[0].role, "developer"); + assert.equal(calls[0]?.body.input[1].role, "user"); +}); + test("Codex responses handler returns controlled error when auth file is missing", async (t) => { const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-codex-responses-missing-")); const authFile = path.join(authDir, ".codex", "auth.json"); diff --git a/tests/codex-sse.test.ts b/tests/codex-sse.test.ts new file mode 100644 index 0000000..389c6ba --- /dev/null +++ b/tests/codex-sse.test.ts @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { collectCodexResponseFromSse } from "../src/providers/codex-sse"; + +function makeResponse(chunks: string[], contentType?: string): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); + + return new Response(stream, { + status: 200, + headers: contentType ? { "Content-Type": contentType } : undefined, + }); +} + +test("collectCodexResponseFromSse parses SSE even when content-type is omitted", async () => { + const response = await collectCodexResponseFromSse(makeResponse([ + "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\",\"object\":\"response\",\"status\":\"in_progress\",\"model\":\"gpt-5.4\"}}\n\n", + "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n", + "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_123\",\"object\":\"response\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n", + ])); + + assert.equal(response.id, "resp_123"); + assert.equal(response.output[0].content[0].text, "ok"); + assert.equal(response.usage.total_tokens, 2); +});