Skip to content

Commit 7392ccb

Browse files
committed
fix(#59,#63,#66): audit-driven hardening — parser order, rate-limit, /v1/responses + /v1/messages spec gaps
7 fixes from a project-wide gpt-5.5 audit: #59 sub-bug 3 — tool-boundary text split (P1 真因 = parser bug) - ToolCallStreamParser.feed() 之前 `{text, toolCalls}` 两个数组返回,丢失 text/tool 相对顺序 - 改成同时返回 `items: [{type:'text',text}|{type:'tool_call',toolCall}]` 保留顺序 - chat.js 流式消费方按 items 顺序 emit,不再先发全部 tool 再发文本 - 老 `text/toolCalls` 字段保留向后兼容 #66 — 300秒限速误报 - rateLimitCooldownMs 解析具体的 retry-after N seconds/minutes/hours,不再一刀切 5min - markRateLimited 改成 max-extend 而不是覆盖,并发 429 不会把 cooldown 不断后推 - preflight checkMessageRateLimit 没拿到 retryAfterMs 时不再本地标 cooldown,本次 skip 即可 - windsurf-api.js 透传上游 retryAfterMs #63 follow-up + P1 #2 — /v1/responses 规范缺口 - 非 function tools (web_search_preview 等) 静默 drop → 改成 400 直接拒 - function-call-only 响应不再带空 message item 在 output P1 #1 — /v1/messages 丢失 thinking + tool_choice - anthropicToOpenAI 透传 body.thinking - Anthropic tool_choice (auto/any/tool/none) 映射到 OpenAI 形状 P1 #3 — 全账号 RPM 满返回 429 不是 503 - isAllTemporarilyUnavailable 聚合 rate_limit / model_rate_limit / rpm_full / strict_reuse_busy - 非流式路径在 queue 超时后返回 429 + Retry-After,503 只在真没账号时返 - 流式 SSE 头已发,body error type 至少标对 rate_limit_exceeded P1 #4 — preflight skip 还在吃本地 RPM headroom - account 加 _lastReservationAt + getApiKey 返回 reservationTimestamp - refundReservation(apiKey, ts) 把最近一条 _rpmHistory 退回去 - preflight !hasCapacity 路径自动调用,避免被跳过的账号占住本地配额 P2 cache-hit chunk 不一致 - cache HIT 流式分支拆成 finish_reason chunk + 单独 usage chunk,跟 live-stream 路径同 shape 测试: - 12 个新单测 + 修了既有的 4 个,全套 158 个测试通过 - 新文件: test/rate-limit.test.js, test/messages.test.js, test/chat-cache-hit.test.js - 改动: test/tool-emulation.test.js (items 顺序), test/responses.test.js (unsupported tool 400 + empty msg) 来源审计报告: tmp/audit-report-2026-04-26.md (gpt-5.5 high reasoning 出的 296 行 P0/P1/P2 全列)
1 parent 615b5a3 commit 7392ccb

11 files changed

Lines changed: 563 additions & 61 deletions

src/auth.js

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -398,12 +398,14 @@ export function getApiKey(excludeKeys = [], modelKey = null) {
398398

399399
const { account } = candidates[0];
400400
account._rpmHistory.push(now);
401+
account._lastReservationAt = now;
401402
account.lastUsed = now;
402403
account._inflight = (account._inflight || 0) + 1;
403404
return {
404405
id: account.id, email: account.email, apiKey: account.apiKey,
405406
apiServerUrl: account.apiServerUrl || '',
406407
proxy: getEffectiveProxy(account.id) || null,
408+
reservationTimestamp: now,
407409
};
408410
}
409411

@@ -439,12 +441,14 @@ export function acquireAccountByKey(apiKey, modelKey = null) {
439441
if (used >= limit) return null;
440442
if (modelKey && !isModelAllowedForAccount(a, modelKey)) return null;
441443
a._rpmHistory.push(now);
444+
a._lastReservationAt = now;
442445
a.lastUsed = now;
443446
a._inflight = (a._inflight || 0) + 1;
444447
return {
445448
id: a.id, email: a.email, apiKey: a.apiKey,
446449
apiServerUrl: a.apiServerUrl || '',
447450
proxy: getEffectiveProxy(a.id) || null,
451+
reservationTimestamp: now,
448452
};
449453
}
450454

@@ -530,17 +534,30 @@ export async function ensureLsForAccount(accountId) {
530534
export function markRateLimited(apiKey, durationMs = 5 * 60 * 1000, modelKey = null) {
531535
const account = accounts.find(a => a.apiKey === apiKey);
532536
if (!account) return;
533-
const until = Date.now() + durationMs;
537+
const safeMs = Math.max(1000, Number(durationMs) || 0);
538+
const until = Date.now() + safeMs;
534539
if (modelKey) {
535540
if (!account._modelRateLimits) account._modelRateLimits = {};
536-
account._modelRateLimits[modelKey] = until;
537-
log.warn(`Account ${account.id} (${account.email}) rate-limited on ${modelKey} for ${Math.round(durationMs / 60000)} min`);
541+
account._modelRateLimits[modelKey] = Math.max(account._modelRateLimits[modelKey] || 0, until);
542+
log.warn(`Account ${account.id} (${account.email}) rate-limited on ${modelKey} for ${Math.round(safeMs / 60000)} min`);
538543
} else {
539-
account.rateLimitedUntil = until;
540-
log.warn(`Account ${account.id} (${account.email}) rate-limited (all models) for ${Math.round(durationMs / 60000)} min`);
544+
account.rateLimitedUntil = Math.max(account.rateLimitedUntil || 0, until);
545+
log.warn(`Account ${account.id} (${account.email}) rate-limited (all models) for ${Math.round(safeMs / 60000)} min`);
541546
}
542547
}
543548

549+
export function refundReservation(apiKey, timestamp) {
550+
const account = accounts.find(a => a.apiKey === apiKey);
551+
if (!account) return false;
552+
if (!Number.isFinite(timestamp)) return false;
553+
if ((account._inflight || 0) <= 0) return false;
554+
pruneRpmHistory(account, Date.now());
555+
const idx = account._rpmHistory?.lastIndexOf(timestamp) ?? -1;
556+
if (idx === -1) return false;
557+
account._rpmHistory.splice(idx, 1);
558+
return true;
559+
}
560+
544561
/**
545562
* Check if an account is rate-limited for a specific model.
546563
*/
@@ -628,6 +645,50 @@ export function isAllRateLimited(modelKey) {
628645
return { allLimited: true, retryAfterMs };
629646
}
630647

648+
export function isAllTemporarilyUnavailable(modelKey) {
649+
const now = Date.now();
650+
let anyEligible = false;
651+
let soonestExpiry = Infinity;
652+
653+
for (const a of accounts) {
654+
if (a.status !== 'active') continue;
655+
const limit = rpmLimitFor(a);
656+
if (limit <= 0) continue;
657+
if (modelKey && !isModelAllowedForAccount(a, modelKey)) continue;
658+
anyEligible = true;
659+
660+
if (a.rateLimitedUntil && a.rateLimitedUntil > now) {
661+
soonestExpiry = Math.min(soonestExpiry, a.rateLimitedUntil);
662+
continue;
663+
}
664+
665+
if (modelKey && a._modelRateLimits) {
666+
const until = a._modelRateLimits[modelKey];
667+
if (until && until > now) {
668+
soonestExpiry = Math.min(soonestExpiry, until);
669+
continue;
670+
}
671+
if (until && until <= now) delete a._modelRateLimits[modelKey];
672+
}
673+
674+
const used = pruneRpmHistory(a, now);
675+
if (used >= limit) {
676+
const oldest = a._rpmHistory?.[0];
677+
soonestExpiry = Math.min(
678+
soonestExpiry,
679+
oldest ? Math.max(now + 30_000, oldest + RPM_WINDOW_MS) : now + 30_000
680+
);
681+
continue;
682+
}
683+
684+
return { allUnavailable: false, retryAfterMs: null };
685+
}
686+
687+
if (!anyEligible) return { allUnavailable: false, retryAfterMs: null };
688+
const retryAfterMs = soonestExpiry === Infinity ? 30_000 : Math.max(1000, soonestExpiry - now);
689+
return { allUnavailable: true, retryAfterMs };
690+
}
691+
631692
export function isAuthenticated() {
632693
return accounts.some(a => a.status === 'active');
633694
}

src/handlers/chat.js

Lines changed: 85 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { createHash, randomUUID } from 'crypto';
77
import { WindsurfClient, isCascadeTransportError } from '../client.js';
8-
import { getApiKey, acquireAccountByKey, releaseAccount, getAccountAvailability, reportError, reportSuccess, markRateLimited, reportInternalError, updateCapability, getAccountList, isAllRateLimited } from '../auth.js';
8+
import { getApiKey, acquireAccountByKey, releaseAccount, getAccountAvailability, reportError, reportSuccess, markRateLimited, reportInternalError, updateCapability, getAccountList, isAllRateLimited, isAllTemporarilyUnavailable, refundReservation } from '../auth.js';
99
import { resolveModel, getModelInfo } from '../models.js';
1010
import { getLsFor, ensureLs } from '../langserver.js';
1111
import { config, log } from '../config.js';
@@ -186,9 +186,17 @@ function strictReuseMessage(model, retryMs, reason = 'temporarily unavailable')
186186
return `${model} 上下文复用绑定账号暂不可用(${reason})。为避免切换账号导致上下文丢失,请 ${Math.ceil(retryMs / 1000)} 秒后重试`;
187187
}
188188

189-
function rateLimitCooldownMs(message = '') {
189+
export function rateLimitCooldownMs(message = '') {
190+
const m = String(message || '').match(/(?:retry (?:after|in)|after)\s+(\d+)\s*(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)/i);
191+
if (m) {
192+
const n = Number(m[1]);
193+
const unit = m[2].toLowerCase();
194+
if (unit.startsWith('h')) return n * 60 * 60 * 1000;
195+
if (unit.startsWith('m')) return n * 60 * 1000;
196+
return n * 1000;
197+
}
190198
if (/about an hour|in an hour|try again in.*hour/i.test(message)) return 60 * 60 * 1000;
191-
return 5 * 60 * 1000;
199+
return 60 * 1000;
192200
}
193201

194202
function genId() {
@@ -436,6 +444,8 @@ export async function handleChatCompletions(body, context = {}) {
436444
} = body;
437445
let messages = body.messages;
438446
const callerKey = context.callerKey || body.__callerKey || '';
447+
const checkMessageRateLimitFn = context.checkMessageRateLimit || checkMessageRateLimit;
448+
const waitForAccountFn = context.waitForAccount || waitForAccount;
439449

440450
// Probe diagnostics: dump compact request shape for every call, plus a
441451
// tail of the last user turn. Keeps us able to see how third-party
@@ -664,7 +674,10 @@ export async function handleChatCompletions(body, context = {}) {
664674
const ckey = cacheKey(body);
665675

666676
if (stream) {
667-
return streamResponse(chatId, created, displayModel, modelKey, messages, cascadeMessages, modelEnum, modelUid, useCascade, ckey, emulateTools, toolPreamble, reqId, wantJson, callerKey);
677+
return streamResponse(chatId, created, displayModel, modelKey, messages, cascadeMessages, modelEnum, modelUid, useCascade, ckey, emulateTools, toolPreamble, reqId, wantJson, callerKey, {
678+
checkMessageRateLimit: checkMessageRateLimitFn,
679+
waitForAccount: waitForAccountFn,
680+
});
668681
}
669682

670683
// ── Local response cache (exact body match) ─────────────
@@ -750,7 +763,7 @@ export async function handleChatCompletions(body, context = {}) {
750763
}
751764
}
752765
if (!acct) {
753-
acct = await waitForAccount(tried, null, QUEUE_MAX_WAIT_MS, modelKey);
766+
acct = await waitForAccountFn(tried, null, QUEUE_MAX_WAIT_MS, modelKey);
754767
if (!acct) break;
755768
}
756769
tried.push(acct.apiKey);
@@ -761,10 +774,13 @@ export async function handleChatCompletions(body, context = {}) {
761774
if (isExperimentalEnabled('preflightRateLimit')) {
762775
try {
763776
const px = getEffectiveProxy(acct.id) || null;
764-
const rl = await checkMessageRateLimit(acct.apiKey, px);
777+
const rl = await checkMessageRateLimitFn(acct.apiKey, px);
765778
if (!rl.hasCapacity) {
766779
log.warn(`Preflight: ${acct.email} has no capacity (remaining=${rl.messagesRemaining}), skipping`);
767-
markRateLimited(acct.apiKey, 5 * 60 * 1000, modelKey);
780+
refundReservation(acct.apiKey, acct.reservationTimestamp);
781+
if (Number.isFinite(rl.retryAfterMs) && rl.retryAfterMs > 0) {
782+
markRateLimited(acct.apiKey, rl.retryAfterMs, modelKey);
783+
}
768784
if (strictReuse && checkedOutReuseEntry && fpBefore && checkedOutReuseEntry.apiKey === acct.apiKey) {
769785
const availability = getAccountAvailability(acct.apiKey, modelKey);
770786
const retryAfterMs = strictReuseRetryMs(availability);
@@ -872,6 +888,25 @@ export async function handleChatCompletions(body, context = {}) {
872888
};
873889
}
874890
// If all accounts exhausted, check if it's because they're all rate-limited
891+
const temporaryUnavailable = isAllTemporarilyUnavailable(modelKey);
892+
if (temporaryUnavailable.allUnavailable) {
893+
if (checkedOutReuseEntry && fpBefore) {
894+
poolCheckin(fpBefore, checkedOutReuseEntry, callerKey);
895+
log.info(`Chat[${reqId}]: restored checked-out cascade after temporary unavailability`);
896+
}
897+
const retryAfterSec = Math.ceil(temporaryUnavailable.retryAfterMs / 1000);
898+
return {
899+
status: 429,
900+
headers: { 'Retry-After': String(retryAfterSec) },
901+
body: {
902+
error: {
903+
message: `${displayModel} 所有账号暂时不可用,请 ${retryAfterSec} 秒后重试`,
904+
type: 'rate_limit_exceeded',
905+
retry_after_ms: temporaryUnavailable.retryAfterMs,
906+
},
907+
},
908+
};
909+
}
875910
if (!lastErr || lastErr.status === 429) {
876911
const rl = isAllRateLimited(modelKey);
877912
if (rl.allLimited) {
@@ -1060,7 +1095,9 @@ async function nonStreamResponse(client, id, created, model, modelKey, messages,
10601095
}
10611096
}
10621097

1063-
function streamResponse(id, created, model, modelKey, messages, cascadeMessages, modelEnum, modelUid, useCascade, ckey, emulateTools, toolPreamble, reqId, wantJson = false, callerKey = '') {
1098+
function streamResponse(id, created, model, modelKey, messages, cascadeMessages, modelEnum, modelUid, useCascade, ckey, emulateTools, toolPreamble, reqId, wantJson = false, callerKey = '', deps = {}) {
1099+
const checkMessageRateLimitFn = deps.checkMessageRateLimit || checkMessageRateLimit;
1100+
const waitForAccountFn = deps.waitForAccount || waitForAccount;
10641101
return {
10651102
status: 200,
10661103
stream: true,
@@ -1120,8 +1157,9 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
11201157
choices: [{ index: 0, delta: { content: cached.text }, finish_reason: null }] });
11211158
}
11221159
send({ id, object: 'chat.completion.chunk', created, model,
1123-
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
1124-
usage: cachedUsage(messages, cached.text) });
1160+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
1161+
send({ id, object: 'chat.completion.chunk', created, model,
1162+
choices: [], usage: cachedUsage(messages, cached.text) });
11251163
if (!res.writableEnded) { res.write('data: [DONE]\n\n'); res.end(); }
11261164
} finally {
11271165
unregisterSse();
@@ -1228,18 +1266,32 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
12281266
// → client
12291267
let safeText = chunk.text;
12301268
if (toolParser) {
1231-
const { text: safe, toolCalls: done } = toolParser.feed(chunk.text);
1232-
safeText = safe;
1233-
// Only emit tool_call deltas when emulating — otherwise the
1234-
// parsed calls came from Cascade's built-in tools and are
1235-
// silently discarded. Sanitize server-internal paths out of
1236-
// the emulated call's input too (issue #38) — otherwise Claude
1237-
// Code tries to Read the sandbox path and fails.
1238-
for (const rawTc of done) {
1239-
const tc = sanitizeToolCall(rawTc);
1240-
const idx = collectedToolCalls.length;
1241-
collectedToolCalls.push(tc);
1242-
emitToolCallDelta(tc, idx);
1269+
const parsed = toolParser.feed(chunk.text);
1270+
safeText = parsed.text;
1271+
if (Array.isArray(parsed.items) && parsed.items.length) {
1272+
for (const item of parsed.items) {
1273+
if (item.type === 'text') {
1274+
emitContent(pathStreamText.feed(item.text));
1275+
continue;
1276+
}
1277+
const tc = sanitizeToolCall(item.toolCall);
1278+
const idx = collectedToolCalls.length;
1279+
collectedToolCalls.push(tc);
1280+
emitToolCallDelta(tc, idx);
1281+
}
1282+
safeText = '';
1283+
} else {
1284+
// Only emit tool_call deltas when emulating — otherwise the
1285+
// parsed calls came from Cascade's built-in tools and are
1286+
// silently discarded. Sanitize server-internal paths out of
1287+
// the emulated call's input too (issue #38) — otherwise Claude
1288+
// Code tries to Read the sandbox path and fails.
1289+
for (const rawTc of parsed.toolCalls) {
1290+
const tc = sanitizeToolCall(rawTc);
1291+
const idx = collectedToolCalls.length;
1292+
collectedToolCalls.push(tc);
1293+
emitToolCallDelta(tc, idx);
1294+
}
12431295
}
12441296
}
12451297
if (safeText) emitContent(pathStreamText.feed(safeText));
@@ -1283,7 +1335,7 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
12831335
}
12841336
}
12851337
if (!acct) {
1286-
acct = await waitForAccount(tried, abortController.signal, QUEUE_MAX_WAIT_MS, modelKey);
1338+
acct = await waitForAccountFn(tried, abortController.signal, QUEUE_MAX_WAIT_MS, modelKey);
12871339
if (!acct) break;
12881340
}
12891341
tried.push(acct.apiKey);
@@ -1294,10 +1346,13 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
12941346
if (isExperimentalEnabled('preflightRateLimit')) {
12951347
try {
12961348
const px = getEffectiveProxy(acct.id) || null;
1297-
const rl = await checkMessageRateLimit(acct.apiKey, px);
1349+
const rl = await checkMessageRateLimitFn(acct.apiKey, px);
12981350
if (!rl.hasCapacity) {
12991351
log.warn(`Preflight: ${acct.email} has no capacity (remaining=${rl.messagesRemaining}), skipping`);
1300-
markRateLimited(acct.apiKey, 5 * 60 * 1000, modelKey);
1352+
refundReservation(acct.apiKey, acct.reservationTimestamp);
1353+
if (Number.isFinite(rl.retryAfterMs) && rl.retryAfterMs > 0) {
1354+
markRateLimited(acct.apiKey, rl.retryAfterMs, modelKey);
1355+
}
13011356
if (strictReuse && checkedOutReuseEntry && fpBefore && checkedOutReuseEntry.apiKey === acct.apiKey) {
13021357
const availability = getAccountAvailability(acct.apiKey, modelKey);
13031358
const retryAfterMs = strictReuseRetryMs(availability);
@@ -1449,12 +1504,15 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
14491504
log.error('Stream error after retries:', lastErr?.message);
14501505
recordRequest(model, false, Date.now() - startTime, currentApiKey);
14511506
try {
1507+
const temporaryUnavailable = isAllTemporarilyUnavailable(modelKey);
14521508
const rl = isAllRateLimited(modelKey);
14531509
const allInternal = streamInternalCount > 0 && tried.length > 0 && streamInternalCount >= tried.length;
14541510
// 优先暴露 upstream_transient,避免把 Cascade transport 抖动误报成账号限流。
14551511
const lastIsTransport = isCascadeTransportError(lastErr);
14561512
const errMsg = allInternal
14571513
? upstreamTransientErrorMessage(model, tried.length, lastIsTransport ? 'cascade_transport' : 'internal_error')
1514+
: temporaryUnavailable.allUnavailable
1515+
? `${model} 所有账号暂时不可用,请 ${Math.ceil(temporaryUnavailable.retryAfterMs / 1000)} 秒后重试`
14581516
: rl.allLimited
14591517
? `${model} 所有账号均已达速率限制,请 ${Math.ceil(rl.retryAfterMs / 1000)} 秒后重试`
14601518
: sanitizeText(lastErr?.message || 'no accounts');
@@ -1473,10 +1531,10 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
14731531
// output). Close cleanly with a plain stop — the caller saw
14741532
// whatever partial content we produced. Error details only
14751533
// go to the server log.
1476-
send(chatStreamError(errMsg, allInternal ? 'upstream_transient_error' : 'upstream_error'));
1534+
send(chatStreamError(errMsg, allInternal ? 'upstream_transient_error' : temporaryUnavailable.allUnavailable ? 'rate_limit_exceeded' : 'upstream_error'));
14771535
log.warn(`Stream: partial response delivered then failed (${errMsg})`);
14781536
} else {
1479-
send(chatStreamError(errMsg, allInternal ? 'upstream_transient_error' : 'upstream_error'));
1537+
send(chatStreamError(errMsg, allInternal ? 'upstream_transient_error' : temporaryUnavailable.allUnavailable ? 'rate_limit_exceeded' : 'upstream_error'));
14801538
}
14811539
res.write('data: [DONE]\n\n');
14821540
} catch {}

src/handlers/messages.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ function genMsgId() {
2222
// ─── Anthropic → OpenAI request translation ──────────────────
2323

2424
function anthropicToOpenAI(body) {
25+
const mapAnthropicToolChoice = (toolChoice) => {
26+
if (!toolChoice || typeof toolChoice !== 'object') return toolChoice;
27+
if (toolChoice.type === 'auto') return 'auto';
28+
if (toolChoice.type === 'any') return 'required';
29+
if (toolChoice.type === 'none') return 'none';
30+
if (toolChoice.type === 'tool' && toolChoice.name) {
31+
return { type: 'function', function: { name: toolChoice.name } };
32+
}
33+
return toolChoice;
34+
};
2535
const messages = [];
2636
if (body.system) {
2737
const sysText = typeof body.system === 'string'
@@ -95,6 +105,8 @@ function anthropicToOpenAI(body) {
95105
...(body.temperature != null ? { temperature: body.temperature } : {}),
96106
...(body.top_p != null ? { top_p: body.top_p } : {}),
97107
...(body.stop_sequences ? { stop: body.stop_sequences } : {}),
108+
...(body.tool_choice ? { tool_choice: mapAnthropicToolChoice(body.tool_choice) } : {}),
109+
...(body.thinking ? { thinking: body.thinking } : {}),
98110
};
99111
}
100112

0 commit comments

Comments
 (0)