@mmmbuto/zai-codex-bridge 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.4.3] - 2026-01-16
9
+
10
+ ### Added
11
+ - Auto-enable tool bridging when tool-related fields are present in the request
12
+ - Extra logging to surface `allowTools` and `toolsPresent` per request
13
+ - Debug tool summary logging (types and sample names)
14
+
15
+ ### Fixed
16
+ - Correct output_index mapping for streaming tool call events
17
+ - Filter non-function tools to avoid upstream schema errors
18
+
19
+ ### Changed
20
+ - README guidance for MCP/tools troubleshooting and proxy startup
21
+
22
+ ## [0.4.2] - 2026-01-16
23
+
24
+ ### Changed
25
+ - Replaced the README with expanded setup, usage, and troubleshooting guidance
26
+ - Clarified Codex provider configuration and proxy endpoint usage
27
+
28
+ ## [0.4.1] - 2026-01-16
29
+
30
+ ### Added
31
+ - Tool calling support (MCP/function calls) when `ALLOW_TOOLS=1`
32
+ - Bridging for `function_call_output` items to Chat `role: tool` messages
33
+ - Streaming support for `delta.tool_calls` with proper Responses API events
34
+ - Non-streaming support for `msg.tool_calls` in final response
35
+ - Tool call events: `response.output_item.added` (function_call), `response.function_call_arguments.delta`, `response.function_call_arguments.done`
36
+ - Automated tool call test in test suite
37
+
38
+ ### Changed
39
+ - `translateResponsesToChat()` now handles `type: function_call_output` items
40
+ - `streamChatToResponses()` now detects and emits tool call events
41
+ - `translateChatToResponses()` now includes `function_call` items in output array
42
+
43
+ ### Fixed
44
+ - Tool responses (from MCP/function calls) are now correctly forwarded to upstream as `role: tool` messages
45
+ - Function call items are now properly included in `response.completed` output array
46
+
47
+ ## [0.4.0] - Previous
48
+
49
+ ### Added
50
+ - Initial release with Responses API to Chat Completions translation
51
+ - Streaming support with SSE
52
+ - Health check endpoint
53
+ - Zero-dependency implementation
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Z.AI Codex Bridge
1
+ # ZAI Codex Bridge
2
2
 
