@bitkyc08/opencodex 2.28.0 → 2.29.0
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/README.md +9 -1
- package/gui/dist/assets/index-BNESwCzn.js +102 -0
- package/gui/dist/assets/index-CH7ncHCC.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/base.ts +3 -1
- package/src/adapters/cursor/checkpoint-store.ts +303 -0
- package/src/adapters/cursor/discovery.ts +25 -0
- package/src/adapters/cursor/live-transport.ts +19 -2
- package/src/adapters/cursor/native-exec.ts +47 -2
- package/src/adapters/cursor/protobuf-request.ts +80 -23
- package/src/adapters/cursor/request-builder.ts +119 -5
- package/src/adapters/cursor/transport.ts +5 -0
- package/src/adapters/cursor/types.ts +13 -0
- package/src/adapters/cursor.ts +109 -3
- package/src/adapters/google-antigravity-replay.ts +31 -4
- package/src/adapters/google.ts +323 -38
- package/src/adapters/openai-chat.ts +19 -0
- package/src/adapters/openai-responses.ts +218 -37
- package/src/bridge.ts +38 -20
- package/src/claude/desktop-3p.ts +15 -6
- package/src/cli/agent.ts +44 -1
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/dispatch.ts +24 -1
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +40 -38
- package/src/cli/integrations.ts +1 -1
- package/src/cli/registry.ts +2 -2
- package/src/clients/config-export.ts +119 -20
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/inject.ts +46 -14
- package/src/codex/journal.ts +22 -8
- package/src/config.ts +1 -0
- package/src/generated/compatibility-version.json +134 -66
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/registry.ts +25 -2
- package/src/integrations/writer.ts +32 -1
- package/src/lab/public/signature.ts +25 -1
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/redact.ts +2 -2
- package/src/oauth/log.ts +3 -1
- package/src/providers/derive.ts +9 -0
- package/src/providers/fastwire.ts +24 -18
- package/src/providers/openai-tiers.ts +60 -1
- package/src/providers/registry.ts +14 -7
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/responses/compaction.ts +18 -0
- package/src/responses/custom-tool-compat.ts +70 -5
- package/src/responses/namespace-tool-compat.ts +356 -0
- package/src/responses/parser.ts +2 -2
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/reasoning-replay-cache.ts +125 -7
- package/src/responses/spill-store.ts +6 -1
- package/src/responses/state.ts +11 -0
- package/src/router.ts +14 -0
- package/src/routing/compatibility/behavior.ts +1 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/management/agent-settings-routes.ts +57 -9
- package/src/server/management/config-routes.ts +134 -15
- package/src/server/management/integration-routes.ts +8 -55
- package/src/server/management/model-routes.ts +23 -1
- package/src/server/management/provider-routes.ts +22 -0
- package/src/server/management/vision-sidecar-options.ts +18 -11
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/responses/core.ts +588 -78
- package/src/server/responses/responses-field-backfill.ts +7 -11
- package/src/server/responses/terminal-guard.ts +22 -11
- package/src/server/responses-custom-tool-repair.ts +3 -1
- package/src/server/responses-reasoning-summary-rewrite.ts +7 -0
- package/src/server/responses-tool-search-repair.ts +64 -14
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/types/config.ts +26 -5
- package/src/types/provider.ts +18 -0
- package/src/types/request.ts +26 -0
- package/src/types.ts +1 -0
- package/src/usage/log.ts +2 -0
- package/src/vision/index.ts +8 -9
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +88 -0
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +139 -12
- package/src/web-search/loop.ts +45 -5
- package/src/web-search/parse.ts +34 -23
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/xai-executor.ts +219 -0
- package/gui/dist/assets/index-D2sP-biU.js +0 -102
- package/gui/dist/assets/index-DQsMZzI5.css +0 -1
package/src/adapters/google.ts
CHANGED
|
@@ -126,6 +126,7 @@ function toolResultImageParts(content: string | OcxContentPart[]): unknown[] {
|
|
|
126
126
|
*/
|
|
127
127
|
const GEMINI_EMPTY_PLACEHOLDER = "(empty)";
|
|
128
128
|
const GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER = "(empty tool output)";
|
|
129
|
+
const GEMINI_MISSING_TOOL_RESULT = "[missing tool_result for this tool_use in history]";
|
|
129
130
|
|
|
130
131
|
/** A Gemini text part, or undefined when the value cannot form a valid non-empty text block. */
|
|
131
132
|
function geminiTextPart(text: unknown): { text: string } | undefined {
|
|
@@ -144,6 +145,42 @@ function geminiToolResultText(content: string | OcxContentPart[]): string {
|
|
|
144
145
|
return hasContent ? contentPartsToText(content) : GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER;
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
function geminiToolResultParts(
|
|
149
|
+
msg: OcxToolResultMessage,
|
|
150
|
+
wireName: string,
|
|
151
|
+
wireCallId: string,
|
|
152
|
+
): unknown[] {
|
|
153
|
+
const functionResponse: Record<string, unknown> = {
|
|
154
|
+
name: wireName,
|
|
155
|
+
response: { result: geminiToolResultText(msg.content) },
|
|
156
|
+
id: wireCallId,
|
|
157
|
+
};
|
|
158
|
+
return [{ functionResponse }, ...toolResultImageParts(msg.content)];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function geminiMissingToolResultPart(wireName: string, wireCallId: string): unknown {
|
|
162
|
+
return {
|
|
163
|
+
functionResponse: {
|
|
164
|
+
name: wireName,
|
|
165
|
+
response: { result: GEMINI_MISSING_TOOL_RESULT },
|
|
166
|
+
id: wireCallId,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function geminiUnrepresentableToolCallPart(tc: OcxToolCall, wireName: string): unknown {
|
|
172
|
+
const args = typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments);
|
|
173
|
+
return { text: `[tool_use without a usable id: ${wireName}]\n${args}` };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function geminiOrphanToolResultParts(msg: OcxToolResultMessage): unknown[] {
|
|
177
|
+
const label = msg.toolName ? `${msg.toolName} (${msg.toolCallId})` : msg.toolCallId;
|
|
178
|
+
return [
|
|
179
|
+
{ text: `[tool_result without adjacent tool_use: ${label}]\n${geminiToolResultText(msg.content)}` },
|
|
180
|
+
...toolResultImageParts(msg.content),
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
147
184
|
function messagesToGeminiFormat(
|
|
148
185
|
parsed: OcxParsedRequest,
|
|
149
186
|
identityModelId: string,
|
|
@@ -170,7 +207,8 @@ function messagesToGeminiFormat(
|
|
|
170
207
|
callIds.reserve((msg as OcxToolResultMessage).toolCallId);
|
|
171
208
|
}
|
|
172
209
|
}
|
|
173
|
-
for (
|
|
210
|
+
for (let i = 0; i < parsed.context.messages.length; i++) {
|
|
211
|
+
const msg = parsed.context.messages[i];
|
|
174
212
|
switch (msg.role) {
|
|
175
213
|
case "user":
|
|
176
214
|
case "developer": {
|
|
@@ -197,6 +235,7 @@ function messagesToGeminiFormat(
|
|
|
197
235
|
case "assistant": {
|
|
198
236
|
const aMsg = msg as OcxAssistantMessage;
|
|
199
237
|
const parts: unknown[] = [];
|
|
238
|
+
const toolCalls: Array<{ wireCallId: string; wireName: string }> = [];
|
|
200
239
|
for (const p of aMsg.content) {
|
|
201
240
|
if (p.type === "text") {
|
|
202
241
|
const textPart = geminiTextPart((p as OcxTextContent).text);
|
|
@@ -209,10 +248,19 @@ function messagesToGeminiFormat(
|
|
|
209
248
|
// Responses parser also stashes synthetic item ids (`fc_...`) on this field, and sending
|
|
210
249
|
// those as a thoughtSignature breaks continuity (the replay cache supplies the real one).
|
|
211
250
|
const callId = callIds.allocate(tc.id);
|
|
212
|
-
const
|
|
251
|
+
const wireName = namespacedToolName(tc.namespace, tc.name);
|
|
252
|
+
if (callId === undefined) {
|
|
253
|
+
// Claude-on-Antigravity requires a usable id for every translated tool_use. An empty
|
|
254
|
+
// source id cannot be paired safely, so preserve the call as text and let its result
|
|
255
|
+
// follow the same orphan-text path instead of emitting an invalid functionCall.
|
|
256
|
+
parts.push(geminiUnrepresentableToolCallPart(tc, wireName));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const functionCall: Record<string, unknown> = { name: wireName, args: tc.arguments };
|
|
213
260
|
// Claude-on-Antigravity maps this id to Anthropic `tool_use.id`; without it the upstream
|
|
214
261
|
// conversion 400s. Gemini accepts the optional id and pairs call/response by it.
|
|
215
|
-
|
|
262
|
+
functionCall.id = callId;
|
|
263
|
+
toolCalls.push({ wireCallId: callId, wireName });
|
|
216
264
|
const part: Record<string, unknown> = { functionCall };
|
|
217
265
|
// Prefer the metadata that travelled with this exact call; fall back to the legacy
|
|
218
266
|
// field for callers that have not been migrated. Never merge or synthesize.
|
|
@@ -232,22 +280,44 @@ function messagesToGeminiFormat(
|
|
|
232
280
|
// adapter does for its own empty assistant content.
|
|
233
281
|
if (parts.length === 0) break;
|
|
234
282
|
contents.push({ role: "model", parts });
|
|
283
|
+
if (toolCalls.length > 0) {
|
|
284
|
+
// Gemini/Claude-on-Antigravity requires one adjacent response batch for the whole
|
|
285
|
+
// function-call turn. Replayed histories can be interrupted, reversed, duplicated, or
|
|
286
|
+
// contain an orphan result; repair only this wire boundary without inventing success.
|
|
287
|
+
const requiredIds = new Set(toolCalls.map(call => call.wireCallId));
|
|
288
|
+
const resultsById = new Map<string, OcxToolResultMessage>();
|
|
289
|
+
const orphanResults: OcxToolResultMessage[] = [];
|
|
290
|
+
let j = i + 1;
|
|
291
|
+
while (j < parsed.context.messages.length && parsed.context.messages[j].role === "toolResult") {
|
|
292
|
+
const result = parsed.context.messages[j] as OcxToolResultMessage;
|
|
293
|
+
const wireResultId = callIds.lookup(result.toolCallId);
|
|
294
|
+
if (wireResultId !== undefined && requiredIds.has(wireResultId) && !resultsById.has(wireResultId)) {
|
|
295
|
+
resultsById.set(wireResultId, result);
|
|
296
|
+
} else {
|
|
297
|
+
orphanResults.push(result);
|
|
298
|
+
}
|
|
299
|
+
j++;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const responseParts: unknown[] = [];
|
|
303
|
+
for (const call of toolCalls) {
|
|
304
|
+
const result = resultsById.get(call.wireCallId);
|
|
305
|
+
if (result) responseParts.push(...geminiToolResultParts(result, call.wireName, call.wireCallId));
|
|
306
|
+
else responseParts.push(geminiMissingToolResultPart(call.wireName, call.wireCallId));
|
|
307
|
+
}
|
|
308
|
+
for (const orphan of orphanResults) {
|
|
309
|
+
responseParts.push(...geminiOrphanToolResultParts(orphan));
|
|
310
|
+
}
|
|
311
|
+
contents.push({ role: "user", parts: responseParts });
|
|
312
|
+
i = j - 1;
|
|
313
|
+
}
|
|
235
314
|
break;
|
|
236
315
|
}
|
|
237
316
|
case "toolResult": {
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
// lookup(), not allocate(): a response must reuse its call's id and must never mint a new one.
|
|
243
|
-
const responseId = callIds.lookup(msg.toolCallId);
|
|
244
|
-
const functionResponse: Record<string, unknown> = { name: namespacedToolName(msg.toolNamespace, msg.toolName), response: { result: geminiToolResultText(msg.content) } };
|
|
245
|
-
// Mirror the matching functionCall id so Claude-on-Antigravity can pair this result with its
|
|
246
|
-
// `tool_use` block (-> Anthropic `tool_result.tool_use_id`).
|
|
247
|
-
if (responseId !== undefined) functionResponse.id = responseId;
|
|
248
|
-
const parts: unknown[] = [{ functionResponse }];
|
|
249
|
-
for (const part of toolResultImageParts(msg.content)) parts.push(part);
|
|
250
|
-
contents.push({ role: "user", parts });
|
|
317
|
+
// A standalone functionResponse is invalid without an immediately preceding matching
|
|
318
|
+
// functionCall batch. Preserve the result as explicit user text (plus any representable
|
|
319
|
+
// image siblings) rather than manufacturing a successful call or sending a 400-prone shape.
|
|
320
|
+
contents.push({ role: "user", parts: geminiOrphanToolResultParts(msg as OcxToolResultMessage) });
|
|
251
321
|
break;
|
|
252
322
|
}
|
|
253
323
|
}
|
|
@@ -339,11 +409,16 @@ function artifactMarkdownUrl(filePath: string): string {
|
|
|
339
409
|
}
|
|
340
410
|
|
|
341
411
|
interface GoogleResponsePart {
|
|
342
|
-
text?:
|
|
412
|
+
text?: unknown;
|
|
343
413
|
thought?: boolean;
|
|
344
414
|
thoughtSignature?: string;
|
|
345
415
|
thought_signature?: string;
|
|
346
|
-
functionCall?:
|
|
416
|
+
functionCall?: unknown;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
interface GoogleFunctionCall {
|
|
420
|
+
name: string;
|
|
421
|
+
args?: unknown;
|
|
347
422
|
}
|
|
348
423
|
|
|
349
424
|
/**
|
|
@@ -366,12 +441,154 @@ function googleToolCallMetadataFromPart(
|
|
|
366
441
|
* cannot accidentally expose the same hidden reasoning through different event types.
|
|
367
442
|
*/
|
|
368
443
|
function googlePartTextEvent(part: GoogleResponsePart): AdapterEvent | undefined {
|
|
369
|
-
|
|
444
|
+
// A malformed scalar/object is not text and must not cross the AdapterEvent boundary. Dropping
|
|
445
|
+
// only this optional field preserves the rest of the part without inventing assistant output by
|
|
446
|
+
// coercion; an empty string keeps its existing no-event behavior.
|
|
447
|
+
if (typeof part.text !== "string" || part.text.length === 0) return undefined;
|
|
370
448
|
return part.thought === true
|
|
371
449
|
? { type: "reasoning_raw_delta", text: part.text }
|
|
372
450
|
: { type: "text_delta", text: part.text };
|
|
373
451
|
}
|
|
374
452
|
|
|
453
|
+
interface InvalidGoogleFunctionCallDiagnostic {
|
|
454
|
+
reason:
|
|
455
|
+
| "function_call_not_object"
|
|
456
|
+
| "function_call_name_invalid"
|
|
457
|
+
| "function_call_name_blank";
|
|
458
|
+
partIndex: number;
|
|
459
|
+
valueType: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
interface InvalidGoogleShapeDiagnostic {
|
|
463
|
+
reason:
|
|
464
|
+
| "candidates_not_array"
|
|
465
|
+
| "candidate_not_object"
|
|
466
|
+
| "content_not_object"
|
|
467
|
+
| "parts_not_array"
|
|
468
|
+
| "part_not_object";
|
|
469
|
+
partIndex?: number;
|
|
470
|
+
valueType: string;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function isGoogleRecord(value: unknown): value is Record<string, unknown> {
|
|
474
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function googleStructuralValueType(value: unknown): string {
|
|
478
|
+
if (value === null) return "null";
|
|
479
|
+
return Array.isArray(value) ? "array" : typeof value;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Gemini delivers one complete functionCall per part, so a missing name cannot be repaired by a
|
|
484
|
+
* later delta. Validate the whole parts array before observing signatures or emitting content: a
|
|
485
|
+
* malformed call must terminate the claimed response rather than enter replay state or reach the
|
|
486
|
+
* bridge as a nameless dispatch. Null remains an absence encoding, matching other optional fields.
|
|
487
|
+
*/
|
|
488
|
+
function diagnoseGoogleFunctionCalls(
|
|
489
|
+
parts: readonly GoogleResponsePart[],
|
|
490
|
+
): InvalidGoogleFunctionCallDiagnostic | undefined {
|
|
491
|
+
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
|
|
492
|
+
const functionCall = parts[partIndex]?.functionCall;
|
|
493
|
+
if (functionCall === undefined || functionCall === null) continue;
|
|
494
|
+
if (!isGoogleRecord(functionCall)) {
|
|
495
|
+
return {
|
|
496
|
+
reason: "function_call_not_object",
|
|
497
|
+
partIndex,
|
|
498
|
+
valueType: googleStructuralValueType(functionCall),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (typeof functionCall.name !== "string") {
|
|
502
|
+
return {
|
|
503
|
+
reason: "function_call_name_invalid",
|
|
504
|
+
partIndex,
|
|
505
|
+
valueType: googleStructuralValueType(functionCall.name),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
if (functionCall.name.trim().length === 0) {
|
|
509
|
+
return {
|
|
510
|
+
reason: "function_call_name_blank",
|
|
511
|
+
partIndex,
|
|
512
|
+
valueType: "string",
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return undefined;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function googleFunctionCall(part: GoogleResponsePart): GoogleFunctionCall | undefined {
|
|
520
|
+
const functionCall = part.functionCall;
|
|
521
|
+
if (!isGoogleRecord(functionCall) || typeof functionCall.name !== "string") return undefined;
|
|
522
|
+
return { name: functionCall.name, args: functionCall.args };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function invalidGoogleFunctionCallEvent(
|
|
526
|
+
diagnostic: InvalidGoogleFunctionCallDiagnostic,
|
|
527
|
+
): Extract<AdapterEvent, { type: "error" }> {
|
|
528
|
+
const subject = diagnostic.reason === "function_call_not_object"
|
|
529
|
+
? "invalid function call"
|
|
530
|
+
: diagnostic.reason === "function_call_name_blank"
|
|
531
|
+
? "blank function call name"
|
|
532
|
+
: "invalid function call name";
|
|
533
|
+
return {
|
|
534
|
+
type: "error",
|
|
535
|
+
message: `google response contained ${subject} (${diagnostic.reason}; partIndex=${diagnostic.partIndex}; valueType=${diagnostic.valueType}) — cannot dispatch`,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* A candidate's `content` is claimed model output inside a well-formed frame, so it is governed by
|
|
541
|
+
* the #1332 nested-shape rule (fail closed) rather than #1240's root-frame padding rule (skip).
|
|
542
|
+
*
|
|
543
|
+
* Absence stays legal, and so does one encoding of it: an empty array is how a JSON writer with no
|
|
544
|
+
* distinct empty-object form spells an empty `content`, and it already behaves as "no parts". A
|
|
545
|
+
* NON-empty array is the opposite case — `content?.parts` silently reads `undefined` from it, so a
|
|
546
|
+
* candidate shaped `content: [{ parts: [...] }]` dropped its own text and completed as an empty
|
|
547
|
+
* turn.
|
|
548
|
+
*/
|
|
549
|
+
function diagnoseGoogleContent(content: unknown): InvalidGoogleShapeDiagnostic | undefined {
|
|
550
|
+
if (content === undefined || content === null || isGoogleRecord(content)) return undefined;
|
|
551
|
+
if (Array.isArray(content) && content.length === 0) return undefined;
|
|
552
|
+
return { reason: "content_not_object", valueType: googleStructuralValueType(content) };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* `content.parts` sits one rung below the candidate guard added in #1332, and both parsers
|
|
557
|
+
* consumed it unchecked: `for (const part of {})` throws `{} is not iterable`, and a `[null]`
|
|
558
|
+
* element throws on `part.thoughtSignature`. A `parts` that is absent or `null` keeps its existing
|
|
559
|
+
* skip — only a present, non-null container is validated.
|
|
560
|
+
*/
|
|
561
|
+
function diagnoseGoogleParts(parts: unknown): InvalidGoogleShapeDiagnostic | undefined {
|
|
562
|
+
if (!Array.isArray(parts)) {
|
|
563
|
+
return { reason: "parts_not_array", valueType: googleStructuralValueType(parts) };
|
|
564
|
+
}
|
|
565
|
+
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
|
|
566
|
+
const part: unknown = parts[partIndex];
|
|
567
|
+
if (!isGoogleRecord(part)) {
|
|
568
|
+
return { reason: "part_not_object", partIndex, valueType: googleStructuralValueType(part) };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function invalidGoogleShapeEvent(
|
|
575
|
+
diagnostic: InvalidGoogleShapeDiagnostic,
|
|
576
|
+
): Extract<AdapterEvent, { type: "error" }> {
|
|
577
|
+
const at = diagnostic.partIndex !== undefined ? `; partIndex=${diagnostic.partIndex}` : "";
|
|
578
|
+
// The subject names the rung that failed, so an operator reading a log can tell a broken
|
|
579
|
+
// candidate list from a well-formed candidate whose parts are broken. The candidate subject keeps
|
|
580
|
+
// the exact wording #1332 introduced as its prefix, so an existing grep still matches.
|
|
581
|
+
const subject = diagnostic.reason === "candidates_not_array" || diagnostic.reason === "candidate_not_object"
|
|
582
|
+
? "candidates"
|
|
583
|
+
: diagnostic.reason === "content_not_object"
|
|
584
|
+
? "content"
|
|
585
|
+
: "content parts";
|
|
586
|
+
return {
|
|
587
|
+
type: "error",
|
|
588
|
+
message: `google response contained invalid ${subject} (${diagnostic.reason}${at}; valueType=${diagnostic.valueType})`,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
375
592
|
export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter {
|
|
376
593
|
// Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest
|
|
377
594
|
// can stash the CCA model/session for parseStream's reasoning-replay observation.
|
|
@@ -669,22 +886,34 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
669
886
|
sawTerminalSignal = true;
|
|
670
887
|
}
|
|
671
888
|
const rawCandidates = root.candidates;
|
|
672
|
-
|
|
889
|
+
// `null` is an absence encoding, not corruption, and terminating on it is the #1219
|
|
890
|
+
// failure mode one rung in: a `{"candidates":null}` frame arriving between a content
|
|
891
|
+
// delta and the finish chunk killed a turn whose answer had already fully arrived. An
|
|
892
|
+
// absent key and an empty array are already skipped here; `null` joins them. A non-null
|
|
893
|
+
// non-array container is still claimed structure the parser cannot read, and stays
|
|
894
|
+
// terminal.
|
|
895
|
+
if (rawCandidates === undefined || rawCandidates === null) return "continue";
|
|
673
896
|
if (!Array.isArray(rawCandidates)) {
|
|
674
|
-
yield {
|
|
897
|
+
yield invalidGoogleShapeEvent({
|
|
898
|
+
reason: "candidates_not_array",
|
|
899
|
+
valueType: googleStructuralValueType(rawCandidates),
|
|
900
|
+
});
|
|
675
901
|
return "terminate";
|
|
676
902
|
}
|
|
677
903
|
if (rawCandidates.length === 0) return "continue";
|
|
678
904
|
const rawCandidate = rawCandidates[0];
|
|
679
|
-
if (
|
|
905
|
+
if (!isGoogleRecord(rawCandidate)) {
|
|
680
906
|
// Unlike a root `data: null` keepalive, this is a claimed response candidate. Treat it
|
|
681
907
|
// as terminal protocol corruption so the turn cannot complete after silently losing
|
|
682
908
|
// a candidate or tool call (#1325).
|
|
683
|
-
yield {
|
|
909
|
+
yield invalidGoogleShapeEvent({
|
|
910
|
+
reason: "candidate_not_object",
|
|
911
|
+
valueType: googleStructuralValueType(rawCandidate),
|
|
912
|
+
});
|
|
684
913
|
return "terminate";
|
|
685
914
|
}
|
|
686
915
|
const candidate = rawCandidate as {
|
|
687
|
-
content?:
|
|
916
|
+
content?: unknown;
|
|
688
917
|
finishReason?: string;
|
|
689
918
|
};
|
|
690
919
|
|
|
@@ -693,7 +922,29 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
693
922
|
sawTerminalSignal = true;
|
|
694
923
|
}
|
|
695
924
|
|
|
696
|
-
|
|
925
|
+
// One rung below the candidate guard above, same rule: this is claimed content, not
|
|
926
|
+
// padding, so it fails closed rather than being iterated or silently dropped (#1325).
|
|
927
|
+
const rawContent: unknown = candidate.content;
|
|
928
|
+
const invalidContent = diagnoseGoogleContent(rawContent);
|
|
929
|
+
if (invalidContent) {
|
|
930
|
+
yield invalidGoogleShapeEvent(invalidContent);
|
|
931
|
+
return "terminate";
|
|
932
|
+
}
|
|
933
|
+
const rawParts: unknown = isGoogleRecord(rawContent) ? rawContent.parts : undefined;
|
|
934
|
+
let parts: GoogleResponsePart[] | undefined;
|
|
935
|
+
if (rawParts !== undefined && rawParts !== null) {
|
|
936
|
+
const invalidParts = diagnoseGoogleParts(rawParts);
|
|
937
|
+
if (invalidParts) {
|
|
938
|
+
yield invalidGoogleShapeEvent(invalidParts);
|
|
939
|
+
return "terminate";
|
|
940
|
+
}
|
|
941
|
+
parts = rawParts as GoogleResponsePart[];
|
|
942
|
+
const invalidFunctionCall = diagnoseGoogleFunctionCalls(parts);
|
|
943
|
+
if (invalidFunctionCall) {
|
|
944
|
+
yield invalidGoogleFunctionCallEvent(invalidFunctionCall);
|
|
945
|
+
return "terminate";
|
|
946
|
+
}
|
|
947
|
+
}
|
|
697
948
|
// Record Gemini thought signatures for the next stateless tool-result turn. Vertex and
|
|
698
949
|
// Antigravity use separate model namespaces so opaque provider state cannot cross routes.
|
|
699
950
|
const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
|
|
@@ -733,17 +984,19 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
733
984
|
}
|
|
734
985
|
}
|
|
735
986
|
}
|
|
736
|
-
|
|
987
|
+
const functionCall = googleFunctionCall(part);
|
|
988
|
+
if (functionCall) {
|
|
737
989
|
const id = `call_${crypto.randomUUID().slice(0, 8)}`;
|
|
738
990
|
toolCallsStarted++;
|
|
739
991
|
emittedContentEvent = true;
|
|
992
|
+
const restoredName = restoreGoogleToolName(functionCall.name);
|
|
740
993
|
yield {
|
|
741
994
|
type: "tool_call_start",
|
|
742
995
|
id,
|
|
743
|
-
name:
|
|
996
|
+
name: restoredName,
|
|
744
997
|
...googleToolCallMetadataFromPart(part, pendingStreamThoughtSig),
|
|
745
998
|
};
|
|
746
|
-
yield { type: "tool_call_delta", arguments: JSON.stringify(
|
|
999
|
+
yield { type: "tool_call_delta", arguments: JSON.stringify(functionCall.args ?? {}) };
|
|
747
1000
|
yield { type: "tool_call_end" };
|
|
748
1001
|
}
|
|
749
1002
|
}
|
|
@@ -923,23 +1176,54 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
923
1176
|
}
|
|
924
1177
|
const events: AdapterEvent[] = [];
|
|
925
1178
|
|
|
926
|
-
const
|
|
1179
|
+
const rawCandidates: unknown = json.candidates;
|
|
1180
|
+
// Parity with the streaming path, which has rejected a non-array `candidates` since #1332.
|
|
1181
|
+
// Buffered accepted `"abc"` outright (`"abc".length` is 3, so the emptiness check below
|
|
1182
|
+
// passed and `candidates[0]` was the character `"a"`), and reported `{}`/`5` as an absent
|
|
1183
|
+
// candidate list rather than a malformed one.
|
|
1184
|
+
if (rawCandidates !== undefined && rawCandidates !== null && !Array.isArray(rawCandidates)) {
|
|
1185
|
+
return finish([invalidGoogleShapeEvent({
|
|
1186
|
+
reason: "candidates_not_array",
|
|
1187
|
+
valueType: googleStructuralValueType(rawCandidates),
|
|
1188
|
+
})]);
|
|
1189
|
+
}
|
|
1190
|
+
const candidates = rawCandidates as { finishReason?: string }[] | undefined;
|
|
927
1191
|
if (!candidates?.length) {
|
|
928
1192
|
return finish([{ type: "error", message: "google response contained no candidates" }]);
|
|
929
1193
|
}
|
|
1194
|
+
const rawCandidate: unknown = candidates[0];
|
|
1195
|
+
if (!isGoogleRecord(rawCandidate)) {
|
|
1196
|
+
// The streaming parser already treats this as terminal protocol corruption (#1325/#1332).
|
|
1197
|
+
// Buffered returned a bare `done`, so a claimed-but-malformed candidate was reported to
|
|
1198
|
+
// the caller as a successful empty turn.
|
|
1199
|
+
return finish([invalidGoogleShapeEvent({
|
|
1200
|
+
reason: "candidate_not_object",
|
|
1201
|
+
valueType: googleStructuralValueType(rawCandidate),
|
|
1202
|
+
})]);
|
|
1203
|
+
}
|
|
1204
|
+
const candidate = rawCandidate as { content?: unknown; finishReason?: string };
|
|
930
1205
|
let toolCallsStarted = 0;
|
|
931
1206
|
const imageBudget = createImageBudget();
|
|
932
|
-
|
|
1207
|
+
const rawContent: unknown = candidate.content;
|
|
1208
|
+
const invalidContent = diagnoseGoogleContent(rawContent);
|
|
1209
|
+
if (invalidContent) return finish([invalidGoogleShapeEvent(invalidContent)]);
|
|
1210
|
+
const rawParts: unknown = isGoogleRecord(rawContent) ? rawContent.parts : undefined;
|
|
1211
|
+
if (rawParts !== undefined && rawParts !== null) {
|
|
1212
|
+
const invalidParts = diagnoseGoogleParts(rawParts);
|
|
1213
|
+
if (invalidParts) return finish([invalidGoogleShapeEvent(invalidParts)]);
|
|
1214
|
+
const parts = rawParts as GoogleResponsePart[];
|
|
1215
|
+
const invalidFunctionCall = diagnoseGoogleFunctionCalls(parts);
|
|
1216
|
+
if (invalidFunctionCall) return finish([invalidGoogleFunctionCallEvent(invalidFunctionCall)]);
|
|
933
1217
|
// Non-streaming Google-family response: observe thought signatures for the next turn,
|
|
934
1218
|
// using the same transport-scoped namespace as the streaming path.
|
|
935
1219
|
const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
|
|
936
1220
|
const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
|
|
937
1221
|
if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
|
|
938
1222
|
&& replayModel && replaySession) {
|
|
939
|
-
observeAntigravityReplay(replayModel, replaySession,
|
|
1223
|
+
observeAntigravityReplay(replayModel, replaySession, parts as unknown[]);
|
|
940
1224
|
}
|
|
941
1225
|
let pendingThoughtSig: string | undefined;
|
|
942
|
-
for (const part of
|
|
1226
|
+
for (const part of parts) {
|
|
943
1227
|
const sig = part.thoughtSignature ?? part.thought_signature;
|
|
944
1228
|
if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) {
|
|
945
1229
|
pendingThoughtSig = sig;
|
|
@@ -960,16 +1244,17 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
960
1244
|
}
|
|
961
1245
|
}
|
|
962
1246
|
}
|
|
963
|
-
|
|
1247
|
+
const functionCall = googleFunctionCall(part);
|
|
1248
|
+
if (functionCall) {
|
|
964
1249
|
const id = `call_${crypto.randomUUID().slice(0, 8)}`;
|
|
965
1250
|
toolCallsStarted++;
|
|
966
1251
|
events.push({
|
|
967
1252
|
type: "tool_call_start",
|
|
968
1253
|
id,
|
|
969
|
-
name: restoreGoogleToolName(
|
|
1254
|
+
name: restoreGoogleToolName(functionCall.name),
|
|
970
1255
|
...googleToolCallMetadataFromPart(part, pendingThoughtSig),
|
|
971
1256
|
});
|
|
972
|
-
events.push({ type: "tool_call_delta", arguments: JSON.stringify(
|
|
1257
|
+
events.push({ type: "tool_call_delta", arguments: JSON.stringify(functionCall.args ?? {}) });
|
|
973
1258
|
events.push({ type: "tool_call_end" });
|
|
974
1259
|
}
|
|
975
1260
|
}
|
|
@@ -978,8 +1263,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
978
1263
|
// Fail-closed truncation, same as the stream path: a non-stream turn cut off mid tool call
|
|
979
1264
|
// (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done.
|
|
980
1265
|
if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist")
|
|
981
|
-
&& isVertexTruncatedTurn(
|
|
982
|
-
return finish([{ type: "error", message: vertexTruncationErrorMessage(
|
|
1266
|
+
&& isVertexTruncatedTurn(candidate.finishReason, toolCallsStarted)) {
|
|
1267
|
+
return finish([{ type: "error", message: vertexTruncationErrorMessage(candidate.finishReason) }]);
|
|
983
1268
|
}
|
|
984
1269
|
|
|
985
1270
|
const usage = json.usageMetadata as Record<string, number> | undefined;
|
|
@@ -987,7 +1272,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
987
1272
|
// must carry its stop reason, or the bridge sees a clean `done` and reports the truncated
|
|
988
1273
|
// turn as completed — and, on a compaction turn, installs the half-written summary as
|
|
989
1274
|
// replacement history (#422).
|
|
990
|
-
const finishReason =
|
|
1275
|
+
const finishReason = candidate.finishReason as string | undefined;
|
|
991
1276
|
const stopReason = finishReason === "MAX_TOKENS"
|
|
992
1277
|
? "max_tokens"
|
|
993
1278
|
: ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(finishReason ?? "")
|
|
@@ -1563,6 +1563,16 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
1563
1563
|
pendingToolCalls.length = 0;
|
|
1564
1564
|
return calls;
|
|
1565
1565
|
};
|
|
1566
|
+
const pendingToolCallsAreCompleteJsonObjects = (): boolean =>
|
|
1567
|
+
pendingToolCalls.length > 0 && pendingToolCalls.every(call => {
|
|
1568
|
+
if (call.name.trim().length === 0 || !call.sawArgumentsString || call.args.length === 0) return false;
|
|
1569
|
+
try {
|
|
1570
|
+
const parsed = JSON.parse(call.args) as unknown;
|
|
1571
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
|
|
1572
|
+
} catch {
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
});
|
|
1566
1576
|
// Returns "terminate" when a pending call cannot be dispatched, so every flush site
|
|
1567
1577
|
// stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first,
|
|
1568
1578
|
// so budget reservations are released for every pending call even on the early return.
|
|
@@ -1823,6 +1833,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
1823
1833
|
}
|
|
1824
1834
|
const sawFinish = finishReason !== undefined;
|
|
1825
1835
|
if (!sawFinish && pendingToolCalls.length > 0) {
|
|
1836
|
+
// Some OpenAI-compatible gateways close immediately after a complete function-call
|
|
1837
|
+
// delta and omit both terminal conventions. Keep the default fail-closed policy, and
|
|
1838
|
+
// let an opted-in provider recover only calls whose assembled argument payload is a
|
|
1839
|
+
// complete JSON object. A partial JSON prefix still takes the truncation path below.
|
|
1840
|
+
if (provider.openaiChatEofTolerance === true && pendingToolCallsAreCompleteJsonObjects()) {
|
|
1841
|
+
if ((yield* flushToolCalls()) === "terminate") return;
|
|
1842
|
+
yield { type: "done", usage: pendingUsage };
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1826
1845
|
debugProviderDiagnostic("openai-chat", "stream-truncated", {
|
|
1827
1846
|
finishReason: null,
|
|
1828
1847
|
hadUsage: pendingUsage !== undefined,
|