mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): retry transient provider errors in Google adapters (#7471)
The google-vertex and google-generative-ai adapters called client.models.generateContentStream bare, so a transient 429/5xx before the first token became a terminal errored assistant message instead of being retried like in the Anthropic, OpenAI, Azure, and OpenRouter adapters. Wrap the initial request in the shared retryProviderRequest via a new retryGoogleRequest helper that also normalizes @google/genai's ApiError, which has a status property but no headers property, so retryProviderRequest's provider-error guard recognizes it. Opt-in via the existing maxRetries stream option; default behavior is unchanged. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ebf33c0c22
commit
b9d360a2c7
4 changed files with 78 additions and 3 deletions
|
|
@ -32,6 +32,7 @@ import {
|
|||
mapStopReason,
|
||||
resolveGoogleFunctionCallingMode,
|
||||
retainThoughtSignature,
|
||||
retryGoogleRequest,
|
||||
supportsGoogleStrictToolSampling,
|
||||
} from "./google-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
|
|
@ -88,7 +89,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = (
|
|||
if (nextParams !== undefined) {
|
||||
params = nextParams as GenerateContentParameters;
|
||||
}
|
||||
const googleStream = await client.models.generateContentStream(params);
|
||||
const googleStream = await retryGoogleRequest(() => client.models.generateContentStream(params), options);
|
||||
|
||||
stream.push({ type: "start", partial: output });
|
||||
let currentBlock: TextContent | ThinkingContent | null = null;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
*/
|
||||
|
||||
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
|
||||
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.ts";
|
||||
import type { Context, ImageContent, Model, StopReason, StreamOptions, TextContent, Tool } from "../types.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
|
@ -374,3 +375,35 @@ export function mapStopReasonString(reason: string): StopReason {
|
|||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Google GenAI SDK request with the shared provider retry policy
|
||||
* (408/409/429/5xx with backoff, honoring retry-after), mirroring how the
|
||||
* Anthropic and OpenAI adapters wrap their initial request in
|
||||
* retryProviderRequest. The SDK's ApiError has a `status` property but no
|
||||
* `headers` property, and retryProviderRequest only retries errors that carry
|
||||
* both, so normalize the error by adding the missing `headers` before
|
||||
* rethrowing.
|
||||
*/
|
||||
export function retryGoogleRequest<T>(
|
||||
request: () => Promise<T>,
|
||||
options?: Pick<StreamOptions, "maxRetries" | "maxRetryDelayMs" | "signal">,
|
||||
): Promise<T> {
|
||||
return retryProviderRequest(
|
||||
async () => {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "status" in error && !("headers" in error)) {
|
||||
(error as { headers?: Headers }).headers = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
mapStopReason,
|
||||
resolveGoogleFunctionCallingMode,
|
||||
retainThoughtSignature,
|
||||
retryGoogleRequest,
|
||||
supportsGoogleStrictToolSampling,
|
||||
} from "./google-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
|
|
@ -106,7 +107,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
|
|||
if (nextParams !== undefined) {
|
||||
params = nextParams as GenerateContentParameters;
|
||||
}
|
||||
const googleStream = await client.models.generateContentStream(params);
|
||||
const googleStream = await retryGoogleRequest(() => client.models.generateContentStream(params), options);
|
||||
|
||||
stream.push({ type: "start", partial: output });
|
||||
let currentBlock: TextContent | ThinkingContent | null = null;
|
||||
|
|
|
|||
40
packages/ai/test/google-shared-retry.test.ts
Normal file
40
packages/ai/test/google-shared-retry.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { retryGoogleRequest } from "../src/api/google-shared.ts";
|
||||
|
||||
/** Shaped like @google/genai's ApiError: has `status`, but no `headers`. */
|
||||
function googleApiError(status: number): Error {
|
||||
return Object.assign(new Error(`got status: ${status}`), { status });
|
||||
}
|
||||
|
||||
describe("google request retries", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("retries a headers-less SDK error with a retryable status", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi.fn<() => Promise<string>>().mockRejectedValueOnce(googleApiError(429)).mockResolvedValue("ok");
|
||||
|
||||
const result = retryGoogleRequest(request, { maxRetries: 1 });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
await expect(result).resolves.toBe("ok");
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry when maxRetries is unset", async () => {
|
||||
const error = googleApiError(429);
|
||||
const request = vi.fn<() => Promise<string>>().mockRejectedValue(error);
|
||||
|
||||
await expect(retryGoogleRequest(request)).rejects.toBe(error);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry a non-retryable status", async () => {
|
||||
const error = googleApiError(400);
|
||||
const request = vi.fn<() => Promise<string>>().mockRejectedValue(error);
|
||||
|
||||
await expect(retryGoogleRequest(request, { maxRetries: 2 })).rejects.toBe(error);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue