diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index b29244752..ceed2f347 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -212,6 +212,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = ( } if (candidate?.finishReason) { + output.rawStopReason = candidate.finishReason; output.stopReason = mapStopReason(candidate.finishReason); if (output.content.some((b) => b.type === "toolCall")) { output.stopReason = "toolUse"; @@ -266,7 +267,10 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = ( throw new Error("Google stream ended without a finish reason"); } if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error("An unknown error occurred"); + const errorMessage = output.rawStopReason + ? `Gemini stopped with ${output.rawStopReason}` + : "An unknown error occurred"; + throw new Error(errorMessage); } stream.push({ type: "done", reason: output.stopReason, message: output }); diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index dbef8c2f1..864ffe2c6 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -229,6 +229,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = ( } if (candidate?.finishReason) { + output.rawStopReason = candidate.finishReason; output.stopReason = mapStopReason(candidate.finishReason); if (output.content.some((b) => b.type === "toolCall")) { output.stopReason = "toolUse"; @@ -283,7 +284,10 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = ( throw new Error("Google Vertex stream ended without a finish reason"); } if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error("An unknown error occurred"); + const errorMessage = output.rawStopReason + ? `Gemini stopped with ${output.rawStopReason}` + : "An unknown error occurred"; + throw new Error(errorMessage); } stream.push({ type: "done", reason: output.stopReason, message: output }); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 98cd9d22b..26470a458 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -408,6 +408,7 @@ export interface AssistantMessage { usage: Usage; stopReason: StopReason; errorMessage?: string; + rawStopReason?: string; timestamp: number; // Unix timestamp in milliseconds } diff --git a/packages/ai/test/google-raw-stop-reason.test.ts b/packages/ai/test/google-raw-stop-reason.test.ts new file mode 100644 index 000000000..005d389e6 --- /dev/null +++ b/packages/ai/test/google-raw-stop-reason.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +const googleGenAiMock = vi.hoisted(() => ({ + finishReason: "MALFORMED_FUNCTION_CALL", +})); + +vi.mock("@google/genai", () => { + class GoogleGenAI { + models = { + generateContentStream: async function* () { + yield { + responseId: "google-response-id", + candidates: [ + { + finishReason: googleGenAiMock.finishReason, + }, + ], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 0, + totalTokenCount: 1, + }, + }; + }, + }; + } + + return { + FinishReason: { + STOP: "STOP", + MAX_TOKENS: "MAX_TOKENS", + BLOCKLIST: "BLOCKLIST", + PROHIBITED_CONTENT: "PROHIBITED_CONTENT", + SPII: "SPII", + SAFETY: "SAFETY", + IMAGE_SAFETY: "IMAGE_SAFETY", + IMAGE_PROHIBITED_CONTENT: "IMAGE_PROHIBITED_CONTENT", + IMAGE_RECITATION: "IMAGE_RECITATION", + IMAGE_OTHER: "IMAGE_OTHER", + RECITATION: "RECITATION", + FINISH_REASON_UNSPECIFIED: "FINISH_REASON_UNSPECIFIED", + OTHER: "OTHER", + LANGUAGE: "LANGUAGE", + MALFORMED_FUNCTION_CALL: "MALFORMED_FUNCTION_CALL", + UNEXPECTED_TOOL_CALL: "UNEXPECTED_TOOL_CALL", + NO_IMAGE: "NO_IMAGE", + }, + FunctionCallingConfigMode: { + AUTO: "AUTO", + NONE: "NONE", + ANY: "ANY", + VALIDATED: "VALIDATED", + }, + GoogleGenAI, + ResourceScope: { + COLLECTION: "COLLECTION", + }, + ThinkingLevel: { + THINKING_LEVEL_UNSPECIFIED: "THINKING_LEVEL_UNSPECIFIED", + MINIMAL: "MINIMAL", + LOW: "LOW", + MEDIUM: "MEDIUM", + HIGH: "HIGH", + }, + }; +}); + +import { stream as streamGoogleGenerativeAi } from "../src/api/google-generative-ai.ts"; +import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts"; +import { getModel } from "../src/compat.ts"; +import type { Context } from "../src/types.ts"; + +const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], +}; + +describe("Google raw stop reasons", () => { + it("preserves raw Gemini finish reasons for Google Generative AI errors", async () => { + googleGenAiMock.finishReason = "MALFORMED_FUNCTION_CALL"; + + const stream = streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, { + apiKey: "test-api-key", + }); + + const message = await stream.result(); + + expect(message.stopReason).toBe("error"); + expect(message.rawStopReason).toBe("MALFORMED_FUNCTION_CALL"); + expect(message.errorMessage).toBe("Gemini stopped with MALFORMED_FUNCTION_CALL"); + }); + + it("preserves raw Gemini finish reasons for Google Vertex errors", async () => { + googleGenAiMock.finishReason = "SAFETY"; + + const stream = streamGoogleVertex(getModel("google-vertex", "gemini-3-flash-preview"), context, { + project: "test-project", + location: "us-central1", + }); + + const message = await stream.result(); + + expect(message.stopReason).toBe("error"); + expect(message.rawStopReason).toBe("SAFETY"); + expect(message.errorMessage).toBe("Gemini stopped with SAFETY"); + }); +});