| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- 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 LLMRequest,
- type MediaPart,
- type TextPart,
- type ToolCallPart,
- type ToolDefinition,
- } from "../schema"
- import { JsonObject, optionalArray, ProviderShared } from "./shared"
- import { GeminiToolSchema } from "./utils/gemini-tool-schema"
- const ADAPTER = "gemini"
- 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
- }
- const invalid = ProviderShared.invalidRequest
- const mediaData = ProviderShared.mediaBytes
- // =============================================================================
- // 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) => ({
- name: tool.name,
- description: tool.description,
- parameters: GeminiToolSchema.convert(tool.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 = (part: TextPart | MediaPart) =>
- part.type === "text" ? { text: part.text } : { inlineData: { mimeType: part.mediaType, data: mediaData(part) } }
- const lowerToolCall = (part: ToolCallPart) => ({
- functionCall: { name: part.name, args: part.input },
- })
- const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
- const contents: GeminiContent[] = []
- for (const message of request.messages) {
- 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(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 })
- 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"])
- parts.push({
- functionResponse: {
- name: part.name,
- response: {
- name: part.name,
- content: ProviderShared.toolResultText(part),
- },
- },
- })
- }
- 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 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(lowerTool) }] : undefined,
- toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
- generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
- ? generationConfig
- : undefined,
- }
- })
- // =============================================================================
- // Stream Parsing
- // =============================================================================
- const mapUsage = (usage: GeminiUsage | undefined) => {
- if (!usage) return undefined
- return new Usage({
- inputTokens: usage.promptTokenCount,
- outputTokens: usage.candidatesTokenCount,
- reasoningTokens: usage.thoughtsTokenCount,
- cacheReadInputTokens: usage.cachedContentTokenCount,
- totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount),
- native: 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
- ? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })]
- : []
- 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 nextToolCallId = nextState.nextToolCallId
- for (const part of candidate.content.parts) {
- if ("text" in part && part.text.length > 0) {
- events.push(
- part.thought
- ? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text })
- : LLMEvent.textDelta({ id: "text-0", text: part.text }),
- )
- continue
- }
- if ("functionCall" in part) {
- const input = part.functionCall.args
- const id = `tool_${nextToolCallId++}`
- events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input }))
- hasToolCalls = true
- }
- }
- return Effect.succeed([
- {
- ...nextState,
- hasToolCalls,
- nextToolCallId,
- 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 }),
- step,
- onHalt: finish,
- },
- })
- export const route = Route.make({
- id: ADAPTER,
- 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`),
- auth: Auth.apiKeyHeader("x-goog-api-key"),
- framing: Framing.sse,
- })
- // =============================================================================
- // Model Helper
- // =============================================================================
- export const model = Route.model(route, {
- provider: "google",
- baseURL: DEFAULT_BASE_URL,
- })
- export * as Gemini from "./gemini"
|