Skip to content

Commit 81f6156

Browse files
committed
fix: add tool routing diagnostics
1 parent 736eefb commit 81f6156

9 files changed

Lines changed: 361 additions & 3 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,9 @@ A: 主要是 `gemini-2.5-flash`、`glm-4.7` / `5` / `5.1`、`kimi-k2` / `k2.5` /
403403
**Q: 免费账号调工具稳吗**
404404
A: 看模型。Claude family `<tool_use>` 协议训练扎实最稳(free 账号若 entitled 也是优选);GLM-4.7 / Kimi-K2.5 走 NLU 兜底 + `WINDSURFAPI_NLU_RETRY=1` retry-with-correction 多数 case 能调;GLM-5.1 在 cascade 后端经常空回复 proxy 救不动;GPT 系列受 cascade 协议层限制(不传 OpenAI tools[] schema)也不稳。**Claude Code / Cline / Codex 调本地文件 / 跑命令优先 `claude-haiku-4.5``claude-sonnet-4.6`**
405405

406+
**Q: 客户端显示“没有调用工具”,怎么排查**
407+
A: 先看日志里的 `ToolRoute[...]`。它会列出客户端声明的工具、`tool_choice` 过滤后的有效工具、native bridge 映射/未映射工具、preamble 降级层级,以及 `tool_choice_none` / `forced_tool_not_declared` / `preamble_compacted` / `native_bridge_*` 等原因。`/v1/messages``/v1/responses` 的 server-side 工具(如 Anthropic advisor/code_execution,OpenAI file_search/mcp/computer_use)如果代理没有实现,会在翻译层丢弃;这类工具不是普通 function tool,不等于 WindsurfAPI 已经能替客户端执行。native bridge 也不是“本地 IDE 工具修复开关”:默认安全路径仍是 prompt/tool emulation,由客户端本地执行工具;native bridge 是让 Windsurf 远端 workspace 执行 Cascade 内置工具,只适合有模型/账号/API key gate 的小流量实验。
408+
406409
**Q: 31 个 trial 账号一会儿就全 unavailable**
407410
A: 八成是用了周限模型 — `claude-opus-4-7-max` / `gpt-5.5-xhigh` / `claude-sonnet-4-7-thinking` 这类高 reasoning effort 变体每个账号每周只有 5 次配额,31 号 × 5 次 ≈ 150 次就到顶。换 `claude-sonnet-4.6` / `claude-haiku-4.5` daily 配额比较宽松。`docker logs windsurfapi-windsurf-api-1 | grep rate_limit` 看每个账号的 cooldown 字段验证。
408411

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## v2.0.141 - tool routing diagnostics
2+
3+
This release does not widen native bridge production defaults.
4+
5+
### Tool routing diagnostics
6+
7+
- Added `ToolRoute[...]` request logs for tool-bearing chat requests. The log
8+
records requested tools, `tool_choice`-filtered tools, native mapped/unmapped
9+
partitions, native bridge decision reason, tool preamble tier, and compact
10+
routing reasons.
11+
- `/v1/responses` now drops a forced `tool_choice` when that choice points at
12+
an unbridged server-side tool such as `file_search`, `computer_use_preview`,
13+
or `mcp`. This prevents a translated request from carrying a forced tool that
14+
no longer exists after flattening.
15+
- README now has a short FAQ explaining how to interpret "no tool calls" and
16+
why native bridge is not a general local IDE tool fix.
17+
18+
### WebFetch trace canaries
19+
20+
- `scripts/native-bridge-smoke.mjs` now summarizes redacted
21+
`webFetchTrace.state` values from proto trace JSONL files. Gated WebFetch
22+
canaries can now report whether the LS reached `pending_permission`,
23+
`completed_web_document`, `error`, or another known branch without manually
24+
inspecting trace records.
25+
- The trace summary is diagnostic only and does not change smoke pass/fail
26+
criteria.
27+
28+
### Validation
29+
30+
- Added regression coverage for Responses server-side `tool_choice` pruning,
31+
tool routing diagnostics, and smoke WebFetch trace summaries.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "windsurf-api",
3-
"version": "2.0.140",
3+
"version": "2.0.141",
44
"description": "Windsurf to OpenAI + Anthropic compatible API proxy. Turns Windsurf's 107 AI models (Claude, GPT, Gemini, DeepSeek, Grok, Qwen, Kimi, GLM, SWE) into dual-protocol API endpoints. Zero npm deps.",
55
"type": "module",
66
"main": "src/index.js",

scripts/native-bridge-smoke.mjs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#!/usr/bin/env node
22

