gemini.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. import { Effect, Schema } from "effect"
  2. import { Route } from "../route/client"
  3. import { Auth } from "../route/auth"
  4. import { Endpoint } from "../route/endpoint"
  5. import { Framing } from "../route/framing"
  6. import { Protocol } from "../route/protocol"
  7. import {
  8. LLMEvent,
  9. Usage,
  10. type FinishReason,
  11. type JsonSchema,
  12. type LLMRequest,
  13. type MediaPart,
  14. type ProviderMetadata,
  15. type TextPart,
  16. type ToolCallPart,
  17. type ToolDefinition,
  18. type ToolContent,
  19. } from "../schema"
  20. import { JsonObject, optionalArray, ProviderShared } from "./shared"
  21. import { GeminiToolSchema } from "./utils/gemini-tool-schema"
  22. import { Lifecycle } from "./utils/lifecycle"
  23. import { ToolSchemaProjection } from "./utils/tool-schema"
  24. const ADAPTER = "gemini"
  25. const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
  26. export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
  27. // =============================================================================
  28. // Request Body Schema
  29. // =============================================================================
  30. const GeminiTextPart = Schema.Struct({
  31. text: Schema.String,
  32. thought: Schema.optional(Schema.Boolean),
  33. thoughtSignature: Schema.optional(Schema.String),
  34. })
  35. const GeminiInlineDataPart = Schema.Struct({
  36. inlineData: Schema.Struct({
  37. mimeType: Schema.String,
  38. data: Schema.String,
  39. }),
  40. })
  41. const GeminiFunctionCallPart = Schema.Struct({
  42. functionCall: Schema.Struct({
  43. name: Schema.String,
  44. args: Schema.Unknown,
  45. }),
  46. thoughtSignature: Schema.optional(Schema.String),
  47. })
  48. const GeminiFunctionResponsePart = Schema.Struct({
  49. functionResponse: Schema.Struct({
  50. name: Schema.String,
  51. response: Schema.Unknown,
  52. }),
  53. })
  54. const GeminiContentPart = Schema.Union([
  55. GeminiTextPart,
  56. GeminiInlineDataPart,
  57. GeminiFunctionCallPart,
  58. GeminiFunctionResponsePart,
  59. ])
  60. const GeminiContent = Schema.Struct({
  61. role: Schema.Literals(["user", "model"]),
  62. parts: Schema.Array(GeminiContentPart),
  63. })
  64. type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
  65. const GeminiSystemInstruction = Schema.Struct({
  66. parts: Schema.Array(Schema.Struct({ text: Schema.String })),
  67. })
  68. const GeminiFunctionDeclaration = Schema.Struct({
  69. name: Schema.String,
  70. description: Schema.String,
  71. parameters: Schema.optional(JsonObject),
  72. })
  73. const GeminiTool = Schema.Struct({
  74. functionDeclarations: Schema.Array(GeminiFunctionDeclaration),
  75. })
  76. const GeminiToolConfig = Schema.Struct({
  77. functionCallingConfig: Schema.Struct({
  78. mode: Schema.Literals(["AUTO", "NONE", "ANY"]),
  79. allowedFunctionNames: optionalArray(Schema.String),
  80. }),
  81. })
  82. const GeminiThinkingConfig = Schema.Struct({
  83. thinkingBudget: Schema.optional(Schema.Number),
  84. includeThoughts: Schema.optional(Schema.Boolean),
  85. })
  86. const GeminiGenerationConfig = Schema.Struct({
  87. maxOutputTokens: Schema.optional(Schema.Number),
  88. temperature: Schema.optional(Schema.Number),
  89. topP: Schema.optional(Schema.Number),
  90. topK: Schema.optional(Schema.Number),
  91. stopSequences: optionalArray(Schema.String),
  92. thinkingConfig: Schema.optional(GeminiThinkingConfig),
  93. })
  94. const GeminiBodyFields = {
  95. contents: Schema.Array(GeminiContent),
  96. systemInstruction: Schema.optional(GeminiSystemInstruction),
  97. tools: optionalArray(GeminiTool),
  98. toolConfig: Schema.optional(GeminiToolConfig),
  99. generationConfig: Schema.optional(GeminiGenerationConfig),
  100. }
  101. const GeminiBody = Schema.Struct(GeminiBodyFields)
  102. export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
  103. const GeminiUsage = Schema.Struct({
  104. cachedContentTokenCount: Schema.optional(Schema.Number),
  105. thoughtsTokenCount: Schema.optional(Schema.Number),
  106. promptTokenCount: Schema.optional(Schema.Number),
  107. candidatesTokenCount: Schema.optional(Schema.Number),
  108. totalTokenCount: Schema.optional(Schema.Number),
  109. })
  110. type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
  111. const GeminiCandidate = Schema.Struct({
  112. content: Schema.optional(GeminiContent),
  113. finishReason: Schema.optional(Schema.String),
  114. })
  115. const GeminiEvent = Schema.Struct({
  116. candidates: optionalArray(GeminiCandidate),
  117. usageMetadata: Schema.optional(GeminiUsage),
  118. })
  119. type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
  120. interface ParserState {
  121. readonly finishReason?: string
  122. readonly hasToolCalls: boolean
  123. readonly nextToolCallId: number
  124. readonly usage?: Usage
  125. readonly lifecycle: Lifecycle.State
  126. readonly reasoningSignature?: string
  127. }
  128. // =============================================================================
  129. // Tool Schema Conversion
  130. // =============================================================================
  131. // Tool-schema conversion has two distinct concerns:
  132. //
  133. // 1. Sanitize — fix common authoring mistakes Gemini rejects: integer/number
  134. // enums (must be strings), `required` entries that don't match a property,
  135. // untyped arrays (`items` must be present), and `properties`/`required`
  136. // keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
  137. //
  138. // 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
  139. // drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
  140. // coerce `const` to `[const]` enum, recurse properties/items, propagate
  141. // only an allowlisted set of keys (description, required, format, type,
  142. // properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
  143. // allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
  144. //
  145. // Sanitize runs first, then project. The implementation lives in
  146. // `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
  147. // provider protocols.
  148. // =============================================================================
  149. // Request Lowering
  150. // =============================================================================
  151. const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({
  152. name: tool.name,
  153. description: tool.description,
  154. parameters: GeminiToolSchema.convert(inputSchema),
  155. })
  156. const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
  157. ProviderShared.matchToolChoice("Gemini", toolChoice, {
  158. auto: () => ({ functionCallingConfig: { mode: "AUTO" as const } }),
  159. none: () => ({ functionCallingConfig: { mode: "NONE" as const } }),
  160. required: () => ({ functionCallingConfig: { mode: "ANY" as const } }),
  161. tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }),
  162. })
  163. const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) {
  164. if (part.type === "text") return { text: part.text }
  165. const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES)
  166. return { inlineData: { mimeType: media.mime, data: media.base64 } }
  167. })
  168. const googleMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ google: metadata })
  169. const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
  170. const google = providerMetadata?.google
  171. return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string"
  172. ? google.thoughtSignature
  173. : undefined
  174. }
  175. const lowerToolCall = (part: ToolCallPart) => ({
  176. functionCall: { name: part.name, args: part.input },
  177. thoughtSignature: thoughtSignature(part.providerMetadata),
  178. })
  179. const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
  180. const contents: GeminiContent[] = []
  181. for (const message of request.messages) {
  182. if (message.role === "system") {
  183. const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
  184. const previous = contents.at(-1)
  185. if (previous?.role === "user")
  186. contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
  187. else contents.push({ role: "user", parts: [{ text: part.text }] })
  188. continue
  189. }
  190. if (message.role === "user") {
  191. const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
  192. for (const part of message.content) {
  193. if (!ProviderShared.supportsContent(part, ["text", "media"]))
  194. return yield* ProviderShared.unsupportedContent("Gemini", "user", ["text", "media"])
  195. parts.push(yield* lowerUserPart(part))
  196. }
  197. contents.push({ role: "user", parts })
  198. continue
  199. }
  200. if (message.role === "assistant") {
  201. const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
  202. for (const part of message.content) {
  203. if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
  204. return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
  205. if (part.type === "text") {
  206. parts.push({ text: part.text })
  207. continue
  208. }
  209. if (part.type === "reasoning") {
  210. parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
  211. continue
  212. }
  213. if (part.type === "tool-call") {
  214. parts.push(lowerToolCall(part))
  215. continue
  216. }
  217. }
  218. contents.push({ role: "model", parts })
  219. continue
  220. }
  221. const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
  222. for (const part of message.content) {
  223. if (!ProviderShared.supportsContent(part, ["tool-result"]))
  224. return yield* ProviderShared.unsupportedContent("Gemini", "tool", ["tool-result"])
  225. if (part.result.type !== "content") {
  226. parts.push({
  227. functionResponse: {
  228. name: part.name,
  229. response: {
  230. name: part.name,
  231. content: ProviderShared.toolResultText(part),
  232. },
  233. },
  234. })
  235. continue
  236. }
  237. const content: ReadonlyArray<ToolContent> = part.result.value
  238. const text = content.filter((item) => item.type === "text").map((item) => item.text)
  239. parts.push({
  240. functionResponse: {
  241. name: part.name,
  242. response: {
  243. name: part.name,
  244. content: text.join("\n"),
  245. },
  246. },
  247. })
  248. for (const item of content) {
  249. if (item.type === "text") continue
  250. const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
  251. parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
  252. }
  253. }
  254. contents.push({ role: "user", parts })
  255. }
  256. return contents
  257. })
  258. const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini
  259. const thinkingConfig = (request: LLMRequest) => {
  260. const value = geminiOptions(request)?.thinkingConfig
  261. if (!ProviderShared.isRecord(value)) return undefined
  262. const result = {
  263. thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
  264. includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
  265. }
  266. return Object.values(result).some((item) => item !== undefined) ? result : undefined
  267. }
  268. const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
  269. const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
  270. const generation = request.generation
  271. const toolSchemaCompatibility = request.model.compatibility?.toolSchema
  272. const generationConfig = {
  273. maxOutputTokens: generation?.maxTokens,
  274. temperature: generation?.temperature,
  275. topP: generation?.topP,
  276. topK: generation?.topK,
  277. stopSequences: generation?.stop,
  278. thinkingConfig: thinkingConfig(request),
  279. }
  280. return {
  281. contents: yield* lowerMessages(request),
  282. systemInstruction:
  283. request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
  284. tools: toolsEnabled
  285. ? [
  286. {
  287. functionDeclarations: request.tools.map((tool) =>
  288. lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
  289. ),
  290. },
  291. ]
  292. : undefined,
  293. toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
  294. generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
  295. ? generationConfig
  296. : undefined,
  297. }
  298. })
  299. // =============================================================================
  300. // Stream Parsing
  301. // =============================================================================
  302. // Gemini reports `promptTokenCount` (inclusive total) with a
  303. // `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
  304. // of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
  305. // to produce the inclusive `outputTokens` the rest of the contract expects.
  306. const mapUsage = (usage: GeminiUsage | undefined) => {
  307. if (!usage) return undefined
  308. const cached = usage.cachedContentTokenCount
  309. const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
  310. // `candidatesTokenCount` is visible-only; sum with thoughts to produce the
  311. // inclusive `outputTokens` the contract expects. Only compute the total
  312. // when the visible component is reported — otherwise we'd fabricate an
  313. // inclusive number from a partial breakdown.
  314. const outputTokens =
  315. usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
  316. return new Usage({
  317. inputTokens: usage.promptTokenCount,
  318. outputTokens,
  319. nonCachedInputTokens: nonCached,
  320. cacheReadInputTokens: cached,
  321. reasoningTokens: usage.thoughtsTokenCount,
  322. totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
  323. providerMetadata: { google: usage },
  324. })
  325. }
  326. const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
  327. if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
  328. if (finishReason === "MAX_TOKENS") return "length"
  329. if (
  330. finishReason === "IMAGE_SAFETY" ||
  331. finishReason === "RECITATION" ||
  332. finishReason === "SAFETY" ||
  333. finishReason === "BLOCKLIST" ||
  334. finishReason === "PROHIBITED_CONTENT" ||
  335. finishReason === "SPII"
  336. )
  337. return "content-filter"
  338. if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
  339. return "unknown"
  340. }
  341. const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
  342. state.finishReason || state.usage
  343. ? (() => {
  344. const events: LLMEvent[] = []
  345. const lifecycle = state.reasoningSignature
  346. ? Lifecycle.reasoningEnd(
  347. state.lifecycle,
  348. events,
  349. "reasoning-0",
  350. googleMetadata({ thoughtSignature: state.reasoningSignature }),
  351. )
  352. : state.lifecycle
  353. Lifecycle.finish(lifecycle, events, {
  354. reason: mapFinishReason(state.finishReason, state.hasToolCalls),
  355. usage: state.usage,
  356. })
  357. return events
  358. })()
  359. : []
  360. const step = (state: ParserState, event: GeminiEvent) => {
  361. const nextState = {
  362. ...state,
  363. usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
  364. }
  365. const candidate = event.candidates?.[0]
  366. if (!candidate?.content)
  367. return Effect.succeed([
  368. { ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
  369. [],
  370. ] as const)
  371. const events: LLMEvent[] = []
  372. let hasToolCalls = nextState.hasToolCalls
  373. let lifecycle = nextState.lifecycle
  374. let nextToolCallId = nextState.nextToolCallId
  375. let reasoningSignature = nextState.reasoningSignature
  376. for (const part of candidate.content.parts) {
  377. if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
  378. reasoningSignature = part.thoughtSignature
  379. if ("text" in part && part.text.length > 0) {
  380. if (part.thought) {
  381. lifecycle = Lifecycle.reasoningDelta(
  382. lifecycle,
  383. events,
  384. "reasoning-0",
  385. part.text,
  386. part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
  387. )
  388. continue
  389. }
  390. lifecycle = Lifecycle.reasoningEnd(
  391. lifecycle,
  392. events,
  393. "reasoning-0",
  394. reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
  395. )
  396. lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
  397. continue
  398. }
  399. if ("functionCall" in part) {
  400. const input = part.functionCall.args
  401. const id = `tool_${nextToolCallId++}`
  402. lifecycle = Lifecycle.reasoningEnd(
  403. lifecycle,
  404. events,
  405. "reasoning-0",
  406. reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
  407. )
  408. lifecycle = Lifecycle.stepStart(lifecycle, events)
  409. events.push(
  410. LLMEvent.toolCall({
  411. id,
  412. name: part.functionCall.name,
  413. input,
  414. providerMetadata: part.thoughtSignature
  415. ? googleMetadata({ thoughtSignature: part.thoughtSignature })
  416. : undefined,
  417. }),
  418. )
  419. hasToolCalls = true
  420. }
  421. }
  422. return Effect.succeed([
  423. {
  424. ...nextState,
  425. hasToolCalls,
  426. lifecycle,
  427. nextToolCallId,
  428. reasoningSignature,
  429. finishReason: candidate.finishReason ?? nextState.finishReason,
  430. },
  431. events,
  432. ] as const)
  433. }
  434. // =============================================================================
  435. // Protocol And Gemini Route
  436. // =============================================================================
  437. /**
  438. * The Gemini protocol — request body construction, body schema, and the
  439. * streaming-event state machine. Used by Google AI Studio Gemini and (once
  440. * registered) Vertex Gemini.
  441. */
  442. export const protocol = Protocol.make({
  443. id: ADAPTER,
  444. body: {
  445. schema: GeminiBody,
  446. from: fromRequest,
  447. },
  448. stream: {
  449. event: Protocol.jsonEvent(GeminiEvent),
  450. initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
  451. step,
  452. onHalt: finish,
  453. },
  454. })
  455. export const route = Route.make({
  456. id: ADAPTER,
  457. provider: "google",
  458. protocol,
  459. // Gemini's path embeds the model id and pins SSE framing at the URL level.
  460. endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, {
  461. baseURL: DEFAULT_BASE_URL,
  462. }),
  463. auth: Auth.none,
  464. framing: Framing.sse,
  465. })
  466. export * as Gemini from "./gemini"