Skip to content

Commit 8b1bca4

Browse files
committed
added codex gateway
1 parent 8d95954 commit 8b1bca4

6 files changed

Lines changed: 681 additions & 0 deletions

File tree

‎tools/dsh/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ This directory is the repository copy of the RTX 3080 Bonsai coding preset and i
44
Make future changes here, run the tests, and copy the preset files to the DSH installation.
55
The server profile remains in [`../windows/profiles.json`](../windows/profiles.json).
66

7+
To select these and the other configured DSH models in Codex CLI, use the
8+
[client gateway and `/model` setup](client-gateway/README.md).
9+
710
The eight files in [`bonsai2-heretic-3080/`](bonsai2-heretic-3080/) contain the preset composition,
811
native NInfer adapter, timing projection, loop guard, prompt projection, recoverable compaction,
912
and on-demand skill discovery. The six JavaScript modules match the deployed implementation. The configuration derives the

‎tools/dsh/client-gateway/README.md‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Use DSH models in Codex CLI
2+
3+
Launch `codex -p dsh`, then enter `/model` to choose a configured DSH model and
4+
its reasoning effort. The picker can apply a choice to the current session
5+
with **s**, or save it as the profile default with **Enter**. Plain `codex`
6+
keeps the existing base configuration and provider.
7+
8+
This integration was verified with Codex CLI 0.157.0, DSH 0.1.5-rc.3 and Node
9+
24.19.0 on Windows. The CLI uses one provider connection per session. Its
10+
[custom provider transport](https://learn.chatgpt.com/docs/config-file/config-reference)
11+
accepts Responses, while the configured DSH services also use Chat Completions
12+
and Anthropic Messages. This localhost gateway reuses the installed DSH adapters
13+
and exposes the text/tool portion of Responses needed by the CLI. It does not
14+
run another agent loop or change DSH sessions.
15+
16+
## Install and refresh
17+
18+
Use the Node installation that contains the global DSH package, as described in
19+
the [preset instructions](../README.md). From this repository:
20+
21+
```powershell
22+
node tools/dsh/client-gateway/server.mjs --install
23+
codex -p dsh
24+
```
25+
26+
The installer reads DSH's `settings.yaml`, provider rows in `cordis.patch.yml`,
27+
and its installed model definitions. The native NInfer route reads the loaded
28+
server's actual model/context metadata. Keep that server available during
29+
installation. The generated catalog preserves provider/model identity, context
30+
capacity and supported reasoning levels; summary and legacy routes remain
31+
distinct. Model IDs include the provider prefix to prevent ambiguous routing.
32+
33+
Installation creates `$CODEX_HOME/dsh.config.toml` (normally under `~/.codex`),
34+
and a catalog and local authentication token under
35+
`$CODEX_HOME/dsh-client-gateway`. These paths are shared by the desktop app and
36+
ordinary terminals. AppData is not used because Windows can redirect writes from
37+
the packaged desktop app into a private directory, causing terminal startup to
38+
fail with `Error loading configuration: ... (os error 3)`.
39+
Existing generated files are backed up before
40+
replacement. The base CLI configuration is not edited. The starting model and
41+
default subagent model follow DSH's default at installation time; `/model`
42+
changes the main session selection. Explicit subagent overrides must also use
43+
the provider-prefixed catalog IDs.
44+
45+
The profile's credential helper starts the gateway automatically when needed,
46+
hidden and bound to `127.0.0.1:18021`. The helper receives absolute state and DSH
47+
home paths so launching from a different Windows environment does not change
48+
which credential store it uses. Backend keys remain in DSH's existing store or
49+
environment and are read when a request needs them. They are not copied into
50+
the CLI profile, catalog, repository or logs. The separate local gateway token
51+
is consumed privately by the CLI; do not invoke `--auth-token` in a terminal.
52+
53+
After changing DSH's model configuration, rerun `--install` and reopen the CLI.
54+
This refreshes the catalog and reloads a running gateway's routes; an incomplete
55+
reload preserves the previous routes. Updating gateway JavaScript itself needs
56+
a restart of that gateway process. Its PID is recorded in the local
57+
`gateway.log`; verify its command line before stopping it. The next CLI model
58+
request starts the updated gateway. No DSH or NInfer restart is required.
59+
60+
Use an exact catalog ID to start directly on Bonsai:
61+
62+
```powershell
63+
codex -p dsh -m qwen-3080/bonsai2-heretic -c model_reasoning_effort=low
64+
```
65+
66+
## Behavior and limits
67+
68+
Text, visible reasoning, function calls, custom `apply_patch` input, namespaced
69+
tools and tool results are translated without executing tools in the gateway.
70+
Codex continues to own execution, permissions, hooks and conversation history.
71+
The gateway preserves usage and failure status, propagates cancellation, and
72+
publishes tool calls only after successful generation. Context errors and output
73+
limits remain errors/incomplete responses, rather than being reported as success.
74+
75+
This catalog advertises text input only, including for model names containing
76+
“Vision.” Image/file uploads and provider-hosted search tools are not implemented;
77+
unsupported input is rejected explicitly. CLI web search is disabled in this
78+
profile. This does not prevent separately configured client-side MCP tools.
79+
Stored response IDs and remote Responses compaction are not supported; the
80+
custom-provider CLI sends full history and performs its own local compaction.
81+
The DSH Bonsai loop guard and compactor are agent plugins, so they are not part
82+
of a Codex session; the native NInfer adapter still provides exact prompt counts
83+
and answer-space reservation for its route.
84+
85+
The catalog describes configured routes, not live availability. It does not
86+
load a remote model, switch a NInfer server profile, or substitute another model
87+
after an error. An unloaded legacy Bonsai route needs its matching server; remote
88+
services and their existing credentials must remain available.
89+
90+
## Verification
91+
92+
```powershell
93+
node --test tools/dsh/client-gateway/gateway.test.mjs
94+
```
95+
96+
Eight offline tests cover catalog metadata, instruction/tool replay, explicit
97+
unsupported-input failures, streaming usage, custom and namespaced tool calls,
98+
failed-generation handling, HTTP authentication, disconnect cancellation and
99+
atomic catalog reload behavior. They use local mock adapters and make no model
100+
requests.
101+
102+
On September 27, 2026, the installed CLI loaded all 30 configured DSH routes.
103+
The interactive `/model` picker was opened, and a session switched from Fast
104+
Qwen to Z.ai GLM-4.7. Live text requests succeeded through Fast Qwen, Z.ai,
105+
the loaded Unsloth Qwen route and native NInfer Bonsai. A CLI coding probe using
106+
Fast Qwen created a fixture through `apply_patch`, read it back with one
107+
PowerShell command and reported the independently observed marker. This final
108+
probe used an isolated directory and the user's existing execution policy,
109+
without unrelated hooks/plugins. A normal-profile probe also applied its patch,
110+
but the repository's existing post-tool hook failed afterward.
111+
112+
H200 Qwen's configured proxy returned a backend connection error during its
113+
probe. The route remains selectable; its backend must recover before generation
114+
can work. Other configured models were catalog-validated, not all generated
115+
against. This qualification establishes routing and the exercised coding/tool
116+
workflow, not model quality or full Responses API compatibility.
117+
118+
The Windows path correction was checked against the physical filesystem outside
119+
the packaged app: the original AppData catalog path was missing, while the
120+
replacement catalog and token under `~/.codex` were readable. A fresh CLI request
121+
from `~/Postman` using the repaired profile completed successfully; all eight
122+
offline gateway tests also passed.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
import { once } from 'node:events';
4+
import { translateRequest, responseEvents } from './responses.mjs';
5+
import { gatewayServer } from './server.mjs';
6+
import { clientCatalog } from './runtime.mjs';
7+
8+
const route = { id: 'fixture/model', provider: 'fixture', model: 'model', info: {
9+
name: 'Fixture', context: { contextWindow: 65536 }, defaultMaxTokens: 8192,
10+
reasoning: { efforts: [{ id: 'off' }, { id: 'low' }], defaultEffort: 'low' },
11+
} };
12+
async function collect(source) { const result = []; for await (const event of source) result.push(structuredClone(event)); return result; }
13+
async function* chunks(block, finish = { kind: 'stop' }) {
14+
yield { type: 'block-start', index: 0, blockType: block.type };
15+
if (block.type === 'text') yield { type: 'text-delta', index: 0, text: block.text };
16+
yield { type: 'block-end', index: 0, block };
17+
yield { type: 'usage', usage: { inputTokens: 12, cacheReadTokens: 3, outputTokens: 8, reasoningTokens: 2 } };
18+
yield { type: 'finish', reason: finish };
19+
}
20+
21+
test('all exact routes enter the catalog with their own context and efforts', () => {
22+
const models = clientCatalog({ routes: new Map([[route.id, route]]), defaultModel: route.id }).models;
23+
assert.equal(models[0].slug, route.id);
24+
assert.equal(models[0].context_window, 65536);
25+
assert.deepEqual(models[0].supported_reasoning_levels.map(x => x.effort), ['none', 'low']);
26+
assert.deepEqual(models[0].input_modalities, ['text']);
27+
assert.equal(models[0].priority, 0);
28+
});
29+
30+
test('instructions, function calls, tool evidence and custom patches survive replay', () => {
31+
const input = { instructions: 'top instructions', reasoning: { effort: 'none' }, tools: [
32+
{ type: 'function', name: 'read', parameters: { type: 'object' } },
33+
{ type: 'custom', name: 'apply_patch' },
34+
], input: [
35+
{ role: 'developer', content: 'developer instructions' },
36+
{ role: 'user', content: [{ type: 'input_text', text: 'fix it' }] },
37+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'inspect first' }] },
38+
{ type: 'function_call', name: 'read', call_id: 'c1', arguments: '{"path":"x"}' },
39+
{ type: 'function_call_output', call_id: 'c1', output: 'original source' },
40+
{ type: 'custom_tool_call', name: 'apply_patch', call_id: 'c2', input: '*** Begin Patch\n*** End Patch' },
41+
{ type: 'custom_tool_call_output', call_id: 'c2', output: 'done' },
42+
] };
43+
const before = structuredClone(input);
44+
const { options, toolMap } = translateRequest(input, route);
45+
assert.equal(options.system, 'top instructions\n\ndeveloper instructions');
46+
assert.equal(options.reasoningEffort, 'off');
47+
assert.equal(options.messages[2].content[0].content[0].text, 'original source');
48+
assert.deepEqual(JSON.parse(options.messages[3].content[0].arguments), { input: '*** Begin Patch\n*** End Patch' });
49+
assert.equal(toolMap.get('apply_patch').type, 'custom');
50+
assert.deepEqual(input, before);
51+
});
52+
53+
test('unsupported inputs and efforts fail explicitly instead of silently discarding data', () => {
54+
for (const body of [
55+
{ input: [], previous_response_id: 'other' },
56+
{ input: [{ role: 'user', content: [{ type: 'input_image', image_url: 'anything' }] }] },
57+
{ input: [], reasoning: { effort: 'xhigh' } },
58+
{ input: [], tools: [{ type: 'web_search' }] },
59+
]) assert.throws(() => translateRequest(body, route));
60+
});
61+
62+
test('text and reasoning preserve terminal status and cached usage', async () => {
63+
const events = await collect(responseEvents(chunks({ type: 'text', text: 'ready' }), route.id, new Map()));
64+
assert.equal(events.filter(x => x.type === 'response.output_text.delta').map(x => x.delta).join(''), 'ready');
65+
const terminal = events.at(-1);
66+
assert.equal(terminal.type, 'response.completed');
67+
assert.equal(terminal.response.usage.input_tokens, 15);
68+
assert.equal(terminal.response.usage.input_tokens_details.cached_tokens, 3);
69+
assert.equal(terminal.response.usage.total_tokens, 23);
70+
assert.equal(terminal.response.output[0].content[0].text, 'ready');
71+
const reasoning = await collect(responseEvents(chunks({ type: 'reasoning', text: 'thought' }), route.id, new Map()));
72+
assert.equal(reasoning.at(-1).response.output[0].summary[0].text, 'thought');
73+
});
74+
75+
test('custom and namespaced tools round-trip through function-only adapters', async () => {
76+
const body = { input: [], tools: [{ type: 'namespace', name: 'functions', tools: [{ type: 'custom', name: 'apply_patch' }] }] };
77+
const { options, toolMap } = translateRequest(body, route);
78+
assert.equal(options.tools[0].name, 'functions__apply_patch');
79+
const events = await collect(responseEvents(chunks({ type: 'tool-call', name: 'functions__apply_patch', id: 'c1', arguments: '{"input":"patch text"}' }, { kind: 'tool-calls' }), route.id, toolMap));
80+
const call = events.at(-1).response.output[0];
81+
assert.equal(call.type, 'custom_tool_call'); assert.equal(call.namespace, 'functions');
82+
assert.equal(call.name, 'apply_patch'); assert.equal(call.input, 'patch text');
83+
assert.equal(call.call_id, 'c1');
84+
});
85+
86+
test('truncation and context errors never become completed responses', async () => {
87+
for (const [finish, type] of [
88+
[{ kind: 'max-tokens' }, 'response.incomplete'],
89+
[{ kind: 'error', failure: { code: 'CONTEXT_WINDOW_EXCEEDED', message: 'too large' } }, 'response.failed'],
90+
]) {
91+
const events = await collect(responseEvents(chunks({ type: 'text', text: 'partial' }, finish), route.id, new Map()));
92+
assert.equal(events.at(-1).type, type);
93+
assert.ok(!events.some(e => e.type === 'response.completed'));
94+
const tools = new Map([['write', { type: 'function', name: 'write' }]]);
95+
const failedCall = await collect(responseEvents(chunks({ type: 'tool-call', name: 'write', id: 'c', arguments: '{}' }, finish), route.id, tools));
96+
assert.ok(!failedCall.some(e => e.type === 'response.output_item.done'), 'failed generations expose no executable tool call');
97+
}
98+
});
99+
100+
test('HTTP authentication, routing, SSE and disconnect cancellation work end to end', async t => {
101+
let sawAbort = false;
102+
const adapter = { async *stream(options) {
103+
assert.equal(options.provider, 'fixture'); assert.equal(options.model, 'model');
104+
if (options.messages[0]?.content[0]?.text === 'wait') {
105+
await new Promise(resolve => options.signal.addEventListener('abort', () => { sawAbort = true; resolve(); }, { once: true }));
106+
yield { type: 'finish', reason: { kind: 'aborted', failure: { code: 'ABORTED', message: 'aborted' } } }; return;
107+
}
108+
yield* chunks({ type: 'text', text: 'ready' });
109+
} };
110+
const server = gatewayServer({ routes: new Map([[route.id, { ...route, adapter }]]) }, 'test-only');
111+
server.listen(0, '127.0.0.1'); await once(server, 'listening');
112+
t.after(() => { server.closeAllConnections(); server.close(); });
113+
const base = `http://127.0.0.1:${server.address().port}`;
114+
assert.equal((await fetch(base + '/v1/models')).status, 401);
115+
const headers = { authorization: 'Bearer test-only', 'content-type': 'application/json' };
116+
const models = await (await fetch(base + '/v1/models', { headers })).json();
117+
assert.equal(models.data[0].id, route.id);
118+
assert.equal((await fetch(base + '/v1/responses', { method: 'POST', headers, body: JSON.stringify({ model: 'missing', input: 'x' }) })).status, 404);
119+
const response = await fetch(base + '/v1/responses', { method: 'POST', headers, body: JSON.stringify({ model: route.id, input: 'x', stream: true }) });
120+
const text = await response.text(); assert.match(text, /event: response.completed/); assert.match(text, /ready/);
121+
const cancel = new AbortController();
122+
const pending = await fetch(base + '/v1/responses', { method: 'POST', headers, body: JSON.stringify({ model: route.id, input: 'wait', stream: true }), signal: cancel.signal });
123+
await pending.body.getReader().read(); cancel.abort();
124+
for (let i = 0; i < 20 && !sawAbort; i++) await new Promise(resolve => setTimeout(resolve, 10));
125+
assert.equal(sawAbort, true);
126+
});
127+
128+
test('catalog reload is authenticated and preserves working routes on failure', async t => {
129+
let fail = false;
130+
const initial = { routes: new Map([[route.id, route]]) };
131+
const next = { routes: new Map([['fixture/second', { ...route, id: 'fixture/second' }]]), failures: [] };
132+
const server = gatewayServer(initial, 'test-only', async () => {
133+
if (fail) return { ...initial, failures: [{ message: 'offline' }] };
134+
return next;
135+
});
136+
server.listen(0, '127.0.0.1'); await once(server, 'listening');
137+
t.after(() => { server.closeAllConnections(); server.close(); });
138+
const base = `http://127.0.0.1:${server.address().port}`;
139+
assert.equal((await fetch(base + '/reload', { method: 'POST' })).status, 401);
140+
const headers = { authorization: 'Bearer test-only' };
141+
assert.equal((await fetch(base + '/reload', { method: 'POST', headers })).status, 200);
142+
fail = true;
143+
assert.equal((await fetch(base + '/reload', { method: 'POST', headers })).status, 503);
144+
const models = await (await fetch(base + '/v1/models', { headers })).json();
145+
assert.equal(models.data[0].id, 'fixture/second');
146+
});

0 commit comments

Comments
 (0)