Skip to content
Closed
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
5 changes: 3 additions & 2 deletions config/assertion-safety-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ extensions/google/music-generation-provider.ts 1
extensions/google/oauth-token-shared.ts 1
extensions/google/onboard.ts 3
extensions/google/provider-registration.ts 2
extensions/google/realtime-voice-provider.ts 6
extensions/google/realtime-voice-provider.ts 5
extensions/google/transport-stream.ts 13
extensions/google/vertex-adc.ts 4
extensions/google/video-generation-provider.ts 13
Expand Down Expand Up @@ -2705,6 +2705,7 @@ src/gateway/server-methods/plugin-approval.ts 2
src/gateway/server-methods/plugin-host-hooks.ts 1
src/gateway/server-methods/question.ts 2
src/gateway/server-methods/restart-request.ts 8
src/gateway/server-methods/send.ts 1
src/gateway/server-methods/session-catalog-terminal-start.ts 1
src/gateway/server-methods/session-catalog.ts 5
src/gateway/server-methods/session-create-initial-turn.ts 2
Expand Down Expand Up @@ -3230,7 +3231,7 @@ src/plugins/manifest-setup-normalizers.ts 5
src/plugins/manifest.ts 2
src/plugins/marketplace.ts 6
src/plugins/memory-runtime.ts 1
src/plugins/native-module-require.ts 2
src/plugins/native-module-require.ts 3
src/plugins/official-external-provider-endpoints.ts 1
src/plugins/openai-compatible-embedding-provider.ts 2
src/plugins/plugin-command-dispatch-contract.ts 1
Expand Down
13 changes: 13 additions & 0 deletions docs/providers/google.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,19 @@ nearest supported level, while `-1` leaves Google's default in place. See the
[Gemini Live capability comparison](https://ai.google.dev/gemini-api/docs/live-api/capabilities).
</Note>

<Note>
Gemini 3.8 Live (`gemini-3.8-live`) keeps the async function-calling contract and
rejects any thinking config, so OpenClaw sends none for it. Gemini 3.8 Live Extended
Thinking (`gemini-3.8-live-extended-thinking`) requires `NON_BLOCKING` tools, rejects
function response scheduling, and abandons a call after an interim response, so OpenClaw
sends one final result per agent consult without a "working" interim. Configure its
reasoning depth with `thinkingLevel` (`low`, `medium`, or `high`; `minimal` maps to
`low`), or a positive `thinkingBudget` mapped to the nearest level. On this model an
explicit stop or barge-in interrupts generation through a client-content turn; other
Gemini Live models interrupt only through server-side voice activity detection. See the
[Gemini 3.8 Live thinking guide](https://ai.google.dev/gemini-api/docs/live-api/thinking).
</Note>

<Note>
Control UI Talk supports Google Live browser sessions with constrained one-use
tokens. In Video Talk, the browser sends bounded JPEG frames directly to
Expand Down
104 changes: 104 additions & 0 deletions extensions/google/realtime-voice-model-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { type ThinkingConfig, ThinkingLevel } from "@google/genai";
import { resolveGoogleGemini3ThinkingLevel } from "./thinking-api.js";

/** The thinking-related provider settings the Live model contracts read. */
export type GoogleLiveThinkingSettings = {
thinkingLevel?: "minimal" | "low" | "medium" | "high";
thinkingBudget?: number;
};

function normalizeGoogleLiveModelId(model: string): string {
return model.startsWith("models/") ? model.slice("models/".length) : model;
}

// Gemini 3.1 Live replaces client-content text and async tools with realtime text
// and sequential function responses; explicit older models keep their prior contract.
export function isGemini31LiveModel(model: string): boolean {
const modelId = normalizeGoogleLiveModelId(model);
return modelId.startsWith("gemini-3.1-") && modelId.includes("-live");
}

// Gemini 3.8 Live (`gemini-3.8-live`) keeps the async tool contract but closes the
// session (1007) on any thinking config. Gemini 3.8 Live Extended Thinking
// (`gemini-3.8-live-extended-thinking`) requires NON_BLOCKING tools, closes the session
// (1007) on function response scheduling, drops a call after a `willContinue` interim
// instead of waiting for the final result, and takes `thinkingLevel` low|medium|high.
// Both verified against the live API on 2026-09-19.
export function isGemini38LiveModel(model: string): boolean {
return normalizeGoogleLiveModelId(model).startsWith("gemini-3.8-live");
}

export function isGemini38LiveExtendedThinkingModel(model: string): boolean {
const modelId = normalizeGoogleLiveModelId(model);
return modelId.startsWith("gemini-3.8-live") && modelId.includes("extended-thinking");
}

export function supportsAsyncFunctionCalling(model: string): boolean {
return !isGemini31LiveModel(model);
}

// Continuation means an interim `willContinue` response scheduled WHEN_IDLE before the
// final result. Extended Thinking rejects the scheduling and abandons the call after the
// interim, so it gets one final response per call like Gemini 3.1 Live.
export function modelSupportsToolResultContinuation(model: string): boolean {
return supportsAsyncFunctionCalling(model) && !isGemini38LiveExtendedThinkingModel(model);
}

// Gemini Live has no response.cancel. Extended Thinking documents that a client-content
// turn with turnComplete interrupts active generation; the server then emits
// `interrupted` and `turnComplete`. Other Live models only interrupt through server-side
// VAD on input audio.
export function supportsClientContentInterrupt(model: string): boolean {
return isGemini38LiveExtendedThinkingModel(model);
}

function resolveGemini38LiveExtendedThinkingLevel(
config: GoogleLiveThinkingSettings,
): ThinkingLevel | undefined {
switch (config.thinkingLevel) {
case "minimal": // MINIMAL is rejected by this model; LOW is the nearest supported level.
case "low":
return ThinkingLevel.LOW;
case "medium":
return ThinkingLevel.MEDIUM;
case "high":
return ThinkingLevel.HIGH;
default:
break;
}
if (typeof config.thinkingBudget !== "number" || config.thinkingBudget < 0) {
return undefined;
}
if (config.thinkingBudget <= 2048) {
return ThinkingLevel.LOW;
}
if (config.thinkingBudget <= 8192) {
return ThinkingLevel.MEDIUM;
}
return ThinkingLevel.HIGH;
}

export function buildThinkingConfig(
config: GoogleLiveThinkingSettings,
model: string,
): ThinkingConfig | undefined {
if (isGemini31LiveModel(model)) {
const thinkingLevel = resolveGoogleGemini3ThinkingLevel({
modelId: model,
thinkingLevel: config.thinkingLevel,
thinkingBudget: config.thinkingBudget,
});
return thinkingLevel ? { thinkingLevel: ThinkingLevel[thinkingLevel] } : undefined;
}
if (isGemini38LiveModel(model)) {
if (!isGemini38LiveExtendedThinkingModel(model)) {
return undefined;
}
const thinkingLevel = resolveGemini38LiveExtendedThinkingLevel(config);
return thinkingLevel ? { thinkingLevel } : undefined;
}
if (typeof config.thinkingBudget === "number") {
return { thinkingBudget: config.thinkingBudget };
}
return undefined;
}
242 changes: 242 additions & 0 deletions extensions/google/realtime-voice-provider.gemini38.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
// Google tests cover the Gemini 3.8 Live model contracts of the realtime voice provider.
import type { RealtimeVoiceTool } from "openclaw/plugin-sdk/realtime-voice";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildThinkingConfig,
isGemini38LiveExtendedThinkingModel,
isGemini38LiveModel,
modelSupportsToolResultContinuation,
supportsClientContentInterrupt,
} from "./realtime-voice-model-contract.js";
import { buildGoogleRealtimeVoiceProvider } from "./realtime-voice-provider.js";

type MockGoogleLiveConnectParams = {
model: string;
config: Record<string, unknown>;
callbacks: {
onopen: () => void;
onmessage: (message: Record<string, unknown>) => void;
onerror: (event: { error?: unknown; message?: string }) => void;
onclose: (event?: { code?: number; reason?: string; wasClean?: boolean }) => void;
};
};

const { connectMock, createGoogleGenAIMock, session } = vi.hoisted(() => {
const sessionValue = {
close: vi.fn(),
sendClientContent: vi.fn(),
sendRealtimeInput: vi.fn(),
sendToolResponse: vi.fn(),
};
const connectMockLocal = vi.fn(async (_params: MockGoogleLiveConnectParams) => sessionValue);
const createGoogleGenAIMockLocal = vi.fn(() => ({
authTokens: { create: vi.fn(async () => ({ name: "auth_tokens/browser-session" })) },
live: { connect: connectMockLocal },
}));
return {
connectMock: connectMockLocal,
createGoogleGenAIMock: createGoogleGenAIMockLocal,
session: sessionValue,
};
});

vi.mock("./google-genai-runtime.js", () => ({
createGoogleGenAI: createGoogleGenAIMock,
}));

const ENV_KEYS = ["GEMINI_API_KEY", "GOOGLE_API_KEY"] as const;
let envSnapshot: Partial<Record<(typeof ENV_KEYS)[number], string>>;

function lastConnectParams(): MockGoogleLiveConnectParams {
const params = connectMock.mock.calls.at(-1)?.[0];
if (!params) {
throw new Error("expected google live connect call");
}
return params;
}

function consultDeclaration(params: MockGoogleLiveConnectParams) {
const config = params.config as {
tools?: Array<{ functionDeclarations?: Array<{ behavior?: string; name?: string }> }>;
};
return config.tools?.[0]?.functionDeclarations?.[0];
}

function createRealtimeTool(name: string): RealtimeVoiceTool {
return {
type: "function",
name,
description: "Contract test tool",
parameters: { type: "object", properties: {} },
};
}

type GoogleLiveBridgeParams = Parameters<
ReturnType<typeof buildGoogleRealtimeVoiceProvider>["createBridge"]
>[0];

function createGoogleLiveBridge(params: Partial<GoogleLiveBridgeParams> = {}) {
const { providerConfig, ...callbacks } = params;
return buildGoogleRealtimeVoiceProvider().createBridge({
providerConfig: { apiKey: "gemini-key", ...providerConfig },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
...callbacks,
});
}

async function openConfiguredBridge(params: Partial<GoogleLiveBridgeParams> = {}) {
const bridge = createGoogleLiveBridge(params);
await bridge.connect();
lastConnectParams().callbacks.onopen();
lastConnectParams().callbacks.onmessage({ setupComplete: { sessionId: "session-1" } });
return bridge;
}

describe("Gemini 3.8 Live model contracts", () => {
it("classifies the 3.8 Live model ids", () => {
expect(isGemini38LiveModel("gemini-3.8-live")).toBe(true);
expect(isGemini38LiveModel("models/gemini-3.8-live-extended-thinking")).toBe(true);
expect(isGemini38LiveExtendedThinkingModel("gemini-3.8-live")).toBe(false);
expect(isGemini38LiveExtendedThinkingModel("gemini-3.8-live-extended-thinking")).toBe(true);
expect(isGemini38LiveModel("gemini-3.1-flash-live-preview")).toBe(false);
});

it("reserves interim tool responses and client-turn interrupts for the right models", () => {
expect(modelSupportsToolResultContinuation("gemini-3.8-live")).toBe(true);
expect(modelSupportsToolResultContinuation("gemini-3.8-live-extended-thinking")).toBe(false);
expect(modelSupportsToolResultContinuation("gemini-3.1-flash-live-preview")).toBe(false);
expect(supportsClientContentInterrupt("gemini-3.8-live-extended-thinking")).toBe(true);
expect(supportsClientContentInterrupt("gemini-3.8-live")).toBe(false);
});

it("builds thinking config per model family", () => {
expect(buildThinkingConfig({ thinkingLevel: "high" }, "gemini-3.8-live")).toBeUndefined();
expect(buildThinkingConfig({ thinkingBudget: 512 }, "gemini-3.8-live")).toBeUndefined();
expect(buildThinkingConfig({}, "gemini-3.8-live-extended-thinking")).toBeUndefined();
expect(
buildThinkingConfig({ thinkingLevel: "minimal" }, "gemini-3.8-live-extended-thinking"),
).toEqual({ thinkingLevel: "LOW" });
expect(
buildThinkingConfig({ thinkingBudget: 4_096 }, "gemini-3.8-live-extended-thinking"),
).toEqual({ thinkingLevel: "MEDIUM" });
expect(
buildThinkingConfig({ thinkingBudget: -1 }, "gemini-3.8-live-extended-thinking"),
).toBeUndefined();
});
});

describe("buildGoogleRealtimeVoiceProvider with Gemini 3.8 Live", () => {
beforeEach(() => {
envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
connectMock.mockClear();
createGoogleGenAIMock.mockClear();
session.close.mockClear();
session.sendClientContent.mockClear();
session.sendRealtimeInput.mockClear();
session.sendToolResponse.mockClear();
delete process.env.GEMINI_API_KEY;
delete process.env.GOOGLE_API_KEY;
});

afterEach(() => {
vi.restoreAllMocks();
for (const key of ENV_KEYS) {
const value = envSnapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});

it("uses the Gemini 3.8 Live Extended Thinking contract", async () => {
const onError = vi.fn();
const bridge = createGoogleLiveBridge({
providerConfig: {
model: "gemini-3.8-live-extended-thinking",
thinkingLevel: "high",
thinkingBudget: 8_193,
},
tools: [createRealtimeTool("openclaw_agent_consult")],
onToolCall: vi.fn(),
onError,
});

// Extended Thinking rejects function response scheduling (close 1007) and abandons a
// call after a `willContinue` interim, so the relay must send one final response.
expect(bridge.supportsToolResultContinuation).toBe(false);
await bridge.connect();

const params = lastConnectParams();
expect(params.model).toBe("gemini-3.8-live-extended-thinking");
expect(params.config.thinkingConfig).toEqual({ thinkingLevel: "HIGH" });
expect(consultDeclaration(params)).toMatchObject({
name: "openclaw_agent_consult",
behavior: "NON_BLOCKING",
});

params.callbacks.onmessage({
setupComplete: { sessionId: "session-1" },
toolCall: {
functionCalls: [
{ id: "consult-call", name: "openclaw_agent_consult", args: { prompt: "hi" } },
],
},
});
expect(() =>
bridge.submitToolResult("consult-call", { status: "working" }, { willContinue: true }),
).toThrow("does not support continuing tool responses");
expect(session.sendToolResponse).not.toHaveBeenCalled();

void bridge.submitToolResult("consult-call", { text: "The meeting starts at 3." });
expect(session.sendToolResponse).toHaveBeenCalledWith({
functionResponses: [
{
id: "consult-call",
name: "openclaw_agent_consult",
response: { text: "The meeting starts at 3." },
},
],
});
});

it("omits thinking config for Gemini 3.8 Live and keeps its async consult contract", async () => {
const bridge = createGoogleLiveBridge({
providerConfig: { model: "gemini-3.8-live", thinkingLevel: "high", thinkingBudget: 8_193 },
tools: [createRealtimeTool("openclaw_agent_consult")],
onToolCall: vi.fn(),
});

expect(bridge.supportsToolResultContinuation).toBe(true);
await bridge.connect();

const params = lastConnectParams();
expect(params.model).toBe("gemini-3.8-live");
expect(params.config).not.toHaveProperty("thinkingConfig");
expect(consultDeclaration(params)).toMatchObject({
name: "openclaw_agent_consult",
behavior: "NON_BLOCKING",
});
});

it("interrupts Gemini 3.8 Live Extended Thinking output with an empty client turn on barge-in", async () => {
const bridge = await openConfiguredBridge({
providerConfig: { model: "gemini-3.8-live-extended-thinking" },
});

bridge.handleBargeIn?.({ audioPlaybackActive: false });
expect(session.sendClientContent).not.toHaveBeenCalled();

bridge.handleBargeIn?.({ audioPlaybackActive: true });
expect(session.sendClientContent).toHaveBeenCalledWith({ turnComplete: true });
});

it("leaves barge-in to server-side VAD on other Gemini Live models", async () => {
const bridge = await openConfiguredBridge({ providerConfig: { model: "gemini-3.8-live" } });

bridge.handleBargeIn?.({ audioPlaybackActive: true, force: true });
expect(session.sendClientContent).not.toHaveBeenCalled();
});
});
Loading
Loading