Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 92 additions & 9 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,11 +409,16 @@ function artifactMarkdownUrl(filePath: string): string {
}

interface GoogleResponsePart {
text?: string;
text?: unknown;
thought?: boolean;
thoughtSignature?: string;
thought_signature?: string;
functionCall?: { name: string; args: unknown };
functionCall?: unknown;
}

interface GoogleFunctionCall {
name: string;
args?: unknown;
}

/**
Expand All @@ -436,12 +441,24 @@ function googleToolCallMetadataFromPart(
* cannot accidentally expose the same hidden reasoning through different event types.
*/
function googlePartTextEvent(part: GoogleResponsePart): AdapterEvent | undefined {
if (!part.text) return undefined;
// A malformed scalar/object is not text and must not cross the AdapterEvent boundary. Dropping
// only this optional field preserves the rest of the part without inventing assistant output by
// coercion; an empty string keeps its existing no-event behavior.
if (typeof part.text !== "string" || part.text.length === 0) return undefined;
return part.thought === true
? { type: "reasoning_raw_delta", text: part.text }
: { type: "text_delta", text: part.text };
}

interface InvalidGoogleFunctionCallDiagnostic {
reason:
| "function_call_not_object"
| "function_call_name_invalid"
| "function_call_name_blank";
partIndex: number;
valueType: string;
}

interface InvalidGoogleShapeDiagnostic {
reason:
| "candidates_not_array"
Expand All @@ -462,6 +479,63 @@ function googleStructuralValueType(value: unknown): string {
return Array.isArray(value) ? "array" : typeof value;
}

/**
* Gemini delivers one complete functionCall per part, so a missing name cannot be repaired by a
* later delta. Validate the whole parts array before observing signatures or emitting content: a
* malformed call must terminate the claimed response rather than enter replay state or reach the
* bridge as a nameless dispatch. Null remains an absence encoding, matching other optional fields.
*/
function diagnoseGoogleFunctionCalls(
parts: readonly GoogleResponsePart[],
): InvalidGoogleFunctionCallDiagnostic | undefined {
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
const functionCall = parts[partIndex]?.functionCall;
if (functionCall === undefined || functionCall === null) continue;
if (!isGoogleRecord(functionCall)) {
return {
reason: "function_call_not_object",
partIndex,
valueType: googleStructuralValueType(functionCall),
};
}
if (typeof functionCall.name !== "string") {
return {
reason: "function_call_name_invalid",
partIndex,
valueType: googleStructuralValueType(functionCall.name),
};
}
if (functionCall.name.trim().length === 0) {
return {
reason: "function_call_name_blank",
partIndex,
valueType: "string",
};
}
}
return undefined;
}

function googleFunctionCall(part: GoogleResponsePart): GoogleFunctionCall | undefined {
const functionCall = part.functionCall;
if (!isGoogleRecord(functionCall) || typeof functionCall.name !== "string") return undefined;
return { name: functionCall.name, args: functionCall.args };
}

function invalidGoogleFunctionCallEvent(
diagnostic: InvalidGoogleFunctionCallDiagnostic,
): Extract<AdapterEvent, { type: "error" }> {
const subject = diagnostic.reason === "function_call_not_object"
? "invalid function call"
: diagnostic.reason === "function_call_name_blank"
? "blank function call name"
: "invalid function call name";
return {
type: "error",
message: `google response contained ${subject} (${diagnostic.reason}; partIndex=${diagnostic.partIndex}; valueType=${diagnostic.valueType}) — cannot dispatch`,
};
}

