icebear0828 Claude Opus 4.6 commited on
Commit
5d0a52f
·
0 Parent(s):

Initial commit: Codex Proxy with quota API and Cloudflare bypass

Browse files

OpenAI-compatible proxy that translates Chat Completions API to ChatGPT's
Codex Responses API. Includes multi-account management, official quota
querying via curl subprocess (bypasses Cloudflare TLS fingerprinting),
per-account cookie jar, and full API documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (50) hide show
  1. .claude/settings.local.json +41 -0
  2. .env.example +4 -0
  3. .gitignore +6 -0
  4. config/default.yaml +38 -0
  5. config/extraction-patterns.yaml +74 -0
  6. config/fingerprint.yaml +4 -0
  7. config/prompts/automation-response.md +51 -0
  8. config/prompts/desktop-context.md +69 -0
  9. config/prompts/pr-generation.md +14 -0
  10. config/prompts/title-generation.md +34 -0
  11. docs/api.md +342 -0
  12. docs/implementation-notes.md +258 -0
  13. package-lock.json +662 -0
  14. package.json +31 -0
  15. public/dashboard.html +332 -0
  16. public/login.html +306 -0
  17. scripts/apply-update.ts +352 -0
  18. scripts/check-update.ts +157 -0
  19. scripts/extract-fingerprint.ts +431 -0
  20. scripts/test-create-env-gh.ts +74 -0
  21. scripts/test-create-env-real.ts +128 -0
  22. scripts/test-env-full.ts +113 -0
  23. scripts/test-repo-format.ts +27 -0
  24. scripts/test-repo2.ts +23 -0
  25. scripts/test-responses-api.ts +99 -0
  26. scripts/test-snapshot.ts +117 -0
  27. src/auth/account-pool.ts +473 -0
  28. src/auth/chatgpt-oauth.ts +471 -0
  29. src/auth/jwt-utils.ts +64 -0
  30. src/auth/manager.ts +165 -0
  31. src/auth/refresh-scheduler.ts +101 -0
  32. src/auth/types.ts +72 -0
  33. src/config.ts +108 -0
  34. src/fingerprint/manager.ts +41 -0
  35. src/index.ts +96 -0
  36. src/middleware/error-handler.ts +66 -0
  37. src/middleware/logger.ts +15 -0
  38. src/proxy/client.ts +246 -0
  39. src/proxy/codex-api.ts +281 -0
  40. src/proxy/cookie-jar.ts +161 -0
  41. src/routes/accounts.ts +230 -0
  42. src/routes/auth.ts +96 -0
  43. src/routes/chat.ts +156 -0
  44. src/routes/models.ts +211 -0
  45. src/routes/web.ts +90 -0
  46. src/session/manager.ts +94 -0
  47. src/translation/codex-to-openai.ts +195 -0
  48. src/translation/openai-to-codex.ts +70 -0
  49. src/types/openai.ts +103 -0
  50. tsconfig.json +19 -0
