@galaxy-yearn/codex-deepseek-gateway 0.1.2 → 0.1.4
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 +164 -117
- package/bin/codex-deepseek-gateway.js +45 -9
- package/package.json +14 -3
- package/src/codex-launch.js +233 -0
- package/src/codex-sessions.js +235 -0
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Codex DeepSeek Gateway
|
|
2
2
|
|
|
3
|
-
A small local gateway for using DeepSeek
|
|
3
|
+
A small local gateway for using DeepSeek models from Codex.
|
|
4
4
|
|
|
5
|
-
Codex
|
|
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
|
|
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
|
|
27
|
+
This copies the runtime to:
|
|
26
28
|
|
|
27
29
|
```text
|
|
28
30
|
~/.codex/deepseek-gateway
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
It
|
|
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
|
-
|
|
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`:
|
|
40
|
+
Put your DeepSeek API key in `gateway.local.json`:
|
|
52
41
|
|
|
53
42
|
```json
|
|
54
|
-
"
|
|
55
|
-
"tavilyWebSearchEnabled": true
|
|
43
|
+
"upstreamApiKey": "sk-..."
|
|
56
44
|
```
|
|
57
45
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
```json
|
|
61
|
-
"firecrawlApiKey": "fc-REPLACE_ME",
|
|
62
|
-
"firecrawlWebFetchEnabled": true,
|
|
63
|
-
"firecrawlAutoScrapeTopResults": 3
|
|
64
|
-
```
|
|
46
|
+
`model-aliases.json` controls the gateway-facing model IDs exposed on `GET /v1/models` and used by the `new` and `sessions` pickers. Edit it only if you want to add or rename model aliases.
|
|
65
47
|
|
|
66
|
-
|
|
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.
|
|
83
|
-
|
|
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
|
|
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
|
|
118
|
-
|
|
119
|
-
- the active provider via `model_provider`
|
|
120
|
-
- the active model ID via `model`
|
|
121
|
-
- the provider label via `[model_providers.<id>].name`
|
|
73
|
+
Restart Codex after changing `config.toml`.
|
|
122
74
|
|
|
123
|
-
|
|
75
|
+
## Start And Verify
|
|
124
76
|
|
|
125
|
-
|
|
77
|
+
Start, stop, and inspect the local background process:
|
|
126
78
|
|
|
127
|
-
|
|
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
|
+
```
|
|
128
84
|
|
|
129
|
-
|
|
85
|
+
`status` should show `"reachable": true`.
|
|
130
86
|
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
147
|
-
- `
|
|
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
|
|
100
|
+
- `reasoningDisplayMode` shows whether reasoning is shown as `summary`, `hidden`, or `disabled`
|
|
101
|
+
- `tavilyWebSearchReady` and `firecrawlWebFetchReady` show whether optional web search backends are usable
|
|
151
102
|
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
`
|
|
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
|
-
"
|
|
162
|
-
"deepseekReasoningEffort": "max"
|
|
125
|
+
"fetchUpstreamModels": true
|
|
163
126
|
```
|
|
164
127
|
|
|
165
|
-
|
|
128
|
+
Whether Codex TUI `/model` shows custom provider models depends on the Codex client build. `config.toml`, `new`, and `sessions` 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,113 @@ 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
|
-
|
|
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.
|
|
175
142
|
|
|
176
|
-
-
|
|
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.
|
|
177
144
|
|
|
178
|
-
|
|
145
|
+
## Web Search
|
|
146
|
+
|
|
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`.
|
|
148
|
+
|
|
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
|
+
## New Conversations
|
|
175
|
+
|
|
176
|
+
Start a new Codex conversation with a temporary gateway model override:
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
npx @galaxy-yearn/codex-deepseek-gateway new
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The command first lets you choose a model from `~/.codex/deepseek-gateway/config/model-aliases.json`, then choose Codex reasoning effort, then runs:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
codex -c model_provider=deepseek-gateway -c model=<model> -c model_reasoning_effort=<effort>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
This does not edit `~/.codex/config.toml`; the `model_providers.deepseek-gateway` provider entry still needs to exist there.
|
|
189
|
+
|
|
190
|
+
You can skip the picker:
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
npx @galaxy-yearn/codex-deepseek-gateway new --model deepseek-v4-flash --reasoning-effort low
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Print the generated launch command instead of running Codex:
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
npx @galaxy-yearn/codex-deepseek-gateway new --print
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Sessions
|
|
203
|
+
|
|
204
|
+
Open a cross-provider session picker from a project:
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
npx @galaxy-yearn/codex-deepseek-gateway sessions
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The picker is read-only. It scans Codex local transcript files, lists sessions for the current project across providers, then runs:
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
codex resume <session-id> -c model_provider=deepseek-gateway -c model=<model> -c model_reasoning_effort=<effort>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Flow:
|
|
217
|
+
|
|
218
|
+
- choose a model from `~/.codex/deepseek-gateway/config/model-aliases.json`
|
|
219
|
+
- choose Codex reasoning effort
|
|
220
|
+
- choose `[New conversation]` or the session to resume
|
|
221
|
+
- use `↑/↓` to select, `Enter` to confirm, `←` to go back, and `Esc` to quit
|
|
222
|
+
|
|
223
|
+
Print copyable resume commands instead of opening the picker:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
npx @galaxy-yearn/codex-deepseek-gateway sessions --print
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Include sessions outside the current project:
|
|
230
|
+
|
|
231
|
+
```sh
|
|
232
|
+
npx @galaxy-yearn/codex-deepseek-gateway sessions --all
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Resume a matching session directly by row number or unique session id prefix:
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
npx @galaxy-yearn/codex-deepseek-gateway sessions --exec <id-or-row>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Limit how many session rows are shown or offered in the picker:
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
npx @galaxy-yearn/codex-deepseek-gateway sessions --limit 50
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
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
248
|
|
|
180
249
|
## Commands
|
|
181
250
|
|
|
@@ -185,6 +254,8 @@ npx @galaxy-yearn/codex-deepseek-gateway start
|
|
|
185
254
|
npx @galaxy-yearn/codex-deepseek-gateway stop
|
|
186
255
|
npx @galaxy-yearn/codex-deepseek-gateway status
|
|
187
256
|
npx @galaxy-yearn/codex-deepseek-gateway doctor
|
|
257
|
+
npx @galaxy-yearn/codex-deepseek-gateway new
|
|
258
|
+
npx @galaxy-yearn/codex-deepseek-gateway sessions
|
|
188
259
|
npx @galaxy-yearn/codex-deepseek-gateway uninstall
|
|
189
260
|
```
|
|
190
261
|
|
|
@@ -199,48 +270,24 @@ If `start` returns without visible output on your terminal, run `status`; `"reac
|
|
|
199
270
|
- text input and output
|
|
200
271
|
- image, file, and audio content parts when DeepSeek accepts the corresponding Chat Completions shape
|
|
201
272
|
- 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
273
|
- DeepSeek thinking mode and `reasoning_content`
|
|
207
274
|
- lightweight local `previous_response_id` / `conversation` history while the gateway process is running
|
|
208
|
-
- `GET /v1/models` with local
|
|
275
|
+
- `GET /v1/models` with local model aliases and optional upstream discovery
|
|
276
|
+
- optional Tavily/Firecrawl-backed `web_search` emulation
|
|
277
|
+
- new-conversation launcher with per-run Codex config overrides
|
|
278
|
+
- read-only cross-provider session picker
|
|
209
279
|
|
|
210
280
|
## Limits
|
|
211
281
|
|
|
212
282
|
Chat Completions is not a full Responses API replacement. Some Responses features have no equivalent upstream field.
|
|
213
283
|
|
|
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
|
|
215
|
-
- Tavily and Firecrawl web emulation is
|
|
284
|
+
- 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.
|
|
285
|
+
- 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
286
|
- 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
287
|
- OpenAI `file_id` values are passed through; the gateway cannot fetch private OpenAI-hosted files.
|
|
218
288
|
- In-memory conversation history is lost when the gateway restarts.
|
|
219
|
-
- 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`
|
|
220
|
-
|
|
221
|
-
## Local Testing Before Publish
|
|
289
|
+
- 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`, `new`, and `sessions` remain the reliable ways to choose a model.
|
|
222
290
|
|
|
223
|
-
|
|
291
|
+
## License
|
|
224
292
|
|
|
225
|
-
|
|
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`.
|
|
234
|
-
|
|
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
|
-
```
|
|
293
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -13,6 +13,8 @@ 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 { newConversation } from '../src/codex-launch.js';
|
|
17
|
+
import { sessions } from '../src/codex-sessions.js';
|
|
16
18
|
import { toProviderChatCompletionsRequest } from '../src/protocol.js';
|
|
17
19
|
|
|
18
20
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -32,16 +34,27 @@ Usage:
|
|
|
32
34
|
codex-deepseek-gateway stop
|
|
33
35
|
codex-deepseek-gateway status
|
|
34
36
|
codex-deepseek-gateway doctor
|
|
37
|
+
codex-deepseek-gateway new
|
|
38
|
+
codex-deepseek-gateway sessions
|
|
35
39
|
codex-deepseek-gateway uninstall
|
|
36
40
|
|
|
37
41
|
Options:
|
|
38
42
|
--dir <path> Install directory, defaults to ~/.codex/deepseek-gateway
|
|
39
43
|
--no-edit Do not open the local config file after install
|
|
44
|
+
--all With sessions, include sessions outside the current project
|
|
45
|
+
--provider <id> With new/sessions, target model_provider override
|
|
46
|
+
--model <id> With new/sessions, target model override
|
|
47
|
+
--reasoning-effort <level>
|
|
48
|
+
With new/sessions, target Codex reasoning effort
|
|
49
|
+
--exec <id> With sessions, run the generated codex resume command
|
|
50
|
+
--limit <n> With sessions, max rows to print, defaults to 20
|
|
51
|
+
--print With new, print the launch command. With sessions, print
|
|
52
|
+
copyable resume commands instead of picker
|
|
40
53
|
`);
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
function parseArgs(argv) {
|
|
44
|
-
const options = { dir: defaultInstallDir(), noEdit: false };
|
|
57
|
+
const options = { dir: defaultInstallDir(), noEdit: false, all: false };
|
|
45
58
|
const rest = [];
|
|
46
59
|
for (let index = 0; index < argv.length; index += 1) {
|
|
47
60
|
const arg = argv[index];
|
|
@@ -52,6 +65,35 @@ function parseArgs(argv) {
|
|
|
52
65
|
index += 1;
|
|
53
66
|
} else if (arg === '--no-edit' || arg === '-NoEdit') {
|
|
54
67
|
options.noEdit = true;
|
|
68
|
+
} else if (arg === '--all') {
|
|
69
|
+
options.all = true;
|
|
70
|
+
} else if (arg === '--provider') {
|
|
71
|
+
const value = argv[index + 1];
|
|
72
|
+
if (!value) throw new Error(`${arg} requires a provider id`);
|
|
73
|
+
options.provider = value;
|
|
74
|
+
index += 1;
|
|
75
|
+
} else if (arg === '--model' || arg === '-m') {
|
|
76
|
+
const value = argv[index + 1];
|
|
77
|
+
if (!value) throw new Error(`${arg} requires a model id`);
|
|
78
|
+
options.model = value;
|
|
79
|
+
index += 1;
|
|
80
|
+
} else if (arg === '--reasoning-effort') {
|
|
81
|
+
const value = argv[index + 1];
|
|
82
|
+
if (!value) throw new Error(`${arg} requires a reasoning effort`);
|
|
83
|
+
options.reasoningEffort = value;
|
|
84
|
+
index += 1;
|
|
85
|
+
} else if (arg === '--exec') {
|
|
86
|
+
const value = argv[index + 1];
|
|
87
|
+
if (!value) throw new Error(`${arg} requires a session id`);
|
|
88
|
+
options.exec = value;
|
|
89
|
+
index += 1;
|
|
90
|
+
} else if (arg === '--limit') {
|
|
91
|
+
const value = Number(argv[index + 1]);
|
|
92
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`${arg} requires a positive integer`);
|
|
93
|
+
options.limit = value;
|
|
94
|
+
index += 1;
|
|
95
|
+
} else if (arg === '--print') {
|
|
96
|
+
options.print = true;
|
|
55
97
|
} else {
|
|
56
98
|
rest.push(arg);
|
|
57
99
|
}
|
|
@@ -324,7 +366,6 @@ async function doctor(options) {
|
|
|
324
366
|
: thinkingEnabled
|
|
325
367
|
? 'summary'
|
|
326
368
|
: 'disabled';
|
|
327
|
-
const codexSummaryConfigured = supportsReasoningSummaries && summaryMode && summaryMode !== 'none';
|
|
328
369
|
print(JSON.stringify({
|
|
329
370
|
packageVersion: packageJson.version,
|
|
330
371
|
installDir: options.dir,
|
|
@@ -341,17 +382,12 @@ async function doctor(options) {
|
|
|
341
382
|
deepseekThinking: upstreamRequest.thinking || null,
|
|
342
383
|
deepseekReasoningEffort: upstreamRequest.reasoning_effort || null,
|
|
343
384
|
reasoningDisplayMode,
|
|
344
|
-
gatewayEmitsReasoningSummary: reasoningDisplayMode === 'summary',
|
|
345
|
-
codexSummaryConfigured,
|
|
346
385
|
tavilyWebSearchEnabled: Boolean(config.tavilyWebSearchEnabled),
|
|
347
386
|
tavilyWebSearchReady: Boolean(config.tavilyWebSearchEnabled && config.tavilyApiKey),
|
|
348
387
|
firecrawlWebFetchEnabled: Boolean(config.firecrawlWebFetchEnabled),
|
|
349
388
|
firecrawlWebFetchReady: Boolean(config.firecrawlWebFetchEnabled && config.firecrawlApiKey),
|
|
350
389
|
firecrawlAutoScrapeTopResults: config.firecrawlAutoScrapeTopResults,
|
|
351
|
-
|
|
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, new: newConversation, 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.
|
|
4
|
-
"description": "Local Codex Responses API
|
|
3
|
+
"version": "0.1.4",
|
|
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,233 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { clearScreenDown, cursorTo } from 'node:readline';
|
|
5
|
+
import { readCodexConfig } from './codex-config.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_PROVIDER = 'deepseek-gateway';
|
|
8
|
+
export const REASONING_EFFORTS = ['low', 'medium', 'high', 'xhigh'];
|
|
9
|
+
const SELECTED_ROW = '\x1b[38;5;81m';
|
|
10
|
+
const RESET_STYLE = '\x1b[0m';
|
|
11
|
+
|
|
12
|
+
function print(message = '') {
|
|
13
|
+
process.stdout.write(message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function defaultCodexHome() {
|
|
17
|
+
return process.env.CODEX_HOME || join(process.env.USERPROFILE || process.env.HOME || process.cwd(), '.codex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function findProjectRoot(start) {
|
|
21
|
+
let current = resolve(start);
|
|
22
|
+
while (true) {
|
|
23
|
+
if (existsSync(join(current, '.git'))) return current;
|
|
24
|
+
const parent = dirname(current);
|
|
25
|
+
if (parent === current) return resolve(start);
|
|
26
|
+
current = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJsonObject(file) {
|
|
31
|
+
if (!existsSync(file)) return {};
|
|
32
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
33
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function gatewayModels(installDir) {
|
|
37
|
+
return Object.keys(readJsonObject(join(installDir, 'config', 'model-aliases.json'))).sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createLaunchContext(options) {
|
|
41
|
+
const codexHome = defaultCodexHome();
|
|
42
|
+
const models = gatewayModels(options.dir);
|
|
43
|
+
const codexConfig = readCodexConfig();
|
|
44
|
+
const configModel = codexConfig.modelProvider === DEFAULT_PROVIDER && models.includes(codexConfig.model) ? codexConfig.model : '';
|
|
45
|
+
return {
|
|
46
|
+
all: options.all,
|
|
47
|
+
codexHome,
|
|
48
|
+
installDir: options.dir,
|
|
49
|
+
models,
|
|
50
|
+
projectRoot: findProjectRoot(process.cwd()),
|
|
51
|
+
provider: options.provider || DEFAULT_PROVIDER,
|
|
52
|
+
model: options.model || configModel || models[0] || '',
|
|
53
|
+
reasoningEffort: options.reasoningEffort || codexConfig.modelReasoningEffort || 'low',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function missingModelMessage(context) {
|
|
58
|
+
return `No gateway models found in ${join(context.installDir, 'config', 'model-aliases.json')}\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function configOverrideArgs(context) {
|
|
62
|
+
return [
|
|
63
|
+
'-c',
|
|
64
|
+
`model_provider=${JSON.stringify(context.provider)}`,
|
|
65
|
+
'-c',
|
|
66
|
+
`model=${JSON.stringify(context.model)}`,
|
|
67
|
+
'-c',
|
|
68
|
+
`model_reasoning_effort=${JSON.stringify(context.reasoningEffort)}`,
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function configOverrideCommandParts(context) {
|
|
73
|
+
return [
|
|
74
|
+
'-c',
|
|
75
|
+
`model_provider=${context.provider}`,
|
|
76
|
+
'-c',
|
|
77
|
+
`model=${context.model}`,
|
|
78
|
+
'-c',
|
|
79
|
+
`model_reasoning_effort=${context.reasoningEffort}`,
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function codexNewArgs(context) {
|
|
84
|
+
return configOverrideArgs(context);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function codexResumeArgs(sessionId, context) {
|
|
88
|
+
return ['resume', sessionId, ...configOverrideArgs(context)];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function codexNewCommand(context) {
|
|
92
|
+
return ['codex', ...configOverrideCommandParts(context)].join(' ');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function codexResumeCommand(sessionId, context) {
|
|
96
|
+
return ['codex', 'resume', sessionId, ...configOverrideCommandParts(context)].join(' ');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function runCodex(args) {
|
|
100
|
+
const child = spawn('codex', args, {
|
|
101
|
+
stdio: 'inherit',
|
|
102
|
+
shell: process.platform === 'win32',
|
|
103
|
+
});
|
|
104
|
+
process.exitCode = await new Promise((resolveChild) => {
|
|
105
|
+
child.on('exit', (exitCode) => resolveChild(exitCode ?? 0));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function rowOffset(header) {
|
|
110
|
+
return header ? 4 : 2;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderRow(state, index) {
|
|
114
|
+
cursorTo(process.stdout, 0, rowOffset(state.header) + index);
|
|
115
|
+
const row = `${index === state.selected ? '>' : ' '} ${state.rows[index]}`;
|
|
116
|
+
const styled = index === state.selected ? `${SELECTED_ROW}${row}${RESET_STYLE}` : row;
|
|
117
|
+
print(`\x1b[2K${styled}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderPicker(state) {
|
|
121
|
+
const { title, rows, selected, header } = state;
|
|
122
|
+
cursorTo(process.stdout, 0, 0);
|
|
123
|
+
clearScreenDown(process.stdout);
|
|
124
|
+
let output = `${title}\n\n`;
|
|
125
|
+
if (header) output += ` ${header}\n ${'-'.repeat(header.length)}\n`;
|
|
126
|
+
for (const [index] of rows.entries()) {
|
|
127
|
+
const row = `${index === selected ? '>' : ' '} ${rows[index]}`;
|
|
128
|
+
output += `${index === selected ? `${SELECTED_ROW}${row}${RESET_STYLE}` : row}\n`;
|
|
129
|
+
}
|
|
130
|
+
output += '\n↑/↓ select Enter confirm ← back Esc quit\n';
|
|
131
|
+
print(output);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function openPickerScreen() {
|
|
135
|
+
process.stdout.write('\x1b[?1049h\x1b[?25l');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function closePickerScreen() {
|
|
139
|
+
process.stdout.write('\x1b[?25h\x1b[?1049l');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function pick(title, rows, header = '') {
|
|
143
|
+
if (!rows.length) return { action: 'back' };
|
|
144
|
+
let selected = 0;
|
|
145
|
+
const stdin = process.stdin;
|
|
146
|
+
const state = { title, rows, selected, header };
|
|
147
|
+
renderPicker(state);
|
|
148
|
+
|
|
149
|
+
return await new Promise((resolvePick) => {
|
|
150
|
+
const done = (result) => {
|
|
151
|
+
stdin.off('data', onData);
|
|
152
|
+
resolvePick(result);
|
|
153
|
+
};
|
|
154
|
+
const onData = (chunk) => {
|
|
155
|
+
const key = chunk.toString('utf8');
|
|
156
|
+
if (key === '\u0003' || key === '\u001b') return done({ action: 'cancel' });
|
|
157
|
+
if (key === '\u001b[D') return done({ action: 'back' });
|
|
158
|
+
if (key === '\r' || key === '\n') return done({ action: 'select', index: selected });
|
|
159
|
+
const previous = selected;
|
|
160
|
+
if (key === '\u001b[A') selected = Math.max(0, selected - 1);
|
|
161
|
+
else if (key === '\u001b[B') selected = Math.min(rows.length - 1, selected + 1);
|
|
162
|
+
else return;
|
|
163
|
+
if (selected === previous) return;
|
|
164
|
+
state.selected = selected;
|
|
165
|
+
renderRow(state, previous);
|
|
166
|
+
renderRow(state, selected);
|
|
167
|
+
};
|
|
168
|
+
stdin.on('data', onData);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function withPickerScreen(callback) {
|
|
173
|
+
const stdin = process.stdin;
|
|
174
|
+
stdin.resume();
|
|
175
|
+
stdin.setRawMode(true);
|
|
176
|
+
openPickerScreen();
|
|
177
|
+
try {
|
|
178
|
+
return await callback();
|
|
179
|
+
} finally {
|
|
180
|
+
stdin.setRawMode(false);
|
|
181
|
+
stdin.pause();
|
|
182
|
+
closePickerScreen();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function pickModel(context) {
|
|
187
|
+
const result = await pick('Choose gateway model', context.models);
|
|
188
|
+
if (result.action === 'select') context.model = context.models[result.index];
|
|
189
|
+
return result.action;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function pickReasoning(context) {
|
|
193
|
+
const result = await pick(`Choose Codex reasoning effort for ${context.model}`, REASONING_EFFORTS);
|
|
194
|
+
if (result.action === 'select') context.reasoningEffort = REASONING_EFFORTS[result.index];
|
|
195
|
+
return result.action;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function chooseLaunchContext(context) {
|
|
199
|
+
return await withPickerScreen(async () => {
|
|
200
|
+
let step = 'model';
|
|
201
|
+
while (true) {
|
|
202
|
+
if (step === 'model') {
|
|
203
|
+
const action = await pickModel(context);
|
|
204
|
+
if (action !== 'select') return false;
|
|
205
|
+
step = 'reasoning';
|
|
206
|
+
} else {
|
|
207
|
+
const action = await pickReasoning(context);
|
|
208
|
+
if (action === 'cancel') return false;
|
|
209
|
+
if (action === 'back') step = 'model';
|
|
210
|
+
else return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function newConversation(options) {
|
|
217
|
+
const context = createLaunchContext(options);
|
|
218
|
+
if (!context.model) {
|
|
219
|
+
print(missingModelMessage(context));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const canPick = process.stdin.isTTY && process.stdout.isTTY;
|
|
223
|
+
const hasLaunchOverrides = options.provider || options.model || options.reasoningEffort;
|
|
224
|
+
if (!hasLaunchOverrides && !options.print && canPick) {
|
|
225
|
+
const selected = await chooseLaunchContext(context);
|
|
226
|
+
if (!selected) return;
|
|
227
|
+
}
|
|
228
|
+
if (options.print || (!hasLaunchOverrides && !canPick)) {
|
|
229
|
+
print(`${codexNewCommand(context)}\n`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
await runCodex(codexNewArgs(context));
|
|
233
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
openSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
codexNewArgs,
|
|
12
|
+
codexResumeArgs,
|
|
13
|
+
codexResumeCommand,
|
|
14
|
+
createLaunchContext,
|
|
15
|
+
missingModelMessage,
|
|
16
|
+
pick,
|
|
17
|
+
pickModel,
|
|
18
|
+
pickReasoning,
|
|
19
|
+
runCodex,
|
|
20
|
+
withPickerScreen,
|
|
21
|
+
} from './codex-launch.js';
|
|
22
|
+
|
|
23
|
+
const TIME_WIDTH = 10;
|
|
24
|
+
const PROVIDER_WIDTH = 17;
|
|
25
|
+
const ID_WIDTH = 36;
|
|
26
|
+
const TITLE_WIDTH = 16;
|
|
27
|
+
const TABLE_INDENT = ' ';
|
|
28
|
+
const COLUMN_GAP = ' ';
|
|
29
|
+
const NEW_SESSION_ROW = '[New conversation]';
|
|
30
|
+
|
|
31
|
+
function print(message = '') {
|
|
32
|
+
process.stdout.write(message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readStart(file, bytes = 256 * 1024) {
|
|
36
|
+
const handle = openSync(file, 'r');
|
|
37
|
+
try {
|
|
38
|
+
const buffer = Buffer.alloc(bytes);
|
|
39
|
+
return buffer.subarray(0, readSync(handle, buffer, 0, bytes, 0)).toString('utf8');
|
|
40
|
+
} finally {
|
|
41
|
+
closeSync(handle);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function walkJsonl(dir, result = []) {
|
|
46
|
+
if (!existsSync(dir)) return result;
|
|
47
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
48
|
+
const path = join(dir, entry.name);
|
|
49
|
+
if (entry.isDirectory()) walkJsonl(path, result);
|
|
50
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) result.push(path);
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseJsonLine(line) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(line);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readSessionIndex(codexHome) {
|
|
64
|
+
const file = join(codexHome, 'session_index.jsonl');
|
|
65
|
+
const byId = new Map();
|
|
66
|
+
if (!existsSync(file)) return byId;
|
|
67
|
+
for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
68
|
+
if (!line.trim()) continue;
|
|
69
|
+
const row = parseJsonLine(line);
|
|
70
|
+
if (!row?.id) continue;
|
|
71
|
+
const prior = byId.get(row.id);
|
|
72
|
+
if (!prior || String(row.updated_at || '') > String(prior.updated_at || '')) byId.set(row.id, row);
|
|
73
|
+
}
|
|
74
|
+
return byId;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function textFromContent(content) {
|
|
78
|
+
if (!Array.isArray(content)) return '';
|
|
79
|
+
return content
|
|
80
|
+
.map((part) => part?.text || '')
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(' ');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function firstUserPreview(lines) {
|
|
86
|
+
for (const line of lines.slice(1, 180)) {
|
|
87
|
+
const payload = parseJsonLine(line)?.payload;
|
|
88
|
+
if (payload?.type !== 'message' || payload.role !== 'user') continue;
|
|
89
|
+
const text = textFromContent(payload.content).trim().replace(/\s+/g, ' ');
|
|
90
|
+
if (!text || text.startsWith('<environment_context>') || text.startsWith('# AGENTS.md instructions')) continue;
|
|
91
|
+
return truncate(text, TITLE_WIDTH);
|
|
92
|
+
}
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function truncate(text, max) {
|
|
97
|
+
return text.length > max ? `${text.slice(0, max - 1)}...` : text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pad(text, width) {
|
|
101
|
+
return truncate(String(text || ''), width).padEnd(width, ' ');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatTime(value) {
|
|
105
|
+
if (!value) return '';
|
|
106
|
+
return String(value).slice(0, 10);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizePath(path) {
|
|
110
|
+
const resolved = resolve(path || '');
|
|
111
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isInsideProject(cwd, projectRoot) {
|
|
115
|
+
if (!cwd) return false;
|
|
116
|
+
const sessionCwd = normalizePath(cwd);
|
|
117
|
+
const root = normalizePath(projectRoot);
|
|
118
|
+
return sessionCwd === root || sessionCwd.startsWith(`${root}${process.platform === 'win32' ? '\\' : '/'}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readSession(file, indexById) {
|
|
122
|
+
const lines = readStart(file).split(/\r?\n/).filter(Boolean);
|
|
123
|
+
const meta = parseJsonLine(lines[0])?.payload;
|
|
124
|
+
if (!meta?.id) return null;
|
|
125
|
+
const index = indexById.get(meta.id) || {};
|
|
126
|
+
return {
|
|
127
|
+
id: meta.id,
|
|
128
|
+
provider: meta.model_provider || '',
|
|
129
|
+
cwd: meta.cwd || '',
|
|
130
|
+
updatedAt: index.updated_at || meta.timestamp || '',
|
|
131
|
+
title: index.thread_name || firstUserPreview(lines) || '(untitled)',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveSessionSelection(selection, sessionsList) {
|
|
136
|
+
if (/^\d+$/.test(selection)) {
|
|
137
|
+
const byIndex = sessionsList[Number(selection) - 1];
|
|
138
|
+
if (byIndex) return byIndex;
|
|
139
|
+
}
|
|
140
|
+
const matches = sessionsList.filter((session) => session.id.startsWith(selection));
|
|
141
|
+
return matches.length === 1 ? matches[0] : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function printSessions(sessionsList, options, context) {
|
|
145
|
+
const listed = sessionsList.slice(0, options.limit || 20);
|
|
146
|
+
print(`Codex sessions ${options.all ? `under ${context.codexHome}` : `for project ${context.projectRoot}`}\n`);
|
|
147
|
+
print(`Target: ${context.provider} / ${context.model} / ${context.reasoningEffort}\n\n`);
|
|
148
|
+
if (!listed.length) {
|
|
149
|
+
print('No matching sessions found.\n');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
print(`${TABLE_INDENT}${sessionHeader()}\n`);
|
|
153
|
+
print(`${TABLE_INDENT}${'-'.repeat(sessionHeader().length)}\n`);
|
|
154
|
+
listed.forEach((session, index) => {
|
|
155
|
+
print(`${String(index + 1).padStart(2, ' ')} ${sessionRow(session)}\n`);
|
|
156
|
+
if (options.all) print(` cwd: ${session.cwd || '(unknown cwd)'}\n`);
|
|
157
|
+
print(` resume: ${codexResumeCommand(session.id, context)}\n\n`);
|
|
158
|
+
});
|
|
159
|
+
if (sessionsList.length > listed.length) print(`Showing ${listed.length} of ${sessionsList.length}. Use --limit ${sessionsList.length} or --all as needed.\n`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function sessionHeader() {
|
|
163
|
+
return `${pad('Date', TIME_WIDTH)}${COLUMN_GAP}${pad('Provider', PROVIDER_WIDTH)}${COLUMN_GAP}${pad('Session ID', ID_WIDTH)}${COLUMN_GAP}Title`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sessionRow(session) {
|
|
167
|
+
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)}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sessionRows(sessionsList) {
|
|
171
|
+
return sessionsList.map((session) => sessionRow(session));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function chooseSessionFlow(allSessions, context, limit) {
|
|
175
|
+
if (!context.models.length) {
|
|
176
|
+
print(missingModelMessage(context));
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const listed = allSessions.slice(0, limit);
|
|
181
|
+
const header = sessionHeader();
|
|
182
|
+
return await withPickerScreen(async () => {
|
|
183
|
+
let step = 'model';
|
|
184
|
+
while (true) {
|
|
185
|
+
if (step === 'model') {
|
|
186
|
+
const action = await pickModel(context);
|
|
187
|
+
if (action !== 'select') return null;
|
|
188
|
+
step = 'reasoning';
|
|
189
|
+
} else if (step === 'reasoning') {
|
|
190
|
+
const action = await pickReasoning(context);
|
|
191
|
+
if (action === 'cancel') return null;
|
|
192
|
+
if (action === 'back') step = 'model';
|
|
193
|
+
else step = 'session';
|
|
194
|
+
} else {
|
|
195
|
+
const rows = [NEW_SESSION_ROW, ...sessionRows(listed)];
|
|
196
|
+
const result = await pick(`Choose Codex session for ${context.provider} / ${context.model} / ${context.reasoningEffort}`, rows, header);
|
|
197
|
+
if (result.action === 'cancel') return null;
|
|
198
|
+
if (result.action === 'back') step = 'reasoning';
|
|
199
|
+
else if (result.index === 0) return { newConversation: true };
|
|
200
|
+
else return listed[result.index - 1];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function sessions(options) {
|
|
207
|
+
const context = createLaunchContext(options);
|
|
208
|
+
const sessionsDir = join(context.codexHome, 'sessions');
|
|
209
|
+
const indexById = readSessionIndex(context.codexHome);
|
|
210
|
+
const seen = new Map();
|
|
211
|
+
|
|
212
|
+
for (const file of walkJsonl(sessionsDir)) {
|
|
213
|
+
const session = readSession(file, indexById);
|
|
214
|
+
if (!session || (!options.all && !isInsideProject(session.cwd, context.projectRoot))) continue;
|
|
215
|
+
const prior = seen.get(session.id);
|
|
216
|
+
if (!prior || session.updatedAt > prior.updatedAt) seen.set(session.id, session);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const allSessions = [...seen.values()].sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
220
|
+
if (options.exec) {
|
|
221
|
+
const session = resolveSessionSelection(options.exec, allSessions);
|
|
222
|
+
if (!session) throw new Error(`Session not found or ambiguous: ${options.exec}`);
|
|
223
|
+
await runCodex(codexResumeArgs(session.id, context));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (options.print || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
228
|
+
printSessions(allSessions, options, context);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const selected = await chooseSessionFlow(allSessions, context, options.limit || 20);
|
|
233
|
+
if (selected?.newConversation) await runCodex(codexNewArgs(context));
|
|
234
|
+
else if (selected) await runCodex(codexResumeArgs(selected.id, context));
|
|
235
|
+
}
|