fix(google): retain provider-returned response model (#129606)

* fix(google): preserve provider-returned response models

Co-authored-by: Peter Steinberger <steipete@gmail.com>

* test(google): use typed Vertex stream options

Co-authored-by: Peter Steinberger <steipete@gmail.com>

* fix(google): normalize Vertex response model resources

Co-authored-by: Peter Steinberger <steipete@gmail.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-08-26 19:20:29 +08:00
committed by GitHub
parent 51e2dfe68e
commit e2e5d1bca6
4 changed files with 418 additions and 0 deletions
+260
View File
@@ -801,6 +801,266 @@ describe("google transport stream", () => {
expect(result.content[2]).toHaveProperty("thoughtSignature", "Y2FsbF9zaWdfMQ==");
});
it.each([
{
provider: "google",
requested: "gemini-2.5-pro",
returned: ["gemini-2.5-pro-002"],
expected: "gemini-2.5-pro-002",
},
{
provider: "google-vertex",
requested: "gemini-2.5-pro",
returned: ["gemini-2.5-pro-002"],
expected: "gemini-2.5-pro-002",
},
{
provider: "google",
requested: "gemini-2.5-pro",
returned: ["gemini-2.5-pro"],
},
{
provider: "google",
requested: "google/gemini-2.5-pro",
returned: ["gemini-2.5-pro"],
},
{
provider: "google",
requested: "models/gemini-2.5-pro",
returned: ["gemini-2.5-pro"],
},
{
provider: "google",
requested: "gemini-2.5-pro",
returned: ["models/gemini-2.5-pro"],
},
{
provider: "google",
requested: "gemini-2.5-pro",
returned: ["google/gemini-2.5-pro"],
},
{
provider: "google",
requested: "tunedModels/fixture-gemini",
returned: ["tunedModels/fixture-gemini"],
},
{
provider: "google-vertex",
requested: "google/gemini-2.5-pro",
returned: ["gemini-2.5-pro"],
},
{
provider: "google-vertex",
requested: "gemini-2.5-pro",
returned: ["publishers/google/models/gemini-2.5-pro"],
},
{
provider: "google-vertex",
requested: "gemini-2.5-pro",
returned: [
"projects/fixture-project/locations/global/publishers/google/models/gemini-2.5-pro",
],
},
{
provider: "google-vertex",
requested: "publishers/google/models/gemini-2.5-pro",
returned: ["gemini-2.5-pro"],
},
{
provider: "google-vertex",
requested:
"projects/fixture-project/locations/global/publishers/google/models/gemini-2.5-pro",
returned: ["gemini-2.5-pro"],
},
{
provider: "google-vertex",
requested: "gemini-2.5-pro",
returned: ["publishers/meta/models/gemini-2.5-pro"],
expected: "publishers/meta/models/gemini-2.5-pro",
},
{
provider: "google-vertex",
requested: "gemini-2.5-pro",
returned: ["tunedModels/gemini-2.5-pro"],
expected: "tunedModels/gemini-2.5-pro",
},
{
provider: "google",
requested: "gemini-2.5-pro",
returned: [undefined, "", " "],
},
{
provider: "google",
requested: "gemini-2.5-pro",
returned: ["", "gemini-2.5-pro-002", "gemini-2.5-pro-003"],
expected: "gemini-2.5-pro-002",
},
])(
"retains the concrete provider-returned model only when it actually differs ($provider, $requested)",
async ({ provider, requested, returned, expected }) => {
vi.stubEnv("GOOGLE_CLOUD_PROJECT", "fixture-google-project");
vi.stubEnv("GOOGLE_CLOUD_LOCATION", "global");
guardedFetchMock.mockResolvedValueOnce(
buildSseResponse(
returned.map((modelVersion, index) => ({
...(modelVersion === undefined ? {} : { modelVersion }),
...(index === returned.length - 1
? {
responseId: "actual-google-response",
candidates: [
{ content: { parts: [{ text: "actual response" }] }, finishReason: "STOP" },
],
}
: {}),
})),
),
);
const model =
provider === "google"
? buildGeminiModel({ id: requested })
: buildGoogleVertexModel({ id: requested });
const { buildGoogleProvider } = await import("./provider-registration.js");
const streamFn = buildGoogleProvider().createStreamFn?.({
provider,
modelId: requested,
model,
});
if (!streamFn) {
throw new Error(`Missing registered ${provider} stream`);
}
const stream = await Promise.resolve(
streamFn(
model,
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
{
apiKey: "fixture-google-api-key",
},
),
);
const result = await stream.result();
expect(result.responseId).toBe("actual-google-response");
expect(result.stopReason).toBe("stop");
if (expected) {
expect(result.responseModel).toBe(expected);
} else {
expect(result).not.toHaveProperty("responseModel");
}
},
);
it.each([
{
provider: "google",
requested: "google/gemini-2.5-pro",
returned: "gemini-2.5-pro-002",
expected: "gemini-2.5-pro-002",
},
{
provider: "google-vertex",
requested: "google/gemini-2.5-pro",
returned: "gemini-2.5-pro-002",
expected: "gemini-2.5-pro-002",
},
{
provider: "google",
requested: "models/gemini-2.5-pro",
returned: "gemini-2.5-pro",
},
{
provider: "google-vertex",
requested: "gemini-2.5-pro",
returned: "publishers/google/models/gemini-2.5-pro",
},
{
provider: "google-vertex",
requested: "gemini-2.5-pro",
returned: "projects/fixture-project/locations/global/publishers/google/models/gemini-2.5-pro",
},
])(
"keeps the registered $provider model identity across actual localhost HTTP SSE",
async ({ provider, requested, returned, expected }) => {
vi.stubEnv("GOOGLE_CLOUD_PROJECT", "fixture-google-project");
vi.stubEnv("GOOGLE_CLOUD_LOCATION", "global");
const observedRequests: Array<{ method?: string; url?: string }> = [];
const server = createServer((request, response) => {
observedRequests.push({ method: request.method, url: request.url });
response.writeHead(200, { "content-type": "text/event-stream" });
response.end(
`data: ${JSON.stringify({
responseId: "loopback-google-response",
modelVersion: returned,
candidates: [
{ content: { parts: [{ text: "actual response" }] }, finishReason: "STOP" },
],
})}\n\ndata: [DONE]\n\n`,
);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Missing Google loopback server address");
}
buildGuardedModelFetchMock.mockReturnValue(fetch);
const model =
provider === "google"
? buildGeminiModel({
id: requested,
baseUrl: `http://127.0.0.1:${address.port}/v1beta`,
})
: buildGoogleVertexModel({
id: requested,
baseUrl: `http://127.0.0.1:${address.port}`,
});
const { buildGoogleProvider } = await import("./provider-registration.js");
const streamFn = buildGoogleProvider().createStreamFn?.({
provider,
modelId: requested,
model,
});
if (!streamFn) {
throw new Error(`Missing registered ${provider} stream`);
}
const stream = await Promise.resolve(
streamFn(
model,
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
{
apiKey: "fixture-google-api-key",
},
),
);
const result = await stream.result();
expect(observedRequests).toEqual([
{ method: "POST", url: expect.stringContaining(":streamGenerateContent?alt=sse") },
]);
expect(result).toMatchObject({
responseId: "loopback-google-response",
stopReason: "stop",
});
if (expected) {
expect(result.responseModel).toBe(expected);
} else {
expect(result).not.toHaveProperty("responseModel");
}
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
},
);
it.each([
{
name: "includes billed tool-result prompt tokens in input accounting",
+12
View File
@@ -121,6 +121,9 @@ type GoogleVideoSlots = Map<Record<string, unknown>, VideoContent>;
const GOOGLE_GEMINI3_FIRST_RESPONSE_RETRY_DEFAULT_MS = 45_000;
const GOOGLE_GEMINI3_FIRST_RESPONSE_RETRY_ENV = "OPENCLAW_GOOGLE_GEMINI_FIRST_RESPONSE_RETRY_MS";
const GOOGLE_SSE_EVENT_BOUNDARY_RE = /(?:\r\n|\r(?!\n)|\n){2}/u;
// Compare Google-owned publisher resources without changing outbound request paths.
const GOOGLE_VERTEX_MODEL_RESOURCE_PREFIX =
/^(?:projects\/[^/]+\/locations\/[^/]+\/)?publishers\/google\/models\//u;
type GoogleTransportContentBlock =
| { type: "text"; text: string; textSignature?: string }
@@ -142,6 +145,7 @@ const GOOGLE_VERTEX_DEFAULT_API_VERSION = "v1";
type GoogleSseChunk = {
responseId?: string;
modelVersion?: string;
promptFeedback?: {
blockReason?: string;
blockReasonMessage?: string;
@@ -1549,6 +1553,14 @@ function createGoogleTransportStreamFn(kind: CanonicalGoogleTransportApi): Strea
})(sse.firstChunk);
for await (const chunk of chunks) {
output.responseId ||= chunk.responseId;
const responseModel = normalizeOptionalString(chunk.modelVersion);
if (
responseModel &&
resolveGoogleModelPath(model.id.replace(GOOGLE_VERTEX_MODEL_RESOURCE_PREFIX, "")) !==
resolveGoogleModelPath(responseModel.replace(GOOGLE_VERTEX_MODEL_RESOURCE_PREFIX, ""))
) {
output.responseModel ||= responseModel;
}
updateUsage(output, model, chunk, knownUsage);
const candidate = chunk.candidates?.[0];
const promptFeedback = chunk.promptFeedback;
@@ -1,4 +1,5 @@
// Google shared provider tests cover response conversion and finish reasons.
import { createServer } from "node:http";
import {
ApiError,
BlockedReason,
@@ -146,6 +147,7 @@ type GoogleResponseFixture = {
finishReason?: FinishReason;
finishMessage?: string;
responseId?: string;
modelVersion?: string;
usageMetadata?: GenerateContentResponse["usageMetadata"];
promptFeedback?: GenerateContentResponse["promptFeedback"];
};
@@ -321,6 +323,85 @@ describe("consumeGoogleGenerateContentStream", () => {
expect(output.usage.cost.total).toBeGreaterThan(0);
});
it.each([
{
api: "google-generative-ai",
requested: "gemini-test",
returned: ["gemini-test-002"],
expected: "gemini-test-002",
},
{
api: "google-vertex",
requested: "gemini-test",
returned: ["gemini-test-002"],
expected: "gemini-test-002",
},
{ api: "google-generative-ai", requested: "gemini-test", returned: ["gemini-test"] },
{ api: "google-generative-ai", requested: "google/gemini-test", returned: ["gemini-test"] },
{ api: "google-generative-ai", requested: "models/gemini-test", returned: ["gemini-test"] },
{ api: "google-generative-ai", requested: "gemini-test", returned: ["models/gemini-test"] },
{
api: "google-vertex",
requested: "publishers/google/models/gemini-test",
returned: ["gemini-test"],
},
{
api: "google-vertex",
requested: "projects/fixture-project/locations/global/publishers/google/models/gemini-test",
returned: ["gemini-test"],
},
{
api: "google-vertex",
requested: "gemini-test",
returned: ["publishers/google/models/gemini-test"],
},
{
api: "google-vertex",
requested: "publishers/meta/models/gemini-test",
returned: ["gemini-test"],
expected: "gemini-test",
},
{
api: "google-generative-ai",
requested: "tunedModels/fixture-gemini",
returned: ["tunedModels/fixture-gemini"],
},
{
api: "google-generative-ai",
requested: "gemini-test",
returned: ["", "gemini-test-002", "gemini-test-003"],
expected: "gemini-test-002",
},
] as const)(
"retains an actually different $api SDK response model for $requested",
async ({ api, requested, returned, expected }) => {
const targetModel = {
...model,
id: requested,
api,
provider: api === "google-vertex" ? "google-vertex" : "google",
} satisfies Model<"google-generative-ai" | "google-vertex">;
const { result } = await runGoogleFixture(
returned.map((modelVersion, index) =>
googleResponse({
modelVersion,
...(index === returned.length - 1
? { parts: [{ text: "actual response" }], finishReason: FinishReason.STOP }
: {}),
}),
),
{ targetModel },
);
expect(result.stopReason).toBe("stop");
if (expected) {
expect(result.responseModel).toBe(expected);
} else {
expect(result).not.toHaveProperty("responseModel");
}
},
);
it.each([
{
name: "includes billed tool-result prompt tokens in input accounting",
@@ -591,6 +672,59 @@ describe("consumeGoogleGenerateContentStream", () => {
});
describe("runGoogleGenerateContentLifecycle", () => {
it("retains the actual SDK response model across localhost HTTP SSE", async () => {
const observedRequests: Array<{ method?: string; url?: string }> = [];
const server = createServer((request, response) => {
observedRequests.push({ method: request.method, url: request.url });
response.writeHead(200, { "content-type": "text/event-stream" });
response.end(
`data: ${JSON.stringify({
responseId: "sdk-google-response",
modelVersion: "gemini-test-002",
candidates: [{ content: { parts: [{ text: "actual response" }] }, finishReason: "STOP" }],
})}\n\n`,
);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Missing Google SDK loopback server address");
}
const { result } = await runGoogleFixture([], {
createClient: () =>
new GoogleGenAI({
apiKey: "fixture-google-api-key",
httpOptions: { baseUrl: `http://127.0.0.1:${address.port}` },
}),
buildParams: () => ({
model: model.id,
contents: [{ role: "user", parts: [{ text: "hello" }] }],
}),
});
expect(observedRequests).toEqual([
{ method: "POST", url: expect.stringContaining(":streamGenerateContent?alt=sse") },
]);
expect(result).toMatchObject({
responseId: "sdk-google-response",
responseModel: "gemini-test-002",
stopReason: "stop",
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it("reports SDK stream acceptance without fabricated HTTP metadata", async () => {
const acceptanceObserver = vi.fn();
const options = withProviderAcceptanceObserver({}, acceptanceObserver);
@@ -49,6 +49,10 @@ import {
type GoogleApiType = "google-generative-ai" | "google-vertex";
// Google-owned SDK resource spellings identify the same model; other publishers do not.
const GOOGLE_MODEL_RESOURCE_PREFIX =
/^(?:(?:projects\/[^/]+\/locations\/[^/]+\/)?publishers\/google\/models\/|google\/|models\/)/u;
type GoogleThinkingLevel = `${ThinkingLevel}`;
type GoogleToolChoice = "auto" | "none" | "any";
@@ -801,6 +805,14 @@ export async function consumeGoogleGenerateContentStream<T extends GoogleApiType
for await (const chunk of params.chunks) {
params.output.responseId ||= chunk.responseId;
const responseModel = chunk.modelVersion?.trim();
if (
responseModel &&
params.model.id.replace(GOOGLE_MODEL_RESOURCE_PREFIX, "") !==
responseModel.replace(GOOGLE_MODEL_RESOURCE_PREFIX, "")
) {
params.output.responseModel ||= responseModel;
}
if (chunk.usageMetadata) {
for (const field of Object.keys(knownUsage) as Array<keyof typeof knownUsage>) {
const value = chunk.usageMetadata[field];