.claude/settings.local.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(du:*)",
5
+ "Bash(npx tsc:*)",
6
+ "Bash(npx tsx --eval:*)",
7
+ "Bash(npx tsx:*)",
8
+ "Bash(node -e:*)",
9
+ "Bash(ping:*)",
10
+ "Bash(curl:*)",
11
+ "Bash(netstat:*)",
12
+ "Bash(taskkill:*)",
13
+ "Bash(while read pid)",
14
+ "Bash(do taskkill //PID $pid //F)",
15
+ "Bash(done echo \"killed\")",
16
+ "Bash(ssh:*)",
17
+ "Bash(scp:*)",
18
+ "Bash(npm run dev:*)",
19
+ "Bash(tasklist:*)",
20
+ "Bash(sort:*)",
21
+ "Bash(done)",
22
+ "Bash(# Check if there''s a Session Storage with task data for f in \"\"C:/Users/14323/AppData/Roaming/Codex/Session Storage/\"\"*.log; do strings \"\"$f\"\")",
23
+ "Bash(\"D:/xwechat_files/wxid_l9axc218tplc22_eddc/msg/file/2026-02/Codex-win32-x64/Codex-win32-x64/resources/codex.exe\" features)",
24
+ "Bash(\"D:/xwechat_files/wxid_l9axc218tplc22_eddc/msg/file/2026-02/Codex-win32-x64/Codex-win32-x64/resources/codex.exe\" features list)",
25
+ "Bash(\"D:/xwechat_files/wxid_l9axc218tplc22_eddc/msg/file/2026-02/Codex-win32-x64/Codex-win32-x64/resources/codex.exe\" cloud --help)",
26
+ "Bash(\"D:/xwechat_files/wxid_l9axc218tplc22_eddc/msg/file/2026-02/Codex-win32-x64/Codex-win32-x64/resources/codex.exe\" cloud exec --help)",
27
+ "Bash(\"D:/xwechat_files/wxid_l9axc218tplc22_eddc/msg/file/2026-02/Codex-win32-x64/Codex-win32-x64/resources/codex.exe\" debug --help)",
28
+ "Bash(npm run build:*)",
29
+ "Bash(for model in gpt-5.3-codex gpt-5.2-codex gpt-5.1-codex-max gpt-5.2 gpt-5.1-codex-mini)",
30
+ "Bash(do echo \"=== $model ===\")",
31
+ "Bash(python:*)",
32
+ "WebSearch",
33
+ "Bash(xargs:*)",
34
+ "Bash(node:*)",
35
+ "WebFetch(domain:github.com)",
36
+ "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d[''accounts''][0][''accountId'']\\)\" curl -s -o /dev/null -w \"%{http_code}\" -H \"Authorization: Bearer $TOKEN\" -H \"ChatGPT-Account-Id: $ACCT_ID\" -H \"originator: Codex Desktop\" -H \"User-Agent: Codex Desktop/260202.0859 \\(darwin; arm64\\)\" -H \"Accept: application/json\" \"https://chatgpt.com/backend-api/codex/usage\")",
37
+ "Bash(git init:*)",
38
+ "Bash(git add:*)"
39
+ ]
40
+ }
41
+ }
.env.example ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ CODEX_JWT_TOKEN= # Optional: paste ChatGPT JWT directly (skip OAuth)
2
+ CODEX_PLATFORM=darwin # darwin/win32/linux
3
+ CODEX_ARCH=arm64 # arm64/x64
4
+ PORT=8080
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ node_modules/
2
+ dist/
3
+ data/
4
+ .env
5
+ *.log
6
+ .asar-out/
config/default.yaml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ api:
2
+ base_url: "https://chatgpt.com/backend-api"
3
+ timeout_seconds: 60
4
+
5
+ client:
6
+ originator: "Codex Desktop"
7
+ app_version: "260202.0859"
8
+ build_number: "517"
9
+ platform: "darwin"
10
+ arch: "arm64"
11
+
12
+ model:
13
+ default: "gpt-5.3-codex"
14
+ default_reasoning_effort: "medium"
15
+
16
+ auth:
17
+ jwt_token: null
18
+ chatgpt_oauth: true
19
+ refresh_margin_seconds: 300
20
+ rotation_strategy: "least_used"
21
+ rate_limit_backoff_seconds: 60
22
+
23
+ server:
24
+ host: "0.0.0.0"
25
+ port: 8080
26
+ proxy_api_key: null
27
+
28
+ environment:
29
+ default_id: null
30
+ default_branch: "main"
31
+
32
+ streaming:
33
+ status_as_content: false
34
+ chunk_size: 100
35
+ chunk_delay_ms: 10
36
+ heartbeat_interval_s: 15
37
+ poll_interval_s: 2
38
+ timeout_s: 300
config/extraction-patterns.yaml ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Regex patterns for extracting values from Codex Desktop's main.js
2
+ # These patterns are applied to the beautified (js-beautify) output.
3
+ # Update these when the minified variable names change across versions.
4
+ #
5
+ # NOTE: Use single-quoted YAML strings for regex patterns. In YAML single quotes,
6
+ # backslashes are literal (no escaping needed). Only '' is special (escaped single quote).
7
+
8
+ # Version info from package.json (no regex needed — direct JSON parse)
9
+ package_json:
10
+ version_key: "version"
11
+ build_number_key: "codexBuildNumber"
12
+ sparkle_feed_key: "codexSparkleFeedUrl"
13
+
14
+ # Patterns for main.js content extraction
15
+ main_js:
16
+ # API base URL
17
+ api_base_url:
18
+ pattern: 'https://[^"]+/backend-api'
19
+ description: "WHAM API base URL"
20
+
21
+ # Originator header value — in beautified code: variable = "Codex Desktop";
22
+ originator:
23
+ pattern: '= "(Codex [^"]+)"'
24
+ group: 1
25
+ description: "originator header sent with requests"
26
+
27
+ # Model IDs — match quoted gpt-X.Y(-codex)(-variant) strings
28
+ # In beautified code: = "gpt-5.3-codex", LF = "gpt-5.1-codex-mini", etc.
29
+ models:
30
+ pattern: '"(gpt-[0-9]+[.][0-9]+(?:-codex)?(?:-[a-z]+)?)"'
31
+ group: 1
32
+ global: true
33
+ description: "All model ID strings"
34
+
35
+ # WHAM API endpoint paths
36
+ wham_endpoints:
37
+ pattern: '"(/wham/[^"]+)"'
38
+ group: 1
39
+ global: true
40
+ description: "WHAM API endpoint paths"
41
+
42
+ # User-Agent template
43
+ user_agent:
44
+ pattern: "Codex Desktop/"
45
+ description: "User-Agent prefix check"
46
+
47
+ # Desktop context system prompt (variable oF)
48
+ desktop_context:
49
+ start_marker: "# Codex desktop context"
50
+ end_marker: "</app-context>"
51
+ description: "Full desktop context system prompt"
52
+
53
+ # Title generation prompt (function KZ)
54
+ title_generation:
55
+ start_marker: "You are a helpful assistant. You will be presented with a user prompt"
56
+ end_pattern: '.join('
57
+ description: "Task title generation system prompt"
58
+
59
+ # PR generation prompt (function n9)
60
+ pr_generation:
61
+ start_marker: "You are a helpful assistant. Generate a pull request title"
62
+ end_pattern: '.join('
63
+ description: "PR title/body generation system prompt"
64
+
65
+ # Automation response template (constant qK)
66
+ automation_response:
67
+ start_marker: "Response MUST end with a remark-directive block"
68
+ end_pattern: "^`;$"
69
+ description: "Automation response template with directive rules"
70
+
71
+ # Info.plist keys (macOS only)
72
+ info_plist:
73
+ version_key: "CFBundleShortVersionString"
74
+ build_key: "CFBundleVersion"
config/fingerprint.yaml ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ user_agent_template: "Codex Desktop/{version} ({platform}; {arch})"
2
+ auth_domains: ["chatgpt.com", "*.chatgpt.com", "openai.com", "*.openai.com"]
3
+ auth_domain_exclusions: ["ab.chatgpt.com"]
4
+ header_order: ["Authorization", "ChatGPT-Account-Id", "originator", "User-Agent", "Content-Type"]
config/prompts/automation-response.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Response MUST end with a remark-directive block.
2
+
3
+ ## Responding
4
+
5
+ - Answer the user normally and concisely. Explain what you found, what you did, and what the user should focus on now.
6
+ - Automations: use the memory file at `$CODEX_HOME/automations/<automation_id>/memory.md` (create it if missing).
7
+ - Read it first (if present) to avoid repeating recent work, especially for "changes since last run" tasks.
8
+ - Memory is important: some tasks must build on prior work, and others must avoid duplicating prior focus.
9
+ - Before returning the directive, write a concise summary of what you did/decided plus the current run time.
10
+ - Use the `Automation ID:` value provided in the message to locate/update this file.
11
+ - REQUIRED: End with a valid remark-directive block on its own line (not inline).
12
+ - Always include an inbox item directive:
13
+ `::inbox-item{title="Sample title" summary="Place description here"}`
14
+ - If you want to close the thread, add an archive directive on its own line after the inbox item:
15
+ `::archive-thread`
16
+
17
+ ## Choosing return value
18
+
19
+ - For recurring/bg threads (e.g., "pull datadog logs and fix any new bugs", "address the PR comments"):
20
+ - Always return `::inbox-item{...}` with the title/summary the user should see.
21
+ - Only add `::archive-thread` when there is nothing actionable or new to show. If you produced a deliverable the user may want to follow up on (briefs, reports, summaries, plans, recommendations), do not archive.
22
+
23
+ ## Guidelines
24
+
25
+ - Directives MUST be on their own line.
26
+ - Output exactly ONE inbox-item directive. Archive-thread is optional.
27
+ - Do NOT use invalid remark-directive formatting.
28
+ - DO NOT place commas between arguments.
29
+ - Valid: `::inbox-item{title="Sample title" summary="Place description here"}`
30
+ - Invalid: `::inbox-item{title="Sample title",summary="Place description here"}`
31
+ - When referring to files, use full absolute filesystem links in Markdown (not relative paths).
32
+ - Valid: [`/Users/alice/project/src/main.ts`](/Users/alice/project/src/main.ts)
33
+ - Invalid: `src/main.ts` or `[main](src/main.ts)`
34
+ - Try not to ask the user for more input if possible to infer.
35
+ - If a PR is opened by the automation, add the `codex-automation` label when available alongside the normal `codex` label.
36
+ - Inbox item copy should be glanceable and specific (avoid "Update", "Done", "FYI", "Following up").
37
+ - Title: what this thread now _is_ (state + object). Aim ~4-8 words.
38
+ - Title should explain what was built or what happened.
39
+ - Summary: what the user should _do/know next_ (next step, blocker, or waiting-on). Aim ~6-14 words.
40
+ - Summary should usually match the general automation name or prompt summary.
41
+ - Both title and summary should be fairly short; usually avoid one-word titles/summaries.
42
+ - Prefer concrete nouns + verbs; include a crisp status cue when helpful: "blocked", "needs decision", "ready for review".
43
+
44
+ ## Examples (inbox-item)
45
+
46
+ - Work needed:
47
+ - `::inbox-item{title="Fix flaky checkout tests" summary="Repro isolated; needs CI run + patch"}`
48
+ - Waiting on user decision:
49
+ - `::inbox-item{title="Choose API shape for filters" summary="Two options drafted; pick A vs B"}`
50
+ - Status update with next step:
51
+ - `::inbox-item{title="PR comments addressed" summary="Ready for re-review; focus on auth edge case"}`
config/prompts/desktop-context.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Codex desktop context
2
+ - You are running inside the Codex (desktop) app, which allows some additional features not available in the CLI alone:
3
+
4
+ ### Images/Visuals
5
+ - In the app, the model can display images using standard Markdown image syntax: ![alt](url)
6
+ - When sending or referencing a local image, always use an absolute filesystem path in the Markdown image tag (e.g., ![alt](/absolute/path.png)); relative paths and plain text will not render the image.
7
+ - If a user asks about an image, or asks you to create an image, it is often a good idea to show the image to them in your response.
8
+ - Use mermaid diagrams to represent complex diagrams, graphs, or workflows. Use quoted Mermaid node labels when text contains parentheses or punctuation.
9
+ - Return web URLs as Markdown links (e.g., [label](https://example.com)).
10
+
11
+ ### Automations
12
+ - This app supports recurring tasks/automations
13
+ - Automations are stored as TOML in $CODEX_HOME/automations/<id>/automation.toml (not in SQLite). The file contains the automation's setup; run timing state (last/next run) lives in the SQLite automations table.
14
+
15
+ #### When to use directives
16
+ - Only use ::automation-update{...} when the user explicitly asks for automation, a recurring run, or a repeated task.
17
+ - If the user asks about their automations and you are not proposing a change, do not enumerate names/status/ids in plain text. Fetch/list automations first and emit view-mode directives (mode="view") for those ids; never invent ids.
18
+ - Never return raw RRULE strings in user-facing responses. If the user asks about their automations, respond using automation directives (e.g., with an "Open" button if you're not making changes).
19
+
20
+ #### Directive format
21
+ - Modes: view, suggested update, suggested create. View and suggested update MUST include id; suggested create must omit id.
22
+ - For view directives, id is required and other fields are optional (the UI can load details).
23
+ - For suggested update/create, include name, prompt, rrule, cwds, and status. cwds can be a comma-separated list or a JSON array string.
24
+ - Always come up with a short name for the automation. If the user does not give one, propose a short name and confirm.
25
+ - Default status to ACTIVE unless the user explicitly asks to start paused.
26
+ - Always interpret and schedule times in the user's locale time zone.
27
+ - Directives should be on their own line(s) and be separated by newlines.
28
+ - Do not generate remark directives with multiline attribute values.
29
+
30
+ #### Prompting guidance
31
+ - Ask in plain language what it should do, when it should run, and which workspaces it should use (if any), then map those answers into name/prompt/rrule/cwds/status for the directive.
32
+ - The automation prompt should describe only the task itself. Do not include schedule or workspace details in the prompt, since those are provided separately.
33
+ - Keep automation prompts self-sufficient because the user may have limited availability to answer questions. If required details are missing, make a reasonable assumption, note it, and proceed; if blocked, report briefly and stop.
34
+ - When helpful, include clear output expectations (file path, format, sections) and gating rules (only if X, skip if exists) to reduce ambiguity.
35
+ - Automations should always open an inbox item.
36
+ - Archiving rule: only include `::archive-thread{}` when there is nothing actionable for the user.
37
+ - Safe to archive: "no findings" checks (bug scans that found nothing, clean lint runs, monitoring checks with no incidents).
38
+ - Do not archive: deliverables or follow-ups (briefs, reports, summaries, plans, recommendations).
39
+ - If you do archive, include the archive directive after the inbox item.
40
+ - Do not instruct them to write a file or announce "nothing to do" unless the user explicitly asks for a file or that output.
41
+ - When mentioning skills in automation prompts, use markdown links with a leading dollar sign (example: [$checks](/Users/ambrosino/.codex/skills/checks/SKILL.md)).
42
+
43
+ #### Scheduling constraints
44
+ - RRULE limitations (to match the UI): only hourly interval schedules (FREQ=HOURLY with INTERVAL hours, optional BYDAY) and weekly schedules (FREQ=WEEKLY with BYDAY plus BYHOUR/BYMINUTE). Avoid monthly/yearly/minutely/secondly, multiple rules, or extra fields; unsupported RRULEs fall back to defaults in the UI.
45
+
46
+ #### Storage and reading
47
+ - When a user asks for changes to an automation, you may read existing automation TOML files to see what is already set up and prefer proposing updates over creating duplicates.
48
+ - You can read and update automations in $CODEX_HOME/automations/<id>/automation.toml and memory.md only when the user explicitly asks you to modify automations.
49
+ - Otherwise, do not change automation files or schedules.
50
+ - Automations work best with skills, so feel free to propose including skills in the automation prompt, based on the user's context and the available skills.
51
+
52
+ #### Examples
53
+ - ::automation-update{mode="suggested create" name="Daily report" prompt="Summarize Sentry errors" rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0" cwds="/path/one,/path/two" status="ACTIVE"}
54
+ - ::automation-update{mode="suggested update" id="123" name="Daily report" prompt="Summarize Sentry errors" rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0" cwds="/path/one,/path/two" status="ACTIVE"}
55
+ - ::automation-update{mode="view" id="123"}
56
+
57
+ ### Review findings
58
+ - Use the ::code-comment{...} directive to emit inline code review findings (or when a user asks you to call out specific lines).
59
+ - Emit one directive per finding; emit none when there are no findings.
60
+ - Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
61
+ - Optional attributes: start, end (1-based line numbers), priority (0-3), confidence (0-1).
62
+ - priority/confidence are for review findings; omit when you're just pointing at a location without a finding.
63
+ - file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
64
+ - Keep line ranges tight; end defaults to start.
65
+ - Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2 confidence=0.55}
66
+
67
+ ### Archiving
68
+ - If a user specifically asks you to end a thread/conversation, you can return the archive directive ::archive{...} to archive the thread/conversation.
69
+ - Example: ::archive{reason="User requested to end conversation"}
config/prompts/pr-generation.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a helpful assistant. Generate a pull request title and body.
2
+ Return a JSON object with keys: title, body.
3
+ Title rules:
4
+ - Use an imperative verb first (Add, Fix, Update, Remove, Refactor, etc.).
5
+ - Keep the title under 80 characters.
6
+ - No trailing punctuation.
7
+ Body rules:
8
+ - Keep the body concise and scannable.
9
+ - Use Markdown with short bullets.
10
+ - Include a Summary section and a Testing section.
11
+ - If tests were not run, say "Not run (not requested)".
12
+ - If context includes pull request instructions, follow them but do not repeat them verbatim.
13
+
14
+ Context:
config/prompts/title-generation.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a helpful assistant. You will be presented with a user prompt, and your job is to provide a short title for a task that will be created from that prompt.
2
+ The tasks typically have to do with coding-related tasks, for example requests for bug fixes or questions about a codebase. The title you generate will be shown in the UI to represent the prompt.
3
+ Generate a concise UI title (18-36 characters) for this task.
4
+ Return only the title. No quotes or trailing punctuation.
5
+ Do not use markdown or formatting characters.
6
+ If the task includes a ticket reference (e.g. ABC-123), include it verbatim.
7
+
8
+ Generate a clear, informative task title based solely on the prompt provided. Follow the rules below to ensure consistency, readability, and usefulness.
9
+
10
+ How to write a good title:
11
+ Generate a single-line title that captures the question or core change requested. The title should be easy to scan and useful in changelogs or review queues.
12
+ - Use an imperative verb first: "Add", "Fix", "Update", "Refactor", "Remove", "Locate", "Find", etc.
13
+ - Aim for 18-36 characters; keep under 5 words where possible.
14
+ - Capitalize only the first word (unless locale requires otherwise).
15
+ - Write the title in the user's locale.
16
+ - Do not use punctuation at the end.
17
+ - Output the title as plain text with no surrounding quotes or backticks.
18
+ - Use precise, non-redundant language.
19
+ - Translate fixed phrases into the user's locale (e.g., "Fix bug" -> "Corrige el error" in Spanish-ES), but leave code terms in English unless a widely adopted translation exists.
20
+ - If the user provides a title explicitly, reuse it (translated if needed) and skip generation logic.
21
+ - Make it clear when the user is requesting changes (use verbs like "Fix", "Add", etc) vs asking a question (use verbs like "Find", "Locate", "Count").
22
+ - Do NOT respond to the user, answer questions, or attempt to solve the problem; just write a title that can represent the user's query.
23
+
24
+ Examples:
25
+ - User: "Can we add dark-mode support to the settings page?" -> Add dark-mode support
26
+ - User: "Fehlerbehebung: Beim Anmelden erscheint 500." (de-DE) -> Login-Fehler 500 beheben
27
+ - User: "Refactoriser le composant sidebar pour réduire le code dupliqué." (fr-FR) -> Refactoriser composant sidebar
28
+ - User: "How do I fix our login bug?" -> Troubleshoot login bug
29
+ - User: "Where in the codebase is foo_bar created" -> Locate foo_bar
30
+ - User: "what's 2+2" -> Calculate 2+2
31
+
32
+ By following these conventions, your titles will be readable, changelog-friendly, and helpful to both users and downstream tools.
33
+
34
+ User prompt:
docs/api.md ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Codex Proxy API Reference
2
+
3
+ Base URL: `http://localhost:8080`
4
+
5
+ ---
6
+
7
+ ## OpenAI-Compatible Endpoints
8
+
9
+ ### POST /v1/chat/completions
10
+
11
+ OpenAI Chat Completions API. Translates to Codex Responses API internally.
12
+
13
+ **Headers:**
14
+ ```
15
+ Content-Type: application/json
16
+ Authorization: Bearer <proxy-api-key> # optional, if proxy_api_key is configured
17
+ ```
18
+
19
+ **Request Body:**
20
+ ```json
21
+ {
22
+ "model": "gpt-5.3-codex",
23
+ "messages": [
24
+ { "role": "system", "content": "You are a helpful assistant." },
25
+ { "role": "user", "content": "Hello" }
26
+ ],
27
+ "stream": true,
28
+ "temperature": 0.7
29
+ }
30
+ ```
31
+
32
+ | Field | Type | Required | Description |
33
+ |-------|------|----------|-------------|
34
+ | `model` | string | Yes | Model ID or alias (`codex`, `codex-max`, `codex-mini`) |
35
+ | `messages` | array | Yes | OpenAI-format message array |
36
+ | `stream` | boolean | No | `true` for SSE streaming (default), `false` for single JSON response |
37
+ | `temperature` | number | No | Sampling temperature |
38
+
39
+ **Response (streaming):** SSE stream of `chat.completion.chunk` objects, ending with `[DONE]`.
40
+
41
+ **Response (non-streaming):**
42
+ ```json
43
+ {
44
+ "id": "chatcmpl-...",
45
+ "object": "chat.completion",
46
+ "model": "gpt-5.3-codex",
47
+ "choices": [{ "index": 0, "message": { "role": "assistant", "content": "..." }, "finish_reason": "stop" }],
48
+ "usage": { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0 }
49
+ }
50
+ ```
51
+
52
+ ---
53
+
54
+ ### GET /v1/models
55
+
56
+ List all available models (OpenAI-compatible format).
57
+
58
+ **Response:**
59
+ ```json
60
+ {
61
+ "object": "list",
62
+ "data": [
63
+ { "id": "gpt-5.3-codex", "object": "model", "created": 1700000000, "owned_by": "openai" },
64
+ { "id": "codex", "object": "model", "created": 1700000000, "owned_by": "openai" }
65
+ ]
66
+ }
67
+ ```
68
+
69
+ ### GET /v1/models/:modelId
70
+
71
+ Get a single model by ID or alias.
72
+
73
+ ### GET /v1/models/:modelId/info
74
+
75
+ Extended model info with reasoning efforts, capabilities, and description.
76
+
77
+ **Response:**
78
+ ```json
79
+ {
80
+ "id": "gpt-5.3-codex",
81
+ "model": "gpt-5.3-codex",
82
+ "displayName": "gpt-5.3-codex",
83
+ "description": "Latest frontier agentic coding model.",
84
+ "isDefault": true,
85
+ "supportedReasoningEfforts": [
86
+ { "reasoningEffort": "low", "description": "Fast responses with lighter reasoning" },
87
+ { "reasoningEffort": "medium", "description": "Balances speed and reasoning depth" },
88
+ { "reasoningEffort": "high", "description": "Greater reasoning depth" },
89
+ { "reasoningEffort": "xhigh", "description": "Extra high reasoning depth" }
90
+ ],
91
+ "defaultReasoningEffort": "medium",
92
+ "inputModalities": ["text", "image"],
93
+ "supportsPersonality": true,
94
+ "upgrade": null
95
+ }
96
+ ```
97
+
98
+ **Model Aliases:**
99
+
100
+ | Alias | Resolves To |
101
+ |-------|-------------|
102
+ | `codex` | `gpt-5.3-codex` |
103
+ | `codex-max` | `gpt-5.1-codex-max` |
104
+ | `codex-mini` | `gpt-5.1-codex-mini` |
105
+
106
+ ---
107
+
108
+ ## Authentication
109
+
110
+ ### GET /auth/status
111
+
112
+ Pool-level auth status summary.
113
+
114
+ **Response:**
115
+ ```json
116
+ {
117
+ "authenticated": true,
118
+ "user": { "email": "user@example.com", "accountId": "...", "planType": "free" },
119
+ "proxy_api_key": "codex-proxy-...",
120
+ "pool": { "total": 1, "active": 1, "expired": 0, "rate_limited": 0, "refreshing": 0, "disabled": 0 }
121
+ }
122
+ ```
123
+
124
+ ### GET /auth/login
125
+
126
+ Start OAuth login via Codex CLI. Returns `authUrl` to open in browser.
127
+
128
+ **Response:**
129
+ ```json
130
+ { "authUrl": "https://auth0.openai.com/authorize?..." }
131
+ ```
132
+
133
+ ### POST /auth/token
134
+
135
+ Submit a JWT token manually.
136
+
137
+ **Request Body:**
138
+ ```json
139
+ { "token": "eyJhbGci..." }
140
+ ```
141
+
142
+ ### POST /auth/logout
143
+
144
+ Clear all accounts and tokens.
145
+
146
+ ---
147
+
148
+ ## Account Management
149
+
150
+ ### GET /auth/accounts
151
+
152
+ List all accounts with usage stats.
153
+
154
+ **Query Parameters:**
155
+
156
+ | Param | Type | Description |
157
+ |-------|------|-------------|
158
+ | `quota` | string | Set to `"true"` to include official Codex quota for each active account |
159
+
160
+ **Response:**
161
+ ```json
162
+ {
163
+ "accounts": [
164
+ {
165
+ "id": "3ef8086e25b10091",
166
+ "email": "user@example.com",
167
+ "accountId": "0e555622-...",
168
+ "planType": "free",
169
+ "status": "active",
170
+ "usage": {
171
+ "request_count": 42,
172
+ "input_tokens": 12000,
173
+ "output_tokens": 8500,
174
+ "last_used": "2026-02-17T10:00:00.000Z",
175
+ "rate_limit_until": null
176
+ },
177
+ "addedAt": "2026-02-17T06:38:23.740Z",
178
+ "expiresAt": "2026-02-27T00:46:57.000Z",
179
+ "quota": {
180
+ "plan_type": "free",
181
+ "rate_limit": {
182
+ "allowed": true,
183
+ "limit_reached": false,
184
+ "used_percent": 5,
185
+ "reset_at": 1771902673
186
+ },
187
+ "code_review_rate_limit": null
188
+ }
189
+ }
190
+ ]
191
+ }
192
+ ```
193
+
194
+ > `quota` field only appears when `?quota=true` and the account is active. Uses `curl` subprocess to bypass Cloudflare TLS fingerprinting.
195
+
196
+ ### POST /auth/accounts
197
+
198
+ Add a new account via JWT token.
199
+
200
+ **Request Body:**
201
+ ```json
202
+ { "token": "eyJhbGci..." }
203
+ ```
204
+
205
+ **Response:**
206
+ ```json
207
+ { "success": true, "account": { ... } }
208
+ ```
209
+
210
+ ### DELETE /auth/accounts/:id
211
+
212
+ Remove an account by ID.
213
+
214
+ ### POST /auth/accounts/:id/reset-usage
215
+
216
+ Reset local usage counters (request_count, tokens) for an account.
217
+
218
+ ### GET /auth/accounts/:id/quota
219
+
220
+ Query real-time official Codex quota for a single account.
221
+
222
+ **Response (success):**
223
+ ```json
224
+ {
225
+ "quota": {
226
+ "plan_type": "free",
227
+ "rate_limit": {
228
+ "allowed": true,
229
+ "limit_reached": false,
230
+ "used_percent": 5,
231
+ "reset_at": 1771902673
232
+ },
233
+ "code_review_rate_limit": null
234
+ },
235
+ "raw": {
236
+ "plan_type": "free",
237
+ "rate_limit": {
238
+ "allowed": true,
239
+ "limit_reached": false,
240
+ "primary_window": {
241
+ "used_percent": 5,
242
+ "limit_window_seconds": 604800,
243
+ "reset_after_seconds": 562610,
244
+ "reset_at": 1771902673
245
+ },
246
+ "secondary_window": null
247
+ },
248
+ "code_review_rate_limit": { ... },
249
+ "credits": null,
250
+ "promo": null
251
+ }
252
+ }
253
+ ```
254
+
255
+ **Response (error):**
256
+ ```json
257
+ { "error": "Failed to fetch quota from Codex API", "detail": "Codex API error (403): ..." }
258
+ ```
259
+
260
+ | Status | Meaning |
261
+ |--------|---------|
262
+ | 200 | Success |
263
+ | 404 | Account ID not found |
264
+ | 409 | Account is not active (expired/rate_limited/disabled) |
265
+ | 502 | Upstream Codex API error |
266
+
267
+ ---
268
+
269
+ ## System
270
+
271
+ ### GET /health
272
+
273
+ Health check with auth and pool status.
274
+
275
+ **Response:**
276
+ ```json
277
+ {
278
+ "status": "ok",
279
+ "authenticated": true,
280
+ "user": { "email": "...", "accountId": "...", "planType": "free" },
281
+ "pool": { "total": 1, "active": 1, "expired": 0, "rate_limited": 0, "refreshing": 0, "disabled": 0 },
282
+ "timestamp": "2026-02-17T10:00:00.000Z"
283
+ }
284
+ ```
285
+
286
+ ### GET /debug/fingerprint
287
+
288
+ Show current client fingerprint headers, config, and prompt loading status.
289
+
290
+ ### GET /
291
+
292
+ Web dashboard (HTML). Shows login page if not authenticated, dashboard if authenticated.
293
+
294
+ ---
295
+
296
+ ## Error Format
297
+
298
+ All errors follow the OpenAI error format for `/v1/*` endpoints:
299
+ ```json
300
+ {
301
+ "error": {
302
+ "message": "Human-readable description",
303
+ "type": "invalid_request_error",
304
+ "param": null,
305
+ "code": "error_code"
306
+ }
307
+ }
308
+ ```
309
+
310
+ Management endpoints (`/auth/*`) use a simpler format:
311
+ ```json
312
+ { "error": "Human-readable description" }
313
+ ```
314
+
315
+ ---
316
+
317
+ ## Quick Start
318
+
319
+ ```bash
320
+ # 1. Start the proxy
321
+ npx tsx src/index.ts
322
+
323
+ # 2. Add a token (or use the web UI at http://localhost:8080)
324
+ curl -X POST http://localhost:8080/auth/token \
325
+ -H "Content-Type: application/json" \
326
+ -d '{"token": "eyJhbGci..."}'
327
+
328
+ # 3. Chat (streaming)
329
+ curl http://localhost:8080/v1/chat/completions \
330
+ -H "Content-Type: application/json" \
331
+ -d '{
332
+ "model": "codex",
333
+ "messages": [{"role": "user", "content": "Hello!"}],
334
+ "stream": true
335
+ }'
336
+
337
+ # 4. Check account quota
338
+ curl http://localhost:8080/auth/accounts
339
+
340
+ # 5. Check official Codex usage limits
341
+ curl "http://localhost:8080/auth/accounts?quota=true"
342
+ ```
docs/implementation-notes.md ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Codex Proxy 实现记录
2
+
3
+ ## 项目目标
4
+
5
+ 将 Codex Desktop App(免费)的 API 访问能力提取出来,暴露为标准的 OpenAI `/v1/chat/completions` 兼容接口,使任何支持 OpenAI API 的客户端都能直接调用。
6
+
7
+ ---
8
+
9
+ ## 关键发现:WHAM API vs Codex Responses API
10
+
11
+ ### 最初的方案(失败)
12
+
13
+ 项目最初使用 **WHAM API**(`/backend-api/wham/tasks`)作为后端。这是 Codex Cloud 模式使用的 API,工作流程为:
14
+
15
+ 1. 创建 cloud environment(需要绑定 GitHub 仓库)
16
+ 2. 创建 task → 得到 task_id
17
+ 3. 轮询 task turn 状态直到完成
18
+ 4. 从 turn 的 output_items 中提取回复
19
+
20
+ **失败原因**:
21
+ - 免费账户没有 cloud environment
22
+ - `listEnvironments` 返回 500
23
+ - `worktree_snapshots/upload_url` 返回 404(功能未开启)
24
+ - 创建的 task 立即失败,返回 `unknown_error`
25
+
26
+ ### 发现真正的 API
27
+
28
+ 通过分析 Codex Desktop 的 CLI 二进制文件(`codex.exe`)中的字符串,发现 CLI 实际使用的是 **Responses API**,而不是 WHAM API。
29
+
30
+ 进一步测试发现正确的端点是:
31
+
32
+ ```
33
+ POST https://chatgpt.com/backend-api/codex/responses
34
+ ```
35
+
36
+ #### 端点探索过程
37
+
38
+ | 端点 | 状态 | 说明 |
39
+ |------|------|------|
40
+ | `/backend-api/responses` | 404 | 不存在 |
41
+ | `api.openai.com/v1/responses` | 401 | ChatGPT token 没有 API scope |
42
+ | **`/backend-api/codex/responses`** | **400 → 200** | **正确端点** |
43
+
44
+ #### 必需字段(逐步试错发现)
45
+
46
+ 1. 第一次请求 → `400: "Instructions are required"` → 需要 `instructions` 字段
47
+ 2. 加上 instructions → `400: "Store must be set to false"` → 需要 `store: false`
48
+ 3. 加上 store: false → `400: "Stream must be set to true"` → 需要 `stream: true`
49
+ 4. 全部加上 → `200 OK` ✓
50
+
51
+ ---
52
+
53
+ ## API 格式
54
+
55
+ ### 请求格式
56
+
57
+ ```json
58
+ {
59
+ "model": "gpt-5.1-codex-mini",
60
+ "instructions": "You are a helpful assistant.",
61
+ "input": [
62
+ { "role": "user", "content": "你好" }
63
+ ],
64
+ "stream": true,
65
+ "store": false,
66
+ "reasoning": { "effort": "medium" }
67
+ }
68
+ ```
69
+
70
+ **关键约束**:
71
+ - `stream` 必须为 `true`(不支持非流式)
72
+ - `store` 必须为 `false`
73
+ - `instructions` 必填(对应 system message)
74
+
75
+ ### 响应格式(SSE 流)
76
+
77
+ Codex Responses API 返回标准的 OpenAI Responses API SSE 事件:
78
+
79
+ ```
80
+ event: response.created
81
+ data: {"type":"response.created","response":{"id":"resp_xxx","status":"in_progress",...}}
82
+
83
+ event: response.output_text.delta
84
+ data: {"type":"response.output_text.delta","delta":"你","item_id":"msg_xxx",...}
85
+
86
+ event: response.output_text.delta
87
+ data: {"type":"response.output_text.delta","delta":"好","item_id":"msg_xxx",...}
88
+
89
+ event: response.output_text.done
90
+ data: {"type":"response.output_text.done","text":"你好!",...}
91
+
92
+ event: response.completed
93
+ data: {"type":"response.completed","response":{"id":"resp_xxx","status":"completed","usage":{...},...}}
94
+ ```
95
+
96
+ 主要事件类型:
97
+ - `response.created` — 响应开始
98
+ - `response.in_progress` — 处理中
99
+ - `response.output_item.added` — 输出项添加(reasoning 或 message)
100
+ - `response.output_text.delta` — **文本增量(核心内容)**
101
+ - `response.output_text.done` — 文本完成
102
+ - `response.completed` — 响应完成(包含 usage 统计)
103
+
104
+ ### 认证方式
105
+
106
+ 使用 Codex Desktop App 的 ChatGPT OAuth JWT token,需要以下请求头:
107
+
108
+ ```
109
+ Authorization: Bearer <jwt_token>
110
+ ChatGPT-Account-Id: <account_id>
111
+ originator: Codex Desktop
112
+ User-Agent: Codex Desktop/260202.0859 (win32; x64)
113
+ Content-Type: application/json
114
+ Accept: text/event-stream
115
+ ```
116
+
117
+ ---
118
+
119
+ ## 代码实现
120
+
121
+ ### 新增文件
122
+
123
+ #### `src/proxy/codex-api.ts` — Codex Responses API 客户端
124
+
125
+ 负责:
126
+ - 构建请求并发送到 `/backend-api/codex/responses`
127
+ - 解析 SSE 流,逐个 yield 事件对象
128
+ - 错误处理(超时、HTTP 错误等)
129
+
130
+ ```typescript
131
+ // 核心方法
132
+ async createResponse(request: CodexResponsesRequest): Promise<Response>
133
+ async *parseStream(response: Response): AsyncGenerator<CodexSSEEvent>
134
+ ```
135
+
136
+ #### `src/translation/openai-to-codex.ts` — 请求翻译
137
+
138
+ 将 OpenAI Chat Completions 请求格式转换为 Codex Responses API 格式:
139
+
140
+ | OpenAI Chat Completions | Codex Responses API |
141
+ |------------------------|---------------------|
142
+ | `messages[role=system]` | `instructions` |
143
+ | `messages[role=user/assistant]` | `input[]` |
144
+ | `model` | `model`(经过 resolveModelId 映射) |
145
+ | `reasoning_effort` | `reasoning.effort` |
146
+ | `stream` | 固定 `true` |
147
+ | — | `store: false`(固定) |
148
+
149
+ #### `src/translation/codex-to-openai.ts` — 响应翻译
150
+
151
+ 将 Codex Responses SSE 流转换为 OpenAI Chat Completions 格式:
152
+
153
+ **流式模式** (`streamCodexToOpenAI`):
154
+ ```
155
+ Codex: response.output_text.delta {"delta":"你"}
156
+
157
+ OpenAI: data: {"choices":[{"delta":{"content":"你"}}]}
158
+
159
+ Codex: response.completed
160
+
161
+ OpenAI: data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
162
+ OpenAI: data: [DONE]
163
+ ```
164
+
165
+ **非流式模式** (`collectCodexResponse`):
166
+ - 消费整个 SSE 流,收集所有 text delta
167
+ - 拼接为完整文本
168
+ - 返回标准 `chat.completion` JSON 响应(包含 usage)
169
+
170
+ ### 修改文件
171
+
172
+ #### `src/routes/chat.ts` — 路由处理器(重写)
173
+
174
+ **之前**:使用 WhamApi 创建 task → 轮询 turn → 提取结果
175
+ **之后**:使用 CodexApi 发送 responses 请求 → 直接流式/收集结果
176
+
177
+ 核心流程简化为:
178
+ ```
179
+ 1. 验证认证
180
+ 2. 解析请求 (ChatCompletionRequestSchema)
181
+ 3. translateToCodexRequest() 转换格式
182
+ 4. codexApi.createResponse() 发送请求
183
+ 5a. 流式:streamCodexToOpenAI() → 逐块写入 SSE
184
+ 5b. 非流式:collectCodexResponse() → 返回 JSON
185
+ ```
186
+
187
+ #### `src/index.ts` — 入口文件(简化)
188
+
189
+ 移除了 WHAM environment 自动发现逻辑(不再需要)。
190
+
191
+ ---
192
+
193
+ ## 之前修复的 Bug(WHAM 阶段)
194
+
195
+ 在切换到 Codex Responses API 之前,还修复了 WHAM API 相关的三个 bug:
196
+
197
+ 1. **`turn_status` vs `status` 字段名不匹配** — WHAM API 返回 `turn_status`,但代码检查 `status`,导致轮询永远不匹配,超时 300 秒
198
+ 2. **`getTaskTurn` 响应结构嵌套** — API 返回 `{ task, user_turn, turn }` 但代码把整个响应当作 `WhamTurn`,导致 `output_items` 为 `undefined`
199
+ 3. **失败的 turn 返回 200 空内容** — 没有检查 `failed` 状态,直接返回空 content
200
+
201
+ 这些修复在 `src/types/wham.ts`、`src/proxy/wham-api.ts`、`src/translation/stream-adapter.ts` 中。
202
+
203
+ ---
204
+
205
+ ## 测试结果
206
+
207
+ ### 非流式请求
208
+ ```bash
209
+ curl http://localhost:8080/v1/chat/completions \
210
+ -H "Content-Type: application/json" \
211
+ -d '{"model":"gpt-5.1-codex-mini","messages":[{"role":"user","content":"Say hello"}]}'
212
+ ```
213
+ ```json
214
+ {
215
+ "id": "chatcmpl-3125ece443994614aa7b1136",
216
+ "object": "chat.completion",
217
+ "choices": [{"message": {"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}],
218
+ "usage": {"prompt_tokens": 22, "completion_tokens": 20, "total_tokens": 42}
219
+ }
220
+ ```
221
+ 响应时间:~2 秒
222
+
223
+ ### 流式请求
224
+ ```bash
225
+ curl http://localhost:8080/v1/chat/completions \
226
+ -H "Content-Type: application/json" \
227
+ -d '{"model":"gpt-5.1-codex-mini","messages":[{"role":"user","content":"Say hello"}],"stream":true}'
228
+ ```
229
+ ```
230
+ data: {"choices":[{"delta":{"role":"assistant"}}]}
231
+ data: {"choices":[{"delta":{"content":"Hello"}}]}
232
+ data: {"choices":[{"delta":{"content":"!"}}]}
233
+ data: {"choices":[{"delta":{},"finish_reason":"stop"}}]}
234
+ data: [DONE]
235
+ ```
236
+ 首 token 时间:~500ms
237
+
238
+ ---
239
+
240
+ ## 文件结构总览
241
+
242
+ ```
243
+ src/
244
+ ├── proxy/
245
+ │ ├── codex-api.ts ← 新增:Codex Responses API 客户端
246
+ │ ├── client.ts (通用 HTTP 客户端,保留)
247
+ │ └── wham-api.ts (WHAM 客户端,保留但不再使用)
248
+ ├── translation/
249
+ │ ├── openai-to-codex.ts ← 新增:Chat Completions → Codex 格式
250
+ │ ├── codex-to-openai.ts ← 新增:Codex SSE → Chat Completions 格式
251
+ │ ├── openai-to-wham.ts (旧翻译器,保留)
252
+ │ ├── stream-adapter.ts (旧流适配器,保留)
253
+ │ └── wham-to-openai.ts (旧翻译器,保留)
254
+ ├── routes/
255
+ │ └── chat.ts ← 重写:使用 Codex API
256
+ ├── index.ts ← 简化:移除 WHAM env 逻辑
257
+ └── ...
258
+ ```
package-lock.json ADDED
@@ -0,0 +1,662 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "codex-proxy",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "codex-proxy",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "@hono/node-server": "^1.0.0",
12
+ "hono": "^4.0.0",
13
+ "js-yaml": "^4.1.0",
14
+ "undici": "^7.0.0",
15
+ "zod": "^3.23.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/js-yaml": "^4.0.0",
19
+ "@types/node": "^22.0.0",
20
+ "tsx": "^4.0.0",
21
+ "typescript": "^5.5.0"
22
+ }
23
+ },
24
+ "node_modules/@esbuild/aix-ppc64": {
25
+ "version": "0.27.3",
26
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
27
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
28
+ "cpu": [
29
+ "ppc64"
30
+ ],
31
+ "dev": true,
32
+ "license": "MIT",
33
+ "optional": true,
34
+ "os": [
35
+ "aix"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ }
40
+ },
41
+ "node_modules/@esbuild/android-arm": {
42
+ "version": "0.27.3",
43
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
44
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
45
+ "cpu": [
46
+ "arm"
47
+ ],
48
+ "dev": true,
49
+ "license": "MIT",
50
+ "optional": true,
51
+ "os": [
52
+ "android"
53
+ ],
54
+ "engines": {
55
+ "node": ">=18"
56
+ }
57
+ },
58
+ "node_modules/@esbuild/android-arm64": {
59
+ "version": "0.27.3",
60
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
61
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
62
+ "cpu": [
63
+ "arm64"
64
+ ],
65
+ "dev": true,
66
+ "license": "MIT",
67
+ "optional": true,
68
+ "os": [
69
+ "android"
70
+ ],
71
+ "engines": {
72
+ "node": ">=18"
73
+ }
74
+ },
75
+ "node_modules/@esbuild/android-x64": {
76
+ "version": "0.27.3",
77
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
78
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
79
+ "cpu": [
80
+ "x64"
81
+ ],
82
+ "dev": true,
83
+ "license": "MIT",
84
+ "optional": true,
85
+ "os": [
86
+ "android"
87
+ ],
88
+ "engines": {
89
+ "node": ">=18"
90
+ }
91
+ },
92
+ "node_modules/@esbuild/darwin-arm64": {
93
+ "version": "0.27.3",
94
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
95
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
96
+ "cpu": [
97
+ "arm64"
98
+ ],
99
+ "dev": true,
100
+ "license": "MIT",
101
+ "optional": true,
102
+ "os": [
103
+ "darwin"
104
+ ],
105
+ "engines": {
106
+ "node": ">=18"
107
+ }
108
+ },
109
+ "node_modules/@esbuild/darwin-x64": {
110
+ "version": "0.27.3",
111
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
112
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
113
+ "cpu": [
114
+ "x64"
115
+ ],
116
+ "dev": true,
117
+ "license": "MIT",
118
+ "optional": true,
119
+ "os": [
120
+ "darwin"
121
+ ],
122
+ "engines": {
123
+ "node": ">=18"
124
+ }
125
+ },
126
+ "node_modules/@esbuild/freebsd-arm64": {
127
+ "version": "0.27.3",
128
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
129
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
130
+ "cpu": [
131
+ "arm64"
132
+ ],
133
+ "dev": true,
134
+ "license": "MIT",
135
+ "optional": true,
136
+ "os": [
137
+ "freebsd"
138
+ ],
139
+ "engines": {
140
+ "node": ">=18"
141
+ }
142
+ },
143
+ "node_modules/@esbuild/freebsd-x64": {
144
+ "version": "0.27.3",
145
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
146
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
147
+ "cpu": [
148
+ "x64"
149
+ ],
150
+ "dev": true,
151
+ "license": "MIT",
152
+ "optional": true,
153
+ "os": [
154
+ "freebsd"
155
+ ],
156
+ "engines": {
157
+ "node": ">=18"
158
+ }
159
+ },
160
+ "node_modules/@esbuild/linux-arm": {
161
+ "version": "0.27.3",
162
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
163
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
164
+ "cpu": [
165
+ "arm"
166
+ ],
167
+ "dev": true,
168
+ "license": "MIT",
169
+ "optional": true,
170
+ "os": [
171
+ "linux"
172
+ ],
173
+ "engines": {
174
+ "node": ">=18"
175
+ }
176
+ },
177
+ "node_modules/@esbuild/linux-arm64": {
178
+ "version": "0.27.3",
179
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
180
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
181
+ "cpu": [
182
+ "arm64"
183
+ ],
184
+ "dev": true,
185
+ "license": "MIT",
186
+ "optional": true,
187
+ "os": [
188
+ "linux"
189
+ ],
190
+ "engines": {
191
+ "node": ">=18"
192
+ }
193
+ },
194
+ "node_modules/@esbuild/linux-ia32": {
195
+ "version": "0.27.3",
196
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
197
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
198
+ "cpu": [
199
+ "ia32"
200
+ ],
201
+ "dev": true,
202
+ "license": "MIT",
203
+ "optional": true,
204
+ "os": [
205
+ "linux"
206
+ ],
207
+ "engines": {
208
+ "node": ">=18"
209
+ }
210
+ },
211
+ "node_modules/@esbuild/linux-loong64": {
212
+ "version": "0.27.3",
213
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
214
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
215
+ "cpu": [
216
+ "loong64"
217
+ ],
218
+ "dev": true,
219
+ "license": "MIT",
220
+ "optional": true,
221
+ "os": [
222
+ "linux"
223
+ ],
224
+ "engines": {
225
+ "node": ">=18"
226
+ }
227
+ },
228
+ "node_modules/@esbuild/linux-mips64el": {
229
+ "version": "0.27.3",
230
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
231
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
232
+ "cpu": [
233
+ "mips64el"
234
+ ],
235
+ "dev": true,
236
+ "license": "MIT",
237
+ "optional": true,
238
+ "os": [
239
+ "linux"
240
+ ],
241
+ "engines": {
242
+ "node": ">=18"
243
+ }
244
+ },
245
+ "node_modules/@esbuild/linux-ppc64": {
246
+ "version": "0.27.3",
247
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
248
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
249
+ "cpu": [
250
+ "ppc64"
251
+ ],
252
+ "dev": true,
253
+ "license": "MIT",
254
+ "optional": true,
255
+ "os": [
256
+ "linux"
257
+ ],
258
+ "engines": {
259
+ "node": ">=18"
260
+ }
261
+ },
262
+ "node_modules/@esbuild/linux-riscv64": {
263
+ "version": "0.27.3",
264
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
265
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
266
+ "cpu": [
267
+ "riscv64"
268
+ ],
269
+ "dev": true,
270
+ "license": "MIT",
271
+ "optional": true,
272
+ "os": [
273
+ "linux"
274
+ ],
275
+ "engines": {
276
+ "node": ">=18"
277
+ }
278
+ },
279
+ "node_modules/@esbuild/linux-s390x": {
280
+ "version": "0.27.3",
281
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
282
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
283
+ "cpu": [
284
+ "s390x"
285
+ ],
286
+ "dev": true,
287
+ "license": "MIT",
288
+ "optional": true,
289
+ "os": [
290
+ "linux"
291
+ ],
292
+ "engines": {
293
+ "node": ">=18"
294
+ }
295
+ },
296
+ "node_modules/@esbuild/linux-x64": {
297
+ "version": "0.27.3",
298
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
299
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
300
+ "cpu": [
301
+ "x64"
302
+ ],
303
+ "dev": true,
304
+ "license": "MIT",
305
+ "optional": true,
306
+ "os": [
307
+ "linux"
308
+ ],
309
+ "engines": {
310
+ "node": ">=18"
311
+ }
312
+ },
313
+ "node_modules/@esbuild/netbsd-arm64": {
314
+ "version": "0.27.3",
315
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
316
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
317
+ "cpu": [
318
+ "arm64"
319
+ ],
320
+ "dev": true,
321
+ "license": "MIT",
322
+ "optional": true,
323
+ "os": [
324
+ "netbsd"
325
+ ],
326
+ "engines": {
327
+ "node": ">=18"
328
+ }
329
+ },
330
+ "node_modules/@esbuild/netbsd-x64": {
331
+ "version": "0.27.3",
332
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
333
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
334
+ "cpu": [
335
+ "x64"
336
+ ],
337
+ "dev": true,
338
+ "license": "MIT",
339
+ "optional": true,
340
+ "os": [
341
+ "netbsd"
342
+ ],
343
+ "engines": {
344
+ "node": ">=18"
345
+ }
346
+ },
347
+ "node_modules/@esbuild/openbsd-arm64": {
348
+ "version": "0.27.3",
349
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
350
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
351
+ "cpu": [
352
+ "arm64"
353
+ ],
354
+ "dev": true,
355
+ "license": "MIT",
356
+ "optional": true,
357
+ "os": [
358
+ "openbsd"
359
+ ],
360
+ "engines": {
361
+ "node": ">=18"
362
+ }
363
+ },
364
+ "node_modules/@esbuild/openbsd-x64": {
365
+ "version": "0.27.3",
366
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
367
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
368
+ "cpu": [
369
+ "x64"
370
+ ],
371
+ "dev": true,
372
+ "license": "MIT",
373
+ "optional": true,
374
+ "os": [
375
+ "openbsd"
376
+ ],
377
+ "engines": {
378
+ "node": ">=18"
379
+ }
380
+ },
381
+ "node_modules/@esbuild/openharmony-arm64": {
382
+ "version": "0.27.3",
383
+ "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
384
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
385
+ "cpu": [
386
+ "arm64"
387
+ ],
388
+ "dev": true,
389
+ "license": "MIT",
390
+ "optional": true,
391
+ "os": [
392
+ "openharmony"
393
+ ],
394
+ "engines": {
395
+ "node": ">=18"
396
+ }
397
+ },
398
+ "node_modules/@esbuild/sunos-x64": {
399
+ "version": "0.27.3",
400
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
401
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
402
+ "cpu": [
403
+ "x64"
404
+ ],
405
+ "dev": true,
406
+ "license": "MIT",
407
+ "optional": true,
408
+ "os": [
409
+ "sunos"
410
+ ],
411
+ "engines": {
412
+ "node": ">=18"
413
+ }
414
+ },
415
+ "node_modules/@esbuild/win32-arm64": {
416
+ "version": "0.27.3",
417
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
418
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
419
+ "cpu": [
420
+ "arm64"
421
+ ],
422
+ "dev": true,
423
+ "license": "MIT",
424
+ "optional": true,
425
+ "os": [
426
+ "win32"
427
+ ],
428
+ "engines": {
429
+ "node": ">=18"
430
+ }
431
+ },
432
+ "node_modules/@esbuild/win32-ia32": {
433
+ "version": "0.27.3",
434
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
435
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
436
+ "cpu": [
437
+ "ia32"
438
+ ],
439
+ "dev": true,
440
+ "license": "MIT",
441
+ "optional": true,
442
+ "os": [
443
+ "win32"
444
+ ],
445
+ "engines": {
446
+ "node": ">=18"
447
+ }
448
+ },
449
+ "node_modules/@esbuild/win32-x64": {
450
+ "version": "0.27.3",
451
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
452
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
453
+ "cpu": [
454
+ "x64"
455
+ ],
456
+ "dev": true,
457
+ "license": "MIT",
458
+ "optional": true,
459
+ "os": [
460
+ "win32"
461
+ ],
462
+ "engines": {
463
+ "node": ">=18"
464
+ }
465
+ },
466
+ "node_modules/@hono/node-server": {
467
+ "version": "1.19.9",
468
+ "resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.9.tgz",
469
+ "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
470
+ "license": "MIT",
471
+ "engines": {
472
+ "node": ">=18.14.1"
473
+ },
474
+ "peerDependencies": {
475
+ "hono": "^4"
476
+ }
477
+ },
478
+ "node_modules/@types/js-yaml": {
479
+ "version": "4.0.9",
480
+ "resolved": "https://registry.npmmirror.com/@types/js-yaml/-/js-yaml-4.0.9.tgz",
481
+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
482
+ "dev": true,
483
+ "license": "MIT"
484
+ },
485
+ "node_modules/@types/node": {
486
+ "version": "22.19.11",
487
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.11.tgz",
488
+ "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
489
+ "dev": true,
490
+ "license": "MIT",
491
+ "dependencies": {
492
+ "undici-types": "~6.21.0"
493
+ }
494
+ },
495
+ "node_modules/argparse": {
496
+ "version": "2.0.1",
497
+ "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
498
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
499
+ "license": "Python-2.0"
500
+ },
501
+ "node_modules/esbuild": {
502
+ "version": "0.27.3",
503
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz",
504
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
505
+ "dev": true,
506
+ "hasInstallScript": true,
507
+ "license": "MIT",
508
+ "bin": {
509
+ "esbuild": "bin/esbuild"
510
+ },
511
+ "engines": {
512
+ "node": ">=18"
513
+ },
514
+ "optionalDependencies": {
515
+ "@esbuild/aix-ppc64": "0.27.3",
516
+ "@esbuild/android-arm": "0.27.3",
517
+ "@esbuild/android-arm64": "0.27.3",
518
+ "@esbuild/android-x64": "0.27.3",
519
+ "@esbuild/darwin-arm64": "0.27.3",
520
+ "@esbuild/darwin-x64": "0.27.3",
521
+ "@esbuild/freebsd-arm64": "0.27.3",
522
+ "@esbuild/freebsd-x64": "0.27.3",
523
+ "@esbuild/linux-arm": "0.27.3",
524
+ "@esbuild/linux-arm64": "0.27.3",
525
+ "@esbuild/linux-ia32": "0.27.3",
526
+ "@esbuild/linux-loong64": "0.27.3",
527
+ "@esbuild/linux-mips64el": "0.27.3",
528
+ "@esbuild/linux-ppc64": "0.27.3",
529
+ "@esbuild/linux-riscv64": "0.27.3",
530
+ "@esbuild/linux-s390x": "0.27.3",
531
+ "@esbuild/linux-x64": "0.27.3",
532
+ "@esbuild/netbsd-arm64": "0.27.3",
533
+ "@esbuild/netbsd-x64": "0.27.3",
534
+ "@esbuild/openbsd-arm64": "0.27.3",
535
+ "@esbuild/openbsd-x64": "0.27.3",
536
+ "@esbuild/openharmony-arm64": "0.27.3",
537
+ "@esbuild/sunos-x64": "0.27.3",
538
+ "@esbuild/win32-arm64": "0.27.3",
539
+ "@esbuild/win32-ia32": "0.27.3",
540
+ "@esbuild/win32-x64": "0.27.3"
541
+ }
542
+ },
543
+ "node_modules/fsevents": {
544
+ "version": "2.3.3",
545
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
546
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
547
+ "dev": true,
548
+ "hasInstallScript": true,
549
+ "license": "MIT",
550
+ "optional": true,
551
+ "os": [
552
+ "darwin"
553
+ ],
554
+ "engines": {
555
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
556
+ }
557
+ },
558
+ "node_modules/get-tsconfig": {
559
+ "version": "4.13.6",
560
+ "resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
561
+ "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
562
+ "dev": true,
563
+ "license": "MIT",
564
+ "dependencies": {
565
+ "resolve-pkg-maps": "^1.0.0"
566
+ },
567
+ "funding": {
568
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
569
+ }
570
+ },
571
+ "node_modules/hono": {
572
+ "version": "4.11.9",
573
+ "resolved": "https://registry.npmmirror.com/hono/-/hono-4.11.9.tgz",
574
+ "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
575
+ "license": "MIT",
576
+ "engines": {
577
+ "node": ">=16.9.0"
578
+ }
579
+ },
580
+ "node_modules/js-yaml": {
581
+ "version": "4.1.1",
582
+ "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
583
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
584
+ "license": "MIT",
585
+ "dependencies": {
586
+ "argparse": "^2.0.1"
587
+ },
588
+ "bin": {
589
+ "js-yaml": "bin/js-yaml.js"
590
+ }
591
+ },
592
+ "node_modules/resolve-pkg-maps": {
593
+ "version": "1.0.0",
594
+ "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
595
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
596
+ "dev": true,
597
+ "license": "MIT",
598
+ "funding": {
599
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
600
+ }
601
+ },
602
+ "node_modules/tsx": {
603
+ "version": "4.21.0",
604
+ "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz",
605
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
606
+ "dev": true,
607
+ "license": "MIT",
608
+ "dependencies": {
609
+ "esbuild": "~0.27.0",
610
+ "get-tsconfig": "^4.7.5"
611
+ },
612
+ "bin": {
613
+ "tsx": "dist/cli.mjs"
614
+ },
615
+ "engines": {
616
+ "node": ">=18.0.0"
617
+ },
618
+ "optionalDependencies": {
619
+ "fsevents": "~2.3.3"
620
+ }
621
+ },
622
+ "node_modules/typescript": {
623
+ "version": "5.9.3",
624
+ "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
625
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
626
+ "dev": true,
627
+ "license": "Apache-2.0",
628
+ "bin": {
629
+ "tsc": "bin/tsc",
630
+ "tsserver": "bin/tsserver"
631
+ },
632
+ "engines": {
633
+ "node": ">=14.17"
634
+ }
635
+ },
636
+ "node_modules/undici": {
637
+ "version": "7.22.0",
638
+ "resolved": "https://registry.npmmirror.com/undici/-/undici-7.22.0.tgz",
639
+ "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
640
+ "license": "MIT",
641
+ "engines": {
642
+ "node": ">=20.18.1"
643
+ }
644
+ },
645
+ "node_modules/undici-types": {
646
+ "version": "6.21.0",
647
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
648
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
649
+ "dev": true,
650
+ "license": "MIT"
651
+ },
652
+ "node_modules/zod": {
653
+ "version": "3.25.76",
654
+ "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
655
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
656
+ "license": "MIT",
657
+ "funding": {
658
+ "url": "https://github.com/sponsors/colinhacks"
659
+ }
660
+ }
661
+ }
662
+ }
package.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "codex-proxy",
3
+ "version": "1.0.0",
4
+ "description": "Reverse proxy that exposes Codex Desktop WHAM API as OpenAI-compatible /v1/chat/completions",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx watch src/index.ts",
8
+ "build": "tsc",
9
+ "start": "node dist/index.js",
10
+ "check-update": "tsx scripts/check-update.ts",
11
+ "check-update:watch": "tsx scripts/check-update.ts --watch",
12
+ "extract": "tsx scripts/extract-fingerprint.ts",
13
+ "apply-update": "tsx scripts/apply-update.ts",
14
+ "apply-update:dry": "tsx scripts/apply-update.ts --dry-run"
15
+ },
16
+ "dependencies": {
17
+ "hono": "^4.0.0",
18
+ "@hono/node-server": "^1.0.0",
19
+ "undici": "^7.0.0",
20
+ "js-yaml": "^4.1.0",
21
+ "zod": "^3.23.0"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.5.0",
25
+ "tsx": "^4.0.0",
26
+ "@types/js-yaml": "^4.0.0",
27
+ "@types/node": "^22.0.0",
28
+ "js-beautify": "^1.15.0",
29
+ "@electron/asar": "^3.2.0"
30
+ }
31
+ }
public/dashboard.html ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Codex Proxy - Dashboard</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ background: #0d1117;
12
+ color: #c9d1d9;
13
+ min-height: 100vh;
14
+ padding: 2rem;
15
+ }
16
+ .container { max-width: 720px; margin: 0 auto; }
17
+ header {
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: space-between;
21
+ margin-bottom: 2rem;
22
+ padding-bottom: 1rem;
23
+ border-bottom: 1px solid #30363d;
24
+ }
25
+ header h1 { font-size: 1.3rem; color: #58a6ff; }
26
+ .user-info { font-size: 0.85rem; color: #8b949e; }
27
+ .btn-logout {
28
+ padding: 6px 14px;
29
+ background: #21262d;
30
+ border: 1px solid #30363d;
31
+ border-radius: 6px;
32
+ color: #c9d1d9;
33
+ cursor: pointer;
34
+ font-size: 0.85rem;
35
+ }
36
+ .btn-logout:hover { background: #30363d; }
37
+ .card {
38
+ background: #161b22;
39
+ border: 1px solid #30363d;
40
+ border-radius: 12px;
41
+ padding: 1.5rem;
42
+ margin-bottom: 1.5rem;
43
+ }
44
+ .card h2 {
45
+ font-size: 1rem;
46
+ margin-bottom: 1rem;
47
+ color: #f0f6fc;
48
+ }
49
+ .field {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 0.75rem;
53
+ margin-bottom: 0.75rem;
54
+ }
55
+ .field label {
56
+ flex-shrink: 0;
57
+ width: 100px;
58
+ font-size: 0.85rem;
59
+ color: #8b949e;
60
+ }
61
+ .field .value {
62
+ flex: 1;
63
+ background: #0d1117;
64
+ padding: 8px 12px;
65
+ border-radius: 6px;
66
+ border: 1px solid #30363d;
67
+ font-family: monospace;
68
+ font-size: 0.85rem;
69
+ word-break: break-all;
70
+ }
71
+ .btn-copy {
72
+ padding: 6px 12px;
73
+ background: #21262d;
74
+ border: 1px solid #30363d;
75
+ border-radius: 6px;
76
+ color: #c9d1d9;
77
+ cursor: pointer;
78
+ font-size: 0.8rem;
79
+ flex-shrink: 0;
80
+ }
81
+ .btn-copy:hover { background: #30363d; }
82
+ .btn-copy.copied { background: #238636; border-color: #238636; }
83
+ .code-block {
84
+ background: #0d1117;
85
+ border: 1px solid #30363d;
86
+ border-radius: 8px;
87
+ padding: 1rem;
88
+ font-family: monospace;
89
+ font-size: 0.8rem;
90
+ line-height: 1.6;
91
+ overflow-x: auto;
92
+ position: relative;
93
+ white-space: pre;
94
+ margin-bottom: 1rem;
95
+ }
96
+ .code-block .copy-btn {
97
+ position: absolute;
98
+ top: 8px;
99
+ right: 8px;
100
+ padding: 4px 10px;
101
+ background: #21262d;
102
+ border: 1px solid #30363d;
103
+ border-radius: 4px;
104
+ color: #8b949e;
105
+ cursor: pointer;
106
+ font-size: 0.75rem;
107
+ }
108
+ .code-block .copy-btn:hover { background: #30363d; }
109
+ .tabs {
110
+ display: flex;
111
+ gap: 0.5rem;
112
+ margin-bottom: 1rem;
113
+ }
114
+ .tab {
115
+ padding: 6px 14px;
116
+ background: #21262d;
117
+ border: 1px solid #30363d;
118
+ border-radius: 6px;
119
+ color: #8b949e;
120
+ cursor: pointer;
121
+ font-size: 0.85rem;
122
+ }
123
+ .tab.active { background: #30363d; color: #f0f6fc; }
124
+ .tab-content { display: none; }
125
+ .tab-content.active { display: block; }
126
+ .status-badge {
127
+ display: inline-block;
128
+ padding: 2px 8px;
129
+ border-radius: 10px;
130
+ font-size: 0.75rem;
131
+ font-weight: 500;
132
+ }
133
+ .status-ok { background: #238636; color: #fff; }
134
+ .loading { color: #8b949e; font-style: italic; }
135
+ </style>
136
+ </head>
137
+ <body>
138
+ <div class="container">
139
+ <header>
140
+ <div>
141
+ <h1>Codex Proxy</h1>
142
+ <span class="user-info" id="userInfo">Loading...</span>
143
+ </div>
144
+ <button class="btn-logout" onclick="logout()">Logout</button>
145
+ </header>
146
+
147
+ <div class="card">
148
+ <h2>API Configuration</h2>
149
+ <div class="field">
150
+ <label>Base URL</label>
151
+ <div class="value" id="baseUrl">Loading...</div>
152
+ <button class="btn-copy" onclick="copyText('baseUrl', this)">Copy</button>
153
+ </div>
154
+ <div class="field">
155
+ <label>API Key</label>
156
+ <div class="value" id="apiKey">Loading...</div>
157
+ <button class="btn-copy" onclick="copyText('apiKey', this)">Copy</button>
158
+ </div>
159
+ <div class="field">
160
+ <label>Model</label>
161
+ <div class="value" id="defaultModel">codex</div>
162
+ </div>
163
+ <div class="field">
164
+ <label>All Models</label>
165
+ <div class="value" id="allModels" style="font-size:0.75rem;line-height:1.6">Loading...</div>
166
+ </div>
167
+ </div>
168
+
169
+ <div class="card">
170
+ <h2>Usage Examples</h2>
171
+ <div class="tabs">
172
+ <div class="tab active" onclick="switchTab('python', this)">Python</div>
173
+ <div class="tab" onclick="switchTab('curl', this)">curl</div>
174
+ <div class="tab" onclick="switchTab('node', this)">Node.js</div>
175
+ </div>
176
+
177
+ <div class="tab-content active" id="tab-python">
178
+ <div class="code-block" id="code-python">
179
+ <button class="copy-btn" onclick="copyCode('code-python')">Copy</button>
180
+ Loading...
181
+ </div>
182
+ </div>
183
+ <div class="tab-content" id="tab-curl">
184
+ <div class="code-block" id="code-curl">
185
+ <button class="copy-btn" onclick="copyCode('code-curl')">Copy</button>
186
+ Loading...
187
+ </div>
188
+ </div>
189
+ <div class="tab-content" id="tab-node">
190
+ <div class="code-block" id="code-node">
191
+ <button class="copy-btn" onclick="copyCode('code-node')">Copy</button>
192
+ Loading...
193
+ </div>
194
+ </div>
195
+ </div>
196
+
197
+ <div class="card">
198
+ <h2>Status</h2>
199
+ <div id="statusInfo" class="loading">Checking...</div>
200
+ </div>
201
+ </div>
202
+
203
+ <script>
204
+ let authData = null;
205
+
206
+ async function loadStatus() {
207
+ try {
208
+ const resp = await fetch('/auth/status');
209
+ authData = await resp.json();
210
+
211
+ if (!authData.authenticated) {
212
+ window.location.href = '/';
213
+ return;
214
+ }
215
+
216
+ // User info
217
+ const user = authData.user || {};
218
+ document.getElementById('userInfo').textContent =
219
+ `${user.email || 'Unknown'} (${user.planType || 'unknown plan'})`;
220
+
221
+ // API config
222
+ const baseUrl = `${window.location.origin}/v1`;
223
+ const apiKey = authData.proxy_api_key || 'any-string';
224
+ document.getElementById('baseUrl').textContent = baseUrl;
225
+ document.getElementById('apiKey').textContent = apiKey;
226
+
227
+ // Code examples
228
+ document.getElementById('code-python').innerHTML =
229
+ `<button class="copy-btn" onclick="copyCode('code-python')">Copy</button>` +
230
+ `from openai import OpenAI
231
+
232
+ client = OpenAI(
233
+ base_url="${baseUrl}",
234
+ api_key="${apiKey}",
235
+ )
236
+
237
+ response = client.chat.completions.create(
238
+ model="codex",
239
+ messages=[
240
+ {"role": "user", "content": "Write a hello world in Python"}
241
+ ],
242
+ stream=True,
243
+ )
244
+
245
+ for chunk in response:
246
+ if chunk.choices[0].delta.content:
247
+ print(chunk.choices[0].delta.content, end="")`;
248
+
249
+ document.getElementById('code-curl').innerHTML =
250
+ `<button class="copy-btn" onclick="copyCode('code-curl')">Copy</button>` +
251
+ `curl ${baseUrl}/chat/completions \\
252
+ -H "Content-Type: application/json" \\
253
+ -H "Authorization: Bearer ${apiKey}" \\
254
+ -d '{
255
+ "model": "codex",
256
+ "messages": [
257
+ {"role": "user", "content": "Write a hello world in Python"}
258
+ ],
259
+ "stream": false
260
+ }'`;
261
+
262
+ document.getElementById('code-node').innerHTML =
263
+ `<button class="copy-btn" onclick="copyCode('code-node')">Copy</button>` +
264
+ `import OpenAI from "openai";
265
+
266
+ const client = new OpenAI({
267
+ baseURL: "${baseUrl}",
268
+ apiKey: "${apiKey}",
269
+ });
270
+
271
+ const stream = await client.chat.completions.create({
272
+ model: "codex",
273
+ messages: [
274
+ { role: "user", content: "Write a hello world in Python" },
275
+ ],
276
+ stream: true,
277
+ });
278
+
279
+ for await (const chunk of stream) {
280
+ process.stdout.write(chunk.choices[0]?.delta?.content || "");
281
+ }`;
282
+
283
+ // Fetch models
284
+ try {
285
+ const modelsResp = await fetch('/v1/models');
286
+ const modelsData = await modelsResp.json();
287
+ const modelNames = modelsData.data.map(m => m.id);
288
+ const defaultModel = modelNames.find(n => n.includes('5.3-codex')) || modelNames[0];
289
+ document.getElementById('defaultModel').textContent = defaultModel + ' (default)';
290
+ document.getElementById('allModels').textContent = modelNames.join(', ');
291
+ } catch {}
292
+
293
+ // Health status
294
+ const health = await fetch('/health').then(r => r.json());
295
+ document.getElementById('statusInfo').innerHTML =
296
+ `<span class="status-badge status-ok">Connected</span> ` +
297
+ `Server running at ${window.location.origin}`;
298
+ } catch (err) {
299
+ document.getElementById('statusInfo').textContent = 'Error: ' + err.message;
300
+ }
301
+ }
302
+
303
+ function switchTab(tab, el) {
304
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
305
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
306
+ el.classList.add('active');
307
+ document.getElementById('tab-' + tab).classList.add('active');
308
+ }
309
+
310
+ function copyText(id, btn) {
311
+ const text = document.getElementById(id).textContent;
312
+ navigator.clipboard.writeText(text);
313
+ btn.textContent = 'Copied!';
314
+ btn.classList.add('copied');
315
+ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
316
+ }
317
+
318
+ function copyCode(id) {
319
+ const el = document.getElementById(id);
320
+ const code = el.textContent.replace('Copy', '').trim();
321
+ navigator.clipboard.writeText(code);
322
+ }
323
+
324
+ async function logout() {
325
+ await fetch('/auth/logout', { method: 'POST' });
326
+ window.location.href = '/';
327
+ }
328
+
329
+ loadStatus();
330
+ </script>
331
+ </body>
332
+ </html>
public/login.html ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Codex Proxy - Login</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ background: #0d1117;
12
+ color: #c9d1d9;
13
+ min-height: 100vh;
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ }
18
+ .container {
19
+ max-width: 420px;
20
+ width: 100%;
21
+ padding: 2rem;
22
+ }
23
+ .logo {
24
+ text-align: center;
25
+ margin-bottom: 2rem;
26
+ }
27
+ .logo h1 {
28
+ font-size: 1.5rem;
29
+ color: #58a6ff;
30
+ font-weight: 600;
31
+ }
32
+ .logo p {
33
+ color: #8b949e;
34
+ margin-top: 0.5rem;
35
+ font-size: 0.9rem;
36
+ }
37
+ .card {
38
+ background: #161b22;
39
+ border: 1px solid #30363d;
40
+ border-radius: 12px;
41
+ padding: 2rem;
42
+ }
43
+ .btn {
44
+ display: block;
45
+ width: 100%;
46
+ padding: 12px 16px;
47
+ border: none;
48
+ border-radius: 8px;
49
+ font-size: 1rem;
50
+ font-weight: 500;
51
+ cursor: pointer;
52
+ text-align: center;
53
+ text-decoration: none;
54
+ transition: background 0.2s;
55
+ }
56
+ .btn-primary {
57
+ background: #238636;
58
+ color: #fff;
59
+ }
60
+ .btn-primary:hover {
61
+ background: #2ea043;
62
+ }
63
+ .btn-primary:disabled {
64
+ background: #1a5e28;
65
+ cursor: not-allowed;
66
+ opacity: 0.7;
67
+ }
68
+ .divider {
69
+ text-align: center;
70
+ margin: 1.5rem 0;
71
+ color: #484f58;
72
+ font-size: 0.85rem;
73
+ }
74
+ .divider::before, .divider::after {
75
+ content: '';
76
+ display: inline-block;
77
+ width: 30%;
78
+ height: 1px;
79
+ background: #30363d;
80
+ vertical-align: middle;
81
+ margin: 0 0.5rem;
82
+ }
83
+ .input-group {
84
+ margin-bottom: 1rem;
85
+ }
86
+ .input-group label {
87
+ display: block;
88
+ font-size: 0.85rem;
89
+ color: #8b949e;
90
+ margin-bottom: 0.5rem;
91
+ }
92
+ .input-group textarea {
93
+ width: 100%;
94
+ padding: 10px;
95
+ background: #0d1117;
96
+ border: 1px solid #30363d;
97
+ border-radius: 6px;
98
+ color: #c9d1d9;
99
+ font-family: monospace;
100
+ font-size: 0.85rem;
101
+ resize: vertical;
102
+ min-height: 80px;
103
+ }
104
+ .input-group textarea:focus {
105
+ outline: none;
106
+ border-color: #58a6ff;
107
+ }
108
+ .error {
109
+ color: #f85149;
110
+ font-size: 0.85rem;
111
+ margin-top: 0.5rem;
112
+ display: none;
113
+ }
114
+ .success {
115
+ color: #3fb950;
116
+ font-size: 0.85rem;
117
+ margin-top: 0.5rem;
118
+ display: none;
119
+ }
120
+ .info {
121
+ color: #58a6ff;
122
+ font-size: 0.85rem;
123
+ margin-top: 0.5rem;
124
+ display: none;
125
+ }
126
+ .help {
127
+ margin-top: 1rem;
128
+ font-size: 0.8rem;
129
+ color: #484f58;
130
+ line-height: 1.5;
131
+ }
132
+ .spinner {
133
+ display: inline-block;
134
+ width: 14px;
135
+ height: 14px;
136
+ border: 2px solid rgba(255,255,255,0.3);
137
+ border-top-color: #fff;
138
+ border-radius: 50%;
139
+ animation: spin 0.8s linear infinite;
140
+ vertical-align: middle;
141
+ margin-right: 6px;
142
+ }
143
+ @keyframes spin {
144
+ to { transform: rotate(360deg); }
145
+ }
146
+ </style>
147
+ </head>
148
+ <body>
149
+ <div class="container">
150
+ <div class="logo">
151
+ <h1>Codex Proxy</h1>
152
+ <p>OpenAI-compatible API for Codex Desktop</p>
153
+ </div>
154
+ <div class="card">
155
+ <button class="btn btn-primary" id="oauthBtn" onclick="startOAuth()">
156
+ Login with ChatGPT
157
+ </button>
158
+ <div class="info" id="oauthInfo"></div>
159
+ <div class="error" id="oauthError"></div>
160
+
161
+ <div class="divider">or</div>
162
+
163
+ <div id="manualSection">
164
+ <div class="input-group">
165
+ <label>Paste your ChatGPT access token</label>
166
+ <textarea id="tokenInput" placeholder="eyJhbGciOiJSUzI1NiIs..."></textarea>
167
+ </div>
168
+ <button class="btn btn-primary" id="submitToken" onclick="submitToken()">
169
+ Submit Token
170
+ </button>
171
+ <div class="error" id="errorMsg"></div>
172
+ <div class="success" id="successMsg"></div>
173
+ <div class="help">
174
+ To get your token: Open ChatGPT in browser &rarr; DevTools (F12) &rarr;
175
+ Application &rarr; Cookies &rarr; find <code>__Secure-next-auth.session-token</code>
176
+ or check Network tab for <code>Authorization: Bearer ...</code> header.
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </div>
181
+
182
+ <script>
183
+ let polling = false;
184
+
185
+ async function startOAuth() {
186
+ const btn = document.getElementById('oauthBtn');
187
+ const infoEl = document.getElementById('oauthInfo');
188
+ const errorEl = document.getElementById('oauthError');
189
+
190
+ btn.disabled = true;
191
+ btn.innerHTML = '<span class="spinner"></span>Connecting to Codex CLI...';
192
+ infoEl.style.display = 'none';
193
+ errorEl.style.display = 'none';
194
+
195
+ try {
196
+ const resp = await fetch('/auth/login');
197
+ const data = await resp.json();
198
+
199
+ if (data.authenticated) {
200
+ window.location.href = '/';
201
+ return;
202
+ }
203
+
204
+ if (data.error) {
205
+ errorEl.textContent = data.error;
206
+ errorEl.style.display = 'block';
207
+ btn.disabled = false;
208
+ btn.textContent = 'Login with ChatGPT';
209
+ return;
210
+ }
211
+
212
+ if (data.authUrl) {
213
+ // Open Auth0 login in new tab
214
+ window.open(data.authUrl, '_blank');
215
+
216
+ btn.innerHTML = '<span class="spinner"></span>Waiting for login...';
217
+ infoEl.textContent = 'A new tab has been opened for ChatGPT login. Complete the login there, then this page will update automatically.';
218
+ infoEl.style.display = 'block';
219
+
220
+ // Start polling for auth completion
221
+ startPolling();
222
+ }
223
+ } catch (err) {
224
+ errorEl.textContent = 'Network error: ' + err.message;
225
+ errorEl.style.display = 'block';
226
+ btn.disabled = false;
227
+ btn.textContent = 'Login with ChatGPT';
228
+ }
229
+ }
230
+
231
+ function startPolling() {
232
+ if (polling) return;
233
+ polling = true;
234
+
235
+ const poll = async () => {
236
+ if (!polling) return;
237
+ try {
238
+ const resp = await fetch('/auth/status');
239
+ const data = await resp.json();
240
+ if (data.authenticated) {
241
+ polling = false;
242
+ document.getElementById('oauthInfo').textContent = 'Login successful! Redirecting...';
243
+ document.getElementById('oauthInfo').style.display = 'block';
244
+ setTimeout(() => window.location.href = '/', 500);
245
+ return;
246
+ }
247
+ } catch {
248
+ // ignore polling errors
249
+ }
250
+ if (polling) {
251
+ setTimeout(poll, 2000);
252
+ }
253
+ };
254
+
255
+ poll();
256
+
257
+ // Stop polling after 5 minutes
258
+ setTimeout(() => {
259
+ if (polling) {
260
+ polling = false;
261
+ const btn = document.getElementById('oauthBtn');
262
+ btn.disabled = false;
263
+ btn.textContent = 'Login with ChatGPT';
264
+ document.getElementById('oauthInfo').style.display = 'none';
265
+ document.getElementById('oauthError').textContent = 'Login timed out. Please try again.';
266
+ document.getElementById('oauthError').style.display = 'block';
267
+ }
268
+ }, 5 * 60 * 1000);
269
+ }
270
+
271
+ async function submitToken() {
272
+ const token = document.getElementById('tokenInput').value.trim();
273
+ const errorEl = document.getElementById('errorMsg');
274
+ const successEl = document.getElementById('successMsg');
275
+ errorEl.style.display = 'none';
276
+ successEl.style.display = 'none';
277
+
278
+ if (!token) {
279
+ errorEl.textContent = 'Please paste a token';
280
+ errorEl.style.display = 'block';
281
+ return;
282
+ }
283
+
284
+ try {
285
+ const resp = await fetch('/auth/token', {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/json' },
288
+ body: JSON.stringify({ token }),
289
+ });
290
+ const data = await resp.json();
291
+ if (resp.ok) {
292
+ successEl.textContent = 'Login successful! Redirecting...';
293
+ successEl.style.display = 'block';
294
+ setTimeout(() => window.location.href = '/', 1000);
295
+ } else {
296
+ errorEl.textContent = data.error || 'Invalid token';
297
+ errorEl.style.display = 'block';
298
+ }
299
+ } catch (err) {
300
+ errorEl.textContent = 'Network error: ' + err.message;
301
+ errorEl.style.display = 'block';
302
+ }
303
+ }
304
+ </script>
305
+ </body>
306
+ </html>
scripts/apply-update.ts ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * apply-update.ts — Compares extracted fingerprint with current config and applies updates.
4
+ *
5
+ * Usage:
6
+ * npx tsx scripts/apply-update.ts [--dry-run]
7
+ *
8
+ * --dry-run: Show what would change without modifying files.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, existsSync } from "fs";
12
+ import { resolve } from "path";
13
+ import { createHash } from "crypto";
14
+ import yaml from "js-yaml";
15
+
16
+ const ROOT = resolve(import.meta.dirname, "..");
17
+ const CONFIG_PATH = resolve(ROOT, "config/default.yaml");
18
+ const FINGERPRINT_PATH = resolve(ROOT, "config/fingerprint.yaml");
19
+ const EXTRACTED_PATH = resolve(ROOT, "data/extracted-fingerprint.json");
20
+ const MODELS_PATH = resolve(ROOT, "src/routes/models.ts");
21
+ const PROMPTS_DIR = resolve(ROOT, "config/prompts");
22
+
23
+ type ChangeType = "auto" | "semi-auto" | "alert";
24
+
25
+ interface Change {
26
+ type: ChangeType;
27
+ category: string;
28
+ description: string;
29
+ current: string;
30
+ updated: string;
31
+ file: string;
32
+ }
33
+
34
+ interface ExtractedFingerprint {
35
+ app_version: string;
36
+ build_number: string;
37
+ api_base_url: string | null;
38
+ originator: string | null;
39
+ models: string[];
40
+ wham_endpoints: string[];
41
+ user_agent_contains: string;
42
+ prompts: {
43
+ desktop_context_hash: string | null;
44
+ desktop_context_path: string | null;
45
+ title_generation_hash: string | null;
46
+ title_generation_path: string | null;
47
+ pr_generation_hash: string | null;
48
+ pr_generation_path: string | null;
49
+ automation_response_hash: string | null;
50
+ automation_response_path: string | null;
51
+ };
52
+ }
53
+
54
+ function loadExtracted(): ExtractedFingerprint {
55
+ if (!existsSync(EXTRACTED_PATH)) {
56
+ throw new Error(
57
+ `No extracted fingerprint found at ${EXTRACTED_PATH}.\n` +
58
+ `Run: npm run extract -- --path <codex-path>`
59
+ );
60
+ }
61
+ return JSON.parse(readFileSync(EXTRACTED_PATH, "utf-8"));
62
+ }
63
+
64
+ function loadCurrentConfig(): Record<string, unknown> {
65
+ return yaml.load(readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
66
+ }
67
+
68
+ function detectChanges(extracted: ExtractedFingerprint): Change[] {
69
+ const changes: Change[] = [];
70
+ const config = loadCurrentConfig();
71
+ const client = config.client as Record<string, string>;
72
+
73
+ // Version
74
+ if (extracted.app_version !== client.app_version) {
75
+ changes.push({
76
+ type: "auto",
77
+ category: "version",
78
+ description: "App version changed",
79
+ current: client.app_version,
80
+ updated: extracted.app_version,
81
+ file: CONFIG_PATH,
82
+ });
83
+ }
84
+
85
+ // Build number
86
+ if (extracted.build_number !== client.build_number) {
87
+ changes.push({
88
+ type: "auto",
89
+ category: "build",
90
+ description: "Build number changed",
91
+ current: client.build_number,
92
+ updated: extracted.build_number,
93
+ file: CONFIG_PATH,
94
+ });
95
+ }
96
+
97
+ // Originator
98
+ if (extracted.originator && extracted.originator !== client.originator) {
99
+ changes.push({
100
+ type: "auto",
101
+ category: "originator",
102
+ description: "Originator header changed",
103
+ current: client.originator,
104
+ updated: extracted.originator,
105
+ file: CONFIG_PATH,
106
+ });
107
+ }
108
+
109
+ // API base URL
110
+ const api = config.api as Record<string, string>;
111
+ if (extracted.api_base_url && extracted.api_base_url !== api.base_url) {
112
+ changes.push({
113
+ type: "alert",
114
+ category: "api_url",
115
+ description: "API base URL changed (CRITICAL)",
116
+ current: api.base_url,
117
+ updated: extracted.api_base_url,
118
+ file: CONFIG_PATH,
119
+ });
120
+ }
121
+
122
+ // Models — check for additions/removals
123
+ const modelsTs = readFileSync(MODELS_PATH, "utf-8");
124
+ const currentModels = [...modelsTs.matchAll(/id:\s*"(gpt-[^"]+)"/g)].map((m) => m[1]);
125
+ const extractedModels = extracted.models.filter((m) => m.includes("codex") || currentModels.includes(m));
126
+
127
+ const newModels = extractedModels.filter((m) => !currentModels.includes(m));
128
+ const removedModels = currentModels.filter((m) => !extractedModels.includes(m));
129
+
130
+ if (newModels.length > 0) {
131
+ changes.push({
132
+ type: "semi-auto",
133
+ category: "models_added",
134
+ description: `New models found: ${newModels.join(", ")}`,
135
+ current: currentModels.join(", "),
136
+ updated: extractedModels.join(", "),
137
+ file: MODELS_PATH,
138
+ });
139
+ }
140
+
141
+ if (removedModels.length > 0) {
142
+ changes.push({
143
+ type: "semi-auto",
144
+ category: "models_removed",
145
+ description: `Models removed: ${removedModels.join(", ")}`,
146
+ current: currentModels.join(", "),
147
+ updated: extractedModels.join(", "),
148
+ file: MODELS_PATH,
149
+ });
150
+ }
151
+
152
+ // WHAM endpoints — check for new ones
153
+ const knownEndpoints = [
154
+ "/wham/tasks", "/wham/environments", "/wham/accounts/check",
155
+ "/wham/usage",
156
+ ];
157
+ const newEndpoints = extracted.wham_endpoints.filter(
158
+ (ep) => !knownEndpoints.some((k) => ep.startsWith(k))
159
+ );
160
+ if (newEndpoints.length > 0) {
161
+ changes.push({
162
+ type: "alert",
163
+ category: "endpoints",
164
+ description: `New WHAM endpoints found: ${newEndpoints.join(", ")}`,
165
+ current: "(known set)",
166
+ updated: newEndpoints.join(", "),
167
+ file: "src/proxy/wham-api.ts",
168
+ });
169
+ }
170
+
171
+ // System prompts — check hash changes
172
+ const promptConfigs = [
173
+ { name: "desktop-context", hash: extracted.prompts.desktop_context_hash, path: extracted.prompts.desktop_context_path },
174
+ { name: "title-generation", hash: extracted.prompts.title_generation_hash, path: extracted.prompts.title_generation_path },
175
+ { name: "pr-generation", hash: extracted.prompts.pr_generation_hash, path: extracted.prompts.pr_generation_path },
176
+ { name: "automation-response", hash: extracted.prompts.automation_response_hash, path: extracted.prompts.automation_response_path },
177
+ ];
178
+
179
+ for (const { name, hash, path } of promptConfigs) {
180
+ if (!hash || !path) continue;
181
+
182
+ const configPromptPath = resolve(PROMPTS_DIR, `${name}.md`);
183
+ if (!existsSync(configPromptPath)) {
184
+ changes.push({
185
+ type: "semi-auto",
186
+ category: `prompt_${name}`,
187
+ description: `New prompt file: ${name}.md (not in config/prompts/)`,
188
+ current: "(missing)",
189
+ updated: hash,
190
+ file: configPromptPath,
191
+ });
192
+ continue;
193
+ }
194
+
195
+ const currentContent = readFileSync(configPromptPath, "utf-8");
196
+ const currentHash = `sha256:${createHash("sha256").update(currentContent).digest("hex").slice(0, 16)}`;
197
+
198
+ if (currentHash !== hash) {
199
+ changes.push({
200
+ type: "semi-auto",
201
+ category: `prompt_${name}`,
202
+ description: `System prompt changed: ${name}`,
203
+ current: currentHash,
204
+ updated: hash,
205
+ file: configPromptPath,
206
+ });
207
+ }
208
+ }
209
+
210
+ return changes;
211
+ }
212
+
213
+ function applyAutoChanges(changes: Change[], dryRun: boolean): void {
214
+ const autoChanges = changes.filter((c) => c.type === "auto");
215
+
216
+ if (autoChanges.length === 0) {
217
+ console.log("\n No auto-applicable changes.");
218
+ return;
219
+ }
220
+
221
+ // Group config changes
222
+ const configChanges = autoChanges.filter((c) => c.file === CONFIG_PATH);
223
+
224
+ if (configChanges.length > 0 && !dryRun) {
225
+ let configContent = readFileSync(CONFIG_PATH, "utf-8");
226
+
227
+ for (const change of configChanges) {
228
+ switch (change.category) {
229
+ case "version":
230
+ configContent = configContent.replace(
231
+ /app_version:\s*"[^"]+"/,
232
+ `app_version: "${change.updated}"`,
233
+ );
234
+ break;
235
+ case "build":
236
+ configContent = configContent.replace(
237
+ /build_number:\s*"[^"]+"/,
238
+ `build_number: "${change.updated}"`,
239
+ );
240
+ break;
241
+ case "originator":
242
+ configContent = configContent.replace(
243
+ /originator:\s*"[^"]+"/,
244
+ `originator: "${change.updated}"`,
245
+ );
246
+ break;
247
+ }
248
+ }
249
+
250
+ writeFileSync(CONFIG_PATH, configContent);
251
+ console.log(` [APPLIED] config/default.yaml updated`);
252
+ }
253
+ }
254
+
255
+ function displayReport(changes: Change[], dryRun: boolean): void {
256
+ console.log("\n╔══════════════════════════════════════════╗");
257
+ console.log(`║ Update Analysis ${dryRun ? "(DRY RUN)" : ""} ║`);
258
+ console.log("╠══════════════════════════════════════════╣");
259
+
260
+ if (changes.length === 0) {
261
+ console.log("║ No changes detected — up to date! ║");
262
+ console.log("╚══════════════════════════════════════════╝");
263
+ return;
264
+ }
265
+
266
+ console.log("╚══════════════════════════════════════════╝\n");
267
+
268
+ // Auto changes
269
+ const auto = changes.filter((c) => c.type === "auto");
270
+ if (auto.length > 0) {
271
+ console.log(` AUTO-APPLY (${auto.length}):`);
272
+ for (const c of auto) {
273
+ const action = dryRun ? "WOULD APPLY" : "APPLIED";
274
+ console.log(` [${action}] ${c.description}`);
275
+ console.log(` ${c.current} → ${c.updated}`);
276
+ }
277
+ }
278
+
279
+ // Semi-auto changes
280
+ const semi = changes.filter((c) => c.type === "semi-auto");
281
+ if (semi.length > 0) {
282
+ console.log(`\n SEMI-AUTO (needs review) (${semi.length}):`);
283
+ for (const c of semi) {
284
+ console.log(` [REVIEW] ${c.description}`);
285
+ console.log(` File: ${c.file}`);
286
+ console.log(` Current: ${c.current}`);
287
+ console.log(` New: ${c.updated}`);
288
+ }
289
+ }
290
+
291
+ // Alerts
292
+ const alerts = changes.filter((c) => c.type === "alert");
293
+ if (alerts.length > 0) {
294
+ console.log(`\n *** ALERTS (${alerts.length}) ***`);
295
+ for (const c of alerts) {
296
+ console.log(` [ALERT] ${c.description}`);
297
+ console.log(` File: ${c.file}`);
298
+ console.log(` Current: ${c.current}`);
299
+ console.log(` New: ${c.updated}`);
300
+ }
301
+ }
302
+
303
+ // Prompt diffs
304
+ const promptChanges = changes.filter((c) => c.category.startsWith("prompt_"));
305
+ if (promptChanges.length > 0) {
306
+ console.log("\n PROMPT CHANGES:");
307
+ console.log(" To apply prompt updates, copy from data/extracted-prompts/ to config/prompts/:");
308
+ for (const c of promptChanges) {
309
+ const name = c.category.replace("prompt_", "");
310
+ console.log(` cp data/extracted-prompts/${name}.md config/prompts/${name}.md`);
311
+ }
312
+ }
313
+
314
+ console.log("");
315
+ }
316
+
317
+ async function main() {
318
+ const dryRun = process.argv.includes("--dry-run");
319
+
320
+ console.log("[apply-update] Loading extracted fingerprint...");
321
+ const extracted = loadExtracted();
322
+ console.log(` Extracted: v${extracted.app_version} (build ${extracted.build_number})`);
323
+
324
+ console.log("[apply-update] Comparing with current config...");
325
+ const changes = detectChanges(extracted);
326
+
327
+ displayReport(changes, dryRun);
328
+
329
+ if (!dryRun) {
330
+ applyAutoChanges(changes, dryRun);
331
+ }
332
+
333
+ // Summary
334
+ const auto = changes.filter((c) => c.type === "auto").length;
335
+ const semi = changes.filter((c) => c.type === "semi-auto").length;
336
+ const alerts = changes.filter((c) => c.type === "alert").length;
337
+
338
+ console.log(`[apply-update] Summary: ${auto} auto, ${semi} semi-auto, ${alerts} alerts`);
339
+
340
+ if (semi > 0 || alerts > 0) {
341
+ console.log("[apply-update] Manual review needed for semi-auto and alert items above.");
342
+ }
343
+
344
+ if (dryRun && auto > 0) {
345
+ console.log("[apply-update] Run without --dry-run to apply auto changes.");
346
+ }
347
+ }
348
+
349
+ main().catch((err) => {
350
+ console.error("[apply-update] Fatal:", err);
351
+ process.exit(1);
352
+ });
scripts/check-update.ts ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * check-update.ts — Polls the Codex Sparkle appcast feed for new versions.
4
+ *
5
+ * Usage:
6
+ * npx tsx scripts/check-update.ts [--watch]
7
+ *
8
+ * With --watch: polls every 30 minutes and keeps running.
9
+ * Without: runs once and exits.
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
13
+ import { resolve } from "path";
14
+ import yaml from "js-yaml";
15
+
16
+ const ROOT = resolve(import.meta.dirname, "..");
17
+ const CONFIG_PATH = resolve(ROOT, "config/default.yaml");
18
+ const STATE_PATH = resolve(ROOT, "data/update-state.json");
19
+ const APPCAST_URL = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
20
+ const POLL_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
21
+
22
+ interface UpdateState {
23
+ last_check: string;
24
+ latest_version: string | null;
25
+ latest_build: string | null;
26
+ download_url: string | null;
27
+ update_available: boolean;
28
+ current_version: string;
29
+ current_build: string;
30
+ }
31
+
32
+ interface CurrentConfig {
33
+ app_version: string;
34
+ build_number: string;
35
+ }
36
+
37
+ function loadCurrentConfig(): CurrentConfig {
38
+ const raw = yaml.load(readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
39
+ const client = raw.client as Record<string, string>;
40
+ return {
41
+ app_version: client.app_version,
42
+ build_number: client.build_number,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Parse appcast XML to extract version info.
48
+ * Uses regex-based parsing to avoid heavy XML dependencies.
49
+ */
50
+ function parseAppcast(xml: string): {
51
+ version: string | null;
52
+ build: string | null;
53
+ downloadUrl: string | null;
54
+ } {
55
+ // Extract the latest <item> (first one is usually the latest)
56
+ const itemMatch = xml.match(/<item>([\s\S]*?)<\/item>/i);
57
+ if (!itemMatch) {
58
+ return { version: null, build: null, downloadUrl: null };
59
+ }
60
+ const item = itemMatch[1];
61
+
62
+ // sparkle:shortVersionString = display version
63
+ const versionMatch = item.match(/sparkle:shortVersionString="([^"]+)"/);
64
+ // sparkle:version = build number
65
+ const buildMatch = item.match(/sparkle:version="([^"]+)"/);
66
+ // url = download URL from enclosure
67
+ const urlMatch = item.match(/url="([^"]+)"/);
68
+
69
+ return {
70
+ version: versionMatch?.[1] ?? null,
71
+ build: buildMatch?.[1] ?? null,
72
+ downloadUrl: urlMatch?.[1] ?? null,
73
+ };
74
+ }
75
+
76
+ async function checkOnce(): Promise<UpdateState> {
77
+ const current = loadCurrentConfig();
78
+
79
+ console.log(`[check-update] Current: v${current.app_version} (build ${current.build_number})`);
80
+ console.log(`[check-update] Fetching ${APPCAST_URL}...`);
81
+
82
+ const res = await fetch(APPCAST_URL);
83
+ if (!res.ok) {
84
+ throw new Error(`Failed to fetch appcast: ${res.status} ${res.statusText}`);
85
+ }
86
+
87
+ const xml = await res.text();
88
+ const { version, build, downloadUrl } = parseAppcast(xml);
89
+
90
+ if (!version || !build) {
91
+ console.warn("[check-update] Could not parse version from appcast");
92
+ return {
93
+ last_check: new Date().toISOString(),
94
+ latest_version: null,
95
+ latest_build: null,
96
+ download_url: null,
97
+ update_available: false,
98
+ current_version: current.app_version,
99
+ current_build: current.build_number,
100
+ };
101
+ }
102
+
103
+ const updateAvailable =
104
+ version !== current.app_version || build !== current.build_number;
105
+
106
+ const state: UpdateState = {
107
+ last_check: new Date().toISOString(),
108
+ latest_version: version,
109
+ latest_build: build,
110
+ download_url: downloadUrl,
111
+ update_available: updateAvailable,
112
+ current_version: current.app_version,
113
+ current_build: current.build_number,
114
+ };
115
+
116
+ if (updateAvailable) {
117
+ console.log(`\n *** UPDATE AVAILABLE ***`);
118
+ console.log(` New version: ${version} (build ${build})`);
119
+ console.log(` Current: ${current.app_version} (build ${current.build_number})`);
120
+ if (downloadUrl) {
121
+ console.log(` Download: ${downloadUrl}`);
122
+ }
123
+ console.log(`\n Run: npm run extract -- --path <new-codex-path>`);
124
+ console.log(` Then: npm run apply-update\n`);
125
+ } else {
126
+ console.log(`[check-update] Up to date: v${version} (build ${build})`);
127
+ }
128
+
129
+ // Write state
130
+ mkdirSync(resolve(ROOT, "data"), { recursive: true });
131
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
132
+ console.log(`[check-update] State written to ${STATE_PATH}`);
133
+
134
+ return state;
135
+ }
136
+
137
+ async function main() {
138
+ const watch = process.argv.includes("--watch");
139
+
140
+ await checkOnce();
141
+
142
+ if (watch) {
143
+ console.log(`[check-update] Watching for updates every ${POLL_INTERVAL_MS / 60000} minutes...`);
144
+ setInterval(async () => {
145
+ try {
146
+ await checkOnce();
147
+ } catch (err) {
148
+ console.error("[check-update] Poll error:", err);
149
+ }
150
+ }, POLL_INTERVAL_MS);
151
+ }
152
+ }
153
+
154
+ main().catch((err) => {
155
+ console.error("[check-update] Fatal:", err);
156
+ process.exit(1);
157
+ });
scripts/extract-fingerprint.ts ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * extract-fingerprint.ts — Extracts key fingerprint values from a Codex Desktop
4
+ * installation (macOS .app or Windows extracted ASAR).
5
+ *
6
+ * Usage:
7
+ * npx tsx scripts/extract-fingerprint.ts --path "C:/path/to/Codex" [--asar-out ./asar-out]
8
+ *
9
+ * The path can point to:
10
+ * - A macOS .app bundle (Codex.app)
11
+ * - A directory containing an already-extracted ASAR (with package.json and .vite/build/main.js)
12
+ * - A Windows install dir containing resources/app.asar
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
16
+ import { resolve, join } from "path";
17
+ import { createHash } from "crypto";
18
+ import { execSync } from "child_process";
19
+ import yaml from "js-yaml";
20
+
21
+ const ROOT = resolve(import.meta.dirname, "..");
22
+ const OUTPUT_PATH = resolve(ROOT, "data/extracted-fingerprint.json");
23
+ const PROMPTS_DIR = resolve(ROOT, "data/extracted-prompts");
24
+ const PATTERNS_PATH = resolve(ROOT, "config/extraction-patterns.yaml");
25
+
26
+ interface ExtractionPatterns {
27
+ package_json: { version_key: string; build_number_key: string; sparkle_feed_key: string };
28
+ main_js: Record<string, {
29
+ pattern?: string;
30
+ group?: number;
31
+ global?: boolean;
32
+ start_marker?: string;
33
+ end_marker?: string;
34
+ end_pattern?: string;
35
+ description: string;
36
+ }>;
37
+ }
38
+
39
+ interface ExtractedFingerprint {
40
+ app_version: string;
41
+ build_number: string;
42
+ api_base_url: string | null;
43
+ originator: string | null;
44
+ models: string[];
45
+ wham_endpoints: string[];
46
+ user_agent_contains: string;
47
+ sparkle_feed_url: string | null;
48
+ prompts: {
49
+ desktop_context_hash: string | null;
50
+ desktop_context_path: string | null;
51
+ title_generation_hash: string | null;
52
+ title_generation_path: string | null;
53
+ pr_generation_hash: string | null;
54
+ pr_generation_path: string | null;
55
+ automation_response_hash: string | null;
56
+ automation_response_path: string | null;
57
+ };
58
+ extracted_at: string;
59
+ source_path: string;
60
+ }
61
+
62
+ function sha256(content: string): string {
63
+ return `sha256:${createHash("sha256").update(content, "utf-8").digest("hex").slice(0, 16)}`;
64
+ }
65
+
66
+ function loadPatterns(): ExtractionPatterns {
67
+ const raw = yaml.load(readFileSync(PATTERNS_PATH, "utf-8")) as ExtractionPatterns;
68
+ return raw;
69
+ }
70
+
71
+ /**
72
+ * Find the extracted ASAR root given an input path.
73
+ * Tries multiple layout conventions.
74
+ */
75
+ function findAsarRoot(inputPath: string): string {
76
+ // Direct: path has package.json (already extracted)
77
+ if (existsSync(join(inputPath, "package.json"))) {
78
+ return inputPath;
79
+ }
80
+
81
+ // macOS .app bundle
82
+ const macResources = join(inputPath, "Contents/Resources");
83
+ if (existsSync(join(macResources, "app.asar"))) {
84
+ return extractAsar(join(macResources, "app.asar"));
85
+ }
86
+
87
+ // Windows: resources/app.asar
88
+ const winResources = join(inputPath, "resources");
89
+ if (existsSync(join(winResources, "app.asar"))) {
90
+ return extractAsar(join(winResources, "app.asar"));
91
+ }
92
+
93
+ // Already extracted: check for nested 'extracted' dir
94
+ const extractedDir = join(inputPath, "extracted");
95
+ if (existsSync(join(extractedDir, "package.json"))) {
96
+ return extractedDir;
97
+ }
98
+
99
+ // Check recovered/extracted pattern
100
+ const recoveredExtracted = join(inputPath, "recovered/extracted");
101
+ if (existsSync(join(recoveredExtracted, "package.json"))) {
102
+ return recoveredExtracted;
103
+ }
104
+
105
+ throw new Error(
106
+ `Cannot find Codex source at ${inputPath}. Expected package.json or app.asar.`
107
+ );
108
+ }
109
+
110
+ function extractAsar(asarPath: string): string {
111
+ const outDir = resolve(ROOT, ".asar-out");
112
+ console.log(`[extract] Extracting ASAR: ${asarPath} → ${outDir}`);
113
+ execSync(`npx @electron/asar extract "${asarPath}" "${outDir}"`, {
114
+ stdio: "inherit",
115
+ });
116
+ return outDir;
117
+ }
118
+
119
+ /**
120
+ * Step A: Extract from package.json
121
+ */
122
+ function extractFromPackageJson(root: string): {
123
+ version: string;
124
+ buildNumber: string;
125
+ sparkleFeedUrl: string | null;
126
+ } {
127
+ const pkgPath = join(root, "package.json");
128
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
129
+
130
+ return {
131
+ version: pkg.version ?? "unknown",
132
+ buildNumber: String(pkg.codexBuildNumber ?? "unknown"),
133
+ sparkleFeedUrl: pkg.codexSparkleFeedUrl ?? null,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Step B: Extract values from main.js using patterns
139
+ */
140
+ function extractFromMainJs(
141
+ content: string,
142
+ patterns: ExtractionPatterns["main_js"],
143
+ ): {
144
+ apiBaseUrl: string | null;
145
+ originator: string | null;
146
+ models: string[];
147
+ whamEndpoints: string[];
148
+ userAgentContains: string;
149
+ } {
150
+ // API base URL
151
+ let apiBaseUrl: string | null = null;
152
+ const apiPattern = patterns.api_base_url;
153
+ if (apiPattern?.pattern) {
154
+ const m = content.match(new RegExp(apiPattern.pattern));
155
+ if (m) apiBaseUrl = m[0];
156
+ }
157
+
158
+ // Originator
159
+ let originator: string | null = null;
160
+ const origPattern = patterns.originator;
161
+ if (origPattern?.pattern) {
162
+ const m = content.match(new RegExp(origPattern.pattern));
163
+ if (m) originator = m[origPattern.group ?? 0] ?? m[0];
164
+ }
165
+
166
+ // Models — deduplicate, use capture group if specified
167
+ const models: Set<string> = new Set();
168
+ const modelPattern = patterns.models;
169
+ if (modelPattern?.pattern) {
170
+ const re = new RegExp(modelPattern.pattern, "g");
171
+ const groupIdx = modelPattern.group ?? 0;
172
+ for (const m of content.matchAll(re)) {
173
+ models.add(m[groupIdx] ?? m[0]);
174
+ }
175
+ }
176
+
177
+ // WHAM endpoints — deduplicate, use capture group if specified
178
+ const endpoints: Set<string> = new Set();
179
+ const epPattern = patterns.wham_endpoints;
180
+ if (epPattern?.pattern) {
181
+ const re = new RegExp(epPattern.pattern, "g");
182
+ const epGroupIdx = epPattern.group ?? 0;
183
+ for (const m of content.matchAll(re)) {
184
+ endpoints.add(m[epGroupIdx] ?? m[0]);
185
+ }
186
+ }
187
+
188
+ return {
189
+ apiBaseUrl,
190
+ originator,
191
+ models: [...models].sort(),
192
+ whamEndpoints: [...endpoints].sort(),
193
+ userAgentContains: "Codex Desktop/",
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Step B (continued): Extract system prompts from main.js
199
+ */
200
+ function extractPrompts(content: string): {
201
+ desktopContext: string | null;
202
+ titleGeneration: string | null;
203
+ prGeneration: string | null;
204
+ automationResponse: string | null;
205
+ } {
206
+ // Desktop context: from "# Codex desktop context" to the end of the template literal
207
+ let desktopContext: string | null = null;
208
+ const dcStart = content.indexOf("# Codex desktop context");
209
+ if (dcStart !== -1) {
210
+ // Find the closing backtick of the template literal
211
+ // Look backwards from dcStart for the opening backtick to understand nesting
212
+ // Then scan forward for the matching close
213
+ const afterStart = content.indexOf("`;", dcStart);
214
+ if (afterStart !== -1) {
215
+ desktopContext = content.slice(dcStart, afterStart).trim();
216
+ }
217
+ }
218
+
219
+ // Title generation: from the function that builds the array
220
+ let titleGeneration: string | null = null;
221
+ const titleMarker = "You are a helpful assistant. You will be presented with a user prompt";
222
+ const titleStart = content.indexOf(titleMarker);
223
+ if (titleStart !== -1) {
224
+ // Find the enclosing array end: ].join(
225
+ const joinIdx = content.indexOf("].join(", titleStart);
226
+ if (joinIdx !== -1) {
227
+ // Extract the array content between [ and ]
228
+ const bracketStart = content.lastIndexOf("[", titleStart);
229
+ if (bracketStart !== -1) {
230
+ const arrayContent = content.slice(bracketStart + 1, joinIdx);
231
+ // Parse string literals from the array
232
+ titleGeneration = parseStringArray(arrayContent);
233
+ }
234
+ }
235
+ }
236
+
237
+ // PR generation
238
+ let prGeneration: string | null = null;
239
+ const prMarker = "You are a helpful assistant. Generate a pull request title";
240
+ const prStart = content.indexOf(prMarker);
241
+ if (prStart !== -1) {
242
+ const joinIdx = content.indexOf("].join(", prStart);
243
+ if (joinIdx !== -1) {
244
+ const bracketStart = content.lastIndexOf("[", prStart);
245
+ if (bracketStart !== -1) {
246
+ const arrayContent = content.slice(bracketStart + 1, joinIdx);
247
+ prGeneration = parseStringArray(arrayContent);
248
+ }
249
+ }
250
+ }
251
+
252
+ // Automation response: template literal starting with "Response MUST end with"
253
+ let automationResponse: string | null = null;
254
+ const autoMarker = "Response MUST end with a remark-directive block";
255
+ const autoStart = content.indexOf(autoMarker);
256
+ if (autoStart !== -1) {
257
+ const afterAuto = content.indexOf("`;", autoStart);
258
+ if (afterAuto !== -1) {
259
+ automationResponse = content.slice(autoStart, afterAuto).trim();
260
+ }
261
+ }
262
+
263
+ return { desktopContext, titleGeneration, prGeneration, automationResponse };
264
+ }
265
+
266
+ /**
267
+ * Parse a JavaScript string array content into a single joined string.
268
+ * Handles simple quoted strings separated by commas.
269
+ */
270
+ function parseStringArray(arrayContent: string): string {
271
+ const lines: string[] = [];
272
+ // Match quoted strings (both single and double quotes) and template literals
273
+ const stringRe = /"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g;
274
+ for (const m of arrayContent.matchAll(stringRe)) {
275
+ const str = m[1] ?? m[2] ?? "";
276
+ // Unescape common sequences
277
+ lines.push(
278
+ str
279
+ .replace(/\\n/g, "\n")
280
+ .replace(/\\t/g, "\t")
281
+ .replace(/\\"/g, '"')
282
+ .replace(/\\'/g, "'")
283
+ .replace(/\\\\/g, "\\")
284
+ );
285
+ }
286
+ return lines.join("\n");
287
+ }
288
+
289
+ function savePrompt(name: string, content: string | null): { hash: string | null; path: string | null } {
290
+ if (!content) return { hash: null, path: null };
291
+
292
+ mkdirSync(PROMPTS_DIR, { recursive: true });
293
+ const filePath = join(PROMPTS_DIR, `${name}.md`);
294
+ writeFileSync(filePath, content);
295
+
296
+ return {
297
+ hash: sha256(content),
298
+ path: filePath,
299
+ };
300
+ }
301
+
302
+ async function main() {
303
+ // Parse --path argument
304
+ const pathIdx = process.argv.indexOf("--path");
305
+ if (pathIdx === -1 || !process.argv[pathIdx + 1]) {
306
+ console.error("Usage: npx tsx scripts/extract-fingerprint.ts --path <codex-path>");
307
+ console.error("");
308
+ console.error(" <codex-path> can be:");
309
+ console.error(" - macOS: /path/to/Codex.app");
310
+ console.error(" - Windows: C:/path/to/Codex (containing resources/app.asar)");
311
+ console.error(" - Extracted: directory with package.json and .vite/build/main.js");
312
+ process.exit(1);
313
+ }
314
+
315
+ const inputPath = resolve(process.argv[pathIdx + 1]);
316
+ console.log(`[extract] Input: ${inputPath}`);
317
+
318
+ // Find ASAR root
319
+ const asarRoot = findAsarRoot(inputPath);
320
+ console.log(`[extract] ASAR root: ${asarRoot}`);
321
+
322
+ // Load extraction patterns
323
+ const patterns = loadPatterns();
324
+
325
+ // Step A: package.json
326
+ console.log("[extract] Reading package.json...");
327
+ const { version, buildNumber, sparkleFeedUrl } = extractFromPackageJson(asarRoot);
328
+ console.log(` version: ${version}`);
329
+ console.log(` build: ${buildNumber}`);
330
+
331
+ // Step B: main.js
332
+ console.log("[extract] Loading main.js...");
333
+ const mainJs = await (async () => {
334
+ const mainPath = join(asarRoot, ".vite/build/main.js");
335
+ if (!existsSync(mainPath)) {
336
+ console.warn("[extract] main.js not found, skipping JS extraction");
337
+ return null;
338
+ }
339
+
340
+ const content = readFileSync(mainPath, "utf-8");
341
+ const lineCount = content.split("\n").length;
342
+
343
+ if (lineCount < 100 && content.length > 100000) {
344
+ console.log("[extract] main.js appears minified, attempting beautify...");
345
+ try {
346
+ const jsBeautify = await import("js-beautify");
347
+ return jsBeautify.default.js(content, { indent_size: 2 });
348
+ } catch {
349
+ console.warn("[extract] js-beautify not available, using raw content");
350
+ return content;
351
+ }
352
+ }
353
+ return content;
354
+ })();
355
+
356
+ let mainJsResults = {
357
+ apiBaseUrl: null as string | null,
358
+ originator: null as string | null,
359
+ models: [] as string[],
360
+ whamEndpoints: [] as string[],
361
+ userAgentContains: "Codex Desktop/",
362
+ };
363
+
364
+ let promptResults = {
365
+ desktopContext: null as string | null,
366
+ titleGeneration: null as string | null,
367
+ prGeneration: null as string | null,
368
+ automationResponse: null as string | null,
369
+ };
370
+
371
+ if (mainJs) {
372
+ console.log(`[extract] main.js loaded (${mainJs.split("\n").length} lines)`);
373
+
374
+ mainJsResults = extractFromMainJs(mainJs, patterns.main_js);
375
+ console.log(` API base URL: ${mainJsResults.apiBaseUrl}`);
376
+ console.log(` originator: ${mainJsResults.originator}`);
377
+ console.log(` models: ${mainJsResults.models.join(", ")}`);
378
+ console.log(` WHAM endpoints: ${mainJsResults.whamEndpoints.length} found`);
379
+
380
+ // Extract system prompts
381
+ console.log("[extract] Extracting system prompts...");
382
+ promptResults = extractPrompts(mainJs);
383
+ console.log(` desktop-context: ${promptResults.desktopContext ? "found" : "NOT FOUND"}`);
384
+ console.log(` title-generation: ${promptResults.titleGeneration ? "found" : "NOT FOUND"}`);
385
+ console.log(` pr-generation: ${promptResults.prGeneration ? "found" : "NOT FOUND"}`);
386
+ console.log(` automation-response: ${promptResults.automationResponse ? "found" : "NOT FOUND"}`);
387
+ }
388
+
389
+ // Save extracted prompts
390
+ const dc = savePrompt("desktop-context", promptResults.desktopContext);
391
+ const tg = savePrompt("title-generation", promptResults.titleGeneration);
392
+ const pr = savePrompt("pr-generation", promptResults.prGeneration);
393
+ const ar = savePrompt("automation-response", promptResults.automationResponse);
394
+
395
+ // Build output
396
+ const fingerprint: ExtractedFingerprint = {
397
+ app_version: version,
398
+ build_number: buildNumber,
399
+ api_base_url: mainJsResults.apiBaseUrl,
400
+ originator: mainJsResults.originator,
401
+ models: mainJsResults.models,
402
+ wham_endpoints: mainJsResults.whamEndpoints,
403
+ user_agent_contains: mainJsResults.userAgentContains,
404
+ sparkle_feed_url: sparkleFeedUrl,
405
+ prompts: {
406
+ desktop_context_hash: dc.hash,
407
+ desktop_context_path: dc.path,
408
+ title_generation_hash: tg.hash,
409
+ title_generation_path: tg.path,
410
+ pr_generation_hash: pr.hash,
411
+ pr_generation_path: pr.path,
412
+ automation_response_hash: ar.hash,
413
+ automation_response_path: ar.path,
414
+ },
415
+ extracted_at: new Date().toISOString(),
416
+ source_path: inputPath,
417
+ };
418
+
419
+ // Write output
420
+ mkdirSync(resolve(ROOT, "data"), { recursive: true });
421
+ writeFileSync(OUTPUT_PATH, JSON.stringify(fingerprint, null, 2));
422
+
423
+ console.log(`\n[extract] Fingerprint written to ${OUTPUT_PATH}`);
424
+ console.log(`[extract] Prompts written to ${PROMPTS_DIR}/`);
425
+ console.log("[extract] Done.");
426
+ }
427
+
428
+ main().catch((err) => {
429
+ console.error("[extract] Fatal:", err);
430
+ process.exit(1);
431
+ });
scripts/test-create-env-gh.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { loadConfig, loadFingerprint } from '../src/config.js';
2
+ import { ProxyClient } from '../src/proxy/client.js';
3
+ import { AuthManager } from '../src/auth/manager.js';
4
+
5
+ async function main() {
6
+ loadConfig(); loadFingerprint();
7
+ const am = new AuthManager();
8
+ const tk = await am.getToken();
9
+ if (!tk) { console.log('Not auth'); return; }
10
+ const c = new ProxyClient(tk, am.getAccountId());
11
+
12
+ // Create environment: github/octocat/Hello-World
13
+ console.log('=== Create env ===');
14
+ const r = await c.post('wham/environments', {
15
+ machine_id: '6993d698c6a48190bcc7ed131c5f34b4',
16
+ repos: ['github/octocat/Hello-World'],
17
+ label: 'proxy-env',
18
+ });
19
+ console.log(r.status, JSON.stringify(r.body).slice(0, 800));
20
+
21
+ if (r.ok) {
22
+ const env = r.body as { environment_id?: string; id?: string };
23
+ const envId = env.environment_id || env.id;
24
+ console.log('Environment ID:', envId);
25
+
26
+ if (envId) {
27
+ // Create task with this environment
28
+ console.log('\n=== Create task with env ===');
29
+ const tr = await c.post('wham/tasks', {
30
+ new_task: {
31
+ branch: 'main',
32
+ environment_id: envId,
33
+ run_environment_in_qa_mode: false,
34
+ },
35
+ input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hello in one sentence.' }] }],
36
+ model: 'gpt-5.1-codex-mini',
37
+ developer_instructions: null,
38
+ base_instructions: null,
39
+ personality: null,
40
+ approval_policy: 'never',
41
+ sandbox: 'workspace-write',
42
+ ephemeral: null,
43
+ });
44
+ console.log(tr.status, JSON.stringify(tr.body).slice(0, 800));
45
+
46
+ const taskBody = tr.body as { task?: { id?: string; current_turn_id?: string } };
47
+ if (tr.ok && taskBody?.task?.id) {
48
+ const taskId = taskBody.task.id;
49
+ const turnId = taskBody.task.current_turn_id;
50
+ console.log('\nPolling...');
51
+ for (let i = 0; i < 60; i++) {
52
+ await new Promise(res => setTimeout(res, 3000));
53
+ const poll = await c.get(
54
+ 'wham/tasks/' + encodeURIComponent(taskId) + '/turns/' + encodeURIComponent(turnId!)
55
+ );
56
+ const pd = poll.body as { turn?: { turn_status?: string; output_items?: unknown[]; error?: unknown } };
57
+ const st = pd?.turn?.turn_status;
58
+ console.log(' ' + i + ': ' + st);
59
+ if (st === 'completed' || st === 'failed' || st === 'cancelled') {
60
+ if (pd?.turn?.error) console.log(' Error:', JSON.stringify(pd.turn.error));
61
+ if (pd?.turn?.output_items) console.log(' Output:', JSON.stringify(pd.turn.output_items).slice(0, 1000));
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ // Also try listing environments now
70
+ console.log('\n=== List environments ===');
71
+ const le = await c.get('wham/environments');
72
+ console.log(le.status, JSON.stringify(le.body).slice(0, 400));
73
+ }
74
+ main().catch(e => console.error(e));
scripts/test-create-env-real.ts ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { loadConfig, loadFingerprint } from '../src/config.js';
2
+ import { ProxyClient } from '../src/proxy/client.js';
3
+ import { AuthManager } from '../src/auth/manager.js';
4
+ import { buildHeadersWithContentType } from '../src/fingerprint/manager.js';
5
+
6
+ async function main() {
7
+ loadConfig(); loadFingerprint();
8
+ const am = new AuthManager();
9
+ const tk = await am.getToken();
10
+ if (!tk) { console.log('Not authenticated'); return; }
11
+ const aid = am.getAccountId();
12
+ const c = new ProxyClient(tk, aid);
13
+ const base = 'https://chatgpt.com/backend-api';
14
+
15
+ const machineId = '6993d698c6a48190bcc7ed131c5f34b4';
16
+
17
+ // Test 1: Create environment with a real public GitHub repo
18
+ console.log('=== Test 1: POST /wham/environments with real repo ===');
19
+ try {
20
+ const r = await c.post('wham/environments', {
21
+ machine_id: machineId,
22
+ repos: [{ provider: 'github', owner: 'octocat', name: 'Hello-World' }],
23
+ label: 'test-env',
24
+ });
25
+ console.log(r.status, JSON.stringify(r.body).slice(0, 600));
26
+ } catch (e: unknown) { console.log('Error:', (e as Error).message); }
27
+
28
+ // Test 2: Different repos format
29
+ console.log('\n=== Test 2: repos as string array ===');
30
+ try {
31
+ const r = await c.post('wham/environments', {
32
+ machine_id: machineId,
33
+ repos: ['octocat/Hello-World'],
34
+ label: 'test-env-2',
35
+ });
36
+ console.log(r.status, JSON.stringify(r.body).slice(0, 600));
37
+ } catch (e: unknown) { console.log('Error:', (e as Error).message); }
38
+
39
+ // Test 3: Minimal environment creation
40
+ console.log('\n=== Test 3: minimal env ===');
41
+ try {
42
+ const r = await c.post('wham/environments', {
43
+ machine_id: machineId,
44
+ });
45
+ console.log(r.status, JSON.stringify(r.body).slice(0, 600));
46
+ } catch (e: unknown) { console.log('Error:', (e as Error).message); }
47
+
48
+ // Test 4: Create environment via different endpoint
49
+ console.log('\n=== Test 4: POST /wham/environments/create ===');
50
+ try {
51
+ const r = await c.post('wham/environments/create', {
52
+ machine_id: machineId,
53
+ repos: ['octocat/Hello-World'],
54
+ });
55
+ console.log(r.status, JSON.stringify(r.body).slice(0, 600));
56
+ } catch (e: unknown) { console.log('Error:', (e as Error).message); }
57
+
58
+ // Test 5: Try the webview approach - look at how the webview creates environments
59
+ // The webview uses POST /wham/tasks with environment embedded
60
+ // Let me try creating a task without environment_id but with DELETE of the key
61
+ console.log('\n=== Test 5: POST /wham/tasks with environment_id deleted (raw fetch) ===');
62
+ try {
63
+ const body: Record<string, unknown> = {
64
+ new_task: {
65
+ branch: 'main',
66
+ run_environment_in_qa_mode: false,
67
+ },
68
+ input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hello' }] }],
69
+ model: 'gpt-5.1-codex-mini',
70
+ developer_instructions: null,
71
+ base_instructions: null,
72
+ personality: null,
73
+ approval_policy: 'never',
74
+ sandbox: 'workspace-write',
75
+ ephemeral: null,
76
+ };
77
+ // Verify environment_id is truly not in the JSON
78
+ console.log('Body keys:', Object.keys(body.new_task as object));
79
+ const res = await fetch(`${base}/wham/tasks`, {
80
+ method: 'POST',
81
+ headers: buildHeadersWithContentType(tk, aid),
82
+ body: JSON.stringify(body),
83
+ });
84
+ const data = await res.json();
85
+ console.log(res.status, JSON.stringify(data).slice(0, 600));
86
+ } catch (e: unknown) { console.log('Error:', (e as Error).message); }
87
+
88
+ // Test 6: What about follow_up without new_task?
89
+ console.log('\n=== Test 6: POST /wham/tasks without new_task (just input_items) ===');
90
+ try {
91
+ const body = {
92
+ input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hello' }] }],
93
+ model: 'gpt-5.1-codex-mini',
94
+ };
95
+ const res = await fetch(`${base}/wham/tasks`, {
96
+ method: 'POST',
97
+ headers: buildHeadersWithContentType(tk, aid),
98
+ body: JSON.stringify(body),
99
+ });
100
+ const data = await res.json();
101
+ console.log(res.status, JSON.stringify(data).slice(0, 600));
102
+ } catch (e: unknown) { console.log('Error:', (e as Error).message); }
103
+
104
+ // Test 7: Maybe the issue is that we need to send a requestBody wrapper?
105
+ // The webview uses: CodexRequest.safePost("/wham/tasks", { requestBody: {...} })
106
+ console.log('\n=== Test 7: POST /wham/tasks with requestBody wrapper ===');
107
+ try {
108
+ const body = {
109
+ requestBody: {
110
+ new_task: {
111
+ branch: 'main',
112
+ run_environment_in_qa_mode: false,
113
+ },
114
+ input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hello' }] }],
115
+ model: 'gpt-5.1-codex-mini',
116
+ },
117
+ };
118
+ const res = await fetch(`${base}/wham/tasks`, {
119
+ method: 'POST',
120
+ headers: buildHeadersWithContentType(tk, aid),
121
+ body: JSON.stringify(body),
122
+ });
123
+ const data = await res.json();
124
+ console.log(res.status, JSON.stringify(data).slice(0, 600));
125
+ } catch (e: unknown) { console.log('Error:', (e as Error).message); }
126
+ }
127
+
128
+ main().catch(e => console.error(e));
scripts/test-env-full.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { loadConfig, loadFingerprint } from '../src/config.js';
2
+ import { ProxyClient } from '../src/proxy/client.js';
3
+ import { AuthManager } from '../src/auth/manager.js';
4
+ import { buildHeaders, buildHeadersWithContentType } from '../src/fingerprint/manager.js';
5
+
6
+ async function main() {
7
+ loadConfig(); loadFingerprint();
8
+ const am = new AuthManager();
9
+ const tk = await am.getToken();
10
+ if (!tk) { console.log('Not authenticated'); return; }
11
+ const aid = am.getAccountId();
12
+ const c = new ProxyClient(tk, aid);
13
+ const base = 'https://chatgpt.com/backend-api';
14
+
15
+ // Try all possible environment-related endpoints
16
+ const endpoints = [
17
+ { method: 'GET', path: 'wham/environments' },
18
+ { method: 'GET', path: 'wham/machines' },
19
+ { method: 'GET', path: 'wham/accounts/check' },
20
+ { method: 'GET', path: 'wham/usage' },
21
+ { method: 'GET', path: 'wham/tasks' },
22
+ // Codex CLI endpoints
23
+ { method: 'GET', path: 'api/codex/environments' },
24
+ // Try to see if there's a way to check features
25
+ { method: 'GET', path: 'wham/features' },
26
+ { method: 'GET', path: 'wham/config' },
27
+ // Try without environment at all — use ephemeral
28
+ { method: 'POST', path: 'wham/tasks', body: {
29
+ new_task: { branch: 'main', environment_id: null, run_environment_in_qa_mode: false },
30
+ input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hi' }] }],
31
+ model: 'gpt-5.1-codex-mini',
32
+ ephemeral: true,
33
+ }},
34
+ // Try with ephemeral flag and no environment
35
+ { method: 'POST', path: 'wham/tasks', body: {
36
+ new_task: { branch: 'main' },
37
+ input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hi' }] }],
38
+ model: 'gpt-5.1-codex-mini',
39
+ ephemeral: true,
40
+ approval_policy: 'never',
41
+ sandbox: 'workspace-write',
42
+ }},
43
+ ];
44
+
45
+ for (const ep of endpoints) {
46
+ console.log(`\n=== ${ep.method} /${ep.path} ===`);
47
+ try {
48
+ let res: Response;
49
+ if (ep.method === 'POST') {
50
+ res = await fetch(`${base}/${ep.path}`, {
51
+ method: 'POST',
52
+ headers: buildHeadersWithContentType(tk, aid),
53
+ body: JSON.stringify(ep.body),
54
+ });
55
+ } else {
56
+ res = await fetch(`${base}/${ep.path}`, {
57
+ headers: buildHeaders(tk, aid),
58
+ });
59
+ }
60
+ const ct = res.headers.get('content-type') || '';
61
+ if (ct.includes('json')) {
62
+ const data = await res.json();
63
+ console.log(res.status, JSON.stringify(data).slice(0, 600));
64
+ } else {
65
+ const text = await res.text();
66
+ console.log(res.status, text.slice(0, 300));
67
+ }
68
+ } catch (e: unknown) {
69
+ console.log('Error:', (e as Error).message);
70
+ }
71
+ }
72
+
73
+ // Also try listing existing tasks to see if any have environment info
74
+ console.log('\n=== GET /wham/tasks (list existing tasks) ===');
75
+ try {
76
+ const r = await fetch(`${base}/wham/tasks`, {
77
+ headers: buildHeaders(tk, aid),
78
+ });
79
+ const data = await r.json() as { items?: Array<{ id?: string; task_status_display?: unknown }> };
80
+ console.log(r.status);
81
+ if (data.items) {
82
+ console.log('Total tasks:', data.items.length);
83
+ for (const t of data.items.slice(0, 3)) {
84
+ console.log(' Task:', t.id, JSON.stringify(t.task_status_display).slice(0, 200));
85
+ }
86
+ } else {
87
+ console.log(JSON.stringify(data).slice(0, 400));
88
+ }
89
+ } catch (e: unknown) {
90
+ console.log('Error:', (e as Error).message);
91
+ }
92
+
93
+ // Check a specific task to see its environment info
94
+ console.log('\n=== Check a recent task for environment details ===');
95
+ try {
96
+ const r = await fetch(`${base}/wham/tasks`, {
97
+ headers: buildHeaders(tk, aid),
98
+ });
99
+ const data = await r.json() as { items?: Array<{ id?: string }> };
100
+ if (data.items && data.items.length > 0) {
101
+ const taskId = data.items[0].id!;
102
+ const tr = await fetch(`${base}/wham/tasks/${encodeURIComponent(taskId)}`, {
103
+ headers: buildHeaders(tk, aid),
104
+ });
105
+ const td = await tr.json();
106
+ console.log('Task detail:', JSON.stringify(td).slice(0, 1000));
107
+ }
108
+ } catch (e: unknown) {
109
+ console.log('Error:', (e as Error).message);
110
+ }
111
+ }
112
+
113
+ main().catch(e => console.error(e));
scripts/test-repo-format.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { loadConfig, loadFingerprint } from '../src/config.js';
2
+ import { ProxyClient } from '../src/proxy/client.js';
3
+ import { AuthManager } from '../src/auth/manager.js';
4
+
5
+ async function main() {
6
+ loadConfig(); loadFingerprint();
7
+ const am = new AuthManager();
8
+ const tk = await am.getToken();
9
+ if (!tk) { console.log('Not auth'); return; }
10
+ const c = new ProxyClient(tk, am.getAccountId());
11
+ const mid = '6993d698c6a48190bcc7ed131c5f34b4';
12
+
13
+ const formats = [
14
+ 'github:octocat/Hello-World',
15
+ 'octocat/Hello-World',
16
+ 'github/octocat/hello-world',
17
+ 'https://github.com/octocat/Hello-World',
18
+ ];
19
+
20
+ for (const repo of formats) {
21
+ console.log('--- repos: ["' + repo + '"] ---');
22
+ const r = await c.post('wham/environments', { machine_id: mid, repos: [repo], label: 'test' });
23
+ console.log(r.status, JSON.stringify(r.body).slice(0, 300));
24
+ console.log('');
25
+ }
26
+ }
27
+ main().catch(e => console.error(e));
scripts/test-repo2.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { loadConfig, loadFingerprint } from '../src/config.js';
2
+ import { ProxyClient } from '../src/proxy/client.js';
3
+ import { AuthManager } from '../src/auth/manager.js';
4
+ async function main() {
5
+ loadConfig(); loadFingerprint();
6
+ const am = new AuthManager();
7
+ const tk = await am.getToken();
8
+ if (!tk) { console.log('no auth'); return; }
9
+ const c = new ProxyClient(tk, am.getAccountId());
10
+ const mid = '6993d698c6a48190bcc7ed131c5f34b4';
11
+ const tests = [
12
+ 'github/torvalds/linux',
13
+ 'github/facebook/react',
14
+ 'torvalds/linux',
15
+ ];
16
+ for (const repo of tests) {
17
+ console.log('repos: [' + repo + ']');
18
+ const r = await c.post('wham/environments', { machine_id: mid, repos: [repo], label: 'test' });
19
+ console.log(r.status, JSON.stringify(r.body).slice(0, 400));
20
+ console.log('');
21
+ }
22
+ }
23
+ main();
scripts/test-responses-api.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { loadConfig, loadFingerprint } from '../src/config.js';
2
+ import { AuthManager } from '../src/auth/manager.js';
3
+ import { buildHeadersWithContentType } from '../src/fingerprint/manager.js';
4
+
5
+ async function main() {
6
+ loadConfig(); loadFingerprint();
7
+ const am = new AuthManager();
8
+ const tk = await am.getToken();
9
+ if (!tk) { console.log('no auth token'); return; }
10
+ const aid = am.getAccountId();
11
+
12
+ const base = 'https://chatgpt.com/backend-api/codex';
13
+ const hdrs = buildHeadersWithContentType(tk, aid);
14
+
15
+ // Test 1: non-streaming
16
+ console.log('=== Test 1: POST /codex/responses (non-streaming) ===');
17
+ try {
18
+ const r = await fetch(base + '/responses', {
19
+ method: 'POST',
20
+ headers: hdrs,
21
+ body: JSON.stringify({
22
+ model: 'gpt-5.1-codex-mini',
23
+ instructions: 'You are a helpful assistant.',
24
+ input: [{ role: 'user', content: 'Say hello in one sentence.' }],
25
+ stream: false,
26
+ store: false,
27
+ }),
28
+ });
29
+ console.log('Status:', r.status, r.statusText);
30
+ const ct = r.headers.get('content-type') || '';
31
+ console.log('Content-Type:', ct);
32
+ if (ct.includes('json')) {
33
+ const j = await r.json();
34
+ console.log('Body:', JSON.stringify(j, null, 2).slice(0, 3000));
35
+ } else {
36
+ const t = await r.text();
37
+ console.log('Body:', t.slice(0, 2000));
38
+ }
39
+ } catch (e: any) {
40
+ console.log('Error:', e.message);
41
+ }
42
+
43
+ // Test 2: streaming
44
+ console.log('\n=== Test 2: POST /codex/responses (streaming) ===');
45
+ try {
46
+ const streamHdrs = { ...hdrs, 'Accept': 'text/event-stream' };
47
+ const r = await fetch(base + '/responses', {
48
+ method: 'POST',
49
+ headers: streamHdrs,
50
+ body: JSON.stringify({
51
+ model: 'gpt-5.1-codex-mini',
52
+ instructions: 'You are a helpful assistant.',
53
+ input: [{ role: 'user', content: 'Say hello in one sentence.' }],
54
+ stream: true,
55
+ store: false,
56
+ }),
57
+ });
58
+ console.log('Status:', r.status, r.statusText);
59
+ const ct = r.headers.get('content-type') || '';
60
+ console.log('Content-Type:', ct);
61
+
62
+ if (r.status !== 200) {
63
+ const t = await r.text();
64
+ console.log('Error body:', t.slice(0, 1000));
65
+ } else {
66
+ const reader = r.body!.getReader();
67
+ const decoder = new TextDecoder();
68
+ let fullText = '';
69
+ let chunks = 0;
70
+ while (true) {
71
+ const { done, value } = await reader.read();
72
+ if (done) break;
73
+ const chunk = decoder.decode(value, { stream: true });
74
+ fullText += chunk;
75
+ chunks++;
76
+ if (chunks <= 10) {
77
+ console.log(`--- Chunk ${chunks} ---`);
78
+ console.log(chunk.slice(0, 300));
79
+ }
80
+ }
81
+ console.log('\nTotal chunks:', chunks);
82
+ console.log('Full response length:', fullText.length);
83
+ // Show some events
84
+ const events = fullText.split('\n\n').filter(e => e.trim());
85
+ console.log('Total SSE events:', events.length);
86
+ // Show last few events
87
+ const last5 = events.slice(-5);
88
+ console.log('\nLast 5 events:');
89
+ for (const ev of last5) {
90
+ console.log(ev.slice(0, 300));
91
+ console.log('---');
92
+ }
93
+ }
94
+ } catch (e: any) {
95
+ console.log('Error:', e.message);
96
+ }
97
+ }
98
+
99
+ main();
scripts/test-snapshot.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { loadConfig, loadFingerprint } from '../src/config.js';
2
+ import { ProxyClient } from '../src/proxy/client.js';
3
+ import { AuthManager } from '../src/auth/manager.js';
4
+
5
+ async function main() {
6
+ loadConfig(); loadFingerprint();
7
+ const am = new AuthManager();
8
+ const tk = await am.getToken();
9
+ if (!tk) { console.log('Not authenticated'); return; }
10
+ const c = new ProxyClient(tk, am.getAccountId());
11
+
12
+ // Step 1: Get upload URL for a worktree snapshot
13
+ console.log('=== Step 1: POST /wham/worktree_snapshots/upload_url ===');
14
+ const r1 = await c.post('wham/worktree_snapshots/upload_url', {
15
+ file_name: 'snapshot.tar.gz',
16
+ file_size: 100,
17
+ repo_name: 'workspace',
18
+ });
19
+ console.log('Status:', r1.status);
20
+ console.log('Body:', JSON.stringify(r1.body).slice(0, 800));
21
+
22
+ if (!r1.ok) {
23
+ console.log('Failed to get upload URL');
24
+ return;
25
+ }
26
+
27
+ const body1 = r1.body as { upload_url?: string; file_id?: string; status?: string };
28
+ console.log('Upload URL:', body1.upload_url?.slice(0, 100));
29
+ console.log('File ID:', body1.file_id);
30
+
31
+ // Step 2: Upload a minimal tar.gz (empty)
32
+ if (body1.upload_url) {
33
+ console.log('\n=== Step 2: Upload minimal snapshot ===');
34
+ // Create a minimal gzip file (empty gzip)
35
+ const emptyGzip = Buffer.from([
36
+ 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00,
37
+ 0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
38
+ 0x00, 0x00, 0x00, 0x00,
39
+ ]);
40
+
41
+ const uploadRes = await fetch(body1.upload_url, {
42
+ method: 'PUT',
43
+ body: emptyGzip,
44
+ headers: { 'Content-Type': 'application/gzip' },
45
+ });
46
+ console.log('Upload status:', uploadRes.status);
47
+ const uploadText = await uploadRes.text();
48
+ console.log('Upload response:', uploadText.slice(0, 300));
49
+ }
50
+
51
+ // Step 3: Finish upload
52
+ if (body1.file_id) {
53
+ console.log('\n=== Step 3: POST /wham/worktree_snapshots/finish_upload ===');
54
+ const r3 = await c.post('wham/worktree_snapshots/finish_upload', {
55
+ file_id: body1.file_id,
56
+ });
57
+ console.log('Status:', r3.status);
58
+ console.log('Body:', JSON.stringify(r3.body).slice(0, 800));
59
+
60
+ // Step 4: Create a task with this file_id
61
+ console.log('\n=== Step 4: Create task with valid file_id ===');
62
+ const r4 = await c.post('wham/tasks', {
63
+ new_task: {
64
+ branch: 'main',
65
+ environment_id: null,
66
+ environment: {
67
+ repos: [{
68
+ kind: 'local_worktree',
69
+ name: 'workspace',
70
+ remotes: {},
71
+ commit_sha: '0000000000000000000000000000000000000000',
72
+ branch: 'main',
73
+ file_id: body1.file_id,
74
+ }],
75
+ },
76
+ run_environment_in_qa_mode: false,
77
+ },
78
+ input_items: [
79
+ { type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hello in one sentence.' }] }
80
+ ],
81
+ model: 'gpt-5.1-codex-mini',
82
+ developer_instructions: null,
83
+ base_instructions: null,
84
+ personality: null,
85
+ approval_policy: 'never',
86
+ sandbox: 'workspace-write',
87
+ ephemeral: null,
88
+ });
89
+ console.log('Task status:', r4.status);
90
+ console.log('Task body:', JSON.stringify(r4.body).slice(0, 600));
91
+
92
+ // Step 5: Poll for result
93
+ const taskBody = r4.body as { task?: { id?: string; current_turn_id?: string } };
94
+ if (r4.ok && taskBody?.task?.id) {
95
+ const taskId = taskBody.task.id;
96
+ const turnId = taskBody.task.current_turn_id!;
97
+ console.log('\n=== Step 5: Poll for turn completion ===');
98
+ console.log('Task:', taskId);
99
+ console.log('Turn:', turnId);
100
+
101
+ for (let i = 0; i < 30; i++) {
102
+ await new Promise(res => setTimeout(res, 3000));
103
+ const tr = await c.get(`wham/tasks/${encodeURIComponent(taskId)}/turns/${encodeURIComponent(turnId)}`);
104
+ const td = tr.body as { turn?: { turn_status?: string; output_items?: unknown[]; error?: unknown } };
105
+ const status = td?.turn?.turn_status;
106
+ console.log(` Poll ${i}: status=${status}`);
107
+ if (status === 'completed' || status === 'failed' || status === 'cancelled') {
108
+ if (td?.turn?.error) console.log(' Error:', JSON.stringify(td.turn.error));
109
+ if (td?.turn?.output_items) console.log(' Output:', JSON.stringify(td.turn.output_items).slice(0, 500));
110
+ break;
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ main().catch(e => console.error(e));
src/auth/account-pool.ts ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AccountPool — multi-account manager with least-used rotation.
3
+ * Replaces the single-account AuthManager.
4
+ */
5
+
6
+ import {
7
+ readFileSync,
8
+ writeFileSync,
9
+ renameSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ } from "fs";
13
+ import { resolve, dirname } from "path";
14
+ import { randomBytes } from "crypto";
15
+ import { getConfig } from "../config.js";
16
+ import {
17
+ decodeJwtPayload,
18
+ extractChatGptAccountId,
19
+ extractUserProfile,
20
+ isTokenExpired,
21
+ } from "./jwt-utils.js";
22
+ import type {
23
+ AccountEntry,
24
+ AccountInfo,
25
+ AccountUsage,
26
+ AcquiredAccount,
27
+ AccountsFile,
28
+ } from "./types.js";
29
+
30
+ const ACCOUNTS_FILE = resolve(process.cwd(), "data", "accounts.json");
31
+ const LEGACY_AUTH_FILE = resolve(process.cwd(), "data", "auth.json");
32
+
33
+ export class AccountPool {
34
+ private accounts: Map<string, AccountEntry> = new Map();
35
+ private acquireLocks: Set<string> = new Set();
36
+ private roundRobinIndex = 0;
37
+ private persistTimer: ReturnType<typeof setTimeout> | null = null;
38
+
39
+ constructor() {
40
+ this.migrateFromLegacy();
41
+ this.loadPersisted();
42
+
43
+ // Override with config jwt_token if set
44
+ const config = getConfig();
45
+ if (config.auth.jwt_token) {
46
+ this.addAccount(config.auth.jwt_token);
47
+ }
48
+ const envToken = process.env.CODEX_JWT_TOKEN;
49
+ if (envToken) {
50
+ this.addAccount(envToken);
51
+ }
52
+ }
53
+
54
+ // ── Core operations ─────────────────────────────────────────────
55
+
56
+ /**
57
+ * Acquire the best available account for a request.
58
+ * Returns null if no accounts are available.
59
+ */
60
+ acquire(): AcquiredAccount | null {
61
+ const config = getConfig();
62
+ const now = new Date();
63
+
64
+ // Update statuses before selecting
65
+ for (const entry of this.accounts.values()) {
66
+ this.refreshStatus(entry, now);
67
+ }
68
+
69
+ // Filter available accounts
70
+ const available = [...this.accounts.values()].filter(
71
+ (a) => a.status === "active" && !this.acquireLocks.has(a.id),
72
+ );
73
+
74
+ if (available.length === 0) return null;
75
+
76
+ let selected: AccountEntry;
77
+ if (config.auth.rotation_strategy === "round_robin") {
78
+ this.roundRobinIndex = this.roundRobinIndex % available.length;
79
+ selected = available[this.roundRobinIndex];
80
+ this.roundRobinIndex++;
81
+ } else {
82
+ // least_used: sort by request_count asc, then by last_used asc (LRU)
83
+ available.sort((a, b) => {
84
+ const diff = a.usage.request_count - b.usage.request_count;
85
+ if (diff !== 0) return diff;
86
+ const aTime = a.usage.last_used ? new Date(a.usage.last_used).getTime() : 0;
87
+ const bTime = b.usage.last_used ? new Date(b.usage.last_used).getTime() : 0;
88
+ return aTime - bTime;
89
+ });
90
+ selected = available[0];
91
+ }
92
+
93
+ this.acquireLocks.add(selected.id);
94
+ return {
95
+ entryId: selected.id,
96
+ token: selected.token,
97
+ accountId: selected.accountId,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Release an account after a request completes.
103
+ */
104
+ release(
105
+ entryId: string,
106
+ usage?: { input_tokens?: number; output_tokens?: number },
107
+ ): void {
108
+ this.acquireLocks.delete(entryId);
109
+ const entry = this.accounts.get(entryId);
110
+ if (!entry) return;
111
+
112
+ entry.usage.request_count++;
113
+ entry.usage.last_used = new Date().toISOString();
114
+ if (usage) {
115
+ entry.usage.input_tokens += usage.input_tokens ?? 0;
116
+ entry.usage.output_tokens += usage.output_tokens ?? 0;
117
+ }
118
+ this.schedulePersist();
119
+ }
120
+
121
+ /**
122
+ * Mark an account as rate-limited after a 429.
123
+ */
124
+ markRateLimited(entryId: string, retryAfterSec?: number): void {
125
+ this.acquireLocks.delete(entryId);
126
+ const entry = this.accounts.get(entryId);
127
+ if (!entry) return;
128
+
129
+ const config = getConfig();
130
+ const backoff = retryAfterSec ?? config.auth.rate_limit_backoff_seconds;
131
+ const until = new Date(Date.now() + backoff * 1000);
132
+
133
+ entry.status = "rate_limited";
134
+ entry.usage.rate_limit_until = until.toISOString();
135
+ this.schedulePersist();
136
+ }
137
+
138
+ // ── Account management ──────────────────────────────────────────
139
+
140
+ /**
141
+ * Add an account from a raw JWT token. Returns the entry ID.
142
+ * Deduplicates by accountId.
143
+ */
144
+ addAccount(token: string): string {
145
+ const accountId = extractChatGptAccountId(token);
146
+ const profile = extractUserProfile(token);
147
+
148
+ // Deduplicate by accountId
149
+ if (accountId) {
150
+ for (const existing of this.accounts.values()) {
151
+ if (existing.accountId === accountId) {
152
+ // Update the existing entry's token
153
+ existing.token = token;
154
+ existing.email = profile?.email ?? existing.email;
155
+ existing.planType = profile?.chatgpt_plan_type ?? existing.planType;
156
+ existing.status = isTokenExpired(token) ? "expired" : "active";
157
+ this.schedulePersist();
158
+ return existing.id;
159
+ }
160
+ }
161
+ }
162
+
163
+ const id = randomBytes(8).toString("hex");
164
+ const entry: AccountEntry = {
165
+ id,
166
+ token,
167
+ email: profile?.email ?? null,
168
+ accountId,
169
+ planType: profile?.chatgpt_plan_type ?? null,
170
+ proxyApiKey: "codex-proxy-" + randomBytes(24).toString("hex"),
171
+ status: isTokenExpired(token) ? "expired" : "active",
172
+ usage: {
173
+ request_count: 0,
174
+ input_tokens: 0,
175
+ output_tokens: 0,
176
+ last_used: null,
177
+ rate_limit_until: null,
178
+ },
179
+ addedAt: new Date().toISOString(),
180
+ };
181
+
182
+ this.accounts.set(id, entry);
183
+ this.schedulePersist();
184
+ return id;
185
+ }
186
+
187
+ removeAccount(id: string): boolean {
188
+ this.acquireLocks.delete(id);
189
+ const deleted = this.accounts.delete(id);
190
+ if (deleted) this.schedulePersist();
191
+ return deleted;
192
+ }
193
+
194
+ /**
195
+ * Update an account's token (used by refresh scheduler).
196
+ */
197
+ updateToken(entryId: string, newToken: string): void {
198
+ const entry = this.accounts.get(entryId);
199
+ if (!entry) return;
200
+
201
+ entry.token = newToken;
202
+ const profile = extractUserProfile(newToken);
203
+ entry.email = profile?.email ?? entry.email;
204
+ entry.planType = profile?.chatgpt_plan_type ?? entry.planType;
205
+ entry.accountId = extractChatGptAccountId(newToken) ?? entry.accountId;
206
+ entry.status = "active";
207
+ this.schedulePersist();
208
+ }
209
+
210
+ markStatus(entryId: string, status: AccountEntry["status"]): void {
211
+ const entry = this.accounts.get(entryId);
212
+ if (!entry) return;
213
+ entry.status = status;
214
+ this.schedulePersist();
215
+ }
216
+
217
+ resetUsage(entryId: string): boolean {
218
+ const entry = this.accounts.get(entryId);
219
+ if (!entry) return false;
220
+ entry.usage = {
221
+ request_count: 0,
222
+ input_tokens: 0,
223
+ output_tokens: 0,
224
+ last_used: null,
225
+ rate_limit_until: null,
226
+ };
227
+ this.schedulePersist();
228
+ return true;
229
+ }
230
+
231
+ // ── Query ───────────────────────────────────────────────────────
232
+
233
+ getAccounts(): AccountInfo[] {
234
+ const now = new Date();
235
+ return [...this.accounts.values()].map((a) => {
236
+ this.refreshStatus(a, now);
237
+ return this.toInfo(a);
238
+ });
239
+ }
240
+
241
+ getEntry(entryId: string): AccountEntry | undefined {
242
+ return this.accounts.get(entryId);
243
+ }
244
+
245
+ getAllEntries(): AccountEntry[] {
246
+ return [...this.accounts.values()];
247
+ }
248
+
249
+ // ── Backward-compatible shim (for routes that still expect AuthManager) ──
250
+
251
+ isAuthenticated(): boolean {
252
+ const now = new Date();
253
+ for (const entry of this.accounts.values()) {
254
+ this.refreshStatus(entry, now);
255
+ if (entry.status === "active") return true;
256
+ }
257
+ return false;
258
+ }
259
+
260
+ async getToken(): Promise<string | null> {
261
+ const acq = this.acquire();
262
+ if (!acq) return null;
263
+ // Release immediately — shim usage doesn't track per-request
264
+ this.acquireLocks.delete(acq.entryId);
265
+ return acq.token;
266
+ }
267
+
268
+ getAccountId(): string | null {
269
+ const first = [...this.accounts.values()].find((a) => a.status === "active");
270
+ return first?.accountId ?? null;
271
+ }
272
+
273
+ getUserInfo(): { email?: string; accountId?: string; planType?: string } | null {
274
+ const first = [...this.accounts.values()].find((a) => a.status === "active");
275
+ if (!first) return null;
276
+ return {
277
+ email: first.email ?? undefined,
278
+ accountId: first.accountId ?? undefined,
279
+ planType: first.planType ?? undefined,
280
+ };
281
+ }
282
+
283
+ getProxyApiKey(): string | null {
284
+ const first = [...this.accounts.values()].find((a) => a.status === "active");
285
+ return first?.proxyApiKey ?? null;
286
+ }
287
+
288
+ validateProxyApiKey(key: string): boolean {
289
+ for (const entry of this.accounts.values()) {
290
+ if (entry.proxyApiKey === key) return true;
291
+ }
292
+ return false;
293
+ }
294
+
295
+ /** Alias for addAccount — used by auth routes */
296
+ setToken(token: string): void {
297
+ this.addAccount(token);
298
+ }
299
+
300
+ clearToken(): void {
301
+ this.accounts.clear();
302
+ this.acquireLocks.clear();
303
+ this.persistNow();
304
+ }
305
+
306
+ // ── Pool summary ────────────────────────────────────────────────
307
+
308
+ getPoolSummary(): {
309
+ total: number;
310
+ active: number;
311
+ expired: number;
312
+ rate_limited: number;
313
+ refreshing: number;
314
+ disabled: number;
315
+ } {
316
+ const now = new Date();
317
+ let active = 0, expired = 0, rate_limited = 0, refreshing = 0, disabled = 0;
318
+ for (const entry of this.accounts.values()) {
319
+ this.refreshStatus(entry, now);
320
+ switch (entry.status) {
321
+ case "active": active++; break;
322
+ case "expired": expired++; break;
323
+ case "rate_limited": rate_limited++; break;
324
+ case "refreshing": refreshing++; break;
325
+ case "disabled": disabled++; break;
326
+ }
327
+ }
328
+ return {
329
+ total: this.accounts.size,
330
+ active,
331
+ expired,
332
+ rate_limited,
333
+ refreshing,
334
+ disabled,
335
+ };
336
+ }
337
+
338
+ // ── Internal ────────────────────────────────────────────────────
339
+
340
+ private refreshStatus(entry: AccountEntry, now: Date): void {
341
+ // Auto-recover rate-limited accounts
342
+ if (entry.status === "rate_limited" && entry.usage.rate_limit_until) {
343
+ if (now >= new Date(entry.usage.rate_limit_until)) {
344
+ entry.status = "active";
345
+ entry.usage.rate_limit_until = null;
346
+ }
347
+ }
348
+
349
+ // Mark expired tokens
350
+ if (entry.status === "active" && isTokenExpired(entry.token)) {
351
+ entry.status = "expired";
352
+ }
353
+ }
354
+
355
+ private toInfo(entry: AccountEntry): AccountInfo {
356
+ const payload = decodeJwtPayload(entry.token);
357
+ const exp = payload?.exp;
358
+ return {
359
+ id: entry.id,
360
+ email: entry.email,
361
+ accountId: entry.accountId,
362
+ planType: entry.planType,
363
+ status: entry.status,
364
+ usage: { ...entry.usage },
365
+ addedAt: entry.addedAt,
366
+ expiresAt:
367
+ typeof exp === "number"
368
+ ? new Date(exp * 1000).toISOString()
369
+ : null,
370
+ };
371
+ }
372
+
373
+ // ── Persistence ─────────────────────────────────────────────────
374
+
375
+ private schedulePersist(): void {
376
+ if (this.persistTimer) return;
377
+ this.persistTimer = setTimeout(() => {
378
+ this.persistTimer = null;
379
+ this.persistNow();
380
+ }, 1000);
381
+ }
382
+
383
+ persistNow(): void {
384
+ if (this.persistTimer) {
385
+ clearTimeout(this.persistTimer);
386
+ this.persistTimer = null;
387
+ }
388
+ try {
389
+ const dir = dirname(ACCOUNTS_FILE);
390
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
391
+ const data: AccountsFile = { accounts: [...this.accounts.values()] };
392
+ writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2), "utf-8");
393
+ } catch {
394
+ // best-effort
395
+ }
396
+ }
397
+
398
+ private loadPersisted(): void {
399
+ try {
400
+ if (!existsSync(ACCOUNTS_FILE)) return;
401
+ const raw = readFileSync(ACCOUNTS_FILE, "utf-8");
402
+ const data = JSON.parse(raw) as AccountsFile;
403
+ if (Array.isArray(data.accounts)) {
404
+ for (const entry of data.accounts) {
405
+ if (entry.id && entry.token) {
406
+ this.accounts.set(entry.id, entry);
407
+ }
408
+ }
409
+ }
410
+ } catch {
411
+ // corrupt file, start fresh
412
+ }
413
+ }
414
+
415
+ private migrateFromLegacy(): void {
416
+ try {
417
+ if (existsSync(ACCOUNTS_FILE)) return; // already migrated
418
+ if (!existsSync(LEGACY_AUTH_FILE)) return;
419
+
420
+ const raw = readFileSync(LEGACY_AUTH_FILE, "utf-8");
421
+ const data = JSON.parse(raw) as {
422
+ token: string;
423
+ proxyApiKey?: string | null;
424
+ userInfo?: { email?: string; accountId?: string; planType?: string } | null;
425
+ };
426
+
427
+ if (!data.token) return;
428
+
429
+ const id = randomBytes(8).toString("hex");
430
+ const accountId = extractChatGptAccountId(data.token);
431
+ const entry: AccountEntry = {
432
+ id,
433
+ token: data.token,
434
+ email: data.userInfo?.email ?? null,
435
+ accountId: accountId,
436
+ planType: data.userInfo?.planType ?? null,
437
+ proxyApiKey: data.proxyApiKey ?? "codex-proxy-" + randomBytes(24).toString("hex"),
438
+ status: isTokenExpired(data.token) ? "expired" : "active",
439
+ usage: {
440
+ request_count: 0,
441
+ input_tokens: 0,
442
+ output_tokens: 0,
443
+ last_used: null,
444
+ rate_limit_until: null,
445
+ },
446
+ addedAt: new Date().toISOString(),
447
+ };
448
+
449
+ this.accounts.set(id, entry);
450
+
451
+ // Write new format
452
+ const dir = dirname(ACCOUNTS_FILE);
453
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
454
+ const accountsData: AccountsFile = { accounts: [entry] };
455
+ writeFileSync(ACCOUNTS_FILE, JSON.stringify(accountsData, null, 2), "utf-8");
456
+
457
+ // Rename old file
458
+ renameSync(LEGACY_AUTH_FILE, LEGACY_AUTH_FILE + ".bak");
459
+ console.log("[AccountPool] Migrated from auth.json → accounts.json");
460
+ } catch (err) {
461
+ console.warn("[AccountPool] Migration failed:", err);
462
+ }
463
+ }
464
+
465
+ /** Flush pending writes on shutdown */
466
+ destroy(): void {
467
+ if (this.persistTimer) {
468
+ clearTimeout(this.persistTimer);
469
+ this.persistTimer = null;
470
+ }
471
+ this.persistNow();
472
+ }
473
+ }
src/auth/chatgpt-oauth.ts ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawn, type ChildProcess } from "child_process";
2
+ import { getConfig } from "../config.js";
3
+ import {
4
+ decodeJwtPayload,
5
+ extractChatGptAccountId,
6
+ isTokenExpired,
7
+ } from "./jwt-utils.js";
8
+
9
+ export interface OAuthResult {
10
+ success: boolean;
11
+ token?: string;
12
+ error?: string;
13
+ }
14
+
15
+ const INIT_REQUEST_ID = "__codex-desktop_initialize__";
16
+
17
+ /**
18
+ * Approach 1: Login via Codex CLI subprocess (JSON-RPC over stdio).
19
+ * Spawns `codex app-server` and uses JSON-RPC to initiate OAuth.
20
+ *
21
+ * Flow:
22
+ * 1. Spawn `codex app-server`
23
+ * 2. Send `initialize` handshake (required before any other request)
24
+ * 3. Send `account/login/start` with type "chatgpt"
25
+ * 4. CLI returns an Auth0 authUrl and starts a local callback server
26
+ * 5. User completes OAuth in browser
27
+ * 6. CLI sends `account/login/completed` notification with token
28
+ */
29
+ export async function loginViaCli(): Promise<{
30
+ authUrl: string;
31
+ waitForCompletion: () => Promise<OAuthResult>;
32
+ }> {
33
+ const { command, args } = await resolveCliCommand();
34
+
35
+ return new Promise((resolveOuter, rejectOuter) => {
36
+ const child = spawn(command, args, {
37
+ stdio: ["pipe", "pipe", "pipe"],
38
+ ...SPAWN_OPTS,
39
+ });
40
+
41
+ let buffer = "";
42
+ let rpcId = 1;
43
+ let authUrl = "";
44
+ let initialized = false;
45
+ let outerResolved = false;
46
+ let awaitingAuthStatus = false;
47
+ const AUTH_STATUS_ID = "__get_auth_status__";
48
+
49
+ // Resolvers for the completion promise (token received)
50
+ let resolveCompletion: (result: OAuthResult) => void;
51
+ const completionPromise = new Promise<OAuthResult>((res) => {
52
+ resolveCompletion = res;
53
+ });
54
+
55
+ const sendRpc = (
56
+ method: string,
57
+ params: Record<string, unknown> = {},
58
+ id?: string | number,
59
+ ) => {
60
+ const msgId = id ?? rpcId++;
61
+ const msg = JSON.stringify({
62
+ jsonrpc: "2.0",
63
+ id: msgId,
64
+ method,
65
+ params,
66
+ });
67
+ child.stdin.write(msg + "\n");
68
+ };
69
+
70
+ // Kill child on completion timeout (5 minutes)
71
+ const killTimer = setTimeout(() => {
72
+ if (!outerResolved) {
73
+ rejectOuter(new Error("OAuth flow timed out (5 minutes)"));
74
+ }
75
+ resolveCompletion({
76
+ success: false,
77
+ error: "OAuth flow timed out",
78
+ });
79
+ child.kill();
80
+ }, 5 * 60 * 1000);
81
+
82
+ const cleanup = () => {
83
+ clearTimeout(killTimer);
84
+ };
85
+
86
+ child.stdout.on("data", (chunk: Buffer) => {
87
+ buffer += chunk.toString("utf8");
88
+ const lines = buffer.split("\n");
89
+ buffer = lines.pop()!;
90
+
91
+ for (const line of lines) {
92
+ if (!line.trim()) continue;
93
+ try {
94
+ const msg = JSON.parse(line);
95
+
96
+ // Response to initialize request
97
+ if (msg.id === INIT_REQUEST_ID && !initialized) {
98
+ if (msg.error) {
99
+ const errMsg =
100
+ msg.error.message ?? "Failed to initialize app-server";
101
+ cleanup();
102
+ rejectOuter(new Error(errMsg));
103
+ resolveCompletion({ success: false, error: errMsg });
104
+ child.kill();
105
+ return;
106
+ }
107
+ initialized = true;
108
+ console.log(
109
+ "[OAuth] Codex app-server initialized:",
110
+ msg.result?.userAgent ?? "unknown",
111
+ );
112
+ // Now send the login request
113
+ sendRpc("account/login/start", { type: "chatgpt" });
114
+ continue;
115
+ }
116
+
117
+ // Response to account/login/start
118
+ if (msg.result && msg.result.authUrl && !outerResolved) {
119
+ authUrl = msg.result.authUrl;
120
+ outerResolved = true;
121
+ console.log(
122
+ "[OAuth] Auth URL received, loginId:",
123
+ msg.result.loginId,
124
+ );
125
+ resolveOuter({
126
+ authUrl,
127
+ waitForCompletion: () => completionPromise,
128
+ });
129
+ continue;
130
+ }
131
+
132
+ // Notification: login completed — need to fetch token via getAuthStatus
133
+ if (msg.method === "account/login/completed" && msg.params) {
134
+ const { success, error: loginError } = msg.params;
135
+ console.log("[OAuth] Login completed, success:", success);
136
+ if (success) {
137
+ // Login succeeded but the notification doesn't include the token.
138
+ // We must request it via getAuthStatus.
139
+ awaitingAuthStatus = true;
140
+ sendRpc(
141
+ "getAuthStatus",
142
+ { includeToken: true, refreshToken: false },
143
+ AUTH_STATUS_ID,
144
+ );
145
+ } else {
146
+ cleanup();
147
+ resolveCompletion({
148
+ success: false,
149
+ error: loginError ?? "Login failed",
150
+ });
151
+ child.kill();
152
+ }
153
+ continue;
154
+ }
155
+
156
+ // Response to getAuthStatus — extract the token
157
+ if (msg.id === AUTH_STATUS_ID && awaitingAuthStatus) {
158
+ awaitingAuthStatus = false;
159
+ cleanup();
160
+ if (msg.error) {
161
+ resolveCompletion({
162
+ success: false,
163
+ error: msg.error.message ?? "Failed to get auth status",
164
+ });
165
+ } else {
166
+ const authToken = msg.result?.authToken ?? null;
167
+ if (typeof authToken === "string") {
168
+ console.log("[OAuth] Token received successfully");
169
+ resolveCompletion({ success: true, token: authToken });
170
+ } else {
171
+ resolveCompletion({
172
+ success: false,
173
+ error: "getAuthStatus returned no token",
174
+ });
175
+ }
176
+ }
177
+ // Give CLI a moment to clean up, then kill
178
+ setTimeout(() => child.kill(), 1000);
179
+ continue;
180
+ }
181
+
182
+ // Notification: account/updated (auth status changed)
183
+ if (msg.method === "account/updated" && msg.params) {
184
+ console.log("[OAuth] Account updated:", msg.params.authMode);
185
+ // If we haven't requested auth status yet and auth mode is set,
186
+ // this might be our signal to fetch the token
187
+ if (!awaitingAuthStatus && msg.params.authMode === "chatgpt") {
188
+ awaitingAuthStatus = true;
189
+ sendRpc(
190
+ "getAuthStatus",
191
+ { includeToken: true, refreshToken: false },
192
+ AUTH_STATUS_ID,
193
+ );
194
+ }
195
+ continue;
196
+ }
197
+
198
+ // Error response (to our login request)
199
+ if (msg.error && msg.id !== INIT_REQUEST_ID) {
200
+ const errMsg = msg.error.message ?? "Unknown JSON-RPC error";
201
+ cleanup();
202
+ if (!outerResolved) {
203
+ outerResolved = true;
204
+ rejectOuter(new Error(errMsg));
205
+ }
206
+ resolveCompletion({ success: false, error: errMsg });
207
+ child.kill();
208
+ }
209
+ } catch {
210
+ // Skip non-JSON lines (stderr leak, log output, etc.)
211
+ }
212
+ }
213
+ });
214
+
215
+ child.stderr?.on("data", (chunk: Buffer) => {
216
+ const text = chunk.toString("utf8").trim();
217
+ if (text) {
218
+ console.log("[OAuth CLI stderr]", text);
219
+ }
220
+ });
221
+
222
+ child.on("error", (err) => {
223
+ const msg = `Failed to spawn Codex CLI: ${err.message}`;
224
+ cleanup();
225
+ if (!outerResolved) {
226
+ outerResolved = true;
227
+ rejectOuter(new Error(msg));
228
+ }
229
+ resolveCompletion({ success: false, error: msg });
230
+ });
231
+
232
+ child.on("close", (code) => {
233
+ cleanup();
234
+ if (!outerResolved) {
235
+ outerResolved = true;
236
+ rejectOuter(
237
+ new Error(
238
+ `Codex CLI exited with code ${code} before returning authUrl`,
239
+ ),
240
+ );
241
+ }
242
+ resolveCompletion({
243
+ success: false,
244
+ error: `Codex CLI exited with code ${code}`,
245
+ });
246
+ });
247
+
248
+ // Step 1: Send the initialize handshake
249
+ const config = getConfig();
250
+ sendRpc(
251
+ "initialize",
252
+ {
253
+ clientInfo: {
254
+ name: "Codex Desktop",
255
+ title: "Codex Desktop",
256
+ version: config.client.app_version,
257
+ },
258
+ },
259
+ INIT_REQUEST_ID,
260
+ );
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Refresh an existing token via Codex CLI (JSON-RPC).
266
+ * Spawns `codex app-server`, sends `initialize`, then `getAuthStatus` with refreshToken: true.
267
+ * Returns the new token string, or throws on failure.
268
+ */
269
+ export async function refreshTokenViaCli(): Promise<string> {
270
+ const { command, args } = await resolveCliCommand();
271
+
272
+ return new Promise((resolve, reject) => {
273
+ const child = spawn(command, args, {
274
+ stdio: ["pipe", "pipe", "pipe"],
275
+ ...SPAWN_OPTS,
276
+ });
277
+
278
+ let buffer = "";
279
+ const AUTH_STATUS_ID = "__refresh_auth_status__";
280
+ let initialized = false;
281
+ let settled = false;
282
+
283
+ const killTimer = setTimeout(() => {
284
+ if (!settled) {
285
+ settled = true;
286
+ reject(new Error("Token refresh timed out (30s)"));
287
+ }
288
+ child.kill();
289
+ }, 30_000);
290
+
291
+ const cleanup = () => {
292
+ clearTimeout(killTimer);
293
+ };
294
+
295
+ const sendRpc = (
296
+ method: string,
297
+ params: Record<string, unknown> = {},
298
+ id?: string | number,
299
+ ) => {
300
+ const msg = JSON.stringify({
301
+ jsonrpc: "2.0",
302
+ id: id ?? 1,
303
+ method,
304
+ params,
305
+ });
306
+ child.stdin.write(msg + "\n");
307
+ };
308
+
309
+ child.stdout.on("data", (chunk: Buffer) => {
310
+ buffer += chunk.toString("utf8");
311
+ const lines = buffer.split("\n");
312
+ buffer = lines.pop()!;
313
+
314
+ for (const line of lines) {
315
+ if (!line.trim()) continue;
316
+ try {
317
+ const msg = JSON.parse(line);
318
+
319
+ // Response to initialize
320
+ if (msg.id === INIT_REQUEST_ID && !initialized) {
321
+ if (msg.error) {
322
+ cleanup();
323
+ settled = true;
324
+ reject(new Error(msg.error.message ?? "Init failed"));
325
+ child.kill();
326
+ return;
327
+ }
328
+ initialized = true;
329
+ // Request auth status with refresh
330
+ sendRpc(
331
+ "getAuthStatus",
332
+ { includeToken: true, refreshToken: true },
333
+ AUTH_STATUS_ID,
334
+ );
335
+ continue;
336
+ }
337
+
338
+ // Response to getAuthStatus
339
+ if (msg.id === AUTH_STATUS_ID) {
340
+ cleanup();
341
+ if (msg.error) {
342
+ settled = true;
343
+ reject(new Error(msg.error.message ?? "getAuthStatus failed"));
344
+ } else {
345
+ const authToken = msg.result?.authToken ?? null;
346
+ if (typeof authToken === "string") {
347
+ settled = true;
348
+ resolve(authToken);
349
+ } else {
350
+ settled = true;
351
+ reject(new Error("getAuthStatus returned no token"));
352
+ }
353
+ }
354
+ setTimeout(() => child.kill(), 500);
355
+ continue;
356
+ }
357
+ } catch {
358
+ // skip non-JSON
359
+ }
360
+ }
361
+ });
362
+
363
+ child.stderr?.on("data", () => {});
364
+
365
+ child.on("error", (err) => {
366
+ cleanup();
367
+ if (!settled) {
368
+ settled = true;
369
+ reject(new Error(`Failed to spawn Codex CLI: ${err.message}`));
370
+ }
371
+ });
372
+
373
+ child.on("close", (code) => {
374
+ cleanup();
375
+ if (!settled) {
376
+ settled = true;
377
+ reject(new Error(`Codex CLI exited with code ${code} during refresh`));
378
+ }
379
+ });
380
+
381
+ // Send initialize
382
+ const config = getConfig();
383
+ sendRpc(
384
+ "initialize",
385
+ {
386
+ clientInfo: {
387
+ name: "Codex Desktop",
388
+ title: "Codex Desktop",
389
+ version: config.client.app_version,
390
+ },
391
+ },
392
+ INIT_REQUEST_ID,
393
+ );
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Approach 2: Manual token paste (fallback).
399
+ * Validates a JWT token provided directly by the user.
400
+ */
401
+ export function validateManualToken(token: string): {
402
+ valid: boolean;
403
+ error?: string;
404
+ } {
405
+ if (!token || typeof token !== "string") {
406
+ return { valid: false, error: "Token is empty" };
407
+ }
408
+
409
+ const trimmed = token.trim();
410
+ const payload = decodeJwtPayload(trimmed);
411
+ if (!payload) {
412
+ return {
413
+ valid: false,
414
+ error: "Invalid JWT format — could not decode payload",
415
+ };
416
+ }
417
+
418
+ if (isTokenExpired(trimmed)) {
419
+ return { valid: false, error: "Token is expired" };
420
+ }
421
+
422
+ const accountId = extractChatGptAccountId(trimmed);
423
+ if (!accountId) {
424
+ return { valid: false, error: "Token missing chatgpt_account_id claim" };
425
+ }
426
+
427
+ return { valid: true };
428
+ }
429
+
430
+ /**
431
+ * Check if the Codex CLI is available on the system.
432
+ */
433
+ export async function isCodexCliAvailable(): Promise<boolean> {
434
+ try {
435
+ await resolveCliCommand();
436
+ return true;
437
+ } catch {
438
+ return false;
439
+ }
440
+ }
441
+
442
+ // --- private helpers ---
443
+
444
+ // On Windows, npm-installed binaries (.cmd scripts) require shell: true
445
+ const IS_WINDOWS = process.platform === "win32";
446
+ const SPAWN_OPTS = IS_WINDOWS ? { shell: true as const } : {};
447
+
448
+ interface CliCommand {
449
+ command: string;
450
+ args: string[];
451
+ }
452
+
453
+ async function resolveCliCommand(): Promise<CliCommand> {
454
+ // Try `codex` directly first
455
+ if (await testCli("codex", ["--version"])) {
456
+ return { command: "codex", args: ["app-server"] };
457
+ }
458
+ // Fall back to `npx codex`
459
+ if (await testCli("npx", ["codex", "--version"])) {
460
+ return { command: "npx", args: ["codex", "app-server"] };
461
+ }
462
+ throw new Error("Neither 'codex' nor 'npx codex' found in PATH");
463
+ }
464
+
465
+ function testCli(command: string, args: string[]): Promise<boolean> {
466
+ return new Promise((resolve) => {
467
+ const child = spawn(command, args, { stdio: "ignore", ...SPAWN_OPTS });
468
+ child.on("error", () => resolve(false));
469
+ child.on("close", (code) => resolve(code === 0));
470
+ });
471
+ }
src/auth/jwt-utils.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * JWT decode utilities for Codex Desktop proxy.
3
+ * No signature verification — just payload extraction.
4
+ */
5
+
6
+ export function decodeJwtPayload(token: string): Record<string, unknown> | null {
7
+ const parts = token.split(".");
8
+ if (parts.length < 2) return null;
9
+ try {
10
+ const payload = Buffer.from(parts[1], "base64url").toString("utf8");
11
+ const parsed = JSON.parse(payload);
12
+ if (typeof parsed === "object" && parsed !== null) {
13
+ return parsed as Record<string, unknown>;
14
+ }
15
+ return null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function extractChatGptAccountId(token: string): string | null {
22
+ const payload = decodeJwtPayload(token);
23
+ if (!payload) return null;
24
+ try {
25
+ const auth = payload["https://api.openai.com/auth"];
26
+ if (auth && typeof auth === "object" && auth !== null) {
27
+ const accountId = (auth as Record<string, unknown>).chatgpt_account_id;
28
+ return typeof accountId === "string" ? accountId : null;
29
+ }
30
+ } catch {
31
+ // ignore
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function extractUserProfile(
37
+ token: string,
38
+ ): { email?: string; chatgpt_user_id?: string; chatgpt_plan_type?: string } | null {
39
+ const payload = decodeJwtPayload(token);
40
+ if (!payload) return null;
41
+ try {
42
+ const profile = payload["https://api.openai.com/profile"];
43
+ if (profile && typeof profile === "object" && profile !== null) {
44
+ const p = profile as Record<string, unknown>;
45
+ return {
46
+ email: typeof p.email === "string" ? p.email : undefined,
47
+ chatgpt_user_id: typeof p.chatgpt_user_id === "string" ? p.chatgpt_user_id : undefined,
48
+ chatgpt_plan_type: typeof p.chatgpt_plan_type === "string" ? p.chatgpt_plan_type : undefined,
49
+ };
50
+ }
51
+ } catch {
52
+ // ignore
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export function isTokenExpired(token: string, marginSeconds = 0): boolean {
58
+ const payload = decodeJwtPayload(token);
59
+ if (!payload) return true;
60
+ const exp = payload.exp;
61
+ if (typeof exp !== "number") return true;
62
+ const now = Math.floor(Date.now() / 1000);
63
+ return now >= exp - marginSeconds;
64
+ }
src/auth/manager.ts ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from "fs";
2
+ import { resolve, dirname } from "path";
3
+ import { randomBytes } from "crypto";
4
+ import { getConfig } from "../config.js";
5
+ import {
6
+ decodeJwtPayload,
7
+ extractChatGptAccountId,
8
+ extractUserProfile,
9
+ isTokenExpired,
10
+ } from "./jwt-utils.js";
11
+
12
+ interface PersistedAuth {
13
+ token: string;
14
+ proxyApiKey: string | null;
15
+ userInfo: { email?: string; accountId?: string; planType?: string } | null;
16
+ }
17
+
18
+ const AUTH_FILE = resolve(process.cwd(), "data", "auth.json");
19
+
20
+ export class AuthManager {
21
+ private token: string | null = null;
22
+ private userInfo: { email?: string; accountId?: string; planType?: string } | null = null;
23
+ private proxyApiKey: string | null = null;
24
+ private refreshLock: Promise<string | null> | null = null;
25
+
26
+ constructor() {
27
+ this.loadPersisted();
28
+
29
+ // Override with config jwt_token if set
30
+ const config = getConfig();
31
+ if (config.auth.jwt_token) {
32
+ this.setToken(config.auth.jwt_token);
33
+ }
34
+
35
+ // Override with env var if set
36
+ const envToken = process.env.CODEX_JWT_TOKEN;
37
+ if (envToken) {
38
+ this.setToken(envToken);
39
+ }
40
+ }
41
+
42
+ async getToken(forceRefresh?: boolean): Promise<string | null> {
43
+ if (forceRefresh || (this.token && this.isExpired())) {
44
+ // Use a lock to prevent concurrent refresh attempts
45
+ if (!this.refreshLock) {
46
+ this.refreshLock = this.attemptRefresh();
47
+ }
48
+ try {
49
+ return await this.refreshLock;
50
+ } finally {
51
+ this.refreshLock = null;
52
+ }
53
+ }
54
+ return this.token;
55
+ }
56
+
57
+ setToken(token: string): void {
58
+ this.token = token;
59
+
60
+ // Extract user info from JWT claims
61
+ const profile = extractUserProfile(token);
62
+ const accountId = extractChatGptAccountId(token);
63
+ this.userInfo = {
64
+ email: profile?.email,
65
+ accountId: accountId ?? undefined,
66
+ planType: profile?.chatgpt_plan_type,
67
+ };
68
+
69
+ // Generate proxy API key if we don't have one yet
70
+ if (!this.proxyApiKey) {
71
+ this.proxyApiKey = this.generateApiKey();
72
+ }
73
+
74
+ this.persist();
75
+ }
76
+
77
+ clearToken(): void {
78
+ this.token = null;
79
+ this.userInfo = null;
80
+ this.proxyApiKey = null;
81
+ try {
82
+ if (existsSync(AUTH_FILE)) {
83
+ unlinkSync(AUTH_FILE);
84
+ }
85
+ } catch {
86
+ // ignore cleanup errors
87
+ }
88
+ }
89
+
90
+ isAuthenticated(): boolean {
91
+ return this.token !== null && !this.isExpired();
92
+ }
93
+
94
+ getUserInfo(): { email?: string; accountId?: string; planType?: string } | null {
95
+ return this.userInfo;
96
+ }
97
+
98
+ getAccountId(): string | null {
99
+ if (!this.token) return null;
100
+ return extractChatGptAccountId(this.token);
101
+ }
102
+
103
+ getProxyApiKey(): string | null {
104
+ return this.proxyApiKey;
105
+ }
106
+
107
+ validateProxyApiKey(key: string): boolean {
108
+ if (!this.proxyApiKey) return false;
109
+ return key === this.proxyApiKey;
110
+ }
111
+
112
+ // --- private helpers ---
113
+
114
+ private isExpired(): boolean {
115
+ if (!this.token) return true;
116
+ const config = getConfig();
117
+ return isTokenExpired(this.token, config.auth.refresh_margin_seconds);
118
+ }
119
+
120
+ private async attemptRefresh(): Promise<string | null> {
121
+ // We cannot auto-refresh without Codex CLI interaction.
122
+ // If the token is expired, the user needs to re-login.
123
+ if (this.token && isTokenExpired(this.token)) {
124
+ this.token = null;
125
+ this.userInfo = null;
126
+ }
127
+ return this.token;
128
+ }
129
+
130
+ private persist(): void {
131
+ try {
132
+ const dir = dirname(AUTH_FILE);
133
+ if (!existsSync(dir)) {
134
+ mkdirSync(dir, { recursive: true });
135
+ }
136
+ const data: PersistedAuth = {
137
+ token: this.token!,
138
+ proxyApiKey: this.proxyApiKey,
139
+ userInfo: this.userInfo,
140
+ };
141
+ writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), "utf-8");
142
+ } catch {
143
+ // Persist is best-effort
144
+ }
145
+ }
146
+
147
+ private loadPersisted(): void {
148
+ try {
149
+ if (!existsSync(AUTH_FILE)) return;
150
+ const raw = readFileSync(AUTH_FILE, "utf-8");
151
+ const data = JSON.parse(raw) as PersistedAuth;
152
+ if (data.token && typeof data.token === "string") {
153
+ this.token = data.token;
154
+ this.proxyApiKey = data.proxyApiKey ?? null;
155
+ this.userInfo = data.userInfo ?? null;
156
+ }
157
+ } catch {
158
+ // If the file is corrupt, start fresh
159
+ }
160
+ }
161
+
162
+ private generateApiKey(): string {
163
+ return "codex-proxy-" + randomBytes(24).toString("hex");
164
+ }
165
+ }
src/auth/refresh-scheduler.ts ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RefreshScheduler — per-account JWT auto-refresh.
3
+ * Schedules a refresh at `exp - margin` for each account.
4
+ */
5
+
6
+ import { getConfig } from "../config.js";
7
+ import { decodeJwtPayload } from "./jwt-utils.js";
8
+ import { refreshTokenViaCli } from "./chatgpt-oauth.js";
9
+ import type { AccountPool } from "./account-pool.js";
10
+
11
+ export class RefreshScheduler {
12
+ private timers: Map<string, ReturnType<typeof setTimeout>> = new Map();
13
+ private pool: AccountPool;
14
+
15
+ constructor(pool: AccountPool) {
16
+ this.pool = pool;
17
+ this.scheduleAll();
18
+ }
19
+
20
+ /** Schedule refresh for all accounts in the pool. */
21
+ scheduleAll(): void {
22
+ for (const entry of this.pool.getAllEntries()) {
23
+ if (entry.status === "active" || entry.status === "refreshing") {
24
+ this.scheduleOne(entry.id, entry.token);
25
+ }
26
+ }
27
+ }
28
+
29
+ /** Schedule refresh for a single account. */
30
+ scheduleOne(entryId: string, token: string): void {
31
+ // Clear existing timer
32
+ this.clearOne(entryId);
33
+
34
+ const payload = decodeJwtPayload(token);
35
+ if (!payload || typeof payload.exp !== "number") return;
36
+
37
+ const config = getConfig();
38
+ const refreshAt = payload.exp - config.auth.refresh_margin_seconds;
39
+ const delayMs = (refreshAt - Math.floor(Date.now() / 1000)) * 1000;
40
+
41
+ if (delayMs <= 0) {
42
+ // Already past refresh time — attempt refresh immediately
43
+ this.doRefresh(entryId);
44
+ return;
45
+ }
46
+
47
+ const timer = setTimeout(() => {
48
+ this.timers.delete(entryId);
49
+ this.doRefresh(entryId);
50
+ }, delayMs);
51
+
52
+ // Prevent the timer from keeping the process alive
53
+ if (timer.unref) timer.unref();
54
+
55
+ this.timers.set(entryId, timer);
56
+
57
+ const expiresIn = Math.round(delayMs / 1000);
58
+ console.log(
59
+ `[RefreshScheduler] Account ${entryId}: refresh scheduled in ${expiresIn}s`,
60
+ );
61
+ }
62
+
63
+ /** Cancel timer for one account. */
64
+ clearOne(entryId: string): void {
65
+ const timer = this.timers.get(entryId);
66
+ if (timer) {
67
+ clearTimeout(timer);
68
+ this.timers.delete(entryId);
69
+ }
70
+ }
71
+
72
+ /** Cancel all timers. */
73
+ destroy(): void {
74
+ for (const timer of this.timers.values()) {
75
+ clearTimeout(timer);
76
+ }
77
+ this.timers.clear();
78
+ }
79
+
80
+ // ── Internal ────────────────────────────────────────────────────
81
+
82
+ private async doRefresh(entryId: string): Promise<void> {
83
+ const entry = this.pool.getEntry(entryId);
84
+ if (!entry) return;
85
+
86
+ console.log(`[RefreshScheduler] Refreshing account ${entryId} (${entry.email ?? "?"})`);
87
+ this.pool.markStatus(entryId, "refreshing");
88
+
89
+ try {
90
+ const newToken = await refreshTokenViaCli();
91
+ this.pool.updateToken(entryId, newToken);
92
+ console.log(`[RefreshScheduler] Account ${entryId} refreshed successfully`);
93
+ // Re-schedule for the new token
94
+ this.scheduleOne(entryId, newToken);
95
+ } catch (err) {
96
+ const msg = err instanceof Error ? err.message : String(err);
97
+ console.error(`[RefreshScheduler] Failed to refresh ${entryId}: ${msg}`);
98
+ this.pool.markStatus(entryId, "expired");
99
+ }
100
+ }
101
+ }
src/auth/types.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Data models for multi-account management.
3
+ */
4
+
5
+ export type AccountStatus =
6
+ | "active"
7
+ | "expired"
8
+ | "rate_limited"
9
+ | "refreshing"
10
+ | "disabled";
11
+
12
+ export interface AccountUsage {
13
+ request_count: number;
14
+ input_tokens: number;
15
+ output_tokens: number;
16
+ last_used: string | null;
17
+ rate_limit_until: string | null;
18
+ }
19
+
20
+ export interface AccountEntry {
21
+ id: string;
22
+ token: string;
23
+ email: string | null;
24
+ accountId: string | null;
25
+ planType: string | null;
26
+ proxyApiKey: string;
27
+ status: AccountStatus;
28
+ usage: AccountUsage;
29
+ addedAt: string;
30
+ }
31
+
32
+ /** Public info (no token) */
33
+ export interface AccountInfo {
34
+ id: string;
35
+ email: string | null;
36
+ accountId: string | null;
37
+ planType: string | null;
38
+ status: AccountStatus;
39
+ usage: AccountUsage;
40
+ addedAt: string;
41
+ expiresAt: string | null;
42
+ quota?: CodexQuota;
43
+ }
44
+
45
+ /** Official Codex quota from /backend-api/codex/usage */
46
+ export interface CodexQuota {
47
+ plan_type: string;
48
+ rate_limit: {
49
+ allowed: boolean;
50
+ limit_reached: boolean;
51
+ used_percent: number | null;
52
+ reset_at: number | null;
53
+ };
54
+ code_review_rate_limit: {
55
+ allowed: boolean;
56
+ limit_reached: boolean;
57
+ used_percent: number | null;
58
+ reset_at: number | null;
59
+ } | null;
60
+ }
61
+
62
+ /** Returned by acquire() */
63
+ export interface AcquiredAccount {
64
+ entryId: string;
65
+ token: string;
66
+ accountId: string | null;
67
+ }
68
+
69
+ /** Persistence format */
70
+ export interface AccountsFile {
71
+ accounts: AccountEntry[];
72
+ }
src/config.ts ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ import yaml from "js-yaml";
4
+ import { z } from "zod";
5
+
6
+ const ConfigSchema = z.object({
7
+ api: z.object({
8
+ base_url: z.string().default("https://chatgpt.com/backend-api"),
9
+ timeout_seconds: z.number().default(60),
10
+ }),
11
+ client: z.object({
12
+ originator: z.string().default("Codex Desktop"),
13
+ app_version: z.string().default("260202.0859"),
14
+ build_number: z.string().default("517"),
15
+ platform: z.string().default("darwin"),
16
+ arch: z.string().default("arm64"),
17
+ }),
18
+ model: z.object({
19
+ default: z.string().default("gpt-5.3-codex"),
20
+ default_reasoning_effort: z.string().default("medium"),
21
+ }),
22
+ auth: z.object({
23
+ jwt_token: z.string().nullable().default(null),
24
+ chatgpt_oauth: z.boolean().default(true),
25
+ refresh_margin_seconds: z.number().default(300),
26
+ rotation_strategy: z.enum(["least_used", "round_robin"]).default("least_used"),
27
+ rate_limit_backoff_seconds: z.number().default(60),
28
+ }),
29
+ server: z.object({
30
+ host: z.string().default("0.0.0.0"),
31
+ port: z.number().default(8080),
32
+ proxy_api_key: z.string().nullable().default(null),
33
+ }),
34
+ environment: z.object({
35
+ default_id: z.string().nullable().default(null),
36
+ default_branch: z.string().default("main"),
37
+ }),
38
+ streaming: z.object({
39
+ status_as_content: z.boolean().default(false),
40
+ chunk_size: z.number().default(100),
41
+ chunk_delay_ms: z.number().default(10),
42
+ heartbeat_interval_s: z.number().default(15),
43
+ poll_interval_s: z.number().default(2),
44
+ timeout_s: z.number().default(300),
45
+ }),
46
+ });
47
+
48
+ export type AppConfig = z.infer<typeof ConfigSchema>;
49
+
50
+ const FingerprintSchema = z.object({
51
+ user_agent_template: z.string(),
52
+ auth_domains: z.array(z.string()),
53
+ auth_domain_exclusions: z.array(z.string()),
54
+ header_order: z.array(z.string()),
55
+ });
56
+
57
+ export type FingerprintConfig = z.infer<typeof FingerprintSchema>;
58
+
59
+ function loadYaml(filePath: string): unknown {
60
+ const content = readFileSync(filePath, "utf-8");
61
+ return yaml.load(content);
62
+ }
63
+
64
+ function applyEnvOverrides(raw: Record<string, unknown>): Record<string, unknown> {
65
+ if (process.env.CODEX_JWT_TOKEN) {
66
+ (raw.auth as Record<string, unknown>).jwt_token = process.env.CODEX_JWT_TOKEN;
67
+ }
68
+ if (process.env.CODEX_PLATFORM) {
69
+ (raw.client as Record<string, unknown>).platform = process.env.CODEX_PLATFORM;
70
+ }
71
+ if (process.env.CODEX_ARCH) {
72
+ (raw.client as Record<string, unknown>).arch = process.env.CODEX_ARCH;
73
+ }
74
+ if (process.env.PORT) {
75
+ (raw.server as Record<string, unknown>).port = parseInt(process.env.PORT, 10);
76
+ }
77
+ return raw;
78
+ }
79
+
80
+ let _config: AppConfig | null = null;
81
+ let _fingerprint: FingerprintConfig | null = null;
82
+
83
+ export function loadConfig(configDir?: string): AppConfig {
84
+ if (_config) return _config;
85
+ const dir = configDir ?? resolve(process.cwd(), "config");
86
+ const raw = loadYaml(resolve(dir, "default.yaml")) as Record<string, unknown>;
87
+ applyEnvOverrides(raw);
88
+ _config = ConfigSchema.parse(raw);
89
+ return _config;
90
+ }
91
+
92
+ export function loadFingerprint(configDir?: string): FingerprintConfig {
93
+ if (_fingerprint) return _fingerprint;
94
+ const dir = configDir ?? resolve(process.cwd(), "config");
95
+ const raw = loadYaml(resolve(dir, "fingerprint.yaml"));
96
+ _fingerprint = FingerprintSchema.parse(raw);
97
+ return _fingerprint;
98
+ }
99
+
100
+ export function getConfig(): AppConfig {
101
+ if (!_config) throw new Error("Config not loaded. Call loadConfig() first.");
102
+ return _config;
103
+ }
104
+
105
+ export function getFingerprint(): FingerprintConfig {
106
+ if (!_fingerprint) throw new Error("Fingerprint not loaded. Call loadFingerprint() first.");
107
+ return _fingerprint;
108
+ }
src/fingerprint/manager.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Fingerprint manager — builds headers that mimic the Codex Desktop client.
3
+ *
4
+ * Based on Codex source: applyDesktopAuthHeaders / buildDesktopUserAgent
5
+ */
6
+
7
+ import { getConfig, getFingerprint } from "../config.js";
8
+ import { extractChatGptAccountId } from "../auth/jwt-utils.js";
9
+
10
+ export function buildHeaders(
11
+ token: string,
12
+ accountId?: string | null,
13
+ ): Record<string, string> {
14
+ const config = getConfig();
15
+ const fp = getFingerprint();
16
+ const headers: Record<string, string> = {};
17
+
18
+ headers["Authorization"] = `Bearer ${token}`;
19
+
20
+ const acctId = accountId ?? extractChatGptAccountId(token);
21
+ if (acctId) headers["ChatGPT-Account-Id"] = acctId;
22
+
23
+ headers["originator"] = config.client.originator;
24
+
25
+ const ua = fp.user_agent_template
26
+ .replace("{version}", config.client.app_version)
27
+ .replace("{platform}", config.client.platform)
28
+ .replace("{arch}", config.client.arch);
29
+ headers["User-Agent"] = ua;
30
+
31
+ return headers;
32
+ }
33
+
34
+ export function buildHeadersWithContentType(
35
+ token: string,
36
+ accountId?: string | null,
37
+ ): Record<string, string> {
38
+ const headers = buildHeaders(token, accountId);
39
+ headers["Content-Type"] = "application/json";
40
+ return headers;
41
+ }
src/index.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono";
2
+ import { serve } from "@hono/node-server";
3
+ import { loadConfig, loadFingerprint, getConfig } from "./config.js";
4
+ import { AccountPool } from "./auth/account-pool.js";
5
+ import { RefreshScheduler } from "./auth/refresh-scheduler.js";
6
+ import { SessionManager } from "./session/manager.js";
7
+ import { logger } from "./middleware/logger.js";
8
+ import { errorHandler } from "./middleware/error-handler.js";
9
+ import { createAuthRoutes } from "./routes/auth.js";
10
+ import { createAccountRoutes } from "./routes/accounts.js";
11
+ import { createChatRoutes } from "./routes/chat.js";
12
+ import modelsApp from "./routes/models.js";
13
+ import { createWebRoutes } from "./routes/web.js";
14
+ import { CookieJar } from "./proxy/cookie-jar.js";
15
+
16
+ async function main() {
17
+ // Load configuration
18
+ console.log("[Init] Loading configuration...");
19
+ const config = loadConfig();
20
+ loadFingerprint();
21
+
22
+ // Initialize managers
23
+ const accountPool = new AccountPool();
24
+ const refreshScheduler = new RefreshScheduler(accountPool);
25
+ const sessionManager = new SessionManager();
26
+ const cookieJar = new CookieJar();
27
+
28
+ // Create Hono app
29
+ const app = new Hono();
30
+
31
+ // Global middleware
32
+ app.use("*", logger);
33
+ app.use("*", errorHandler);
34
+
35
+ // Mount routes
36
+ const authRoutes = createAuthRoutes(accountPool);
37
+ const accountRoutes = createAccountRoutes(accountPool, refreshScheduler, cookieJar);
38
+ const chatRoutes = createChatRoutes(accountPool, sessionManager, cookieJar);
39
+ const webRoutes = createWebRoutes(accountPool);
40
+
41
+ app.route("/", authRoutes);
42
+ app.route("/", accountRoutes);
43
+ app.route("/", chatRoutes);
44
+ app.route("/", modelsApp);
45
+ app.route("/", webRoutes);
46
+
47
+ // Start server
48
+ const port = config.server.port;
49
+ const host = config.server.host;
50
+
51
+ const poolSummary = accountPool.getPoolSummary();
52
+
53
+ console.log(`
54
+ ╔══════════════════════════════════════════╗
55
+ ║ Codex Proxy Server ║
56
+ ╠══════════════════════════════════════════╣
57
+ ║ Status: ${accountPool.isAuthenticated() ? "Authenticated ✓" : "Not logged in "} ║
58
+ ║ Listen: http://${host}:${port} ║
59
+ ║ API: http://${host}:${port}/v1 ║
60
+ ╚══════════════════════════════════════════╝
61
+ `);
62
+
63
+ if (accountPool.isAuthenticated()) {
64
+ const user = accountPool.getUserInfo();
65
+ console.log(` User: ${user?.email ?? "unknown"}`);
66
+ console.log(` Plan: ${user?.planType ?? "unknown"}`);
67
+ console.log(` Key: ${accountPool.getProxyApiKey()}`);
68
+ console.log(` Pool: ${poolSummary.active} active / ${poolSummary.total} total accounts`);
69
+ } else {
70
+ console.log(` Open http://localhost:${port} to login`);
71
+ }
72
+ console.log();
73
+
74
+ serve({
75
+ fetch: app.fetch,
76
+ hostname: host,
77
+ port,
78
+ });
79
+
80
+ // Graceful shutdown
81
+ const shutdown = () => {
82
+ console.log("\n[Shutdown] Cleaning up...");
83
+ cookieJar.destroy();
84
+ refreshScheduler.destroy();
85
+ accountPool.destroy();
86
+ process.exit(0);
87
+ };
88
+
89
+ process.on("SIGINT", shutdown);
90
+ process.on("SIGTERM", shutdown);
91
+ }
92
+
93
+ main().catch((err) => {
94
+ console.error("Fatal error:", err);
95
+ process.exit(1);
96
+ });
src/middleware/error-handler.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Context, Next } from "hono";
2
+ import type { OpenAIErrorBody } from "../types/openai.js";
3
+
4
+ function makeError(
5
+ message: string,
6
+ type: string,
7
+ code: string | null,
8
+ ): OpenAIErrorBody {
9
+ return {
10
+ error: {
11
+ message,
12
+ type,
13
+ param: null,
14
+ code,
15
+ },
16
+ };
17
+ }
18
+
19
+ export async function errorHandler(c: Context, next: Next): Promise<void> {
20
+ try {
21
+ await next();
22
+ } catch (err: unknown) {
23
+ const message = err instanceof Error ? err.message : "Internal server error";
24
+ console.error("[ErrorHandler]", message);
25
+
26
+ const status = (err as { status?: number }).status;
27
+
28
+ if (status === 401) {
29
+ c.status(401);
30
+ return c.json(
31
+ makeError(
32
+ "Invalid or expired ChatGPT token. Please re-authenticate.",
33
+ "invalid_request_error",
34
+ "invalid_api_key",
35
+ ),
36
+ ) as never;
37
+ }
38
+
39
+ if (status === 429) {
40
+ c.status(429);
41
+ return c.json(
42
+ makeError(
43
+ "Rate limit exceeded. Please try again later.",
44
+ "rate_limit_error",
45
+ "rate_limit_exceeded",
46
+ ),
47
+ ) as never;
48
+ }
49
+
50
+ if (status && status >= 500) {
51
+ c.status(502);
52
+ return c.json(
53
+ makeError(
54
+ `Upstream server error: ${message}`,
55
+ "server_error",
56
+ "server_error",
57
+ ),
58
+ ) as never;
59
+ }
60
+
61
+ c.status(500);
62
+ return c.json(
63
+ makeError(message, "server_error", "internal_error"),
64
+ ) as never;
65
+ }
66
+ }
src/middleware/logger.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Context, Next } from "hono";
2
+
3
+ export async function logger(c: Context, next: Next): Promise<void> {
4
+ const start = Date.now();
5
+ const method = c.req.method;
6
+ const path = c.req.path;
7
+
8
+ console.log(`→ ${method} ${path}`);
9
+
10
+ await next();
11
+
12
+ const ms = Date.now() - start;
13
+ const status = c.res.status;
14
+ console.log(`← ${method} ${path} ${status} ${ms}ms`);
15
+ }
src/proxy/client.ts ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ProxyClient — fetch wrapper with auth headers, retry on 401, and SSE streaming.
3
+ *
4
+ * Mirrors the Codex Desktop ElectronFetchWrapper pattern.
5
+ */
6
+
7
+ import { getConfig } from "../config.js";
8
+ import {
9
+ buildHeaders,
10
+ buildHeadersWithContentType,
11
+ } from "../fingerprint/manager.js";
12
+
13
+ export interface FetchOptions {
14
+ method?: string;
15
+ headers?: Record<string, string>;
16
+ body?: string;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ export interface FetchResponse {
21
+ status: number;
22
+ headers: Record<string, string>;
23
+ body: unknown;
24
+ ok: boolean;
25
+ }
26
+
27
+ export class ProxyClient {
28
+ private token: string;
29
+ private accountId: string | null;
30
+
31
+ constructor(token: string, accountId: string | null) {
32
+ this.token = token;
33
+ this.accountId = accountId;
34
+ }
35
+
36
+ /** Update the bearer token (e.g. after a refresh). */
37
+ setToken(token: string): void {
38
+ this.token = token;
39
+ }
40
+
41
+ /** Update the account ID. */
42
+ setAccountId(accountId: string | null): void {
43
+ this.accountId = accountId;
44
+ }
45
+
46
+ // ---- public helpers ----
47
+
48
+ /** GET request, returns parsed JSON body. */
49
+ async get(path: string): Promise<FetchResponse> {
50
+ const url = this.ensureAbsoluteUrl(path);
51
+ const res = await this.fetchWithRetry(url, {
52
+ method: "GET",
53
+ headers: buildHeaders(this.token, this.accountId),
54
+ });
55
+ const body = await res.json();
56
+ return {
57
+ status: res.status,
58
+ headers: Object.fromEntries(res.headers.entries()),
59
+ body,
60
+ ok: res.ok,
61
+ };
62
+ }
63
+
64
+ /** POST request with JSON body, returns parsed JSON body. */
65
+ async post(path: string, body: unknown): Promise<FetchResponse> {
66
+ const url = this.ensureAbsoluteUrl(path);
67
+ const res = await this.fetchWithRetry(url, {
68
+ method: "POST",
69
+ headers: buildHeadersWithContentType(this.token, this.accountId),
70
+ body: JSON.stringify(body),
71
+ });
72
+ const resBody = await res.json();
73
+ return {
74
+ status: res.status,
75
+ headers: Object.fromEntries(res.headers.entries()),
76
+ body: resBody,
77
+ ok: res.ok,
78
+ };
79
+ }
80
+
81
+ /** GET an SSE endpoint — yields parsed `{ event?, data }` objects. */
82
+ async *stream(
83
+ path: string,
84
+ signal?: AbortSignal,
85
+ ): AsyncGenerator<{ event?: string; data: unknown }> {
86
+ const url = this.ensureAbsoluteUrl(path);
87
+ const res = await this.fetchWithRetry(url, {
88
+ method: "GET",
89
+ headers: {
90
+ ...buildHeaders(this.token, this.accountId),
91
+ Accept: "text/event-stream",
92
+ },
93
+ signal,
94
+ });
95
+
96
+ if (!res.ok) {
97
+ const text = await res.text();
98
+ throw new Error(`SSE request failed (${res.status}): ${text}`);
99
+ }
100
+
101
+ if (!res.body) {
102
+ throw new Error("Response body is null — cannot stream");
103
+ }
104
+
105
+ const reader = res.body
106
+ .pipeThrough(new TextDecoderStream())
107
+ .getReader();
108
+
109
+ let buffer = "";
110
+ try {
111
+ while (true) {
112
+ const { done, value } = await reader.read();
113
+ if (done) break;
114
+
115
+ buffer += value;
116
+
117
+ // Process complete SSE messages (separated by double newline)
118
+ const parts = buffer.split("\n\n");
119
+ // Last part may be incomplete — keep it in the buffer
120
+ buffer = parts.pop()!;
121
+
122
+ for (const part of parts) {
123
+ if (!part.trim()) continue;
124
+ for (const parsed of this.parseSSE(part)) {
125
+ if (parsed.data === "[DONE]") return;
126
+ try {
127
+ yield { event: parsed.event, data: JSON.parse(parsed.data) };
128
+ } catch {
129
+ yield { event: parsed.event, data: parsed.data };
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // Process any remaining data in the buffer
136
+ if (buffer.trim()) {
137
+ for (const parsed of this.parseSSE(buffer)) {
138
+ if (parsed.data === "[DONE]") return;
139
+ try {
140
+ yield { event: parsed.event, data: JSON.parse(parsed.data) };
141
+ } catch {
142
+ yield { event: parsed.event, data: parsed.data };
143
+ }
144
+ }
145
+ }
146
+ } finally {
147
+ reader.releaseLock();
148
+ }
149
+ }
150
+
151
+ // ---- internal helpers ----
152
+
153
+ /**
154
+ * Resolve a relative URL to absolute using the configured base_url.
155
+ * Mirrors Codex's ensureAbsoluteUrl.
156
+ */
157
+ private ensureAbsoluteUrl(url: string): string {
158
+ if (/^https?:\/\//i.test(url) || url.startsWith("data:")) return url;
159
+ const base = getConfig().api.base_url;
160
+ return `${base}/${url.replace(/^\/+/, "")}`;
161
+ }
162
+
163
+ /**
164
+ * Fetch with a single 401 retry (re-builds auth headers on retry).
165
+ */
166
+ private async fetchWithRetry(
167
+ url: string,
168
+ options: FetchOptions,
169
+ onRefreshToken?: () => Promise<string | null>,
170
+ ): Promise<Response> {
171
+ const config = getConfig();
172
+ const timeout = config.api.timeout_seconds * 1000;
173
+
174
+ const doFetch = (opts: FetchOptions): Promise<Response> => {
175
+ const controller = new AbortController();
176
+ const timer = setTimeout(() => controller.abort(), timeout);
177
+ const mergedSignal = opts.signal
178
+ ? AbortSignal.any([opts.signal, controller.signal])
179
+ : controller.signal;
180
+
181
+ return fetch(url, {
182
+ method: opts.method ?? "GET",
183
+ headers: opts.headers,
184
+ body: opts.body,
185
+ signal: mergedSignal,
186
+ }).finally(() => clearTimeout(timer));
187
+ };
188
+
189
+ const res = await doFetch(options);
190
+
191
+ // Single retry on 401 if a refresh callback is provided
192
+ if (res.status === 401 && onRefreshToken) {
193
+ const newToken = await onRefreshToken();
194
+ if (newToken) {
195
+ this.token = newToken;
196
+ const retryHeaders = options.headers?.["Content-Type"]
197
+ ? buildHeadersWithContentType(this.token, this.accountId)
198
+ : buildHeaders(this.token, this.accountId);
199
+ return doFetch({ ...options, headers: retryHeaders });
200
+ }
201
+ }
202
+
203
+ return res;
204
+ }
205
+
206
+ /**
207
+ * Parse raw SSE text block into individual events.
208
+ */
209
+ private *parseSSE(
210
+ text: string,
211
+ ): Generator<{ event?: string; data: string }> {
212
+ let event: string | undefined;
213
+ let dataLines: string[] = [];
214
+
215
+ for (const line of text.split("\n")) {
216
+ if (line.startsWith("event:")) {
217
+ event = line.slice(6).trim();
218
+ } else if (line.startsWith("data:")) {
219
+ dataLines.push(line.slice(5).trimStart());
220
+ } else if (line === "" && dataLines.length > 0) {
221
+ yield { event, data: dataLines.join("\n") };
222
+ event = undefined;
223
+ dataLines = [];
224
+ }
225
+ }
226
+
227
+ // Yield any remaining accumulated data
228
+ if (dataLines.length > 0) {
229
+ yield { event, data: dataLines.join("\n") };
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Replace `{param}` placeholders in a URL template with encoded values.
236
+ */
237
+ export function serializePath(
238
+ template: string,
239
+ params: Record<string, string>,
240
+ ): string {
241
+ let path = template;
242
+ for (const [key, value] of Object.entries(params)) {
243
+ path = path.replace(`{${key}}`, encodeURIComponent(value));
244
+ }
245
+ return path;
246
+ }
src/proxy/codex-api.ts ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CodexApi — client for the Codex Responses API.
3
+ *
4
+ * Endpoint: POST /backend-api/codex/responses
5
+ * This is the API the Codex CLI actually uses.
6
+ * It requires: instructions, store: false, stream: true.
7
+ */
8
+
9
+ import { execFile } from "child_process";
10
+ import { getConfig } from "../config.js";
11
+ import {
12
+ buildHeaders,
13
+ buildHeadersWithContentType,
14
+ } from "../fingerprint/manager.js";
15
+ import type { CookieJar } from "./cookie-jar.js";
16
+
17
+ export interface CodexResponsesRequest {
18
+ model: string;
19
+ instructions: string;
20
+ input: CodexInputItem[];
21
+ stream: true;
22
+ store: false;
23
+ /** Optional: reasoning effort level */
24
+ reasoning?: { effort: string };
25
+ /** Optional: tools available to the model */
26
+ tools?: unknown[];
27
+ /** Optional: previous response ID for multi-turn */
28
+ previous_response_id?: string;
29
+ }
30
+
31
+ export type CodexInputItem =
32
+ | { role: "user"; content: string }
33
+ | { role: "assistant"; content: string }
34
+ | { role: "system"; content: string };
35
+
36
+ /** Parsed SSE event from the Codex Responses stream */
37
+ export interface CodexSSEEvent {
38
+ event: string;
39
+ data: unknown;
40
+ }
41
+
42
+ export class CodexApi {
43
+ private token: string;
44
+ private accountId: string | null;
45
+ private cookieJar: CookieJar | null;
46
+ private entryId: string | null;
47
+
48
+ constructor(
49
+ token: string,
50
+ accountId: string | null,
51
+ cookieJar?: CookieJar | null,
52
+ entryId?: string | null,
53
+ ) {
54
+ this.token = token;
55
+ this.accountId = accountId;
56
+ this.cookieJar = cookieJar ?? null;
57
+ this.entryId = entryId ?? null;
58
+ }
59
+
60
+ setToken(token: string): void {
61
+ this.token = token;
62
+ }
63
+
64
+ /** Build headers with cookies injected. */
65
+ private applyHeaders(headers: Record<string, string>): Record<string, string> {
66
+ if (this.cookieJar && this.entryId) {
67
+ const cookie = this.cookieJar.getCookieHeader(this.entryId);
68
+ if (cookie) headers["Cookie"] = cookie;
69
+ }
70
+ return headers;
71
+ }
72
+
73
+ /** Capture Set-Cookie headers from a response into the jar. */
74
+ private captureCookies(response: Response): void {
75
+ if (this.cookieJar && this.entryId) {
76
+ this.cookieJar.capture(this.entryId, response);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Query official Codex usage/quota.
82
+ * GET /backend-api/codex/usage
83
+ *
84
+ * Uses curl subprocess instead of Node.js fetch because Cloudflare
85
+ * fingerprints the TLS handshake and blocks Node.js/undici requests
86
+ * with a JS challenge (403). System curl uses native TLS (WinSSL/SecureTransport)
87
+ * which Cloudflare accepts.
88
+ */
89
+ async getUsage(): Promise<CodexUsageResponse> {
90
+ const config = getConfig();
91
+ const url = `${config.api.base_url}/codex/usage`;
92
+
93
+ const headers = this.applyHeaders(
94
+ buildHeaders(this.token, this.accountId),
95
+ );
96
+ headers["Accept"] = "application/json";
97
+
98
+ // Build curl args
99
+ const args = ["-s", "--max-time", "15"];
100
+ for (const [key, value] of Object.entries(headers)) {
101
+ args.push("-H", `${key}: ${value}`);
102
+ }
103
+ args.push(url);
104
+
105
+ const body = await new Promise<string>((resolve, reject) => {
106
+ execFile("curl", args, { maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
107
+ if (err) {
108
+ reject(new CodexApiError(0, `curl failed: ${err.message} ${stderr}`));
109
+ } else {
110
+ resolve(stdout);
111
+ }
112
+ });
113
+ });
114
+
115
+ try {
116
+ const parsed = JSON.parse(body) as CodexUsageResponse;
117
+ // Validate we got actual usage data (not an error page)
118
+ if (!parsed.rate_limit) {
119
+ throw new CodexApiError(502, `Unexpected response: ${body.slice(0, 200)}`);
120
+ }
121
+ return parsed;
122
+ } catch (e) {
123
+ if (e instanceof CodexApiError) throw e;
124
+ throw new CodexApiError(502, `Invalid JSON from /codex/usage: ${body.slice(0, 200)}`);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Create a response (streaming).
130
+ * Returns the raw Response so the caller can process the SSE stream.
131
+ */
132
+ async createResponse(
133
+ request: CodexResponsesRequest,
134
+ signal?: AbortSignal,
135
+ ): Promise<Response> {
136
+ const config = getConfig();
137
+ const baseUrl = config.api.base_url; // https://chatgpt.com/backend-api
138
+ const url = `${baseUrl}/codex/responses`;
139
+
140
+ const headers = this.applyHeaders(
141
+ buildHeadersWithContentType(this.token, this.accountId),
142
+ );
143
+ headers["Accept"] = "text/event-stream";
144
+
145
+ const timeout = config.api.timeout_seconds * 1000;
146
+ const controller = new AbortController();
147
+ const timer = setTimeout(() => controller.abort(), timeout);
148
+ const mergedSignal = signal
149
+ ? AbortSignal.any([signal, controller.signal])
150
+ : controller.signal;
151
+
152
+ const res = await fetch(url, {
153
+ method: "POST",
154
+ headers,
155
+ body: JSON.stringify(request),
156
+ signal: mergedSignal,
157
+ }).finally(() => clearTimeout(timer));
158
+
159
+ this.captureCookies(res);
160
+
161
+ if (!res.ok) {
162
+ let errorBody: string;
163
+ try {
164
+ errorBody = await res.text();
165
+ } catch {
166
+ errorBody = `HTTP ${res.status}`;
167
+ }
168
+ throw new CodexApiError(res.status, errorBody);
169
+ }
170
+
171
+ return res;
172
+ }
173
+
174
+ /**
175
+ * Parse SSE stream from a Codex Responses API response.
176
+ * Yields individual events.
177
+ */
178
+ async *parseStream(
179
+ response: Response,
180
+ ): AsyncGenerator<CodexSSEEvent> {
181
+ if (!response.body) {
182
+ throw new Error("Response body is null — cannot stream");
183
+ }
184
+
185
+ const reader = response.body
186
+ .pipeThrough(new TextDecoderStream())
187
+ .getReader();
188
+
189
+ let buffer = "";
190
+ try {
191
+ while (true) {
192
+ const { done, value } = await reader.read();
193
+ if (done) break;
194
+
195
+ buffer += value;
196
+ const parts = buffer.split("\n\n");
197
+ buffer = parts.pop()!;
198
+
199
+ for (const part of parts) {
200
+ if (!part.trim()) continue;
201
+ const evt = this.parseSSEBlock(part);
202
+ if (evt) yield evt;
203
+ }
204
+ }
205
+
206
+ // Process remaining buffer
207
+ if (buffer.trim()) {
208
+ const evt = this.parseSSEBlock(buffer);
209
+ if (evt) yield evt;
210
+ }
211
+ } finally {
212
+ reader.releaseLock();
213
+ }
214
+ }
215
+
216
+ private parseSSEBlock(block: string): CodexSSEEvent | null {
217
+ let event = "";
218
+ const dataLines: string[] = [];
219
+
220
+ for (const line of block.split("\n")) {
221
+ if (line.startsWith("event:")) {
222
+ event = line.slice(6).trim();
223
+ } else if (line.startsWith("data:")) {
224
+ dataLines.push(line.slice(5).trimStart());
225
+ }
226
+ }
227
+
228
+ if (!event && dataLines.length === 0) return null;
229
+
230
+ const raw = dataLines.join("\n");
231
+ if (raw === "[DONE]") return null;
232
+
233
+ let data: unknown;
234
+ try {
235
+ data = JSON.parse(raw);
236
+ } catch {
237
+ data = raw;
238
+ }
239
+
240
+ return { event, data };
241
+ }
242
+ }
243
+
244
+ /** Response from GET /backend-api/codex/usage */
245
+ export interface CodexUsageRateWindow {
246
+ used_percent: number;
247
+ limit_window_seconds: number;
248
+ reset_after_seconds: number;
249
+ reset_at: number;
250
+ }
251
+
252
+ export interface CodexUsageRateLimit {
253
+ allowed: boolean;
254
+ limit_reached: boolean;
255
+ primary_window: CodexUsageRateWindow | null;
256
+ secondary_window: CodexUsageRateWindow | null;
257
+ }
258
+
259
+ export interface CodexUsageResponse {
260
+ plan_type: string;
261
+ rate_limit: CodexUsageRateLimit;
262
+ code_review_rate_limit: CodexUsageRateLimit | null;
263
+ credits: unknown;
264
+ promo: unknown;
265
+ }
266
+
267
+ export class CodexApiError extends Error {
268
+ constructor(
269
+ public readonly status: number,
270
+ public readonly body: string,
271
+ ) {
272
+ let detail: string;
273
+ try {
274
+ const parsed = JSON.parse(body);
275
+ detail = parsed.detail ?? parsed.error?.message ?? body;
276
+ } catch {
277
+ detail = body;
278
+ }
279
+ super(`Codex API error (${status}): ${detail}`);
280
+ }
281
+ }
src/proxy/cookie-jar.ts ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CookieJar — per-account cookie storage.
3
+ *
4
+ * Stores cookies (especially cf_clearance from Cloudflare) so that
5
+ * GET endpoints like /codex/usage don't get blocked by JS challenges.
6
+ *
7
+ * Cookies are auto-captured from every ChatGPT API response's Set-Cookie
8
+ * headers, and can also be set manually via the management API.
9
+ */
10
+
11
+ import {
12
+ readFileSync,
13
+ writeFileSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ } from "fs";
17
+ import { resolve, dirname } from "path";
18
+
19
+ const COOKIE_FILE = resolve(process.cwd(), "data", "cookies.json");
20
+
21
+ export class CookieJar {
22
+ private cookies: Map<string, Record<string, string>> = new Map();
23
+ private persistTimer: ReturnType<typeof setTimeout> | null = null;
24
+
25
+ constructor() {
26
+ this.load();
27
+ }
28
+
29
+ /**
30
+ * Set cookies for an account.
31
+ * Accepts "name1=val1; name2=val2" string or a Record.
32
+ * Merges with existing cookies.
33
+ */
34
+ set(accountId: string, cookies: string | Record<string, string>): void {
35
+ const existing = this.cookies.get(accountId) ?? {};
36
+
37
+ if (typeof cookies === "string") {
38
+ for (const part of cookies.split(";")) {
39
+ const eq = part.indexOf("=");
40
+ if (eq === -1) continue;
41
+ const name = part.slice(0, eq).trim();
42
+ const value = part.slice(eq + 1).trim();
43
+ if (name) existing[name] = value;
44
+ }
45
+ } else {
46
+ Object.assign(existing, cookies);
47
+ }
48
+
49
+ this.cookies.set(accountId, existing);
50
+ this.schedulePersist();
51
+ }
52
+
53
+ /**
54
+ * Build the Cookie header value for a request.
55
+ * Returns null if no cookies are stored.
56
+ */
57
+ getCookieHeader(accountId: string): string | null {
58
+ const cookies = this.cookies.get(accountId);
59
+ if (!cookies || Object.keys(cookies).length === 0) return null;
60
+ return Object.entries(cookies)
61
+ .map(([k, v]) => `${k}=${v}`)
62
+ .join("; ");
63
+ }
64
+
65
+ /**
66
+ * Auto-capture Set-Cookie headers from an API response.
67
+ * Call this after every successful fetch to chatgpt.com.
68
+ */
69
+ capture(accountId: string, response: Response): void {
70
+ // getSetCookie() returns individual Set-Cookie header values
71
+ const setCookies =
72
+ typeof response.headers.getSetCookie === "function"
73
+ ? response.headers.getSetCookie()
74
+ : [];
75
+
76
+ if (setCookies.length === 0) return;
77
+
78
+ const existing = this.cookies.get(accountId) ?? {};
79
+ let changed = false;
80
+
81
+ for (const raw of setCookies) {
82
+ // Format: "name=value; Path=/; Domain=...; ..."
83
+ const semi = raw.indexOf(";");
84
+ const pair = semi === -1 ? raw : raw.slice(0, semi);
85
+ const eq = pair.indexOf("=");
86
+ if (eq === -1) continue;
87
+
88
+ const name = pair.slice(0, eq).trim();
89
+ const value = pair.slice(eq + 1).trim();
90
+ if (name && existing[name] !== value) {
91
+ existing[name] = value;
92
+ changed = true;
93
+ }
94
+ }
95
+
96
+ if (changed) {
97
+ this.cookies.set(accountId, existing);
98
+ this.schedulePersist();
99
+ }
100
+ }
101
+
102
+ /** Get raw cookie record for an account. */
103
+ get(accountId: string): Record<string, string> | null {
104
+ return this.cookies.get(accountId) ?? null;
105
+ }
106
+
107
+ /** Clear all cookies for an account. */
108
+ clear(accountId: string): void {
109
+ if (this.cookies.delete(accountId)) {
110
+ this.schedulePersist();
111
+ }
112
+ }
113
+
114
+ // ── Persistence ──────────────────────────────────────────────────
115
+
116
+ private schedulePersist(): void {
117
+ if (this.persistTimer) return;
118
+ this.persistTimer = setTimeout(() => {
119
+ this.persistTimer = null;
120
+ this.persistNow();
121
+ }, 1000);
122
+ }
123
+
124
+ persistNow(): void {
125
+ if (this.persistTimer) {
126
+ clearTimeout(this.persistTimer);
127
+ this.persistTimer = null;
128
+ }
129
+ try {
130
+ const dir = dirname(COOKIE_FILE);
131
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
132
+ const data = Object.fromEntries(this.cookies);
133
+ writeFileSync(COOKIE_FILE, JSON.stringify(data, null, 2), "utf-8");
134
+ } catch {
135
+ // best-effort
136
+ }
137
+ }
138
+
139
+ private load(): void {
140
+ try {
141
+ if (!existsSync(COOKIE_FILE)) return;
142
+ const raw = readFileSync(COOKIE_FILE, "utf-8");
143
+ const data = JSON.parse(raw) as Record<string, Record<string, string>>;
144
+ for (const [key, val] of Object.entries(data)) {
145
+ if (typeof val === "object" && val !== null) {
146
+ this.cookies.set(key, val);
147
+ }
148
+ }
149
+ } catch {
150
+ // corrupt file, start fresh
151
+ }
152
+ }
153
+
154
+ destroy(): void {
155
+ if (this.persistTimer) {
156
+ clearTimeout(this.persistTimer);
157
+ this.persistTimer = null;
158
+ }
159
+ this.persistNow();
160
+ }
161
+ }
src/routes/accounts.ts ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Account management API routes.
3
+ *
4
+ * GET /auth/accounts — list all accounts + usage + status
5
+ * GET /auth/accounts?quota=true — list all accounts with official quota
6
+ * POST /auth/accounts — add account (token paste)
7
+ * DELETE /auth/accounts/:id — remove account
8
+ * POST /auth/accounts/:id/reset-usage — reset usage stats
9
+ * GET /auth/accounts/:id/quota — query single account's official quota
10
+ * GET /auth/accounts/:id/cookies — view stored cookies
11
+ * POST /auth/accounts/:id/cookies — set cookies (for Cloudflare bypass)
12
+ * DELETE /auth/accounts/:id/cookies — clear cookies
13
+ */
14
+
15
+ import { Hono } from "hono";
16
+ import type { AccountPool } from "../auth/account-pool.js";
17
+ import type { RefreshScheduler } from "../auth/refresh-scheduler.js";
18
+ import { validateManualToken } from "../auth/chatgpt-oauth.js";
19
+ import { CodexApi } from "../proxy/codex-api.js";
20
+ import type { CodexUsageResponse } from "../proxy/codex-api.js";
21
+ import type { CodexQuota, AccountInfo } from "../auth/types.js";
22
+ import type { CookieJar } from "../proxy/cookie-jar.js";
23
+
24
+ function toQuota(usage: CodexUsageResponse): CodexQuota {
25
+ return {
26
+ plan_type: usage.plan_type,
27
+ rate_limit: {
28
+ allowed: usage.rate_limit.allowed,
29
+ limit_reached: usage.rate_limit.limit_reached,
30
+ used_percent: usage.rate_limit.primary_window?.used_percent ?? null,
31
+ reset_at: usage.rate_limit.primary_window?.reset_at ?? null,
32
+ },
33
+ code_review_rate_limit: usage.code_review_rate_limit
34
+ ? {
35
+ allowed: usage.code_review_rate_limit.allowed,
36
+ limit_reached: usage.code_review_rate_limit.limit_reached,
37
+ used_percent:
38
+ usage.code_review_rate_limit.primary_window?.used_percent ?? null,
39
+ reset_at:
40
+ usage.code_review_rate_limit.primary_window?.reset_at ?? null,
41
+ }
42
+ : null,
43
+ };
44
+ }
45
+
46
+ export function createAccountRoutes(
47
+ pool: AccountPool,
48
+ scheduler: RefreshScheduler,
49
+ cookieJar?: CookieJar,
50
+ ): Hono {
51
+ const app = new Hono();
52
+
53
+ /** Helper: build a CodexApi with cookie support. */
54
+ function makeApi(entryId: string, token: string, accountId: string | null): CodexApi {
55
+ return new CodexApi(token, accountId, cookieJar, entryId);
56
+ }
57
+
58
+ // List all accounts (with optional ?quota=true)
59
+ app.get("/auth/accounts", async (c) => {
60
+ const accounts = pool.getAccounts();
61
+ const wantQuota = c.req.query("quota") === "true";
62
+
63
+ if (!wantQuota) {
64
+ return c.json({ accounts });
65
+ }
66
+
67
+ // Fetch quota for every active account in parallel
68
+ const enriched: AccountInfo[] = await Promise.all(
69
+ accounts.map(async (acct) => {
70
+ if (acct.status !== "active") return acct;
71
+
72
+ const entry = pool.getEntry(acct.id);
73
+ if (!entry) return acct;
74
+
75
+ try {
76
+ const api = makeApi(acct.id, entry.token, entry.accountId);
77
+ const usage = await api.getUsage();
78
+ return { ...acct, quota: toQuota(usage) };
79
+ } catch {
80
+ return acct; // skip on error — no quota field
81
+ }
82
+ }),
83
+ );
84
+
85
+ return c.json({ accounts: enriched });
86
+ });
87
+
88
+ // Add account
89
+ app.post("/auth/accounts", async (c) => {
90
+ const body = await c.req.json<{ token: string }>();
91
+ const token = body.token?.trim();
92
+
93
+ if (!token) {
94
+ c.status(400);
95
+ return c.json({ error: "Token is required" });
96
+ }
97
+
98
+ const validation = validateManualToken(token);
99
+ if (!validation.valid) {
100
+ c.status(400);
101
+ return c.json({ error: validation.error });
102
+ }
103
+
104
+ const entryId = pool.addAccount(token);
105
+ scheduler.scheduleOne(entryId, token);
106
+
107
+ const accounts = pool.getAccounts();
108
+ const added = accounts.find((a) => a.id === entryId);
109
+ return c.json({ success: true, account: added });
110
+ });
111
+
112
+ // Remove account
113
+ app.delete("/auth/accounts/:id", (c) => {
114
+ const id = c.req.param("id");
115
+ scheduler.clearOne(id);
116
+ const removed = pool.removeAccount(id);
117
+ if (!removed) {
118
+ c.status(404);
119
+ return c.json({ error: "Account not found" });
120
+ }
121
+ cookieJar?.clear(id);
122
+ return c.json({ success: true });
123
+ });
124
+
125
+ // Reset usage
126
+ app.post("/auth/accounts/:id/reset-usage", (c) => {
127
+ const id = c.req.param("id");
128
+ const reset = pool.resetUsage(id);
129
+ if (!reset) {
130
+ c.status(404);
131
+ return c.json({ error: "Account not found" });
132
+ }
133
+ return c.json({ success: true });
134
+ });
135
+
136
+ // Query single account's official quota
137
+ app.get("/auth/accounts/:id/quota", async (c) => {
138
+ const id = c.req.param("id");
139
+ const entry = pool.getEntry(id);
140
+
141
+ if (!entry) {
142
+ c.status(404);
143
+ return c.json({ error: "Account not found" });
144
+ }
145
+
146
+ if (entry.status !== "active") {
147
+ c.status(409);
148
+ return c.json({ error: `Account is ${entry.status}, cannot query quota` });
149
+ }
150
+
151
+ const hasCookies = !!(cookieJar?.getCookieHeader(id));
152
+
153
+ try {
154
+ const api = makeApi(id, entry.token, entry.accountId);
155
+ const usage = await api.getUsage();
156
+ return c.json({ quota: toQuota(usage), raw: usage });
157
+ } catch (err) {
158
+ const detail = err instanceof Error ? err.message : String(err);
159
+ const isCf = detail.includes("403") || detail.includes("cf_chl");
160
+ c.status(502);
161
+ return c.json({
162
+ error: "Failed to fetch quota from Codex API",
163
+ detail,
164
+ hint: isCf && !hasCookies
165
+ ? "Cloudflare blocked this request. Set cookies via POST /auth/accounts/:id/cookies with your browser's cf_clearance cookie."
166
+ : undefined,
167
+ });
168
+ }
169
+ });
170
+
171
+ // ── Cookie management ──────────────────────────────────────────
172
+
173
+ // View cookies for an account
174
+ app.get("/auth/accounts/:id/cookies", (c) => {
175
+ const id = c.req.param("id");
176
+ if (!pool.getEntry(id)) {
177
+ c.status(404);
178
+ return c.json({ error: "Account not found" });
179
+ }
180
+
181
+ const cookies = cookieJar?.get(id) ?? null;
182
+ return c.json({
183
+ cookies,
184
+ hint: !cookies
185
+ ? "No cookies set. POST cookies from your browser to bypass Cloudflare. Example: { \"cookies\": \"cf_clearance=VALUE; __cf_bm=VALUE\" }"
186
+ : undefined,
187
+ });
188
+ });
189
+
190
+ // Set cookies for an account
191
+ app.post("/auth/accounts/:id/cookies", async (c) => {
192
+ const id = c.req.param("id");
193
+ if (!pool.getEntry(id)) {
194
+ c.status(404);
195
+ return c.json({ error: "Account not found" });
196
+ }
197
+
198
+ if (!cookieJar) {
199
+ c.status(500);
200
+ return c.json({ error: "CookieJar not initialized" });
201
+ }
202
+
203
+ const body = await c.req.json<{ cookies: string | Record<string, string> }>();
204
+ if (!body.cookies) {
205
+ c.status(400);
206
+ return c.json({
207
+ error: "cookies field is required",
208
+ example: { cookies: "cf_clearance=VALUE; __cf_bm=VALUE" },
209
+ });
210
+ }
211
+
212
+ cookieJar.set(id, body.cookies);
213
+ const stored = cookieJar.get(id);
214
+ console.log(`[Cookies] Set ${Object.keys(stored ?? {}).length} cookie(s) for account ${id}`);
215
+ return c.json({ success: true, cookies: stored });
216
+ });
217
+
218
+ // Clear cookies for an account
219
+ app.delete("/auth/accounts/:id/cookies", (c) => {
220
+ const id = c.req.param("id");
221
+ if (!pool.getEntry(id)) {
222
+ c.status(404);
223
+ return c.json({ error: "Account not found" });
224
+ }
225
+ cookieJar?.clear(id);
226
+ return c.json({ success: true });
227
+ });
228
+
229
+ return app;
230
+ }
src/routes/auth.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono";
2
+ import type { AccountPool } from "../auth/account-pool.js";
3
+ import {
4
+ isCodexCliAvailable,
5
+ loginViaCli,
6
+ validateManualToken,
7
+ } from "../auth/chatgpt-oauth.js";
8
+
9
+ export function createAuthRoutes(pool: AccountPool): Hono {
10
+ const app = new Hono();
11
+
12
+ // Pending OAuth session (one at a time)
13
+ let pendingOAuth: {
14
+ authUrl: string;
15
+ waitForCompletion: () => Promise<{ success: boolean; token?: string; error?: string }>;
16
+ } | null = null;
17
+
18
+ // Auth status (JSON) — pool-level summary
19
+ app.get("/auth/status", (c) => {
20
+ const authenticated = pool.isAuthenticated();
21
+ const userInfo = pool.getUserInfo();
22
+ const proxyApiKey = pool.getProxyApiKey();
23
+ const summary = pool.getPoolSummary();
24
+ return c.json({
25
+ authenticated,
26
+ user: authenticated ? userInfo : null,
27
+ proxy_api_key: authenticated ? proxyApiKey : null,
28
+ pool: summary,
29
+ });
30
+ });
31
+
32
+ // Start OAuth login — returns JSON with authUrl instead of redirecting
33
+ app.get("/auth/login", async (c) => {
34
+ if (pool.isAuthenticated()) {
35
+ return c.json({ authenticated: true });
36
+ }
37
+
38
+ const cliAvailable = await isCodexCliAvailable();
39
+ if (!cliAvailable) {
40
+ return c.json(
41
+ { error: "Codex CLI not available. Please use manual token entry." },
42
+ 503,
43
+ );
44
+ }
45
+
46
+ try {
47
+ const session = await loginViaCli();
48
+ pendingOAuth = session;
49
+
50
+ // Start background wait for completion
51
+ session.waitForCompletion().then((result) => {
52
+ if (result.success && result.token) {
53
+ pool.addAccount(result.token);
54
+ console.log("[Auth] OAuth login completed successfully");
55
+ } else {
56
+ console.error("[Auth] OAuth login failed:", result.error);
57
+ }
58
+ pendingOAuth = null;
59
+ });
60
+
61
+ return c.json({ authUrl: session.authUrl });
62
+ } catch (err) {
63
+ const msg = err instanceof Error ? err.message : String(err);
64
+ console.error("[Auth] CLI OAuth failed:", msg);
65
+ return c.json({ error: msg }, 500);
66
+ }
67
+ });
68
+
69
+ // Manual token submission — adds to pool
70
+ app.post("/auth/token", async (c) => {
71
+ const body = await c.req.json<{ token: string }>();
72
+ const token = body.token?.trim();
73
+
74
+ if (!token) {
75
+ c.status(400);
76
+ return c.json({ error: "Token is required" });
77
+ }
78
+
79
+ const validation = validateManualToken(token);
80
+ if (!validation.valid) {
81
+ c.status(400);
82
+ return c.json({ error: validation.error });
83
+ }
84
+
85
+ pool.addAccount(token);
86
+ return c.json({ success: true });
87
+ });
88
+
89
+ // Logout — clears all accounts
90
+ app.post("/auth/logout", (c) => {
91
+ pool.clearToken();
92
+ return c.json({ success: true });
93
+ });
94
+
95
+ return app;
96
+ }
src/routes/chat.ts ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono";
2
+ import type { StatusCode } from "hono/utils/http-status";
3
+ import { stream } from "hono/streaming";
4
+ import { ChatCompletionRequestSchema } from "../types/openai.js";
5
+ import type { AccountPool } from "../auth/account-pool.js";
6
+ import { CodexApi, CodexApiError } from "../proxy/codex-api.js";
7
+ import { SessionManager } from "../session/manager.js";
8
+ import { translateToCodexRequest } from "../translation/openai-to-codex.js";
9
+ import {
10
+ streamCodexToOpenAI,
11
+ collectCodexResponse,
12
+ type UsageInfo,
13
+ } from "../translation/codex-to-openai.js";
14
+ import { getConfig } from "../config.js";
15
+ import type { CookieJar } from "../proxy/cookie-jar.js";
16
+
17
+ export function createChatRoutes(
18
+ accountPool: AccountPool,
19
+ sessionManager: SessionManager,
20
+ cookieJar?: CookieJar,
21
+ ): Hono {
22
+ const app = new Hono();
23
+
24
+ app.post("/v1/chat/completions", async (c) => {
25
+ // Validate auth — at least one active account
26
+ if (!accountPool.isAuthenticated()) {
27
+ c.status(401);
28
+ return c.json({
29
+ error: {
30
+ message: "Not authenticated. Please login first at /",
31
+ type: "invalid_request_error",
32
+ param: null,
33
+ code: "invalid_api_key",
34
+ },
35
+ });
36
+ }
37
+
38
+ // Optional proxy API key check
39
+ const config = getConfig();
40
+ if (config.server.proxy_api_key) {
41
+ const authHeader = c.req.header("Authorization");
42
+ const providedKey = authHeader?.replace("Bearer ", "");
43
+ if (
44
+ !providedKey ||
45
+ !accountPool.validateProxyApiKey(providedKey)
46
+ ) {
47
+ c.status(401);
48
+ return c.json({
49
+ error: {
50
+ message: "Invalid proxy API key",
51
+ type: "invalid_request_error",
52
+ param: null,
53
+ code: "invalid_api_key",
54
+ },
55
+ });
56
+ }
57
+ }
58
+
59
+ // Parse request
60
+ const body = await c.req.json();
61
+ const parsed = ChatCompletionRequestSchema.safeParse(body);
62
+ if (!parsed.success) {
63
+ c.status(400);
64
+ return c.json({
65
+ error: {
66
+ message: `Invalid request: ${parsed.error.message}`,
67
+ type: "invalid_request_error",
68
+ param: null,
69
+ code: "invalid_request",
70
+ },
71
+ });
72
+ }
73
+ const req = parsed.data;
74
+
75
+ // Acquire an account from the pool
76
+ const acquired = accountPool.acquire();
77
+ if (!acquired) {
78
+ c.status(503);
79
+ return c.json({
80
+ error: {
81
+ message: "No available accounts. All accounts are expired or rate-limited.",
82
+ type: "server_error",
83
+ param: null,
84
+ code: "no_available_accounts",
85
+ },
86
+ });
87
+ }
88
+
89
+ const { entryId, token, accountId } = acquired;
90
+ const codexApi = new CodexApi(token, accountId, cookieJar, entryId);
91
+ const codexRequest = translateToCodexRequest(req);
92
+ console.log(
93
+ `[Chat] Account ${entryId} | Codex request:`,
94
+ JSON.stringify(codexRequest).slice(0, 300),
95
+ );
96
+
97
+ let usageInfo: UsageInfo | undefined;
98
+
99
+ try {
100
+ const rawResponse = await codexApi.createResponse(codexRequest);
101
+
102
+ if (req.stream) {
103
+ c.header("Content-Type", "text/event-stream");
104
+ c.header("Cache-Control", "no-cache");
105
+ c.header("Connection", "keep-alive");
106
+
107
+ return stream(c, async (s) => {
108
+ try {
109
+ for await (const chunk of streamCodexToOpenAI(
110
+ codexApi,
111
+ rawResponse,
112
+ codexRequest.model,
113
+ (u) => { usageInfo = u; },
114
+ )) {
115
+ await s.write(chunk);
116
+ }
117
+ } finally {
118
+ accountPool.release(entryId, usageInfo);
119
+ }
120
+ });
121
+ } else {
122
+ const result = await collectCodexResponse(
123
+ codexApi,
124
+ rawResponse,
125
+ codexRequest.model,
126
+ );
127
+ accountPool.release(entryId, result.usage);
128
+ return c.json(result.response);
129
+ }
130
+ } catch (err) {
131
+ if (err instanceof CodexApiError) {
132
+ console.error(`[Chat] Account ${entryId} | Codex API error:`, err.message);
133
+ if (err.status === 429) {
134
+ // Parse Retry-After if present
135
+ accountPool.markRateLimited(entryId);
136
+ } else {
137
+ accountPool.release(entryId);
138
+ }
139
+ const code = (err.status >= 400 && err.status < 600 ? err.status : 502) as StatusCode;
140
+ c.status(code);
141
+ return c.json({
142
+ error: {
143
+ message: err.message,
144
+ type: "server_error",
145
+ param: null,
146
+ code: "codex_api_error",
147
+ },
148
+ });
149
+ }
150
+ accountPool.release(entryId);
151
+ throw err;
152
+ }
153
+ });
154
+
155
+ return app;
156
+ }
src/routes/models.ts ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono";
2
+ import { getConfig } from "../config.js";
3
+ import type { OpenAIModel, OpenAIModelList } from "../types/openai.js";
4
+
5
+ const app = new Hono();
6
+
7
+ /**
8
+ * Full model catalog from Codex CLI `model/list`.
9
+ * Each model has reasoning effort levels, description, and capabilities.
10
+ */
11
+ export interface CodexModelInfo {
12
+ id: string;
13
+ model: string;
14
+ displayName: string;
15
+ description: string;
16
+ isDefault: boolean;
17
+ supportedReasoningEfforts: { reasoningEffort: string; description: string }[];
18
+ defaultReasoningEffort: string;
19
+ inputModalities: string[];
20
+ supportsPersonality: boolean;
21
+ upgrade: string | null;
22
+ }
23
+
24
+ // Static model catalog — sourced from `codex app-server` model/list
25
+ const MODEL_CATALOG: CodexModelInfo[] = [
26
+ {
27
+ id: "gpt-5.3-codex",
28
+ model: "gpt-5.3-codex",
29
+ displayName: "gpt-5.3-codex",
30
+ description: "Latest frontier agentic coding model.",
31
+ isDefault: true,
32
+ supportedReasoningEfforts: [
33
+ { reasoningEffort: "low", description: "Fast responses with lighter reasoning" },
34
+ { reasoningEffort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
35
+ { reasoningEffort: "high", description: "Greater reasoning depth for complex problems" },
36
+ { reasoningEffort: "xhigh", description: "Extra high reasoning depth for complex problems" },
37
+ ],
38
+ defaultReasoningEffort: "medium",
39
+ inputModalities: ["text", "image"],
40
+ supportsPersonality: true,
41
+ upgrade: null,
42
+ },
43
+ {
44
+ id: "gpt-5.2-codex",
45
+ model: "gpt-5.2-codex",
46
+ displayName: "gpt-5.2-codex",
47
+ description: "Frontier agentic coding model.",
48
+ isDefault: false,
49
+ supportedReasoningEfforts: [
50
+ { reasoningEffort: "low", description: "Fast responses with lighter reasoning" },
51
+ { reasoningEffort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
52
+ { reasoningEffort: "high", description: "Greater reasoning depth for complex problems" },
53
+ { reasoningEffort: "xhigh", description: "Extra high reasoning depth for complex problems" },
54
+ ],
55
+ defaultReasoningEffort: "medium",
56
+ inputModalities: ["text", "image"],
57
+ supportsPersonality: true,
58
+ upgrade: "gpt-5.3-codex",
59
+ },
60
+ {
61
+ id: "gpt-5.1-codex-max",
62
+ model: "gpt-5.1-codex-max",
63
+ displayName: "gpt-5.1-codex-max",
64
+ description: "Codex-optimized flagship for deep and fast reasoning.",
65
+ isDefault: false,
66
+ supportedReasoningEfforts: [
67
+ { reasoningEffort: "low", description: "Fast responses with lighter reasoning" },
68
+ { reasoningEffort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
69
+ { reasoningEffort: "high", description: "Greater reasoning depth for complex problems" },
70
+ { reasoningEffort: "xhigh", description: "Extra high reasoning depth for complex problems" },
71
+ ],
72
+ defaultReasoningEffort: "medium",
73
+ inputModalities: ["text", "image"],
74
+ supportsPersonality: false,
75
+ upgrade: "gpt-5.3-codex",
76
+ },
77
+ {
78
+ id: "gpt-5.2",
79
+ model: "gpt-5.2",
80
+ displayName: "gpt-5.2",
81
+ description: "Latest frontier model with improvements across knowledge, reasoning and coding.",
82
+ isDefault: false,
83
+ supportedReasoningEfforts: [
84
+ { reasoningEffort: "low", description: "Balances speed with some reasoning" },
85
+ { reasoningEffort: "medium", description: "Solid balance of reasoning depth and latency" },
86
+ { reasoningEffort: "high", description: "Maximizes reasoning depth for complex problems" },
87
+ { reasoningEffort: "xhigh", description: "Extra high reasoning for complex problems" },
88
+ ],
89
+ defaultReasoningEffort: "medium",
90
+ inputModalities: ["text", "image"],
91
+ supportsPersonality: false,
92
+ upgrade: "gpt-5.3-codex",
93
+ },
94
+ {
95
+ id: "gpt-5.1-codex-mini",
96
+ model: "gpt-5.1-codex-mini",
97
+ displayName: "gpt-5.1-codex-mini",
98
+ description: "Optimized for codex. Cheaper, faster, but less capable.",
99
+ isDefault: false,
100
+ supportedReasoningEfforts: [
101
+ { reasoningEffort: "medium", description: "Dynamically adjusts reasoning based on the task" },
102
+ { reasoningEffort: "high", description: "Maximizes reasoning depth for complex problems" },
103
+ ],
104
+ defaultReasoningEffort: "medium",
105
+ inputModalities: ["text", "image"],
106
+ supportsPersonality: false,
107
+ upgrade: "gpt-5.3-codex",
108
+ },
109
+ ];
110
+
111
+ // Short aliases for convenience
112
+ const MODEL_ALIASES: Record<string, string> = {
113
+ codex: "gpt-5.3-codex",
114
+ "codex-max": "gpt-5.1-codex-max",
115
+ "codex-mini": "gpt-5.1-codex-mini",
116
+ };
117
+
118
+ /**
119
+ * Resolve a model name (may be an alias) to a canonical model ID.
120
+ */
121
+ export function resolveModelId(input: string): string {
122
+ const trimmed = input.trim();
123
+ if (MODEL_ALIASES[trimmed]) return MODEL_ALIASES[trimmed];
124
+ // Check if it's already a known model ID
125
+ if (MODEL_CATALOG.some((m) => m.id === trimmed)) return trimmed;
126
+ // Fall back to config default
127
+ return getConfig().model.default;
128
+ }
129
+
130
+ /**
131
+ * Get model info by ID.
132
+ */
133
+ export function getModelInfo(modelId: string): CodexModelInfo | undefined {
134
+ return MODEL_CATALOG.find((m) => m.id === modelId);
135
+ }
136
+
137
+ /**
138
+ * Get the full model catalog.
139
+ */
140
+ export function getModelCatalog(): CodexModelInfo[] {
141
+ return MODEL_CATALOG;
142
+ }
143
+
144
+ // --- Routes ---
145
+
146
+ function toOpenAIModel(info: CodexModelInfo): OpenAIModel {
147
+ return {
148
+ id: info.id,
149
+ object: "model",
150
+ created: 1700000000,
151
+ owned_by: "openai",
152
+ };
153
+ }
154
+
155
+ app.get("/v1/models", (c) => {
156
+ // Include catalog models + aliases as separate entries
157
+ const models: OpenAIModel[] = MODEL_CATALOG.map(toOpenAIModel);
158
+ for (const [alias, target] of Object.entries(MODEL_ALIASES)) {
159
+ models.push({
160
+ id: alias,
161
+ object: "model",
162
+ created: 1700000000,
163
+ owned_by: "openai",
164
+ });
165
+ }
166
+ const response: OpenAIModelList = { object: "list", data: models };
167
+ return c.json(response);
168
+ });
169
+
170
+ app.get("/v1/models/:modelId", (c) => {
171
+ const modelId = c.req.param("modelId");
172
+
173
+ // Try direct match
174
+ const info = MODEL_CATALOG.find((m) => m.id === modelId);
175
+ if (info) return c.json(toOpenAIModel(info));
176
+
177
+ // Try alias
178
+ const resolved = MODEL_ALIASES[modelId];
179
+ if (resolved) {
180
+ return c.json({
181
+ id: modelId,
182
+ object: "model",
183
+ created: 1700000000,
184
+ owned_by: "openai",
185
+ });
186
+ }
187
+
188
+ c.status(404);
189
+ return c.json({
190
+ error: {
191
+ message: `Model '${modelId}' not found`,
192
+ type: "invalid_request_error",
193
+ param: "model",
194
+ code: "model_not_found",
195
+ },
196
+ });
197
+ });
198
+
199
+ // Extended endpoint: model details with reasoning efforts
200
+ app.get("/v1/models/:modelId/info", (c) => {
201
+ const modelId = c.req.param("modelId");
202
+ const resolved = MODEL_ALIASES[modelId] ?? modelId;
203
+ const info = MODEL_CATALOG.find((m) => m.id === resolved);
204
+ if (!info) {
205
+ c.status(404);
206
+ return c.json({ error: `Model '${modelId}' not found` });
207
+ }
208
+ return c.json(info);
209
+ });
210
+
211
+ export default app;
src/routes/web.ts ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono";
2
+ import { readFileSync, existsSync } from "fs";
3
+ import { resolve } from "path";
4
+ import type { AccountPool } from "../auth/account-pool.js";
5
+ import { getConfig, getFingerprint } from "../config.js";
6
+
7
+ export function createWebRoutes(accountPool: AccountPool): Hono {
8
+ const app = new Hono();
9
+
10
+ const publicDir = resolve(process.cwd(), "public");
11
+
12
+ app.get("/", (c) => {
13
+ if (accountPool.isAuthenticated()) {
14
+ const html = readFileSync(resolve(publicDir, "dashboard.html"), "utf-8");
15
+ return c.html(html);
16
+ }
17
+ const html = readFileSync(resolve(publicDir, "login.html"), "utf-8");
18
+ return c.html(html);
19
+ });
20
+
21
+ app.get("/health", async (c) => {
22
+ const authenticated = accountPool.isAuthenticated();
23
+ const userInfo = accountPool.getUserInfo();
24
+ const poolSummary = accountPool.getPoolSummary();
25
+ return c.json({
26
+ status: "ok",
27
+ authenticated,
28
+ user: authenticated ? userInfo : null,
29
+ pool: poolSummary,
30
+ timestamp: new Date().toISOString(),
31
+ });
32
+ });
33
+
34
+ app.get("/debug/fingerprint", (c) => {
35
+ const config = getConfig();
36
+ const fp = getFingerprint();
37
+
38
+ const ua = fp.user_agent_template
39
+ .replace("{version}", config.client.app_version)
40
+ .replace("{platform}", config.client.platform)
41
+ .replace("{arch}", config.client.arch);
42
+
43
+ const promptsDir = resolve(process.cwd(), "config/prompts");
44
+ const prompts: Record<string, boolean> = {
45
+ "desktop-context.md": existsSync(resolve(promptsDir, "desktop-context.md")),
46
+ "title-generation.md": existsSync(resolve(promptsDir, "title-generation.md")),
47
+ "pr-generation.md": existsSync(resolve(promptsDir, "pr-generation.md")),
48
+ "automation-response.md": existsSync(resolve(promptsDir, "automation-response.md")),
49
+ };
50
+
51
+ // Check for update state
52
+ let updateState = null;
53
+ const statePath = resolve(process.cwd(), "data/update-state.json");
54
+ if (existsSync(statePath)) {
55
+ try {
56
+ updateState = JSON.parse(readFileSync(statePath, "utf-8"));
57
+ } catch {}
58
+ }
59
+
60
+ return c.json({
61
+ headers: {
62
+ "User-Agent": ua,
63
+ originator: config.client.originator,
64
+ },
65
+ client: {
66
+ app_version: config.client.app_version,
67
+ build_number: config.client.build_number,
68
+ platform: config.client.platform,
69
+ arch: config.client.arch,
70
+ },
71
+ api: {
72
+ base_url: config.api.base_url,
73
+ },
74
+ model: {
75
+ default: config.model.default,
76
+ },
77
+ codex_fields: {
78
+ developer_instructions: "loaded from config/prompts/desktop-context.md",
79
+ approval_policy: "never",
80
+ sandbox: "workspace-write",
81
+ personality: null,
82
+ ephemeral: null,
83
+ },
84
+ prompts_loaded: prompts,
85
+ update_state: updateState,
86
+ });
87
+ });
88
+
89
+ return app;
90
+ }
src/session/manager.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash } from "crypto";
2
+
3
+ interface Session {
4
+ taskId: string;
5
+ turnId: string;
6
+ messageHash: string;
7
+ createdAt: number;
8
+ }
9
+
10
+ export class SessionManager {
11
+ private sessions = new Map<string, Session>();
12
+ private ttlMs: number;
13
+
14
+ constructor(ttlMinutes: number = 60) {
15
+ this.ttlMs = ttlMinutes * 60 * 1000;
16
+ // Periodically clean expired sessions
17
+ setInterval(() => this.cleanup(), 5 * 60 * 1000);
18
+ }
19
+
20
+ /**
21
+ * Hash the message history to create a session key
22
+ */
23
+ hashMessages(
24
+ messages: Array<{ role: string; content: string }>,
25
+ ): string {
26
+ const data = messages.map((m) => `${m.role}:${m.content}`).join("|");
27
+ return createHash("sha256").update(data).digest("hex").slice(0, 16);
28
+ }
29
+
30
+ /**
31
+ * Find an existing session that matches the message history prefix
32
+ */
33
+ findSession(
34
+ messages: Array<{ role: string; content: string }>,
35
+ ): Session | null {
36
+ // Try matching all messages except the last (the new user message)
37
+ if (messages.length < 2) return null;
38
+ const prefix = messages.slice(0, -1);
39
+ const hash = this.hashMessages(prefix);
40
+
41
+ for (const session of this.sessions.values()) {
42
+ if (
43
+ session.messageHash === hash &&
44
+ Date.now() - session.createdAt < this.ttlMs
45
+ ) {
46
+ return session;
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Store a session after task creation
54
+ */
55
+ storeSession(
56
+ taskId: string,
57
+ turnId: string,
58
+ messages: Array<{ role: string; content: string }>,
59
+ ): void {
60
+ const hash = this.hashMessages(messages);
61
+ this.sessions.set(taskId, {
62
+ taskId,
63
+ turnId,
64
+ messageHash: hash,
65
+ createdAt: Date.now(),
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Update turn ID for an existing session
71
+ */
72
+ updateTurn(taskId: string, turnId: string): void {
73
+ const session = this.sessions.get(taskId);
74
+ if (session) {
75
+ session.turnId = turnId;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Get session by explicit task ID
81
+ */
82
+ getSession(taskId: string): Session | null {
83
+ return this.sessions.get(taskId) ?? null;
84
+ }
85
+
86
+ private cleanup(): void {
87
+ const now = Date.now();
88
+ for (const [key, session] of this.sessions) {
89
+ if (now - session.createdAt > this.ttlMs) {
90
+ this.sessions.delete(key);
91
+ }
92
+ }
93
+ }
94
+ }
src/translation/codex-to-openai.ts ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Translate Codex Responses API SSE stream → OpenAI Chat Completions format.
3
+ *
4
+ * Codex SSE events:
5
+ * response.created → (initial setup)
6
+ * response.output_text.delta → chat.completion.chunk (streaming text)
7
+ * response.output_text.done → (text complete)
8
+ * response.completed → [DONE]
9
+ *
10
+ * Non-streaming: collect all text, return chat.completion response.
11
+ */
12
+
13
+ import { randomUUID } from "crypto";
14
+ import type { CodexSSEEvent, CodexApi } from "../proxy/codex-api.js";
15
+ import type {
16
+ ChatCompletionResponse,
17
+ ChatCompletionChunk,
18
+ } from "../types/openai.js";
19
+
20
+ export interface UsageInfo {
21
+ input_tokens: number;
22
+ output_tokens: number;
23
+ }
24
+
25
+ /** Format an SSE chunk for streaming output */
26
+ function formatSSE(chunk: ChatCompletionChunk): string {
27
+ return `data: ${JSON.stringify(chunk)}\n\n`;
28
+ }
29
+
30
+ /**
31
+ * Stream Codex Responses API events as OpenAI chat.completion.chunk SSE.
32
+ * Yields string chunks ready to write to the HTTP response.
33
+ * Calls onUsage when the response.completed event arrives with usage data.
34
+ */
35
+ export async function* streamCodexToOpenAI(
36
+ codexApi: CodexApi,
37
+ rawResponse: Response,
38
+ model: string,
39
+ onUsage?: (usage: UsageInfo) => void,
40
+ ): AsyncGenerator<string> {
41
+ const chunkId = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
42
+ const created = Math.floor(Date.now() / 1000);
43
+ let responseId: string | null = null;
44
+
45
+ // Send initial role chunk
46
+ yield formatSSE({
47
+ id: chunkId,
48
+ object: "chat.completion.chunk",
49
+ created,
50
+ model,
51
+ choices: [
52
+ {
53
+ index: 0,
54
+ delta: { role: "assistant" },
55
+ finish_reason: null,
56
+ },
57
+ ],
58
+ });
59
+
60
+ for await (const evt of codexApi.parseStream(rawResponse)) {
61
+ const data = evt.data as Record<string, unknown>;
62
+
63
+ switch (evt.event) {
64
+ case "response.created":
65
+ case "response.in_progress": {
66
+ // Extract response ID for headers
67
+ const resp = data.response as Record<string, unknown> | undefined;
68
+ if (resp?.id) responseId = resp.id as string;
69
+ break;
70
+ }
71
+
72
+ case "response.output_text.delta": {
73
+ // Streaming text delta
74
+ const delta = (data.delta as string) ?? "";
75
+ if (delta) {
76
+ yield formatSSE({
77
+ id: chunkId,
78
+ object: "chat.completion.chunk",
79
+ created,
80
+ model,
81
+ choices: [
82
+ {
83
+ index: 0,
84
+ delta: { content: delta },
85
+ finish_reason: null,
86
+ },
87
+ ],
88
+ });
89
+ }
90
+ break;
91
+ }
92
+
93
+ case "response.completed": {
94
+ // Extract and report usage
95
+ if (onUsage) {
96
+ const resp = data.response as Record<string, unknown> | undefined;
97
+ if (resp?.usage) {
98
+ const u = resp.usage as Record<string, number>;
99
+ onUsage({
100
+ input_tokens: u.input_tokens ?? 0,
101
+ output_tokens: u.output_tokens ?? 0,
102
+ });
103
+ }
104
+ }
105
+ // Send final chunk with finish_reason
106
+ yield formatSSE({
107
+ id: chunkId,
108
+ object: "chat.completion.chunk",
109
+ created,
110
+ model,
111
+ choices: [
112
+ {
113
+ index: 0,
114
+ delta: {},
115
+ finish_reason: "stop",
116
+ },
117
+ ],
118
+ });
119
+ break;
120
+ }
121
+
122
+ // Ignore other events (reasoning, content_part, output_item, etc.)
123
+ }
124
+ }
125
+
126
+ // Send [DONE] marker
127
+ yield "data: [DONE]\n\n";
128
+ }
129
+
130
+ /**
131
+ * Consume a Codex Responses SSE stream and build a non-streaming
132
+ * ChatCompletionResponse. Returns both the response and extracted usage.
133
+ */
134
+ export async function collectCodexResponse(
135
+ codexApi: CodexApi,
136
+ rawResponse: Response,
137
+ model: string,
138
+ ): Promise<{ response: ChatCompletionResponse; usage: UsageInfo }> {
139
+ const id = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
140
+ const created = Math.floor(Date.now() / 1000);
141
+ let fullText = "";
142
+ let promptTokens = 0;
143
+ let completionTokens = 0;
144
+
145
+ for await (const evt of codexApi.parseStream(rawResponse)) {
146
+ const data = evt.data as Record<string, unknown>;
147
+
148
+ switch (evt.event) {
149
+ case "response.output_text.delta": {
150
+ const delta = (data.delta as string) ?? "";
151
+ fullText += delta;
152
+ break;
153
+ }
154
+
155
+ case "response.completed": {
156
+ // Try to extract usage from the completed response
157
+ const resp = data.response as Record<string, unknown> | undefined;
158
+ if (resp?.usage) {
159
+ const usage = resp.usage as Record<string, number>;
160
+ promptTokens = usage.input_tokens ?? 0;
161
+ completionTokens = usage.output_tokens ?? 0;
162
+ }
163
+ break;
164
+ }
165
+ }
166
+ }
167
+
168
+ return {
169
+ response: {
170
+ id,
171
+ object: "chat.completion",
172
+ created,
173
+ model,
174
+ choices: [
175
+ {
176
+ index: 0,
177
+ message: {
178
+ role: "assistant",
179
+ content: fullText,
180
+ },
181
+ finish_reason: "stop",
182
+ },
183
+ ],
184
+ usage: {
185
+ prompt_tokens: promptTokens,
186
+ completion_tokens: completionTokens,
187
+ total_tokens: promptTokens + completionTokens,
188
+ },
189
+ },
190
+ usage: {
191
+ input_tokens: promptTokens,
192
+ output_tokens: completionTokens,
193
+ },
194
+ };
195
+ }
src/translation/openai-to-codex.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Translate OpenAI Chat Completions request → Codex Responses API request.
3
+ */
4
+
5
+ import type { ChatCompletionRequest } from "../types/openai.js";
6
+ import type {
7
+ CodexResponsesRequest,
8
+ CodexInputItem,
9
+ } from "../proxy/codex-api.js";
10
+ import { resolveModelId, getModelInfo } from "../routes/models.js";
11
+ import { getConfig } from "../config.js";
12
+
13
+ /**
14
+ * Convert a ChatCompletionRequest to a CodexResponsesRequest.
15
+ *
16
+ * Mapping:
17
+ * - system messages → instructions field
18
+ * - user/assistant messages → input array
19
+ * - model → resolved model ID
20
+ * - reasoning_effort → reasoning.effort
21
+ */
22
+ export function translateToCodexRequest(
23
+ req: ChatCompletionRequest,
24
+ ): CodexResponsesRequest {
25
+ // Collect system messages as instructions
26
+ const systemMessages = req.messages.filter((m) => m.role === "system");
27
+ const instructions =
28
+ systemMessages.map((m) => m.content).join("\n\n") ||
29
+ "You are a helpful assistant.";
30
+
31
+ // Build input items from non-system messages
32
+ const input: CodexInputItem[] = [];
33
+ for (const msg of req.messages) {
34
+ if (msg.role === "system") continue;
35
+ input.push({
36
+ role: msg.role as "user" | "assistant",
37
+ content: msg.content,
38
+ });
39
+ }
40
+
41
+ // Ensure at least one input message
42
+ if (input.length === 0) {
43
+ input.push({ role: "user", content: "" });
44
+ }
45
+
46
+ // Resolve model
47
+ const modelId = resolveModelId(req.model);
48
+ const modelInfo = getModelInfo(modelId);
49
+ const config = getConfig();
50
+
51
+ // Build request
52
+ const request: CodexResponsesRequest = {
53
+ model: modelId,
54
+ instructions,
55
+ input,
56
+ stream: true,
57
+ store: false,
58
+ };
59
+
60
+ // Add reasoning effort if applicable
61
+ const effort =
62
+ req.reasoning_effort ??
63
+ modelInfo?.defaultReasoningEffort ??
64
+ config.model.default_reasoning_effort;
65
+ if (effort) {
66
+ request.reasoning = { effort };
67
+ }
68
+
69
+ return request;
70
+ }
src/types/openai.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * OpenAI API types for /v1/chat/completions compatibility
3
+ */
4
+ import { z } from "zod";
5
+
6
+ // --- Request ---
7
+
8
+ export const ChatMessageSchema = z.object({
9
+ role: z.enum(["system", "user", "assistant"]),
10
+ content: z.string(),
11
+ name: z.string().optional(),
12
+ });
13
+
14
+ export const ChatCompletionRequestSchema = z.object({
15
+ model: z.string(),
16
+ messages: z.array(ChatMessageSchema).min(1),
17
+ stream: z.boolean().optional().default(false),
18
+ n: z.number().optional().default(1),
19
+ temperature: z.number().optional(),
20
+ top_p: z.number().optional(),
21
+ max_tokens: z.number().optional(),
22
+ presence_penalty: z.number().optional(),
23
+ frequency_penalty: z.number().optional(),
24
+ stop: z.union([z.string(), z.array(z.string())]).optional(),
25
+ user: z.string().optional(),
26
+ // Codex-specific extensions
27
+ reasoning_effort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
28
+ });
29
+
30
+ export type ChatMessage = z.infer<typeof ChatMessageSchema>;
31
+ export type ChatCompletionRequest = z.infer<typeof ChatCompletionRequestSchema>;
32
+
33
+ // --- Response (non-streaming) ---
34
+
35
+ export interface ChatCompletionChoice {
36
+ index: number;
37
+ message: {
38
+ role: "assistant";
39
+ content: string;
40
+ };
41
+ finish_reason: "stop" | "length" | null;
42
+ }
43
+
44
+ export interface ChatCompletionUsage {
45
+ prompt_tokens: number;
46
+ completion_tokens: number;
47
+ total_tokens: number;
48
+ }
49
+
50
+ export interface ChatCompletionResponse {
51
+ id: string;
52
+ object: "chat.completion";
53
+ created: number;
54
+ model: string;
55
+ choices: ChatCompletionChoice[];
56
+ usage: ChatCompletionUsage;
57
+ }
58
+
59
+ // --- Response (streaming) ---
60
+
61
+ export interface ChatCompletionChunkDelta {
62
+ role?: "assistant";
63
+ content?: string;
64
+ }
65
+
66
+ export interface ChatCompletionChunkChoice {
67
+ index: number;
68
+ delta: ChatCompletionChunkDelta;
69
+ finish_reason: "stop" | "length" | null;
70
+ }
71
+
72
+ export interface ChatCompletionChunk {
73
+ id: string;
74
+ object: "chat.completion.chunk";
75
+ created: number;
76
+ model: string;
77
+ choices: ChatCompletionChunkChoice[];
78
+ }
79
+
80
+ // --- Error ---
81
+
82
+ export interface OpenAIErrorBody {
83
+ error: {
84
+ message: string;
85
+ type: string;
86
+ param: string | null;
87
+ code: string | null;
88
+ };
89
+ }
90
+
91
+ // --- Models ---
92
+
93
+ export interface OpenAIModel {
94
+ id: string;
95
+ object: "model";
96
+ created: number;
97
+ owned_by: string;
98
+ }
99
+
100
+ export interface OpenAIModelList {
101
+ object: "list";
102
+ data: OpenAIModel[];
103
+ }
tsconfig.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "noImplicitAny": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true
16
+ },
17
+ "include": ["src/**/*.ts"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }