@galaxy-yearn/codex-deepseek-gateway 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,13 @@
2
2
 
3
3
  A small local gateway for using DeepSeek V4 models from Codex.
4
4
 
5
- Codex sends OpenAI `Responses API` requests to the local gateway. The gateway converts them to DeepSeek-compatible `Chat Completions`, calls DeepSeek, then converts the answer back to Responses objects or streaming `response.*` events.
5
+ Codex keeps sending OpenAI `Responses API` requests to a local endpoint. The gateway converts those requests to DeepSeek-compatible `Chat Completions`, calls DeepSeek, then converts the result back to Responses JSON or streaming `response.*` events.
6
+
7
+ NPM package: [@galaxy-yearn/codex-deepseek-gateway](https://www.npmjs.com/package/@galaxy-yearn/codex-deepseek-gateway)
6
8
 
7
9
  The runtime uses only the Node.js standard library. It installs under `~/.codex/deepseek-gateway`, runs as a detached background process, and does not edit your existing Codex config.
8
10
 
9
- Use this if you want Codex to keep its normal Responses API client path while the actual model is DeepSeek, including DeepSeek thinking output and optional web search emulation through Tavily and Firecrawl.
11
+ Use this when you want Codex to stay on its normal Responses API path while the actual model is DeepSeek, including DeepSeek thinking output, function calls, local session resume help, and optional web search emulation through Tavily and Firecrawl.
10
12
 
11
13
  ## Requirements
12
14
 
@@ -22,82 +24,36 @@ Run:
22
24
  npx @galaxy-yearn/codex-deepseek-gateway install
23
25
  ```
24
26
 
25
- This copies the gateway to:
27
+ This copies the runtime to:
26
28
 
27
29
  ```text
28
30
  ~/.codex/deepseek-gateway
29
31
  ```
30
32
 
31
- It also creates:
33
+ It creates two local config files:
32
34
 
33
35
  ```text
34
36
  ~/.codex/deepseek-gateway/config/gateway.local.json
35
- ```
36
-
37
- Open that file and replace `sk-REPLACE_ME` with your DeepSeek API key.
38
-
39
- The install also creates:
40
-
41
- ```text
42
37
  ~/.codex/deepseek-gateway/config/model-aliases.json
43
38
  ```
44
39
 
45
- That file controls which model IDs the gateway exposes on `GET /v1/models`. You only need to edit it if you want to add or rename gateway-facing model aliases.
46
-
47
- By default, the gateway serves the local alias list directly. If you also want to merge DeepSeek's upstream `/models` list, set `"fetchUpstreamModels": true` in `gateway.local.json`. Leaving it `false` keeps `/v1/models` lighter and more predictable.
48
-
49
- ### Web Search
50
-
51
- Web search is off by default. To let Codex's native `web_search` tool work while the active model is DeepSeek, configure Tavily in `gateway.local.json`:
52
-
53
- ```json
54
- "tavilyApiKey": "tvly-REPLACE_ME",
55
- "tavilyWebSearchEnabled": true
56
- ```
57
-
58
- To add opened-page reading after search, also configure Firecrawl:
40
+ Put your DeepSeek API key in `gateway.local.json`:
59
41
 
60
42
  ```json
61
- "firecrawlApiKey": "fc-REPLACE_ME",
62
- "firecrawlWebFetchEnabled": true,
63
- "firecrawlAutoScrapeTopResults": 3
43
+ "upstreamApiKey": "sk-..."
64
44
  ```
65
45
 
66
- Codex does not need MCP, a different tool name, or prompt changes. It can keep sending the normal Responses `web_search` or `web_search_preview` tool. The gateway converts that request to internal Chat Completions tools for DeepSeek:
67
-
68
- - `tavily_search` searches the web and returns citation-ready snippets.
69
- - `firecrawl_open_page` reads a specific public page.
70
- - `firecrawl_find_in_page` reads a page with a focused query.
71
-
72
- DeepSeek receives a compact text summary it can read directly:
73
-
74
- - the search query
75
- - an optional Tavily answer summary
76
- - numbered sources
77
- - each source's title, URL, optional date, relevance score, and snippet
78
- - optional Firecrawl opened-page title, summary, relevant matches, cleaned markdown excerpt, and page links
79
-
80
- Codex receives Responses-compatible `web_search_call` items, final assistant messages, and URL citation annotations when the final text cites a matching source marker. If Codex replays prior `web_search_call` items as conversation state, the gateway preserves them as search records instead of sending broken Chat tool calls upstream.
81
-
82
- The gateway does not pass Tavily's raw response object, images, screenshots, or provider-only fields to DeepSeek. Tavily is called with `include_raw_content: false`. Firecrawl returns cleaned page text, defaults to main content, removes base64 images, rejects local/private URLs, and truncates page text before it reaches the model.
46
+ `model-aliases.json` controls the gateway-facing model IDs exposed on `GET /v1/models` and used by the `sessions` picker. Edit it only if you want to add or rename model aliases.
83
47
 
84
- If the key is already configured, `install` also starts the gateway. If this is your first install, run `start` after adding the key:
48
+ If the key is already configured, `install` also starts the gateway. If this is your first install, add the key and then run:
85
49
 
86
50
  ```sh
87
51
  npx @galaxy-yearn/codex-deepseek-gateway start
88
52
  ```
89
53
 
90
- Check that it is running:
91
-
92
- ```sh
93
- npx @galaxy-yearn/codex-deepseek-gateway status
94
- ```
95
-
96
- You should see `"reachable": true`.
97
-
98
54
  ## Configure Codex
99
55
 
100
- Edit `~/.codex/config.toml` and set the active provider to the gateway:
56
+ Edit `~/.codex/config.toml`:
101
57
 
102
58
  ```toml
103
59
  model_provider = "deepseek-gateway"
@@ -114,23 +70,21 @@ wire_api = "responses"
114
70
 
115
71
  Use `deepseek-v4-pro` instead of `deepseek-v4-flash` if you want the pro model.
116
72
 
117
- `config.toml` can change:
118
-
119
- - the active provider via `model_provider`
120
- - the active model ID via `model`
121
- - the provider label via `[model_providers.<id>].name`
122
-
123
- It does not define a separate display name for each model entry inside Codex `/model`. If your Codex build reads custom provider models from `/v1/models`, the visible model names come from the gateway's model IDs in `~/.codex/deepseek-gateway/config/model-aliases.json`.
73
+ Restart Codex after changing `config.toml`.
124
74
 
125
- `model_supports_reasoning_summaries = true` and `model_reasoning_summary = "auto"` tell Codex to use its normal thinking UI path. When DeepSeek returns `reasoning_content`, the gateway keeps the raw text as `reasoning_text` and mirrors it into a display-safe `summary_text` value for Codex.
75
+ ## Start And Verify
126
76
 
127
- When DeepSeek thinking is enabled, the gateway buffers visible assistant text and tool-call output until the upstream response completes. It then emits reasoning before the answer or tool calls. The tradeoff is higher first-token latency while thinking is on.
77
+ Start, stop, and inspect the local background process:
128
78
 
129
- Restart Codex after changing `config.toml`.
79
+ ```sh
80
+ npx @galaxy-yearn/codex-deepseek-gateway start
81
+ npx @galaxy-yearn/codex-deepseek-gateway stop
82
+ npx @galaxy-yearn/codex-deepseek-gateway status
83
+ ```
130
84
 
131
- ## Verify
85
+ `status` should show `"reachable": true`.
132
86
 
133
- Run:
87
+ Run `doctor` to check the active Codex config and the DeepSeek request shape the gateway will send:
134
88
 
135
89
  ```sh
136
90
  npx @galaxy-yearn/codex-deepseek-gateway doctor
@@ -139,30 +93,43 @@ npx @galaxy-yearn/codex-deepseek-gateway doctor
139
93
  Important fields:
140
94
 
141
95
  - `codexConfigUsingGateway` should be `true`
142
- - `codexModel` should be your DeepSeek model, for example `deepseek-v4-pro`
96
+ - `codexModel` should be your gateway model, for example `deepseek-v4-pro`
143
97
  - `codexReasoningEffort` should match `model_reasoning_effort`
144
98
  - `deepseekThinking` shows the DeepSeek `thinking` payload
145
99
  - `deepseekReasoningEffort` shows the DeepSeek effort sent upstream
146
- - `reasoningDisplayMode` shows whether the gateway will emit `summary`, `disabled`, or `hidden`
147
100
  - `gatewayEmitsReasoningSummary` should be `true` when DeepSeek thinking is enabled
148
- - `codexSummaryConfigured` should be `true` so Codex TUI is configured to show summaries
149
- - `tavilyWebSearchReady` should be `true` if you want Codex `web_search` to route through Tavily
150
- - `firecrawlWebFetchReady` should be `true` if you want Tavily search results to include opened-page excerpts
101
+ - `tavilyWebSearchReady` and `firecrawlWebFetchReady` show whether optional web search backends are usable
151
102
 
152
- For example, with:
103
+ Avoid running Codex through a proxy while using the local gateway. Some proxy clients intercept `http://127.0.0.1:3000` and can break local gateway requests.
153
104
 
154
- ```toml
155
- model_reasoning_effort = "xhigh"
105
+ ## Models
106
+
107
+ The installed `config/model-aliases.json` starts with:
108
+
109
+ ```json
110
+ {
111
+ "deepseek-v4-flash": {
112
+ "model": "deepseek-v4-flash",
113
+ "thinking": "auto"
114
+ },
115
+ "deepseek-v4-pro": {
116
+ "model": "deepseek-v4-pro",
117
+ "thinking": "auto"
118
+ }
119
+ }
156
120
  ```
157
121
 
158
- `doctor` should show:
122
+ The gateway serves these aliases directly on `GET /v1/models`. If you also want to merge DeepSeek's upstream `/models` list, set this in `gateway.local.json`:
159
123
 
160
124
  ```json
161
- "deepseekThinking": { "type": "enabled" },
162
- "deepseekReasoningEffort": "max"
125
+ "fetchUpstreamModels": true
163
126
  ```
164
127
 
165
- ## Reasoning Mapping
128
+ Whether Codex TUI `/model` shows custom provider models depends on the Codex client build. `config.toml` and the `sessions` command remain the reliable ways to choose a model.
129
+
130
+ ## Reasoning
131
+
132
+ Codex reasoning effort maps to DeepSeek V4 thinking mode:
166
133
 
167
134
  | Codex `model_reasoning_effort` | DeepSeek request |
168
135
  | --- | --- |
@@ -171,11 +138,73 @@ model_reasoning_effort = "xhigh"
171
138
  | `high` | `thinking.type = enabled`, `reasoning_effort = high` |
172
139
  | `xhigh` | `thinking.type = enabled`, `reasoning_effort = max` |
173
140
 
174
- When thinking is enabled, every non-empty DeepSeek `reasoning_content` value is converted into Responses reasoning output:
141
+ `model_supports_reasoning_summaries = true` and `model_reasoning_summary = "auto"` tell Codex to use its normal thinking UI path. When DeepSeek returns `reasoning_content`, the gateway keeps the raw text as `reasoning_text` and mirrors it into `summary_text` with a `Reasoning` heading for Codex TUI compatibility.
142
+
143
+ When thinking is enabled, the gateway intentionally buffers visible assistant text and tool-call output until the upstream response completes, then emits reasoning first. This improves thinking order in Codex at the cost of higher first-token latency.
144
+
145
+ ## Web Search
175
146
 
176
- - `reasoning_summary_text.delta` for Codex's native summary-style thinking UI
147
+ Web search is off by default. To let Codex's native `web_search` or `web_search_preview` tool work while DeepSeek is active, configure the optional backends in `gateway.local.json`.
177
148
 
178
- The completed response keeps the original DeepSeek text as `reasoning_text`. The visible summary path uses a plain-text rendering of the same text with common Markdown markers removed and a `Reasoning` heading for Codex TUI compatibility. This also applies to Tavily/Firecrawl-backed `web_search` turns.
149
+ Tavily provides search results:
150
+
151
+ ```json
152
+ "tavilyApiKey": "tvly-...",
153
+ "tavilyWebSearchEnabled": true
154
+ ```
155
+
156
+ Firecrawl provides opened-page reading and focused page lookup:
157
+
158
+ ```json
159
+ "firecrawlApiKey": "fc-...",
160
+ "firecrawlWebFetchEnabled": true,
161
+ "firecrawlAutoScrapeTopResults": 3
162
+ ```
163
+
164
+ Codex does not need MCP, a different tool name, or prompt changes. The gateway converts Codex web search requests into internal Chat Completions tools:
165
+
166
+ - `tavily_search` searches the web and returns citation-ready snippets.
167
+ - `firecrawl_open_page` reads a specific public page.
168
+ - `firecrawl_find_in_page` reads a page with a focused query.
169
+
170
+ DeepSeek receives compact text context containing the search query, sources, snippets, and optional opened-page excerpts. Codex receives Responses-compatible `web_search_call` items, final assistant messages, and URL citation annotations when the final text cites a matching source marker.
171
+
172
+ The web payload is intentionally text-focused. Tavily is called with `include_raw_content: false`; Firecrawl defaults to main content, removes base64 images, rejects local/private URLs, and truncates page text before it reaches the model.
173
+
174
+ ## Sessions
175
+
176
+ Open a cross-provider session picker from a project:
177
+
178
+ ```sh
179
+ npx @galaxy-yearn/codex-deepseek-gateway sessions
180
+ ```
181
+
182
+ The picker is read-only. It scans Codex local transcript files, lists sessions for the current project across providers, then runs:
183
+
184
+ ```sh
185
+ codex resume <session-id> -c model_provider=deepseek-gateway -c model=<model> -c model_reasoning_effort=<effort>
186
+ ```
187
+
188
+ Flow:
189
+
190
+ - choose a model from `~/.codex/deepseek-gateway/config/model-aliases.json`
191
+ - choose Codex reasoning effort
192
+ - choose the session to resume
193
+ - use `Up/Down` to select, `Enter` to confirm, `Left` to go back, and `Esc` to quit
194
+
195
+ Print copyable resume commands instead of opening the picker:
196
+
197
+ ```sh
198
+ npx @galaxy-yearn/codex-deepseek-gateway sessions --print
199
+ ```
200
+
201
+ Include sessions outside the current project:
202
+
203
+ ```sh
204
+ npx @galaxy-yearn/codex-deepseek-gateway sessions --all
205
+ ```
206
+
207
+ The command does not edit session files, change provider ownership, or change Codex's native resume picker filters. It only helps you find the hidden session id and resume it with explicit config overrides.
179
208
 
180
209
  ## Commands
181
210
 
@@ -185,6 +214,7 @@ npx @galaxy-yearn/codex-deepseek-gateway start
185
214
  npx @galaxy-yearn/codex-deepseek-gateway stop
186
215
  npx @galaxy-yearn/codex-deepseek-gateway status
187
216
  npx @galaxy-yearn/codex-deepseek-gateway doctor
217
+ npx @galaxy-yearn/codex-deepseek-gateway sessions
188
218
  npx @galaxy-yearn/codex-deepseek-gateway uninstall
189
219
  ```
190
220
 
@@ -199,48 +229,23 @@ If `start` returns without visible output on your terminal, run `status`; `"reac
199
229
  - text input and output
200
230
  - image, file, and audio content parts when DeepSeek accepts the corresponding Chat Completions shape
201
231
  - function tools and tool-call history
202
- - conservative schema-based repair for streamed and non-streamed tool arguments where DeepSeek returns stringified JSON values for fields that Codex declared as arrays, objects, booleans, or numbers
203
- - Codex `web_search` emulation through Tavily when `tavilyWebSearchEnabled` is true and `tavilyApiKey` is configured, with Responses-style `web_search_call` output and compact source snippets for DeepSeek
204
- - Firecrawl-backed opened-page reading for `web_search` when `firecrawlWebFetchEnabled` is true and `firecrawlApiKey` is configured, including automatic top-result scraping plus explicit `open_page` and `find_in_page` internal tool calls
205
- - Responses-style URL citation metadata for Tavily-backed answers when the final text contains matching source markers
206
232
  - DeepSeek thinking mode and `reasoning_content`
207
233
  - lightweight local `previous_response_id` / `conversation` history while the gateway process is running
208
- - `GET /v1/models` with local DeepSeek V4 aliases and optional upstream discovery
234
+ - `GET /v1/models` with local model aliases and optional upstream discovery
235
+ - optional Tavily/Firecrawl-backed `web_search` emulation
236
+ - read-only cross-provider session picker
209
237
 
210
238
  ## Limits
211
239
 
212
240
  Chat Completions is not a full Responses API replacement. Some Responses features have no equivalent upstream field.
213
241
 
214
- - Hosted tools such as file search, computer use, image generation, and code interpreter are represented as function-tool shims unless Codex executes matching tools locally. Web search is the only hosted tool the gateway can emulate directly, and only when Tavily is configured.
215
- - Tavily and Firecrawl web emulation is intentionally text-focused. It covers search, opened-page excerpts, page links, and find-in-page style matching; it does not expose raw HTML, screenshots, browser actions, crawl/map jobs, cookies, private network access, or provider-specific payloads through Codex `web_search`.
242
+ - Hosted tools such as file search, computer use, image generation, and code interpreter are represented as function-tool shims unless Codex executes matching tools locally. Web search is the only hosted tool the gateway can emulate directly.
243
+ - Tavily and Firecrawl web emulation is text-focused. It does not expose raw HTML, screenshots, browser actions, crawl/map jobs, cookies, private network access, or provider-specific payloads through Codex `web_search`.
216
244
  - URL citations are returned in the Responses metadata path. Whether they appear as clickable links in the terminal depends on the Codex client build and how it renders custom-provider citation annotations.
217
245
  - OpenAI `file_id` values are passed through; the gateway cannot fetch private OpenAI-hosted files.
218
246
  - In-memory conversation history is lost when the gateway restarts.
219
247
  - The gateway exposes model aliases on `/v1/models`, including aliases from `config/model-aliases.json`. Whether Codex TUI `/model` actually shows custom provider models depends on the Codex build. `config.toml` remains the reliable fallback.
220
248
 
221
- ## Local Testing Before Publish
222
-
223
- From this repository:
224
-
225
- ```sh
226
- npm test
227
- npm pack
228
- npx --yes --package ./<generated-tarball>.tgz codex-deepseek-gateway install --no-edit
229
- npx --yes --package ./<generated-tarball>.tgz codex-deepseek-gateway status
230
- npx --yes --package ./<generated-tarball>.tgz codex-deepseek-gateway doctor
231
- ```
232
-
233
- Replace `<generated-tarball>.tgz` with the filename printed by `npm pack`.
249
+ ## License
234
250
 
235
- For published usage, use the shorter package command shown in the install section.
236
-
237
- ## Publish
238
-
239
- When ready:
240
-
241
- ```sh
242
- npm login
243
- npm test
244
- npm pack --dry-run
245
- npm publish
246
- ```
251
+ MIT. See [LICENSE](LICENSE).
@@ -13,6 +13,7 @@ import { createRequire } from 'node:module';
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { loadConfig } from '../src/config.js';
16
+ import { sessions } from '../src/codex-sessions.js';
16
17
  import { toProviderChatCompletionsRequest } from '../src/protocol.js';
17
18
 
18
19
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -32,16 +33,25 @@ Usage:
32
33
  codex-deepseek-gateway stop
33
34
  codex-deepseek-gateway status
34
35
  codex-deepseek-gateway doctor
36
+ codex-deepseek-gateway sessions
35
37
  codex-deepseek-gateway uninstall
36
38
 
37
39
  Options:
38
40
  --dir <path> Install directory, defaults to ~/.codex/deepseek-gateway
39
41
  --no-edit Do not open the local config file after install
42
+ --all With sessions, include sessions outside the current project
43
+ --provider <id> With sessions, target model_provider override
44
+ --model <id> With sessions, target model override
45
+ --reasoning-effort <level>
46
+ With sessions, target Codex reasoning effort
47
+ --exec <id> With sessions, run the generated codex resume command
48
+ --limit <n> With sessions, max rows to print, defaults to 20
49
+ --print With sessions, print copyable resume commands instead of picker
40
50
  `);
