diff --git a/packages/ai/README.md b/packages/ai/README.md index c196b83c5da..198be6aae08 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -27,6 +27,31 @@ Run `LLM.stream(...)` instead of `generate` when you want incremental `LLMEvent` `LLM.request(...)`. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment. +### Google Interactions + +`Google.configure({ apiKey }).interactions(modelID)` selects the Interactions API; `.model(modelID)` still selects +GenerateContent. The package entrypoint is `@opencode/ai/providers/google/interactions`. + +```ts +const model = Google.configure({ apiKey }).interactions("gemini-3.8-flash") +const response = yield* LLM.generate({ + model, + prompt: "Say hello.", + providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto", store: true }, +}) +``` + +Interactions supports text output, streamed function calls, native tool results, thought signatures, and multimodal +input. Full-history replay is the default (`store: false`); implicit caching works without retained interactions. +For server-side continuation, set `store: true` on the predecessor, read `interactionId` from the final event's +`providerMetadata.google`, and pass `previousInteractionId` on the next request with **only new messages**. Repeat +the system instructions and tool declarations on each request. Set `store: true` on each response you intend to +continue from. The package does not automatically select or persist continuation IDs. + +Raw usage is preserved in `usage.providerMetadata.google`. `inputTokens` follows Google's top-level accounting; +`contextTokens` uses its full `raw_prompt_token` count when supplied. These can differ substantially with server-side +continuation. Explicit caches, hosted tools, and generated media are not supported by this initial protocol. + The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes: diff --git a/packages/ai/src/protocols/google-interactions.ts b/packages/ai/src/protocols/google-interactions.ts new file mode 100644 index 00000000000..4e74f0758e1 --- /dev/null +++ b/packages/ai/src/protocols/google-interactions.ts @@ -0,0 +1,561 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client.js" +import { Auth } from "../route/auth.js" +import { Endpoint } from "../route/endpoint.js" +import { Framing } from "../route/framing.js" +import { Protocol } from "../route/protocol.js" +import { + AIError, + LLMEvent, + ProviderID, + Usage, + type LLMRequest, + type ProviderMetadata, + type ToolResultPart, +} from "../schema/index.js" +import { Media } from "../media.js" +import { classifyProviderFailure, providerErrorMessage } from "../provider-error.js" +import { encodeJson } from "../utils/json.js" +import { JsonObject, knownString, lenient, optionalNull, ProviderShared } from "./shared.js" +import { Lifecycle } from "./utils/lifecycle.js" +import { MediaInput } from "./utils/media-input.js" +import { ToolStream } from "./utils/tool-stream.js" + +const ADAPTER = "google-interactions" +export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" + +// ============================================================================= +// Public Model Input +// ============================================================================= +const ThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">() +const Options = Schema.Struct({ + previousInteractionId: lenient(Schema.String), + store: lenient(Schema.Boolean), + thinkingLevel: lenient(ThinkingLevel), + thinkingSummaries: lenient(knownString<"auto" | "none">()), + serviceTier: lenient(knownString<"standard" | "flex" | "priority">()), +}) +export type OptionsInput = typeof Options.Encoded +export type ProviderOptionsInput = OptionsInput +const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options)) + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const Text = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }) +const MediaContent = Schema.Struct({ + type: Schema.Literals(["image", "audio", "video", "document"]), + data: Schema.optional(Schema.String), + uri: Schema.optional(Schema.String), + mime_type: Schema.String, +}) +const Content = Schema.Union([Text, MediaContent]) +const InputStep = Schema.Union([ + Schema.Struct({ type: Schema.Literals(["user_input", "model_output"]), content: Schema.Array(Content) }), + Schema.Struct({ + type: Schema.Literal("thought"), + signature: Schema.optional(Schema.String), + summary: Schema.optional(Schema.Array(Text)), + }), + Schema.Struct({ + type: Schema.Literal("function_call"), + id: Schema.String, + name: Schema.String, + arguments: Schema.Unknown, + signature: Schema.optional(Schema.String), + }), + Schema.Struct({ + type: Schema.Literal("function_result"), + call_id: Schema.String, + name: Schema.String, + result: Schema.Unknown, + is_error: Schema.optional(Schema.Boolean), + }), +]) +type InputStep = typeof InputStep.Type +const ToolChoice = Schema.Union([ + Schema.Literals(["auto", "any", "none"]), + Schema.Struct({ allowed_tools: Schema.Struct({ mode: Schema.Literal("any"), tools: Schema.Array(Schema.String) }) }), +]) +const Body = Schema.Struct({ + model: Schema.String, + input: Schema.Array(InputStep), + stream: Schema.Literal(true), + store: Schema.Boolean, + previous_interaction_id: Schema.optional(Schema.String), + system_instruction: Schema.optional(Schema.String), + service_tier: Schema.optional(Schema.String), + tools: Schema.optional( + Schema.Array( + Schema.Struct({ + type: Schema.Literal("function"), + name: Schema.String, + description: Schema.String, + parameters: JsonObject, + }), + ), + ), + generation_config: Schema.Struct({ + max_output_tokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), + seed: Schema.optional(Schema.Number), + stop_sequences: Schema.optional(Schema.Array(Schema.String)), + thinking_level: Schema.optional(ThinkingLevel), + thinking_summaries: Schema.optional(Schema.String), + tool_choice: Schema.optional(ToolChoice), + }), +}) + +// ============================================================================= +// Streaming Event Schema +// ============================================================================= +const RawUsage = Schema.StructWithRest( + Schema.Struct({ + total_input_tokens: optionalNull(Schema.Number), + total_cached_tokens: optionalNull(Schema.Number), + total_output_tokens: optionalNull(Schema.Number), + total_thought_tokens: optionalNull(Schema.Number), + total_tokens: optionalNull(Schema.Number), + raw_prompt_token: optionalNull(Schema.Number), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +type RawUsage = typeof RawUsage.Type +const OutputStep = Schema.Struct({ + type: Schema.String, + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + arguments: Schema.optional(JsonObject), + signature: Schema.optional(Schema.String), + summary: Schema.optional(Schema.Array(Text)), + content: Schema.optional(Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) }))), +}) +type OutputStep = typeof OutputStep.Type +const Delta = Schema.Union([ + Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("arguments_delta"), arguments: Schema.String }), + Schema.Struct({ type: Schema.Literal("thought_signature"), signature: Schema.String }), + Schema.Struct({ type: Schema.Literal("thought_summary"), content: Text }), + // Unknown output modalities must fail explicitly rather than disappearing from a successful response. + Schema.Struct({ type: Schema.String }), +]) +const Interaction = Schema.StructWithRest( + Schema.Struct({ + id: Schema.optional(Schema.String), + status: Schema.String, + usage: Schema.optional(RawUsage), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +const Event = Schema.Union([ + Schema.Struct({ event_type: Schema.Literal("step.start"), index: Schema.Number, step: OutputStep }), + Schema.Struct({ event_type: Schema.Literal("step.delta"), index: Schema.Number, delta: Delta }), + Schema.Struct({ event_type: Schema.Literal("step.stop"), index: Schema.Number }), + Schema.Struct({ + event_type: Schema.Literal("interaction.created"), + interaction: Schema.Struct({ id: Schema.optional(Schema.String) }), + }), + Schema.Struct({ + event_type: Schema.Literal("interaction.status_update"), + interaction_id: Schema.optional(Schema.String), + status: Schema.String, + }), + Schema.Struct({ event_type: Schema.Literal("interaction.completed"), interaction: Interaction }), + Schema.Struct({ event_type: Schema.Literal("error"), error: Schema.Unknown }), +]) +type Event = typeof Event.Type + +// ============================================================================= +// Parser State +// ============================================================================= +interface ParserState { + readonly route: string + readonly metadataKey: string + readonly lifecycle: Lifecycle.State + readonly steps: Partial> + readonly tools: ToolStream.State + readonly completed: boolean +} +type StepResult = readonly [ParserState, ReadonlyArray] + +// ============================================================================= +// Request Body Construction +// ============================================================================= +const mediaContent = Effect.fn("GoogleInteractions.mediaContent")(function* (asset: Media.Asset) { + if ( + asset.kind !== "image" && + asset.kind !== "audio" && + asset.kind !== "video" && + asset.mediaType !== "application/pdf" && + asset.mediaType !== "text/csv" + ) + return yield* ProviderShared.invalidRequest( + `Google Interactions does not support ${asset.mediaType} document input`, + ) + const type: (typeof MediaContent.Type)["type"] = + asset.kind === "image" || asset.kind === "audio" || asset.kind === "video" ? asset.kind : "document" + const uri = MediaInput.refID(asset, ProviderID.make("google")) + if (uri !== undefined) return { type, uri, mime_type: asset.mediaType } + const inline = yield* ProviderShared.requireInlineMedia("Google Interactions", asset) + return { type, data: inline.base64, mime_type: inline.mime } +}) + +const signature = (metadata: ProviderMetadata | undefined, key: string) => { + const value = metadata?.[key] + return ProviderShared.isRecord(value) && typeof value.interactionSignature === "string" + ? value.interactionSignature + : undefined +} + +const lowerMessages = Effect.fn("GoogleInteractions.lowerMessages")(function* (request: LLMRequest) { + const steps: InputStep[] = [] + const key = request.model.route.providerMetadataKey ?? String(request.model.provider) + for (const message of request.messages) { + if (message.role === "system") { + const part = yield* ProviderShared.wrappedSystemUpdate("Google Interactions", message) + steps.push({ type: "user_input", content: [{ type: "text", text: part.text }] }) + continue + } + const start = steps.length + // Consecutive ordinary content remains one native message; tools and thoughts retain their chronology. + const append = (content: typeof Content.Type) => { + const type = message.role === "assistant" ? "model_output" : "user_input" + const last = steps.at(-1) + if (steps.length > start && last?.type === type) + steps[steps.length - 1] = { type, content: [...last.content, content] } + else steps.push({ type, content: [content] }) + } + for (const part of message.content) { + if (message.role === "tool") { + if (part.type !== "tool-result") + return yield* ProviderShared.unsupportedContent(ADAPTER, "tool", ["tool-result"]) + steps.push({ + type: "function_result", + call_id: part.id, + name: part.name, + result: yield* lowerToolResult(part), + is_error: part.result.type === "error" || undefined, + }) + continue + } + if (part.type === "text") { + append({ type: "text", text: part.text }) + continue + } + if (part.type === "media") { + append(yield* mediaContent(part.media)) + continue + } + if (message.role === "assistant" && part.type === "reasoning") { + steps.push({ + type: "thought", + signature: signature(part.providerMetadata, key), + summary: part.text ? [{ type: "text", text: part.text }] : undefined, + }) + continue + } + if (message.role === "assistant" && part.type === "tool-call") { + steps.push({ + type: "function_call", + id: part.id, + name: part.name, + arguments: part.input, + signature: signature(part.providerMetadata, key), + }) + continue + } + return yield* ProviderShared.unsupportedContent( + ADAPTER, + message.role, + message.role === "user" ? ["text", "media"] : ["text", "media", "reasoning", "tool-call"], + ) + } + } + return steps +}) + +const lowerToolResult = Effect.fn("GoogleInteractions.lowerToolResult")(function* (part: ToolResultPart) { + if (part.result.type === "json" && ProviderShared.isRecord(part.result.value)) return part.result.value + if (part.result.type !== "content") return ProviderShared.toolResultText(part) + + return yield* Effect.forEach(part.result.value, (item): Effect.Effect => { + if (item.type === "text") return Effect.succeed({ type: "text", text: item.text }) + return mediaContent(ProviderShared.toolFileMedia(item).media) + }) +}) + +const fromRequest = Effect.fn("GoogleInteractions.fromRequest")(function* (request: LLMRequest) { + const options = yield* decodeOptions(request.providerOptions ?? {}) + const flattened = ProviderShared.flattenToolRequest(request) + if (flattened.tools.some((tool) => tool.native !== undefined)) + return yield* ProviderShared.invalidRequest("Google Interactions hosted tools are not supported") + if ( + request.generation?.topK !== undefined || + request.generation?.frequencyPenalty !== undefined || + request.generation?.presencePenalty !== undefined + ) + return yield* ProviderShared.invalidRequest( + "Google Interactions does not support topK, frequencyPenalty, or presencePenalty", + ) + const choice = + request.toolChoice === undefined + ? undefined + : yield* ProviderShared.matchToolChoice(ADAPTER, request.toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "any" as const, + tool: (name) => ({ allowed_tools: { mode: "any" as const, tools: [name] } }), + }) + return { + model: request.model.id, + input: yield* lowerMessages(flattened.request), + stream: true as const, + // Full-history replay need not create retained provider resources. Continuation callers opt in to storage. + store: options.store ?? false, + previous_interaction_id: options.previousInteractionId, + system_instruction: request.system.length ? ProviderShared.joinText(request.system) : undefined, + service_tier: options.serviceTier, + tools: flattened.tools.length + ? flattened.tools.map((tool) => ({ + type: "function" as const, + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + })) + : undefined, + generation_config: { + max_output_tokens: request.generation?.maxTokens, + temperature: request.generation?.temperature, + top_p: request.generation?.topP, + seed: request.generation?.seed, + stop_sequences: request.generation?.stop, + thinking_level: options.thinkingLevel, + thinking_summaries: options.thinkingSummaries, + tool_choice: choice, + }, + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +const metadata = (state: ParserState, step: OutputStep): ProviderMetadata => ({ + [state.metadataKey]: { interactionSignature: step.signature }, +}) +const mapUsage = (usage: RawUsage | undefined, key: string) => { + if (!usage) return undefined + const input = usage.total_input_tokens ?? undefined + const cached = usage.total_cached_tokens ?? undefined + const reasoning = usage.total_thought_tokens ?? undefined + const output = + usage.total_output_tokens === undefined || usage.total_output_tokens === null + ? undefined + : usage.total_output_tokens + (reasoning ?? 0) + return new Usage({ + contextTokens: usage.raw_prompt_token ?? input, + inputTokens: input, + outputTokens: output, + nonCachedInputTokens: ProviderShared.subtractTokens(input, cached), + cacheReadInputTokens: cached, + reasoningTokens: reasoning, + totalTokens: usage.total_tokens ?? undefined, + providerMetadata: { [key]: usage }, + }) +} + +const onStart = Effect.fn("GoogleInteractions.onStart")(function* ( + state: ParserState, + index: number, + step: OutputStep, +) { + const events: LLMEvent[] = [] + let lifecycle = Lifecycle.stepStart(state.lifecycle, events) + let tools = state.tools + const id = String(index) + if (step.type === "thought") { + lifecycle = Lifecycle.reasoningStart(lifecycle, events, id, metadata(state, step)) + for (const part of step.summary ?? []) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, id, part.text) + } else if (step.type === "model_output") { + lifecycle = Lifecycle.textStart(lifecycle, events, id) + for (const part of step.content ?? []) { + if (part.type !== "text") + return yield* ProviderShared.eventError( + ADAPTER, + `Unsupported Interactions output: ${part.type}`, + encodeJson(step), + ) + if (part.text) lifecycle = Lifecycle.textDelta(lifecycle, events, id, part.text) + } + } else if (step.type === "function_call") { + if (!step.id || !step.name) + return yield* ProviderShared.eventError(ADAPTER, "Interactions function call lacks id or name", encodeJson(step)) + tools = ToolStream.start(tools, index, { + id: step.id, + name: step.name, + providerMetadata: metadata(state, step), + input: step.arguments && Object.keys(step.arguments).length ? encodeJson(step.arguments) : "", + }) + events.push(LLMEvent.toolInputStart({ id: step.id, name: step.name, providerMetadata: metadata(state, step) })) + } else + return yield* ProviderShared.eventError(ADAPTER, `Unsupported Interactions step: ${step.type}`, encodeJson(step)) + return [{ ...state, lifecycle, tools, steps: { ...state.steps, [index]: step } }, events] satisfies StepResult +}) + +const onDelta = Effect.fn("GoogleInteractions.onDelta")(function* ( + state: ParserState, + index: number, + delta: typeof Delta.Type, +) { + const step = state.steps[index] + if (!step) + return yield* ProviderShared.eventError(ADAPTER, "Interactions delta without step.start", encodeJson(delta)) + const events: LLMEvent[] = [] + if (delta.type === "text" && "text" in delta && step.type === "model_output") + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, String(index), delta.text) }, + events, + ] satisfies StepResult + if (delta.type === "thought_summary" && "content" in delta && step.type === "thought") + return [ + { ...state, lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, String(index), delta.content.text) }, + events, + ] satisfies StepResult + if (delta.type === "thought_signature" && "signature" in delta) { + const next = { ...step, signature: delta.signature } + const tool = state.tools[index] + return [ + { + ...state, + steps: { ...state.steps, [index]: next }, + tools: tool ? { ...state.tools, [index]: { ...tool, providerMetadata: metadata(state, next) } } : state.tools, + }, + events, + ] satisfies StepResult + } + if (delta.type === "arguments_delta" && "arguments" in delta && step.type === "function_call") { + const result = ToolStream.appendExisting( + ADAPTER, + state.tools, + index, + delta.arguments, + "Interactions arguments without function call", + ) + if (ToolStream.isError(result)) return yield* result + return [{ ...state, tools: result.tools }, result.events] satisfies StepResult + } + return yield* ProviderShared.eventError(ADAPTER, `Unsupported Interactions delta: ${delta.type}`, encodeJson(delta)) +}) + +const onStop = Effect.fn("GoogleInteractions.onStop")(function* (state: ParserState, index: number) { + const step = state.steps[index] + if (!step) return yield* ProviderShared.eventError(ADAPTER, "Interactions step.stop without step.start") + const events: LLMEvent[] = [] + if (step.type === "thought") + return [ + { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, String(index), metadata(state, step)) }, + events, + ] satisfies StepResult + if (step.type === "model_output") + return [ + { ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, String(index)) }, + events, + ] satisfies StepResult + const result = yield* ToolStream.finish(ADAPTER, state.tools, index) + return [{ ...state, tools: result.tools }, result.events ?? []] satisfies StepResult +}) + +const step = Effect.fn("GoogleInteractions.step")(function* (state: ParserState, event: Event) { + switch (event.event_type) { + case "step.start": + return yield* onStart(state, event.index, event.step) + case "step.delta": + return yield* onDelta(state, event.index, event.delta) + case "step.stop": + return yield* onStop(state, event.index) + case "interaction.created": + case "interaction.status_update": + return [state, []] satisfies StepResult + case "error": + return yield* new AIError({ + reason: classifyProviderFailure({ + message: providerErrorMessage(encodeJson(event)) ?? "Google Interactions stream error", + data: event.error, + rawBody: encodeJson(event), + }), + }) + case "interaction.completed": { + const interaction = event.interaction + if (interaction.status === "failed" || interaction.status === "cancelled") + return yield* new AIError({ + reason: classifyProviderFailure({ + message: `Google Interactions ${interaction.status}`, + data: interaction, + rawBody: encodeJson(event), + }), + }) + if (!["completed", "requires_action", "incomplete"].includes(interaction.status)) + return yield* ProviderShared.eventError( + ADAPTER, + `Unexpected terminal Interactions status: ${interaction.status}`, + encodeJson(event), + ) + const pending = yield* ToolStream.finishAll(ADAPTER, state.tools) + const events = [...pending.events] + const lifecycle = Lifecycle.finish(state.lifecycle, events, { + reason: { + normalized: + interaction.status === "requires_action" + ? "tool-calls" + : interaction.status === "incomplete" + ? "length" + : "stop", + raw: interaction.status, + }, + usage: mapUsage(interaction.usage, state.metadataKey), + providerMetadata: { [state.metadataKey]: { interactionId: interaction.id } }, + }) + return [{ ...state, lifecycle, tools: pending.tools, completed: true }, events] satisfies StepResult + } + } +}) + +// ============================================================================= +// Protocol And Route +// ============================================================================= +export const protocol = Protocol.make({ + id: ADAPTER, + sanitizer: "gemini", + body: { schema: Body, from: fromRequest }, + stream: { + event: Protocol.jsonEvent(Event), + initial: (request): ParserState => ({ + route: `${request.model.provider}/${ADAPTER}`, + metadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider), + lifecycle: Lifecycle.initial(), + steps: {}, + tools: ToolStream.empty(), + completed: false, + }), + step, + terminal: (event) => event.event_type === "interaction.completed", + onHalt: (state) => + state.completed + ? Effect.succeed([]) + : Effect.fail( + ProviderShared.eventError(ADAPTER, "Google Interactions stream ended before interaction.completed"), + ), + }, +}) + +export const route = Route.make({ + id: ADAPTER, + provider: "google", + providerMetadataKey: "google", + protocol, + endpoint: Endpoint.path("/interactions", { baseURL: DEFAULT_BASE_URL }), + auth: Auth.none, + framing: Framing.sse, +}) + +export * as GoogleInteractions from "./google-interactions.js" diff --git a/packages/ai/src/protocols/index.ts b/packages/ai/src/protocols/index.ts index ece727e2abf..da27748dd84 100644 --- a/packages/ai/src/protocols/index.ts +++ b/packages/ai/src/protocols/index.ts @@ -2,6 +2,7 @@ export * as AnthropicMessages from "./anthropic-messages.js" export * as BedrockConverse from "./bedrock-converse.js" export * as CohereChat from "./cohere-chat.js" export * as Gemini from "./gemini.js" +export * as GoogleInteractions from "./google-interactions.js" export * as MistralChat from "./mistral-chat.js" export * as OpenAIChat from "./openai-chat.js" export * as OpenAIImages from "./openai-images.js" diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 6c697373484..579ba690e7b 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -5,6 +5,7 @@ import { MediaRoute } from "../route/media.js" import type { ProviderPackage } from "../provider-package.js" import { ProviderID, type ModelID } from "../schema/index.js" import { Gemini } from "../protocols/gemini.js" +import { GoogleInteractions } from "../protocols/google-interactions.js" import { GoogleImages } from "../protocols/google-images.js" import { GoogleSpeech } from "../protocols/google-speech.js" import { GoogleTranscription } from "../protocols/google-transcription.js" @@ -16,15 +17,16 @@ export type { GoogleTranscriptionOptions } from "../protocols/google-transcripti export type { GoogleVideoOptions } from "../protocols/google-video.js" export type GeminiOptionsInput = Gemini.OptionsInput export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput +export type GoogleInteractionsOptionsInput = GoogleInteractions.OptionsInput export const id = ProviderID.make("google") -export const routes = [Gemini.route] +export const routes = [Gemini.route, GoogleInteractions.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string - readonly providerOptions?: Gemini.ProviderOptionsInput + readonly providerOptions?: Gemini.ProviderOptionsInput & GoogleInteractions.ProviderOptionsInput } export type Settings = ProviderPackage.Settings & @@ -45,12 +47,19 @@ const configuredRoute = (input: Config) => { return Gemini.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) }) } +const interactionsRoute = (input: Config) => { + const { apiKey: _, auth: _auth, baseURL, ...rest } = input + return GoogleInteractions.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) }) +} + export const configure = (input: Config = {}) => { const route = configuredRoute(input) const media = MediaRoute.deployment(input, auth(input)) return { id, model: (modelID: string | ModelID) => route.model({ id: modelID }), + interactions: (modelID: string | ModelID) => + interactionsRoute(input).model({ id: modelID }), image: (modelID: string | ModelID) => GoogleImages.model({ ...media, id: modelID }), video: (modelID: string | ModelID) => GoogleVideo.model({ ...media, id: modelID }), speech: (modelID: string | ModelID) => GoogleSpeech.model({ ...media, id: modelID }), @@ -73,6 +82,7 @@ export const model: ProviderPackage.Definition["model"] = ( + modelID, + { apiKey, baseURL, body, headers, ...providerOptions }, +) => + configure({ + apiKey, + baseURL, + headers: headers === undefined ? undefined : { ...headers }, + http: body === undefined ? undefined : { body: { ...body } }, + providerOptions, + }).interactions(modelID) diff --git a/packages/ai/test/fixtures/recordings/google-interactions/continues-tool-results-with-previous-interaction-id.json b/packages/ai/test/fixtures/recordings/google-interactions/continues-tool-results-with-previous-interaction-id.json new file mode 100644 index 00000000000..12ca424defc --- /dev/null +++ b/packages/ai/test/fixtures/recordings/google-interactions/continues-tool-results-with-previous-interaction-id.json @@ -0,0 +1,66 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:google-interactions", + "provider:google", + "protocol:google-interactions" + ], + "name": "google-interactions/continues-tool-results-with-previous-interaction-id", + "recordedAt": "2026-10-03T18:10:17.900Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/interactions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"store\":true,\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"tool_choice\":\"any\"}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "event: interaction.created\ndata: {\"interaction\":{\"id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"interaction_id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EoACCv0BAWkUfRMR9cx79IeL9V4HvDAHann4OEdOjz7FebGRwlCFGJHqLGG1lG1d+9xzzyPXN0d60gjMwbeix1bE4cCckDeMV0vAkOfR/epPhvmcfI+vWK1nURu+3UcgUBQ9on4VlZFcnOg7SoihuvxuQB4qqT65EAyqi8ybd3P8NB+V/FNcTBvMmJuNNCNxI5KgwsHjdT+xLdssTieljIG3H99rSz3/Nv90dYEJIr0WsQ1a3ZevpD6gPts/8Id/Z92zQ3aq4LAr47rdItCVF5h63Lchi9QFrav5LGf6lNVAOnlHIyXBrlAnVbdLsAdzlemlHY+oIWNMncL/NKayCt+qvQ==\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"id\":\"call_326996\",\"signature\":\"AWkUfRMR9cx79IeL9V4HvDAHann4OEdOjz7FebGRwlCFGJHqLGG1lG1d+9xzzyPXN0d60gjMwbeix1bE4cCckDeMV0vAkOfR/epPhvmcfI+vWK1nURu+3UcgUBQ9on4VlZFcnOg7SoihuvxuQB4qqT65EAyqi8ybd3P8NB+V/FNcTBvMmJuNNCNxI5KgwsHjdT+xLdssTieljIG3H99rSz3/Nv90dYEJIr0WsQ1a3ZevpD6gPts/8Id/Z92zQ3aq4LAr47rdItCVF5h63Lchi9QFrav5LGf6lNVAOnlHIyXBrlAnVbdLsAdzlemlHY+oIWNMncL/NKayCt+qvQ==\",\"type\":\"function_call\",\"name\":\"get_weather\",\"arguments\":{}},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"type\":\"arguments_delta\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"status\":\"requires_action\",\"usage\":{\"total_tokens\":114,\"total_input_tokens\":68,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":68}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":30,\"raw_prompt_token\":107,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}],\"thoughts_tokens_details\":[{\"modality\":\"text\",\"tokens\":30}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}],\"thoughts_tokens_details\":[{\"modality\":\"text\",\"tokens\":30}]}]},\"created\":\"2026-10-03T18:10:16Z\",\"updated\":\"2026-10-03T18:10:16Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/interactions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"function_result\",\"call_id\":\"call_326996\",\"name\":\"get_weather\",\"result\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"stream\":true,\"store\":false,\"previous_interaction_id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"tool_choice\":\"none\"}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TchP60hnCGy9QgdyJKikXfeP9DBx/BVTE92JiMUcdYnAQ7vGOt2zL8v0GQMbCshndVE0+WeHt0QXyzeeMeNFH/UQ5bxo3Vs+T3H1e6Fq3LFIjDuvYvMYMRLk44oULO1EhoCg=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"type\":\"model_output\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"The weather in Paris\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" is sunny with a temperature of 22°C.\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"completed\",\"usage\":{\"total_tokens\":115,\"total_input_tokens\":99,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":99}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":134,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":134}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":134}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}]},\"created\":\"2026-10-03T18:10:17Z\",\"updated\":\"2026-10-03T18:10:17Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "DELETE", + "url": "https://generativelanguage.googleapis.com/v1beta/interactions/v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE", + "headers": {}, + "body": "" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{}" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/google-interactions/replays-native-tool-results-and-signatures-statelessly.json b/packages/ai/test/fixtures/recordings/google-interactions/replays-native-tool-results-and-signatures-statelessly.json new file mode 100644 index 00000000000..91686b7d606 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/google-interactions/replays-native-tool-results-and-signatures-statelessly.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:google-interactions", + "provider:google", + "protocol:google-interactions" + ], + "name": "google-interactions/replays-native-tool-results-and-signatures-statelessly", + "recordedAt": "2026-10-03T18:10:15.040Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/interactions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"store\":false,\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"thinking_summaries\":\"auto\",\"tool_choice\":{\"allowed_tools\":{\"mode\":\"any\",\"tools\":[\"get_weather\"]}}}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TU3VE2ypZTy8W0/W4ikeJNHd8J/UqUDCBIILV/U6sCJUEUat3oneLPVOSvuy+mLi2ByVh2ZlkcxEiY4DsOcsHKSF6q1WBk9YuCqE+W8BMNjZoRwBQeyDeq6cJpXDVkFtBYbg=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"id\":\"call_1850980\",\"signature\":\"AWkUfRNTdUTbKllPLxbT9biKR4k0d3wn9SpQMIEggtX9TqwIlQRRq3eid4s9U5K+7L6YuLYHJWHZmWRzESJjgOw5ywcpIXqrVYGT1i4KoT5bwEw2NmhHAFB7IN6rpwmlcNWQW0FhuA==\",\"type\":\"function_call\",\"name\":\"get_weather\",\"arguments\":{}},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"type\":\"arguments_delta\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"requires_action\",\"usage\":{\"total_tokens\":84,\"total_input_tokens\":68,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":68}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":107,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}]}]},\"created\":\"2026-10-03T18:10:14Z\",\"updated\":\"2026-10-03T18:10:14Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/interactions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"thought\",\"signature\":\"EmkKZwFpFH0TU3VE2ypZTy8W0/W4ikeJNHd8J/UqUDCBIILV/U6sCJUEUat3oneLPVOSvuy+mLi2ByVh2ZlkcxEiY4DsOcsHKSF6q1WBk9YuCqE+W8BMNjZoRwBQeyDeq6cJpXDVkFtBYbg=\"},{\"type\":\"function_call\",\"id\":\"call_1850980\",\"name\":\"get_weather\",\"arguments\":{\"city\":\"Paris\"},\"signature\":\"AWkUfRNTdUTbKllPLxbT9biKR4k0d3wn9SpQMIEggtX9TqwIlQRRq3eid4s9U5K+7L6YuLYHJWHZmWRzESJjgOw5ywcpIXqrVYGT1i4KoT5bwEw2NmhHAFB7IN6rpwmlcNWQW0FhuA==\"},{\"type\":\"function_result\",\"call_id\":\"call_1850980\",\"name\":\"get_weather\",\"result\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"stream\":true,\"store\":false,\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"thinking_summaries\":\"auto\",\"tool_choice\":\"none\"}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TRPN/gVeX86bn3+mP2Y1jv3jX464uSnSB+RuBqyig1Xfg52RBzk5GzUt98niutpbttYfMLkXURehjrP1AJUJzQbGLMqmqKD5iEp3bgLq46B3Ov+RcO539Fcz6+qCpPx+VaMQ=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"type\":\"model_output\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"The weather in Paris\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" is sunny with a temperature of 22°C.\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"completed\",\"usage\":{\"total_tokens\":108,\"total_input_tokens\":92,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":92}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":104,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":104}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":104}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}]},\"created\":\"2026-10-03T18:10:15Z\",\"updated\":\"2026-10-03T18:10:15Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/google-interactions/streams-reasoning-and-retains-thought-signatures.json b/packages/ai/test/fixtures/recordings/google-interactions/streams-reasoning-and-retains-thought-signatures.json new file mode 100644 index 00000000000..e2c07cfa97a --- /dev/null +++ b/packages/ai/test/fixtures/recordings/google-interactions/streams-reasoning-and-retains-thought-signatures.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:google-interactions", + "provider:google", + "protocol:google-interactions" + ], + "name": "google-interactions/streams-reasoning-and-retains-thought-signatures", + "recordedAt": "2026-10-03T18:08:37.674Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/interactions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"Find the smallest positive integer that leaves remainder 1 modulo 7, 2 modulo 9, and 3 modulo 11. Explain briefly.\"}]}],\"stream\":true,\"store\":false,\"generation_config\":{\"max_output_tokens\":4096,\"thinking_level\":\"high\",\"thinking_summaries\":\"auto\"}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"content\":{\"text\":\"**Analyzing Congruences**\\n\\nI'm tackling a system of congruences to find the smallest positive integer x. Currently, I'm reviewing the Chinese Remainder Theorem as a potential solution, or considering a direct congruence-based approach. The congruences are as follows: $x \\\\equiv 1 \\\\pmod{7}$, $x \\\\equiv 2 \\\\pmod{9}$, $x \\\\equiv 3 \\\\pmod{11}$. I will use these congruences to find a solution.\\n\\n\\n\",\"type\":\"text\"},\"type\":\"thought_summary\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"content\":{\"text\":\"**Combining Congruences**\\n\\nI've made progress by combining the first two congruences. I determined that x is congruent to 29 modulo 63. Now, I am focused on the remaining congruence, attempting to solve for k in the expression 63k + 29 ≡ 3 (mod 11). I simplified the expression to 8k ≡ 7 (mod 11) and am looking to multiply both sides to eliminate the coefficient of k.\\n\\n\\n\",\"type\":\"text\"},\"type\":\"thought_summary\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"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\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"type\":\"model_output\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"To find the smallest positive integer $x$, we set up the system of congruences:\\n1. $x \\\\equiv 1\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" \\\\pmod 7$\\n2. $x \\\\equiv 2 \\\\pmod 9$\\n3. $x\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" \\\\equiv 3 \\\\pmod{11}$\\n\\n**Step 1: Combine the first two congruences** \\nFrom (\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"2), the values of $x$ are $2, 11, 20, 29,\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" \\\\dots$ \\nTesting these modulo $7$:\\n* $29 = 4 \\\\times 7 + \",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"1 \\\\equiv 1 \\\\pmod 7$\\n\\nThus, $x \\\\equiv 29 \\\\pmod{\\\\text\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"{lcm}(7, 9)}$, so:\\n$$x \\\\equiv 29 \\\\pmod{63}$$\\n\\n**Step \",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"2: Combine with the third congruence** \\nWrite $x = 63k + 29$ and substitute\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" it into (3):\\n$$63k + 29 \\\\equiv 3 \\\\pmod{11}$$\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"\\n$$8k + 7 \\\\equiv 3 \\\\pmod{11}$$\\n$$8k \\\\equiv -4\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" \\\\equiv 7 \\\\pmod{11}$$\\n\\nSince $-3 \\\\equiv 8 \\\\pmod{11}$, we can rewrite\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" this as:\\n$$-3k \\\\equiv -4 \\\\pmod{11} \\\\implies 3k \\\\equiv\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" 4 \\\\equiv 15 \\\\pmod{11} \\\\implies k \\\\equiv 5 \\\\pmod{11}$$\\n\\n**\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"Step 3: Calculate the smallest positive integer** \\nThe smallest non-negative integer is $k = 5$:\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"\\n$$x = 63(5) + 29 = 315 + 29 =\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" 344$$\\n\\nThe smallest positive integer is **344**.\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"completed\",\"usage\":{\"total_tokens\":1707,\"total_input_tokens\":32,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":32}],\"total_cached_tokens\":0,\"total_output_tokens\":399,\"total_tool_use_tokens\":0,\"total_thought_tokens\":1276,\"raw_prompt_token\":63,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":63}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":403}],\"thoughts_tokens_details\":[{\"modality\":\"text\",\"tokens\":1276}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":63}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":403}],\"thoughts_tokens_details\":[{\"modality\":\"text\",\"tokens\":1276}]}]},\"created\":\"2026-10-03T18:08:37Z\",\"updated\":\"2026-10-03T18:08:37Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/google-interactions/streams-text-and-reports-usage.json b/packages/ai/test/fixtures/recordings/google-interactions/streams-text-and-reports-usage.json new file mode 100644 index 00000000000..ca8c0a07ed8 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/google-interactions/streams-text-and-reports-usage.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:google-interactions", + "provider:google", + "protocol:google-interactions" + ], + "name": "google-interactions/streams-text-and-reports-usage", + "recordedAt": "2026-10-03T18:08:32.334Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/interactions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"Reply with exactly one word: hello\"}]}],\"stream\":true,\"store\":false,\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\"}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TKZDuA7i8rhztLxjVI9+u+bpJI/x6/j60nie/dFgj5TyzAJCDDZQYfmeXEBrujJcdKT5FiKPuuMYWq1shTTILJ1JQpG2WkGAk6rAxxgxp7Ac0+rporJ7knHFF/jVWQD+AYGY=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"type\":\"model_output\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"hello\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"completed\",\"usage\":{\"total_tokens\":9,\"total_input_tokens\":8,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":8}],\"total_cached_tokens\":0,\"total_output_tokens\":1,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":39,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":39}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":5}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":39}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":5}]}]},\"created\":\"2026-10-03T18:08:32Z\",\"updated\":\"2026-10-03T18:08:32Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index 90987a95b98..8050ad772bb 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -430,6 +430,7 @@ describe("provider package entrypoints", () => { test("maps Google package settings onto the Gemini model", async () => { const Google = await import("@opencode/ai/providers/google") + const GoogleInteractions = await import("@opencode/ai/providers/google/interactions") const selected = Google.model("gemini-2.5-flash", { apiKey: "fixture", baseURL: "https://generativelanguage.test/v1beta", @@ -443,6 +444,16 @@ describe("provider package entrypoints", () => { expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] }) expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } }) + const interactions = GoogleInteractions.model("gemini-3.8-flash", { + apiKey: "fixture", + baseURL: "https://generativelanguage.test/v1beta", + thinkingLevel: "low", + store: true, + }) + expect(interactions.route.id).toBe("google-interactions") + expect(interactions.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta") + expect(interactions.route.defaults.providerOptions).toEqual({ thinkingLevel: "low", store: true }) + expect(Google.configure().interactions("gemini-3.8-flash").route.protocol).toBe("google-interactions") }) test("selects Vertex entrypoints with the same model contract", async () => { diff --git a/packages/ai/test/provider/google-interactions.recorded.test.ts b/packages/ai/test/provider/google-interactions.recorded.test.ts new file mode 100644 index 00000000000..4a4a33e5e08 --- /dev/null +++ b/packages/ai/test/provider/google-interactions.recorded.test.ts @@ -0,0 +1,169 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, LLMEvent, Message } from "../../src/index.js" +import { Google } from "../../src/providers.js" +import { LLMClient, RequestExecutor } from "../../src/route.js" +import { recordedTests } from "../recorded-test.js" +import { weatherTool } from "../recorded-scenarios.js" + +const model = Google.configure({ apiKey: process.env.GEMINI_API_KEY ?? "fixture" }).interactions("gemini-3.8-flash") +const recorded = recordedTests({ + prefix: "google-interactions", + provider: "google", + protocol: "google-interactions", + requires: ["GEMINI_API_KEY"], +}) +const InteractionMetadata = Schema.Struct({ interactionId: Schema.String }) + +describe("Google Interactions recorded", () => { + recorded.effect("streams text and reports usage", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model, + prompt: "Reply with exactly one word: hello", + generation: { maxTokens: 2048 }, + providerOptions: { thinkingLevel: "low" }, + }), + ) + + expect(response.text.trim().toLowerCase()).toBe("hello") + expect(response.events.some(LLMEvent.is.textDelta)).toBe(true) + expect(response.finishReason.normalized).toBe("stop") + expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1) + expect(response.usage?.inputTokens).toBeGreaterThan(0) + expect(response.usage?.contextTokens).toBeGreaterThan(0) + expect(response.usage?.providerMetadata?.google).toMatchObject({ total_input_tokens: expect.any(Number) }) + }), + ) + + recorded.effect( + "streams reasoning and retains thought signatures", + () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model, + prompt: + "Find the smallest positive integer that leaves remainder 1 modulo 7, 2 modulo 9, and 3 modulo 11. Explain briefly.", + generation: { maxTokens: 4096 }, + providerOptions: { thinkingLevel: "high", thinkingSummaries: "auto" }, + }), + ) + + expect(response.reasoning.length).toBeGreaterThan(0) + expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true) + expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + providerMetadata: { google: { interactionSignature: expect.any(String) } }, + }), + ]), + ) + expect(response.text.length).toBeGreaterThan(0) + expect(response.finishReason.normalized).toBe("stop") + }), + 120_000, + ) + + recorded.effect( + "replays native tool results and signatures statelessly", + () => + Effect.gen(function* () { + const request = LLM.request({ + model, + system: "Use get_weather for weather questions. Answer concisely after receiving the result.", + prompt: "What is the weather in Paris?", + tools: [weatherTool], + toolChoice: { type: "tool", name: weatherTool.name }, + generation: { maxTokens: 2048 }, + providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto" }, + }) + const first = yield* LLMClient.generate(request) + + expect(first.toolCalls).toHaveLength(1) + expect(first.events.some(LLMEvent.is.toolInputDelta)).toBe(true) + expect(first.finishReason.normalized).toBe("tool-calls") + const call = first.toolCalls[0] + if (!call) throw new Error("Missing recorded weather tool call") + expect(call.name).toBe(weatherTool.name) + expect(call.input).toEqual({ city: "Paris" }) + expect(call.providerMetadata?.google).toHaveProperty("interactionSignature") + const second = yield* LLMClient.generate( + LLM.request({ + model, + system: request.system, + tools: [weatherTool], + toolChoice: "none", + generation: { maxTokens: 2048 }, + providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto" }, + messages: [ + ...request.messages, + first.message, + Message.tool({ id: call.id, name: call.name, result: { temperature: 22, condition: "sunny" } }), + ], + }), + ) + + expect(second.text.toLowerCase()).toContain("sunny") + expect(second.text).toContain("22") + expect(second.toolCalls).toHaveLength(0) + expect(second.finishReason.normalized).toBe("stop") + }), + 120_000, + ) + + recorded.effect( + "continues tool results with previous interaction id", + () => + Effect.gen(function* () { + const request = LLM.request({ + model, + system: "Use get_weather for weather questions. Answer concisely after receiving the result.", + prompt: "What is the weather in Paris?", + tools: [weatherTool], + toolChoice: "required", + generation: { maxTokens: 2048 }, + providerOptions: { thinkingLevel: "low", store: true }, + }) + const first = yield* LLMClient.generate(request) + const metadata = yield* Schema.decodeUnknownEffect(InteractionMetadata)( + first.events.find(LLMEvent.is.finish)?.providerMetadata?.google, + ) + const executor = yield* RequestExecutor.Service + const cleanup = executor + .execute( + HttpClientRequest.delete( + `https://generativelanguage.googleapis.com/v1beta/interactions/${metadata.interactionId}`, + ).pipe(HttpClientRequest.setHeader("x-goog-api-key", process.env.GEMINI_API_KEY ?? "fixture")), + ) + .pipe(Effect.orDie) + + yield* Effect.gen(function* () { + expect(first.toolCalls).toHaveLength(1) + const call = first.toolCalls[0] + if (!call) throw new Error("Missing recorded weather tool call") + const second = yield* LLMClient.generate( + LLM.request({ + model, + system: request.system, + tools: [weatherTool], + toolChoice: "none", + generation: { maxTokens: 2048 }, + providerOptions: { thinkingLevel: "low", previousInteractionId: metadata.interactionId, store: false }, + messages: [ + Message.tool({ id: call.id, name: call.name, result: { temperature: 22, condition: "sunny" } }), + ], + }), + ) + + expect(second.text.toLowerCase()).toContain("sunny") + expect(second.text).toContain("22") + expect(second.toolCalls).toHaveLength(0) + expect(second.finishReason.normalized).toBe("stop") + }).pipe(Effect.ensuring(cleanup)) + }), + 120_000, + ) +}) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 63a5cc0f2e1..aa111cb0f5f 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -453,6 +453,7 @@ function usesAPIKeyAuth(packageName: string | undefined) { name === "@opencode/ai/providers/fireworks" || name === "@opencode/ai/providers/openai-compatible" || name === "@opencode/ai/providers/google" || + name === "@opencode/ai/providers/google/interactions" || name === "@opencode/ai/providers/groq" || name === "@opencode/ai/providers/mistral" || name === "@opencode/ai/providers/togetherai" || diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index c03bfd6c58c..c05bc50ea03 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -74,6 +74,7 @@ const builtins = new Map Promise>([ ["@opencode/ai/providers/digitalocean", () => import("@opencode/ai/providers/digitalocean")], ["@opencode/ai/providers/fireworks", () => import("@opencode/ai/providers/fireworks")], ["@opencode/ai/providers/google", () => import("@opencode/ai/providers/google")], + ["@opencode/ai/providers/google/interactions", () => import("@opencode/ai/providers/google/interactions")], ["@opencode/ai/providers/google-vertex", () => import("@opencode/ai/providers/google-vertex")], ["@opencode/ai/providers/google-vertex/gemini", () => import("@opencode/ai/providers/google-vertex/gemini")], ["@opencode/ai/providers/google-vertex/chat", () => import("@opencode/ai/providers/google-vertex/chat")],