mixdog 0.9.1 → 0.9.2
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/package.json +8 -1
- package/scripts/_bench-cwc.json +20 -0
- package/scripts/agent-loop-policy-test.mjs +37 -0
- package/scripts/agent-parallel-smoke.mjs +54 -10
- package/scripts/background-task-meta-smoke.mjs +1 -1
- package/scripts/bench-run.mjs +262 -0
- package/scripts/compact-smoke.mjs +12 -0
- package/scripts/compact-trigger-migration-smoke.mjs +67 -1
- package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
- package/scripts/internal-comms-bench.mjs +727 -0
- package/scripts/internal-comms-smoke.mjs +75 -0
- package/scripts/lead-workflow-smoke.mjs +4 -4
- package/scripts/live-worker-smoke.mjs +9 -9
- package/scripts/output-style-bench.mjs +285 -0
- package/scripts/output-style-smoke.mjs +13 -10
- package/scripts/patch-replay.mjs +90 -0
- package/scripts/provider-stream-stall-test.mjs +276 -0
- package/scripts/provider-toolcall-test.mjs +599 -1
- package/scripts/routing-corpus.mjs +281 -0
- package/scripts/session-bench.mjs +1526 -0
- package/scripts/session-diag.mjs +595 -0
- package/scripts/task-bench.mjs +207 -0
- package/scripts/tool-failures.mjs +6 -6
- package/scripts/tool-smoke.mjs +306 -66
- package/scripts/toolcall-args-test.mjs +81 -0
- package/src/agents/debugger/AGENT.md +4 -4
- package/src/agents/heavy-worker/AGENT.md +4 -2
- package/src/agents/reviewer/AGENT.md +4 -4
- package/src/agents/worker/AGENT.md +4 -2
- package/src/app.mjs +10 -6
- package/src/defaults/{hidden-roles.json → agents.json} +7 -7
- package/src/examples/schedules/SCHEDULE.example.md +32 -0
- package/src/examples/webhooks/WEBHOOK.example.md +40 -0
- package/src/headless-role.mjs +14 -14
- package/src/help.mjs +1 -0
- package/src/lib/rules-builder.cjs +32 -54
- package/src/mixdog-session-runtime.mjs +710 -318
- package/src/output-styles/default.md +12 -7
- package/src/output-styles/minimal.md +25 -0
- package/src/output-styles/oneline.md +21 -0
- package/src/output-styles/simple.md +10 -9
- package/src/repl.mjs +12 -2
- package/src/rules/agent/00-common.md +7 -5
- package/src/rules/agent/30-explorer.md +7 -8
- package/src/rules/lead/01-general.md +3 -1
- package/src/rules/lead/lead-tool.md +7 -0
- package/src/rules/shared/01-tool.md +17 -12
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
- package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
- package/src/runtime/agent/orchestrator/config.mjs +3 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
- package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
- package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
- package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
- package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
- package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
- package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
- package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
- package/src/runtime/channels/backends/discord.mjs +99 -9
- package/src/runtime/channels/backends/telegram.mjs +501 -0
- package/src/runtime/channels/index.mjs +441 -1224
- package/src/runtime/channels/lib/config.mjs +54 -2
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +283 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +184 -19
- package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory.mjs +101 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/session-ingest.mjs +107 -0
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +6 -3
- package/src/runtime/shared/channel-notification-routing.mjs +12 -0
- package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
- package/src/runtime/shared/config.mjs +9 -0
- package/src/runtime/shared/llm/http-agent.mjs +12 -5
- package/src/runtime/shared/schedules-store.mjs +21 -19
- package/src/runtime/shared/tool-surface.mjs +98 -13
- package/src/runtime/shared/transcript-writer.mjs +129 -0
- package/src/runtime/shared/update-checker.mjs +214 -0
- package/src/standalone/agent-tool.mjs +255 -109
- package/src/standalone/channel-admin.mjs +133 -40
- package/src/standalone/channel-worker.mjs +8 -291
- package/src/standalone/explore-tool.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +3 -1
- package/src/standalone/provider-admin.mjs +11 -0
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2096 -732
- package/src/tui/components/ConfirmBar.jsx +47 -0
- package/src/tui/components/ContextPanel.jsx +5 -3
- package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
- package/src/tui/components/Markdown.jsx +22 -98
- package/src/tui/components/Message.jsx +14 -35
- package/src/tui/components/Picker.jsx +87 -12
- package/src/tui/components/PromptInput.jsx +83 -7
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +8 -5
- package/src/tui/components/Spinner.jsx +7 -7
- package/src/tui/components/StatusLine.jsx +40 -21
- package/src/tui/components/TextEntryPanel.jsx +51 -7
- package/src/tui/components/ToolExecution.jsx +170 -98
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +159 -21
- package/src/tui/components/tool-output-format.test.mjs +87 -0
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +6965 -2391
- package/src/tui/engine.mjs +287 -126
- package/src/tui/index.jsx +117 -7
- package/src/tui/keyboard-protocol.mjs +42 -0
- package/src/tui/lib/voice-recorder.mjs +453 -0
- package/src/tui/markdown/format-token.mjs +129 -76
- package/src/tui/markdown/format-token.test.mjs +61 -19
- package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
- package/src/tui/markdown/render-ansi.test.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +167 -0
- package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
- package/src/tui/markdown/table-layout.mjs +9 -9
- package/src/tui/paste-attachments.mjs +0 -11
- package/src/tui/prompt-history-store.mjs +129 -0
- package/src/tui/prompt-history-store.test.mjs +52 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
- package/src/tui/theme.mjs +41 -657
- package/src/tui/themes/base.mjs +86 -0
- package/src/tui/themes/basic.mjs +85 -0
- package/src/tui/themes/catppuccin.mjs +72 -0
- package/src/tui/themes/dracula.mjs +70 -0
- package/src/tui/themes/everforest.mjs +71 -0
- package/src/tui/themes/gruvbox.mjs +71 -0
- package/src/tui/themes/index.mjs +71 -0
- package/src/tui/themes/indigo.mjs +78 -0
- package/src/tui/themes/kanagawa.mjs +80 -0
- package/src/tui/themes/light.mjs +81 -0
- package/src/tui/themes/nord.mjs +72 -0
- package/src/tui/themes/onedark.mjs +16 -0
- package/src/tui/themes/rosepine.mjs +70 -0
- package/src/tui/themes/teal.mjs +81 -0
- package/src/tui/themes/tokyonight.mjs +79 -0
- package/src/tui/themes/utils.mjs +106 -0
- package/src/tui/themes/warm.mjs +79 -0
- package/src/tui/transcript-tool-failures.mjs +13 -2
- package/src/ui/markdown.mjs +1 -1
- package/src/ui/model-display.mjs +2 -2
- package/src/ui/statusline.mjs +26 -27
- package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
- package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
- package/src/workflows/default/WORKFLOW.md +39 -12
- package/src/workflows/sequential/WORKFLOW.md +46 -0
- package/src/workflows/solo/WORKFLOW.md +7 -0
- package/vendor/ink/build/display-width.js +62 -0
- package/vendor/ink/build/ink.js +100 -12
- package/vendor/ink/build/measure-text.js +4 -1
- package/vendor/ink/build/output.js +115 -9
- package/vendor/ink/build/render-node-to-output.js +4 -1
- package/vendor/ink/build/render.js +4 -0
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/workflows/default/workflow.json +0 -13
- package/src/workflows/solo/workflow.json +0 -7
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
3
|
-
PROVIDER_SSE_IDLE_TIMEOUT_MS,
|
|
4
3
|
PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
|
|
4
|
+
PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
|
|
5
|
+
streamStalledError,
|
|
5
6
|
createTimeoutSignal,
|
|
6
7
|
providerTimeoutError,
|
|
7
8
|
} from '../stall-policy.mjs';
|
|
8
9
|
import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
|
|
9
10
|
import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
|
|
11
|
+
import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
|
|
12
|
+
import { randomBytes } from 'crypto';
|
|
13
|
+
|
|
14
|
+
// Synthesize a native-shaped OpenAI tool call from a recovered leaked call.
|
|
15
|
+
// Matches the `call_...` id scheme the native Responses/Chat paths use so the
|
|
16
|
+
// dispatch loop and any downstream tool_result reference line up.
|
|
17
|
+
function synthLeakedOpenAICall(recovered) {
|
|
18
|
+
let args = recovered?.arguments;
|
|
19
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
|
|
20
|
+
return {
|
|
21
|
+
id: `call_leaked_${randomBytes(8).toString('hex')}`,
|
|
22
|
+
name: recovered.name,
|
|
23
|
+
arguments: args,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
10
26
|
|
|
11
27
|
function truncatedCompatStreamError(label, detail) {
|
|
12
28
|
return Object.assign(
|
|
@@ -87,17 +103,29 @@ function firstByteCompatStreamError(label) {
|
|
|
87
103
|
return err;
|
|
88
104
|
}
|
|
89
105
|
|
|
90
|
-
async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, idleLabel } = {}) {
|
|
106
|
+
async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, idleLabel, emittedToolCall } = {}) {
|
|
91
107
|
let idleTimer = null;
|
|
92
108
|
let idleReject = null;
|
|
93
109
|
let idleTimedOut = false;
|
|
110
|
+
// Double-dispatch guard (reviewer High): if a tool call was already emitted
|
|
111
|
+
// this stream, a stall must be unsafe-to-retry so withRetry() won't replay
|
|
112
|
+
// the turn and re-run the side-effecting tool. `emittedToolCall` may be a
|
|
113
|
+
// boolean or a getter evaluated at abort time (state mutates mid-stream).
|
|
114
|
+
const didEmitToolCall = () => {
|
|
115
|
+
try { return typeof emittedToolCall === 'function' ? !!emittedToolCall() : !!emittedToolCall; }
|
|
116
|
+
catch { return false; }
|
|
117
|
+
};
|
|
94
118
|
const armIdle = () => {
|
|
95
119
|
if (!idleEnabled || !(idleMs > 0)) return;
|
|
96
120
|
if (idleTimer) clearTimeout(idleTimer);
|
|
97
121
|
idleTimer = setTimeout(() => {
|
|
98
122
|
idleTimedOut = true;
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
// SEMANTIC idle abort: this timer is (re)armed only around waiting
|
|
124
|
+
// for the NEXT stream event, so keepalive/comment frames the SDK
|
|
125
|
+
// filters out cannot keep it alive. Throw the named terminal
|
|
126
|
+
// StreamStalledError so the retry-classifier treats it as a stream
|
|
127
|
+
// failure (owner gets notified) rather than a user cancel.
|
|
128
|
+
const e = streamStalledError(idleLabel || 'compat SSE', idleMs, { emittedToolCall: didEmitToolCall() });
|
|
101
129
|
if (idleReject) {
|
|
102
130
|
const r = idleReject;
|
|
103
131
|
idleReject = null;
|
|
@@ -143,7 +171,7 @@ async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, id
|
|
|
143
171
|
return result;
|
|
144
172
|
} catch (err) {
|
|
145
173
|
if (idleTimer) clearTimeout(idleTimer);
|
|
146
|
-
if (idleTimedOut) throw
|
|
174
|
+
if (idleTimedOut) throw streamStalledError(idleLabel || 'compat SSE', idleMs, { emittedToolCall: didEmitToolCall() });
|
|
147
175
|
throw err;
|
|
148
176
|
}
|
|
149
177
|
}
|
|
@@ -213,6 +241,15 @@ function emitCompatToolCallOnce(state, call, onToolCall) {
|
|
|
213
241
|
const key = `id:${call.id}`;
|
|
214
242
|
if (!state.emittedToolCallKeys) state.emittedToolCallKeys = new Set();
|
|
215
243
|
if (state.emittedToolCallKeys.has(key)) return false;
|
|
244
|
+
// Fix 2: cross-path name+args dedupe. A synthesized text-leaked call and an
|
|
245
|
+
// identical native tool_call must fire onToolCall exactly once. state._toolDedupe
|
|
246
|
+
// is created per stream; when absent (older callers) behavior is unchanged.
|
|
247
|
+
if (state._toolDedupe && !state._toolDedupe.shouldDispatch(call.name, call.arguments)) {
|
|
248
|
+
// Still mark the id as emitted so later id-frames for the same native
|
|
249
|
+
// call don't retry, but do NOT invoke onToolCall (already dispatched).
|
|
250
|
+
state.emittedToolCallKeys.add(key);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
216
253
|
state.emittedToolCallKeys.add(key);
|
|
217
254
|
state.emittedToolCall = true;
|
|
218
255
|
const { _pendingItemId, ...cleanCall } = call;
|
|
@@ -257,11 +294,14 @@ function isMaxOutputIncompleteReason(reason) {
|
|
|
257
294
|
return /^(?:max_output_tokens|max_tokens|length|output_token_limit)$/i.test(String(reason || '').trim());
|
|
258
295
|
}
|
|
259
296
|
|
|
260
|
-
export async function consumeCompatChatCompletionStream(stream, { signal, label, onStreamDelta, onToolCall, onTextDelta, parseToolCalls } = {}) {
|
|
297
|
+
export async function consumeCompatChatCompletionStream(stream, { signal, label, onStreamDelta, onToolCall, onTextDelta, parseToolCalls, knownToolNames } = {}) {
|
|
261
298
|
const iterator = stream[Symbol.asyncIterator]();
|
|
262
299
|
const firstByteTimeout = createTimeoutSignal(signal, PROVIDER_FIRST_BYTE_TIMEOUT_MS, `${label} first byte`);
|
|
263
300
|
const idleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED;
|
|
264
|
-
|
|
301
|
+
// Per-event (last-event-relative) SEMANTIC idle: nextAsyncWithWatchdog arms
|
|
302
|
+
// the timer only while awaiting the NEXT stream event, so a stream that
|
|
303
|
+
// emits some deltas then goes silent trips it within the window.
|
|
304
|
+
const idleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
|
|
265
305
|
let sawFirstEvent = false;
|
|
266
306
|
let content = '';
|
|
267
307
|
let reasoningContent = '';
|
|
@@ -276,6 +316,46 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
|
|
|
276
316
|
let rawUsage = null;
|
|
277
317
|
const toolAcc = new Map();
|
|
278
318
|
const toolBucketState = { _orderSeq: 0, _nextAnonId: 0, _lastAnonKey: null };
|
|
319
|
+
// Fix 2: one dedupe per stream, shared by the synthetic leaked-call
|
|
320
|
+
// dispatch and every native emit so an identical (name,args) fires once.
|
|
321
|
+
const _toolDedupe = createToolCallDedupe();
|
|
322
|
+
// Leaked tool-call guard: the model sometimes emits a tool call as plain
|
|
323
|
+
// text (XML `<invoke>`/`<function_calls>` or gpt-oss harmony
|
|
324
|
+
// `<|channel|>...to=functions.NAME...<|call|>`) inside `delta.content`
|
|
325
|
+
// instead of a native `tool_calls` delta. Route content through the guard
|
|
326
|
+
// so leaked calls are suppressed from visible text, synthesized, and
|
|
327
|
+
// dispatched like native calls. Additive: the native tool_calls path is
|
|
328
|
+
// untouched. Harmony detection is opt-in here (gpt-oss compat backends).
|
|
329
|
+
const leakGuard = createLeakGuard({ knownToolNames, harmony: true });
|
|
330
|
+
const dispatchLeakedCall = (recovered) => {
|
|
331
|
+
const call = synthLeakedOpenAICall(recovered);
|
|
332
|
+
const emitState = { emittedToolCallKeys: new Set(), _toolDedupe };
|
|
333
|
+
emitCompatToolCallOnce(emitState, call, onToolCall);
|
|
334
|
+
return call;
|
|
335
|
+
};
|
|
336
|
+
const leakedCalls = [];
|
|
337
|
+
const relayText = (delta) => {
|
|
338
|
+
const { text, calls } = leakGuard.push(delta);
|
|
339
|
+
if (text) {
|
|
340
|
+
content += text;
|
|
341
|
+
if (onTextDelta) {
|
|
342
|
+
emittedText = true;
|
|
343
|
+
try { onTextDelta(text); } catch {}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (const c of calls) leakedCalls.push(dispatchLeakedCall(c));
|
|
347
|
+
};
|
|
348
|
+
const flushLeak = () => {
|
|
349
|
+
const { text, calls } = leakGuard.flush();
|
|
350
|
+
if (text) {
|
|
351
|
+
content += text;
|
|
352
|
+
if (onTextDelta) {
|
|
353
|
+
emittedText = true;
|
|
354
|
+
try { onTextDelta(text); } catch {}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const c of calls) leakedCalls.push(dispatchLeakedCall(c));
|
|
358
|
+
};
|
|
279
359
|
try {
|
|
280
360
|
while (true) {
|
|
281
361
|
const { value: chunk, done } = await nextAsyncWithWatchdog(iterator, {
|
|
@@ -285,6 +365,9 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
|
|
|
285
365
|
idleMs,
|
|
286
366
|
idleEnabled: sawFirstEvent && idleEnabled,
|
|
287
367
|
idleLabel: `${label} SSE idle`,
|
|
368
|
+
// A stall after a tool call has already been dispatched (native
|
|
369
|
+
// or recovered-leaked) must be unsafe-to-retry (no double-run).
|
|
370
|
+
emittedToolCall: () => leakedCalls.length > 0 || toolAcc.size > 0,
|
|
288
371
|
});
|
|
289
372
|
if (done) break;
|
|
290
373
|
if (!sawFirstEvent) {
|
|
@@ -296,12 +379,18 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
|
|
|
296
379
|
if (chunk?.model) model = chunk.model;
|
|
297
380
|
const choice = chunk?.choices?.[0];
|
|
298
381
|
if (choice?.delta?.content) {
|
|
299
|
-
|
|
300
|
-
//
|
|
382
|
+
// Live text relay (gateway): explicit assistant text delta,
|
|
383
|
+
// routed through the leaked-tool-call guard (which appends to
|
|
384
|
+
// `content`, forwards visible text, and recovers leaked calls).
|
|
301
385
|
// reasoning_content + tool_calls deltas stay off this path.
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
386
|
+
if (leakGuard.enabled) {
|
|
387
|
+
relayText(choice.delta.content);
|
|
388
|
+
} else {
|
|
389
|
+
content += choice.delta.content;
|
|
390
|
+
if (onTextDelta) {
|
|
391
|
+
emittedText = true;
|
|
392
|
+
try { onTextDelta(choice.delta.content); } catch {}
|
|
393
|
+
}
|
|
305
394
|
}
|
|
306
395
|
}
|
|
307
396
|
if (typeof choice?.delta?.reasoning_content === 'string') {
|
|
@@ -311,9 +400,23 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
|
|
|
311
400
|
if (choice?.finish_reason) stopReason = choice.finish_reason;
|
|
312
401
|
if (chunk?.usage) rawUsage = chunk.usage;
|
|
313
402
|
}
|
|
403
|
+
// Flush any partial-sentinel tail held back mid-stream so legitimate
|
|
404
|
+
// trailing text is never lost.
|
|
405
|
+
if (leakGuard.enabled) flushLeak();
|
|
314
406
|
} catch (err) {
|
|
315
407
|
// Any mid-stream failure after live text was relayed is non-retryable.
|
|
316
408
|
if (emittedText) throw markErrorLiveTextEmitted(err);
|
|
409
|
+
// Partial-final recovery parity: on a mid-stream stall, attach the
|
|
410
|
+
// streamed partial state so the loop can accept a wedged FINAL no-tool
|
|
411
|
+
// summary as partial-final success. pendingToolUse gates out any
|
|
412
|
+
// in-flight/emitted tool call.
|
|
413
|
+
if (err?.streamStalled === true) {
|
|
414
|
+
try {
|
|
415
|
+
err.partialContent = content;
|
|
416
|
+
err.pendingToolUse = toolAcc.size > 0 || leakedCalls.length > 0;
|
|
417
|
+
err.partialModel = model || undefined;
|
|
418
|
+
} catch { /* best-effort */ }
|
|
419
|
+
}
|
|
317
420
|
throw err;
|
|
318
421
|
} finally {
|
|
319
422
|
firstByteTimeout.cleanup();
|
|
@@ -356,9 +459,18 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
|
|
|
356
459
|
throw err;
|
|
357
460
|
}
|
|
358
461
|
if (Array.isArray(toolCalls) && toolCalls.length) {
|
|
359
|
-
const emitState = { emittedToolCallKeys: new Set() };
|
|
462
|
+
const emitState = { emittedToolCallKeys: new Set(), _toolDedupe };
|
|
360
463
|
for (const call of toolCalls) emitCompatToolCallOnce(emitState, call, onToolCall);
|
|
361
464
|
}
|
|
465
|
+
// Fold recovered leaked calls into the returned toolCalls so the dispatch
|
|
466
|
+
// loop treats them exactly like native ones. They were already emitted via
|
|
467
|
+
// onToolCall in relayText/flushLeak, so no re-dispatch here. Dedupe the
|
|
468
|
+
// final array by name+args (Fix 2, array side): a synthetic leaked call and
|
|
469
|
+
// an identical native tool_call must not both remain, else the loop runs
|
|
470
|
+
// the side-effecting tool twice.
|
|
471
|
+
if (leakedCalls.length) {
|
|
472
|
+
toolCalls = dedupeToolCallList([...(Array.isArray(toolCalls) ? toolCalls : []), ...leakedCalls]);
|
|
473
|
+
}
|
|
362
474
|
return {
|
|
363
475
|
response,
|
|
364
476
|
model,
|
|
@@ -370,7 +482,7 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
|
|
|
370
482
|
};
|
|
371
483
|
}
|
|
372
484
|
|
|
373
|
-
function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta }) {
|
|
485
|
+
function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta, relayLeakText }) {
|
|
374
486
|
if (!event || typeof event.type !== 'string') return;
|
|
375
487
|
const pushToolSearchCall = (item) => {
|
|
376
488
|
if (!item || item.type !== 'tool_search_call') return;
|
|
@@ -399,12 +511,17 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
|
|
|
399
511
|
if (event.response?.id) state.responseId = event.response.id;
|
|
400
512
|
break;
|
|
401
513
|
case 'response.output_text.delta':
|
|
402
|
-
state.content += event.delta || '';
|
|
403
514
|
state.sawOutput = true;
|
|
404
515
|
try { onStreamDelta?.(); } catch {}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
516
|
+
// Route assistant text through the leaked-tool-call guard (appends
|
|
517
|
+
// to state.content, forwards visible text, recovers leaked calls).
|
|
518
|
+
if (relayLeakText) relayLeakText(event.delta || '');
|
|
519
|
+
else {
|
|
520
|
+
state.content += event.delta || '';
|
|
521
|
+
if (event.delta && onTextDelta) {
|
|
522
|
+
state.emittedText = true;
|
|
523
|
+
try { onTextDelta(event.delta); } catch {}
|
|
524
|
+
}
|
|
408
525
|
}
|
|
409
526
|
break;
|
|
410
527
|
case 'response.output_item.added':
|
|
@@ -413,13 +530,23 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
|
|
|
413
530
|
name: event.item.name || '',
|
|
414
531
|
callId: event.item.call_id || '',
|
|
415
532
|
});
|
|
533
|
+
state.toolInFlight = true;
|
|
534
|
+
} else if (event.item?.type === 'custom_tool_call') {
|
|
535
|
+
state.toolInFlight = true;
|
|
416
536
|
}
|
|
417
537
|
try { onStreamDelta?.(); } catch {}
|
|
418
538
|
break;
|
|
419
539
|
case 'response.function_call_arguments.delta':
|
|
540
|
+
// A tool call's args are streaming — mark tool work in-flight so a
|
|
541
|
+
// mid-args stall is NEVER accepted as a text-only partial-final.
|
|
542
|
+
state.toolInFlight = true;
|
|
420
543
|
try { onStreamDelta?.(); } catch {}
|
|
421
544
|
break;
|
|
422
545
|
case 'response.custom_tool_call_input.delta':
|
|
546
|
+
// Custom-tool input streams before output_item.done records the call
|
|
547
|
+
// in pendingCalls; flag it so a mid-input stall gates out partial-
|
|
548
|
+
// final success (otherwise a tool-bearing turn looks text-only).
|
|
549
|
+
state.toolInFlight = true;
|
|
423
550
|
try { onStreamDelta?.(); } catch {}
|
|
424
551
|
break;
|
|
425
552
|
case 'response.function_call_arguments.done': {
|
|
@@ -555,11 +682,13 @@ export async function consumeCompatResponsesStream(stream, {
|
|
|
555
682
|
onTextDelta,
|
|
556
683
|
parseResponsesToolCalls,
|
|
557
684
|
responseOutputText,
|
|
685
|
+
knownToolNames,
|
|
558
686
|
} = {}) {
|
|
559
687
|
const iterator = stream[Symbol.asyncIterator]();
|
|
560
688
|
const firstByteTimeout = createTimeoutSignal(signal, PROVIDER_FIRST_BYTE_TIMEOUT_MS, `${label} first byte`);
|
|
561
689
|
const idleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED;
|
|
562
|
-
|
|
690
|
+
// Per-event (last-event-relative) SEMANTIC idle — see the Chat path note.
|
|
691
|
+
const idleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
|
|
563
692
|
const state = {
|
|
564
693
|
content: '',
|
|
565
694
|
model: '',
|
|
@@ -572,13 +701,51 @@ export async function consumeCompatResponsesStream(stream, {
|
|
|
572
701
|
completed: false,
|
|
573
702
|
completedResponse: null,
|
|
574
703
|
sawOutput: false,
|
|
704
|
+
// Fix 2: cross-path name+args dedupe shared by synthetic leaked-call
|
|
705
|
+
// dispatch and every native emit in this Responses stream.
|
|
706
|
+
_toolDedupe: createToolCallDedupe(),
|
|
575
707
|
// Gateway live-text relay invariant: set once a non-empty text chunk
|
|
576
708
|
// has been forwarded. A later failure is non-retryable (rendered text
|
|
577
709
|
// cannot be withdrawn; a retry would concatenate attempts).
|
|
578
710
|
emittedText: false,
|
|
579
711
|
};
|
|
580
712
|
let sawFirstEvent = false;
|
|
581
|
-
|
|
713
|
+
// Leaked tool-call guard for the Responses text stream. Same recovery as
|
|
714
|
+
// the Chat path: leaked XML/harmony tool syntax in `output_text.delta` is
|
|
715
|
+
// suppressed from visible text, synthesized, and dispatched like native.
|
|
716
|
+
const leakGuard = createLeakGuard({ knownToolNames, harmony: true });
|
|
717
|
+
const leakedCalls = [];
|
|
718
|
+
const dispatchLeakedCall = (recovered) => {
|
|
719
|
+
const call = synthLeakedOpenAICall(recovered);
|
|
720
|
+
emitCompatToolCallOnce(state, call, onToolCall);
|
|
721
|
+
leakedCalls.push(call);
|
|
722
|
+
};
|
|
723
|
+
const relayLeakText = leakGuard.enabled
|
|
724
|
+
? (delta) => {
|
|
725
|
+
const { text, calls } = leakGuard.push(delta);
|
|
726
|
+
if (text) {
|
|
727
|
+
state.content += text;
|
|
728
|
+
if (onTextDelta) {
|
|
729
|
+
state.emittedText = true;
|
|
730
|
+
try { onTextDelta(text); } catch {}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
for (const c of calls) dispatchLeakedCall(c);
|
|
734
|
+
}
|
|
735
|
+
: null;
|
|
736
|
+
const flushLeak = () => {
|
|
737
|
+
if (!leakGuard.enabled) return;
|
|
738
|
+
const { text, calls } = leakGuard.flush();
|
|
739
|
+
if (text) {
|
|
740
|
+
state.content += text;
|
|
741
|
+
if (onTextDelta) {
|
|
742
|
+
state.emittedText = true;
|
|
743
|
+
try { onTextDelta(text); } catch {}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
for (const c of calls) dispatchLeakedCall(c);
|
|
747
|
+
};
|
|
748
|
+
const deps = { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta, relayLeakText };
|
|
582
749
|
try {
|
|
583
750
|
while (true) {
|
|
584
751
|
const { value: event, done } = await nextAsyncWithWatchdog(iterator, {
|
|
@@ -586,6 +753,9 @@ export async function consumeCompatResponsesStream(stream, {
|
|
|
586
753
|
idleMs,
|
|
587
754
|
idleEnabled: sawFirstEvent && idleEnabled,
|
|
588
755
|
idleLabel: `${label} SSE idle`,
|
|
756
|
+
// Unsafe-to-retry once any tool call (native or recovered-leaked)
|
|
757
|
+
// has been emitted this stream — avoid a double side-effect.
|
|
758
|
+
emittedToolCall: () => state.emittedToolCall || leakedCalls.length > 0,
|
|
589
759
|
});
|
|
590
760
|
if (done) break;
|
|
591
761
|
if (!sawFirstEvent) {
|
|
@@ -594,7 +764,21 @@ export async function consumeCompatResponsesStream(stream, {
|
|
|
594
764
|
}
|
|
595
765
|
handleCompatResponsesStreamEvent(event, state, deps);
|
|
596
766
|
}
|
|
767
|
+
flushLeak();
|
|
597
768
|
} catch (err) {
|
|
769
|
+
// Partial-final recovery parity: attach streamed partial state so a
|
|
770
|
+
// wedged FINAL no-tool summary can be accepted as partial-final success.
|
|
771
|
+
if (err?.streamStalled === true) {
|
|
772
|
+
try {
|
|
773
|
+
err.partialContent = state.content || '';
|
|
774
|
+
err.pendingToolUse = state.emittedToolCall === true
|
|
775
|
+
|| leakedCalls.length > 0
|
|
776
|
+
|| (state.pendingCalls && state.pendingCalls.size > 0)
|
|
777
|
+
|| (Array.isArray(state.toolCalls) && state.toolCalls.length > 0)
|
|
778
|
+
|| state.toolInFlight === true;
|
|
779
|
+
err.partialModel = state.model || undefined;
|
|
780
|
+
} catch { /* best-effort */ }
|
|
781
|
+
}
|
|
598
782
|
throw markUnsafeRetryIfToolEmitted(err, state);
|
|
599
783
|
} finally {
|
|
600
784
|
firstByteTimeout.cleanup();
|
|
@@ -618,9 +802,14 @@ export async function consumeCompatResponsesStream(stream, {
|
|
|
618
802
|
output_text: state.content,
|
|
619
803
|
output: [],
|
|
620
804
|
};
|
|
621
|
-
|
|
805
|
+
let toolCalls = state.toolCalls.length
|
|
622
806
|
? state.toolCalls.map(({ _pendingItemId, ...t }) => t)
|
|
623
807
|
: parseResponsesToolCalls(response, label);
|
|
808
|
+
// Fold recovered leaked calls in (already emitted via onToolCall above).
|
|
809
|
+
// Dedupe by name+args so an identical native+synthetic pair can't run twice.
|
|
810
|
+
if (leakedCalls.length) {
|
|
811
|
+
toolCalls = dedupeToolCallList([...(Array.isArray(toolCalls) ? toolCalls : []), ...leakedCalls]);
|
|
812
|
+
}
|
|
624
813
|
return {
|
|
625
814
|
response,
|
|
626
815
|
content: state.content || responseOutputText(response),
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
3
|
import { loadConfig } from '../config.mjs';
|
|
4
4
|
import { shouldFallbackTransport, withRetry } from './retry-classifier.mjs';
|
|
5
|
+
import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
|
|
5
6
|
import { sendViaWebSocket } from './openai-oauth-ws.mjs';
|
|
6
7
|
import {
|
|
7
8
|
consumeCompatChatCompletionStream,
|
|
@@ -32,6 +33,17 @@ import {
|
|
|
32
33
|
customToolCallFromResponseItem,
|
|
33
34
|
} from './custom-tool-wire.mjs';
|
|
34
35
|
import { OPENAI_COMPAT_PRESETS } from './openai-compat-presets.mjs';
|
|
36
|
+
|
|
37
|
+
const requireOpenAI = createRequire(import.meta.url);
|
|
38
|
+
let _OpenAI = null;
|
|
39
|
+
|
|
40
|
+
function loadOpenAI() {
|
|
41
|
+
if (!_OpenAI) {
|
|
42
|
+
const mod = requireOpenAI('openai');
|
|
43
|
+
_OpenAI = mod.default || mod.OpenAI || mod;
|
|
44
|
+
}
|
|
45
|
+
return _OpenAI;
|
|
46
|
+
}
|
|
35
47
|
export { OPENAI_COMPAT_PRESETS } from './openai-compat-presets.mjs';
|
|
36
48
|
const PRESETS = OPENAI_COMPAT_PRESETS;
|
|
37
49
|
const MODEL_LIST_TIMEOUT_MS = resolveTimeoutMs(
|
|
@@ -970,6 +982,25 @@ function nativeResponsesTools(opts) {
|
|
|
970
982
|
? opts.nativeTools.filter(t => t && typeof t === 'object')
|
|
971
983
|
: [];
|
|
972
984
|
}
|
|
985
|
+
// Known tool-name sets for the leaked-tool-call guard, derived from the exact
|
|
986
|
+
// request body so a recovered leaked call is only synthesized when it names a
|
|
987
|
+
// tool the model was actually offered. Chat tools nest the name under
|
|
988
|
+
// `function.name`; Responses tools carry a top-level `name`.
|
|
989
|
+
function knownToolNamesFromOpenAITools(tools) {
|
|
990
|
+
return new Set(
|
|
991
|
+
(Array.isArray(tools) ? tools : [])
|
|
992
|
+
.map((t) => (typeof t?.function?.name === 'string' ? t.function.name
|
|
993
|
+
: typeof t?.name === 'string' ? t.name : null))
|
|
994
|
+
.filter(Boolean),
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
function knownToolNamesFromResponsesTools(tools) {
|
|
998
|
+
return new Set(
|
|
999
|
+
(Array.isArray(tools) ? tools : [])
|
|
1000
|
+
.map((t) => (typeof t?.name === 'string' ? t.name : null))
|
|
1001
|
+
.filter(Boolean),
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
973
1004
|
export function parseToolCalls(choice, label) {
|
|
974
1005
|
const calls = choice.message?.tool_calls;
|
|
975
1006
|
if (!calls?.length)
|
|
@@ -1198,10 +1229,20 @@ export class OpenAICompatProvider {
|
|
|
1198
1229
|
// extraHeaders behave exactly as before.
|
|
1199
1230
|
this.defaultHeaders = { ...(preset?.extraHeaders || {}), ...(config.extraHeaders || {}) };
|
|
1200
1231
|
this.defaultModel = preset?.defaultModel || 'default';
|
|
1201
|
-
this.client = new
|
|
1232
|
+
this.client = new (loadOpenAI())({
|
|
1202
1233
|
baseURL,
|
|
1203
1234
|
apiKey,
|
|
1204
1235
|
defaultHeaders: this.defaultHeaders,
|
|
1236
|
+
// The SDK's own retry loop (default 2) would nest underneath our
|
|
1237
|
+
// withRetry wrapper and multiply tail latency on a transient
|
|
1238
|
+
// backend. We own retry/backoff via withRetry, so disable the SDK's.
|
|
1239
|
+
maxRetries: 0,
|
|
1240
|
+
// Force the shared long-keepalive undici dispatcher to be installed
|
|
1241
|
+
// globally (setGlobalDispatcher) so the SDK's global fetch rides a
|
|
1242
|
+
// warm socket pool instead of Node's short-keepalive default. The
|
|
1243
|
+
// return value is undefined once installed globally; the option is
|
|
1244
|
+
// a harmless no-op then.
|
|
1245
|
+
fetchOptions: { dispatcher: getLlmDispatcher() },
|
|
1205
1246
|
});
|
|
1206
1247
|
}
|
|
1207
1248
|
reloadApiKey() {
|
|
@@ -1216,10 +1257,12 @@ export class OpenAICompatProvider {
|
|
|
1216
1257
|
this.baseURL = baseURL;
|
|
1217
1258
|
this.apiKey = newKey;
|
|
1218
1259
|
this.defaultHeaders = { ...(preset?.extraHeaders || {}), ...(this.config.extraHeaders || {}) };
|
|
1219
|
-
this.client = new
|
|
1260
|
+
this.client = new (loadOpenAI())({
|
|
1220
1261
|
baseURL,
|
|
1221
1262
|
apiKey: newKey,
|
|
1222
1263
|
defaultHeaders: this.defaultHeaders,
|
|
1264
|
+
maxRetries: 0,
|
|
1265
|
+
fetchOptions: { dispatcher: getLlmDispatcher() },
|
|
1223
1266
|
});
|
|
1224
1267
|
}
|
|
1225
1268
|
} catch { /* best effort */ }
|
|
@@ -1239,6 +1282,11 @@ export class OpenAICompatProvider {
|
|
|
1239
1282
|
async _doSend(messages, model, tools, sendOpts) {
|
|
1240
1283
|
const useModel = model || this.defaultModel;
|
|
1241
1284
|
const opts = sendOpts || {};
|
|
1285
|
+
// Re-warm a kept-alive socket to the provider origin before the turn so
|
|
1286
|
+
// the request hot path lands on a live socket instead of paying a cold
|
|
1287
|
+
// TLS handshake after an idle gap. Fire-and-forget; never awaited. This
|
|
1288
|
+
// mirrors anthropic-oauth's send()-start preconnect.
|
|
1289
|
+
preconnect(this.baseURL);
|
|
1242
1290
|
if (this.name === 'xai' && useXaiResponsesApi(opts, this.config)) {
|
|
1243
1291
|
if (useXaiResponsesWebSocket(opts, this.config)) {
|
|
1244
1292
|
try {
|
|
@@ -1339,6 +1387,11 @@ export class OpenAICompatProvider {
|
|
|
1339
1387
|
({ signal: openSignal }) => this.client.chat.completions.create(params, { signal: openSignal }),
|
|
1340
1388
|
{
|
|
1341
1389
|
signal: attemptSignal,
|
|
1390
|
+
// Single attempt: this inner wrapper exists only to
|
|
1391
|
+
// apply the first-byte per-attempt timeout. Retry is
|
|
1392
|
+
// owned by the outer withRetry — nesting retry loops
|
|
1393
|
+
// here multiplied tail latency (5x5).
|
|
1394
|
+
maxAttempts: 1,
|
|
1342
1395
|
perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
1343
1396
|
perAttemptLabel: `${this.name} first byte`,
|
|
1344
1397
|
},
|
|
@@ -1351,6 +1404,10 @@ export class OpenAICompatProvider {
|
|
|
1351
1404
|
onToolCall: opts.onToolCall,
|
|
1352
1405
|
onTextDelta: opts.onTextDelta,
|
|
1353
1406
|
parseToolCalls,
|
|
1407
|
+
// Known tool names for the leaked-tool-call guard:
|
|
1408
|
+
// recovered leaked calls only synthesize when they name
|
|
1409
|
+
// a tool actually offered to this request.
|
|
1410
|
+
knownToolNames: knownToolNamesFromOpenAITools(params.tools),
|
|
1354
1411
|
});
|
|
1355
1412
|
},
|
|
1356
1413
|
{
|
|
@@ -1434,6 +1491,13 @@ export class OpenAICompatProvider {
|
|
|
1434
1491
|
model: response.model || useModel,
|
|
1435
1492
|
toolCalls,
|
|
1436
1493
|
stopReason,
|
|
1494
|
+
// P1 audit fix: a text-only completion that hit finish_reason=
|
|
1495
|
+
// 'length' (no tool calls, so not thrown above as
|
|
1496
|
+
// ProviderIncompleteError) previously returned as an ordinary
|
|
1497
|
+
// success with no signal that the content is a mid-sentence
|
|
1498
|
+
// cutoff. Flag it so loop.mjs can surface a one-line warning
|
|
1499
|
+
// instead of silently treating a truncated answer as complete.
|
|
1500
|
+
...(stopReason === 'length' && (assembled.content || '').length > 0 ? { truncated: true } : {}),
|
|
1437
1501
|
...(reasoningContent ? { reasoningContent } : {}),
|
|
1438
1502
|
usage: response.usage ? (() => {
|
|
1439
1503
|
const input = response.usage.prompt_tokens ?? response.usage.input_tokens ?? 0;
|
|
@@ -1516,6 +1580,9 @@ export class OpenAICompatProvider {
|
|
|
1516
1580
|
({ signal: openSignal }) => this.client.responses.create(params, { signal: openSignal }),
|
|
1517
1581
|
{
|
|
1518
1582
|
signal: attemptSignal,
|
|
1583
|
+
// Single attempt: first-byte timeout only; retry
|
|
1584
|
+
// is owned by the outer withRetry (see chat path).
|
|
1585
|
+
maxAttempts: 1,
|
|
1519
1586
|
perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
1520
1587
|
perAttemptLabel: 'xai responses first byte',
|
|
1521
1588
|
},
|
|
@@ -1529,6 +1596,7 @@ export class OpenAICompatProvider {
|
|
|
1529
1596
|
onTextDelta: opts.onTextDelta,
|
|
1530
1597
|
parseResponsesToolCalls,
|
|
1531
1598
|
responseOutputText,
|
|
1599
|
+
knownToolNames: knownToolNamesFromResponsesTools(params.tools),
|
|
1532
1600
|
});
|
|
1533
1601
|
},
|
|
1534
1602
|
{
|
|
@@ -1598,6 +1666,10 @@ export class OpenAICompatProvider {
|
|
|
1598
1666
|
model: response.model || useModel,
|
|
1599
1667
|
toolCalls,
|
|
1600
1668
|
stopReason: streamed.stopReason || null,
|
|
1669
|
+
// P1 audit fix: mirror the chat-completions truncated flag for
|
|
1670
|
+
// the xAI Responses HTTP path — a max-output cutoff with real
|
|
1671
|
+
// content must not look identical to a clean stop.
|
|
1672
|
+
...(streamed.stopReason === 'length' && (streamed.content || '').length > 0 ? { truncated: true } : {}),
|
|
1601
1673
|
citations: searchSources.citations.length ? searchSources.citations : undefined,
|
|
1602
1674
|
webSearchCalls: searchSources.webSearchCalls.length ? searchSources.webSearchCalls : undefined,
|
|
1603
1675
|
providerState: {
|
|
@@ -1753,6 +1825,9 @@ export class OpenAICompatProvider {
|
|
|
1753
1825
|
model: result.model || useModel,
|
|
1754
1826
|
toolCalls: result.toolCalls,
|
|
1755
1827
|
stopReason: result.stopReason || null,
|
|
1828
|
+
// P1 audit fix: same truncated signal as the HTTP path (see
|
|
1829
|
+
// _doSendXaiResponses above) for the WebSocket transport.
|
|
1830
|
+
...(result.stopReason === 'length' && (result.content || '').length > 0 ? { truncated: true } : {}),
|
|
1756
1831
|
providerState: {
|
|
1757
1832
|
...(opts.providerState || {}),
|
|
1758
1833
|
xaiResponses: {
|