41
51
  }
42
52
 
43
53
  function parseArgs(argv) {
44
- const options = { dir: defaultInstallDir(), noEdit: false };
54
+ const options = { dir: defaultInstallDir(), noEdit: false, all: false };
45
55
  const rest = [];
46
56
  for (let index = 0; index < argv.length; index += 1) {
47
57
  const arg = argv[index];
@@ -52,6 +62,35 @@ function parseArgs(argv) {
52
62
  index += 1;
53
63
  } else if (arg === '--no-edit' || arg === '-NoEdit') {
54
64
  options.noEdit = true;
65
+ } else if (arg === '--all') {
66
+ options.all = true;
67
+ } else if (arg === '--provider') {
68
+ const value = argv[index + 1];
69
+ if (!value) throw new Error(`${arg} requires a provider id`);
70
+ options.provider = value;
71
+ index += 1;
72
+ } else if (arg === '--model' || arg === '-m') {
73
+ const value = argv[index + 1];
74
+ if (!value) throw new Error(`${arg} requires a model id`);
75
+ options.model = value;
76
+ index += 1;
77
+ } else if (arg === '--reasoning-effort') {
78
+ const value = argv[index + 1];
79
+ if (!value) throw new Error(`${arg} requires a reasoning effort`);
80
+ options.reasoningEffort = value;
81
+ index += 1;
82
+ } else if (arg === '--exec') {
83
+ const value = argv[index + 1];
84
+ if (!value) throw new Error(`${arg} requires a session id`);
85
+ options.exec = value;
86
+ index += 1;
87
+ } else if (arg === '--limit') {
88
+ const value = Number(argv[index + 1]);
89
+ if (!Number.isInteger(value) || value < 1) throw new Error(`${arg} requires a positive integer`);
90
+ options.limit = value;
91
+ index += 1;
92
+ } else if (arg === '--print') {
93
+ options.print = true;
55
94
  } else {
56
95
  rest.push(arg);
57
96
  }
@@ -348,10 +387,7 @@ async function doctor(options) {
348
387
  firecrawlWebFetchEnabled: Boolean(config.firecrawlWebFetchEnabled),
349
388
  firecrawlWebFetchReady: Boolean(config.firecrawlWebFetchEnabled && config.firecrawlApiKey),
350
389
  firecrawlAutoScrapeTopResults: config.firecrawlAutoScrapeTopResults,
351
- reasoningSummaryHint: 'When DeepSeek thinking is enabled, the gateway maps every non-empty reasoning_content chunk into the Responses reasoning summary path. Keep model_supports_reasoning_summaries = true and model_reasoning_summary = "auto" so Codex TUI is configured to show summaries.',
352
- tavilyWebSearchHint: 'Set tavilyApiKey in gateway.local.json to route Codex web_search tools through Tavily while using DeepSeek.',
353
- firecrawlWebFetchHint: 'Set firecrawlApiKey and firecrawlWebFetchEnabled in gateway.local.json to add opened-page excerpts to Tavily-backed Codex web_search.',
354
- modelDiscoveryHint: 'The gateway exposes model aliases on /v1/models. Whether Codex TUI /model shows them depends on the Codex build.',
390
+ hint: 'The gateway exposes model aliases on /v1/models. Whether Codex TUI /model shows them depends on the Codex build.',
355
391
  }, null, 2));
