Skip to content
Closed
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
12 changes: 10 additions & 2 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ function messagesToGeminiFormat(
stripRejectedClaudeSdkParagraph = false,
isCloudCodeAssist = false,
nestToolResultImages = false,
toolResponseImagesLast = false,
): { systemInstruction?: unknown; contents: unknown[]; replayedCallIds: string[] } {
// Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model
// never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream.
Expand Down Expand Up @@ -557,11 +558,17 @@ function messagesToGeminiFormat(
}

const responseParts: unknown[] = [];
const trailingImages: unknown[] = [];
for (const call of toolCalls) {
const result = resultsById.get(call.wireCallId);
if (result) responseParts.push(...geminiToolResultParts(result, call.wireName, call.wireCallId, nestToolResultImages));
else responseParts.push(geminiMissingToolResultPart(call.wireName, call.wireCallId));
if (result) {
const [response, ...images] = geminiToolResultParts(result, call.wireName, call.wireCallId, nestToolResultImages);
responseParts.push(response);
(toolResponseImagesLast ? trailingImages : responseParts).push(...images);
} else responseParts.push(geminiMissingToolResultPart(call.wireName, call.wireCallId));
}
// CCA Claude misses later responses when image siblings interrupt the response batch.
responseParts.push(...trailingImages);
const orphanParts = orphanResults.flatMap(geminiOrphanToolResultParts);
contents.push({ role: "user", parts: responseParts });
// Orphan images cannot share Argon's response batch or claim another call's ownership.
Expand Down Expand Up @@ -1039,6 +1046,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
stripRejectedClaudeSdkParagraph,
isCloudCodeAssist,
provider.googleMode === "vertex" && parsed.modelId === "gemini-4-argon",
isCloudCodeAssist && /^claude-/i.test(routedModelId),
);
lastInjectedCallIds = [...replayedCallIds];
lastReasoningReplayScope = parsed._reasoningReplayScope;
Expand Down
7 changes: 7 additions & 0 deletions structure/providers/google.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ Google-family requests serialize a model tool-call turn and its results as one a
`model -> user` pair. The user turn contains exactly one `functionResponse` for every representable
call in original call order. Missing results use an explicit unknown-history marker; duplicate,
mismatched, and standalone results become marked text instead of unpaired function responses.
For Cloud Code Assist routed `claude-*` models, the complete matched `functionResponse` batch
precedes its sibling images in the same user turn; images keep call order, MIME types and bytes.
Interleaving an image before the remaining responses makes CCA Claude reject the batch as missing
later tool results. Single-call and text-only batches keep their existing shape. CCA Gemini,
non-Claude CCA models, AI Studio and non-Argon Vertex retain interleaved image siblings.
`tests/adapters/google/google-tool-result-adjacency.test.ts` checks the final compiled request
for three parallel calls, reversed results, distinct images and route isolation.
For Vertex `gemini-4-argon`, matched results carry representable data-URL images inside
`functionResponse.parts`, preserving each image's call ownership. Sibling images on that route can
produce a misleading "Requests ending with a model turn are not supported" error even when the
Expand Down
85 changes: 84 additions & 1 deletion tests/adapters/google/google-tool-result-adjacency.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { createGoogleAdapter } from "../../../src/adapters/google";
import type { OcxContentPart, OcxMessage, OcxParsedRequest } from "../../../src/types";
import type { OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../../../src/types";

const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" };

Expand Down Expand Up @@ -49,6 +49,89 @@ function functionResponses(turn: GeminiTurn): Array<Record<string, unknown>> {
.map(part => part.functionResponse as Record<string, unknown>);
}

describe("CCA Claude tool-result image ordering", () => {
const cca: OcxProviderConfig = { ...provider, googleMode: "cloud-code-assist",
baseUrl: "https://cloudcode-pa.googleapis.com", project: "test-project" };
const imageA = { inline_data: { mime_type: "image/png", data: "YQ==" } };
const imageB = { inline_data: { mime_type: "image/jpeg", data: "Yg==" } };
const responseA = { functionResponse: { name: "first", id: "call_A", response: { result: "screen A[image]" } } };
const responseB = { functionResponse: { name: "second", id: "call_B", response: { result: "screen B[image]" } } };
const responseC = { functionResponse: { name: "third", id: "call_C", response: { result: "text C" } } };

function parallelHistory(reversed: boolean): OcxMessage[] {
const results = [
result("call_A", "first", [{ type: "text", text: "screen A" },
{ type: "image", imageUrl: "data:image/png;base64,YQ==" }]),
result("call_B", "second", [{ type: "text", text: "screen B" },
{ type: "image", imageUrl: "data:image/jpeg;base64,Yg==" }]),
result("call_C", "third", "text C"),
];
return [user("inspect"), assistant([
{ id: "call_A", name: "first" }, { id: "call_B", name: "second" }, { id: "call_C", name: "third" },
]), ...(reversed ? results.reverse() : results)];
}

async function build(messages: OcxMessage[], config = cca, modelId = "claude-sonnet-4-6", stream = false) {
const original = structuredClone(messages);
const built = await createGoogleAdapter(config).buildRequest({
modelId, stream, options: {}, context: { messages },
} as OcxParsedRequest);
expect(messages).toEqual(original);
const body = JSON.parse(built.body);
return { contents: (body.request ?? body).contents as GeminiTurn[], built, body };
}

for (const model of ["claude-sonnet-4-6", "claude-opus-5-5-high"]) {
for (const stream of [false, true]) {
test.each([false, true])(`${model} puts all three responses before both images (stream=${stream}, reversed=%p)`, async reversed => {
const { contents, built, body } = await build(parallelHistory(reversed), cca, model, stream);
expect(built.url).toContain(stream ? ":streamGenerateContent?alt=sse" : ":generateContent");
expect(body.model).toBe(model);
expect(contents.map(turn => turn.role)).toEqual(["user", "model", "user"]);
expect(contents[1].parts.map(part => part.functionCall)).toEqual([
{ name: "first", id: "call_A", args: {} },
{ name: "second", id: "call_B", args: {} },
{ name: "third", id: "call_C", args: {} },
]);
// Assert the final compiled envelope, not a filtered list that hides interleaving.
expect(contents[2].parts).toEqual([responseA, responseB, responseC, imageA, imageB]);
});
}
}

test("single image result keeps its sibling and text-only parallel results keep their shape", async () => {
const history = parallelHistory(false);
const single = await build([history[0], assistant([{ id: "call_A", name: "first" }]), history[2]]);
expect(single.contents.at(-1)?.parts).toEqual([responseA, imageA]);
const textOnly = await build([history[0], history[1],
result("call_C", "third", "text C"), result("call_B", "second", "text B"), result("call_A", "first", "text A")]);
expect(textOnly.contents.at(-1)?.parts).toEqual([
{ functionResponse: { name: "first", id: "call_A", response: { result: "text A" } } },
{ functionResponse: { name: "second", id: "call_B", response: { result: "text B" } } }, responseC,
]);
});

test.each([
["cloud-code-assist", "gemini-4-argon"],
["cloud-code-assist", "gpt-oss-120b-medium"],
["ai-studio", "gemini-4-argon"],
["ai-studio", "claude-sonnet-4-6"],
["vertex", "gemini-3.1-pro"],
["vertex", "claude-sonnet-4-6"],
] as const)("other routes keep interleaved siblings (%s, %s)", async (googleMode, model) => {
const { contents } = await build(parallelHistory(true), { ...cca, googleMode }, model);
expect(contents.at(-1)?.parts).toEqual([responseA, imageA, responseB, imageB, responseC]);
});

test("Vertex Argon keeps each image nested in its matching response", async () => {
const { contents } = await build(parallelHistory(true), { ...cca, googleMode: "vertex" }, "gemini-4-argon");
expect(contents.at(-1)?.parts).toEqual([
{ functionResponse: { ...responseA.functionResponse, parts: [imageA] } },
{ functionResponse: { ...responseB.functionResponse, parts: [imageB] } }, responseC,
]);
});
});

describe("Google adapter tool-result adjacency repair (#2199)", () => {
test("normal call/result history stays one adjacent model/user pair", async () => {
const contents = await wire([
Expand Down
Loading