Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
5d0a52f
0
Parent(s):
Initial commit: Codex Proxy with quota API and Cloudflare bypass
Browse filesOpenAI-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>
- .claude/settings.local.json +41 -0
- .env.example +4 -0
- .gitignore +6 -0
- config/default.yaml +38 -0
- config/extraction-patterns.yaml +74 -0
- config/fingerprint.yaml +4 -0
- config/prompts/automation-response.md +51 -0
- config/prompts/desktop-context.md +69 -0
- config/prompts/pr-generation.md +14 -0
- config/prompts/title-generation.md +34 -0
- docs/api.md +342 -0
- docs/implementation-notes.md +258 -0
- package-lock.json +662 -0
- package.json +31 -0
- public/dashboard.html +332 -0
- public/login.html +306 -0
- scripts/apply-update.ts +352 -0
- scripts/check-update.ts +157 -0
- scripts/extract-fingerprint.ts +431 -0
- scripts/test-create-env-gh.ts +74 -0
- scripts/test-create-env-real.ts +128 -0
- scripts/test-env-full.ts +113 -0
- scripts/test-repo-format.ts +27 -0
- scripts/test-repo2.ts +23 -0
- scripts/test-responses-api.ts +99 -0
- scripts/test-snapshot.ts +117 -0
- src/auth/account-pool.ts +473 -0
- src/auth/chatgpt-oauth.ts +471 -0
- src/auth/jwt-utils.ts +64 -0
- src/auth/manager.ts +165 -0
- src/auth/refresh-scheduler.ts +101 -0
- src/auth/types.ts +72 -0
- src/config.ts +108 -0
- src/fingerprint/manager.ts +41 -0
- src/index.ts +96 -0
- src/middleware/error-handler.ts +66 -0
- src/middleware/logger.ts +15 -0
- src/proxy/client.ts +246 -0
- src/proxy/codex-api.ts +281 -0
- src/proxy/cookie-jar.ts +161 -0
- src/routes/accounts.ts +230 -0
- src/routes/auth.ts +96 -0
- src/routes/chat.ts +156 -0
- src/routes/models.ts +211 -0
- src/routes/web.ts +90 -0
- src/session/manager.ts +94 -0
- src/translation/codex-to-openai.ts +195 -0
- src/translation/openai-to-codex.ts +70 -0
- src/types/openai.ts +103 -0
- 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: 
|
| 6 |
+
- When sending or referencing a local image, always use an absolute filesystem path in the Markdown image tag (e.g., ); 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 → DevTools (F12) →
|
| 175 |
+
Application → Cookies → 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 |
+
}
|