356
392
  print('\n');
357
393
  }
@@ -368,7 +404,7 @@ async function main() {
368
404
  usage();
369
405
  return;
370
406
  }
371
- const commands = { install, start, stop, status, doctor, uninstall };
407
+ const commands = { install, start, stop, status, doctor, sessions, uninstall };
372
408
  const handler = commands[command];
373
409
  if (!handler) {
374
410
  throw new Error(`Unknown command: ${command}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@galaxy-yearn/codex-deepseek-gateway",
3
- "version": "0.1.2",
4
- "description": "Local Codex Responses API to DeepSeek Chat Completions gateway.",
3
+ "version": "0.1.3",
4
+ "description": "Local OpenAI Codex Responses API gateway for DeepSeek Chat Completions.",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
7
7
  "bin": {
@@ -27,7 +27,18 @@
27
27
  "openai",
28
28
  "responses-api",
29
29
  "chat-completions",
30
- "gateway"
30
+ "gateway",
31
+ "proxy",
32
+ "codex-cli",
33
+ "openai-responses",
34
+ "deepseek-v4",
35
+ "deepseek-v4-flash",
36
+ "deepseek-v4-pro",
37
+ "function-calling",
38
+ "tool-calls",
39
+ "web-search",
40
+ "tavily",
41
+ "firecrawl"
31
42
  ],
32
43
  "license": "MIT",
33
44
  "repository": {
@@ -0,0 +1,375 @@
1
+ import { spawn } from 'node:child_process';
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ openSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ readSync,
9
+ } from 'node:fs';
10
+ import { dirname, join, resolve } from 'node:path';
11
+ import { clearLine, clearScreenDown, cursorTo } from 'node:readline';
12
+ import { readCodexConfig } from './codex-config.js';
13
+
14
+ const DEFAULT_PROVIDER = 'deepseek-gateway';
15
+ const REASONING_EFFORTS = ['low', 'medium', 'high', 'xhigh'];
16
+ const TIME_WIDTH = 10;
17
+ const PROVIDER_WIDTH = 18;
18
+ const ID_WIDTH = 36;
19
+ const TITLE_WIDTH = 16;
20
+ const TABLE_INDENT = ' ';
21
+ const COLUMN_GAP = ' ';
22
+
23
+ function print(message = '') {
24
+ process.stdout.write(message);
25
+ }
26
+
27
+ function defaultCodexHome() {
28
+ return process.env.CODEX_HOME || join(process.env.USERPROFILE || process.env.HOME || process.cwd(), '.codex');
29
+ }
30
+
31
+ function readStart(file, bytes = 256 * 1024) {
32
+ const handle = openSync(file, 'r');
33
+ try {
34
+ const buffer = Buffer.alloc(bytes);
35
+ return buffer.subarray(0, readSync(handle, buffer, 0, bytes, 0)).toString('utf8');
36
+ } finally {
37
+ closeSync(handle);
38
+ }
39
+ }
40
+
41
+ function walkJsonl(dir, result = []) {
42
+ if (!existsSync(dir)) return result;
43
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
44
+ const path = join(dir, entry.name);
45
+ if (entry.isDirectory()) walkJsonl(path, result);
46
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) result.push(path);
47
+ }
48
+ return result;
49
+ }
50
+
51
+ function parseJsonLine(line) {
52
+ try {
53
+ return JSON.parse(line);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function readSessionIndex(codexHome) {
60
+ const file = join(codexHome, 'session_index.jsonl');
61
+ const byId = new Map();
62
+ if (!existsSync(file)) return byId;
63
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
64
+ if (!line.trim()) continue;
65
+ const row = parseJsonLine(line);
66
+ if (!row?.id) continue;
67
+ const prior = byId.get(row.id);
68
+ if (!prior || String(row.updated_at || '') > String(prior.updated_at || '')) byId.set(row.id, row);
69
+ }
70
+ return byId;
71
+ }
72
+
73
+ function textFromContent(content) {
74
+ if (!Array.isArray(content)) return '';
75
+ return content
76
+ .map((part) => part?.text || '')
77
+ .filter(Boolean)
78
+ .join(' ');
79
+ }
80
+
81
+ function firstUserPreview(lines) {
82
+ for (const line of lines.slice(1, 180)) {
83
+ const payload = parseJsonLine(line)?.payload;
84
+ if (payload?.type !== 'message' || payload.role !== 'user') continue;
85
+ const text = textFromContent(payload.content).trim().replace(/\s+/g, ' ');
86
+ if (!text || text.startsWith('<environment_context>') || text.startsWith('# AGENTS.md instructions')) continue;
87
+ return truncate(text, TITLE_WIDTH);
88
+ }
89
+ return '';
90
+ }
91
+
92
+ function truncate(text, max) {
93
+ return text.length > max ? `${text.slice(0, max - 1)}...` : text;
94
+ }
95
+
96
+ function pad(text, width) {
97
+ return truncate(String(text || ''), width).padEnd(width, ' ');
98
+ }
99
+
100
+ function formatTime(value) {
101
+ if (!value) return '';
102
+ return String(value).slice(0, 10);
103
+ }
104
+
105
+ function normalizePath(path) {
106
+ const resolved = resolve(path || '');
107
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
108
+ }
109
+
110
+ function findProjectRoot(start) {
111
+ let current = resolve(start);
112
+ while (true) {
113
+ if (existsSync(join(current, '.git'))) return current;
114
+ const parent = dirname(current);
115
+ if (parent === current) return resolve(start);
116
+ current = parent;
117
+ }
118
+ }
119
+
120
+ function isInsideProject(cwd, projectRoot) {
121
+ if (!cwd) return false;
122
+ const sessionCwd = normalizePath(cwd);
123
+ const root = normalizePath(projectRoot);
124
+ return sessionCwd === root || sessionCwd.startsWith(`${root}${process.platform === 'win32' ? '\\' : '/'}`);
125
+ }
126
+
127
+ function readSession(file, indexById) {
128
+ const lines = readStart(file).split(/\r?\n/).filter(Boolean);
129
+ const meta = parseJsonLine(lines[0])?.payload;
130
+ if (!meta?.id) return null;
131
+ const index = indexById.get(meta.id) || {};
132
+ return {
133
+ id: meta.id,
134
+ provider: meta.model_provider || '',
135
+ cwd: meta.cwd || '',
136
+ updatedAt: index.updated_at || meta.timestamp || '',
137
+ title: index.thread_name || firstUserPreview(lines) || '(untitled)',
138
+ };
139
+ }
140
+
141
+ function shellQuoteTomlString(key, value) {
142
+ return `-c ${key}=${value}`;
143
+ }
144
+
145
+ function resumeCommand(sessionId, context) {
146
+ return [
147
+ 'codex',
148
+ 'resume',
149
+ sessionId,
150
+ shellQuoteTomlString('model_provider', context.provider),
151
+ shellQuoteTomlString('model', context.model),
152
+ shellQuoteTomlString('model_reasoning_effort', context.reasoningEffort),
153
+ ].join(' ');
154
+ }
155
+
156
+ function readJsonObject(file) {
157
+ if (!existsSync(file)) return {};
158
+ const value = JSON.parse(readFileSync(file, 'utf8'));
159
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
160
+ }
161
+
162
+ function gatewayModels(installDir) {
163
+ return Object.keys(readJsonObject(join(installDir, 'config', 'model-aliases.json'))).sort();
164
+ }
165
+
166
+ function resolveSessionSelection(selection, sessionsList) {
167
+ if (/^\d+$/.test(selection)) {
168
+ const byIndex = sessionsList[Number(selection) - 1];
169
+ if (byIndex) return byIndex;
170
+ }
171
+ const matches = sessionsList.filter((session) => session.id.startsWith(selection));
172
+ return matches.length === 1 ? matches[0] : null;
173
+ }
174
+
175
+ function resumeArgs(session, context) {
176
+ return [
177
+ 'resume',
178
+ session.id,
179
+ '-c',
180
+ `model_provider=${JSON.stringify(context.provider)}`,
181
+ '-c',
182
+ `model=${JSON.stringify(context.model)}`,
183
+ '-c',
184
+ `model_reasoning_effort=${JSON.stringify(context.reasoningEffort)}`,
185
+ ];
186
+ }
187
+
188
+ async function runCodexResume(session, context) {
189
+ const child = spawn('codex', resumeArgs(session, context), {
190
+ stdio: 'inherit',
191
+ shell: process.platform === 'win32',
192
+ });
193
+ process.exitCode = await new Promise((resolveChild) => {
194
+ child.on('exit', (exitCode) => resolveChild(exitCode ?? 0));
195
+ });
196
+ }
197
+
198
+ function printSessions(sessionsList, options, context) {
199
+ const listed = sessionsList.slice(0, options.limit || 20);
200
+ print(`Codex sessions ${options.all ? `under ${context.codexHome}` : `for project ${context.projectRoot}`}\n`);
201
+ print(`Target: ${context.provider} / ${context.model} / ${context.reasoningEffort}\n\n`);
202
+ if (!listed.length) {
203
+ print('No matching sessions found.\n');
204
+ return;
205
+ }
206
+ print(`${TABLE_INDENT}${sessionHeader()}\n`);
207
+ print(`${TABLE_INDENT}${'-'.repeat(TIME_WIDTH)}${COLUMN_GAP}${'-'.repeat(PROVIDER_WIDTH)}${COLUMN_GAP}${'-'.repeat(ID_WIDTH)}${COLUMN_GAP}${'-'.repeat(TITLE_WIDTH)}\n`);
208
+ listed.forEach((session, index) => {
209
+ print(`${String(index + 1).padStart(2, ' ')} ${sessionRow(session)}\n`);
210
+ if (options.all) print(` cwd: ${session.cwd || '(unknown cwd)'}\n`);
211
+ print(` resume: ${resumeCommand(session.id, context)}\n\n`);
212
+ });
213
+ if (sessionsList.length > listed.length) print(`Showing ${listed.length} of ${sessionsList.length}. Use --limit ${sessionsList.length} or --all as needed.\n`);
214
+ }
215
+
216
+ function sessionHeader() {
217
+ return `${pad('Date', TIME_WIDTH)}${COLUMN_GAP}${pad('Provider', PROVIDER_WIDTH)}${COLUMN_GAP}${pad('Session ID', ID_WIDTH)}${COLUMN_GAP}Title`;
218
+ }
219
+
220
+ function sessionRow(session) {
221
+ return `${pad(formatTime(session.updatedAt), TIME_WIDTH)}${COLUMN_GAP}${pad(session.provider || '(unknown)', PROVIDER_WIDTH)}${COLUMN_GAP}${session.id}${COLUMN_GAP}${truncate(session.title, TITLE_WIDTH)}`;
222
+ }
223
+
224
+ function sessionRows(sessionsList) {
225
+ return sessionsList.map((session) => sessionRow(session));
226
+ }
227
+
228
+ function rowOffset(header) {
229
+ return header ? 4 : 2;
230
+ }
231
+
232
+ function renderRow(state, index) {
233
+ cursorTo(process.stdout, 0, rowOffset(state.header) + index);
234
+ clearLine(process.stdout, 0);
235
+ print(`${index === state.selected ? '>' : ' '} ${state.rows[index]}`);
236
+ }
237
+
238
+ function renderPicker(state) {
239
+ const { title, rows, selected, header } = state;
240
+ cursorTo(process.stdout, 0, 0);
241
+ clearScreenDown(process.stdout);
242
+ let output = `${title}\n\n`;
243
+ if (header) output += ` ${header}\n ${'-'.repeat(header.length)}\n`;
244
+ for (const [index, row] of rows.entries()) output += `${index === selected ? '>' : ' '} ${row}\n`;
245
+ output += '\n↑/↓ select Enter confirm ← back Esc quit\n';
246
+ print(output);
247
+ }
248
+
249
+ function openPickerScreen() {
250
+ process.stdout.write('\x1b[?1049h\x1b[?25l');
251
+ }
252
+
253
+ function closePickerScreen() {
254
+ process.stdout.write('\x1b[?25h\x1b[?1049l');
255
+ }
256
+
257
+ async function pick(title, rows, header = '') {
258
+ if (!rows.length) return { action: 'back' };
259
+ let selected = 0;
260
+ const stdin = process.stdin;
261
+ const state = { title, rows, selected, header };
262
+ renderPicker(state);
263
+
264
+ return await new Promise((resolvePick) => {
265
+ const done = (result) => {
266
+ stdin.off('data', onData);
267
+ resolvePick(result);
268
+ };
269
+ const onData = (chunk) => {
270
+ const key = chunk.toString('utf8');
271
+ if (key === '\u0003' || key === '\u001b') return done({ action: 'cancel' });
272
+ if (key === '\u001b[D') return done({ action: 'back' });
273
+ if (key === '\r' || key === '\n') return done({ action: 'select', index: selected });
274
+ const previous = selected;
275
+ if (key === '\u001b[A') selected = Math.max(0, selected - 1);
276
+ else if (key === '\u001b[B') selected = Math.min(rows.length - 1, selected + 1);
277
+ else return;
278
+ if (selected === previous) return;
279
+ state.selected = selected;
280
+ renderRow(state, previous);
281
+ renderRow(state, selected);
282
+ };
283
+ stdin.on('data', onData);
284
+ });
285
+ }
286
+
287
+ async function chooseSessionFlow(allSessions, context, limit) {
288
+ const models = gatewayModels(context.installDir);
289
+ const listed = allSessions.slice(0, limit);
290
+ const header = sessionHeader();
291
+ if (!listed.length) {
292
+ print('No matching sessions found.\n');
293
+ return null;
294
+ }
295
+ if (!models.length) {
296
+ print(`No gateway models found in ${join(context.installDir, 'config', 'model-aliases.json')}\n`);
297
+ return null;
298
+ }
299
+
300
+ const stdin = process.stdin;
301
+ stdin.resume();
302
+ stdin.setRawMode(true);
303
+ openPickerScreen();
304
+ try {
305
+ let step = 'model';
306
+ while (true) {
307
+ if (step === 'model') {
308
+ const result = await pick('Choose gateway model', models);
309
+ if (result.action !== 'select') return null;
310
+ context.model = models[result.index];
311
+ step = 'reasoning';
312
+ } else if (step === 'reasoning') {
313
+ const result = await pick(`Choose Codex reasoning effort for ${context.model}`, REASONING_EFFORTS);
314
+ if (result.action === 'cancel') return null;
315
+ if (result.action === 'back') step = 'model';
316
+ else {
317
+ context.reasoningEffort = REASONING_EFFORTS[result.index];
318
+ step = 'session';
319
+ }
320
+ } else {
321
+ const result = await pick(`Choose Codex session for ${context.provider} / ${context.model} / ${context.reasoningEffort}`, sessionRows(listed), header);
322
+ if (result.action === 'cancel') return null;
323
+ if (result.action === 'back') step = 'reasoning';
324
+ else return listed[result.index];
325
+ }
326
+ }
327
+ } finally {
328
+ stdin.setRawMode(false);
329
+ stdin.pause();
330
+ closePickerScreen();
331
+ }
332
+ }
333
+
334
+ export async function sessions(options) {
335
+ const codexHome = defaultCodexHome();
336
+ const sessionsDir = join(codexHome, 'sessions');
337
+ const indexById = readSessionIndex(codexHome);
338
+ const projectRoot = findProjectRoot(process.cwd());
339
+ const codexConfig = readCodexConfig();
340
+ const models = gatewayModels(options.dir);
341
+ const configModel = codexConfig.modelProvider === DEFAULT_PROVIDER && models.includes(codexConfig.model) ? codexConfig.model : '';
342
+ const context = {
343
+ all: options.all,
344
+ codexHome,
345
+ installDir: options.dir,
346
+ projectRoot,
347
+ provider: options.provider || DEFAULT_PROVIDER,
348
+ model: options.model || configModel || models[0] || '',
349
+ reasoningEffort: options.reasoningEffort || codexConfig.modelReasoningEffort || 'low',
350
+ };
351
+ const seen = new Map();
352
+
353
+ for (const file of walkJsonl(sessionsDir)) {
354
+ const session = readSession(file, indexById);
355
+ if (!session || (!options.all && !isInsideProject(session.cwd, projectRoot))) continue;
356
+ const prior = seen.get(session.id);
357
+ if (!prior || session.updatedAt > prior.updatedAt) seen.set(session.id, session);
358
+ }
359
+
360
+ const allSessions = [...seen.values()].sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
361
+ if (options.exec) {
362
+ const session = resolveSessionSelection(options.exec, allSessions);
363
+ if (!session) throw new Error(`Session not found or ambiguous: ${options.exec}`);
364
+ await runCodexResume(session, context);
365
+ return;
366
+ }
367
+
368
+ if (options.print || !process.stdin.isTTY || !process.stdout.isTTY) {
369
+ printSessions(allSessions, options, context);
370
+ return;
371
+ }
372
+
373
+ const session = await chooseSessionFlow(allSessions, context, options.limit || 20);
374
+ if (session) await runCodexResume(session, context);
375
+ }