Skip to content

Commit d9baa81

Browse files
authored
Merge pull request #8 from Jiangzhewen/fix/foreign-tool-call-formats
2 parents 8a1e8f8 + c9e4b8a commit d9baa81

7 files changed

Lines changed: 1857 additions & 150 deletions

File tree

‎index.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ console.log(` - Cleanup Interval: ${finalConfig.CLEANUP_INTERVAL_MS}ms`);
147147
console.log(` - Cleanup Max Age: ${finalConfig.CLEANUP_MAX_AGE_MS}ms`);
148148
console.log(` - Debug: ${finalConfig.DEBUG ? 'Yes' : 'No'}`);
149149

150+
// A rejected promise inside a request handler must not take the whole proxy down.
151+
// Node's default --unhandled-rejections=throw turns one bad request into a process
152+
// exit, which reads to users as "the service stopped working after a while".
153+
process.on('unhandledRejection', (reason) => {
154+
const detail = reason instanceof Error
155+
? `${reason.name}: ${reason.message}`
156+
: typeof reason === 'string' ? reason : JSON.stringify(reason);
157+
console.error('[Proxy] Unhandled rejection (request dropped, server continues):', detail);
158+
if (reason instanceof Error && reason.stack) console.error(reason.stack);
159+
});
160+
150161
// Start the proxy
151162
try {
152163
const proxy = startProxy(finalConfig);

‎src/proxy.js‎

Lines changed: 134 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,10 @@ const STARTING_WAIT_ITERATIONS = 120;
193193
const STARTING_WAIT_INTERVAL_MS = 1000;
194194
const DEFAULT_REQUEST_TIMEOUT_MS = 300000;
195195
const DEFAULT_POLL_INTERVAL_MS = 500;
196-
const DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS = 4000;
196+
// Reasoning models can take well over 10s before emitting their first token.
197+
// A short window here makes the event stream give up and fall back to polling on
198+
// every request, which loses true streaming. Configurable for slow backends.
199+
const DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS = Number(process.env.OPENCODE2API_EVENT_FIRST_DELTA_TIMEOUT_MS) || 30000;
197200
const DEFAULT_EVENT_IDLE_TIMEOUT_MS = Number(process.env.OPENCODE2API_EVENT_IDLE_TIMEOUT_MS) || 8000;
198201

199202
const OPENCODE_BASENAME = 'opencode';
@@ -1078,15 +1081,22 @@ export function createApp(config) {
10781081
}
10791082

10801083
function extractFromParts(parts) {
1081-
if (!Array.isArray(parts)) return { content: '', reasoning: '' };
1084+
if (!Array.isArray(parts)) return { content: '', reasoning: '', toolParts: [] };
10821085
const content = parts.filter(p => p.type === 'text').map(p => p.text).join('');
10831086
const reasoning = parts.filter(p => p.type === 'reasoning').map(p => p.text).join('');
1084-
return { content, reasoning };
1087+
const toolParts = parts.filter(p => p.type === 'tool');
1088+
return { content, reasoning, toolParts };
10851089
}
10861090

10871091
async function pollForAssistantResponse(sessionId, timeoutMs, intervalMs = DEFAULT_POLL_INTERVAL_MS) {
10881092
const pollStart = Date.now();
10891093
const startedAt = Date.now();
1094+
// Best-effort snapshot of the most recent in-flight assistant message. Polling
1095+
// observes partial messages: a reasoning model emits its reasoning part first and
1096+
// the text part only afterwards, so returning on the first non-empty snapshot
1097+
// truncates the answer to the reasoning alone. Keep the partial around purely as
1098+
// a timeout fallback and otherwise wait for the message to actually finish.
1099+
let lastPartial = null;
10901100
while (Date.now() - startedAt < timeoutMs) {
10911101
const messagesRes = await client.session.messages({ path: { id: sessionId } });
10921102
const messages = messagesRes?.data || messagesRes || [];
@@ -1095,10 +1105,25 @@ export function createApp(config) {
10951105
const entry = messages[i];
10961106
const info = entry?.info;
10971107
if (info?.role !== 'assistant') continue;
1098-
const { content, reasoning } = extractFromParts(entry?.parts || []);
1108+
const { content, reasoning, toolParts } = extractFromParts(entry?.parts || []);
10991109
const error = info?.error || null;
1100-
const done = Boolean(info.finish || info.time?.completed || error);
1101-
if (done || content || reasoning) {
1110+
// finish === 'tool' marks an intermediate turn that pauses for a tool
1111+
// result; the assistant is not done producing output yet.
1112+
const finished = info.finish && info.finish !== 'tool';
1113+
const done = Boolean(finished || info.time?.completed || error);
1114+
if (toolParts.length > 0) {
1115+
logDebug('Polling found tool parts', {
1116+
sessionId,
1117+
count: toolParts.length,
1118+
parts: toolParts.map(p => ({
1119+
id: p.id,
1120+
tool: p.tool,
1121+
status: p.state?.status,
1122+
input: p.state?.input
1123+
}))
1124+
});
1125+
}
1126+
if (done) {
11021127
if (error) {
11031128
console.error('[Proxy] OpenCode assistant error:', error);
11041129
}
@@ -1112,10 +1137,23 @@ export function createApp(config) {
11121137
});
11131138
return { content, reasoning, error };
11141139
}
1140+
if (content || reasoning) {
1141+
lastPartial = { content, reasoning, error: null };
1142+
}
1143+
break;
11151144
}
11161145
}
11171146
await sleep(intervalMs);
11181147
}
1148+
if (lastPartial) {
1149+
logDebug('Polling timeout with partial response', {
1150+
sessionId,
1151+
ms: Date.now() - pollStart,
1152+
contentLen: lastPartial.content.length,
1153+
reasoningLen: lastPartial.reasoning.length
1154+
});
1155+
return lastPartial;
1156+
}
11191157
logDebug('Polling timeout', { sessionId, ms: Date.now() - pollStart });
11201158
throw new Error(`Request timeout after ${timeoutMs}ms`);
11211159
}
@@ -1236,6 +1274,22 @@ export function createApp(config) {
12361274
event.properties.info.sessionID === sessionId) {
12371275
const info = event.properties.info;
12381276
const finish = info.finish;
1277+
// An aborted or failed message never produces another delta. Without
1278+
// this, the collector waits out the whole first-delta window before
1279+
// polling rediscovers the same error.
1280+
if (info.error && !finished) {
1281+
finished = true;
1282+
clearTimeout(timeoutId);
1283+
if (firstDeltaTimer) clearTimeout(firstDeltaTimer);
1284+
if (idleTimer) clearTimeout(idleTimer);
1285+
logDebug('SSE upstream message error', {
1286+
sessionId,
1287+
ms: Date.now() - startedAt,
1288+
error: info.error.name || 'UnknownError'
1289+
});
1290+
resolve({ content, reasoning, error: info.error });
1291+
break;
1292+
}
12391293
// Reconcile active tool calls from the full message snapshot so we
12401294
// detect pending tools even when only message.updated fires.
12411295
if (Array.isArray(info.parts)) {
@@ -1523,7 +1577,13 @@ export function createApp(config) {
15231577
body: {
15241578
model: { providerID: pID, modelID: mID },
15251579
system: systemWithGuard,
1526-
parts: parts,
1580+
// Append a short contract reminder as the last part so the model
1581+
// sees it immediately before generating. With the contract only in
1582+
// the 16KB+ system prompt it gets buried; position matters a lot for
1583+
// compliance. deepseek-v4-flash-free: 50% → 100% call rate.
1584+
parts: externalToolContext.reminder
1585+
? [...parts, { type: 'text', text: externalToolContext.reminder }]
1586+
: parts,
15271587
...(requestParams.max_tokens && { max_tokens: requestParams.max_tokens }),
15281588
...(requestParams.temperature !== undefined && { temperature: requestParams.temperature }),
15291589
...(requestParams.top_p !== undefined && { top_p: requestParams.top_p }),
@@ -1740,11 +1800,32 @@ export function createApp(config) {
17401800
})}\n\n`);
17411801
}
17421802

1803+
// Flush held buffers from the stream parsers and filters before final batch parse.
1804+
const flushedReasoningCalls = parseReasoningToolCalls.flush ? parseReasoningToolCalls.flush() : [];
1805+
const flushedContentCalls = parseContentToolCalls.flush ? parseContentToolCalls.flush() : [];
1806+
const flushedReasoningText = filterReasoningDelta.flush ? filterReasoningDelta.flush() : '';
1807+
const flushedContentText = filterContentDelta.flush ? filterContentDelta.flush() : '';
1808+
const finalReasoningText = rawStreamedReasoning + flushedReasoningText;
1809+
const finalContentText = rawStreamedContent + flushedContentText;
1810+
1811+
// Parse each channel, then retry on the two joined. Models sometimes open a
1812+
// block in reasoning and close it in content, leaving neither channel with a
1813+
// complete block. The joined retry only runs when nothing was found, so a
1814+
// block contained in one channel is never counted twice.
1815+
const parseStreamedToolCalls = () => {
1816+
if (externalToolRegistry.length === 0) return [];
1817+
const perChannel = [
1818+
...flushedReasoningCalls,
1819+
...flushedContentCalls,
1820+
...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText)
1821+
];
1822+
if (perChannel.length > 0) return perChannel;
1823+
return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText);
1824+
};
1825+
17431826
let parsedToolCalls = streamedToolCalls.length > 0
17441827
? streamedToolCalls
1745-
: (externalToolRegistry.length > 0
1746-
? parseExternalToolCallsFromText(externalToolRegistry, rawStreamedReasoning, rawStreamedContent)
1747-
: []);
1828+
: parseStreamedToolCalls();
17481829
if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') {
17491830
const forcedResponse = await requestForcedChatToolCall();
17501831
if (forcedResponse) {
@@ -2218,7 +2299,9 @@ export function createApp(config) {
22182299
body: {
22192300
model: { providerID: pID, modelID: mID },
22202301
...(systemWithGuard ? { system: systemWithGuard } : {}),
2221-
parts,
2302+
parts: externalToolContext.reminder
2303+
? [...parts, { type: 'text', text: externalToolContext.reminder }]
2304+
: parts,
22222305
...(max_output_tokens && { max_tokens: max_output_tokens }),
22232306
...(temperature !== undefined && { temperature }),
22242307
...(top_p !== undefined && { top_p })
@@ -2505,15 +2588,30 @@ export function createApp(config) {
25052588
} catch (e) { }
25062589
}
25072590

2591+
// Flush held buffers from the stream parsers and filters before final batch parse.
2592+
const flushedReasoningCalls = parseReasoningToolCalls.flush ? parseReasoningToolCalls.flush() : [];
2593+
const flushedContentCalls = parseContentToolCalls.flush ? parseContentToolCalls.flush() : [];
2594+
const flushedReasoningText = filterReasoningDelta.flush ? filterReasoningDelta.flush() : '';
2595+
const flushedContentText = filterContentDelta.flush ? filterContentDelta.flush() : '';
2596+
const finalReasoningText = (polledForToolCalls?.reasoning || rawReasoning) + flushedReasoningText;
2597+
const finalContentText = (polledForToolCalls?.content || rawContent) + flushedContentText;
2598+
2599+
// Parse each channel, then retry on the two joined. See the matching comment
2600+
// in /v1/chat/completions for why the joined retry is gated on finding nothing.
2601+
const parseStreamedToolCalls = () => {
2602+
if (externalToolRegistry.length === 0) return [];
2603+
const perChannel = [
2604+
...flushedReasoningCalls,
2605+
...flushedContentCalls,
2606+
...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText)
2607+
];
2608+
if (perChannel.length > 0) return perChannel;
2609+
return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText);
2610+
};
2611+
25082612
let parsedToolCalls = streamedToolCalls.length > 0
25092613
? streamedToolCalls
2510-
: (externalToolRegistry.length > 0
2511-
? parseExternalToolCallsFromText(
2512-
externalToolRegistry,
2513-
polledForToolCalls?.reasoning || rawReasoning,
2514-
polledForToolCalls?.content || rawContent
2515-
)
2516-
: []);
2614+
: parseStreamedToolCalls();
25172615
if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') {
25182616
const forcedResponse = await requestForcedResponsesToolCall();
25192617
if (forcedResponse) {
@@ -2643,9 +2741,25 @@ export function createApp(config) {
26432741

26442742
return res.json(response);
26452743
} catch (error) {
2646-
console.error('[Proxy] Responses API Error:', error.message);
2744+
console.error('[Proxy] Responses API Error:', error?.message || error?.data?.message || error?.name || error);
26472745
const transformed = transformUpstreamError(error);
2648-
res.status(transformed.statusCode).json(transformed.error);
2746+
// Once the SSE headers are out, res.json() throws ERR_HTTP_HEADERS_SENT. That throw
2747+
// escapes this async handler as an unhandled rejection, which terminates the whole
2748+
// process under Node's default --unhandled-rejections=throw. Report the failure on
2749+
// the already-open stream instead.
2750+
if (res.headersSent) {
2751+
try {
2752+
res.write(`data: ${JSON.stringify({
2753+
type: 'response.failed',
2754+
response: { error: transformed.error.error || transformed.error }
2755+
})}\n\n`);
2756+
res.write('data: [DONE]\n\n');
2757+
} catch (writeError) {
2758+
logDebug('Failed to report error on open response stream', { error: writeError.message });
2759+
}
2760+
return res.end();
2761+
}
2762+
return res.status(transformed.statusCode).json(transformed.error);
26492763
}
26502764
});
26512765

0 commit comments

Comments
 (0)