Compare commits

..
22 changed files with 554 additions and 992 deletions

No files matched your search

+2 -2
View File
@@ -365,7 +365,7 @@
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
"bun-pty": "0.4.8",
"bun-pty": "0.4.9",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -3547,7 +3547,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-pty": ["bun-pty@0.4.9", "", {}, "sha512-IUF/B3FANo8vIQ775Zt7Er7lphMpYMhLkes45am2WE8FaVI7KRYtj1rQwliZ18b6GpNzBNWcl7sz9QT5wDFBeQ=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-DgZ5YAuYTn2y3E/o0rR73qBonKRidkckIYPIOEiTWGs=",
"aarch64-linux": "sha256-tdqmOYBL6PYL0421zgiOSDJ5NB4xWnR8on/mJ7Tk/5A=",
"aarch64-darwin": "sha256-pu/XstAefnlKDgt1WG8O++JZqymzUFx5ZpskU/2ohMg="
"x86_64-linux": "sha256-onbxt1PCyU4Nh14S5gIeLH20OtbQ7v2ipRq1zqZkLWs=",
"aarch64-linux": "sha256-P7DAE018lTJNGmttg87U9oaTAuw/wHlnMStdnXYKCr0=",
"aarch64-darwin": "sha256-N+NfV1ObOTnW+ez7As+CS+6ci/iRGCQ6cslvyQFCp6E="
}
}
-25
View File
@@ -27,31 +27,6 @@ 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:
@@ -1,555 +0,0 @@
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 } 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<Record<number, OutputStep>>
readonly tools: ToolStream.State<number>
readonly completed: boolean
}
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
// =============================================================================
// 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"])
const result =
part.result.type === "content"
? yield* Effect.forEach(
part.result.value,
(item): Effect.Effect<typeof Content.Type, AIError> =>
item.type === "text"
? Effect.succeed({ type: "text", text: item.text })
: mediaContent(ProviderShared.toolFileMedia(item).media),
)
: part.result.type === "json" && ProviderShared.isRecord(part.result.value)
? part.result.value
: ProviderShared.toolResultText(part)
steps.push({
type: "function_result",
call_id: part.id,
name: part.name,
result,
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 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<number>(),
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"
-1
View File
@@ -2,7 +2,6 @@ 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"
+2 -12
View File
@@ -5,7 +5,6 @@ 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"
@@ -17,16 +16,15 @@ 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, GoogleInteractions.route]
export const routes = [Gemini.route]
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: Gemini.ProviderOptionsInput & GoogleInteractions.ProviderOptionsInput
readonly providerOptions?: Gemini.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
@@ -47,19 +45,12 @@ 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<Gemini.ProviderOptionsInput>({ id: modelID }),
interactions: (modelID: string | ModelID) =>
interactionsRoute(input).model<GoogleInteractions.ProviderOptionsInput>({ 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 }),
@@ -82,7 +73,6 @@ export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsI
}).model(modelID)
export const image = provider.image
export const interactions = provider.interactions
export const video = provider.video
export const speech = provider.speech
export const transcription = provider.transcription
@@ -1,21 +0,0 @@
import { configure } from "../google.js"
import type { ProviderPackage } from "../../provider-package.js"
import type { GoogleInteractions } from "../../protocols/google-interactions.js"
export type Settings = ProviderPackage.Settings &
GoogleInteractions.ProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const model: ProviderPackage.Definition<Settings, GoogleInteractions.ProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).interactions(modelID)
@@ -1,66 +0,0 @@
{
"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": "{}"
}
}
]
}
@@ -1,50 +0,0 @@
{
"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"
}
}
]
}
@@ -1,32 +0,0 @@
{
"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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 truncated
}
}
]
}
@@ -1,32 +0,0 @@
{
"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"
}
}
]
}
-11
View File
@@ -430,7 +430,6 @@ 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",
@@ -444,16 +443,6 @@ 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 () => {
@@ -1,169 +0,0 @@
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,
)
})
+1 -1
View File
@@ -116,7 +116,7 @@
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
"bun-pty": "0.4.8",
"bun-pty": "0.4.9",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
-1
View File
@@ -453,7 +453,6 @@ 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" ||
-1
View File
@@ -74,7 +74,6 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@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")],
+23
View File
@@ -361,11 +361,34 @@ export interface DialogSelectOption<Value> {
readonly disabled?: boolean
}
export type DialogSelectShortcut<Value> = {
readonly key: string
readonly title: string
readonly side?: "left" | "right"
} & (
| {
/** Unavailable while no option is selected. */
readonly selection?: "required"
readonly onTrigger: (value: Value) => void
}
| {
readonly selection: "none"
readonly onTrigger: () => void
}
)
export interface DialogSelectOptions<Value> {
readonly title: string
readonly placeholder?: string
readonly options: readonly DialogSelectOption<Value>[]
readonly current?: Value
/** Returns matching options in display order, including for an empty query. Receives only enabled options. Results are listed flat. */
readonly search?: (
query: string,
options: readonly DialogSelectOption<Value>[],
) => readonly DialogSelectOption<Value>[]
/** Modal shortcuts shown in the footer. Triggering one does not close the dialog. */
readonly shortcuts?: readonly DialogSelectShortcut<Value>[]
}
export interface Dialog {
+36 -6
View File
@@ -1,6 +1,15 @@
import { PluginContextProvider } from "@opencode/plugin/tui"
import type { JSX } from "solid-js"
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode/plugin/tui/context"
import { createUniqueId, type JSX } from "solid-js"
import type {
Context,
Dialog,
DialogSelectOptions,
Page,
SlotClaim,
SlotMap,
SlotPath,
Toast,
} from "@opencode/plugin/tui/context"
import type { Placement, PlacementKind } from "./structure"
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
@@ -356,16 +365,37 @@ export function createDialogApi(
)
})
},
select(options) {
return new Promise((resolve) => {
const done = settle<(typeof options.options)[number]["value"] | undefined>(resolve)
select<Value>(options: DialogSelectOptions<Value>) {
return new Promise<Value | undefined>((resolve) => {
const done = settle<Value | undefined>(resolve)
const search = options.search
const id = createUniqueId()
api.show(
() => (
<DialogSelect
<DialogSelect<Value>
title={options.title}
placeholder={options.placeholder}
options={options.options.map((option) => ({ ...option }))}
current={options.current}
search={
search &&
((query) =>
search(
query,
options.options.filter((option) => !option.disabled),
))
}
actions={options.shortcuts?.map((shortcut, index) => {
const action = {
command: `plugin.dialog.select.${id}.${index}`,
title: shortcut.title,
side: shortcut.side,
bind: shortcut.key,
}
if (shortcut.selection === "none")
return { ...action, selection: shortcut.selection, onTrigger: shortcut.onTrigger }
return { ...action, onTrigger: (option) => shortcut.onTrigger(option.value) }
})}
onSelect={(option) => {
done(option.value)
api.clear()
+38 -2
View File
@@ -29,6 +29,7 @@ export interface DialogSelectProps<T> {
onSelect?: (option: DialogSelectOption<T>) => void
onCancel?: () => void
skipFilter?: boolean
search?: (query: string) => readonly DialogSelectOption<T>[]
renderFilter?: boolean
locked?: boolean
preserveSelection?: boolean
@@ -48,6 +49,7 @@ export interface DialogSelectProps<T> {
type DialogSelectActionBase<T> = {
command: string
title: string
bind?: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
@@ -136,7 +138,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
[() => props.focusTarget ?? props.current, () => (props.focusTarget === undefined ? undefined : flat())],
([current]) => {
if (props.focusCurrent === false) return
if (props.search || props.focusCurrent === false) return
if (props.focusTarget !== undefined && (props.preserveSelection || store.filter.length > 0)) return
if (current !== undefined) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
@@ -183,6 +185,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
const filtered = createMemo(() => {
if (props.search) return props.search(store.filter).filter((x) => x.disabled !== true)
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
const options = pipe(
@@ -209,7 +212,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setFocusedAction(undefined)
})
const flatten = createMemo(() => props.flat && store.filter.length > 0)
const flatten = createMemo(() => props.search !== undefined || (props.flat && store.filter.length > 0))
const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => {
if (flatten()) return filtered().length ? [["", filtered()]] : []
@@ -246,6 +249,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
() => props.options,
() => {
if (props.search) return
if (
!props.preserveSelection &&
((props.focusTarget ?? props.current) === undefined || props.focusCurrent === false)
@@ -302,6 +306,36 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
},
),
)
createEffect(
on(
[
flat,
() => store.filter,
() => (props.focusCurrent === false ? undefined : (props.focusTarget ?? props.current)),
],
([options, query, current], previous) => {
if (!props.search) return
const queried = previous !== undefined && query !== previous[1]
const focused = current !== undefined && (previous === undefined || !isDeepEqual(current, previous[2]))
selection = intent()
const kept = selection
const index = kept ? options.findIndex((option) => isDeepEqual(option.value, kept.value)) : -1
const next = index >= 0 ? index : reconcileSelection(store.selected, options.length)
const option = options[next]
if (!option) return
setStore("selected", next)
selection = option
scrollAfterLayout(queried || focused, option.value)
function intent() {
if (queried && query) return options[0]
if ((queried || focused) && current !== undefined) return { value: current }
if (queried) return options[reconcileSelection(store.selected, options.length)]
return selection
}
},
),
)
onCleanup(() => {
if (!pendingScroll) return
renderer.off(CliRenderEvents.FRAME, pendingScroll)
@@ -310,6 +344,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
createEffect(
on([() => store.filter, () => props.focusTarget ?? props.current], ([filter, current]) => {
if (props.search) return
if (filter.length > 0) resetSelection = true
if (filter.length > 0) {
const option = flat()[0]
@@ -476,6 +511,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
id: item.command,
title: item.title,
group: "Dialog",
bind: item.bind,
run: () => trigger(item),
})),
...(visible.length
@@ -4,7 +4,7 @@ import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { createSignal, onCleanup, onMount } from "solid-js"
import { batch, createSignal, onCleanup, onMount } from "solid-js"
import { dialogWidth } from "../../../src/ui/dialog"
import {
dialogSelectContentWidth,
@@ -91,7 +91,10 @@ async function mountSelect<T>(
initial: DialogSelectOption<T>[],
current?: T,
focusCurrent?: boolean,
select?: Pick<DialogSelectProps<T>, "flat" | "ref" | "onFilter" | "renderFilter" | "onCancel" | "focusTarget">,
select?: Pick<
DialogSelectProps<T>,
"flat" | "ref" | "onFilter" | "renderFilter" | "onCancel" | "focusTarget" | "search"
>,
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -522,6 +525,27 @@ test("keeps the current option selected when options reorder", async () => {
}
})
test("custom search follows a current change batched with reordered results", async () => {
await using tmp = await tmpdir()
const options = ["alpha", "beta", "gamma"].map((value) => ({ title: value, value }))
const [results, setResults] = createSignal(options)
const select = await mountSelect(tmp.path, options, "alpha", undefined, { search: () => results() })
try {
batch(() => {
select.replaceCurrent("gamma")
setResults(options.toReversed())
})
await select.app.waitForFrame((frame) => frame.indexOf("gamma") < frame.indexOf("alpha"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["gamma"])
} finally {
select.app.renderer.destroy()
}
})
test.each([false, 0, "", null, "current", undefined])("focuses current %p when it changes", async (current) => {
await using tmp = await tmpdir()
const select = await mountSelect<string | typeof current>(
+385
View File
@@ -0,0 +1,385 @@
import { InputRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import type { Dialog } from "@opencode/plugin/tui/context"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../src/config"
import { Keymap } from "../src/context/keymap"
import { ThemeProvider } from "../src/context/theme"
import { createDialogApi } from "../src/plugin/api"
import { DialogProvider, useDialog } from "../src/ui/dialog"
import { ToastProvider } from "../src/ui/toast"
import { emptyThemeSource, tmpdir } from "./fixture/fixture"
import { TestTuiContexts } from "./fixture/tui-environment"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
async function mount(root: string) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const dialog = Promise.withResolvers<Dialog>()
function Fixture() {
dialog.resolve(createDialogApi(useDialog(), (render) => render()))
return null
}
const app = await testRender(
() => (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Fixture />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 80, height: 24, kittyKeyboard: true },
)
app.renderer.start()
return { app, dialog: await dialog.promise }
}
async function opened(app: Awaited<ReturnType<typeof mount>>["app"], title: string) {
await app.waitForFrame((frame) => frame.includes(title))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
}
function rows(frame: string, titles: string[]) {
return titles.toSorted((left, right) => frame.indexOf(left) - frame.indexOf(right))
}
test("search orders enabled options across categories and receives every query", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
const calls: [string, string[]][] = []
try {
const chosen = select.dialog.select({
title: "Projects",
options: [
{ title: "Alpha", value: "alpha", category: "Recent" },
{ title: "Beta", value: "beta", category: "Pinned" },
{ title: "Gamma", value: "gamma", category: "Recent" },
{ title: "Delta", value: "delta", category: "Pinned", disabled: true },
],
search: (query, options) => {
calls.push([query, options.map((option) => option.value)])
return options
.filter((option) => option.title.toLowerCase().includes(query))
.toSorted((left, right) => right.title.localeCompare(left.title))
},
})
await opened(select.app, "Projects")
const frame = select.app.captureCharFrame()
expect(rows(frame, ["Alpha", "Beta", "Gamma"])).toEqual(["Gamma", "Beta", "Alpha"])
expect(frame).not.toContain("Delta")
expect(frame).toMatch(/Gamma +Recent/)
expect(frame).toMatch(/Beta +Pinned/)
await select.app.mockInput.typeText("l")
await select.app.waitForFrame((frame) => !frame.includes("Gamma"))
expect(select.app.captureCharFrame()).not.toContain("Delta")
select.app.mockInput.pressEnter()
expect(await chosen).toBe("alpha")
expect(calls[0]).toEqual(["", ["alpha", "beta", "gamma"]])
expect(calls.at(-1)).toEqual(["l", ["alpha", "beta", "gamma"]])
} finally {
select.app.renderer.destroy()
}
})
test("search keeps the selected value when its results reorder", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
const [reversed, setReversed] = createSignal(false)
try {
const chosen = select.dialog.select({
title: "Ranked",
options: ["First", "Second", "Third", "Fourth"].map((title) => ({ title, value: title.toLowerCase() })),
search: (_, options) => (reversed() ? options.toReversed() : options),
})
await opened(select.app, "Ranked")
select.app.mockInput.pressArrow("down")
setReversed(true)
await select.app.waitForFrame((frame) => frame.indexOf("Fourth") < frame.indexOf("First"))
select.app.mockInput.pressEnter()
expect(await chosen).toBe("second")
} finally {
select.app.renderer.destroy()
}
})
test("search keeps a query's first result when the same query reorders", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
const [reversed, setReversed] = createSignal(false)
try {
const chosen = select.dialog.select({
title: "Ranked",
options: ["First", "Second", "Third", "Fourth"].map((title) => ({ title, value: title.toLowerCase() })),
search: (query, options) => {
const matches = options.filter((option) => option.value.includes(query))
return reversed() ? matches.toReversed() : matches
},
})
await opened(select.app, "Ranked")
await select.app.mockInput.typeText("r")
await select.app.waitForFrame((frame) => !frame.includes("Second"))
setReversed(true)
await select.app.waitForFrame((frame) => frame.indexOf("Fourth") < frame.indexOf("First"))
select.app.mockInput.pressEnter()
expect(await chosen).toBe("first")
} finally {
select.app.renderer.destroy()
}
})
test("search scrolls a moved or replaced selection into view", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
const items = Array.from({ length: 30 }, (_, index) => {
const title = `Item ${String(index).padStart(2, "0")}`
return { title, value: title }
})
const [results, setResults] = createSignal(items)
try {
const chosen = select.dialog.select({
title: "Long",
options: items,
search: () => results(),
})
await opened(select.app, "Long")
expect(select.app.captureCharFrame()).not.toContain("Item 29")
setResults(items.toReversed())
await select.app.waitForFrame((frame) => frame.includes("Item 00") && !frame.includes("Item 29"))
setResults(items.slice(1).toReversed())
await select.app.waitForFrame((frame) => frame.includes("Item 01") && !frame.includes("Item 00"))
select.app.mockInput.pressEnter()
expect(await chosen).toBe("Item 01")
} finally {
select.app.renderer.destroy()
}
})
test("search ignores disabled records returned by the callback", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
const hidden = { title: "Hidden", value: "hidden", disabled: true }
const triggered: string[] = []
try {
const chosen = select.dialog.select({
title: "Guarded",
options: [{ title: "Alpha", value: "alpha" }],
search: (_, options) => [hidden, ...options],
shortcuts: [{ key: "ctrl+o", title: "Inspect", onTrigger: (value) => triggered.push(value) }],
})
await opened(select.app, "Guarded")
expect(select.app.captureCharFrame()).not.toContain("Hidden")
select.app.mockInput.pressKey("o", { ctrl: true })
select.app.mockInput.pressEnter()
expect(await chosen).toBe("alpha")
expect(triggered).toEqual(["alpha"])
} finally {
select.app.renderer.destroy()
}
})
test("search returns to the first row when the query clears without a current value", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
try {
const chosen = select.dialog.select({
title: "Cleared",
options: ["Alpha", "Beta", "Gamma"].map((title) => ({ title, value: title.toLowerCase() })),
search: (query, options) => options.filter((option) => option.value.includes(query)),
})
await opened(select.app, "Cleared")
await select.app.mockInput.typeText("g")
await select.app.waitForFrame((frame) => !frame.includes("Beta"))
select.app.mockInput.pressBackspace()
await select.app.waitForFrame((frame) => frame.includes("Beta"))
select.app.mockInput.pressEnter()
expect(await chosen).toBe("alpha")
} finally {
select.app.renderer.destroy()
}
})
test("search selects the current value once it arrives in the results", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
const options = ["Alpha", "Beta", "Gamma"].map((title) => ({ title, value: title.toLowerCase() }))
const [results, setResults] = createSignal<typeof options>([])
try {
const chosen = select.dialog.select({ title: "Loading", options, current: "gamma", search: () => results() })
await opened(select.app, "Loading")
setResults(options)
await select.app.waitForFrame((frame) => frame.includes("Gamma"))
select.app.mockInput.pressEnter()
expect(await chosen).toBe("gamma")
} finally {
select.app.renderer.destroy()
}
})
test("search returns to the current value when the query clears", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
try {
const chosen = select.dialog.select({
title: "Current",
options: ["Alpha", "Beta", "Gamma"].map((title) => ({ title, value: title.toLowerCase() })),
current: "beta",
search: (query, options) => options.filter((option) => option.value.includes(query)),
})
await opened(select.app, "Current")
await select.app.mockInput.typeText("g")
await select.app.waitForFrame((frame) => !frame.includes("Beta"))
select.app.mockInput.pressBackspace()
await select.app.waitForFrame((frame) => frame.includes("Beta"))
select.app.mockInput.pressEnter()
expect(await chosen).toBe("beta")
} finally {
select.app.renderer.destroy()
}
})
test.each([
[false, true],
[0, 1],
["", "other"],
])("shortcuts deliver the selected value %p from keyboard, footer click, and footer focus", async (value, other) => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
const triggered: unknown[] = []
let settled = false
try {
const chosen = select.dialog.select<typeof value | typeof other>({
title: "Values",
options: [
{ title: "Chosen", value },
{ title: "Other", value: other },
],
shortcuts: [{ key: "ctrl+o", title: "Inspect", onTrigger: (selected) => triggered.push(selected) }],
})
void chosen.then(() => (settled = true))
await opened(select.app, "Values")
await select.app.waitForFrame((frame) => frame.includes("Inspect ctrl+o"))
select.app.mockInput.pressKey("o", { ctrl: true })
const frame = select.app.captureCharFrame().split("\n")
const row = frame.findIndex((line) => line.includes("Inspect ctrl+o"))
await select.app.mockMouse.click(frame[row]!.indexOf("Inspect") + 1, row)
select.app.mockInput.pressTab()
select.app.mockInput.pressEnter()
await select.app.waitFor(() => triggered.length === 3)
await Bun.sleep(10)
expect(triggered).toEqual([value, value, value])
expect(settled).toBe(false)
expect(select.app.captureCharFrame()).toContain("Values")
} finally {
select.app.renderer.destroy()
}
})
test("shortcuts receive object values and unbind when the dialog closes", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
const project = { id: "acme" }
const triggered: { id: string }[] = []
try {
const chosen = select.dialog.select({
title: "Objects",
options: [{ title: "Acme", value: project }],
shortcuts: [{ key: "ctrl+o", title: "Inspect", onTrigger: (selected) => triggered.push(selected) }],
})
await opened(select.app, "Objects")
select.app.mockInput.pressKey("o", { ctrl: true })
select.app.mockInput.pressEscape()
expect(await chosen).toBeUndefined()
await select.app.waitForFrame((frame) => !frame.includes("Objects"))
select.app.mockInput.pressKey("o", { ctrl: true })
expect(triggered).toEqual([project])
expect(triggered[0]).toBe(project)
} finally {
select.app.renderer.destroy()
}
})
test("only no-selection shortcuts run without results", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
const triggered: string[] = []
try {
void select.dialog.select({
title: "Empty",
options: [{ title: "Alpha", value: "alpha" }],
search: () => [],
shortcuts: [
{ key: "ctrl+o", title: "Inspect", onTrigger: (value) => triggered.push(value) },
{ key: "ctrl+a", title: "New", side: "right", selection: "none", onTrigger: () => triggered.push("new") },
],
})
await opened(select.app, "Empty")
await select.app.waitForFrame((frame) => frame.includes("New ctrl+a"))
select.app.mockInput.pressKey("o", { ctrl: true })
select.app.mockInput.pressKey("a", { ctrl: true })
expect(triggered).toEqual(["new"])
} finally {
select.app.renderer.destroy()
}
})
test("a shortcut that opens a replacement dialog settles the select and keeps the replacement", async () => {
await using tmp = await tmpdir()
const select = await mount(tmp.path)
const triggered: string[] = []
try {
const chosen = select.dialog.select({
title: "Projects",
options: [{ title: "Alpha", value: "alpha" }],
shortcuts: [
{
key: "ctrl+o",
title: "Rename",
onTrigger: (value) => {
triggered.push(value)
void select.dialog.prompt({ title: `Rename ${value}` })
},
},
],
})
await opened(select.app, "Projects")
select.app.mockInput.pressKey("o", { ctrl: true })
expect(await chosen).toBeUndefined()
await select.app.waitForFrame((frame) => frame.includes("Rename alpha"))
select.app.mockInput.pressKey("o", { ctrl: true })
await Bun.sleep(10)
expect(triggered).toEqual(["alpha"])
expect(select.app.captureCharFrame()).toContain("Rename alpha")
expect(select.app.captureCharFrame()).not.toContain("Projects")
} finally {
select.app.renderer.destroy()
}
})
@@ -333,6 +333,44 @@ const mode = await context.ui.dialog.select({
})
```
### Select search
Pass `search` to own matching and result order. It runs for every query, including the empty one, and receives only enabled options. Results are listed in the returned order, with each category shown inline.
```ts
const project = await context.ui.dialog.select({
title: "Projects",
options: [
{ title: "Alpha", value: "alpha", category: "Recent" },
{ title: "Beta", value: "beta", category: "Pinned" },
],
search: (query, options) =>
options
.filter((option) => option.title.toLowerCase().includes(query.toLowerCase()))
.toSorted((left, right) => right.title.localeCompare(left.title)),
})
```
### Select shortcuts
Shortcuts add footer actions that run from their key, a click, or Tab focus followed by Enter. By default they receive the selected value and are unavailable without a selection. Use `selection: "none"` for actions that need no selection.
```ts
await context.ui.dialog.select({
title: "Projects",
options: [{ title: "Alpha", value: "alpha" }],
shortcuts: [
{ key: "ctrl+o", title: "Inspect", onTrigger: (value) => context.ui.toast.show({ message: value }) },
{ key: "ctrl+a", title: "Close", side: "right", selection: "none", onTrigger: () => context.ui.dialog.clear() },
],
})
```
- A shortcut does not close the dialog or settle the promise.
- Enter or clicking an option resolves the selected value.
- Escape, `clear()`, or showing another dialog resolves `undefined`.
- Do not use `ctrl+n` or `ctrl+p`. The dialog reserves them for navigation, and its own bindings take precedence.
Custom JSX dialogs can set their size and close themselves.
```tsx