3
- > Local proxy that translates OpenAI Responses API format to Z.AI Chat Completions format
3
+ > Local proxy that translates OpenAI **Responses API** Z.AI **Chat Completions** for Codex CLI
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@mmmbuto/zai-codex-bridge?style=flat-square&logo=npm)](https://www.npmjs.org/package/@mmmbuto/zai-codex-bridge)
6
6
  [![node](https://img.shields.io/node/v/@mmmbuto/zai-codex-bridge?style=flat-square&logo=node.js)](https://github.com/DioNanos/zai-codex-bridge)
@@ -10,36 +10,39 @@
10
10
 
11
11
  ## What It Solves
12
12
 
13
- Codex uses the OpenAI **Responses API** format (with `instructions` and `input` fields), but Z.AI only supports the legacy **Chat Completions** format (with `messages` array).
13
+ Newer **Codex CLI** versions speak the OpenAI **Responses API** (e.g. `/v1/responses`, with `instructions` + `input` + event-stream semantics).
14
+ Some gateways/providers (including Z.AI endpoints) only expose legacy **Chat Completions** (`messages[]`).
14
15
 
15
16
  This proxy:
16
- 1. Accepts Codex requests in **Responses format**
17
- 2. Translates them to **Chat format**
17
+ 1. Accepts Codex requests in **Responses** format
18
+ 2. Translates them to **Chat Completions**
18
19
  3. Forwards to Z.AI
19
- 4. Translates the response back to **Responses format**
20
+ 4. Translates back to **Responses** format (stream + non-stream)
20
21
  5. Returns to Codex
21
22
 
22
- **Without this proxy**, Codex fails with error from Z.AI:
23
+ **Without this proxy**, Codex may fail (example from upstream error payloads):
23
24
  ```json
24
25
  {"error":{"code":"1214","message":"Incorrect role information"}}
25
26
  ```
26
27
 
28
+ > If you’re using **codex-termux** and a gateway that doesn’t fully match the Responses API, this proxy is the recommended compatibility layer.
29
+
27
30
  ---
28
31
 
29
32
  ## Features
30
33
 
31
- - Transparent translation between Responses and Chat formats
34
+ - Responses API Chat Completions translation (request + response)
32
35
  - Streaming support with SSE (Server-Sent Events)
33
- - Zero dependencies - uses Node.js built-ins only
34
- - Health checks at `/health` endpoint
35
- - Configurable via CLI flags and environment variables
36
+ - Health check endpoint (`/health`)
37
+ - Works on Linux/macOS/Windows (WSL) + Termux (ARM64)
38
+ - **Optional tool/MCP bridging** (see “Tools / MCP” below)
39
+ - Zero/low dependencies (Node built-ins only, unless noted by package.json)
36
40
 
37
41
  ---
38
42
 
39
43
  ## Requirements
40
44
 
41
- - **Node.js**: 18.0.0 or higher (for native `fetch`)
42
- - **Platform**: Linux, macOS, Windows (WSL), Termux (ARM64)
45
+ - **Node.js**: 18+ (native `fetch`)
43
46
  - **Port**: 31415 (default, configurable)
44
47
 
45
48
  ---
@@ -54,28 +57,34 @@ npm install -g @mmmbuto/zai-codex-bridge
54
57
 
55
58
  ## Quick Start
56
59
 
57
- ### 1. Start the Proxy
60
+ ### 1) Start the Proxy
58
61
 
59
62
  ```bash
60
63
  zai-codex-bridge
61
64
  ```
62
65
 
63
- The proxy will listen on `http://127.0.0.1:31415`
66
+ Default listen address:
67
+
68
+ - `http://127.0.0.1:31415`
64
69
 
65
- ### 2. Configure Codex
70
+ ### 2) Configure Codex
66
71
 
67
- Add to `~/.codex/config.toml`:
72
+ Add this provider to `~/.codex/config.toml`:
68
73
 
69
74
  ```toml
70
75
  [model_providers.zai_proxy]
71
76
  name = "ZAI via local proxy"
72
- base_url = "http://127.0.0.1:31415/v1"
77
+ base_url = "http://127.0.0.1:31415"
73
78
  env_key = "OPENAI_API_KEY"
74
79
  wire_api = "responses"
75
80
  stream_idle_timeout_ms = 3000000
76
81
  ```
77
82
 
78
- ### 3. Use with Codex
83
+ > Notes:
84
+ > - `base_url` is the server root. Codex will call `/v1/responses`; this proxy supports that path.
85
+ > - We keep `env_key = "OPENAI_API_KEY"` because Codex expects that key name. You can store your Z.AI key there.
86
+
87
+ ### 3) Run Codex via the Proxy
79
88
 
80
89
  ```bash
81
90
  export OPENAI_API_KEY="your-zai-api-key"
@@ -84,6 +93,31 @@ codex -m "GLM-4.7" -c model_provider="zai_proxy"
84
93
 
85
94
  ---
86
95
 
96
+ ## Tools / MCP (optional)
97
+
98
+ Codex tool-calling / MCP memory requires an additional compatibility layer:
99
+ - Codex uses **Responses API tool events** (function call items + arguments delta/done, plus function_call_output inputs)
100
+ - Some upstream models/providers may not emit tool calls (or may emit them in a different shape)
101
+
102
+ This proxy can **attempt** to bridge tools automatically when the request carries tool definitions
103
+ (`tools`, `tool_choice`, or tool outputs). You can also force it on:
104
+
105
+ ```bash
106
+ export ALLOW_TOOLS=1
107
+ ```
108
+
109
+ Important:
110
+ - Tool support is **provider/model dependent**. If upstream never emits tool calls, the proxy can’t invent them.
111
+ - If tools are enabled, the proxy must translate:
112
+ - Responses `tools` + `tool_choice` → Chat `tools` + `tool_choice`
113
+ - Chat `tool_calls` (stream/non-stream) → Responses function-call events
114
+ - Responses `function_call_output` → Chat `role=tool` messages
115
+ - Non-function tool types are dropped for Z.AI compatibility.
116
+
117
+ (See repo changelog and docs for the exact implemented behavior.)
118
+
119
+ ---
120
+
87
121
  ## CLI Usage
88
122
 
89
123
  ```bash
@@ -97,7 +131,7 @@ zai-codex-bridge --port 8080
97
131
  zai-codex-bridge --log-level debug
98
132
 
99
133
  # Custom Z.AI endpoint
100
- zai-codex-bridge --zai-base-url https://custom.z.ai/v1
134
+ zai-codex-bridge --zai-base-url https://api.z.ai/api/coding/paas/v4
101
135
 
102
136
  # Show help
103
137
  zai-codex-bridge --help
@@ -106,17 +140,21 @@ zai-codex-bridge --help
106
140
  ### Environment Variables
107
141
 
108
142
  ```bash
109
- export PORT=31415
110
143
  export HOST=127.0.0.1
144
+ export PORT=31415
111
145
  export ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4
112
146
  export LOG_LEVEL=info
147
+
148
+ # Optional
149
+ export ALLOW_TOOLS=1 # force tool bridging (otherwise auto-enabled when tools are present)
150
+ export ALLOW_SYSTEM=1 # only if your provider supports system role
113
151
  ```
114
152
 
115
153
  ---
116
154
 
117
- ## Auto-Starting Proxy with Codex
155
+ ## Auto-start the Proxy with Codex (recommended)
118
156
 
119
- You can create a shell function that starts the proxy automatically when needed:
157
+ Use a shell function that starts the proxy only if needed:
120
158
 
121
159
  ```bash
122
160
  codex-with-zai() {
@@ -125,114 +163,114 @@ codex-with-zai() {
125
163
  local HEALTH="http://${HOST}:${PORT}/health"
126
164
  local PROXY_PID=""
127
165
 
128
- # Start proxy only if not responding
129
166
  if ! curl -fsS "$HEALTH" >/dev/null 2>&1; then
130
- zai-codex-bridge --host "$HOST" --port "$PORT" >/dev/null 2>&1 &
167
+ ALLOW_TOOLS=1 zai-codex-bridge --host "$HOST" --port "$PORT" >/dev/null 2>&1 &
131
168
  PROXY_PID=$!
132
169
  trap 'kill $PROXY_PID 2>/dev/null' EXIT INT TERM
133
- sleep 2
170
+ sleep 1
134
171
  fi
135
172
 
136
- # Run codex
137
- codex -m "GLM-4.7" -c model_provider="zai_proxy" "$@"
173
+ codex -c model_provider="zai_proxy" "$@"
138
174
  }
139
175
  ```
140
176
 
141
177
  Usage:
178
+
142
179
  ```bash
143
- codex-with-zai
144
- # Proxy auto-starts, Codex runs
145
- # Ctrl+D exits both
180
+ export OPENAI_API_KEY="your-zai-api-key"
181
+ codex-with-zai -m "GLM-4.7"
146
182
  ```
147
183
 
148
184
  ---
149
185
 
150
186
  ## API Endpoints
151
187
 
152
- ### `POST /responses`
153
- Accepts OpenAI Responses API format, translates to Chat, returns Responses format.
154
-
155
- ### `POST /v1/responses`
156
- Same as `/responses` (for compatibility with Codex's path structure).
157
-
158
- ### `GET /health`
159
- Health check endpoint.
188
+ - `POST /responses` — accepts Responses API requests
189
+ - `POST /v1/responses` same as above (Codex default path)
190
+ - `GET /health` — health check
160
191
 
161
192
  ---
162
193
 
163
- ## Translation Details
194
+ ## Translation Overview
164
195
 
165
196
  ### Request: Responses → Chat
166
197
 
167
- ```javascript
168
- // Input (Responses format)
198
+ ```js
199
+ // Input (Responses)
169
200
  {
170
- model: "GLM-4.7",
171
- instructions: "Be helpful",
172
- input: [
173
- { role: "user", content: "Hello" }
174
- ],
175
- max_output_tokens: 1000
201
+ "model": "GLM-4.7",
202
+ "instructions": "Be helpful",
203
+ "input": [{ "role": "user", "content": "Hello" }],
204
+ "max_output_tokens": 1000
176
205
  }
177
206
 
178
- // Output (Chat format)
207
+ // Output (Chat)
179
208
  {
180
- model: "GLM-4.7",
181
- messages: [
182
- { role: "system", content: "Be helpful" },
183
- { role: "user", content: "Hello" }
209
+ "model": "GLM-4.7",
210
+ "messages": [
211
+ { "role": "system", "content": "Be helpful" },
212
+ { "role": "user", "content": "Hello" }
184
213
  ],
185
- max_tokens: 1000
214
+ "max_tokens": 1000
186
215
  }
187
216
  ```
188
217
 
189
- ### Response: Chat → Responses
218
+ ### Response: Chat → Responses (simplified)
190
219
 
191
- ```javascript
192
- // Input (Chat format)
220
+ ```js
221
+ // Input (Chat)
193
222
  {
194
- choices: [{
195
- message: { content: "Hi there!" }
196
- }],
197
- usage: {
198
- prompt_tokens: 10,
199
- completion_tokens: 5
200
- }
223
+ "choices": [{ "message": { "content": "Hi there!" } }],
224
+ "usage": { "prompt_tokens": 10, "completion_tokens": 5 }
201
225
  }
202
226
 
203
- // Output (Responses format)
227
+ // Output (Responses - simplified)
204
228
  {
205
- output: [{ value: "Hi there!", content_type: "text" }],
206
- status: "completed",
207
- usage: {
208
- input_tokens: 10,
209
- output_tokens: 5
210
- }
229
+ "status": "completed",
230
+ "output": [{ "type": "message", "content": [{ "type": "output_text", "text": "Hi there!" }] }],
231
+ "usage": { "input_tokens": 10, "output_tokens": 5 }
211
232
  }
212
233
  ```
213
234
 
214
235
  ---
215
236
 
216
- ## Testing
237
+ ## Troubleshooting
217
238
 
218
- ```bash
219
- # Set your Z.AI API key
220
- export ZAI_API_KEY="sk-your-key"
239
+ ### 401 / “token expired or incorrect”
240
+ - Verify the key is exported as `OPENAI_API_KEY` (or matches `env_key` in config.toml).
241
+ - Make sure the proxy is not overwriting Authorization headers.
221
242
 
222
- # Run test suite
223
- npm run test:curl
224
- ```
243
+ ### 404 on `/v1/responses`
244
+ - Ensure `base_url` points to the proxy root (example: `http://127.0.0.1:31415`).
245
+ - Confirm the proxy is running and `/health` returns `ok`.
246
+
247
+ ### MCP/tools not being called
248
+ - Check proxy logs for `allowTools: true` and `toolsPresent: true`.
249
+ - If `toolsPresent: false`, Codex did not send tool definitions (verify your provider config).
250
+ - If tools are present but the model prints literal `<function=...>` markup or never emits tool calls,
251
+ your upstream model likely doesn’t support tool calling.
252
+ - If your provider supports `system` role, try `ALLOW_SYSTEM=1` to improve tool adherence.
253
+
254
+ ### 502 Bad Gateway
255
+ - Proxy reached upstream but upstream failed. Enable debug:
256
+ ```bash
257
+ LOG_LEVEL=debug zai-codex-bridge
258
+ ```
225
259
 
226
260
  ---
227
261
 
228
- ## Documentation
262
+ ## Versioning Policy
229
263
 
230
- Complete usage guide: [docs/guide.md](docs/guide.md)
264
+ This repo follows **small, safe patch increments** while stabilizing provider compatibility:
265
+
266
+ - Keep patch bumps only: `0.4.0 → 0.4.1 → 0.4.2 → ...`
267
+ - No big jumps unless strictly necessary.
268
+
269
+ (See `CHANGELOG.md` for details once present.)
231
270
 
232
271
  ---
233
272
 
234
273
  ## License
235
274
 
236
- MIT License - Copyright (c) 2026 Davide A. Guglielmi
237
-
275
+ MIT License Copyright (c) 2026 Davide A. Guglielmi
238
276
  See [LICENSE](LICENSE) for details.
package/RELEASING.md ADDED
@@ -0,0 +1,80 @@
1
+ # Releasing
2
+
3
+ This document describes the release process for zai-codex-bridge.
4
+
5
+ ## Version Policy
6
+
7
+ - **Patch releases only** (0.4.0 → 0.4.1 → 0.4.2, etc.)
8
+ - No minor or major bumps without explicit discussion
9
+ - Always increment by +0.0.1 from current version
10
+
11
+ ## Release Steps
12
+
13
+ ### 1. Run Tests
14
+
15
+ ```bash
16
+ # Set your API key
17
+ export ZAI_API_KEY="sk-your-key"
18
+
19
+ # Run test suite
20
+ npm run test:curl
21
+ # or
22
+ npm test
23
+ ```
24
+
25
+ ### 2. Bump Version
26
+
27
+ ```bash
28
+ # Use the release script (recommended)
29
+ npm run release:patch
30
+
31
+ # Or manually edit package.json and change:
32
+ # "version": "0.4.0" -> "version": "0.4.1"
33
+ ```
34
+
35
+ ### 3. Update CHANGELOG.md
36
+
37
+ Add an entry for the new version following [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format.
38
+
39
+ ### 4. Commit
40
+
41
+ ```bash
42
+ git add package.json CHANGELOG.md
43
+ git commit -m "chore: release v0.4.1"
44
+ ```
45
+
46
+ ### 5. Tag
47
+
48
+ ```bash
49
+ git tag v0.4.1
50
+ ```
51
+
52
+ ### 6. Push (Optional)
53
+
54
+ ```bash
55
+ git push
56
+ git push --tags
57
+ ```
58
+
59
+ ### 7. Publish to npm
60
+
61
+ ```bash
62
+ npm publish
63
+ ```
64
+
65
+ ## release:patch Script
66
+
67
+ The `npm run release:patch` script:
68
+
69
+ 1. Verifies current version is 0.4.x
70
+ 2. Bumps patch version by +0.0.1
71
+ 3. Refuses to bump minor/major versions
72
+ 4. Updates package.json in-place
73
+
74
+ Example:
75
+ ```bash
76
+ $ npm run release:patch
77
+ Current version: 0.4.0
78
+ Bumping to: 0.4.1
79
+ Updated package.json
80
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/zai-codex-bridge",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "Local proxy that translates OpenAI Responses API format to Z.AI Chat Completions format for Codex",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -8,7 +8,9 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/server.js",
11
- "test:curl": "node scripts/test-curl.js"
11
+ "test": "node scripts/test-curl.js",
12
+ "test:curl": "node scripts/test-curl.js",
13
+ "release:patch": "node scripts/release-patch.js"
12
14
  },
13
15
  "keywords": [
14
16
  "codex",
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Safe patch version bumper
5
+ * Only allows patch releases (0.4.0 -> 0.4.1)
6
+ * Refuses minor/major bumps
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const PACKAGE_PATH = path.join(__dirname, '..', 'package.json');
13
+
14
+ function bumpPatch(version) {
15
+ const parts = version.split('.').map(Number);
16
+
17
+ if (parts.length !== 3) {
18
+ throw new Error(`Invalid version format: ${version}`);
19
+ }
20
+
21
+ const [major, minor, patch] = parts;
22
+
23
+ // Only allow 0.4.x versions
24
+ if (major !== 0 || minor !== 4) {
25
+ console.error(`ERROR: Current version is ${version}`);
26
+ console.error('This script only supports patch releases for 0.4.x versions.');
27
+ console.error('For other version changes, edit package.json manually.');
28
+ process.exit(1);
29
+ }
30
+
31
+ const newVersion = `0.4.${patch + 1}`;
32
+ return newVersion;
33
+ }
34
+
35
+ function main() {
36
+ // Read package.json
37
+ const pkg = JSON.parse(fs.readFileSync(PACKAGE_PATH, 'utf8'));
38
+ const currentVersion = pkg.version;
39
+
40
+ console.log(`Current version: ${currentVersion}`);
41
+
42
+ // Bump patch
43
+ const newVersion = bumpPatch(currentVersion);
44
+ console.log(`Bumping to: ${newVersion}`);
45
+
46
+ // Update package.json
47
+ pkg.version = newVersion;
48
+
49
+ // Write back
50
+ fs.writeFileSync(PACKAGE_PATH, JSON.stringify(pkg, null, 2) + '\n');
51
+
52
+ console.log('Updated package.json');
53
+ console.log('\nNext steps:');
54
+ console.log(' 1. Update CHANGELOG.md');
55
+ console.log(' 2. Commit: git add package.json CHANGELOG.md && git commit -m "chore: release v' + newVersion + '"');
56
+ console.log(' 3. Tag: git tag v' + newVersion);
57
+ console.log(' 4. Publish: npm publish');
58
+ }
59
+
60
+ main();
@@ -135,6 +135,155 @@ async function testStreamingFormat() {
135
135
  });
136
136
  }
137
137
 
138
+ async function testToolCall() {
139
+ console.log('\n=== Testing POST /v1/responses (Tool Call) ===\n');
140
+ console.log('Note: This test requires ALLOW_TOOLS=1 and upstream model support for tools.\n');
141
+
142
+ const payload = {
143
+ model: 'GLM-4.7',
144
+ instructions: 'You are a helpful assistant.',
145
+ input: [
146
+ {
147
+ role: 'user',
148
+ content: 'What is the weather in Tokyo? Use the get_weather tool.'
149
+ }
150
+ ],
151
+ tools: [
152
+ {
153
+ type: 'function',
154
+ function: {
155
+ name: 'get_weather',
156
+ description: 'Get the current weather for a location',
157
+ parameters: {
158
+ type: 'object',
159
+ properties: {
160
+ location: {
161
+ type: 'string',
162
+ description: 'The city and state, e.g. San Francisco, CA'
163
+ }
164
+ },
165
+ required: ['location']
166
+ }
167
+ }
168
+ }
169
+ ],
170
+ tool_choice: 'auto',
171
+ stream: true
172
+ };
173
+
174
+ return new Promise((resolve, reject) => {
175
+ const options = {
176
+ hostname: PROXY_HOST,
177
+ port: PROXY_PORT,
178
+ path: '/v1/responses',
179
+ method: 'POST',
180
+ headers: {
181
+ 'Content-Type': 'application/json',
182
+ 'Authorization': `Bearer ${ZAI_API_KEY}`
183
+ }
184
+ };
185
+
186
+ const req = http.request(options, (res) => {
187
+ console.log('Status:', res.statusCode);
188
+
189
+ if (res.statusCode !== 200) {
190
+ let body = '';
191
+ res.on('data', (chunk) => body += chunk);
192
+ res.on('end', () => {
193
+ console.log('Error response:', body);
194
+ resolve({ status: 'error', message: body });
195
+ });
196
+ return;
197
+ }
198
+
199
+ console.log('\nStreaming response:');
200
+ let buffer = '';
201
+ let foundToolCall = false;
202
+ let foundOutputItemAdded = false;
203
+ let foundFunctionCallDelta = false;
204
+ let foundOutputItemDone = false;
205
+ let foundResponseCompleted = false;
206
+
207
+ res.on('data', (chunk) => {
208
+ buffer += chunk.toString();
209
+ const events = buffer.split('\n\n');
210
+ buffer = events.pop() || '';
211
+
212
+ for (const evt of events) {
213
+ const lines = evt.split('\n');
214
+ for (const line of lines) {
215
+ if (!line.startsWith('data:')) continue;
216
+ const payload = line.slice(5).trim();
217
+ if (!payload || payload === '[DONE]') continue;
218
+
219
+ try {
220
+ const data = JSON.parse(payload);
221
+ const type = data.type;
222
+
223
+ // Look for tool call events
224
+ if (type === 'response.output_item.added') {
225
+ if (data.item?.type === 'function_call') {
226
+ foundOutputItemAdded = true;
227
+ console.log('[EVENT] output_item.added (function_call):', data.item?.name);
228
+ }
229
+ }
230
+
231
+ if (type === 'response.function_call_arguments.delta') {
232
+ foundFunctionCallDelta = true;
233
+ process.stdout.write('.');
234
+ }
235
+
236
+ if (type === 'response.output_item.done') {
237
+ if (data.item?.type === 'function_call') {
238
+ foundOutputItemDone = true;
239
+ }
240
+ }
241
+
242
+ if (type === 'response.completed') {
243
+ foundResponseCompleted = true;
244
+ }
245
+ } catch (e) {
246
+ // Skip parse errors
247
+ }
248
+ }
249
+ }
250
+ });
251
+
252
+ res.on('end', () => {
253
+ console.log();
254
+ console.log('\n=== Tool Call Test Results ===');
255
+
256
+ if (!foundToolCall && !foundOutputItemAdded) {
257
+ console.log('SKIP: upstream did not return tool_calls');
258
+ console.log('This may mean:');
259
+ console.log(' - ALLOW_TOOLS is not enabled on the proxy');
260
+ console.log(' - The model does not support tool calls');
261
+ console.log(' - The prompt did not trigger a tool call');
262
+ resolve({ status: 'skipped', reason: 'no tool calls from upstream' });
263
+ return;
264
+ }
265
+
266
+ const passed = foundOutputItemAdded && foundFunctionCallDelta && foundOutputItemDone && foundResponseCompleted;
267
+ console.log('output_item.added (function_call):', foundOutputItemAdded ? 'PASS' : 'FAIL');
268
+ console.log('function_call_arguments.delta:', foundFunctionCallDelta ? 'PASS' : 'FAIL');
269
+ console.log('output_item.done (function_call):', foundOutputItemDone ? 'PASS' : 'FAIL');
270
+ console.log('response.completed:', foundResponseCompleted ? 'PASS' : 'FAIL');
271
+ console.log('\nOverall:', passed ? 'PASS' : 'FAIL');
272
+
273
+ resolve({ status: passed ? 'pass' : 'fail', results: { foundOutputItemAdded, foundFunctionCallDelta, foundOutputItemDone, foundResponseCompleted } });
274
+ });
275
+ });
276
+
277
+ req.on('error', (err) => {
278
+ console.error('Request error:', err.message);
279
+ reject(err);
280
+ });
281
+
282
+ req.write(JSON.stringify(payload, null, 2));
283
+ req.end();
284
+ });
285
+ }
286
+
138
287
  async function main() {
139
288
  console.log('zai-codex-bridge Manual Test');
140
289
  console.log('================================');
@@ -151,7 +300,22 @@ async function main() {
151
300
  await testResponsesFormat();
152
301
  await testStreamingFormat();
153
302
 
303
+ // Tool call test (optional - depends on upstream support)
304
+ console.log('\n\n=== Tool Support Tests ===');
305
+ const toolResult = await testToolCall();
306
+
154
307
  console.log('\n=== All Tests Complete ===\n');
308
+ console.log('Summary:');
309
+ console.log(' Health: PASS');
310
+ console.log(' Non-streaming: PASS');
311
+ console.log(' Streaming: PASS');
312
+ if (toolResult.status === 'pass') {
313
+ console.log(' Tool calls: PASS');
314
+ } else if (toolResult.status === 'skipped') {
315
+ console.log(' Tool calls: SKIPPED (upstream does not support or did not return tool_calls)');
316
+ } else {
317
+ console.log(' Tool calls: FAIL or ERROR');
318
+ }
155
319
  } catch (error) {
156
320
  console.error('\nError:', error.message);
157
321
  process.exit(1);
package/src/server.js CHANGED
@@ -22,7 +22,7 @@ const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'glm-4.7';
22
22
 
23
23
  // Env toggles for compatibility
24
24
  const ALLOW_SYSTEM = process.env.ALLOW_SYSTEM === '1';
25
- const ALLOW_TOOLS = process.env.ALLOW_TOOLS === '1';
25
+ const ALLOW_TOOLS_ENV = process.env.ALLOW_TOOLS === '1';
26
26
 
27
27
  function nowSec() {
28
28
  return Math.floor(Date.now() / 1000);
@@ -90,6 +90,67 @@ function detectFormat(body) {
90
90
  return 'unknown';
91
91
  }
92
92
 
93
+ /**
94
+ * Detect if request carries tool-related data
95
+ */
96
+ function requestHasTools(request) {
97
+ if (!request || typeof request !== 'object') return false;
98
+
99
+ if (Array.isArray(request.tools) && request.tools.length > 0) return true;
100
+ if (request.tool_choice) return true;
101
+
102
+ if (Array.isArray(request.input)) {
103
+ for (const item of request.input) {
104
+ if (!item) continue;
105
+ if (item.type === 'function_call_output') return true;
106
+ if (Array.isArray(item.tool_calls) && item.tool_calls.length > 0) return true;
107
+ if (item.tool_call_id) return true;
108
+ }
109
+ }
110
+
111
+ if (Array.isArray(request.messages)) {
112
+ for (const msg of request.messages) {
113
+ if (!msg) continue;
114
+ if (msg.role === 'tool') return true;
115
+ if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) return true;
116
+ if (msg.tool_call_id) return true;
117
+ }
118
+ }
119
+
120
+ return false;
121
+ }
122
+
123
+ function summarizeTools(tools, limit = 8) {
124
+ if (!Array.isArray(tools)) return null;
125
+ const types = {};
126
+ const names = [];
127
+
128
+ for (const tool of tools) {
129
+ const type = tool?.type || 'unknown';
130
+ types[type] = (types[type] || 0) + 1;
131
+ if (names.length < limit) {
132
+ if (type === 'function') {
133
+ names.push(tool?.function?.name || '(missing_name)');
134
+ } else {
135
+ names.push(type);
136
+ }
137
+ }
138
+ }
139
+
140
+ return { count: tools.length, types, sample_names: names };
141
+ }
142
+
143
+ function summarizeToolShape(tool) {
144
+ if (!tool || typeof tool !== 'object') return null;
145
+ return {
146
+ keys: Object.keys(tool),
147
+ type: tool.type,
148
+ name: tool.name,
149
+ functionKeys: tool.function && typeof tool.function === 'object' ? Object.keys(tool.function) : null,
150
+ functionName: tool.function?.name
151
+ };
152
+ }
153
+
93
154
  /**
94
155
  * Flatten content parts to string - supports text, input_text, output_text
95
156
  */
@@ -113,7 +174,7 @@ function flattenContent(content) {
113
174
  /**
114
175
  * Translate Responses format to Chat Completions format
115
176
  */
116
- function translateResponsesToChat(request) {
177
+ function translateResponsesToChat(request, allowTools) {
117
178
  const messages = [];
118
179
 
119
180
  // Add system message from instructions (with ALLOW_SYSTEM toggle)
@@ -143,18 +204,41 @@ function translateResponsesToChat(request) {
143
204
  content: request.input
144
205
  });
145
206
  } else if (Array.isArray(request.input)) {
146
- // Array of ResponseItem objects - filter only Message items with role
207
+ // Array of ResponseItem objects
147
208
  for (const item of request.input) {
209
+ // Handle function_call_output items (tool responses) - only if allowTools
210
+ if (allowTools && item.type === 'function_call_output') {
211
+ const toolMsg = {
212
+ role: 'tool',
213
+ tool_call_id: item.call_id || item.tool_call_id || '',
214
+ content: ''
215
+ };
216
+
217
+ // Extract content from output or content field
218
+ if (item.output !== undefined) {
219
+ toolMsg.content = typeof item.output === 'string'
220
+ ? item.output
221
+ : JSON.stringify(item.output);
222
+ } else if (item.content !== undefined) {
223
+ toolMsg.content = typeof item.content === 'string'
224
+ ? item.content
225
+ : JSON.stringify(item.content);
226
+ }
227
+
228
+ messages.push(toolMsg);
229
+ continue;
230
+ }
231
+
148
232
  // Only process items with a 'role' field (Message items)
149
233
  // Skip Reasoning, FunctionCall, LocalShellCall, etc.
150
234
  if (!item.role) continue;
151
235
 
152
236
  // Map non-standard roles to Z.AI-compatible roles
153
- // Z.AI accepts: system, user, assistant
237
+ // Z.AI accepts: system, user, assistant, tool
154
238
  let role = item.role;
155
239
  if (role === 'developer') {
156
240
  role = 'user'; // Map developer to user
157
- } else if (role !== 'system' && role !== 'user' && role !== 'assistant') {
241
+ } else if (role !== 'system' && role !== 'user' && role !== 'assistant' && role !== 'tool') {
158
242
  // Skip any other non-standard roles
159
243
  continue;
160
244
  }
@@ -164,13 +248,13 @@ function translateResponsesToChat(request) {
164
248
  content: flattenContent(item.content)
165
249
  };
166
250
 
167
- // Handle tool calls if present (only if ALLOW_TOOLS)
168
- if (ALLOW_TOOLS && item.tool_calls && Array.isArray(item.tool_calls)) {
251
+ // Handle tool calls if present (only if allowTools)
252
+ if (allowTools && item.tool_calls && Array.isArray(item.tool_calls)) {
169
253
  msg.tool_calls = item.tool_calls;
170
254
  }
171
255
 
172
- // Handle tool call ID for tool responses (only if ALLOW_TOOLS)
173
- if (ALLOW_TOOLS && item.tool_call_id) {
256
+ // Handle tool call ID for tool responses (only if allowTools)
257
+ if (allowTools && item.tool_call_id) {
174
258
  msg.tool_call_id = item.tool_call_id;
175
259
  }
176
260
 
@@ -203,27 +287,49 @@ function translateResponsesToChat(request) {
203
287
  chatRequest.top_p = request.top_p;
204
288
  }
205
289
 
206
- // Tools handling (only if ALLOW_TOOLS)
207
- if (ALLOW_TOOLS && request.tools && Array.isArray(request.tools)) {
208
- // Filter out tools with null or empty function
209
- chatRequest.tools = request.tools.filter(tool => {
210
- if (tool.type === 'function') {
211
- // Check if function has required fields
212
- return tool.function && typeof tool.function === 'object' &&
213
- tool.function.name && tool.function.name.length > 0 &&
214
- tool.function.parameters !== undefined && tool.function.parameters !== null;
215
- }
216
- // Keep non-function tools (if any)
217
- return true;
218
- });
290
+ // Tools handling (only if allowTools)
291
+ if (allowTools && request.tools && Array.isArray(request.tools)) {
292
+ const originalCount = request.tools.length;
293
+ const normalized = [];
294
+
295
+ for (const tool of request.tools) {
296
+ if (!tool || tool.type !== 'function') continue;
297
+ const fn = tool.function && typeof tool.function === 'object' ? tool.function : null;
298
+ const name = (fn?.name || tool.name || '').trim();
299
+ if (!name) continue;
300
+
301
+ // Prefer nested function fields, fall back to top-level ones if present
302
+ const description = fn?.description ?? tool.description;
303
+ const parameters = fn?.parameters ?? tool.parameters ?? { type: 'object', properties: {} };
304
+
305
+ const functionObj = { name, parameters };
306
+ if (description) functionObj.description = description;
307
+
308
+ // Send minimal tool schema for upstream compatibility
309
+ normalized.push({
310
+ type: 'function',
311
+ function: functionObj
312
+ });
313
+ }
314
+
315
+ chatRequest.tools = normalized;
316
+
317
+ const dropped = originalCount - chatRequest.tools.length;
318
+ if (dropped > 0) {
319
+ log('warn', `Dropped ${dropped} non-function or invalid tools for upstream compatibility`);
320
+ }
321
+
219
322
  // Only add tools array if there are valid tools
220
323
  if (chatRequest.tools.length === 0) {
221
324
  delete chatRequest.tools;
222
325
  }
223
326
  }
224
327
 
225
- if (ALLOW_TOOLS && request.tool_choice) {
328
+ if (allowTools && request.tool_choice) {
226
329
  chatRequest.tool_choice = request.tool_choice;
330
+ if (!chatRequest.tools || chatRequest.tools.length === 0) {
331
+ delete chatRequest.tool_choice;
332
+ }
227
333
  }
228
334
 
229
335
  log('debug', 'Translated Responses->Chat:', {
@@ -238,8 +344,9 @@ function translateResponsesToChat(request) {
238
344
  /**
239
345
  * Translate Chat Completions response to Responses format
240
346
  * Handles both output_text and reasoning_text content
347
+ * Handles tool_calls if present (only if allowTools)
241
348
  */
242
- function translateChatToResponses(chatResponse, responsesRequest, ids) {
349
+ function translateChatToResponses(chatResponse, responsesRequest, ids, allowTools) {
243
350
  const msg = chatResponse.choices?.[0]?.message ?? {};
244
351
  const outputText = msg.content ?? '';
245
352
  const reasoningText = msg.reasoning_content ?? '';
@@ -262,6 +369,27 @@ function translateChatToResponses(chatResponse, responsesRequest, ids) {
262
369
  content,
263
370
  };
264
371
 
372
+ // Build output array: message item + any function_call items
373
+ const finalOutput = [msgItem];
374
+
375
+ // Handle tool_calls (only if allowTools)
376
+ if (allowTools && msg.tool_calls && Array.isArray(msg.tool_calls)) {
377
+ for (const tc of msg.tool_calls) {
378
+ const callId = tc.id || `call_${randomUUID().replace(/-/g, '')}`;
379
+ const name = tc.function?.name || '';
380
+ const args = tc.function?.arguments || '';
381
+
382
+ finalOutput.push({
383
+ id: callId,
384
+ type: 'function_call',
385
+ status: 'completed',
386
+ call_id: callId,
387
+ name: name,
388
+ arguments: typeof args === 'string' ? args : JSON.stringify(args),
389
+ });
390
+ }
391
+ }
392
+
265
393
  return buildResponseObject({
266
394
  id: responseId,
267
395
  model: responsesRequest?.model || chatResponse.model || DEFAULT_MODEL,
@@ -269,7 +397,7 @@ function translateChatToResponses(chatResponse, responsesRequest, ids) {
269
397
  created_at: createdAt,
270
398
  completed_at: nowSec(),
271
399
  input: responsesRequest?.input || [],
272
- output: [msgItem],
400
+ output: finalOutput,
273
401
  tools: responsesRequest?.tools || [],
274
402
  });
275
403
  }
@@ -341,7 +469,7 @@ async function makeUpstreamRequest(path, body, headers) {
341
469
  * Handle streaming response from Z.AI with proper Responses API event format
342
470
  * Separates reasoning_content, content, and tool_calls into distinct events
343
471
  */
344
- async function streamChatToResponses(upstreamBody, res, responsesRequest, ids) {
472
+ async function streamChatToResponses(upstreamBody, res, responsesRequest, ids, allowTools) {
345
473
  const decoder = new TextDecoder();
346
474
  const reader = upstreamBody.getReader();
347
475
  let buffer = '';
@@ -400,6 +528,10 @@ async function streamChatToResponses(upstreamBody, res, responsesRequest, ids) {
400
528
  let out = '';
401
529
  let reasoning = '';
402
530
 
531
+ // Tool call tracking (only if allowTools)
532
+ const toolCallsMap = new Map(); // index -> { callId, name, outputIndex, arguments, partialArgs }
533
+ const TOOL_BASE_INDEX = 1; // After message item
534
+
403
535
  while (true) {
404
536
  const { done, value } = await reader.read();
405
537
  if (done) break;
@@ -428,6 +560,108 @@ async function streamChatToResponses(upstreamBody, res, responsesRequest, ids) {
428
560
 
429
561
  const delta = chunk.choices?.[0]?.delta || {};
430
562
 
563
+ // Handle tool_calls (only if allowTools)
564
+ if (allowTools && delta.tool_calls && Array.isArray(delta.tool_calls)) {
565
+ for (const tc of delta.tool_calls) {
566
+ const index = tc.index;
567
+ if (index == null) continue;
568
+
569
+ if (!toolCallsMap.has(index)) {
570
+ // New tool call - send output_item.added
571
+ const callId = tc.id || `call_${randomUUID().replace(/-/g, '')}`;
572
+ const name = tc.function?.name || '';
573
+ const outputIndex = TOOL_BASE_INDEX + index;
574
+
575
+ toolCallsMap.set(index, {
576
+ callId,
577
+ name,
578
+ outputIndex,
579
+ arguments: '',
580
+ partialArgs: ''
581
+ });
582
+
583
+ const fnItemInProgress = {
584
+ id: callId,
585
+ type: 'function_call',
586
+ status: 'in_progress',
587
+ call_id: callId,
588
+ name: name,
589
+ arguments: '',
590
+ };
591
+
592
+ sse({
593
+ type: 'response.output_item.added',
594
+ output_index: outputIndex,
595
+ item: fnItemInProgress,
596
+ });
597
+
598
+ if (name) {
599
+ sse({
600
+ type: 'response.function_call_name.done',
601
+ item_id: callId,
602
+ output_index: outputIndex,
603
+ name: name,
604
+ });
605
+ }
606
+ }
607
+
608
+ const tcData = toolCallsMap.get(index);
609
+
610
+ // Handle name update if it comes later
611
+ if (tc.function?.name && !tcData.name) {
612
+ tcData.name = tc.function.name;
613
+ sse({
614
+ type: 'response.function_call_name.done',
615
+ item_id: tcData.callId,
616
+ output_index: tcData.outputIndex,
617
+ name: tcData.name,
618
+ });
619
+ }
620
+
621
+ // Handle arguments delta
622
+ if (tc.function?.arguments && typeof tc.function.arguments === 'string') {
623
+ tcData.partialArgs += tc.function.arguments;
624
+
625
+ sse({
626
+ type: 'response.function_call_arguments.delta',
627
+ item_id: tcData.callId,
628
+ output_index: tcData.outputIndex,
629
+ delta: tc.function.arguments,
630
+ });
631
+ }
632
+
633
+ // Check if this tool call is done (finish_reason comes later in the choice)
634
+ const finishReason = chunk.choices?.[0]?.finish_reason;
635
+ if (finishReason === 'tool_calls' || (tc.function?.arguments && tc.function.arguments.length > 0 && chunk.choices?.[0]?.delta !== null)) {
636
+ tcData.arguments = tcData.partialArgs;
637
+
638
+ sse({
639
+ type: 'response.function_call_arguments.done',
640
+ item_id: tcData.callId,
641
+ output_index: tcData.outputIndex,
642
+ arguments: tcData.arguments,
643
+ });
644
+
645
+ const fnItemDone = {
646
+ id: tcData.callId,
647
+ type: 'function_call',
648
+ status: 'completed',
649
+ call_id: tcData.callId,
650
+ name: tcData.name,
651
+ arguments: tcData.arguments,
652
+ };
653
+
654
+ sse({
655
+ type: 'response.output_item.done',
656
+ output_index: tcData.outputIndex,
657
+ item: fnItemDone,
658
+ });
659
+ }
660
+ }
661
+ // Skip to next iteration after handling tool_calls
662
+ continue;
663
+ }
664
+
431
665
  // NON mescolare reasoning in output_text
432
666
  if (typeof delta.reasoning_content === 'string' && delta.reasoning_content.length) {
433
667
  reasoning += delta.reasoning_content;
@@ -495,6 +729,22 @@ async function streamChatToResponses(upstreamBody, res, responsesRequest, ids) {
495
729
  item: msgItemDone,
496
730
  });
497
731
 
732
+ // Build final output array: message item + any function_call items
733
+ const finalOutput = [msgItemDone];
734
+ if (allowTools && toolCallsMap.size > 0) {
735
+ const ordered = Array.from(toolCallsMap.entries()).sort((a, b) => a[0] - b[0]);
736
+ for (const [, tcData] of ordered) {
737
+ finalOutput.push({
738
+ id: tcData.callId,
739
+ type: 'function_call',
740
+ status: 'completed',
741
+ call_id: tcData.callId,
742
+ name: tcData.name,
743
+ arguments: tcData.arguments,
744
+ });
745
+ }
746
+ }
747
+
498
748
  const completed = buildResponseObject({
499
749
  id: responseId,
500
750
  model: responsesRequest?.model || DEFAULT_MODEL,
@@ -502,14 +752,14 @@ async function streamChatToResponses(upstreamBody, res, responsesRequest, ids) {
502
752
  created_at: createdAt,
503
753
  completed_at: nowSec(),
504
754
  input: responsesRequest?.input || [],
505
- output: [msgItemDone],
755
+ output: finalOutput,
506
756
  tools: responsesRequest?.tools || [],
507
757
  });
508
758
 
509
759
  sse({ type: 'response.completed', response: completed });
510
760
  res.end();
511
761
 
512
- log('info', `Stream completed - ${out.length} output, ${reasoning.length} reasoning`);
762
+ log('info', `Stream completed - ${out.length} output, ${reasoning.length} reasoning, ${toolCallsMap.size} tool_calls`);
513
763
  }
514
764
 
515
765
  /**
@@ -543,19 +793,30 @@ async function handlePostRequest(req, res) {
543
793
  return;
544
794
  }
545
795
 
796
+ const hasTools = requestHasTools(request);
797
+ const allowTools = ALLOW_TOOLS_ENV || hasTools;
798
+
546
799
  log('info', 'Incoming request:', {
547
800
  path,
548
801
  format: detectFormat(request),
549
802
  model: request.model,
803
+ allowTools,
804
+ toolsPresent: hasTools,
550
805
  authHeader: req.headers['authorization'] || req.headers['Authorization'] || 'none'
551
806
  });
807
+ if (hasTools) {
808
+ log('debug', 'Tools summary:', summarizeTools(request.tools));
809
+ if (request.tools && request.tools[0]) {
810
+ log('debug', 'Tool[0] shape:', summarizeToolShape(request.tools[0]));
811
+ }
812
+ }
552
813
 
553
814
  let upstreamBody;
554
815
  const format = detectFormat(request);
555
816
 
556
817
  if (format === 'responses') {
557
818
  // Translate Responses to Chat
558
- upstreamBody = translateResponsesToChat(request);
819
+ upstreamBody = translateResponsesToChat(request, allowTools);
559
820
  } else if (format === 'chat') {
560
821
  // Pass through Chat format
561
822
  upstreamBody = request;
@@ -605,7 +866,7 @@ async function handlePostRequest(req, res) {
605
866
  });
606
867
 
607
868
  try {
608
- await streamChatToResponses(upstreamResponse.body, res, request, ids);
869
+ await streamChatToResponses(upstreamResponse.body, res, request, ids, allowTools);
609
870
  log('info', 'Streaming completed');
610
871
  } catch (e) {
611
872
  log('error', 'Streaming error:', e);
@@ -620,7 +881,7 @@ async function handlePostRequest(req, res) {
620
881
  msgId: `msg_${randomUUID().replace(/-/g, '')}`,
621
882
  };
622
883
 
623
- const response = translateChatToResponses(chatResponse, request, ids);
884
+ const response = translateChatToResponses(chatResponse, request, ids, allowTools);
624
885
 
625
886
  res.writeHead(200, { 'Content-Type': 'application/json' });
626
887
  res.end(JSON.stringify(response));