/**
* A candidate's `content` is claimed model output inside a well-formed frame, so it is governed by
* the #1332 nested-shape rule (fail closed) rather than #1240's root-frame padding rule (skip).
Expand Down Expand Up @@ -865,6 +939,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
return "terminate";
}
parts = rawParts as GoogleResponsePart[];
const invalidFunctionCall = diagnoseGoogleFunctionCalls(parts);
if (invalidFunctionCall) {
yield invalidGoogleFunctionCallEvent(invalidFunctionCall);
return "terminate";
}
}
// Record Gemini thought signatures for the next stateless tool-result turn. Vertex and
// Antigravity use separate model namespaces so opaque provider state cannot cross routes.
Expand Down Expand Up @@ -905,18 +984,19 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
}
}
}
if (part.functionCall) {
const functionCall = googleFunctionCall(part);
if (functionCall) {
const id = `call_${crypto.randomUUID().slice(0, 8)}`;
toolCallsStarted++;
emittedContentEvent = true;
const restoredName = restoreGoogleToolName(part.functionCall.name);
const restoredName = restoreGoogleToolName(functionCall.name);
yield {
type: "tool_call_start",
id,
name: restoredName,
...googleToolCallMetadataFromPart(part, pendingStreamThoughtSig),
};
yield { type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) };
yield { type: "tool_call_delta", arguments: JSON.stringify(functionCall.args ?? {}) };
yield { type: "tool_call_end" };
}
}
Expand Down Expand Up @@ -1132,6 +1212,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
const invalidParts = diagnoseGoogleParts(rawParts);
if (invalidParts) return finish([invalidGoogleShapeEvent(invalidParts)]);
const parts = rawParts as GoogleResponsePart[];
const invalidFunctionCall = diagnoseGoogleFunctionCalls(parts);
if (invalidFunctionCall) return finish([invalidGoogleFunctionCallEvent(invalidFunctionCall)]);
// Non-streaming Google-family response: observe thought signatures for the next turn,
// using the same transport-scoped namespace as the streaming path.
const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
Expand Down Expand Up @@ -1162,16 +1244,17 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
}
}
}
if (part.functionCall) {
const functionCall = googleFunctionCall(part);
if (functionCall) {
const id = `call_${crypto.randomUUID().slice(0, 8)}`;
toolCallsStarted++;
events.push({
type: "tool_call_start",
id,
name: restoreGoogleToolName(part.functionCall.name),
name: restoreGoogleToolName(functionCall.name),
...googleToolCallMetadataFromPart(part, pendingThoughtSig),
});
events.push({ type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) });
events.push({ type: "tool_call_delta", arguments: JSON.stringify(functionCall.args ?? {}) });
events.push({ type: "tool_call_end" });
}
}
Expand Down
17 changes: 17 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,23 @@ classification, preserving the opaque continuation state independently of displa
- 다른 대안 대신 이 방식을 선택한 이유: Dropping the text loses reasoning replay/display policy input, while duplicated parser rules can drift and exposing marked thoughts violates the provider's visibility boundary.
- 장점, 단점 및 영향: Internal reasoning no longer leaks into normal answers and both transports stay consistent; downstream reasoning policy still decides whether raw reasoning is rendered or only preserved, and malformed non-boolean markers remain ordinary text rather than broadening hidden-content inference.

## Google response-part field boundary

Google-family adapters validate the values inside an otherwise well-formed response part before
they become `AdapterEvent`s. A present `functionCall` must be an object with a nonblank string
`name`; because Gemini delivers that call atomically rather than across deltas, an invalid name is a
terminal protocol error and is never dispatched. A non-string optional `text` value is dropped
without coercion, while the rest of the part and turn continue. Structured `functionCall.args`
remain provider-native and are serialized as before.

[Decision Log]
- 목적과 의도: Keep malformed Google-compatible response fields from violating the internal string-only text and tool-name contract or dispatching an unidentified tool.
- 기존 구현 및 제약 조건: Container validation guaranteed object parts, but truthy string/number/array functionCall values emitted a nameless tool call and truthy non-string text values crossed as text or reasoning events. Gemini supplies a complete call in one part, so there is no later name fragment to await.
- 검토한 주요 대안: Pass malformed values through; coerce them to strings; silently drop every malformed field; terminate the turn for every malformed field; distinguish dispatch identity from optional text.
- 선택한 방식: Prevalidate function calls and terminate on a non-object, non-string, empty, or whitespace name; drop only non-string text; leave arguments untouched.
- 다른 대안 대신 이 방식을 선택한 이유: Passing or coercing can execute the wrong tool or fabricate transcript text, while terminating for optional malformed text discards an otherwise usable response. An invalid call name cannot be recovered or safely ignored once the model selected a tool.
- 장점, 단점 및 영향: Streaming and buffered paths enforce the same AdapterEvent contract and invalid calls cannot enter thought-signature replay. Nonconforming third-party Google-compatible text fields are ignored rather than surfaced, and operators receive a structured terminal error for call identity failures.

## Google tool-call thought-signature replay

Gemini may attach an opaque `thoughtSignature` to a `functionCall` and requires that exact value on
Expand Down
67 changes: 67 additions & 0 deletions tests/google-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,73 @@ describe("google provider hardening", () => {
});
}

// A Google functionCall is delivered atomically in one part, so there is no later delta that
// can repair a missing name. Passing one through violates AdapterEvent's string-name contract
// and lets the bridge attempt to dispatch a call that cannot be identified. Keep streaming and
// buffered parsing fail-closed on the same field shapes (#2233).
const invalidFunctionCallCases: [string, unknown, string][] = [
["a string call", "x", "google response contained invalid function call (function_call_not_object; partIndex=0; valueType=string) — cannot dispatch"],
["a numeric call", 5, "google response contained invalid function call (function_call_not_object; partIndex=0; valueType=number) — cannot dispatch"],
["an array call", [], "google response contained invalid function call (function_call_not_object; partIndex=0; valueType=array) — cannot dispatch"],
["a call without a name", { args: {} }, "google response contained invalid function call name (function_call_name_invalid; partIndex=0; valueType=undefined) — cannot dispatch"],
["a call with a numeric name", { name: 5, args: {} }, "google response contained invalid function call name (function_call_name_invalid; partIndex=0; valueType=number) — cannot dispatch"],
["a call with an empty name", { name: "", args: {} }, "google response contained blank function call name (function_call_name_blank; partIndex=0; valueType=string) — cannot dispatch"],
["a call with a whitespace name", { name: " ", args: {} }, "google response contained blank function call name (function_call_name_blank; partIndex=0; valueType=string) — cannot dispatch"],
];

for (const [label, functionCall, message] of invalidFunctionCallCases) {
test(`${label} is a terminal stream error`, async () => {
const events = await collect(createGoogleAdapter(provider()).parseStream(
sseResponse([{
candidates: [{ content: { parts: [{ functionCall }] }, finishReason: "STOP" }],
}]),
));

expect(events).toEqual([{ type: "error", message }]);
expect(events.some(event => event.type === "done")).toBe(false);
});

test(`${label} is a terminal non-streaming error`, async () => {
const events = await createGoogleAdapter(provider()).parseResponse!(
new Response(JSON.stringify({
candidates: [{ content: { parts: [{ functionCall }] }, finishReason: "STOP" }],
}), { status: 200 }),
);

expect(events).toEqual([{ type: "error", message }]);
expect(events.some(event => event.type === "done")).toBe(false);
});
}

// Text is optional, but when present it must already be a string. Coercing an object or number
// would invent assistant output, while terminating an otherwise valid turn would be harsher than
// the field warrants. Drop only the malformed text field and keep other parts/terminal state.
const nonStringTextParts: [string, Record<string, unknown>][] = [
["numeric text", { text: 5 }],
["object text", { text: { a: 1 } }],
["array text", { text: [1, 2] }],
["numeric thought text", { text: 5, thought: true }],
];

for (const [label, part] of nonStringTextParts) {
test(`${label} is dropped on both response paths`, async () => {
const payload = {
candidates: [{ content: { parts: [part] }, finishReason: "STOP" }],
};
const streamEvents = await collect(createGoogleAdapter(provider()).parseStream(sseResponse([payload])));
const responseEvents = await createGoogleAdapter(provider()).parseResponse!(
new Response(JSON.stringify(payload), { status: 200 }),
);

for (const events of [streamEvents, responseEvents]) {
expect(events.some(event => event.type === "text_delta")).toBe(false);
expect(events.some(event => event.type === "reasoning_raw_delta")).toBe(false);
expect(events.some(event => event.type === "error")).toBe(false);
expect(events.at(-1)?.type).toBe("done");
}
});
}

// `content` itself has the same status as `parts`: claimed output the parser cannot read. The
// one tolerated non-record form is an empty array, which is how a JSON writer with no distinct
// empty-object form spells an empty `content`. A NON-empty array is where the payload used to
Expand Down
Loading