@galaxy-yearn/codex-deepseek-gateway 0.1.0
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 +207 -0
- package/bin/codex-deepseek-gateway.js +375 -0
- package/config/gateway.example.json +12 -0
- package/config/model-aliases.example.json +10 -0
- package/package.json +34 -0
- package/src/codex-config.js +83 -0
- package/src/common.js +141 -0
- package/src/config.js +31 -0
- package/src/local-config.js +51 -0
- package/src/model-map.js +152 -0
- package/src/protocol.js +1740 -0
- package/src/server.js +350 -0
- package/src/session-store.js +67 -0
- package/src/upstream.js +155 -0
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Codex DeepSeek Gateway
|
|
2
|
+
|
|
3
|
+
A small local gateway for using DeepSeek V4 models from Codex.
|
|
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.
|
|
6
|
+
|
|
7
|
+
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
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Node.js 20 or newer
|
|
12
|
+
- A DeepSeek API key
|
|
13
|
+
- Codex configured from `~/.codex/config.toml`
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Run:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx @galaxy-yearn/codex-deepseek-gateway install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This copies the gateway to:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
~/.codex/deepseek-gateway
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
It also creates:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
~/.codex/deepseek-gateway/config/gateway.local.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Open that file and replace `sk-REPLACE_ME` with your DeepSeek API key.
|
|
36
|
+
|
|
37
|
+
The install also creates:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
~/.codex/deepseek-gateway/config/model-aliases.json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
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.
|
|
46
|
+
|
|
47
|
+
If the key is already configured, `install` also starts the gateway. If this is your first install, run `start` after adding the key:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
npx @galaxy-yearn/codex-deepseek-gateway start
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Check that it is running:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npx @galaxy-yearn/codex-deepseek-gateway status
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
You should see `"reachable": true`.
|
|
60
|
+
|
|
61
|
+
## Configure Codex
|
|
62
|
+
|
|
63
|
+
Edit `~/.codex/config.toml` and set the active provider to the gateway:
|
|
64
|
+
|
|
65
|
+
```toml
|
|
66
|
+
model_provider = "deepseek-gateway"
|
|
67
|
+
model = "deepseek-v4-flash"
|
|
68
|
+
model_reasoning_effort = "low"
|
|
69
|
+
model_supports_reasoning_summaries = true
|
|
70
|
+
model_reasoning_summary = "auto"
|
|
71
|
+
|
|
72
|
+
[model_providers.deepseek-gateway]
|
|
73
|
+
name = "DeepSeekGateway"
|
|
74
|
+
base_url = "http://127.0.0.1:3000/v1"
|
|
75
|
+
wire_api = "responses"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use `deepseek-v4-pro` instead of `deepseek-v4-flash` if you want the pro model.
|
|
79
|
+
|
|
80
|
+
`config.toml` can change:
|
|
81
|
+
|
|
82
|
+
- the active provider via `model_provider`
|
|
83
|
+
- the active model ID via `model`
|
|
84
|
+
- the provider label via `[model_providers.<id>].name`
|
|
85
|
+
|
|
86
|
+
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`.
|
|
87
|
+
|
|
88
|
+
These reasoning settings do different jobs:
|
|
89
|
+
|
|
90
|
+
- `model_supports_reasoning_summaries = true`
|
|
91
|
+
- `model_reasoning_summary = "auto"`
|
|
92
|
+
|
|
93
|
+
These tell Codex to use its normal thinking UI path. When DeepSeek returns `reasoning_content`, the gateway maps it into that path while thinking is enabled. It keeps the raw text as `reasoning_text` and sends a display-safe `summary_text` version with a small heading and common Markdown markers removed, because Codex TUI renders reasoning summaries as plain summary blocks.
|
|
94
|
+
|
|
95
|
+
When DeepSeek thinking is enabled, the gateway buffers visible assistant text and tool-call output until the upstream response completes. It then flushes collected reasoning before the answer/tool calls through one summary part streamed as small deltas. The tradeoff is higher first-token latency while thinking is on.
|
|
96
|
+
|
|
97
|
+
Restart Codex after changing `config.toml`.
|
|
98
|
+
|
|
99
|
+
## Verify
|
|
100
|
+
|
|
101
|
+
Run:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
npx @galaxy-yearn/codex-deepseek-gateway doctor
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Important fields:
|
|
108
|
+
|
|
109
|
+
- `codexConfigUsingGateway` should be `true`
|
|
110
|
+
- `codexModel` should be your DeepSeek model, for example `deepseek-v4-pro`
|
|
111
|
+
- `codexReasoningEffort` should match `model_reasoning_effort`
|
|
112
|
+
- `deepseekThinking` shows the DeepSeek `thinking` payload
|
|
113
|
+
- `deepseekReasoningEffort` shows the DeepSeek effort sent upstream
|
|
114
|
+
- `reasoningDisplayMode` shows whether the gateway will emit `summary`, `disabled`, or `hidden`
|
|
115
|
+
- `gatewayEmitsReasoningSummary` should be `true` when DeepSeek thinking is enabled
|
|
116
|
+
- `codexSummaryConfigured` should be `true` so Codex TUI is configured to show summaries
|
|
117
|
+
|
|
118
|
+
For example, with:
|
|
119
|
+
|
|
120
|
+
```toml
|
|
121
|
+
model_reasoning_effort = "xhigh"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`doctor` should show:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
"deepseekThinking": { "type": "enabled" },
|
|
128
|
+
"deepseekReasoningEffort": "max"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Reasoning Mapping
|
|
132
|
+
|
|
133
|
+
| Codex `model_reasoning_effort` | DeepSeek request |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `low` | `thinking.type = disabled` |
|
|
136
|
+
| `medium` | `thinking.type = enabled`, `reasoning_effort = high` |
|
|
137
|
+
| `high` | `thinking.type = enabled`, `reasoning_effort = high` |
|
|
138
|
+
| `xhigh` | `thinking.type = enabled`, `reasoning_effort = max` |
|
|
139
|
+
|
|
140
|
+
When thinking is enabled, every non-empty DeepSeek streaming `reasoning_content` chunk is collected and converted into Responses reasoning events:
|
|
141
|
+
|
|
142
|
+
- `reasoning_summary_text.delta` for Codex's native summary-style thinking UI
|
|
143
|
+
|
|
144
|
+
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. When thinking is enabled, the gateway holds back visible answer/tool output until completion so the reasoning block can land first and stay contiguous.
|
|
145
|
+
|
|
146
|
+
## Commands
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
npx @galaxy-yearn/codex-deepseek-gateway install
|
|
150
|
+
npx @galaxy-yearn/codex-deepseek-gateway start
|
|
151
|
+
npx @galaxy-yearn/codex-deepseek-gateway stop
|
|
152
|
+
npx @galaxy-yearn/codex-deepseek-gateway status
|
|
153
|
+
npx @galaxy-yearn/codex-deepseek-gateway doctor
|
|
154
|
+
npx @galaxy-yearn/codex-deepseek-gateway uninstall
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`start` launches a headless background Node.js process. `stop` stops it. `uninstall` stops the gateway and removes `~/.codex/deepseek-gateway`, but it does not edit `~/.codex/config.toml`.
|
|
158
|
+
|
|
159
|
+
If `start` returns without visible output on your terminal, run `status`; `"reachable": true` is the source of truth.
|
|
160
|
+
|
|
161
|
+
## What Works
|
|
162
|
+
|
|
163
|
+
- Codex `POST /v1/responses`
|
|
164
|
+
- streaming and non-streaming responses
|
|
165
|
+
- text input and output
|
|
166
|
+
- image, file, and audio content parts when DeepSeek accepts the corresponding Chat Completions shape
|
|
167
|
+
- function tools and tool-call history
|
|
168
|
+
- 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
|
|
169
|
+
- DeepSeek thinking mode and `reasoning_content`
|
|
170
|
+
- lightweight local `previous_response_id` / `conversation` history while the gateway process is running
|
|
171
|
+
- `GET /v1/models` with local DeepSeek V4 aliases and optional upstream discovery
|
|
172
|
+
|
|
173
|
+
## Limits
|
|
174
|
+
|
|
175
|
+
Chat Completions is not a full Responses API replacement. Some Responses features have no equivalent upstream field.
|
|
176
|
+
|
|
177
|
+
- Hosted tools such as web search, file search, computer use, image generation, and code interpreter are represented as function-tool shims unless Codex executes matching tools locally.
|
|
178
|
+
- OpenAI `file_id` values are passed through; the gateway cannot fetch private OpenAI-hosted files.
|
|
179
|
+
- In-memory conversation history is lost when the gateway restarts.
|
|
180
|
+
- 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.
|
|
181
|
+
|
|
182
|
+
## Local Testing Before Publish
|
|
183
|
+
|
|
184
|
+
From this repository:
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
npm test
|
|
188
|
+
npm pack
|
|
189
|
+
npx --yes --package ./<generated-tarball>.tgz codex-deepseek-gateway install --no-edit
|
|
190
|
+
npx --yes --package ./<generated-tarball>.tgz codex-deepseek-gateway status
|
|
191
|
+
npx --yes --package ./<generated-tarball>.tgz codex-deepseek-gateway doctor
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Replace `<generated-tarball>.tgz` with the filename printed by `npm pack`.
|
|
195
|
+
|
|
196
|
+
For published usage, use the shorter package command shown in the install section.
|
|
197
|
+
|
|
198
|
+
## Publish
|
|
199
|
+
|
|
200
|
+
When ready:
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
npm login
|
|
204
|
+
npm test
|
|
205
|
+
npm pack --dry-run
|
|
206
|
+
npm publish
|
|
207
|
+
```
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
copyFileSync,
|
|
5
|
+
cpSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { loadConfig } from '../src/config.js';
|
|
16
|
+
import { toProviderChatCompletionsRequest } from '../src/protocol.js';
|
|
17
|
+
|
|
18
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const packageJson = require('../package.json');
|
|
21
|
+
|
|
22
|
+
function print(message = '') {
|
|
23
|
+
process.stdout.write(message);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function usage() {
|
|
27
|
+
print(`Codex DeepSeek Gateway
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
codex-deepseek-gateway install
|
|
31
|
+
codex-deepseek-gateway start
|
|
32
|
+
codex-deepseek-gateway stop
|
|
33
|
+
codex-deepseek-gateway status
|
|
34
|
+
codex-deepseek-gateway doctor
|
|
35
|
+
codex-deepseek-gateway uninstall
|
|
36
|
+
|
|
37
|
+
Options:
|
|
38
|
+
--dir <path> Install directory, defaults to ~/.codex/deepseek-gateway
|
|
39
|
+
--no-edit Do not open the local config file after install
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseArgs(argv) {
|
|
44
|
+
const options = { dir: defaultInstallDir(), noEdit: false };
|
|
45
|
+
const rest = [];
|
|
46
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
47
|
+
const arg = argv[index];
|
|
48
|
+
if (arg === '--dir' || arg === '-d' || arg === '-InstallDir') {
|
|
49
|
+
const value = argv[index + 1];
|
|
50
|
+
if (!value) throw new Error(`${arg} requires a path`);
|
|
51
|
+
options.dir = resolve(value);
|
|
52
|
+
index += 1;
|
|
53
|
+
} else if (arg === '--no-edit' || arg === '-NoEdit') {
|
|
54
|
+
options.noEdit = true;
|
|
55
|
+
} else {
|
|
56
|
+
rest.push(arg);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { command: rest[0], options };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function defaultInstallDir() {
|
|
63
|
+
const home = process.env.CODEX_HOME || join(process.env.USERPROFILE || process.env.HOME || process.cwd(), '.codex');
|
|
64
|
+
return join(home, 'deepseek-gateway');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function configPath(installDir) {
|
|
68
|
+
return join(installDir, 'config', 'gateway.local.json');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pidPath(installDir) {
|
|
72
|
+
return join(installDir, 'gateway.pid');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function serverPath(installDir) {
|
|
76
|
+
return join(installDir, 'src', 'server.js');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function loadInstalledConfig(installDir) {
|
|
80
|
+
return loadConfig({ ...process.env, GATEWAY_CONFIG_FILE: configPath(installDir) });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function endpoint(config, path = '') {
|
|
84
|
+
return `http://${config.host}:${config.port}${path}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isConfigured(installDir) {
|
|
88
|
+
if (!existsSync(configPath(installDir))) return false;
|
|
89
|
+
const config = loadInstalledConfig(installDir);
|
|
90
|
+
return Boolean(config.upstreamApiKey);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function health(config) {
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetch(endpoint(config, '/health'), { signal: controller.signal });
|
|
98
|
+
if (!response.ok) return null;
|
|
99
|
+
return await response.json();
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function processExists(pid) {
|
|
108
|
+
if (!pid) return false;
|
|
109
|
+
try {
|
|
110
|
+
process.kill(pid, 0);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function killProcess(pid) {
|
|
118
|
+
if (!pid || pid === process.pid) return;
|
|
119
|
+
try {
|
|
120
|
+
if (process.platform === 'win32') {
|
|
121
|
+
execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
122
|
+
} else {
|
|
123
|
+
process.kill(pid);
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findWindowsPidOnPort(port) {
|
|
130
|
+
if (process.platform !== 'win32') return 0;
|
|
131
|
+
try {
|
|
132
|
+
const output = execFileSync('netstat.exe', ['-ano', '-p', 'tcp'], { encoding: 'utf8', windowsHide: true });
|
|
133
|
+
const escapedPort = String(port).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
134
|
+
const pattern = new RegExp(`(?:127\\.0\\.0\\.1|0\\.0\\.0\\.0|\\[::1\\]|\\[::\\]):${escapedPort}\\s+[^\\s]+\\s+LISTENING\\s+(\\d+)`, 'i');
|
|
135
|
+
for (const line of output.split(/\r?\n/)) {
|
|
136
|
+
const match = line.match(pattern);
|
|
137
|
+
if (match) return Number(match[1]) || 0;
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function processCommandLine(pid) {
|
|
145
|
+
if (process.platform !== 'win32' || !pid) return '';
|
|
146
|
+
try {
|
|
147
|
+
const output = execFileSync(
|
|
148
|
+
'wmic.exe',
|
|
149
|
+
['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine', '/value'],
|
|
150
|
+
{ encoding: 'utf8', windowsHide: true },
|
|
151
|
+
);
|
|
152
|
+
const match = output.match(/CommandLine=(.*)/s);
|
|
153
|
+
return match ? match[1].trim() : '';
|
|
154
|
+
} catch {
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readPid(installDir) {
|
|
160
|
+
try {
|
|
161
|
+
return Number(readFileSync(pidPath(installDir), 'utf8').trim()) || 0;
|
|
162
|
+
} catch {
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function copyRuntime(installDir) {
|
|
168
|
+
mkdirSync(join(installDir, 'bin'), { recursive: true });
|
|
169
|
+
mkdirSync(join(installDir, 'src'), { recursive: true });
|
|
170
|
+
mkdirSync(join(installDir, 'config'), { recursive: true });
|
|
171
|
+
copyFileSync(join(ROOT, 'package.json'), join(installDir, 'package.json'));
|
|
172
|
+
rmSync(join(installDir, 'bin'), { recursive: true, force: true });
|
|
173
|
+
rmSync(join(installDir, 'src'), { recursive: true, force: true });
|
|
174
|
+
cpSync(join(ROOT, 'bin'), join(installDir, 'bin'), { recursive: true });
|
|
175
|
+
cpSync(join(ROOT, 'src'), join(installDir, 'src'), { recursive: true });
|
|
176
|
+
const modelAliasesConfig = join(installDir, 'config', 'model-aliases.json');
|
|
177
|
+
if (!existsSync(modelAliasesConfig)) {
|
|
178
|
+
copyFileSync(join(ROOT, 'config', 'model-aliases.example.json'), modelAliasesConfig);
|
|
179
|
+
}
|
|
180
|
+
const localConfig = configPath(installDir);
|
|
181
|
+
if (!existsSync(localConfig)) {
|
|
182
|
+
copyFileSync(join(ROOT, 'config', 'gateway.example.json'), localConfig);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function openConfig(file) {
|
|
187
|
+
const opener =
|
|
188
|
+
process.platform === 'win32'
|
|
189
|
+
? ['notepad.exe', [file]]
|
|
190
|
+
: process.platform === 'darwin'
|
|
191
|
+
? ['open', [file]]
|
|
192
|
+
: ['xdg-open', [file]];
|
|
193
|
+
try {
|
|
194
|
+
const child = spawn(opener[0], opener[1], { detached: true, stdio: 'ignore' });
|
|
195
|
+
child.on('error', () => print(`Edit config: ${file}\n`));
|
|
196
|
+
child.unref();
|
|
197
|
+
} catch {
|
|
198
|
+
print(`Edit config: ${file}\n`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function install(options) {
|
|
203
|
+
copyRuntime(options.dir);
|
|
204
|
+
print(`Installed to ${options.dir}\n`);
|
|
205
|
+
if (!isConfigured(options.dir)) {
|
|
206
|
+
print(`Edit this file and put your DeepSeek API key:\n ${configPath(options.dir)}\n`);
|
|
207
|
+
if (!options.noEdit) openConfig(configPath(options.dir));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
await start(options);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function start(options) {
|
|
214
|
+
if (!existsSync(serverPath(options.dir))) {
|
|
215
|
+
throw new Error(`Missing runtime at ${options.dir}. Run install first.`);
|
|
216
|
+
}
|
|
217
|
+
if (!isConfigured(options.dir)) {
|
|
218
|
+
throw new Error(`Missing DeepSeek API key in ${configPath(options.dir)} or DEEPSEEK_API_KEY`);
|
|
219
|
+
}
|
|
220
|
+
const config = loadInstalledConfig(options.dir);
|
|
221
|
+
const currentHealth = await health(config);
|
|
222
|
+
if (currentHealth?.ok) {
|
|
223
|
+
const pid = readPid(options.dir);
|
|
224
|
+
const message = processExists(pid)
|
|
225
|
+
? 'Gateway is already running.\n'
|
|
226
|
+
: `Gateway is already reachable at ${endpoint(config)}. Another install may already be running.\n`;
|
|
227
|
+
print(message);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
mkdirSync(options.dir, { recursive: true });
|
|
232
|
+
const out = 'ignore';
|
|
233
|
+
print(`Starting gateway on ${endpoint(config)} ...\n`);
|
|
234
|
+
const child = spawn(process.execPath, [serverPath(options.dir)], {
|
|
235
|
+
cwd: options.dir,
|
|
236
|
+
detached: true,
|
|
237
|
+
stdio: ['ignore', out, out],
|
|
238
|
+
windowsHide: true,
|
|
239
|
+
});
|
|
240
|
+
writeFileSync(pidPath(options.dir), String(child.pid));
|
|
241
|
+
child.unref();
|
|
242
|
+
|
|
243
|
+
for (let index = 0; index < 20; index += 1) {
|
|
244
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
|
245
|
+
const ready = await health(config);
|
|
246
|
+
if (ready?.ok) {
|
|
247
|
+
print(`Gateway started. PID ${child.pid}\n`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
throw new Error(`Gateway did not become reachable on ${endpoint(config)}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function stop(options) {
|
|
255
|
+
const config = loadInstalledConfig(options.dir);
|
|
256
|
+
const pid = readPid(options.dir);
|
|
257
|
+
const hadManagedPid = processExists(pid);
|
|
258
|
+
let killedPortProcess = false;
|
|
259
|
+
if (processExists(pid)) {
|
|
260
|
+
killProcess(pid);
|
|
261
|
+
for (let index = 0; index < 20 && processExists(pid); index += 1) {
|
|
262
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const portPid = findWindowsPidOnPort(config.port);
|
|
266
|
+
if (portPid && portPid !== pid) {
|
|
267
|
+
const commandLine = processCommandLine(portPid);
|
|
268
|
+
if (commandLine.includes(serverPath(options.dir))) {
|
|
269
|
+
killProcess(portPid);
|
|
270
|
+
killedPortProcess = true;
|
|
271
|
+
for (let index = 0; index < 20 && processExists(portPid); index += 1) {
|
|
272
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
rmSync(pidPath(options.dir), { force: true });
|
|
277
|
+
const running = await health(config);
|
|
278
|
+
if (!running?.ok) {
|
|
279
|
+
print('Gateway stopped.\n');
|
|
280
|
+
} else if (!hadManagedPid && !killedPortProcess) {
|
|
281
|
+
print(`No gateway process is recorded for this install. ${endpoint(config)} is still reachable, likely from another install.\n`);
|
|
282
|
+
} else {
|
|
283
|
+
print(`Gateway may still be running on ${endpoint(config)}.\n`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function status(options) {
|
|
288
|
+
const config = loadInstalledConfig(options.dir);
|
|
289
|
+
const pid = readPid(options.dir);
|
|
290
|
+
const running = await health(config);
|
|
291
|
+
const versionPath = join(options.dir, 'package.json');
|
|
292
|
+
const installedVersion = existsSync(versionPath) ? JSON.parse(readFileSync(versionPath, 'utf8')).version : '';
|
|
293
|
+
print(JSON.stringify({
|
|
294
|
+
installed: existsSync(options.dir),
|
|
295
|
+
installDir: options.dir,
|
|
296
|
+
configPath: configPath(options.dir),
|
|
297
|
+
pid,
|
|
298
|
+
pidRunning: processExists(pid),
|
|
299
|
+
reachable: Boolean(running?.ok),
|
|
300
|
+
url: endpoint(config),
|
|
301
|
+
version: installedVersion || packageJson.version,
|
|
302
|
+
}, null, 2));
|
|
303
|
+
print('\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function doctor(options) {
|
|
307
|
+
const config = loadInstalledConfig(options.dir);
|
|
308
|
+
const codexConfigUsingGateway = config.codexModelProvider === 'deepseek-gateway';
|
|
309
|
+
const model = codexConfigUsingGateway && config.codexModel ? config.codexModel : 'deepseek-v4-pro';
|
|
310
|
+
const upstreamRequest = toProviderChatCompletionsRequest(
|
|
311
|
+
{
|
|
312
|
+
model,
|
|
313
|
+
messages: [{ role: 'user', content: 'ping' }],
|
|
314
|
+
stream: true,
|
|
315
|
+
},
|
|
316
|
+
config,
|
|
317
|
+
);
|
|
318
|
+
const supportsReasoningSummaries = String(config.codexModelSupportsReasoningSummaries).toLowerCase() === 'true';
|
|
319
|
+
const summaryMode = String(config.codexReasoningSummary || '').toLowerCase();
|
|
320
|
+
const hideAgentReasoning = String(config.codexHideAgentReasoning).toLowerCase() === 'true';
|
|
321
|
+
const thinkingEnabled = upstreamRequest.thinking?.type === 'enabled';
|
|
322
|
+
const reasoningDisplayMode = hideAgentReasoning
|
|
323
|
+
? 'hidden'
|
|
324
|
+
: thinkingEnabled
|
|
325
|
+
? 'summary'
|
|
326
|
+
: 'disabled';
|
|
327
|
+
const codexSummaryConfigured = supportsReasoningSummaries && summaryMode && summaryMode !== 'none';
|
|
328
|
+
print(JSON.stringify({
|
|
329
|
+
packageVersion: packageJson.version,
|
|
330
|
+
installDir: options.dir,
|
|
331
|
+
localConfig: configPath(options.dir),
|
|
332
|
+
codexConfigUsingGateway,
|
|
333
|
+
codexModelProvider: config.codexModelProvider || null,
|
|
334
|
+
codexModel: config.codexModel || null,
|
|
335
|
+
codexReasoningEffort: config.codexReasoningEffort || null,
|
|
336
|
+
codexReasoningSummary: config.codexReasoningSummary || null,
|
|
337
|
+
codexModelSupportsReasoningSummaries: config.codexModelSupportsReasoningSummaries || null,
|
|
338
|
+
codexHideAgentReasoning: config.codexHideAgentReasoning || null,
|
|
339
|
+
sampleModel: model,
|
|
340
|
+
upstreamModel: upstreamRequest.model,
|
|
341
|
+
deepseekThinking: upstreamRequest.thinking || null,
|
|
342
|
+
deepseekReasoningEffort: upstreamRequest.reasoning_effort || null,
|
|
343
|
+
reasoningDisplayMode,
|
|
344
|
+
gatewayEmitsReasoningSummary: reasoningDisplayMode === 'summary',
|
|
345
|
+
codexSummaryConfigured,
|
|
346
|
+
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.',
|
|
347
|
+
modelDiscoveryHint: 'The gateway exposes model aliases on /v1/models. Whether Codex TUI /model shows them depends on the Codex build.',
|
|
348
|
+
}, null, 2));
|
|
349
|
+
print('\n');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function uninstall(options) {
|
|
353
|
+
await stop(options);
|
|
354
|
+
rmSync(options.dir, { recursive: true, force: true });
|
|
355
|
+
print(`Removed ${options.dir}\n`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function main() {
|
|
359
|
+
const { command, options } = parseArgs(process.argv.slice(2));
|
|
360
|
+
if (!command || command === '-h' || command === '--help' || command === 'help') {
|
|
361
|
+
usage();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const commands = { install, start, stop, status, doctor, uninstall };
|
|
365
|
+
const handler = commands[command];
|
|
366
|
+
if (!handler) {
|
|
367
|
+
throw new Error(`Unknown command: ${command}`);
|
|
368
|
+
}
|
|
369
|
+
await handler(options);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
main().catch((error) => {
|
|
373
|
+
process.stderr.write(`${error.message || error}\n`);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"port": 3000,
|
|
3
|
+
"host": "127.0.0.1",
|
|
4
|
+
"upstreamProvider": "deepseek",
|
|
5
|
+
"upstreamBaseUrl": "https://api.deepseek.com",
|
|
6
|
+
"upstreamApiKey": "sk-REPLACE_ME",
|
|
7
|
+
"upstreamTimeoutMs": 120000,
|
|
8
|
+
"fetchUpstreamModels": false,
|
|
9
|
+
"modelsTimeoutMs": 5000,
|
|
10
|
+
"modelsCacheMs": 60000,
|
|
11
|
+
"debugPayload": false
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@galaxy-yearn/codex-deepseek-gateway",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local Codex Responses API to DeepSeek Chat Completions gateway.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"codex-deepseek-gateway": "bin/codex-deepseek-gateway.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"config",
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"codex",
|
|
24
|
+
"deepseek",
|
|
25
|
+
"openai",
|
|
26
|
+
"responses-api",
|
|
27
|
+
"chat-completions",
|
|
28
|
+
"gateway"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|