From d17efe9e1bfcbaa806480d74a8869e931cb3e596 Mon Sep 17 00:00:00 2001 From: Jared Boynton Date: Mon, 14 Sep 2026 19:59:03 -0700 Subject: [PATCH] fix(ai): use supported minimum thinking for Gemini Flash --- packages/ai/CHANGELOG.md | 2 + packages/ai/src/api/google-generative-ai.ts | 7 ++ packages/ai/src/api/google-shared.ts | 9 ++ packages/ai/src/api/google-vertex.ts | 7 ++ packages/ai/src/changes.md | 23 ++++ .../gemini-38-flash-thinking-minimum.test.ts | 118 ++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 2 + 7 files changed, 168 insertions(+) create mode 100644 packages/ai/test/gemini-38-flash-thinking-minimum.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ecee4a1fc9..33246d395d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Fixed Gemini 3.7 and 3.8 Flash requests with omitted, disabled, or minimal reasoning sending unsupported `MINIMAL` thinking. Google and Vertex now use `LOW` for those requests while preserving higher levels and older model behavior. + ### Removed ## [2026.9.13-2] - 2026-09-13 diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index f9a9412242..29fb0ea5ff 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -29,6 +29,7 @@ import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./goog import { convertMessages, convertTools, + isGeminiMinimalUnsupportedFlashModel, isThinkingPart, mapStopReason, resolveGoogleFunctionCallingMode, @@ -478,6 +479,9 @@ function getDisabledThinkingConfig(model: Model<"google-generative-ai">): Thinki // Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite // do not support full thinking-off either. For Gemini 3 models, use the lowest supported // thinkingLevel without includeThoughts so hidden thinking remains invisible to pi. + if (isGeminiMinimalUnsupportedFlashModel(model)) { + return { thinkingLevel: GoogleGenAIThinkingLevel.LOW }; + } if (isGemini3ProModel(model)) { return { thinkingLevel: GoogleGenAIThinkingLevel.LOW }; } @@ -516,6 +520,9 @@ function getThinkingLevel( return "HIGH"; } } + if (effort === "minimal" && isGeminiMinimalUnsupportedFlashModel(model)) { + return "LOW"; + } switch (effort) { case "minimal": return "MINIMAL"; diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index 159c4e57e8..b98ad91689 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -4,6 +4,7 @@ import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; import type { + Api, Context, ImageContent, Model, @@ -52,6 +53,14 @@ export function resolveGoogleThinkingLevel( } } +// Gemini 3.7/3.8 Flash reject MINIMAL and accept LOW/MEDIUM/HIGH +// (https://ai.google.dev/gemini-api/docs/generate-content/thinking). Closed +// exact-id set; do not extend to future versions without docs. +export function isGeminiMinimalUnsupportedFlashModel(model: Pick, "id">): boolean { + const id = model.id.toLowerCase(); + return id === "gemini-3.7-flash" || id === "gemini-3.8-flash"; +} + /** * Determines whether a streamed Gemini `Part` should be treated as "thinking". * diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 51ca9eebfe..ef6e235968 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -33,6 +33,7 @@ import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./goog import { convertMessages, convertTools, + isGeminiMinimalUnsupportedFlashModel, isThinkingPart, mapStopReason, resolveGoogleFunctionCallingMode, @@ -570,6 +571,9 @@ function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfi // Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite // do not support full thinking-off either. For Gemini 3 models, use the lowest supported // thinkingLevel without includeThoughts so hidden thinking remains invisible to pi. + if (isGeminiMinimalUnsupportedFlashModel(model)) { + return { thinkingLevel: ThinkingLevel.LOW }; + } if (isGemini3ProModel(model)) { return { thinkingLevel: ThinkingLevel.LOW }; } @@ -595,6 +599,9 @@ function getGemini3ThinkingLevel( return "HIGH"; } } + if (effort === "minimal" && isGeminiMinimalUnsupportedFlashModel(model)) { + return "LOW"; + } switch (effort) { case "minimal": return "MINIMAL"; diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index e6a34d2844..575dc24f46 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,3 +1,26 @@ +## Gemini 3.8/3.7 Flash reject MINIMAL: floor disabled and explicit minimal at LOW (2026-09-14) + +### What changed + +- `packages/ai/src/api/google-shared.ts`: new exported `isGeminiMinimalUnsupportedFlashModel` exact-id predicate (`gemini-3.7-flash`, `gemini-3.8-flash`). +- `packages/ai/src/api/google-generative-ai.ts`: `getDisabledThinkingConfig` returns `{ thinkingLevel: LOW }` for the shared predicate's models before the generic Flash `MINIMAL` branch; `getThinkingLevel` maps explicit `minimal` to `LOW` for them (`low`/`medium`/`high` already map correctly). Older Flash still floors at `MINIMAL`, Pro still floors at `LOW`. +- `packages/ai/src/api/google-vertex.ts`: same two branches in `getDisabledThinkingConfig` and `getGemini3ThinkingLevel` against the Vertex `ThinkingLevel` enum. +- Tests: `packages/ai/test/gemini-38-flash-thinking-minimum.test.ts` pins the wire `thinkingConfig` for omitted reasoning, explicit runtime off, explicit minimal/high, and the session-title option shape (no `reasoning` key) on both providers, plus older-Flash `MINIMAL` and Pro `LOW` preservation. + +### Why + +- `packages/ai/src/api/google-generative-ai.ts` and `packages/ai/src/api/google-vertex.ts` take the disabled wire form whenever `streamSimple` runs without `reasoning` (the session-title path omits it by construction). Both floored every Gemini 3 Flash at `MINIMAL`, but Google documents 3.8 and 3.7 Flash as rejecting `MINIMAL` and supporting `LOW`/`MEDIUM`/`HIGH`, so title and thinking-off turns on `google/gemini-3.8-flash` failed while older Flash worked. The exact-id predicate lives in `packages/ai/src/api/google-shared.ts`. + +### Why an extension could not handle it + +- `packages/ai/src/api/google-generative-ai.ts` and `packages/ai/src/api/google-vertex.ts` own the disabled-branch decision and the `thinkingLevel` wire mapping. The model check belongs in `packages/ai/src/api/google-shared.ts` so direct SDK consumers and session-title requests receive the same correction without requiring an extension. + +### Expected merge conflict zones + +- LOW: `packages/ai/src/api/google-shared.ts` around the new `isGeminiMinimalUnsupportedFlashModel` export beside `resolveGoogleThinkingLevel`. +- LOW: `packages/ai/src/api/google-generative-ai.ts` around the `getDisabledThinkingConfig` floor and the `getThinkingLevel` minimal-only remap. +- LOW: `packages/ai/src/api/google-vertex.ts` around the `getDisabledThinkingConfig` floor and the `getGemini3ThinkingLevel` minimal-only remap. + ## Responses completion-phase watchdog: a dropped terminal event is a stall, not a five-minute wait (2026-09-13) ### What changed diff --git a/packages/ai/test/gemini-38-flash-thinking-minimum.test.ts b/packages/ai/test/gemini-38-flash-thinking-minimum.test.ts new file mode 100644 index 0000000000..3035a0af42 --- /dev/null +++ b/packages/ai/test/gemini-38-flash-thinking-minimum.test.ts @@ -0,0 +1,118 @@ +import type { GenerateContentParameters } from "@google/genai"; +import { describe, expect, it } from "vitest"; +import { getModel, streamSimple } from "../src/compat.ts"; +import type { Context, SimpleStreamOptions } from "../src/types.ts"; + +type RuntimeReasoning = NonNullable; +const RUNTIME_OFF = "off" as unknown as RuntimeReasoning; + +const context: Context = { + messages: [{ role: "user", content: "Hello", timestamp: 0 }], +}; + +type GoogleFlashModelId = + | "gemini-3.8-flash" + | "gemini-3.7-flash" + | "gemini-3-flash-preview" + | "gemini-3.5-flash" + | "gemini-3.1-pro-preview"; + +async function capturePayload( + provider: "google" | "google-vertex", + modelId: GoogleFlashModelId, + options: SimpleStreamOptions = {}, +): Promise { + const model = getModel(provider, modelId); + let payload: GenerateContentParameters | undefined; + const result = await streamSimple(model, context, { + ...options, + apiKey: "test", + onPayload: (request) => { + payload = request as GenerateContentParameters; + throw new Error("payload captured"); + }, + }).result(); + + expect(result.errorMessage).toContain("payload captured"); + if (!payload) throw new Error(`Payload was not captured for ${provider}/${modelId}`); + return payload; +} + +describe("Gemini 3.8/3.7 Flash thinking minimum", () => { + it("floors omitted reasoning at LOW for google/gemini-3.8-flash", async () => { + const payload = await capturePayload("google", "gemini-3.8-flash", {}); + + expect(payload.config?.thinkingConfig).toEqual({ thinkingLevel: "LOW" }); + }); + + it("floors explicit runtime off at LOW for google/gemini-3.8-flash", async () => { + const payload = await capturePayload("google", "gemini-3.8-flash", { reasoning: RUNTIME_OFF }); + + expect(payload.config?.thinkingConfig).toEqual({ thinkingLevel: "LOW" }); + }); + + it("floors omitted reasoning at LOW for google/gemini-3.7-flash", async () => { + const payload = await capturePayload("google", "gemini-3.7-flash", {}); + + expect(payload.config?.thinkingConfig).toEqual({ thinkingLevel: "LOW" }); + }); + + it("floors omitted reasoning at LOW for vertex gemini-3.8-flash", async () => { + const payload = await capturePayload("google-vertex", "gemini-3.8-flash", {}); + + expect(payload.config?.thinkingConfig).toEqual({ thinkingLevel: "LOW" }); + }); + + it("floors omitted reasoning at LOW for vertex gemini-3.7-flash", async () => { + const payload = await capturePayload("google-vertex", "gemini-3.7-flash", {}); + + expect(payload.config?.thinkingConfig).toEqual({ thinkingLevel: "LOW" }); + }); + + it("maps explicit minimal to LOW and preserves high for google/gemini-3.8-flash", async () => { + const minimal = await capturePayload("google", "gemini-3.8-flash", { reasoning: "minimal" }); + expect(minimal.config?.thinkingConfig).toEqual({ includeThoughts: true, thinkingLevel: "LOW" }); + + const high = await capturePayload("google", "gemini-3.8-flash", { reasoning: "high" }); + expect(high.config?.thinkingConfig).toEqual({ includeThoughts: true, thinkingLevel: "HIGH" }); + }); + + it("maps explicit minimal to LOW and preserves high for vertex gemini-3.8-flash", async () => { + const minimal = await capturePayload("google-vertex", "gemini-3.8-flash", { reasoning: "minimal" }); + expect(minimal.config?.thinkingConfig).toEqual({ includeThoughts: true, thinkingLevel: "LOW" }); + + const high = await capturePayload("google-vertex", "gemini-3.8-flash", { reasoning: "high" }); + expect(high.config?.thinkingConfig).toEqual({ includeThoughts: true, thinkingLevel: "HIGH" }); + }); + + it("preserves MINIMAL for older Flash with omitted reasoning", async () => { + const preview = await capturePayload("google", "gemini-3-flash-preview", {}); + expect(preview.config?.thinkingConfig).toEqual({ thinkingLevel: "MINIMAL" }); + + const older = await capturePayload("google", "gemini-3.5-flash", {}); + expect(older.config?.thinkingConfig).toEqual({ thinkingLevel: "MINIMAL" }); + + const vertexPreview = await capturePayload("google-vertex", "gemini-3-flash-preview", {}); + expect(vertexPreview.config?.thinkingConfig).toEqual({ thinkingLevel: "MINIMAL" }); + }); + + it("preserves LOW for Pro with omitted reasoning", async () => { + const googlePro = await capturePayload("google", "gemini-3.1-pro-preview", {}); + expect(googlePro.config?.thinkingConfig).toEqual({ thinkingLevel: "LOW" }); + + const vertexPro = await capturePayload("google-vertex", "gemini-3.1-pro-preview", {}); + expect(vertexPro.config?.thinkingConfig).toEqual({ thinkingLevel: "LOW" }); + }); + + it("uses the session-title option shape without reasoning for google/gemini-3.8-flash", async () => { + // Mirrors session-title-generator buildTitleOptions over + // agent-session _buildSessionTitleBaseOptions: no reasoning key, short + // retention, small max tokens. + const payload = await capturePayload("google", "gemini-3.8-flash", { + cacheRetention: "short", + maxTokens: 64, + }); + + expect(payload.config?.thinkingConfig).toEqual({ thinkingLevel: "LOW" }); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 87e7ce7004..6646f53381 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -26,6 +26,8 @@ ### Fixed +- Fixed automatic session titles failing with `Thinking level MINIMAL is not supported` on Gemini 3.7 and 3.8 Flash. Title requests now use the supported `LOW` level without changing the chat's reasoning setting. + - Fixed native grep reporting duplicate files across overlapping roots and symlink aliases; each file is searched once and reported under its lexically smallest display path without canonicalizing every file ([#1678](https://github.com/code-yeongyu/senpi/issues/1678)). - Fixed deferred (search-exposed) tools never activating by name in sessions without the tool-search builtin: the session now promotes the tool itself when no catalog activator claims it ([#1682](https://github.com/code-yeongyu/senpi/issues/1682)).