| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- import { Effect, Schema } from "effect"
- import { Route } from "../route/client"
- import { Auth } from "../route/auth"
- import { Endpoint } from "../route/endpoint"
- import { Framing } from "../route/framing"
- import { Protocol } from "../route/protocol"
- import {
- LLMEvent,
- Usage,
- type FinishReason,
- type JsonSchema,
- type LLMRequest,
- type MediaPart,
- type ProviderMetadata,
- type TextPart,
- type ToolCallPart,
- type ToolDefinition,
- type ToolContent,
- } from "../schema"
- import { JsonObject, optionalArray, ProviderShared } from "./shared"
- import { GeminiToolSchema } from "./utils/gemini-tool-schema"
- import { Lifecycle } from "./utils/lifecycle"
- import { ToolSchemaProjection } from "./utils/tool-schema"
- const ADAPTER = "gemini"
- const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
- export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
- // =============================================================================
- // Request Body Schema
- // =============================================================================
- const GeminiTextPart = Schema.Struct({
- text: Schema.String,
- thought: Schema.optional(Schema.Boolean),
- thoughtSignature: Schema.optional(Schema.String),
- })
- const GeminiInlineDataPart = Schema.Struct({
- inlineData: Schema.Struct({
- mimeType: Schema.String,
- data: Schema.String,
- }),
- })
- const GeminiFunctionCallPart = Schema.Struct({
- functionCall: Schema.Struct({
- name: Schema.String,
- args: Schema.Unknown,
- }),
- thoughtSignature: Schema.optional(Schema.String),
- })
- const GeminiFunctionResponsePart = Schema.Struct({
- functionResponse: Schema.Struct({
- name: Schema.String,
- response: Schema.Unknown,
- }),
- })
- const GeminiContentPart = Schema.Union([
- GeminiTextPart,
- GeminiInlineDataPart,
- GeminiFunctionCallPart,
- GeminiFunctionResponsePart,
- ])
- const GeminiContent = Schema.Struct({
- role: Schema.Literals(["user", "model"]),
- parts: Schema.Array(GeminiContentPart),
- })
- type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
- const GeminiSystemInstruction = Schema.Struct({
- parts: Schema.Array(Schema.Struct({ text: Schema.String })),
- })
- const GeminiFunctionDeclaration = Schema.Struct({
- name: Schema.String,
- description: Schema.String,
- parameters: Schema.optional(JsonObject),
- })
- const GeminiTool = Schema.Struct({
- functionDeclarations: Schema.Array(GeminiFunctionDeclaration),
- })
- const GeminiToolConfig = Schema.Struct({
- functionCallingConfig: Schema.Struct({
- mode: Schema.Literals(["AUTO", "NONE", "ANY"]),
- allowedFunctionNames: optionalArray(Schema.String),
- }),
- })
- const GeminiThinkingConfig = Schema.Struct({
- thinkingBudget: Schema.optional(Schema.Number),
- includeThoughts: Schema.optional(Schema.Boolean),
- })
- const GeminiGenerationConfig = Schema.Struct({
- maxOutputTokens: Schema.optional(Schema.Number),
- temperature: Schema.optional(Schema.Number),
- topP: Schema.optional(Schema.Number),
- topK: Schema.optional(Schema.Number),
- stopSequences: optionalArray(Schema.String),
- thinkingConfig: Schema.optional(GeminiThinkingConfig),
- })
- const GeminiBodyFields = {
- contents: Schema.Array(GeminiContent),
- systemInstruction: Schema.optional(GeminiSystemInstruction),
- tools: optionalArray(GeminiTool),
- toolConfig: Schema.optional(GeminiToolConfig),
- generationConfig: Schema.optional(GeminiGenerationConfig),
- }
- const GeminiBody = Schema.Struct(GeminiBodyFields)
- export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
- const GeminiUsage = Schema.Struct({
- cachedContentTokenCount: Schema.optional(Schema.Number),
- thoughtsTokenCount: Schema.optional(Schema.Number),
- promptTokenCount: Schema.optional(Schema.Number),
- candidatesTokenCount: Schema.optional(Schema.Number),
- totalTokenCount: Schema.optional(Schema.Number),
- })
- type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
- const GeminiCandidate = Schema.Struct({
- content: Schema.optional(GeminiContent),
- finishReason: Schema.optional(Schema.String),
- })
- const GeminiEvent = Schema.Struct({
- candidates: optionalArray(GeminiCandidate),
- usageMetadata: Schema.optional(GeminiUsage),
- })
- type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
- interface ParserState {
- readonly finishReason?: string
- readonly hasToolCalls: boolean
- readonly nextToolCallId: number
- readonly usage?: Usage
- readonly lifecycle: Lifecycle.State
- readonly reasoningSignature?: string
- }
- // =============================================================================
- // Tool Schema Conversion
- // =============================================================================
- // Tool-schema conversion has two distinct concerns:
- //
- // 1. Sanitize — fix common authoring mistakes Gemini rejects: integer/number
- // enums (must be strings), `required` entries that don't match a property,
- // untyped arrays (`items` must be present), and `properties`/`required`
- // keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
- //
- // 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
- // drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
- // coerce `const` to `[const]` enum, recurse properties/items, propagate
- // only an allowlisted set of keys (description, required, format, type,
- // properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
- // allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
- //
- // Sanitize runs first, then project. The implementation lives in
- // `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
- // provider protocols.
- // =============================================================================
- // Request Lowering
- // =============================================================================
- const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({
- name: tool.name,
- description: tool.description,
- parameters: GeminiToolSchema.convert(inputSchema),
- })
- const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
- ProviderShared.matchToolChoice("Gemini", toolChoice, {
- auto: () => ({ functionCallingConfig: { mode: "AUTO" as const } }),
- none: () => ({ functionCallingConfig: { mode: "NONE" as const } }),
- required: () => ({ functionCallingConfig: { mode: "ANY" as const } }),
- tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }),
- })
- const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) {
- if (part.type === "text") return { text: part.text }
- const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES)
- return { inlineData: { mimeType: media.mime, data: media.base64 } }
- })
- const googleMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ google: metadata })
- const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
- const google = providerMetadata?.google
- return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string"
- ? google.thoughtSignature
- : undefined
- }
- const lowerToolCall = (part: ToolCallPart) => ({
- functionCall: { name: part.name, args: part.input },
- thoughtSignature: thoughtSignature(part.providerMetadata),
- })
- const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
- const contents: GeminiContent[] = []
- for (const message of request.messages) {
- if (message.role === "system") {
- const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
- const previous = contents.at(-1)
- if (previous?.role === "user")
- contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
- else contents.push({ role: "user", parts: [{ text: part.text }] })
- continue
- }
- if (message.role === "user") {
- const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
- for (const part of message.content) {
- if (!ProviderShared.supportsContent(part, ["text", "media"]))
- return yield* ProviderShared.unsupportedContent("Gemini", "user", ["text", "media"])
- parts.push(yield* lowerUserPart(part))
- }
- contents.push({ role: "user", parts })
- continue
- }
- if (message.role === "assistant") {
- const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
- for (const part of message.content) {
- if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
- return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
- if (part.type === "text") {
- parts.push({ text: part.text })
- continue
- }
- if (part.type === "reasoning") {
- parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
- continue
- }
- if (part.type === "tool-call") {
- parts.push(lowerToolCall(part))
- continue
- }
- }
- contents.push({ role: "model", parts })
- continue
- }
- const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
- for (const part of message.content) {
- if (!ProviderShared.supportsContent(part, ["tool-result"]))
- return yield* ProviderShared.unsupportedContent("Gemini", "tool", ["tool-result"])
- if (part.result.type !== "content") {
- parts.push({
- functionResponse: {
- name: part.name,
- response: {
- name: part.name,
- content: ProviderShared.toolResultText(part),
- },
- },
- })
- continue
- }
- const content: ReadonlyArray<ToolContent> = part.result.value
- const text = content.filter((item) => item.type === "text").map((item) => item.text)
- parts.push({
- functionResponse: {
- name: part.name,
- response: {
- name: part.name,
- content: text.join("\n"),
- },
- },
- })
- for (const item of content) {
- if (item.type === "text") continue
- const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
- parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
- }
- }
- contents.push({ role: "user", parts })
- }
- return contents
- })
- const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini
- const thinkingConfig = (request: LLMRequest) => {
- const value = geminiOptions(request)?.thinkingConfig
- if (!ProviderShared.isRecord(value)) return undefined
- const result = {
- thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
- includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
- }
- return Object.values(result).some((item) => item !== undefined) ? result : undefined
- }
- const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
- const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
- const generation = request.generation
- const toolSchemaCompatibility = request.model.compatibility?.toolSchema
- const generationConfig = {
- maxOutputTokens: generation?.maxTokens,
- temperature: generation?.temperature,
- topP: generation?.topP,
- topK: generation?.topK,
- stopSequences: generation?.stop,
- thinkingConfig: thinkingConfig(request),
- }
- return {
- contents: yield* lowerMessages(request),
- systemInstruction:
- request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
- tools: toolsEnabled
- ? [
- {
- functionDeclarations: request.tools.map((tool) =>
- lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
- ),
- },
- ]
- : undefined,
- toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
- generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
- ? generationConfig
- : undefined,
- }
- })
- // =============================================================================
- // Stream Parsing
- // =============================================================================
- // Gemini reports `promptTokenCount` (inclusive total) with a
- // `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
- // of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
- // to produce the inclusive `outputTokens` the rest of the contract expects.
- const mapUsage = (usage: GeminiUsage | undefined) => {
- if (!usage) return undefined
- const cached = usage.cachedContentTokenCount
- const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
- // `candidatesTokenCount` is visible-only; sum with thoughts to produce the
- // inclusive `outputTokens` the contract expects. Only compute the total
- // when the visible component is reported — otherwise we'd fabricate an
- // inclusive number from a partial breakdown.
- const outputTokens =
- usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
- return new Usage({
- inputTokens: usage.promptTokenCount,
- outputTokens,
- nonCachedInputTokens: nonCached,
- cacheReadInputTokens: cached,
- reasoningTokens: usage.thoughtsTokenCount,
- totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
- providerMetadata: { google: usage },
- })
- }
- const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
- if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
- if (finishReason === "MAX_TOKENS") return "length"
- if (
- finishReason === "IMAGE_SAFETY" ||
- finishReason === "RECITATION" ||
- finishReason === "SAFETY" ||
- finishReason === "BLOCKLIST" ||
- finishReason === "PROHIBITED_CONTENT" ||
- finishReason === "SPII"
- )
- return "content-filter"
- if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
- return "unknown"
- }
- const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
- state.finishReason || state.usage
- ? (() => {
- const events: LLMEvent[] = []
- const lifecycle = state.reasoningSignature
- ? Lifecycle.reasoningEnd(
- state.lifecycle,
- events,
- "reasoning-0",
- googleMetadata({ thoughtSignature: state.reasoningSignature }),
- )
- : state.lifecycle
- Lifecycle.finish(lifecycle, events, {
- reason: mapFinishReason(state.finishReason, state.hasToolCalls),
- usage: state.usage,
- })
- return events
- })()
- : []
- const step = (state: ParserState, event: GeminiEvent) => {
- const nextState = {
- ...state,
- usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
- }
- const candidate = event.candidates?.[0]
- if (!candidate?.content)
- return Effect.succeed([
- { ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
- [],
- ] as const)
- const events: LLMEvent[] = []
- let hasToolCalls = nextState.hasToolCalls
- let lifecycle = nextState.lifecycle
- let nextToolCallId = nextState.nextToolCallId
- let reasoningSignature = nextState.reasoningSignature
- for (const part of candidate.content.parts) {
- if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
- reasoningSignature = part.thoughtSignature
- if ("text" in part && part.text.length > 0) {
- if (part.thought) {
- lifecycle = Lifecycle.reasoningDelta(
- lifecycle,
- events,
- "reasoning-0",
- part.text,
- part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
- )
- continue
- }
- lifecycle = Lifecycle.reasoningEnd(
- lifecycle,
- events,
- "reasoning-0",
- reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
- )
- lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
- continue
- }
- if ("functionCall" in part) {
- const input = part.functionCall.args
- const id = `tool_${nextToolCallId++}`
- lifecycle = Lifecycle.reasoningEnd(
- lifecycle,
- events,
- "reasoning-0",
- reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
- )
- lifecycle = Lifecycle.stepStart(lifecycle, events)
- events.push(
- LLMEvent.toolCall({
- id,
- name: part.functionCall.name,
- input,
- providerMetadata: part.thoughtSignature
- ? googleMetadata({ thoughtSignature: part.thoughtSignature })
- : undefined,
- }),
- )
- hasToolCalls = true
- }
- }
- return Effect.succeed([
- {
- ...nextState,
- hasToolCalls,
- lifecycle,
- nextToolCallId,
- reasoningSignature,
- finishReason: candidate.finishReason ?? nextState.finishReason,
- },
- events,
- ] as const)
- }
- // =============================================================================
- // Protocol And Gemini Route
- // =============================================================================
- /**
- * The Gemini protocol — request body construction, body schema, and the
- * streaming-event state machine. Used by Google AI Studio Gemini and (once
- * registered) Vertex Gemini.
- */
- export const protocol = Protocol.make({
- id: ADAPTER,
- body: {
- schema: GeminiBody,
- from: fromRequest,
- },
- stream: {
- event: Protocol.jsonEvent(GeminiEvent),
- initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
- step,
- onHalt: finish,
- },
- })
- export const route = Route.make({
- id: ADAPTER,
- provider: "google",
- protocol,
- // Gemini's path embeds the model id and pins SSE framing at the URL level.
- endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, {
- baseURL: DEFAULT_BASE_URL,
- }),
- auth: Auth.none,
- framing: Framing.sse,
- })
- export * as Gemini from "./gemini"
|