@sunerpy/opencode-kiro-auth 0.5.0 → 0.5.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/dist/infrastructure/transformers/tool-call-parser.d.ts +15 -0
- package/dist/infrastructure/transformers/tool-call-parser.js +242 -0
- package/dist/plugin/request.js +26 -7
- package/dist/plugin/streaming/dialect-gate.d.ts +29 -0
- package/dist/plugin/streaming/dialect-gate.js +92 -0
- package/dist/plugin/streaming/sdk-stream-transformer.js +32 -10
- package/dist/plugin/streaming/stream-transformer.js +4 -2
- package/package.json +1 -1
|
@@ -2,3 +2,18 @@ import type { ToolCall } from '../../plugin/types.js';
|
|
|
2
2
|
export declare function parseBracketToolCalls(text: string): ToolCall[];
|
|
3
3
|
export declare function deduplicateToolCalls(toolCalls: ToolCall[]): ToolCall[];
|
|
4
4
|
export declare function cleanToolCallsFromText(text: string, toolCalls: ToolCall[]): string;
|
|
5
|
+
export declare const DSML_MARKER = "<\uFF5CDSML\uFF5Cfunction_calls";
|
|
6
|
+
/**
|
|
7
|
+
* Recognize Anthropic XML, deepseek DSML, and legacy bracket tool-call dialects
|
|
8
|
+
* in `text`. Returns the parsed tool calls plus `cleanedText` with EXACTLY the
|
|
9
|
+
* matched dialect spans removed (all other text preserved verbatim).
|
|
10
|
+
*
|
|
11
|
+
* Phantom-execution guards:
|
|
12
|
+
* - only COMPLETE closed tags match (an unclosed `<invoke name="x">` is text);
|
|
13
|
+
* - candidates inside fenced/inline code are skipped;
|
|
14
|
+
* - a dialect that yields no parseable call is stripped, never fabricated.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseTextToolCalls(text: string): {
|
|
17
|
+
toolCalls: ToolCall[];
|
|
18
|
+
cleanedText: string;
|
|
19
|
+
};
|
|
@@ -43,3 +43,245 @@ export function cleanToolCallsFromText(text, toolCalls) {
|
|
|
43
43
|
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
44
44
|
return cleaned;
|
|
45
45
|
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Text-dialect tool-call recovery (bleed-stop)
|
|
48
|
+
//
|
|
49
|
+
// Some models emit tool calls as literal TEXT dialects instead of the
|
|
50
|
+
// structured toolUseEvent path:
|
|
51
|
+
// (a) Anthropic XML: <function_calls><invoke name="X"><parameter name="k">v</parameter></invoke></function_calls>
|
|
52
|
+
// (and a standalone <invoke name="X">...</invoke>)
|
|
53
|
+
// (b) deepseek DSML: <|DSML|function_calls... (U+FF5C '|', NOT ASCII '|')
|
|
54
|
+
// (c) bracket: [Called X with args:{...}] (legacy, kept)
|
|
55
|
+
//
|
|
56
|
+
// These leak verbatim and stall the turn. `parseTextToolCalls` rescues them
|
|
57
|
+
// into structured ToolCalls and returns the text with EXACTLY the matched
|
|
58
|
+
// spans removed. It is deliberately conservative: only COMPLETE closed tags
|
|
59
|
+
// match, candidates inside fenced/inline code are skipped, and a dialect that
|
|
60
|
+
// cannot be parsed is stripped (never fabricated into a phantom call).
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// deepseek DSML opening marker — the exact U+FF5C ('|') form observed leaking.
|
|
63
|
+
export const DSML_MARKER = '<\uFF5CDSML\uFF5Cfunction_calls';
|
|
64
|
+
function genToolUseId() {
|
|
65
|
+
return `tool_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Compute char ranges that are inside fenced code blocks (``` ... ```) or
|
|
69
|
+
* inline code spans (` ... `). Candidates overlapping any of these are skipped
|
|
70
|
+
* so a model *explaining* or *showing* tool-call syntax is never executed.
|
|
71
|
+
*/
|
|
72
|
+
function computeCodeRanges(text) {
|
|
73
|
+
const ranges = [];
|
|
74
|
+
const fence = /```[\s\S]*?```/g;
|
|
75
|
+
let m;
|
|
76
|
+
while ((m = fence.exec(text)) !== null) {
|
|
77
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
78
|
+
}
|
|
79
|
+
const inFence = (i) => ranges.some(([s, e]) => i >= s && i < e);
|
|
80
|
+
const inline = /`[^`\n]+`/g;
|
|
81
|
+
while ((m = inline.exec(text)) !== null) {
|
|
82
|
+
if (!inFence(m.index))
|
|
83
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
84
|
+
}
|
|
85
|
+
return ranges;
|
|
86
|
+
}
|
|
87
|
+
function overlapsCode(start, end, codeRanges) {
|
|
88
|
+
return codeRanges.some(([s, e]) => start < e && end > s);
|
|
89
|
+
}
|
|
90
|
+
function overlapsClaimed(start, end, claimed) {
|
|
91
|
+
return claimed.some(([s, e]) => start < e && end > s);
|
|
92
|
+
}
|
|
93
|
+
/** Parse `<parameter name="K">V</parameter>` pairs from an invoke body into an input object. */
|
|
94
|
+
function parseInvokeParameters(body) {
|
|
95
|
+
const input = {};
|
|
96
|
+
const paramRe = /<parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/parameter>/g;
|
|
97
|
+
let pm;
|
|
98
|
+
while ((pm = paramRe.exec(body)) !== null) {
|
|
99
|
+
const key = pm[1];
|
|
100
|
+
const rawVal = pm[2];
|
|
101
|
+
if (key === undefined)
|
|
102
|
+
continue;
|
|
103
|
+
const val = rawVal ?? '';
|
|
104
|
+
try {
|
|
105
|
+
input[key] = JSON.parse(val);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
input[key] = val;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return input;
|
|
112
|
+
}
|
|
113
|
+
/** Build a ToolCall from a single complete `<invoke name="...">...</invoke>` block. */
|
|
114
|
+
function toolCallFromInvoke(name, body) {
|
|
115
|
+
return {
|
|
116
|
+
toolUseId: genToolUseId(),
|
|
117
|
+
name,
|
|
118
|
+
input: parseInvokeParameters(body)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Match complete Anthropic XML dialects:
|
|
123
|
+
* - `<function_calls>...(invoke)+...</function_calls>` blocks
|
|
124
|
+
* - standalone `<invoke name="X">...</invoke>` NOT inside a function_calls block
|
|
125
|
+
* Skips candidates inside code and records claimed ranges to avoid double-match.
|
|
126
|
+
*/
|
|
127
|
+
function matchAnthropicXml(text, codeRanges, claimed) {
|
|
128
|
+
const matches = [];
|
|
129
|
+
// (1) complete <function_calls>...</function_calls> blocks
|
|
130
|
+
const blockRe = /<function_calls>[\s\S]*?<\/function_calls>/g;
|
|
131
|
+
let bm;
|
|
132
|
+
while ((bm = blockRe.exec(text)) !== null) {
|
|
133
|
+
const start = bm.index;
|
|
134
|
+
const end = start + bm[0].length;
|
|
135
|
+
if (overlapsCode(start, end, codeRanges))
|
|
136
|
+
continue;
|
|
137
|
+
const invokeRe = /<invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/invoke>/g;
|
|
138
|
+
const toolCalls = [];
|
|
139
|
+
let im;
|
|
140
|
+
while ((im = invokeRe.exec(bm[0])) !== null) {
|
|
141
|
+
const name = im[1];
|
|
142
|
+
if (!name)
|
|
143
|
+
continue;
|
|
144
|
+
toolCalls.push(toolCallFromInvoke(name, im[2] ?? ''));
|
|
145
|
+
}
|
|
146
|
+
// Only treat as a tool-call span if at least one invoke parsed; otherwise
|
|
147
|
+
// it is not a real dialect payload — leave the text untouched.
|
|
148
|
+
if (toolCalls.length === 0)
|
|
149
|
+
continue;
|
|
150
|
+
matches.push({ start, end, toolCalls });
|
|
151
|
+
claimed.push([start, end]);
|
|
152
|
+
}
|
|
153
|
+
// (2) standalone complete <invoke ...>...</invoke> not inside a claimed block
|
|
154
|
+
const invokeRe = /<invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/invoke>/g;
|
|
155
|
+
let sm;
|
|
156
|
+
while ((sm = invokeRe.exec(text)) !== null) {
|
|
157
|
+
const start = sm.index;
|
|
158
|
+
const end = start + sm[0].length;
|
|
159
|
+
const name = sm[1];
|
|
160
|
+
if (!name)
|
|
161
|
+
continue;
|
|
162
|
+
if (overlapsCode(start, end, codeRanges))
|
|
163
|
+
continue;
|
|
164
|
+
if (overlapsClaimed(start, end, claimed))
|
|
165
|
+
continue;
|
|
166
|
+
matches.push({ start, end, toolCalls: [toolCallFromInvoke(name, sm[2] ?? '')] });
|
|
167
|
+
claimed.push([start, end]);
|
|
168
|
+
}
|
|
169
|
+
return matches;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Match the deepseek DSML dialect. The strip span runs from the exact U+FF5C
|
|
173
|
+
* marker to its closing counterpart if one exists, else to end-of-text (the
|
|
174
|
+
* observed trailing leak). Best-effort recovery of name/args; if not cleanly
|
|
175
|
+
* recoverable the span is stripped WITHOUT fabricating a call.
|
|
176
|
+
*/
|
|
177
|
+
function matchDsml(text, codeRanges, claimed) {
|
|
178
|
+
const matches = [];
|
|
179
|
+
let from = 0;
|
|
180
|
+
for (;;) {
|
|
181
|
+
const start = text.indexOf(DSML_MARKER, from);
|
|
182
|
+
if (start === -1)
|
|
183
|
+
break;
|
|
184
|
+
// Closing counterpart: a DSML end token (U+FF5C ... end ... U+FF5C) after
|
|
185
|
+
// the marker, else consume to end-of-text.
|
|
186
|
+
const rest = text.slice(start + DSML_MARKER.length);
|
|
187
|
+
const closeRe = /<\uFF5C[^>]*?end[^>]*?>|<\/\uFF5CDSML[^>]*?>/;
|
|
188
|
+
const cm = closeRe.exec(rest);
|
|
189
|
+
const end = cm !== null ? start + DSML_MARKER.length + cm.index + cm[0].length : text.length;
|
|
190
|
+
from = end;
|
|
191
|
+
if (overlapsCode(start, end, codeRanges))
|
|
192
|
+
continue;
|
|
193
|
+
if (overlapsClaimed(start, end, claimed))
|
|
194
|
+
continue;
|
|
195
|
+
// Best-effort recovery — only fires on a clean name + JSON args pair.
|
|
196
|
+
const span = text.slice(start, end);
|
|
197
|
+
const toolCalls = [];
|
|
198
|
+
const nameM = /name["\uFF5C=:\s]+["']?([A-Za-z0-9_]+)/.exec(span);
|
|
199
|
+
const jsonM = /(\{[\s\S]*\})/.exec(span);
|
|
200
|
+
if (nameM?.[1] && jsonM?.[1]) {
|
|
201
|
+
try {
|
|
202
|
+
toolCalls.push({
|
|
203
|
+
toolUseId: genToolUseId(),
|
|
204
|
+
name: nameM[1],
|
|
205
|
+
input: JSON.parse(jsonM[1])
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// fall through to strip-only
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
matches.push({ start, end, toolCalls });
|
|
213
|
+
claimed.push([start, end]);
|
|
214
|
+
}
|
|
215
|
+
return matches;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Match legacy `[Called X with args:{...}]` spans (for cleanedText) and their
|
|
219
|
+
* tool calls, skipping candidates inside code.
|
|
220
|
+
*/
|
|
221
|
+
function matchBracket(text, codeRanges, claimed) {
|
|
222
|
+
const matches = [];
|
|
223
|
+
const pattern = /\[Called\s+(\w+)\s+with\s+args:\s*(\{[^}]*(?:\{[^}]*\}[^}]*)*\})\]/gs;
|
|
224
|
+
let m;
|
|
225
|
+
while ((m = pattern.exec(text)) !== null) {
|
|
226
|
+
const start = m.index;
|
|
227
|
+
const end = start + m[0].length;
|
|
228
|
+
const name = m[1];
|
|
229
|
+
const argsStr = m[2];
|
|
230
|
+
if (!name || !argsStr)
|
|
231
|
+
continue;
|
|
232
|
+
if (overlapsCode(start, end, codeRanges))
|
|
233
|
+
continue;
|
|
234
|
+
if (overlapsClaimed(start, end, claimed))
|
|
235
|
+
continue;
|
|
236
|
+
let input;
|
|
237
|
+
try {
|
|
238
|
+
input = JSON.parse(argsStr);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
matches.push({
|
|
244
|
+
start,
|
|
245
|
+
end,
|
|
246
|
+
toolCalls: [{ toolUseId: genToolUseId(), name, input }]
|
|
247
|
+
});
|
|
248
|
+
claimed.push([start, end]);
|
|
249
|
+
}
|
|
250
|
+
return matches;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Recognize Anthropic XML, deepseek DSML, and legacy bracket tool-call dialects
|
|
254
|
+
* in `text`. Returns the parsed tool calls plus `cleanedText` with EXACTLY the
|
|
255
|
+
* matched dialect spans removed (all other text preserved verbatim).
|
|
256
|
+
*
|
|
257
|
+
* Phantom-execution guards:
|
|
258
|
+
* - only COMPLETE closed tags match (an unclosed `<invoke name="x">` is text);
|
|
259
|
+
* - candidates inside fenced/inline code are skipped;
|
|
260
|
+
* - a dialect that yields no parseable call is stripped, never fabricated.
|
|
261
|
+
*/
|
|
262
|
+
export function parseTextToolCalls(text) {
|
|
263
|
+
if (!text)
|
|
264
|
+
return { toolCalls: [], cleanedText: text };
|
|
265
|
+
const codeRanges = computeCodeRanges(text);
|
|
266
|
+
const claimed = [];
|
|
267
|
+
const matches = [
|
|
268
|
+
...matchAnthropicXml(text, codeRanges, claimed),
|
|
269
|
+
...matchDsml(text, codeRanges, claimed),
|
|
270
|
+
...matchBracket(text, codeRanges, claimed)
|
|
271
|
+
];
|
|
272
|
+
if (matches.length === 0)
|
|
273
|
+
return { toolCalls: [], cleanedText: text };
|
|
274
|
+
matches.sort((a, b) => a.start - b.start);
|
|
275
|
+
const toolCalls = [];
|
|
276
|
+
let cleanedText = '';
|
|
277
|
+
let cursor = 0;
|
|
278
|
+
for (const mt of matches) {
|
|
279
|
+
if (mt.start < cursor)
|
|
280
|
+
continue; // defensive against any residual overlap
|
|
281
|
+
cleanedText += text.slice(cursor, mt.start);
|
|
282
|
+
cursor = mt.end;
|
|
283
|
+
toolCalls.push(...mt.toolCalls);
|
|
284
|
+
}
|
|
285
|
+
cleanedText += text.slice(cursor);
|
|
286
|
+
return { toolCalls, cleanedText };
|
|
287
|
+
}
|
package/dist/plugin/request.js
CHANGED
|
@@ -7,6 +7,28 @@ import { convertToolsToCodeWhisperer, deduplicateToolResults } from '../infrastr
|
|
|
7
7
|
import { getEffectiveEffort, resolveEffort } from './effort.js';
|
|
8
8
|
import { convertImagesToKiroFormat, extractAllImages, extractTextFromParts } from './image-handler.js';
|
|
9
9
|
import { resolveModelVariant } from './models.js';
|
|
10
|
+
function jsonSchemaTypeOf(value) {
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return 'array';
|
|
13
|
+
if (value === null)
|
|
14
|
+
return 'string';
|
|
15
|
+
const t = typeof value;
|
|
16
|
+
if (t === 'number' || t === 'boolean' || t === 'string' || t === 'object')
|
|
17
|
+
return t;
|
|
18
|
+
return 'string';
|
|
19
|
+
}
|
|
20
|
+
// No `required` fields are inferred: fabricated required keys risk a 400.
|
|
21
|
+
function inferToolSpecFromHistory(name, toolUsesInHistory) {
|
|
22
|
+
const sample = toolUsesInHistory.find((tu) => tu.name === name && tu.input && typeof tu.input === 'object' && !Array.isArray(tu.input));
|
|
23
|
+
const properties = {};
|
|
24
|
+
if (sample && sample.input) {
|
|
25
|
+
for (const [key, val] of Object.entries(sample.input)) {
|
|
26
|
+
properties[key] = { type: jsonSchemaTypeOf(val) };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const json = Object.keys(properties).length > 0 ? { type: 'object', properties } : { type: 'object' };
|
|
30
|
+
return { name, description: `Tool ${name}`, inputSchema: { json } };
|
|
31
|
+
}
|
|
10
32
|
function buildCodeWhispererRequest(body, model, auth, think = false, budget = 20000, showToast) {
|
|
11
33
|
const req = typeof body === 'string' ? JSON.parse(body) : body;
|
|
12
34
|
const { messages, tools, system } = req;
|
|
@@ -228,13 +250,10 @@ function buildCodeWhispererRequest(body, model, auth, think = false, budget = 20
|
|
|
228
250
|
const existingToolNames = new Set(existingTools.map((t) => t.toolSpecification?.name).filter(Boolean));
|
|
229
251
|
const missingToolNames = Array.from(toolNamesInHistory).filter((name) => !existingToolNames.has(name));
|
|
230
252
|
if (missingToolNames.length > 0) {
|
|
231
|
-
const placeholderTools = missingToolNames.map((name) =>
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
inputSchema: { json: { type: 'object', properties: {} } }
|
|
236
|
-
}
|
|
237
|
-
}));
|
|
253
|
+
const placeholderTools = missingToolNames.map((name) => {
|
|
254
|
+
const inferred = inferToolSpecFromHistory(name, toolUsesInHistory);
|
|
255
|
+
return { toolSpecification: inferred };
|
|
256
|
+
});
|
|
238
257
|
if (!uim.userInputMessageContext)
|
|
239
258
|
uim.userInputMessageContext = {};
|
|
240
259
|
uim.userInputMessageContext.tools = [...existingTools, ...placeholderTools];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ToolCall } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Streaming suppression gate for text-dialect tool calls.
|
|
4
|
+
*
|
|
5
|
+
* Visible assistant reply text is pushed through the gate as it streams. While
|
|
6
|
+
* no dialect opening marker has appeared, the gate returns the safe prefix to
|
|
7
|
+
* stream (holding back only a possible partial-marker tail). Once a marker
|
|
8
|
+
* appears, everything from the marker onward is withheld. At finalization,
|
|
9
|
+
* `finalize()` parses the full accumulated text into structured tool calls and
|
|
10
|
+
* returns the remaining non-dialect text (dialect spans removed) that still
|
|
11
|
+
* needs to be emitted.
|
|
12
|
+
*/
|
|
13
|
+
export declare class DialectGate {
|
|
14
|
+
private accumulated;
|
|
15
|
+
private emitted;
|
|
16
|
+
private markerSeen;
|
|
17
|
+
/** Append a visible-text chunk; returns the substring safe to emit now. */
|
|
18
|
+
push(text: string): string;
|
|
19
|
+
/** True once a dialect opening marker has been observed (streaming suppressed). */
|
|
20
|
+
get suppressing(): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Finalize: parse the full accumulated text into structured tool calls and
|
|
23
|
+
* return the non-dialect text that was buffered but not yet emitted.
|
|
24
|
+
*/
|
|
25
|
+
finalize(): {
|
|
26
|
+
toolCalls: ToolCall[];
|
|
27
|
+
remainderText: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { DSML_MARKER, parseTextToolCalls } from '../../infrastructure/transformers/tool-call-parser.js';
|
|
2
|
+
// Opening markers that signal a text-dialect tool call may be starting. Once
|
|
3
|
+
// any of these appears in the accumulated visible text, we stop streaming
|
|
4
|
+
// further visible text (buffer it) so a dialect span is never emitted as
|
|
5
|
+
// visible `delta.content`. Authoritative parsing happens only at finalization
|
|
6
|
+
// on the FULL accumulated text (never per-fragment).
|
|
7
|
+
const OPENING_MARKERS = ['<function_calls', '<invoke name=', DSML_MARKER];
|
|
8
|
+
const MAX_MARKER_LEN = Math.max(...OPENING_MARKERS.map((m) => m.length));
|
|
9
|
+
/** Earliest index of any opening marker in `text`, or -1. */
|
|
10
|
+
function firstMarkerIndex(text) {
|
|
11
|
+
let earliest = -1;
|
|
12
|
+
for (const marker of OPENING_MARKERS) {
|
|
13
|
+
const idx = text.indexOf(marker);
|
|
14
|
+
if (idx !== -1 && (earliest === -1 || idx < earliest))
|
|
15
|
+
earliest = idx;
|
|
16
|
+
}
|
|
17
|
+
return earliest;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Length of the longest suffix of `text` that is a proper prefix of some
|
|
21
|
+
* opening marker — i.e. the tail might be the start of a marker split across
|
|
22
|
+
* chunks. That tail is reserved (not emitted yet) to avoid streaming half a
|
|
23
|
+
* marker as visible text.
|
|
24
|
+
*/
|
|
25
|
+
function partialMarkerTail(text) {
|
|
26
|
+
const maxLook = Math.min(text.length, MAX_MARKER_LEN - 1);
|
|
27
|
+
for (let len = maxLook; len > 0; len--) {
|
|
28
|
+
const tail = text.slice(text.length - len);
|
|
29
|
+
for (const marker of OPENING_MARKERS) {
|
|
30
|
+
if (marker.length > len && marker.startsWith(tail))
|
|
31
|
+
return len;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Streaming suppression gate for text-dialect tool calls.
|
|
38
|
+
*
|
|
39
|
+
* Visible assistant reply text is pushed through the gate as it streams. While
|
|
40
|
+
* no dialect opening marker has appeared, the gate returns the safe prefix to
|
|
41
|
+
* stream (holding back only a possible partial-marker tail). Once a marker
|
|
42
|
+
* appears, everything from the marker onward is withheld. At finalization,
|
|
43
|
+
* `finalize()` parses the full accumulated text into structured tool calls and
|
|
44
|
+
* returns the remaining non-dialect text (dialect spans removed) that still
|
|
45
|
+
* needs to be emitted.
|
|
46
|
+
*/
|
|
47
|
+
export class DialectGate {
|
|
48
|
+
accumulated = '';
|
|
49
|
+
emitted = 0;
|
|
50
|
+
markerSeen = false;
|
|
51
|
+
/** Append a visible-text chunk; returns the substring safe to emit now. */
|
|
52
|
+
push(text) {
|
|
53
|
+
if (!text)
|
|
54
|
+
return '';
|
|
55
|
+
this.accumulated += text;
|
|
56
|
+
if (!this.markerSeen) {
|
|
57
|
+
const markerIdx = firstMarkerIndex(this.accumulated);
|
|
58
|
+
if (markerIdx !== -1)
|
|
59
|
+
this.markerSeen = true;
|
|
60
|
+
}
|
|
61
|
+
let safeEnd;
|
|
62
|
+
if (this.markerSeen) {
|
|
63
|
+
safeEnd = firstMarkerIndex(this.accumulated);
|
|
64
|
+
if (safeEnd === -1)
|
|
65
|
+
safeEnd = this.accumulated.length;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// No marker yet — but the tail could be the start of one; reserve it.
|
|
69
|
+
safeEnd = this.accumulated.length - partialMarkerTail(this.accumulated);
|
|
70
|
+
}
|
|
71
|
+
if (safeEnd <= this.emitted)
|
|
72
|
+
return '';
|
|
73
|
+
const out = this.accumulated.slice(this.emitted, safeEnd);
|
|
74
|
+
this.emitted = safeEnd;
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** True once a dialect opening marker has been observed (streaming suppressed). */
|
|
78
|
+
get suppressing() {
|
|
79
|
+
return this.markerSeen;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Finalize: parse the full accumulated text into structured tool calls and
|
|
83
|
+
* return the non-dialect text that was buffered but not yet emitted.
|
|
84
|
+
*/
|
|
85
|
+
finalize() {
|
|
86
|
+
const { toolCalls, cleanedText } = parseTextToolCalls(this.accumulated);
|
|
87
|
+
// Text before the first marker was already emitted verbatim and is never
|
|
88
|
+
// part of a dialect span, so cleanedText[0..emitted) == what we emitted.
|
|
89
|
+
const remainderText = cleanedText.length > this.emitted ? cleanedText.slice(this.emitted) : '';
|
|
90
|
+
return { toolCalls, remainderText };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { parseBracketToolCalls } from '../../infrastructure/transformers/tool-call-parser.js';
|
|
2
1
|
import { getContextWindowSize } from '../models.js';
|
|
3
2
|
import { estimateTokens } from '../response.js';
|
|
3
|
+
import { DialectGate } from './dialect-gate.js';
|
|
4
4
|
import { convertToOpenAI } from './openai-converter.js';
|
|
5
5
|
import { findRealTag } from './stream-parser.js';
|
|
6
6
|
import { createTextDeltaEvents, createThinkingDeltaEvents, stopBlock } from './stream-state.js';
|
|
@@ -24,6 +24,19 @@ export async function* transformSdkStream(sdkResponse, model, conversationId) {
|
|
|
24
24
|
let contextUsagePercentage = null;
|
|
25
25
|
const toolCalls = [];
|
|
26
26
|
let currentToolCall = null;
|
|
27
|
+
// Text deltas route through the gate so a dialect span is withheld from the
|
|
28
|
+
// visible stream once its opening marker appears (recovered at finalization).
|
|
29
|
+
const dialectGate = new DialectGate();
|
|
30
|
+
const toChunk = (ev) => {
|
|
31
|
+
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
|
|
32
|
+
const safe = dialectGate.push(ev.delta.text ?? '');
|
|
33
|
+
if (!safe)
|
|
34
|
+
return null;
|
|
35
|
+
const gated = { ...ev, delta: { ...ev.delta, text: safe } };
|
|
36
|
+
return convertToOpenAI(gated, conversationId, model);
|
|
37
|
+
}
|
|
38
|
+
return convertToOpenAI(ev, conversationId, model);
|
|
39
|
+
};
|
|
27
40
|
// Probe (opus-4.8): reasoning streams via `reasoningContentEvent{text,signature}` as a
|
|
28
41
|
// contiguous run BEFORE `assistantResponseEvent.content`; no `<thinking>` tags emitted.
|
|
29
42
|
// signature is metadata and ignored for rendering.
|
|
@@ -65,7 +78,7 @@ export async function* transformSdkStream(sdkResponse, model, conversationId) {
|
|
|
65
78
|
}
|
|
66
79
|
if (reasoningStarted) {
|
|
67
80
|
for (const ev of createTextDeltaEvents(text, streamState)) {
|
|
68
|
-
const _c =
|
|
81
|
+
const _c = toChunk(ev);
|
|
69
82
|
if (_c !== null)
|
|
70
83
|
yield _c;
|
|
71
84
|
}
|
|
@@ -74,7 +87,7 @@ export async function* transformSdkStream(sdkResponse, model, conversationId) {
|
|
|
74
87
|
if (!thinkingRequested) {
|
|
75
88
|
for (const ev of createTextDeltaEvents(text, streamState)) {
|
|
76
89
|
{
|
|
77
|
-
const _c =
|
|
90
|
+
const _c = toChunk(ev);
|
|
78
91
|
if (_c !== null)
|
|
79
92
|
yield _c;
|
|
80
93
|
}
|
|
@@ -142,7 +155,7 @@ export async function* transformSdkStream(sdkResponse, model, conversationId) {
|
|
|
142
155
|
}
|
|
143
156
|
}
|
|
144
157
|
for (const ev of deltaEvents) {
|
|
145
|
-
const chunk =
|
|
158
|
+
const chunk = toChunk(ev);
|
|
146
159
|
if (chunk !== null)
|
|
147
160
|
yield chunk;
|
|
148
161
|
}
|
|
@@ -218,21 +231,28 @@ export async function* transformSdkStream(sdkResponse, model, conversationId) {
|
|
|
218
231
|
}
|
|
219
232
|
else {
|
|
220
233
|
for (const ev of createTextDeltaEvents(streamState.buffer, streamState)) {
|
|
221
|
-
const _c =
|
|
234
|
+
const _c = toChunk(ev);
|
|
222
235
|
if (_c !== null)
|
|
223
236
|
yield _c;
|
|
224
237
|
}
|
|
225
238
|
streamState.buffer = '';
|
|
226
239
|
}
|
|
227
240
|
}
|
|
241
|
+
const { toolCalls: dialectToolCalls, remainderText } = dialectGate.finalize();
|
|
242
|
+
if (remainderText) {
|
|
243
|
+
for (const ev of createTextDeltaEvents(remainderText, streamState)) {
|
|
244
|
+
const _c = convertToOpenAI(ev, conversationId, model);
|
|
245
|
+
if (_c !== null)
|
|
246
|
+
yield _c;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
228
249
|
for (const ev of stopBlock(streamState.textBlockIndex, streamState)) {
|
|
229
250
|
const _c = convertToOpenAI(ev, conversationId, model);
|
|
230
251
|
if (_c !== null)
|
|
231
252
|
yield _c;
|
|
232
253
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
for (const btc of bracketToolCalls) {
|
|
254
|
+
if (dialectToolCalls.length > 0) {
|
|
255
|
+
for (const btc of dialectToolCalls) {
|
|
236
256
|
toolCalls.push({
|
|
237
257
|
toolUseId: btc.toolUseId,
|
|
238
258
|
name: btc.name,
|
|
@@ -241,12 +261,14 @@ export async function* transformSdkStream(sdkResponse, model, conversationId) {
|
|
|
241
261
|
}
|
|
242
262
|
}
|
|
243
263
|
if (toolCalls.length > 0) {
|
|
244
|
-
|
|
264
|
+
// OpenAI tool_calls[].index must be the tool call's own 0-based ordinal,
|
|
265
|
+
// NOT the Anthropic content_block global index (offset by reasoning/text
|
|
266
|
+
// blocks) — a global index misaligns the AI-SDK accumulator and drops the call.
|
|
245
267
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
246
268
|
const tc = toolCalls[i];
|
|
247
269
|
if (!tc)
|
|
248
270
|
continue;
|
|
249
|
-
const blockIndex =
|
|
271
|
+
const blockIndex = i;
|
|
250
272
|
{
|
|
251
273
|
const _c = convertToOpenAI({
|
|
252
274
|
type: 'content_block_start',
|
|
@@ -216,12 +216,14 @@ export async function* transformKiroStream(response, model, conversationId) {
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
if (toolCalls.length > 0) {
|
|
219
|
-
|
|
219
|
+
// OpenAI tool_calls[].index must be the tool call's own 0-based ordinal,
|
|
220
|
+
// NOT the Anthropic content_block global index (offset by reasoning/text
|
|
221
|
+
// blocks) — a global index misaligns the AI-SDK accumulator and drops the call.
|
|
220
222
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
221
223
|
const tc = toolCalls[i];
|
|
222
224
|
if (!tc)
|
|
223
225
|
continue;
|
|
224
|
-
const blockIndex =
|
|
226
|
+
const blockIndex = i;
|
|
225
227
|
{
|
|
226
228
|
const _c = convertToOpenAI({
|
|
227
229
|
type: 'content_block_start',
|