3+
import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
4+
import { join } from 'node:path';
5+
36
const baseUrl = (process.env.BASE_URL || process.env.WINDSURFAPI_BASE_URL || 'http://127.0.0.1:3003').replace(/\/+$/, '');
47
const apiKey = process.env.API_KEY || process.env.WINDSURFAPI_API_KEY || '';
58
const model = process.env.MODEL || process.env.WINDSURFAPI_SMOKE_MODEL || 'claude-sonnet-4.6';
@@ -15,6 +18,10 @@ const requireNativeBridgeTool = process.env.NATIVE_BRIDGE_SMOKE_REQUIRE_NATIVE !
1518
const validateToolArgs = process.env.NATIVE_BRIDGE_SMOKE_VALIDATE_ARGS !== '0';
1619
const enforceLsBudget = process.env.NATIVE_BRIDGE_SMOKE_LS_BUDGET !== '0';
1720
const requireNativeBridgeEnabled = process.env.NATIVE_BRIDGE_SMOKE_REQUIRE_BRIDGE_ENABLED !== '0';
21+
const includeProtoTraceSummary = process.env.NATIVE_BRIDGE_SMOKE_PROTO_TRACE_SUMMARY !== '0';
22+
const protoTraceDir = process.env.NATIVE_BRIDGE_SMOKE_PROTO_TRACE_DIR
23+
|| process.env.WINDSURFAPI_PROTO_TRACE_DIR
24+
|| '/data/proto-trace';
1825
async function sha256Hex(text) {
1926
const bytes = new TextEncoder().encode(String(text || ''));
2027
const digest = await crypto.subtle.digest('SHA-256', bytes);
@@ -617,6 +624,84 @@ function nativeBridgeDecisionDelta(before, after) {
617624
};
618625
}
619626

627+
function readTailText(file, maxBytes = 2 * 1024 * 1024) {
628+
const stat = statSync(file);
629+
const size = stat.size;
630+
const start = Math.max(0, size - maxBytes);
631+
const length = size - start;
632+
const fd = openSync(file, 'r');
633+
try {
634+
const buf = Buffer.alloc(length);
635+
readSync(fd, buf, 0, length, start);
636+
return buf.toString('utf8');
637+
} finally {
638+
closeSync(fd);
639+
}
640+
}
641+
642+
function summarizeWebFetchTraceDir(dir = protoTraceDir) {
643+
try {
644+
if (!includeProtoTraceSummary) return null;
645+
if (!dir || !existsSync(dir)) return { available: false, dir, reason: 'trace_dir_missing' };
646+
const files = readdirSync(dir)
647+
.filter(name => /GetCascadeTrajectorySteps.*\.jsonl$/i.test(name))
648+
.map(name => {
649+
const path = join(dir, name);
650+
const stat = statSync(path);
651+
return { name, path, mtimeMs: stat.mtimeMs, size: stat.size };
652+
})
653+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
654+
.slice(0, 6);
655+
const stateCounts = {};
656+
const recent = [];
657+
let records = 0;
658+
let parseErrors = 0;
659+
for (const file of files) {
660+
const lines = readTailText(file.path).split('\n').filter(Boolean);
661+
for (const line of lines) {
662+
let rec;
663+
try {
664+
rec = JSON.parse(line);
665+
} catch {
666+
parseErrors++;
667+
continue;
668+
}
669+
records++;
670+
const steps = rec?.semantic?.steps || [];
671+
for (const step of steps) {
672+
const trace = step?.webFetchTrace;
673+
if (!trace?.state) continue;
674+
stateCounts[trace.state] = (stateCounts[trace.state] || 0) + 1;
675+
recent.push({
676+
file: file.name,
677+
method: rec.method || '',
678+
direction: rec.direction || '',
679+
stepIndex: step.index,
680+
state: trace.state,
681+
stepType: trace.stepType,
682+
status: trace.status,
683+
hasRequestedInteraction: !!trace.hasRequestedInteraction,
684+
hasReadUrlOneof: !!trace.hasReadUrlOneof,
685+
hasWebDocument: !!trace.hasWebDocument,
686+
errorClassifications: trace.errorClassifications || {},
687+
});
688+
}
689+
}
690+
}
691+
return {
692+
available: true,
693+
dir,
694+
files: files.map(f => ({ name: f.name, size: f.size })),
695+
records,
696+
parseErrors,
697+
stateCounts,
698+
recent: recent.slice(-12),
699+
};
700+
} catch (error) {
701+
return { available: false, dir, reason: 'trace_summary_failed', error: String(error?.message || error) };
702+
}
703+
}
704+
620705
const selected = expandScenarios(requestedScenarios);
621706
if (!selected.length) {
622707
console.error(`No valid scenarios selected. Use one or more of: ${Object.keys(SCENARIOS).join(',')},all`);
@@ -655,6 +740,7 @@ if (!failures.length) {
655740
}
656741
}
657742
const healthAfter = await fetchHealthSnapshot('after');
743+
const protoTraceSummary = summarizeWebFetchTraceDir();
658744

