From f2d2faa73283f20d2ca709ab18ca602cb9f60366 Mon Sep 17 00:00:00 2001 From: Geng Ruofan Date: Fri, 5 Jun 2026 06:03:29 +0800 Subject: [PATCH] feat: add QoderWork CN bridge tweak --- docs/tweaks/README.md | 1 + docs/tweaks/qoderworkcn-bridge.md | 110 ++ tweaks/qoderworkcn-bridge/.gitignore | 5 + .../QODERWORK_BRIDGE_INCIDENT.md | 65 + tweaks/qoderworkcn-bridge/README.md | 162 +++ .../bridge/models_catalog.json | 46 + tweaks/qoderworkcn-bridge/bridge/package.json | 15 + .../bridge/scripts/smoke.mjs | 60 + .../qoderworkcn-bridge/bridge/src/server.mjs | 1053 +++++++++++++++++ .../bridge/src/usage-ledger.mjs | 284 +++++ .../bridge/test/config.test.mjs | 36 + .../bridge/test/tool-calls.test.mjs | 320 +++++ .../bridge/test/usage-ledger.test.mjs | 87 ++ tweaks/qoderworkcn-bridge/index.js | 787 ++++++++++++ tweaks/qoderworkcn-bridge/manifest.json | 23 + tweaks/qoderworkcn-bridge/package.json | 11 + .../test/resolve-codex-home.test.cjs | 68 ++ 17 files changed, 3133 insertions(+) create mode 100644 docs/tweaks/qoderworkcn-bridge.md create mode 100644 tweaks/qoderworkcn-bridge/.gitignore create mode 100644 tweaks/qoderworkcn-bridge/QODERWORK_BRIDGE_INCIDENT.md create mode 100644 tweaks/qoderworkcn-bridge/README.md create mode 100644 tweaks/qoderworkcn-bridge/bridge/models_catalog.json create mode 100644 tweaks/qoderworkcn-bridge/bridge/package.json create mode 100644 tweaks/qoderworkcn-bridge/bridge/scripts/smoke.mjs create mode 100644 tweaks/qoderworkcn-bridge/bridge/src/server.mjs create mode 100644 tweaks/qoderworkcn-bridge/bridge/src/usage-ledger.mjs create mode 100644 tweaks/qoderworkcn-bridge/bridge/test/config.test.mjs create mode 100644 tweaks/qoderworkcn-bridge/bridge/test/tool-calls.test.mjs create mode 100644 tweaks/qoderworkcn-bridge/bridge/test/usage-ledger.test.mjs create mode 100644 tweaks/qoderworkcn-bridge/index.js create mode 100644 tweaks/qoderworkcn-bridge/manifest.json create mode 100644 tweaks/qoderworkcn-bridge/package.json create mode 100644 tweaks/qoderworkcn-bridge/test/resolve-codex-home.test.cjs diff --git a/docs/tweaks/README.md b/docs/tweaks/README.md index 9aaf9c9..b2cf992 100644 --- a/docs/tweaks/README.md +++ b/docs/tweaks/README.md @@ -11,6 +11,7 @@ Segmented docs for building Codex++ tweaks. - [MCP servers](./mcp.md) - [TypeScript and bundling](./typescript-and-bundling.md) - [Distribution and debugging](./distribution-debugging.md) +- [QoderWork CN Bridge tweak](./qoderworkcn-bridge.md) - [Owl runtime surface](../OWL-RUNTIME.md) - [Owl bridge roadmap](../OWL-BRIDGE-ROADMAP.md) diff --git a/docs/tweaks/qoderworkcn-bridge.md b/docs/tweaks/qoderworkcn-bridge.md new file mode 100644 index 0000000..1bd8ad2 --- /dev/null +++ b/docs/tweaks/qoderworkcn-bridge.md @@ -0,0 +1,110 @@ +# QoderWork CN Bridge Tweak + +`tweaks/qoderworkcn-bridge` is a bundled local tweak for using QoderWork CN +through Codex's OpenAI Responses provider settings. + +It is intentionally implemented as a Codex++ tweak instead of a core runtime +patch: + +- the bridge is a local HTTP Responses server, not an MCP server; +- main-process tweak code can start and stop the Node bridge in-process; +- renderer tweak code only shows health status in Codex++ Settings; +- Codex config changes are isolated in managed TOML blocks; +- completed Responses calls are also written to a CodexBar-style usage ledger. + +## Local Development + +```powershell +node --import tsx packages/installer/src/cli.ts validate-tweak tweaks/qoderworkcn-bridge +node --import tsx packages/installer/src/cli.ts dev tweaks/qoderworkcn-bridge --replace --no-watch +``` + +The live tweak link is created under: + +```text +%APPDATA%\codex-plusplus\tweaks\com.qoderworkcn.responses-bridge +``` + +## Fresh Machine Setup + +This bridge is included as a Codex++ tweak. A new machine still needs the local +QoderWork CN dependency: + +1. Install QoderWork CN and sign in. +2. Confirm `qodercli.exe` exists, or set `QODER_CLI` to its path before + launching Codex++. +3. Install or repair Codex++ so the Codex++ runtime loads inside Codex. +4. Enable the tweak, for example from a source checkout: + + ```powershell + node --import tsx packages/installer/src/cli.ts dev tweaks/qoderworkcn-bridge --replace --no-watch + ``` + +5. Launch Codex through Codex++ and open Settings -> Codex++ -> QoderWork CN + Bridge. The page should show `Bridge: Ready`, `Qoder CLI: Found`, and a + Responses endpoint such as `http://127.0.0.1:38441/v1`. +6. Use the `qoderworkcn-codex` model id from Codex. + +Known blockers are missing QoderWork CN login, a non-default `qodercli.exe` +path, unavailable `qmodel` lane, bridge port conflicts, or launching Codex +without the Codex++ runtime. + +## Codex Config + +At startup, the main-process half updates: + +```text +%APPDATA%\codex-plusplus\tweak-data\com.qoderworkcn.responses-bridge\codex-home\config.toml +%APPDATA%\codex-plusplus\tweak-data\com.qoderworkcn.responses-bridge\codex-home\qoderworkcn.config.toml +``` + +This is intentionally isolated from `%USERPROFILE%\.codex`. To target the +currently launched Codex profile instead, launch Codex++ with +`QODERWORKCN_CODEX_HOME` or `CODEX_HOME` set explicitly. + +For Codex Beta isolation on Windows, use a launcher that sets +`CODEX_PLUSPLUS_HOME` to an isolated Codex++ home, points `CODEX_HOME` and +`QODERWORKCN_CODEX_HOME` at the tweak-managed profile in that home, and sets +`QODERWORKCN_BRIDGE_PORT=38442`. + +The default model block sets: + +```toml +model = "qoderworkcn-codex" +model_provider = "qoderworkcn-bridge" +model_reasoning_effort = "low" +``` + +The provider block sets `wire_api = "responses"` and points to: + +```text +http://127.0.0.1:38441/v1 +``` + +The Beta isolated launcher overrides this to `http://127.0.0.1:38442/v1` so it +does not collide with the default Codex++ Qoder bridge. + +The model catalog path is the absolute path to the tweak's +`bridge/models_catalog.json`. + +## CodexBar-Style Usage + +Completed QoderWork bridge requests are appended to: + +```text +\codexbar-qoderworkcn-usage.jsonl +``` + +The bridge exposes: + +```text +GET http://127.0.0.1:38441/usage +GET http://127.0.0.1:38442/usage # isolated beta launcher +``` + +The payload mirrors CodexBar's local cost JSON shape: `provider`, `source`, +`updatedAt`, `sessionTokens`, `last30DaysTokens`, `daily[]`, `totals`, and +`modelBreakdowns[]`. Costs are `null` until per-million-token estimates are +provided through `QODERWORKCN_INPUT_USD_PER_1M`, +`QODERWORKCN_OUTPUT_USD_PER_1M`, and optionally +`QODERWORKCN_CACHED_INPUT_USD_PER_1M`. diff --git a/tweaks/qoderworkcn-bridge/.gitignore b/tweaks/qoderworkcn-bridge/.gitignore new file mode 100644 index 0000000..29025ee --- /dev/null +++ b/tweaks/qoderworkcn-bridge/.gitignore @@ -0,0 +1,5 @@ +.codexpp-dev-reload +node_modules/ +tmp/ +*.log +codexbar-qoderworkcn-usage.jsonl diff --git a/tweaks/qoderworkcn-bridge/QODERWORK_BRIDGE_INCIDENT.md b/tweaks/qoderworkcn-bridge/QODERWORK_BRIDGE_INCIDENT.md new file mode 100644 index 0000000..3f8fa09 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/QODERWORK_BRIDGE_INCIDENT.md @@ -0,0 +1,65 @@ +# QoderWork Bridge Repair Notes + +## Scope Boundary + +This repair must stay inside Codex++ and the QoderWork bridge. Do not patch the +Codex Beta application bundle. + +Allowed areas: + +- the Codex++ repository's `tweaks/qoderworkcn-bridge` directory +- the user's chosen isolated Codex++ home +- optional wrapper launchers that set isolated environment variables +- the isolated `CODEX_HOME` managed by the tweak + +Do not edit: + +- the installed Codex or Codex Beta application bundle +- patched app copies produced by Codex++ installers + +## Failure + +Codex Beta routed requests to QoderWork successfully, but subagents did not +start. The prior bridge appended a text warning when `body.tools` was present: + +```text +Tool calling is unavailable through this local text bridge. Answer directly in text. +``` + +That converted Codex app tools into plain prompt text. QoderWork could execute +some local CLI work, but it could not ask Codex Beta to run native tools such as +`create_thread`. + +## Fix Direction + +The bridge now: + +1. Injects available Responses tools into the QoderWork prompt. +2. Asks QoderWork to emit a strict `` JSON envelope. +3. Converts recognized calls into Responses `function_call` output items. +4. Includes later `function_call_output` results in the next QoderWork prompt. +5. Directly plans explicit `Subagent A/B/...` startup requests into + `create_thread` calls when QoderWork refuses host-tool envelopes. +6. Accepts both namespaced tools and flat names such as + `codex_app.create_thread` from Codex Beta. + +Codex Beta remains responsible for actually executing native tools. + +## Current Limits + +- QoderWork must comply with the JSON envelope for tool calls. +- The bridge only converts tools that Codex Beta already sent in `body.tools`. +- Unknown tool names are ignored to avoid invalid Responses output. +- Tool execution remains owned by Codex Beta; this tweak does not call Codex + internals directly. +- The host-side subagent planner only handles explicit `Subagent X: ...` lines. + It is intentionally narrow to avoid surprising tool execution. + +## Verification + +Run from the Codex++ repository: + +```powershell +npm test --prefix tweaks/qoderworkcn-bridge/bridge +node --import tsx packages/installer/src/cli.ts validate-tweak tweaks/qoderworkcn-bridge +``` diff --git a/tweaks/qoderworkcn-bridge/README.md b/tweaks/qoderworkcn-bridge/README.md new file mode 100644 index 0000000..bf39582 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/README.md @@ -0,0 +1,162 @@ +# QoderWork CN Bridge + +Codex++ tweak that starts the local QoderWork CN Responses bridge and points +Codex at it. + +## What It Installs + +- Local endpoint: `http://127.0.0.1:38441/v1` by default +- Codex model id: `qoderworkcn-codex` +- QoderWork CN model lane: `qmodel` +- Display label: `QoderWork CN Qwen3.7-Max` +- CodexBar-style usage endpoint: `/usage` +- Structured tool-call bridge for Codex app tools such as background threads + +By default the tweak writes managed Codex config into its own isolated profile: + +```text +%APPDATA%\codex-plusplus\tweak-data\com.qoderworkcn.responses-bridge\codex-home +``` + +Set `QODERWORKCN_CODEX_HOME` or `CODEX_HOME` before launching Codex++ to target a +different profile. The first write backs up the existing config in that target +profile to a `config.toml.backup-codexpp-qoderworkcn-*` file. + +## Fresh Machine Setup + +Installing Codex++ alone only installs the tweak runtime. To use QoderWork CN as +a Codex model provider on a new machine: + +1. Install and launch QoderWork CN, then sign in with the account that can use + the `qmodel` lane. +2. Confirm the QoderWork CLI exists. On Windows the default path is: + + ```text + %LOCALAPPDATA%\Programs\QoderWork CN\resources\bin\qodercli.exe + ``` + + Set `QODER_CLI` before launching Codex++ if your installation uses a + different path. +3. Install/repair Codex++ so Codex loads the Codex++ runtime. +4. Link or install this tweak, for example from a source checkout: + + ```powershell + node --import tsx packages/installer/src/cli.ts dev tweaks/qoderworkcn-bridge --replace --no-watch + ``` + +5. Start Codex through the Codex++ patched app or shortcut. +6. Open Settings -> Codex++ -> QoderWork CN Bridge and verify `Bridge: Ready`, + `Qoder CLI: Found`, and `Tool bridge: structured-json`. +7. Select or verify the `qoderworkcn-codex` model in Codex. The bridge endpoint + defaults to `http://127.0.0.1:38441/v1`. + +Common setup blockers: + +- QoderWork CN is not installed, not logged in, or installed at a non-default + path. +- `qmodel` is not available for the signed-in QoderWork CN account. +- another process already owns the configured bridge port. +- Codex was launched without the Codex++ runtime, so the tweak never starts. +- an isolated profile launcher sets a different port such as `38442`; check the + endpoint shown in the settings page. + +For an isolated Codex Beta profile, launch Codex with a wrapper that sets +`CODEX_PLUSPLUS_HOME`, `CODEX_HOME`, and `QODERWORKCN_CODEX_HOME` only for that +child process. A separate port such as `QODERWORKCN_BRIDGE_PORT=38442` avoids +collisions with the default Codex++ Qoder bridge. Normal Codex launches keep +their existing profile and default port. + +Long-running coding tasks can exceed QoderWork CLI's short interactive response +window. The embedded bridge defaults `QODER_BRIDGE_TIMEOUT_MS` to 2 hours and +keeps streamed Responses requests alive with SSE heartbeats while QoderWork is +still working. Set `QODER_BRIDGE_TIMEOUT_MS=0` to disable the bridge watchdog +entirely. + +## CodexBar Usage + +The bridge records completed Responses calls to a local JSONL ledger: + +```text +\codexbar-qoderworkcn-usage.jsonl +``` + +`GET /usage` returns a CodexBar-style cost payload with today, rolling 30-day, +daily, total, and model-breakdown token counts. Token counts come from the +bridge's Responses `usage` object. Costs stay empty by default because QoderWork +CN is not billed through a public per-token OpenAI price table. + +To enable estimated USD cost, launch Codex++ with per-million-token prices: + +```powershell +$env:QODERWORKCN_INPUT_USD_PER_1M="2" +$env:QODERWORKCN_OUTPUT_USD_PER_1M="8" +$env:QODERWORKCN_CACHED_INPUT_USD_PER_1M="0.5" +``` + +## Development + +From the Codex++ repository: + +```powershell +node --import tsx packages/installer/src/cli.ts validate-tweak tweaks/qoderworkcn-bridge +node --import tsx packages/installer/src/cli.ts dev tweaks/qoderworkcn-bridge --replace --no-watch +``` + +Then start Codex through the Codex++ patched app or shortcut and open the +Codex++ settings page for `QoderWork CN Bridge`. + +## Environment + +The embedded bridge keeps the same environment variables as the standalone +`qoderworkcn-responses-bridge` project: + +| Variable | Default | +| --- | --- | +| `QODER_CLI` | `%LOCALAPPDATA%\Programs\QoderWork CN\resources\bin\qodercli.exe` | +| `QODER_STORAGE_DIR` | `%USERPROFILE%\.qoderworkcn` | +| `QODER_RESOURCE_DIR` | same as `QODER_STORAGE_DIR` | +| `QODER_SITE` | `cn` | +| `QODER_MODEL` | `qmodel` | +| `QODER_BRIDGE_HOST` | `127.0.0.1` | +| `QODERWORKCN_BRIDGE_PORT` | `38441` | +| `QODER_BRIDGE_PORT` | `38441` legacy alias | +| `QODER_BRIDGE_TIMEOUT_MS` | `7200000` | +| `QODERWORKCN_CODEX_HOME` | Codex++ tweak-data isolated profile | +| `QODERWORKCN_USAGE_LEDGER` | `\codexbar-qoderworkcn-usage.jsonl` | +| `QODERWORKCN_INPUT_USD_PER_1M` | unset | +| `QODERWORKCN_OUTPUT_USD_PER_1M` | unset | +| `QODERWORKCN_CACHED_INPUT_USD_PER_1M` | unset | + +`qmodel` is the QoderWork CN lane whose local dynamic text labels as +`Qwen3.7-Max`. + +## Tool Calls / Subagents + +Codex Beta sends Responses `tools` to the provider when app tools are available. +The bridge does not modify Codex Beta app files. Instead, it injects the exact +tool names and JSON schemas into QoderWork's prompt and asks QoderWork to return +tool calls in this envelope: + +```text + +[{"name":"create_thread","arguments":{"prompt":"..."}}] + +``` + +The bridge converts that envelope into standard Responses `function_call` output +items. Codex Beta then executes its own native tools, such as `create_thread`, +and sends `function_call_output` items back on the next Responses request. The +bridge includes those tool results in the next QoderWork prompt so QoderWork can +continue or summarize. + +Because QoderWork may reject host-tool envelopes as non-native tools, the bridge +also contains a narrow host-side planner for explicit subagent startup prompts. +When the user prompt clearly contains lines like `Subagent A: ...` and Codex +sent a `create_thread` tool, the bridge directly returns `create_thread` +function calls without invoking QoderWork first. This only starts the Codex +subagents; later tool outputs still flow back through the bridge for summary. +The planner accepts both namespace-shaped tools and flat tool names such as +`codex_app.create_thread`, matching the formats seen from Codex Beta. + +Ordinary QoderWork text still returns as a normal assistant message. Unknown +tool names are ignored instead of returning invalid tool calls. diff --git a/tweaks/qoderworkcn-bridge/bridge/models_catalog.json b/tweaks/qoderworkcn-bridge/bridge/models_catalog.json new file mode 100644 index 0000000..d4db2e7 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/bridge/models_catalog.json @@ -0,0 +1,46 @@ +{ + "models": [ + { + "slug": "qoderworkcn-codex", + "display_name": "QoderWork CN Qwen3.7-Max", + "description": "Local QoderWork CN bridge model using the qmodel lane, labeled Qwen3.7-Max by QoderWork CN.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast local bridge mode" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 99, + "additional_speed_tiers": [], + "service_tiers": [], + "availability_nux": null, + "upgrade": null, + "base_instructions": "You are Codex, a coding agent. Answer directly in text unless a Codex tool is needed. When tools are available, follow the local QoderWork bridge tool-call envelope exactly.", + "context_window": 128000, + "max_context_window": 128000, + "auto_compact_token_limit": null, + "effective_context_window_percent": 95, + "input_modalities": [ + "text" + ], + "supports_reasoning_summaries": false, + "default_reasoning_summary": "none", + "support_verbosity": false, + "default_verbosity": null, + "apply_patch_tool_type": null, + "web_search_tool_type": "text", + "truncation_policy": { + "mode": "bytes", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "supports_image_detail_original": false, + "experimental_supported_tools": [], + "supports_search_tool": false + } + ] +} diff --git a/tweaks/qoderworkcn-bridge/bridge/package.json b/tweaks/qoderworkcn-bridge/bridge/package.json new file mode 100644 index 0000000..61f9892 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/bridge/package.json @@ -0,0 +1,15 @@ +{ + "name": "qoderworkcn-responses-bridge", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Local OpenAI Responses-compatible bridge for QoderWork CN CLI.", + "scripts": { + "start": "node src/server.mjs", + "smoke": "node scripts/smoke.mjs", + "test": "node --test" + }, + "engines": { + "node": ">=20" + } +} diff --git a/tweaks/qoderworkcn-bridge/bridge/scripts/smoke.mjs b/tweaks/qoderworkcn-bridge/bridge/scripts/smoke.mjs new file mode 100644 index 0000000..5eaf8ba --- /dev/null +++ b/tweaks/qoderworkcn-bridge/bridge/scripts/smoke.mjs @@ -0,0 +1,60 @@ +import { once } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createServer, loadConfig } from '../src/server.mjs'; + +const tmp = mkdtempSync(path.join(os.tmpdir(), 'qoder-bridge-smoke-')); +const config = loadConfig({ + ...process.env, + QODER_BRIDGE_PORT: process.env.QODER_BRIDGE_PORT || '38441', + QODERWORKCN_USAGE_LEDGER: process.env.QODERWORKCN_USAGE_LEDGER || path.join(tmp, 'usage.jsonl'), +}); + +const server = createServer({ config }); +server.listen(config.port, config.host); +await once(server, 'listening'); + +try { + const baseUrl = `http://${config.host}:${config.port}`; + const health = await fetch(`${baseUrl}/health`).then(response => response.json()); + if (!health.ok) { + throw new Error('Health check failed.'); + } + + const response = await fetch(`${baseUrl}/v1/responses`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}), + }, + body: JSON.stringify({ + model: config.bridgeModel, + input: '只回复 OK', + stream: false, + }), + }); + + const body = await response.json(); + if (!response.ok) { + throw new Error(JSON.stringify(body)); + } + + const text = body.output?.[0]?.content?.[0]?.text || ''; + const usage = await fetch(`${baseUrl}/usage`).then(response => response.json()); + console.log(JSON.stringify({ + ok: true, + status: body.status, + responseId: body.id, + text, + usage: body.usage, + usageLedger: { + tokens: usage.last30DaysTokens, + requests: usage.totals?.requestCount, + ledgerExists: usage.ledgerExists, + }, + }, null, 2)); +} finally { + server.close(); + rmSync(tmp, { recursive: true, force: true }); +} diff --git a/tweaks/qoderworkcn-bridge/bridge/src/server.mjs b/tweaks/qoderworkcn-bridge/bridge/src/server.mjs new file mode 100644 index 0000000..b6b787d --- /dev/null +++ b/tweaks/qoderworkcn-bridge/bridge/src/server.mjs @@ -0,0 +1,1053 @@ +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { recordUsage, usageLedgerPath, usagePricing, usageSummary } from './usage-ledger.mjs'; + +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_PORT = 38441; +const DEFAULT_TIMEOUT_MS = 7_200_000; +const STREAM_HEARTBEAT_MS = 15_000; +const DEFAULT_MODEL = 'qoderworkcn-codex'; +const QODER_MODEL = 'qmodel'; + +export function loadConfig(env = process.env) { + const home = os.homedir(); + const storageDir = env.QODER_STORAGE_DIR || path.join(home, '.qoderworkcn'); + const qoderCli = env.QODER_CLI || path.join( + home, + 'AppData', + 'Local', + 'Programs', + 'QoderWork CN', + 'resources', + 'bin', + 'qodercli.exe', + ); + + return { + host: env.QODER_BRIDGE_HOST || DEFAULT_HOST, + port: Number.parseInt(env.QODERWORKCN_BRIDGE_PORT || env.QODER_BRIDGE_PORT || String(DEFAULT_PORT), 10), + apiKey: env.QODERWORKCN_BRIDGE_KEY || '', + qoderCli, + storageDir, + resourceDir: env.QODER_RESOURCE_DIR || storageDir, + qoderSite: env.QODER_SITE || 'cn', + qoderModel: env.QODER_MODEL || QODER_MODEL, + bridgeModel: env.QODER_BRIDGE_MODEL || DEFAULT_MODEL, + codexHome: env.QODERWORKCN_CODEX_HOME || '', + usageLedger: env.QODERWORKCN_USAGE_LEDGER || '', + inputUsdPer1M: env.QODERWORKCN_INPUT_USD_PER_1M || '', + outputUsdPer1M: env.QODERWORKCN_OUTPUT_USD_PER_1M || '', + cachedInputUsdPer1M: env.QODERWORKCN_CACHED_INPUT_USD_PER_1M || '', + workspace: env.QODER_WORKSPACE || '', + timeoutMs: parseTimeoutMs(env.QODER_BRIDGE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS), + maxPromptChars: Number.parseInt(env.QODER_BRIDGE_MAX_PROMPT_CHARS || '1000000', 10), + serialize: env.QODER_BRIDGE_SERIALIZE !== 'false', + }; +} + +export function createServer(options = {}) { + const config = options.config || loadConfig(); + const qoderClient = options.qoderClient || createQoderCliClient(config); + const queue = createQueue(config.serialize); + const responseStore = new Map(); + + return http.createServer(async (req, res) => { + try { + addCorsHeaders(res); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + + if (req.method === 'GET' && (url.pathname === '/health' || url.pathname === '/healthz')) { + sendJson(res, 200, { + ok: true, + bridge: 'qoderworkcn-responses-bridge', + model: config.bridgeModel, + qoderModel: config.qoderModel, + timeoutMs: config.timeoutMs, + toolBridge: 'structured-json', + supportsParallelToolCalls: true, + codexHome: config.codexHome, + usageLedgerPath: usageLedgerPath(config), + usagePricingConfigured: usagePricing(config).configured, + qoderCliExists: existsSync(config.qoderCli), + serialize: config.serialize, + }); + return; + } + + if (req.method === 'GET' && (url.pathname === '/usage' || url.pathname === '/codexbar/cost')) { + sendJson(res, 200, usageSummary(config, { + days: url.searchParams.get('days') || 30, + })); + return; + } + + if (req.method === 'GET' && url.pathname === '/v1/models') { + sendJson(res, 200, { + object: 'list', + data: [ + { + id: config.bridgeModel, + object: 'model', + created: 0, + owned_by: 'qoderworkcn-local', + }, + { + id: 'qoderworkcn/qmodel', + object: 'model', + created: 0, + owned_by: 'qoderworkcn-local', + }, + ], + }); + return; + } + + const responseIdMatch = url.pathname.match(/^\/v1\/responses\/([^/]+)$/); + if (req.method === 'GET' && responseIdMatch) { + const stored = responseStore.get(responseIdMatch[1]); + if (!stored) { + sendJson(res, 404, makeError('not_found', 'Response id not found.')); + return; + } + sendJson(res, 200, stored); + return; + } + + if (req.method === 'POST' && url.pathname === '/v1/responses') { + if (!isAuthorized(req, config)) { + sendJson(res, 401, makeError('unauthorized', 'Missing or invalid bearer token.')); + return; + } + + const body = await readJson(req, config.maxPromptChars + 32_768); + const unsupported = firstUnsupportedFeature(body); + if (unsupported) { + sendJson(res, 400, makeError('unsupported_feature', `${unsupported} is not supported by this bridge yet.`)); + return; + } + + const prompt = normalizeInput(body); + if (!prompt.trim()) { + sendJson(res, 400, makeError('invalid_request', 'input or instructions must contain text.')); + return; + } + if (prompt.length > config.maxPromptChars) { + sendJson(res, 413, makeError('prompt_too_large', `Prompt exceeds ${config.maxPromptChars} characters.`)); + return; + } + + const responseId = `resp_${randomUUID().replaceAll('-', '')}`; + const createdAt = Math.floor(Date.now() / 1000); + const model = body.model || config.bridgeModel; + const hostToolCalls = planHostToolCalls(body); + if (hostToolCalls.length > 0) { + const response = makeResponse({ + responseId, + createdAt, + model, + text: toolCallsToEnvelope(hostToolCalls), + tools: body.tools, + usage: estimateUsage(prompt, ''), + }); + responseStore.set(responseId, response); + if (body.stream === true) { + sendStreamedCompletedResponse(res, response); + } else { + sendJson(res, 200, response); + } + return; + } + + const controller = new AbortController(); + const abortOnDisconnect = () => { + if (!res.writableEnded) { + controller.abort(new Error('client disconnected')); + } + }; + req.on('aborted', abortOnDisconnect); + res.on('close', abortOnDisconnect); + + const run = () => qoderClient.run({ + prompt, + model: config.qoderModel, + maxOutputTokens: body.max_output_tokens, + timeoutMs: config.timeoutMs, + signal: controller.signal, + }); + + if (body.stream === true) { + await streamResponse(res, { + responseId, + createdAt, + model, + prompt, + tools: body.tools, + run: () => queue(run), + responseStore, + config, + }); + return; + } + + const result = await queue(run); + const response = makeResponse({ + responseId, + createdAt, + model, + text: result.text, + tools: body.tools, + usage: estimateUsage(prompt, result.text), + }); + recordBridgeUsage(config, response); + responseStore.set(responseId, response); + sendJson(res, 200, response); + return; + } + + sendJson(res, 404, makeError('not_found', 'Route not found.')); + } catch (error) { + const status = error.statusCode || mapErrorStatus(error); + sendJson(res, status, makeError(error.code || 'bridge_error', sanitizeErrorMessage(error))); + } + }); +} + +export function normalizeInput(body = {}) { + const sections = []; + + if (typeof body.instructions === 'string' && body.instructions.trim()) { + sections.push(`System:\n${body.instructions.trim()}`); + } + + const inputText = inputToText(body.input); + if (inputText.trim()) { + sections.push(inputText.trim()); + } + + const toolsText = toolsToPrompt(body.tools); + if (toolsText) { + sections.push(toolsText); + } + + return sections.join('\n\n'); +} + +function inputToText(input) { + if (typeof input === 'string') { + return input; + } + + if (Array.isArray(input)) { + return input.map(itemToText).filter(Boolean).join('\n\n'); + } + + if (input && typeof input === 'object') { + return itemToText(input); + } + + return ''; +} + +function itemToText(item) { + if (typeof item === 'string') { + return item; + } + if (!item || typeof item !== 'object') { + return ''; + } + + const role = typeof item.role === 'string' ? `${capitalize(item.role)}:\n` : ''; + + if (item.type === 'function_call') { + const args = typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments || {}); + return `Previous tool call ${item.name || 'unknown'} (${item.call_id || item.id || 'no-call-id'}):\n${args}`; + } + if (item.type === 'function_call_output') { + const output = typeof item.output === 'string' ? item.output : JSON.stringify(item.output ?? ''); + return `Tool result for ${item.call_id || 'unknown-call'}:\n${output}`; + } + + if (typeof item.content === 'string') { + return `${role}${item.content}`; + } + if (Array.isArray(item.content)) { + const content = item.content.map(partToText).filter(Boolean).join('\n'); + return content ? `${role}${content}` : ''; + } + if (typeof item.text === 'string') { + return `${role}${item.text}`; + } + if (typeof item.output === 'string') { + return `${role}${item.output}`; + } + + return ''; +} + +function toolsToPrompt(tools) { + const descriptors = toolDescriptors(tools); + if (descriptors.length === 0) return ''; + + const toolLines = descriptors.map(tool => { + const parts = [ + `- ${tool.label}`, + tool.namespace ? ` namespace: ${tool.namespace}` : '', + ` name: ${tool.name}`, + tool.description ? ` description: ${tool.description}` : '', + tool.parameters ? ` input_schema: ${JSON.stringify(tool.parameters)}` : '', + ].filter(Boolean); + return parts.join('\n'); + }); + + return [ + 'Codex host tools are available through this bridge. These are not QoderWork native tools.', + 'To ask Codex to run a host tool, reply with only this XML-tagged JSON and no prose:', + '', + '[{"namespace":"optional_namespace","name":"exact_tool_name","arguments":{"key":"value"}}]', + '', + 'If a needed tool is listed below, do not say tools are unavailable; emit the envelope instead.', + 'Use the exact tool names below. After tool results are returned, continue normally.', + 'Available tools:', + ...toolLines, + ].join('\n'); +} + +function toolDescriptors(tools) { + if (!Array.isArray(tools)) return []; + return tools.flatMap((tool) => { + if (!tool || typeof tool !== 'object') return null; + if (tool.type === 'namespace' && Array.isArray(tool.tools)) { + const namespace = stringOrEmpty(tool.name); + return tool.tools.map(inner => toolDescriptor(inner, namespace)).filter(Boolean); + } + return toolDescriptor(tool, stringOrEmpty(tool.namespace) || stringOrEmpty(tool.tool_namespace)); + }).filter(Boolean); +} + +function toolDescriptor(tool, namespace = '') { + if (!tool || typeof tool !== 'object') return null; + const name = stringOrEmpty(tool.name) || stringOrEmpty(tool.function?.name); + if (!name) return null; + const descriptorNamespace = namespace || stringOrEmpty(tool.namespace) || stringOrEmpty(tool.tool_namespace); + return { + namespace: descriptorNamespace, + name, + label: descriptorNamespace ? `${descriptorNamespace}.${name}` : name, + description: stringOrEmpty(tool.description) || stringOrEmpty(tool.function?.description), + parameters: tool.parameters || tool.input_schema || tool.inputSchema || tool.function?.parameters || null, + }; +} + +function partToText(part) { + if (typeof part === 'string') { + return part; + } + if (!part || typeof part !== 'object') { + return ''; + } + if (typeof part.text === 'string') { + return part.text; + } + if (typeof part.output_text === 'string') { + return part.output_text; + } + if (typeof part.content === 'string') { + return part.content; + } + if (typeof part.output === 'string') { + return part.output; + } + return ''; +} + +function firstUnsupportedFeature(body) { + if (body.response_format) return 'response_format'; + if (body.text && typeof body.text === 'object' && body.text.format && body.text.format.type && body.text.format.type !== 'text') { + return 'text.format'; + } + return ''; +} + +function createQoderCliClient(config) { + return { + async run({ prompt, maxOutputTokens, timeoutMs, signal }) { + if (!existsSync(config.qoderCli)) { + const error = new Error('QoderWork CN CLI was not found.'); + error.code = 'qoder_cli_not_found'; + throw error; + } + + const args = [ + '--storage-dir', + config.storageDir, + '--resource-dir', + config.resourceDir, + '--site', + config.qoderSite, + '--model', + config.qoderModel, + '-q', + '-f', + 'text', + ]; + + if (maxOutputTokens) { + args.push('--max-output-tokens', String(maxOutputTokens)); + } + if (config.workspace) { + args.push('-w', config.workspace); + } + args.push('-p', '-'); + + return runProcess(config.qoderCli, args, { + input: prompt, + timeoutMs, + signal, + cwd: config.workspace || process.cwd(), + }); + }, + }; +} + +export function runProcess(command, args, options) { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + const error = new Error('Request was cancelled.'); + error.code = 'request_cancelled'; + reject(error); + return; + } + + const child = spawn(command, args, { + cwd: options.cwd, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + NO_COLOR: '1', + CI: '1', + TERM: 'dumb', + }, + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + const watchdog = createQoderWatchdog({ + timeoutMs: options.timeoutMs, + onTimeout() { + const error = new Error('QoderWork CN CLI timed out.'); + error.code = 'qoder_cli_timeout'; + cleanup(); + killChild(child); + reject(error); + }, + }); + + const abort = () => { + const error = new Error('Request was cancelled.'); + error.code = 'request_cancelled'; + cleanup(); + killChild(child); + reject(error); + }; + + const cleanup = () => { + if (settled) return false; + settled = true; + watchdog?.dispose(); + options.signal?.removeEventListener('abort', abort); + return true; + }; + + options.signal?.addEventListener('abort', abort, { once: true }); + + child.stdin.on('error', () => {}); + child.stdin.end(options.input || ''); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', chunk => { + stdout += chunk; + }); + child.stderr.on('data', chunk => { + stderr += chunk; + }); + child.on('error', error => { + if (!cleanup()) return; + error.code = error.code || 'qoder_cli_spawn_error'; + reject(error); + }); + child.on('close', code => { + if (!cleanup()) return; + if (code !== 0) { + const error = new Error('QoderWork CN CLI exited with a non-zero status.'); + error.code = 'qoder_cli_failed'; + error.stderr = stripAnsi(stderr).slice(0, 2000); + reject(error); + return; + } + resolve({ + text: stripAnsi(stdout).trim(), + stderr: stripAnsi(stderr).trim(), + }); + }); + }); +} + +export function createQoderWatchdog({ timeoutMs, onTimeout, timers = globalThis } = {}) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return null; + const setTimer = timers.setTimeout || setTimeout; + const clearTimer = timers.clearTimeout || clearTimeout; + const timer = setTimer(onTimeout, timeoutMs); + return { + dispose() { + clearTimer(timer); + }, + }; +} + +function killChild(child) { + if (child.killed) { + return; + } + if (process.platform === 'win32' && child.pid) { + const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { + windowsHide: true, + stdio: 'ignore', + }); + killer.on('error', () => {}); + return; + } + child.kill(); +} + +function createQueue(enabled) { + if (!enabled) { + return task => task(); + } + + let chain = Promise.resolve(); + return task => { + const run = chain.then(task, task); + chain = run.catch(() => {}); + return run; + }; +} + +async function streamResponse(res, options) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + writeSse(res, 'response.created', { + type: 'response.created', + response: { + id: options.responseId, + object: 'response', + created_at: options.createdAt, + status: 'in_progress', + model: options.model, + output: [], + }, + }); + + const heartbeat = setInterval(() => { + writeSseComment(res, 'keep-alive'); + }, STREAM_HEARTBEAT_MS); + heartbeat.unref?.(); + + try { + const result = await options.run(); + const response = makeResponse({ + responseId: options.responseId, + createdAt: options.createdAt, + model: options.model, + text: result.text, + tools: options.tools, + usage: estimateUsage(options.prompt, result.text), + }); + recordBridgeUsage(options.config, response); + options.responseStore.set(options.responseId, response); + + writeResponseOutputEvents(res, response); + writeSse(res, 'response.completed', { + type: 'response.completed', + response, + sequence_number: 1, + }); + res.end(); + } catch (error) { + writeSse(res, 'response.failed', { + type: 'response.failed', + response: { + id: options.responseId, + status: 'failed', + error: { + code: error.code || 'bridge_error', + message: sanitizeErrorMessage(error), + }, + }, + }); + res.end(); + } finally { + clearInterval(heartbeat); + } +} + +function sendStreamedCompletedResponse(res, response) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + writeSse(res, 'response.created', { + type: 'response.created', + response: { + id: response.id, + object: 'response', + created_at: response.created_at, + status: 'in_progress', + model: response.model, + output: [], + }, + }); + writeResponseOutputEvents(res, response); + writeSse(res, 'response.completed', { + type: 'response.completed', + response, + sequence_number: response.output.length + 1, + }); + res.end(); +} + +function writeResponseOutputEvents(res, response) { + response.output.forEach((item, outputIndex) => { + writeSse(res, 'response.output_item.done', { + type: 'response.output_item.done', + output_index: outputIndex, + item, + }); + if (item.type === 'function_call') { + writeSse(res, 'response.function_call_arguments.done', { + type: 'response.function_call_arguments.done', + item_id: item.id, + name: item.name, + output_index: outputIndex, + arguments: item.arguments, + sequence_number: outputIndex + 1, + }); + } + }); +} + +function recordBridgeUsage(config, response) { + try { + recordUsage(config, response, { qoderModel: config?.qoderModel }); + } catch { + // Usage telemetry must never break model responses. + } +} + +export function makeResponse({ responseId, createdAt, model, text, tools, usage }) { + const outputId = `msg_${randomUUID().replaceAll('-', '')}`; + const toolCalls = parseQoderToolCalls(text, tools); + const output = toolCalls.length > 0 + ? toolCalls.map(call => makeFunctionCallItem(call)) + : [ + { + id: outputId, + type: 'message', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text, + annotations: [], + }, + ], + }, + ]; + + return { + id: responseId, + object: 'response', + created_at: createdAt, + status: 'completed', + model, + output, + parallel_tool_calls: output.filter(item => item.type === 'function_call').length > 1, + usage, + }; +} + +function makeFunctionCallItem(call) { + const item = { + id: `fc_${randomUUID().replaceAll('-', '')}`, + type: 'function_call', + status: 'completed', + call_id: `call_${randomUUID().replaceAll('-', '')}`, + name: call.name, + arguments: JSON.stringify(call.arguments || {}), + }; + if (call.namespace) { + item.namespace = call.namespace; + } + return item; +} + +function estimateUsage(input, output) { + const inputTokens = estimateTokens(input); + const outputTokens = estimateTokens(output); + return { + input_tokens: inputTokens, + input_tokens_details: { + cached_tokens: 0, + }, + output_tokens: outputTokens, + output_tokens_details: { + reasoning_tokens: 0, + }, + total_tokens: inputTokens + outputTokens, + }; +} + +export function parseQoderToolCalls(text, tools = []) { + const parsed = parseToolCallPayload(text); + if (!parsed) return []; + + const calls = normalizeToolCallPayload(parsed); + if (calls.length === 0) return []; + + const names = toolNameResolver(tools); + return calls.map((call) => { + const requestedName = stringOrEmpty(call.name || call.tool || call.function?.name); + const requestedNamespace = stringOrEmpty(call.namespace || call.tool_namespace || call.function?.namespace); + const tool = names.resolve(requestedName, requestedNamespace); + if (!tool) return null; + const rawArgs = call.arguments ?? call.args ?? call.input ?? call.function?.arguments ?? {}; + return { + namespace: tool.namespace, + name: tool.name, + arguments: normalizeArguments(rawArgs), + }; + }).filter(Boolean); +} + +export function planHostToolCalls(body = {}) { + if (containsFunctionCallItems(body.input)) return []; + const prompt = inputToText(body.input); + if (!/\bsubagents?\b|子代理|子智能体|子任务/i.test(prompt)) return []; + + const createThreadTool = findToolDescriptor(body.tools, 'create_thread'); + if (!createThreadTool) return []; + + const specs = extractSubagentSpecs(prompt); + if (specs.length === 0) return []; + + return specs.map((spec, index) => ({ + namespace: createThreadTool.namespace, + name: createThreadTool.name, + arguments: { + prompt: buildSubagentPrompt(spec, prompt), + target: { + type: 'projectless', + directoryName: safeDirectoryName(`${index + 1}-${spec.label}`), + }, + }, + })); +} + +function containsFunctionCallItems(input) { + if (!Array.isArray(input)) return false; + return input.some(item => item && typeof item === 'object' && ( + item.type === 'function_call' || + item.type === 'function_call_output' + )); +} + +function findToolDescriptor(tools, name) { + const descriptors = toolDescriptors(tools); + return descriptors.find(tool => ( + tool.name === name || + tool.label === name || + tool.label.endsWith(`.${name}`) || + tool.name.endsWith(`.${name}`) + )) || null; +} + +function extractSubagentSpecs(text) { + const specs = []; + const pattern = /^\s*(?:[-*]\s*)?(Subagent\s+[A-Za-z0-9_-]+|子(?:代理|智能体|任务)?\s*[A-Za-z0-9_-]+)\s*[::]\s*(.+)$/gim; + let match; + while ((match = pattern.exec(String(text || ''))) !== null) { + const task = match[2].trim(); + if (task) { + specs.push({ + label: match[1].trim().replace(/\s+/g, '-'), + task, + }); + } + } + return specs; +} + +function buildSubagentPrompt(spec, originalPrompt) { + return [ + `你是 ${spec.label}。只完成分配给你的子任务,不要改动用户现有项目,除非任务明确要求。`, + '', + `子任务:${spec.task}`, + '', + '原始上下文:', + truncateText(originalPrompt, 12000), + '', + '请输出可并入主线程的结论:核验过的地址、执行过的命令、安装/运行结果、缺失凭据、关键证据路径。', + ].join('\n'); +} + +function toolCallsToEnvelope(calls) { + return `${JSON.stringify(calls.map(call => ({ + namespace: call.namespace, + name: call.name, + arguments: call.arguments, + })))}`; +} + +function safeDirectoryName(value) { + return String(value || 'subagent') + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) || 'subagent'; +} + +function truncateText(text, limit) { + const value = String(text || ''); + return value.length <= limit ? value : `${value.slice(0, limit)}\n...[truncated]`; +} + +function parseToolCallPayload(text) { + const raw = String(text || '').trim(); + if (!raw) return null; + + const tagged = /\s*([\s\S]*?)\s*<\/codex_tool_calls>/i.exec(raw); + if (tagged) { + return parseJsonLoose(tagged[1]); + } + + const fenced = /```(?:json)?\s*([\s\S]*?)\s*```/i.exec(raw); + if (fenced) { + const parsed = parseJsonLoose(fenced[1]); + if (parsed && hasToolCallShape(parsed)) return parsed; + } + + const parsed = parseJsonLoose(raw); + return parsed && hasToolCallShape(parsed) ? parsed : null; +} + +function parseJsonLoose(raw) { + try { + return JSON.parse(String(raw || '').trim()); + } catch { + return null; + } +} + +function hasToolCallShape(value) { + if (Array.isArray(value)) { + return value.some(item => item && typeof item === 'object' && (item.name || item.tool || item.function?.name)); + } + if (!value || typeof value !== 'object') return false; + if (Array.isArray(value.tool_calls) || Array.isArray(value.calls)) return true; + if (value.tool_call || value.call) return true; + return value.type === 'function_call' && Boolean(value.name || value.function?.name); +} + +function normalizeToolCallPayload(value) { + if (Array.isArray(value)) return value; + if (!value || typeof value !== 'object') return []; + if (Array.isArray(value.tool_calls)) return value.tool_calls; + if (Array.isArray(value.calls)) return value.calls; + if (value.tool_call) return [value.tool_call]; + if (value.call) return [value.call]; + if (value.type === 'function_call') return [value]; + return []; +} + +function toolNameResolver(tools = []) { + const exact = new Map(); + const bySuffix = new Map(); + + for (const tool of toolDescriptors(tools)) { + exact.set(tool.name, tool); + exact.set(tool.label, tool); + const suffix = tool.label.split(/[.:/]/).pop(); + if (suffix) { + bySuffix.set(suffix, bySuffix.has(suffix) ? null : tool); + } + } + + return { + resolve(name, namespace = '') { + if (!name) return ''; + if (namespace && exact.has(`${namespace}.${name}`)) return exact.get(`${namespace}.${name}`); + if (exact.has(name)) return exact.get(name); + const suffix = name.split(/[.:/]/).pop(); + return suffix && bySuffix.get(suffix) ? bySuffix.get(suffix) : ''; + }, + }; +} + +function normalizeArguments(value) { + if (typeof value === 'string') { + const parsed = parseJsonLoose(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : { value }; + } + if (value && typeof value === 'object' && !Array.isArray(value)) return value; + return {}; +} + +function estimateTokens(text) { + if (!text) return 0; + return Math.max(1, Math.ceil([...String(text)].length / 4)); +} + +function parseTimeoutMs(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return parsed; +} + +function writeSse(res, event, data) { + if (res.destroyed || res.writableEnded) { + return; + } + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); +} + +function writeSseComment(res, text) { + if (res.destroyed || res.writableEnded) { + return; + } + res.write(`: ${text}\n\n`); +} + +function sendJson(res, status, body) { + if (res.headersSent) { + res.end(); + return; + } + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + }); + res.end(JSON.stringify(body)); +} + +function addCorsHeaders(res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'authorization,content-type'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); +} + +function readJson(req, maxBytes) { + return new Promise((resolve, reject) => { + let raw = ''; + req.setEncoding('utf8'); + req.on('data', chunk => { + raw += chunk; + if (Buffer.byteLength(raw, 'utf8') > maxBytes) { + const error = new Error('Request body too large.'); + error.statusCode = 413; + error.code = 'request_too_large'; + reject(error); + } + }); + req.on('end', () => { + try { + resolve(raw ? JSON.parse(raw) : {}); + } catch { + const error = new Error('Request body must be valid JSON.'); + error.statusCode = 400; + error.code = 'invalid_json'; + reject(error); + } + }); + req.on('error', reject); + }); +} + +function isAuthorized(req, config) { + if (!config.apiKey) return true; + const header = req.headers.authorization || ''; + return header === `Bearer ${config.apiKey}`; +} + +function makeError(code, message) { + return { + error: { + message, + type: code, + code, + }, + }; +} + +function mapErrorStatus(error) { + switch (error.code) { + case 'qoder_cli_not_found': + return 503; + case 'qoder_cli_failed': + return 502; + case 'qoder_cli_timeout': + return 504; + case 'request_cancelled': + return 499; + default: + return 500; + } +} + +function sanitizeErrorMessage(error) { + if (error?.code === 'qoder_cli_failed' && error.stderr) { + return `QoderWork CN CLI failed: ${error.stderr}`; + } + return String(error?.message || 'Bridge error.'); +} + +function stripAnsi(value) { + return String(value || '').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); +} + +function capitalize(value) { + return value ? value.charAt(0).toUpperCase() + value.slice(1) : ''; +} + +function stringOrEmpty(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) { + const config = loadConfig(); + const server = createServer({ config }); + server.listen(config.port, config.host, () => { + console.log(`qoderworkcn-responses-bridge listening on http://${config.host}:${config.port}`); + }); +} diff --git a/tweaks/qoderworkcn-bridge/bridge/src/usage-ledger.mjs b/tweaks/qoderworkcn-bridge/bridge/src/usage-ledger.mjs new file mode 100644 index 0000000..42c0822 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/bridge/src/usage-ledger.mjs @@ -0,0 +1,284 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + +const DEFAULT_LEDGER_FILE = 'codexbar-qoderworkcn-usage.jsonl'; +const USD_PER_1M_KEYS = { + input: 'QODERWORKCN_INPUT_USD_PER_1M', + output: 'QODERWORKCN_OUTPUT_USD_PER_1M', + cachedInput: 'QODERWORKCN_CACHED_INPUT_USD_PER_1M', +}; + +export function usageLedgerPath(config = {}) { + if (config.usageLedgerPath) return path.resolve(String(config.usageLedgerPath)); + const explicit = config.usageLedger || config.env?.QODERWORKCN_USAGE_LEDGER || process.env.QODERWORKCN_USAGE_LEDGER; + if (explicit) return path.resolve(String(explicit)); + + const codexHome = config.codexHome || process.env.QODERWORKCN_CODEX_HOME || process.env.CODEX_HOME; + if (codexHome) return path.join(path.resolve(String(codexHome)), DEFAULT_LEDGER_FILE); + + return path.join(process.cwd(), DEFAULT_LEDGER_FILE); +} + +export function usagePricing(config = {}) { + const env = config.env || process.env; + const inputPer1M = numberFrom(config.inputUsdPer1M ?? env[USD_PER_1M_KEYS.input]); + const outputPer1M = numberFrom(config.outputUsdPer1M ?? env[USD_PER_1M_KEYS.output]); + const cachedInputPer1M = numberFrom(config.cachedInputUsdPer1M ?? env[USD_PER_1M_KEYS.cachedInput]); + const configured = inputPer1M > 0 || outputPer1M > 0 || cachedInputPer1M > 0; + return { inputPer1M, outputPer1M, cachedInputPer1M, configured }; +} + +export function recordUsage(config, response, meta = {}) { + if (!response || !response.usage) return null; + + const ledgerPath = usageLedgerPath(config); + mkdirSync(path.dirname(ledgerPath), { recursive: true }); + + const usage = normalizeUsage(response.usage); + const pricing = usagePricing(config); + const costUSD = estimateCostUSD(usage, pricing); + const timestamp = new Date((response.created_at || Math.floor(Date.now() / 1000)) * 1000).toISOString(); + const record = { + schema: 'codexbar.qoderworkcn.usage.v1', + provider: 'qoderworkcn', + source: 'qoderworkcn-responses-bridge', + timestamp, + responseId: response.id || null, + codexModel: response.model || config.bridgeModel || null, + qoderModel: meta.qoderModel || config.qoderModel || null, + usage, + costUSD, + pricing: pricing.configured ? { + inputPer1M: pricing.inputPer1M, + outputPer1M: pricing.outputPer1M, + cachedInputPer1M: pricing.cachedInputPer1M, + currency: 'USD', + } : null, + }; + + appendFileSync(ledgerPath, `${JSON.stringify(record)}\n`, 'utf8'); + return record; +} + +export function usageSummary(config = {}, options = {}) { + const ledgerPath = usageLedgerPath(config); + const days = clampDays(options.days ?? 30); + const now = options.now instanceof Date ? options.now : new Date(); + const sinceMs = now.getTime() - days * 24 * 60 * 60 * 1000; + const todayKey = dayKey(now); + const pricing = usagePricing(config); + const records = readLedgerRecords(ledgerPath) + .filter((record) => { + const time = Date.parse(record.timestamp || ''); + return Number.isFinite(time) && time >= sinceMs && time <= now.getTime() + 1000; + }); + + const daily = new Map(); + const totals = emptyTotals(); + let latestAt = null; + + for (const record of records) { + const usage = normalizeUsage(record.usage || {}); + const key = dayKey(new Date(record.timestamp)); + const costUSD = typeof record.costUSD === 'number' + ? record.costUSD + : estimateCostUSD(usage, pricing); + const bucket = daily.get(key) || { + date: key, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + totalCost: null, + requestCount: 0, + modelsUsed: [], + modelBreakdowns: new Map(), + }; + + addUsage(bucket, usage, costUSD, record.codexModel || 'unknown'); + addUsage(totals, usage, costUSD, record.codexModel || 'unknown'); + daily.set(key, bucket); + if (!latestAt || record.timestamp > latestAt) latestAt = record.timestamp; + } + + const dailyRows = [...daily.values()] + .sort((a, b) => a.date.localeCompare(b.date)) + .map(finalizeBucket); + const today = dailyRows.find((row) => row.date === todayKey) || emptyDaily(todayKey); + const finalizedTotals = finalizeBucket(totals); + + return { + provider: 'qoderworkcn', + source: 'qoderworkcn-responses-bridge', + updatedAt: latestAt, + ledgerPath, + ledgerExists: existsSync(ledgerPath), + ledgerBytes: fileSize(ledgerPath), + pricingConfigured: pricing.configured, + sessionTokens: today.totalTokens, + sessionCostUSD: today.totalCost, + last30DaysTokens: finalizedTotals.totalTokens, + last30DaysCostUSD: finalizedTotals.totalCost, + daily: dailyRows, + totals: finalizedTotals, + }; +} + +function readLedgerRecords(ledgerPath) { + if (!existsSync(ledgerPath)) return []; + const text = readFileSync(ledgerPath, 'utf8'); + const rows = []; + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + rows.push(JSON.parse(line)); + } catch { + // Keep the endpoint resilient if a previous process was interrupted mid-write. + } + } + return rows; +} + +function addUsage(bucket, usage, costUSD, model) { + bucket.inputTokens += usage.input_tokens; + bucket.outputTokens += usage.output_tokens; + bucket.cacheReadTokens += usage.input_tokens_details.cached_tokens; + bucket.cacheCreationTokens += 0; + bucket.totalTokens += usage.total_tokens; + bucket.requestCount += 1; + if (typeof costUSD === 'number') { + bucket.totalCost = (bucket.totalCost || 0) + costUSD; + } + if (!bucket.modelsUsed.includes(model)) bucket.modelsUsed.push(model); + const breakdown = bucket.modelBreakdowns.get(model) || { + modelName: model, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + cost: null, + requestCount: 0, + }; + breakdown.inputTokens += usage.input_tokens; + breakdown.outputTokens += usage.output_tokens; + breakdown.cacheReadTokens += usage.input_tokens_details.cached_tokens; + breakdown.totalTokens += usage.total_tokens; + breakdown.requestCount += 1; + if (typeof costUSD === 'number') { + breakdown.cost = (breakdown.cost || 0) + costUSD; + } + bucket.modelBreakdowns.set(model, breakdown); +} + +function finalizeBucket(bucket) { + return { + date: bucket.date, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cacheReadTokens, + cacheCreationTokens: bucket.cacheCreationTokens, + totalTokens: bucket.totalTokens, + totalCost: roundCost(bucket.totalCost), + requestCount: bucket.requestCount, + modelsUsed: [...bucket.modelsUsed].sort(), + modelBreakdowns: [...bucket.modelBreakdowns.values()] + .sort((a, b) => b.totalTokens - a.totalTokens || a.modelName.localeCompare(b.modelName)) + .map((item) => ({ ...item, cost: roundCost(item.cost) })), + }; +} + +function emptyTotals() { + return { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + totalCost: null, + requestCount: 0, + modelsUsed: [], + modelBreakdowns: new Map(), + }; +} + +function emptyDaily(date) { + return { + date, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + totalCost: null, + requestCount: 0, + modelsUsed: [], + modelBreakdowns: [], + }; +} + +function normalizeUsage(usage = {}) { + const input = nonNegativeInt(usage.input_tokens); + const output = nonNegativeInt(usage.output_tokens); + const cached = nonNegativeInt( + usage.input_tokens_details?.cached_tokens ?? + usage.input_tokens_details?.cache_read_tokens ?? + usage.cache_read_input_tokens ?? + usage.cached_input_tokens, + ); + return { + input_tokens: input, + input_tokens_details: { + cached_tokens: cached, + }, + output_tokens: output, + output_tokens_details: { + reasoning_tokens: nonNegativeInt(usage.output_tokens_details?.reasoning_tokens), + }, + total_tokens: nonNegativeInt(usage.total_tokens) || input + output, + }; +} + +function estimateCostUSD(usage, pricing) { + if (!pricing.configured) return null; + const cached = usage.input_tokens_details.cached_tokens; + const billableInput = Math.max(0, usage.input_tokens - cached); + const cost = + (billableInput / 1_000_000) * pricing.inputPer1M + + (cached / 1_000_000) * pricing.cachedInputPer1M + + (usage.output_tokens / 1_000_000) * pricing.outputPer1M; + return roundCost(cost); +} + +function numberFrom(value) { + const parsed = Number.parseFloat(String(value ?? '0')); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function nonNegativeInt(value) { + const parsed = Number.parseInt(String(value ?? '0'), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function clampDays(value) { + const parsed = Number.parseInt(String(value ?? '30'), 10); + if (!Number.isFinite(parsed)) return 30; + return Math.min(365, Math.max(1, parsed)); +} + +function dayKey(date) { + return date.toISOString().slice(0, 10); +} + +function fileSize(file) { + try { + return statSync(file).size; + } catch { + return 0; + } +} + +function roundCost(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return Math.round(value * 1_000_000) / 1_000_000; +} diff --git a/tweaks/qoderworkcn-bridge/bridge/test/config.test.mjs b/tweaks/qoderworkcn-bridge/bridge/test/config.test.mjs new file mode 100644 index 0000000..990be70 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/bridge/test/config.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { createServer, loadConfig } from '../src/server.mjs'; +import test from 'node:test'; + +test('defaults to a long Qoder CLI timeout for coding tasks', () => { + const config = loadConfig({}); + assert.equal(config.timeoutMs, 7_200_000); +}); + +test('allows disabling the bridge watchdog timeout', () => { + const config = loadConfig({ + QODER_BRIDGE_TIMEOUT_MS: '0', + }); + assert.equal(config.timeoutMs, 0); +}); + +test('health exposes the active Qoder CLI timeout', async () => { + const config = loadConfig({ + QODER_BRIDGE_PORT: '0', + QODER_BRIDGE_TIMEOUT_MS: '123456', + }); + const server = createServer({ config }); + server.listen(0, config.host); + await once(server, 'listening'); + + try { + const address = server.address(); + const response = await fetch(`http://${config.host}:${address.port}/health`); + const health = await response.json(); + assert.equal(response.ok, true); + assert.equal(health.timeoutMs, 123456); + } finally { + server.close(); + } +}); diff --git a/tweaks/qoderworkcn-bridge/bridge/test/tool-calls.test.mjs b/tweaks/qoderworkcn-bridge/bridge/test/tool-calls.test.mjs new file mode 100644 index 0000000..5125ac6 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/bridge/test/tool-calls.test.mjs @@ -0,0 +1,320 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import test from 'node:test'; +import * as server from '../src/server.mjs'; + +const { createServer, loadConfig, makeResponse, normalizeInput, parseQoderToolCalls, planHostToolCalls } = server; + +const tools = [ + { + type: 'namespace', + name: 'codex_app', + tools: [ + { + type: 'function', + name: 'create_thread', + description: 'Create a Codex subagent thread.', + parameters: { + type: 'object', + properties: { + prompt: { type: 'string' }, + }, + required: ['prompt'], + }, + }, + ], + }, +]; + +test('injects tool-call envelope instructions instead of disabling tools', () => { + const prompt = normalizeInput({ + input: '启动一个 subagent', + tools, + }); + + assert.match(prompt, //); + assert.match(prompt, /create_thread/); + assert.doesNotMatch(prompt, /Tool calling is unavailable/); +}); + +test('parses Qoder tagged tool calls and maps dotted names to exact tool names', () => { + const calls = parseQoderToolCalls( + '[{"name":"codex_app.create_thread","arguments":{"prompt":"A"}}]', + tools, + ); + + assert.deepEqual(calls, [ + { + name: 'create_thread', + namespace: 'codex_app', + arguments: { prompt: 'A' }, + }, + ]); +}); + +test('turns structured Qoder create_thread JSON into Responses function_call items', () => { + const response = makeResponse({ + responseId: 'resp_test', + createdAt: 1, + model: 'qoderworkcn-codex', + text: JSON.stringify({ + type: 'function_call', + name: 'create_thread', + arguments: { prompt: 'A' }, + }), + tools, + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + + assert.equal(response.output.length, 1); + assert.equal(response.output[0].type, 'function_call'); + assert.equal(response.output[0].namespace, 'codex_app'); + assert.equal(response.output[0].name, 'create_thread'); + assert.equal(response.output[0].arguments, '{"prompt":"A"}'); +}); + +test('keeps ordinary text as an assistant message', () => { + const response = makeResponse({ + responseId: 'resp_text', + createdAt: 1, + model: 'qoderworkcn-codex', + text: '普通回复', + tools, + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + + assert.equal(response.output[0].type, 'message'); + assert.equal(response.output[0].content[0].text, '普通回复'); +}); + +test('includes previous function call outputs in the next Qoder prompt', () => { + const prompt = normalizeInput({ + input: [ + { + type: 'function_call', + call_id: 'call_1', + name: 'create_thread', + arguments: '{"prompt":"A"}', + }, + { + type: 'function_call_output', + call_id: 'call_1', + output: '{"threadId":"t1"}', + }, + ], + tools, + }); + + assert.match(prompt, /Previous tool call create_thread/); + assert.match(prompt, /Tool result for call_1/); + assert.match(prompt, /"threadId":"t1"/); +}); + +test('does not create a Qoder watchdog when QODER_BRIDGE_TIMEOUT_MS=0', () => { + assert.equal( + typeof server.createQoderWatchdog, + 'function', + 'Expected ../src/server.mjs to export createQoderWatchdog for watchdog tests.', + ); + + const config = loadConfig({ + QODER_BRIDGE_TIMEOUT_MS: '0', + }); + let setTimeoutCalls = 0; + + const watchdog = server.createQoderWatchdog({ + timeoutMs: config.timeoutMs, + onTimeout() { + assert.fail('disabled watchdog should never time out'); + }, + timers: { + setTimeout() { + setTimeoutCalls += 1; + assert.fail('disabled watchdog should not call setTimeout'); + }, + clearTimeout() { + assert.fail('disabled watchdog should not need cleanup'); + }, + }, + }); + + assert.equal(config.timeoutMs, 0); + assert.equal(watchdog, null); + assert.equal(setTimeoutCalls, 0); +}); + +test('runProcess rejects before spawn when signal is already aborted', async () => { + assert.equal( + typeof server.runProcess, + 'function', + 'Expected ../src/server.mjs to export runProcess for cancellation tests.', + ); + + const controller = new AbortController(); + controller.abort(); + + await assert.rejects( + () => server.runProcess(process.execPath, ['-e', 'process.exit(99)'], { + cwd: process.cwd(), + input: '', + timeoutMs: 0, + signal: controller.signal, + }), + error => error.code === 'request_cancelled', + ); +}); + +test('responses endpoint returns function_call when Qoder emits a tool envelope', async () => { + const config = loadConfig({ + QODER_BRIDGE_PORT: '0', + }); + const bridge = createServer({ + config, + qoderClient: { + async run() { + return { + text: '[{"name":"create_thread","arguments":{"prompt":"A"}}]', + }; + }, + }, + }); + bridge.listen(0, config.host); + await once(bridge, 'listening'); + + try { + const address = bridge.address(); + const response = await fetch(`http://${config.host}:${address.port}/v1/responses`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'qoderworkcn-codex', + input: 'start subagent', + tools, + }), + }); + const body = await response.json(); + assert.equal(response.ok, true); + assert.equal(body.output[0].type, 'function_call'); + assert.equal(body.output[0].namespace, 'codex_app'); + assert.equal(body.output[0].name, 'create_thread'); + assert.equal(body.output[0].arguments, '{"prompt":"A"}'); + } finally { + bridge.close(); + } +}); + +test('plans explicit Subagent A/B requests as host create_thread calls', () => { + const calls = planHostToolCalls({ + input: [ + { + role: 'user', + content: [ + { + type: 'input_text', + text: [ + '请用 subagents 分工。', + '- Subagent A:核验 Storylet 是否有公开源码。', + '- Subagent B:安装 AI Town 到本地。', + ].join('\n'), + }, + ], + }, + ], + tools, + }); + + assert.equal(calls.length, 2); + assert.equal(calls[0].namespace, 'codex_app'); + assert.equal(calls[0].name, 'create_thread'); + assert.match(calls[0].arguments.prompt, /核验 Storylet/); + assert.equal(calls[0].arguments.target.type, 'projectless'); +}); + +test('plans explicit subagents when create_thread is exposed as a dotted function name', () => { + const calls = planHostToolCalls({ + input: [ + { + role: 'user', + content: [ + { + type: 'input_text', + text: [ + '请启动 subagents。', + '- Subagent A:核验 Storylet。', + ].join('\n'), + }, + ], + }, + ], + tools: [ + { + type: 'function', + name: 'codex_app.create_thread', + description: 'Create a Codex subagent thread.', + parameters: { + type: 'object', + properties: { + prompt: { type: 'string' }, + }, + required: ['prompt'], + }, + }, + ], + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].namespace, ''); + assert.equal(calls[0].name, 'codex_app.create_thread'); + assert.match(calls[0].arguments.prompt, /核验 Storylet/); +}); + +test('host planner prevents Qoder call for explicit subagent startup', async () => { + const config = loadConfig({ + QODER_BRIDGE_PORT: '0', + }); + const bridge = createServer({ + config, + qoderClient: { + async run() { + assert.fail('explicit subagent startup should be handled by host planner'); + }, + }, + }); + bridge.listen(0, config.host); + await once(bridge, 'listening'); + + try { + const address = bridge.address(); + const response = await fetch(`http://${config.host}:${address.port}/v1/responses`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'qoderworkcn-codex', + input: [ + { + role: 'user', + content: [ + { + type: 'input_text', + text: [ + '请用 subagents 分工。', + '- Subagent A:核验 Storylet 是否有公开源码。', + '- Subagent B:安装 AI Town 到本地。', + ].join('\n'), + }, + ], + }, + ], + tools, + }), + }); + const body = await response.json(); + assert.equal(response.ok, true); + assert.equal(body.output.length, 2); + assert.equal(body.output[0].type, 'function_call'); + assert.equal(body.output[0].namespace, 'codex_app'); + assert.equal(body.output[0].name, 'create_thread'); + } finally { + bridge.close(); + } +}); diff --git a/tweaks/qoderworkcn-bridge/bridge/test/usage-ledger.test.mjs b/tweaks/qoderworkcn-bridge/bridge/test/usage-ledger.test.mjs new file mode 100644 index 0000000..c6be0c7 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/bridge/test/usage-ledger.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { recordUsage, usageSummary } from '../src/usage-ledger.mjs'; + +test('records bridge usage and summarizes CodexBar-style totals', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'qoder-usage-')); + const ledger = path.join(dir, 'usage.jsonl'); + try { + const config = { + usageLedgerPath: ledger, + bridgeModel: 'qoderworkcn-codex', + qoderModel: 'qmodel', + }; + const response = { + id: 'resp_test', + created_at: Date.parse('2026-06-05T02:00:00.000Z') / 1000, + model: 'qoderworkcn-codex', + usage: { + input_tokens: 12, + input_tokens_details: { cached_tokens: 3 }, + output_tokens: 5, + total_tokens: 17, + }, + }; + + recordUsage(config, response); + + const raw = readFileSync(ledger, 'utf8').trim(); + assert.match(raw, /codexbar\.qoderworkcn\.usage\.v1/); + + const summary = usageSummary(config, { + now: new Date('2026-06-05T03:00:00.000Z'), + days: 30, + }); + + assert.equal(summary.sessionTokens, 17); + assert.equal(summary.last30DaysTokens, 17); + assert.equal(summary.totals.inputTokens, 12); + assert.equal(summary.totals.outputTokens, 5); + assert.equal(summary.totals.cacheReadTokens, 3); + assert.equal(summary.totals.requestCount, 1); + assert.equal(summary.pricingConfigured, false); + assert.equal(summary.last30DaysCostUSD, null); + assert.deepEqual(summary.totals.modelsUsed, ['qoderworkcn-codex']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('estimates cost when per-million token prices are configured', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'qoder-usage-')); + const ledger = path.join(dir, 'usage.jsonl'); + try { + const config = { + usageLedgerPath: ledger, + bridgeModel: 'qoderworkcn-codex', + inputUsdPer1M: '2', + outputUsdPer1M: '8', + cachedInputUsdPer1M: '0.5', + }; + + recordUsage(config, { + id: 'resp_cost', + created_at: Date.parse('2026-06-05T02:00:00.000Z') / 1000, + model: 'qoderworkcn-codex', + usage: { + input_tokens: 1_000_000, + input_tokens_details: { cached_tokens: 250_000 }, + output_tokens: 500_000, + total_tokens: 1_500_000, + }, + }); + + const summary = usageSummary(config, { + now: new Date('2026-06-05T03:00:00.000Z'), + days: 30, + }); + + assert.equal(summary.pricingConfigured, true); + assert.equal(summary.last30DaysCostUSD, 5.625); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tweaks/qoderworkcn-bridge/index.js b/tweaks/qoderworkcn-bridge/index.js new file mode 100644 index 0000000..f9adb7e --- /dev/null +++ b/tweaks/qoderworkcn-bridge/index.js @@ -0,0 +1,787 @@ +const PROVIDER_ID = "qoderworkcn-bridge"; +const BRIDGE_MODEL = "qoderworkcn-codex"; +const QODER_MODEL = "qmodel"; +const DEFAULT_PORT = 38441; +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_TIMEOUT_MS = 7200000; +const DEFAULT_REASONING_EFFORT = "low"; +const DEFAULT_PROFILE_DIRNAME = "codex-home"; + +const DEFAULT_MODEL_START = "# BEGIN CODEX++ MANAGED QODERWORKCN DEFAULT MODEL"; +const DEFAULT_MODEL_END = "# END CODEX++ MANAGED QODERWORKCN DEFAULT MODEL"; +const PROVIDER_START = "# BEGIN CODEX++ MANAGED QODERWORKCN RESPONSES BRIDGE"; +const PROVIDER_END = "# END CODEX++ MANAGED QODERWORKCN RESPONSES BRIDGE"; +const ROOT_MODEL_KEYS = [ + "model", + "model_provider", + "model_reasoning_effort", + "model_catalog_json", +]; + +let bridgeServer = null; +let bridgeStatus = { state: "stopped" }; +let startToken = 0; +let settingsHandle = null; +let healthTimer = null; + +module.exports = { + start(api) { + if (api.process === "main") { + startMain(api); + return; + } + startRenderer(api); + }, + stop() { + startToken += 1; + stopBridge(); + if (settingsHandle) { + settingsHandle.unregister(); + settingsHandle = null; + } + if (healthTimer) { + clearInterval(healthTimer); + healthTimer = null; + } + }, + __private: { + buildManagedCodexConfig, + buildProfileConfig, + resolveBridgePort, + resolveCodexHome, + stripManagedBlock, + stripTopLevelKeys, + stripTable, + }, +}; + +function startMain(api) { + const { pathToFileURL } = require("node:url"); + const token = ++startToken; + const paths = bridgePaths(); + const env = bridgeEnv(api); + + bridgeStatus = { + state: "starting", + endpoint: endpointFromEnv(env), + model: BRIDGE_MODEL, + qoderModel: env.QODER_MODEL, + catalogPath: paths.catalogPath, + }; + + void import(`${pathToFileURL(paths.serverPath).href}?reload=${Date.now()}`) + .then(async (mod) => { + if (token !== startToken) return; + + const config = mod.loadConfig(env); + const configResult = ensureCodexConfig(config, paths.catalogPath, api); + const server = mod.createServer({ config }); + + try { + await listen(server, config.host, config.port); + } catch (error) { + if (error && error.code === "EADDRINUSE") { + const health = await fetchHealth(config.host, config.port); + if (health && health.bridge === "qoderworkcn-responses-bridge") { + bridgeStatus = statusFromConfig("running-external", config, paths, configResult, health); + api.log.info( + `QoderWork CN bridge already listening at ${bridgeStatus.endpoint}; using existing server.`, + ); + return; + } + } + throw error; + } + + if (token !== startToken) { + server.close(); + return; + } + + bridgeServer = server; + bridgeStatus = statusFromConfig("running", config, paths, configResult); + api.log.info( + `QoderWork CN bridge listening at ${bridgeStatus.endpoint} with ${config.qoderModel}.`, + ); + if (configResult.changed) { + api.log.info(`Updated Codex config at ${configResult.configPath}.`); + } + }) + .catch((error) => { + bridgeStatus = { + ...bridgeStatus, + state: "failed", + error: error && error.message ? error.message : String(error), + }; + api.log.error("QoderWork CN bridge failed to start:", error); + }); +} + +function startRenderer(api) { + if (!api.settings) return; + + settingsHandle = api.settings.registerPage({ + id: "main", + title: "QoderWork CN Bridge", + description: "Local Responses bridge status.", + render(root) { + root.innerHTML = ""; + + const page = document.createElement("div"); + page.className = "flex max-w-3xl flex-col gap-4"; + + const header = document.createElement("div"); + header.className = "flex h-toolbar items-center justify-between gap-3"; + + const titleWrap = document.createElement("div"); + titleWrap.className = "flex min-w-0 flex-col gap-1"; + const title = document.createElement("div"); + title.className = "text-base font-medium text-token-text-primary"; + title.textContent = "QoderWork CN"; + const subtitle = document.createElement("div"); + subtitle.className = "text-sm text-token-text-secondary"; + subtitle.textContent = "qmodel / Qwen3.7-Max"; + titleWrap.append(title, subtitle); + + const refresh = document.createElement("button"); + refresh.type = "button"; + refresh.className = + "border-token-border bg-token-foreground/5 hover:bg-token-foreground/10 " + + "h-token-button-composer rounded-md border px-3 text-sm text-token-text-primary cursor-interaction"; + refresh.textContent = "Refresh"; + + header.append(titleWrap, refresh); + + const card = document.createElement("div"); + card.className = + "border-token-border flex flex-col divide-y-[0.5px] divide-token-border rounded-lg border"; + card.style.backgroundColor = "var(--color-background-panel, var(--color-token-bg-fog))"; + + const statusValue = textValue("Checking"); + const cliValue = textValue("Checking"); + const rendererPort = resolveBridgePort(api); + const endpointValue = textValue(`http://${DEFAULT_HOST}:${rendererPort}/v1`); + const modelValue = textValue(BRIDGE_MODEL); + const laneValue = textValue(QODER_MODEL); + const timeoutValue = textValue(formatDuration(DEFAULT_TIMEOUT_MS)); + const toolsValue = textValue("Checking"); + const profileValue = textValue("Codex++ isolated profile"); + + card.append( + settingRow("Bridge", "Local HTTP health", statusValue), + settingRow("Endpoint", "OpenAI Responses-compatible base URL", endpointValue), + settingRow("Codex model", "Model id used by Codex", modelValue), + settingRow("Qoder lane", "QoderWork CN internal model selector", laneValue), + settingRow("Timeout", "Maximum time for one Qoder CLI request", timeoutValue), + settingRow("Tool bridge", "Structured Responses tool-call passthrough", toolsValue), + settingRow("Profile", "Managed Codex home for this bridge", profileValue), + settingRow("Qoder CLI", "Detected by the bridge process", cliValue), + ); + + const usageCard = document.createElement("div"); + usageCard.className = + "border-token-border flex flex-col divide-y-[0.5px] divide-token-border rounded-lg border"; + usageCard.style.backgroundColor = "var(--color-background-panel, var(--color-token-bg-fog))"; + + const todayTokensValue = textValue("0 tokens"); + const monthTokensValue = textValue("0 tokens"); + const requestValue = textValue("0 requests"); + const costValue = textValue("Pricing unset"); + const ledgerValue = textValue("Checking"); + const usageVisual = createUsageVisual(); + + usageCard.append( + usageVisual.root, + settingRow("CodexBar today", "QoderWork bridge usage recorded today", todayTokensValue), + settingRow("CodexBar 30d", "CodexBar-style rolling usage summary", monthTokensValue), + settingRow("Requests", "Completed Responses calls in the usage ledger", requestValue), + settingRow("Estimated cost", "Only shown when QODERWORKCN_*_USD_PER_1M is set", costValue), + settingRow("Ledger", "JSONL source for downstream CodexBar-style tools", ledgerValue), + ); + + page.append(header, card, usageCard); + root.append(page); + + const updateHealth = async () => { + refresh.disabled = true; + statusValue.textContent = "Checking"; + try { + const response = await fetch(`http://${DEFAULT_HOST}:${rendererPort}/health`, { + cache: "no-store", + }); + const body = await response.json(); + if (!response.ok || !body.ok) { + throw new Error(body && body.error ? body.error.message : "Health check failed"); + } + statusValue.textContent = "Ready"; + cliValue.textContent = body.qoderCliExists ? "Found" : "Missing"; + modelValue.textContent = body.model || BRIDGE_MODEL; + laneValue.textContent = body.qoderModel || QODER_MODEL; + timeoutValue.textContent = formatDuration(body.timeoutMs ?? DEFAULT_TIMEOUT_MS); + toolsValue.textContent = body.toolBridge + ? `${body.toolBridge}${body.supportsParallelToolCalls ? " / parallel" : ""}` + : "Unavailable"; + profileValue.textContent = body.codexHome || "Codex++ isolated profile"; + ledgerValue.textContent = body.usageLedgerPath || "Usage ledger pending"; + + const usageResponse = await fetch(`http://${DEFAULT_HOST}:${rendererPort}/usage?days=30`, { + cache: "no-store", + }); + const usage = await usageResponse.json(); + if (!usageResponse.ok) { + throw new Error(usage && usage.error ? usage.error.message : "Usage check failed"); + } + todayTokensValue.textContent = formatTokens(usage.sessionTokens || 0); + monthTokensValue.textContent = formatTokens(usage.last30DaysTokens || 0); + requestValue.textContent = `${usage.totals && usage.totals.requestCount ? usage.totals.requestCount : 0} requests`; + costValue.textContent = usage.pricingConfigured + ? formatUSD(usage.last30DaysCostUSD) + : "Pricing unset"; + ledgerValue.title = usage.ledgerPath || ""; + renderUsageVisual(usageVisual, usage); + } catch (error) { + statusValue.textContent = "Unavailable"; + cliValue.textContent = "Unknown"; + toolsValue.textContent = "Unavailable"; + todayTokensValue.textContent = "Unavailable"; + monthTokensValue.textContent = "Unavailable"; + requestValue.textContent = "Unavailable"; + costValue.textContent = "Unavailable"; + renderUsageUnavailable(usageVisual); + } finally { + refresh.disabled = false; + } + }; + + refresh.addEventListener("click", updateHealth); + void updateHealth(); + if (healthTimer) clearInterval(healthTimer); + healthTimer = setInterval(updateHealth, 10000); + + return () => { + refresh.removeEventListener("click", updateHealth); + if (healthTimer) { + clearInterval(healthTimer); + healthTimer = null; + } + }; + }, + }); +} + +function textValue(text) { + const value = document.createElement("div"); + value.className = "max-w-md truncate text-right text-sm text-token-text-primary"; + value.textContent = text; + return value; +} + +function createUsageVisual() { + const root = document.createElement("div"); + root.className = "flex flex-col gap-3 p-3"; + + const header = document.createElement("div"); + header.className = "flex items-center justify-between gap-3"; + + const title = document.createElement("div"); + title.className = "text-sm font-medium text-token-text-primary"; + title.textContent = "Usage visualization"; + + const updated = document.createElement("div"); + updated.className = "truncate text-right text-xs text-token-text-secondary"; + updated.textContent = "Waiting for bridge"; + + header.append(title, updated); + + const metrics = document.createElement("div"); + metrics.className = "grid grid-cols-2 gap-3 md:grid-cols-4"; + + const today = metricCell("Today", "0", "tokens"); + const last30 = metricCell("30 days", "0", "tokens"); + const requests = metricCell("Requests", "0", "completed"); + const cost = metricCell("Cost", "—", "estimate"); + metrics.append(today.root, last30.root, requests.root, cost.root); + + const chart = document.createElement("div"); + chart.className = "flex min-h-[92px] flex-col gap-2"; + + const empty = document.createElement("div"); + empty.className = "py-6 text-sm text-token-text-secondary"; + empty.textContent = "No usage records yet."; + chart.append(empty); + + root.append(header, metrics, chart); + return { root, updated, today, last30, requests, cost, chart }; +} + +function metricCell(label, value, detail) { + const root = document.createElement("div"); + root.className = "flex min-w-0 flex-col gap-1"; + + const labelEl = document.createElement("div"); + labelEl.className = "truncate text-xs text-token-text-secondary"; + labelEl.textContent = label; + + const valueEl = document.createElement("div"); + valueEl.className = "truncate text-lg font-medium text-token-text-primary"; + valueEl.textContent = value; + + const detailEl = document.createElement("div"); + detailEl.className = "truncate text-xs text-token-text-secondary"; + detailEl.textContent = detail; + + root.append(labelEl, valueEl, detailEl); + return { root, value: valueEl, detail: detailEl }; +} + +function renderUsageVisual(view, usage) { + const totals = usage && usage.totals ? usage.totals : {}; + const daily = Array.isArray(usage && usage.daily) ? usage.daily : []; + const recent = daily.slice(-30); + const maxTokens = Math.max(1, ...recent.map((item) => Number(item.totalTokens || 0))); + + view.updated.textContent = usage && usage.updatedAt + ? `Updated ${formatDateTime(usage.updatedAt)}` + : "Ledger has no completed requests"; + view.today.value.textContent = compactNumber(usage && usage.sessionTokens); + view.last30.value.textContent = compactNumber(usage && usage.last30DaysTokens); + view.requests.value.textContent = compactNumber(totals.requestCount); + view.cost.value.textContent = usage && usage.pricingConfigured + ? formatUSD(usage.last30DaysCostUSD) + : "—"; + view.cost.detail.textContent = usage && usage.pricingConfigured ? "USD estimate" : "pricing unset"; + + view.chart.innerHTML = ""; + if (recent.length === 0) { + const empty = document.createElement("div"); + empty.className = "py-6 text-sm text-token-text-secondary"; + empty.textContent = "No usage records yet."; + view.chart.append(empty); + return; + } + + for (const item of recent) { + const row = document.createElement("div"); + row.className = "grid items-center gap-2"; + row.style.gridTemplateColumns = "4.5rem minmax(0, 1fr) 6rem"; + + const date = document.createElement("div"); + date.className = "truncate text-xs text-token-text-secondary"; + date.textContent = shortDate(item.date); + + const track = document.createElement("div"); + track.className = "h-2 overflow-hidden rounded-sm"; + track.style.backgroundColor = "color-mix(in srgb, var(--color-token-text-secondary) 16%, transparent)"; + + const bar = document.createElement("div"); + bar.className = "h-full rounded-sm"; + bar.style.width = `${Math.max(2, Math.round((Number(item.totalTokens || 0) / maxTokens) * 100))}%`; + bar.style.backgroundColor = "var(--color-token-text-primary)"; + track.append(bar); + + const value = document.createElement("div"); + value.className = "truncate text-right text-xs text-token-text-secondary"; + value.textContent = `${compactNumber(item.totalTokens)} tok`; + + row.append(date, track, value); + view.chart.append(row); + } +} + +function renderUsageUnavailable(view) { + view.updated.textContent = "Bridge unavailable"; + view.today.value.textContent = "—"; + view.last30.value.textContent = "—"; + view.requests.value.textContent = "—"; + view.cost.value.textContent = "—"; + view.cost.detail.textContent = "unavailable"; + view.chart.innerHTML = ""; + const empty = document.createElement("div"); + empty.className = "py-6 text-sm text-token-text-secondary"; + empty.textContent = "Start the isolated QoderWork bridge to view usage."; + view.chart.append(empty); +} + +function formatTokens(value) { + const count = Number(value || 0); + if (!Number.isFinite(count)) return "0 tokens"; + return `${count.toLocaleString()} tokens`; +} + +function compactNumber(value) { + const count = Number(value || 0); + if (!Number.isFinite(count)) return "0"; + return new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(count); +} + +function formatUSD(value) { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return `$${value.toFixed(value >= 1 ? 2 : 6)}`; +} + +function formatDateTime(value) { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return "Unknown"; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function shortDate(value) { + const date = new Date(`${value}T00:00:00`); + if (!Number.isFinite(date.getTime())) return String(value || ""); + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function formatDuration(ms) { + const value = Number(ms || 0); + if (!Number.isFinite(value) || value < 0) return "Unknown"; + if (value === 0) return "No bridge timeout"; + const minutes = Math.round(value / 60000); + if (minutes >= 60) { + const hours = value / 3600000; + return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} hr`; + } + return minutes >= 1 ? `${minutes} min` : `${Math.round(value / 1000)} sec`; +} + +function settingRow(labelText, descriptionText, control) { + const row = document.createElement("div"); + row.className = "flex items-center justify-between gap-4 p-3"; + + const left = document.createElement("div"); + left.className = "flex min-w-0 flex-col gap-1"; + + const label = document.createElement("div"); + label.className = "min-w-0 text-sm text-token-text-primary"; + label.textContent = labelText; + + const description = document.createElement("div"); + description.className = "text-token-text-secondary min-w-0 text-sm"; + description.textContent = descriptionText; + + left.append(label, description); + row.append(left, control); + return row; +} + +function bridgePaths() { + const path = require("node:path"); + const bridgeDir = path.join(__dirname, "bridge"); + return { + bridgeDir, + serverPath: path.join(bridgeDir, "src", "server.mjs"), + catalogPath: path.join(bridgeDir, "models_catalog.json"), + }; +} + +function bridgeEnv(api) { + const port = resolveBridgePort(api); + safeStorageSet(api, "port", String(port)); + const codexHome = resolveCodexHome(api); + return { + ...process.env, + QODER_BRIDGE_HOST: process.env.QODER_BRIDGE_HOST || DEFAULT_HOST, + QODER_BRIDGE_PORT: String(port), + QODERWORKCN_BRIDGE_PORT: String(port), + QODER_BRIDGE_TIMEOUT_MS: resolveBridgeTimeoutMs(), + QODER_BRIDGE_MODEL: process.env.QODER_BRIDGE_MODEL || BRIDGE_MODEL, + QODER_MODEL: process.env.QODER_MODEL || QODER_MODEL, + QODERWORKCN_CODEX_HOME: codexHome, + }; +} + +function resolveBridgePort(api) { + const stored = + api && api.storage && typeof api.storage.get === "function" + ? api.storage.get("port", "") + : ""; + return normalizePort( + envValue("QODERWORKCN_BRIDGE_PORT") || + envValue("QODER_BRIDGE_PORT") || + stored || + DEFAULT_PORT, + ); +} + +function envValue(name) { + if (typeof process === "undefined" || !process || !process.env) return ""; + return process.env[name] || ""; +} + +function resolveBridgeTimeoutMs() { + const raw = envValue("QODER_BRIDGE_TIMEOUT_MS"); + if (!raw) return String(DEFAULT_TIMEOUT_MS); + const parsed = Number.parseInt(String(raw), 10); + if (!Number.isFinite(parsed) || parsed < 0) return String(DEFAULT_TIMEOUT_MS); + if (parsed === 0) return "0"; + return String(Math.max(parsed, DEFAULT_TIMEOUT_MS)); +} + +function safeStorageSet(api, key, value) { + try { + if (api && api.storage && typeof api.storage.set === "function") { + api.storage.set(key, value); + } + } catch {} +} + +function normalizePort(value) { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) return DEFAULT_PORT; + return parsed; +} + +function endpointFromEnv(env) { + return `http://${env.QODER_BRIDGE_HOST || DEFAULT_HOST}:${normalizePort(env.QODER_BRIDGE_PORT)}/v1`; +} + +function listen(server, host, port) { + return new Promise((resolve, reject) => { + const onError = (error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, host); + }); +} + +function stopBridge() { + const server = bridgeServer; + bridgeServer = null; + bridgeStatus = { ...bridgeStatus, state: "stopped" }; + if (server) { + try { + server.close(); + } catch {} + } +} + +function statusFromConfig(state, config, paths, configResult, health) { + const fs = require("node:fs"); + return { + state, + endpoint: `http://${config.host}:${config.port}/v1`, + health: `http://${config.host}:${config.port}/health`, + model: config.bridgeModel, + qoderModel: config.qoderModel, + qoderCli: config.qoderCli, + qoderCliExists: health ? health.qoderCliExists : fs.existsSync(config.qoderCli), + catalogPath: paths.catalogPath, + codexConfigPath: configResult.configPath, + codexProfilePath: configResult.profilePath, + }; +} + +function fetchHealth(host, port) { + const http = require("node:http"); + return new Promise((resolve) => { + const req = http.get( + { + hostname: host, + port, + path: "/health", + timeout: 1500, + }, + (res) => { + let raw = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + raw += chunk; + }); + res.on("end", () => { + try { + resolve(JSON.parse(raw)); + } catch { + resolve(null); + } + }); + }, + ); + req.on("error", () => resolve(null)); + req.on("timeout", () => { + req.destroy(); + resolve(null); + }); + }); +} + +function ensureCodexConfig(config, catalogPath, api) { + const fs = require("node:fs"); + const path = require("node:path"); + const codexHome = resolveCodexHome(api); + fs.mkdirSync(codexHome, { recursive: true }); + + const configPath = path.join(codexHome, "config.toml"); + const profilePath = path.join(codexHome, "qoderworkcn.config.toml"); + const options = { + providerId: PROVIDER_ID, + providerName: "QoderWork CN Bridge", + bridgeModel: config.bridgeModel, + baseUrl: `http://${config.host}:${config.port}/v1`, + catalogPath, + reasoningEffort: DEFAULT_REASONING_EFFORT, + }; + + const existing = readIfExists(configPath); + maybeBackupConfig(configPath, existing, api); + + const nextConfig = buildManagedCodexConfig(existing, options); + const configChanged = writeIfChanged(configPath, nextConfig); + + const nextProfile = buildProfileConfig(options); + const profileChanged = writeIfChanged(profilePath, nextProfile); + + return { + configPath, + profilePath, + changed: configChanged || profileChanged, + }; +} + +function resolveCodexHome(api) { + const path = require("node:path"); + const explicit = process.env.QODERWORKCN_CODEX_HOME || process.env.CODEX_HOME; + if (explicit && String(explicit).trim()) return path.resolve(String(explicit)); + + const dataDir = api && api.fs && api.fs.dataDir ? String(api.fs.dataDir) : ""; + if (dataDir && !dataDir.startsWith("")) { + return path.join(dataDir, DEFAULT_PROFILE_DIRNAME); + } + + const os = require("node:os"); + return path.join(os.homedir(), ".codex-qoderworkcn"); +} + +function maybeBackupConfig(configPath, existing, api) { + const fs = require("node:fs"); + if (!existing.trim()) return; + if (existing.includes(DEFAULT_MODEL_START) || existing.includes(PROVIDER_START)) return; + if (api.storage.get("configBackupPath", "")) return; + + const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14); + const backupPath = `${configPath}.backup-codexpp-qoderworkcn-${stamp}`; + try { + fs.copyFileSync(configPath, backupPath); + api.storage.set("configBackupPath", backupPath); + } catch (error) { + api.log.warn("Could not back up Codex config before QoderWork CN update:", error); + } +} + +function buildManagedCodexConfig(existing, options) { + let body = stripManagedBlock(existing, DEFAULT_MODEL_START, DEFAULT_MODEL_END); + body = stripManagedBlock(body, PROVIDER_START, PROVIDER_END); + body = stripTable(body, `model_providers.${options.providerId}`); + body = stripTopLevelKeys(body, ROOT_MODEL_KEYS).trim(); + + const parts = [buildDefaultModelBlock(options)]; + if (body) parts.push(body); + parts.push(buildProviderBlock(options)); + return `${parts.join("\n\n")}\n`; +} + +function buildProfileConfig(options) { + return `${buildDefaultModelBlock(options)}\n\n${buildProviderBlock(options)}\n`; +} + +function buildDefaultModelBlock(options) { + return [ + DEFAULT_MODEL_START, + `model = ${tomlString(options.bridgeModel)}`, + `model_provider = ${tomlString(options.providerId)}`, + `model_reasoning_effort = ${tomlString(options.reasoningEffort)}`, + `model_catalog_json = ${tomlString(options.catalogPath)}`, + DEFAULT_MODEL_END, + ].join("\n"); +} + +function buildProviderBlock(options) { + return [ + PROVIDER_START, + `[model_providers.${options.providerId}]`, + `name = ${tomlString(options.providerName)}`, + `base_url = ${tomlString(options.baseUrl)}`, + 'wire_api = "responses"', + "request_max_retries = 0", + "stream_max_retries = 0", + "supports_websockets = false", + PROVIDER_END, + ].join("\n"); +} + +function stripManagedBlock(text, start, end) { + const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\s*`, "g"); + return String(text || "").replace(pattern, ""); +} + +function stripTopLevelKeys(toml, keys) { + const keySet = new Set(keys); + let inTopLevel = true; + const output = []; + + for (const line of String(toml || "").split(/\r?\n/)) { + if (/^\s*\[[^\]]+\]\s*$/.test(line)) { + inTopLevel = false; + } + if (inTopLevel) { + const match = /^\s*([A-Za-z0-9_.-]+)\s*=/.exec(line); + if (match && keySet.has(match[1])) continue; + } + output.push(line); + } + + return output.join("\n").replace(/^\n+/, ""); +} + +function stripTable(toml, tableName) { + const output = []; + let skipping = false; + const tablePattern = new RegExp(`^\\s*\\[${escapeRegExp(tableName)}\\]\\s*$`); + + for (const line of String(toml || "").split(/\r?\n/)) { + if (tablePattern.test(line)) { + skipping = true; + continue; + } + if (skipping && /^\s*\[[^\]]+\]\s*$/.test(line)) { + skipping = false; + } + if (!skipping) output.push(line); + } + + return output.join("\n").replace(/\n{3,}/g, "\n\n"); +} + +function readIfExists(file) { + const fs = require("node:fs"); + try { + return fs.readFileSync(file, "utf8"); + } catch { + return ""; + } +} + +function writeIfChanged(file, contents) { + const fs = require("node:fs"); + if (readIfExists(file) === contents) return false; + fs.writeFileSync(file, contents, "utf8"); + return true; +} + +function tomlString(value) { + return JSON.stringify(String(value)); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/tweaks/qoderworkcn-bridge/manifest.json b/tweaks/qoderworkcn-bridge/manifest.json new file mode 100644 index 0000000..52323c4 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/manifest.json @@ -0,0 +1,23 @@ +{ + "id": "com.qoderworkcn.responses-bridge", + "name": "QoderWork CN Bridge", + "version": "0.1.0", + "githubRepo": "qoderworkcn/codex-plusplus-bridge", + "description": "Runs a local OpenAI Responses-compatible bridge for QoderWork CN qmodel/Qwen3.7-Max.", + "author": { + "name": "QoderWork CN local integration" + }, + "tags": [ + "models", + "responses", + "qoderworkcn", + "windows" + ], + "scope": "both", + "main": "index.js", + "permissions": [ + "settings", + "filesystem", + "network" + ] +} diff --git a/tweaks/qoderworkcn-bridge/package.json b/tweaks/qoderworkcn-bridge/package.json new file mode 100644 index 0000000..954db00 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/package.json @@ -0,0 +1,11 @@ +{ + "name": "codex-plusplus-qoderworkcn-bridge", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "scripts": { + "test": "node --test test/*.test.cjs bridge/test/*.test.mjs", + "validate": "codexplusplus validate-tweak .", + "smoke": "node bridge/scripts/smoke.mjs" + } +} diff --git a/tweaks/qoderworkcn-bridge/test/resolve-codex-home.test.cjs b/tweaks/qoderworkcn-bridge/test/resolve-codex-home.test.cjs new file mode 100644 index 0000000..223a307 --- /dev/null +++ b/tweaks/qoderworkcn-bridge/test/resolve-codex-home.test.cjs @@ -0,0 +1,68 @@ +const assert = require("node:assert/strict"); +const path = require("node:path"); +const test = require("node:test"); + +const tweak = require("../index.js"); + +test("uses tweak data dir as the default isolated Codex home", () => { + const previousQoderHome = process.env.QODERWORKCN_CODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + delete process.env.QODERWORKCN_CODEX_HOME; + delete process.env.CODEX_HOME; + + try { + const actual = tweak.__private.resolveCodexHome({ + fs: { dataDir: "C:/Users/tester/AppData/Roaming/codex-plusplus/tweak-data/com.qoderworkcn.responses-bridge" }, + }); + + assert.equal( + actual, + path.join( + "C:/Users/tester/AppData/Roaming/codex-plusplus/tweak-data/com.qoderworkcn.responses-bridge", + "codex-home", + ), + ); + } finally { + restoreEnv("QODERWORKCN_CODEX_HOME", previousQoderHome); + restoreEnv("CODEX_HOME", previousCodexHome); + } +}); + +test("lets an explicit QoderWork profile override CODEX_HOME", () => { + const previousQoderHome = process.env.QODERWORKCN_CODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + process.env.QODERWORKCN_CODEX_HOME = "C:/profiles/qoder"; + process.env.CODEX_HOME = "C:/profiles/codex"; + + try { + assert.equal( + tweak.__private.resolveCodexHome({ fs: { dataDir: "C:/ignored" } }), + path.resolve("C:/profiles/qoder"), + ); + } finally { + restoreEnv("QODERWORKCN_CODEX_HOME", previousQoderHome); + restoreEnv("CODEX_HOME", previousCodexHome); + } +}); + +test("lets QoderWork bridge port override the legacy bridge port", () => { + const previousQoderPort = process.env.QODERWORKCN_BRIDGE_PORT; + const previousBridgePort = process.env.QODER_BRIDGE_PORT; + process.env.QODERWORKCN_BRIDGE_PORT = "38442"; + process.env.QODER_BRIDGE_PORT = "38441"; + + try { + assert.equal(tweak.__private.resolveBridgePort({ storage: { get: () => "" } }), 38442); + } finally { + restoreEnv("QODERWORKCN_BRIDGE_PORT", previousQoderPort); + restoreEnv("QODER_BRIDGE_PORT", previousBridgePort); + } +}); + +function restoreEnv(name, value) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +}