Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/ai/src/api/google-generative-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./goog
import {
convertMessages,
convertTools,
isGeminiMinimalUnsupportedFlashModel,
isThinkingPart,
mapStopReason,
resolveGoogleFunctionCallingMode,
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -516,6 +520,9 @@ function getThinkingLevel(
return "HIGH";
}
}
if (effort === "minimal" && isGeminiMinimalUnsupportedFlashModel(model)) {
return "LOW";
}
switch (effort) {
case "minimal":
return "MINIMAL";
Expand Down
9 changes: 9 additions & 0 deletions packages/ai/src/api/google-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
import type {
Api,
Context,
ImageContent,
Model,
Expand Down Expand Up @@ -52,6 +53,14 @@ export function resolveGoogleThinkingLevel<T extends GoogleApiType>(
}
}

// 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<Model<Api>, "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".
*
Expand Down
7 changes: 7 additions & 0 deletions packages/ai/src/api/google-vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./goog
import {
convertMessages,
convertTools,
isGeminiMinimalUnsupportedFlashModel,
isThinkingPart,
mapStopReason,
resolveGoogleFunctionCallingMode,
Expand Down Expand Up @@ -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 };
}
Expand All @@ -595,6 +599,9 @@ function getGemini3ThinkingLevel(
return "HIGH";
}
}
if (effort === "minimal" && isGeminiMinimalUnsupportedFlashModel(model)) {
return "LOW";
}
switch (effort) {
case "minimal":
return "MINIMAL";
Expand Down
23 changes: 23 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
118 changes: 118 additions & 0 deletions packages/ai/test/gemini-38-flash-thinking-minimum.test.ts
Original file line number Diff line number Diff line change
@@ -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<SimpleStreamOptions["reasoning"]>;
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<GenerateContentParameters> {
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" });
});
});
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down