659745
console.log(JSON.stringify({
660746
ok: failures.length === 0,
@@ -675,6 +761,7 @@ console.log(JSON.stringify({
675761
results,
676762
failures,
677763
nativeBridgeDecisionDelta: nativeBridgeDecisionDelta(healthBefore, healthAfter),
764+
protoTraceSummary,
678765
healthBefore,
679766
healthAfter,
680767
}, null, 2));

src/handlers/chat.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,54 @@ export function effectiveToolsForToolChoice(tools, toolChoice) {
222222
return tools.filter(t => (t?.function?.name || t?.name || '') === forced);
223223
}
224224

225+
function toolNameList(tools) {
226+
if (!Array.isArray(tools)) return [];
227+
return tools.map(t => t?.function?.name || t?.name || '').filter(Boolean);
228+
}
229+
230+
export function summarizeToolRoutingDiagnostics({ tools, effectiveTools, toolChoice, toolRouting, preambleBudget = null }) {
231+
const requested = toolNameList(tools);
232+
const effective = toolNameList(effectiveTools);
233+
const forcedName = toolChoice && typeof toolChoice === 'object'
234+
? (toolChoice.function?.name || toolChoice.name || '')
235+
: '';
236+
const reasons = [];
237+
238+
if (toolChoice === 'none') reasons.push('tool_choice_none');
239+
if (forcedName && requested.length && !requested.includes(forcedName)) reasons.push('forced_tool_not_declared');
240+
if (requested.length && effective.length === 0 && toolChoice !== 'none') reasons.push('effective_tools_empty');
241+
if (toolRouting?.nativeDecision?.reason) reasons.push(toolRouting.nativeDecision.reason);
242+
if (toolRouting?.nativeBridgeOn) reasons.push('native_bridge_on');
243+
if (preambleBudget?.tier) reasons.push(`preamble_${preambleBudget.tier}`);
244+
if (preambleBudget?.compacted) reasons.push('preamble_compacted');
245+
if (preambleBudget && preambleBudget.ok === false) reasons.push('preamble_too_large');
246+
247+
return {
248+
requested,
249+
effective,
250+
mapped: toolNameList(toolRouting?.partition?.mapped || []),
251+
unmapped: toolNameList(toolRouting?.partition?.unmapped || []),
252+
nativeBridgeOn: !!toolRouting?.nativeBridgeOn,
253+
nativeDecisionReason: toolRouting?.nativeDecision?.reason || '',
254+
preambleTier: preambleBudget?.tier || null,
255+
preambleBytes: preambleBudget?.finalBytes ?? null,
256+
forcedName,
257+
reasons: [...new Set(reasons)],
258+
};
259+
}
260+
261+
function logToolRoutingDiagnostics(reqId, diag) {
262+
if (!diag || (!diag.requested.length && !diag.reasons.length)) return;
263+
log.info(
264+
`ToolRoute[${reqId}]: requested=[${diag.requested.join(',') || 'none'}] ` +
265+
`effective=[${diag.effective.join(',') || 'none'}] ` +
266+
`mapped=[${diag.mapped.join(',') || 'none'}] unmapped=[${diag.unmapped.join(',') || 'none'}] ` +
267+
`native=${diag.nativeBridgeOn ? 'on' : 'off'} nativeReason=${diag.nativeDecisionReason || 'none'} ` +
268+
`preamble=${diag.preambleTier || 'none'}${diag.preambleBytes != null ? `/${Math.round(diag.preambleBytes / 1024)}KB` : ''} ` +
269+
`forced=${diag.forcedName || 'none'} reasons=[${diag.reasons.join(',') || 'none'}]`,
270+
);
271+
}
272+
225273
export function redactRequestLogText(text) {
226274
return String(text || '')
227275
.replace(/sk-[A-Za-z0-9_-]{20,}/g, 'sk-***')
@@ -1768,6 +1816,7 @@ async function _handleChatCompletionsInner(body, context = {}) {
17681816
const callerEnv = emulateTools ? extractCallerEnvironment(messages) : '';
17691817
let toolPreamble = '';
17701818
let preambleTier = null;
1819+
let toolPreambleBudget = null;
17711820
// Payload budget for the proto-level tool preamble. The upstream LS
17721821
// panel state caps total request size at ~30KB; the preamble alone can
17731822
// approach that with 30+ tools (Claude Code, opencode, Cline). Past the
@@ -1801,6 +1850,7 @@ async function _handleChatCompletionsInner(body, context = {}) {
18011850
// route picks the gpt_native dialect (bare-JSON anti-refusal).
18021851
route: body.__route || 'chat',
18031852
});
1853+
toolPreambleBudget = budget;
18041854
preambleTier = budget.tier;
18051855
if (budget.compacted) {
18061856
log.warn(`Probe[${reqId}]: toolPreamble ${Math.round(budget.fullBytes / 1024)}KB exceeds soft cap ${Math.round(budget.softBytes / 1024)}KB; using ${budget.tier} tier (${Math.round(budget.finalBytes / 1024)}KB, ${budgetTools.length} tools)`);
@@ -1821,6 +1871,13 @@ async function _handleChatCompletionsInner(body, context = {}) {
18211871
}
18221872
toolPreamble = budget.preamble;
18231873
}
1874+
logToolRoutingDiagnostics(reqId, summarizeToolRoutingDiagnostics({
1875+
tools,
1876+
effectiveTools,
1877+
toolChoice: tool_choice,
1878+
toolRouting,
1879+
preambleBudget: toolPreambleBudget,
1880+
}));
18241881
// Diagnostic: surface whether environment lifting actually fired so a real
18251882
// request log immediately tells us if Claude Code 2.x changed `<env>` block
18261883
// wording, or if the extraction guard rejected a valid hint. Cheap to log,

src/handlers/responses.js

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,40 @@ function normalizeResponseToolChoice(toolChoice) {
219219
return toolChoice;
220220
}
221221

222+
function requestedResponseToolChoiceName(toolChoice) {
223+
if (!toolChoice || typeof toolChoice !== 'object') return '';
224+
if (toolChoice.type === 'function') {
225+
return encodeToolName(toolChoice.function?.name || toolChoice.name || '', toolChoice.function?.namespace || toolChoice.namespace || '');
226+
}
227+
if (toolChoice.type === 'custom' || toolChoice.type === 'namespace') {
228+
return encodeToolName(toolChoice.name || toolChoice.function?.name || '', toolChoice.namespace || toolChoice.function?.namespace || '');
229+
}
230+
if (toolChoice.type === 'web_search' || toolChoice.type === 'web_search_preview') return 'web_search';
231+
if (toolChoice.type === 'tool_search') return 'tool_search';
232+
return toolChoice.name || toolChoice.function?.name || toolChoice.type || '';
233+
}
234+
235+
function pruneResponseToolChoice(toolChoice, forwardedTools) {
236+
const normalized = normalizeResponseToolChoice(toolChoice);
237+
if (normalized == null) return undefined;
238+
if (normalized === 'auto' || normalized === 'required' || normalized === 'none') return normalized;
239+
240+
const requested = requestedResponseToolChoiceName(toolChoice);
241+
const availableNames = new Set((forwardedTools || []).map(t => t.function?.name || t.name).filter(Boolean));
242+
const forcedName = normalized.function?.name || '';
243+
if (forcedName) {
244+
if (availableNames.has(forcedName)) return normalized;
245+
log.warn(`responses: dropped forced tool_choice "${requested || forcedName}" because the matching tool was not forwarded (available=[${[...availableNames].join(',') || 'none'}])`);
246+
return undefined;
247+
}
248+
249+
if (toolChoice && typeof toolChoice === 'object' && UNBRIDGED_SERVER_SIDE_TYPES.has(toolChoice.type)) {
250+
log.warn(`responses: dropped forced server-side tool_choice "${toolChoice.type}" because this proxy does not bridge that tool type`);
251+
return undefined;
252+
}
253+
return normalized;
254+
}
255+
222256
function normalizeResponseTextFormat(format) {
223257
if (!format || typeof format !== 'object') return null;
224258
if (format.type === 'json_object') return { type: 'json_object' };
@@ -306,6 +340,9 @@ export function responsesToChat(body) {
306340

307341
const tools = flattenResponseTools(body.tools || []);
308342
const responseFormat = normalizeResponseTextFormat(body.text?.format);
343+
const forwardedToolChoice = body.tool_choice != null
344+
? pruneResponseToolChoice(body.tool_choice, tools)
345+
: undefined;
309346
return {
310347
model: body.model || 'claude-sonnet-4.6',
311348
messages,
@@ -315,7 +352,7 @@ export function responsesToChat(body) {
315352
...(tools.length ? { tools } : {}),
316353
...(body.temperature != null ? { temperature: body.temperature } : {}),
317354
...(body.top_p != null ? { top_p: body.top_p } : {}),
318-
...(body.tool_choice != null ? { tool_choice: normalizeResponseToolChoice(body.tool_choice) } : {}),
355+
...(forwardedToolChoice != null ? { tool_choice: forwardedToolChoice } : {}),
319356
...(responseFormat ? { response_format: responseFormat } : {}),
320357
};
321358
}

0 commit comments

Comments
 (0)