gemini.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  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 LLMRequest,
  12. type MediaPart,
  13. type TextPart,
  14. type ToolCallPart,
  15. type ToolDefinition,
  16. } from "../schema"
  17. import { JsonObject, optionalArray, ProviderShared } from "./shared"
  18. import { GeminiToolSchema } from "./utils/gemini-tool-schema"
  19. const ADAPTER = "gemini"
  20. export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
  21. // =============================================================================
  22. // Request Body Schema
  23. // =============================================================================
  24. const GeminiTextPart = Schema.Struct({
  25. text: Schema.String,
  26. thought: Schema.optional(Schema.Boolean),
  27. thoughtSignature: Schema.optional(Schema.String),
  28. })
  29. const GeminiInlineDataPart = Schema.Struct({
  30. inlineData: Schema.Struct({
  31. mimeType: Schema.String,
  32. data: Schema.String,
  33. }),
  34. })
  35. const GeminiFunctionCallPart = Schema.Struct({
  36. functionCall: Schema.Struct({
  37. name: Schema.String,
  38. args: Schema.Unknown,
  39. }),
  40. thoughtSignature: Schema.optional(Schema.String),
  41. })
  42. const GeminiFunctionResponsePart = Schema.Struct({
  43. functionResponse: Schema.Struct({
  44. name: Schema.String,
  45. response: Schema.Unknown,
  46. }),
  47. })
  48. const GeminiContentPart = Schema.Union([
  49. GeminiTextPart,
  50. GeminiInlineDataPart,
  51. GeminiFunctionCallPart,
  52. GeminiFunctionResponsePart,
  53. ])
  54. const GeminiContent = Schema.Struct({
  55. role: Schema.Literals(["user", "model"]),
  56. parts: Schema.Array(GeminiContentPart),
  57. })
  58. type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
  59. const GeminiSystemInstruction = Schema.Struct({
  60. parts: Schema.Array(Schema.Struct({ text: Schema.String })),
  61. })
  62. const GeminiFunctionDeclaration = Schema.Struct({
  63. name: Schema.String,
  64. description: Schema.String,
  65. parameters: Schema.optional(JsonObject),
  66. })
  67. const GeminiTool = Schema.Struct({
  68. functionDeclarations: Schema.Array(GeminiFunctionDeclaration),
  69. })
  70. const GeminiToolConfig = Schema.Struct({
  71. functionCallingConfig: Schema.Struct({
  72. mode: Schema.Literals(["AUTO", "NONE", "ANY"]),
  73. allowedFunctionNames: optionalArray(Schema.String),
  74. }),
  75. })
  76. const GeminiThinkingConfig = Schema.Struct({
  77. thinkingBudget: Schema.optional(Schema.Number),
  78. includeThoughts: Schema.optional(Schema.Boolean),
  79. })
  80. const GeminiGenerationConfig = Schema.Struct({
  81. maxOutputTokens: Schema.optional(Schema.Number),
  82. temperature: Schema.optional(Schema.Number),
  83. topP: Schema.optional(Schema.Number),
  84. topK: Schema.optional(Schema.Number),
  85. stopSequences: optionalArray(Schema.String),
  86. thinkingConfig: Schema.optional(GeminiThinkingConfig),
  87. })
  88. const GeminiBodyFields = {
  89. contents: Schema.Array(GeminiContent),
  90. systemInstruction: Schema.optional(GeminiSystemInstruction),
  91. tools: optionalArray(GeminiTool),
  92. toolConfig: Schema.optional(GeminiToolConfig),
  93. generationConfig: Schema.optional(GeminiGenerationConfig),
  94. }
  95. const GeminiBody = Schema.Struct(GeminiBodyFields)
  96. export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
  97. const GeminiUsage = Schema.Struct({
  98. cachedContentTokenCount: Schema.optional(Schema.Number),
  99. thoughtsTokenCount: Schema.optional(Schema.Number),
  100. promptTokenCount: Schema.optional(Schema.Number),
  101. candidatesTokenCount: Schema.optional(Schema.Number),
  102. totalTokenCount: Schema.optional(Schema.Number),
  103. })
  104. type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
  105. const GeminiCandidate = Schema.Struct({
  106. content: Schema.optional(GeminiContent),
  107. finishReason: Schema.optional(Schema.String),
  108. })
  109. const GeminiEvent = Schema.Struct({
  110. candidates: optionalArray(GeminiCandidate),
  111. usageMetadata: Schema.optional(GeminiUsage),
  112. })
  113. type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
  114. interface ParserState {
  115. readonly finishReason?: string
  116. readonly hasToolCalls: boolean
  117. readonly nextToolCallId: number
  118. readonly usage?: Usage
  119. }
  120. const invalid = ProviderShared.invalidRequest
  121. const mediaData = ProviderShared.mediaBytes
  122. // =============================================================================
  123. // Tool Schema Conversion
  124. // =============================================================================
  125. // Tool-schema conversion has two distinct concerns:
  126. //
  127. // 1. Sanitize — fix common authoring mistakes Gemini rejects: integer/number
  128. // enums (must be strings), `required` entries that don't match a property,
  129. // untyped arrays (`items` must be present), and `properties`/`required`
  130. // keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
  131. //
  132. // 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
  133. // drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
  134. // coerce `const` to `[const]` enum, recurse properties/items, propagate
  135. // only an allowlisted set of keys (description, required, format, type,
  136. // properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
  137. // allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
  138. //
  139. // Sanitize runs first, then project. The implementation lives in
  140. // `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
  141. // provider protocols.
  142. // =============================================================================
  143. // Request Lowering
  144. // =============================================================================
  145. const lowerTool = (tool: ToolDefinition) => ({
  146. name: tool.name,
  147. description: tool.description,
  148. parameters: GeminiToolSchema.convert(tool.inputSchema),
  149. })
  150. const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
  151. ProviderShared.matchToolChoice("Gemini", toolChoice, {
  152. auto: () => ({ functionCallingConfig: { mode: "AUTO" as const } }),
  153. none: () => ({ functionCallingConfig: { mode: "NONE" as const } }),
  154. required: () => ({ functionCallingConfig: { mode: "ANY" as const } }),
  155. tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }),
  156. })
  157. const lowerUserPart = (part: TextPart | MediaPart) =>
  158. part.type === "text" ? { text: part.text } : { inlineData: { mimeType: part.mediaType, data: mediaData(part) } }
  159. const lowerToolCall = (part: ToolCallPart) => ({
  160. functionCall: { name: part.name, args: part.input },
  161. })
  162. const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
  163. const contents: GeminiContent[] = []
  164. for (const message of request.messages) {
  165. if (message.role === "user") {
  166. const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
  167. for (const part of message.content) {
  168. if (!ProviderShared.supportsContent(part, ["text", "media"]))
  169. return yield* ProviderShared.unsupportedContent("Gemini", "user", ["text", "media"])
  170. parts.push(lowerUserPart(part))
  171. }
  172. contents.push({ role: "user", parts })
  173. continue
  174. }
  175. if (message.role === "assistant") {
  176. const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
  177. for (const part of message.content) {
  178. if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
  179. return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
  180. if (part.type === "text") {
  181. parts.push({ text: part.text })
  182. continue
  183. }
  184. if (part.type === "reasoning") {
  185. parts.push({ text: part.text, thought: true })
  186. continue
  187. }
  188. if (part.type === "tool-call") {
  189. parts.push(lowerToolCall(part))
  190. continue
  191. }
  192. }
  193. contents.push({ role: "model", parts })
  194. continue
  195. }
  196. const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
  197. for (const part of message.content) {
  198. if (!ProviderShared.supportsContent(part, ["tool-result"]))
  199. return yield* ProviderShared.unsupportedContent("Gemini", "tool", ["tool-result"])
  200. parts.push({
  201. functionResponse: {
  202. name: part.name,
  203. response: {
  204. name: part.name,
  205. content: ProviderShared.toolResultText(part),
  206. },
  207. },
  208. })
  209. }
  210. contents.push({ role: "user", parts })
  211. }
  212. return contents
  213. })
  214. const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini
  215. const thinkingConfig = (request: LLMRequest) => {
  216. const value = geminiOptions(request)?.thinkingConfig
  217. if (!ProviderShared.isRecord(value)) return undefined
  218. const result = {
  219. thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
  220. includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
  221. }
  222. return Object.values(result).some((item) => item !== undefined) ? result : undefined
  223. }
  224. const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
  225. const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
  226. const generation = request.generation
  227. const generationConfig = {
  228. maxOutputTokens: generation?.maxTokens,
  229. temperature: generation?.temperature,
  230. topP: generation?.topP,
  231. topK: generation?.topK,
  232. stopSequences: generation?.stop,
  233. thinkingConfig: thinkingConfig(request),
  234. }
  235. return {
  236. contents: yield* lowerMessages(request),
  237. systemInstruction:
  238. request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
  239. tools: toolsEnabled ? [{ functionDeclarations: request.tools.map(lowerTool) }] : undefined,
  240. toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
  241. generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
  242. ? generationConfig
  243. : undefined,
  244. }
  245. })
  246. // =============================================================================
  247. // Stream Parsing
  248. // =============================================================================
  249. const mapUsage = (usage: GeminiUsage | undefined) => {
  250. if (!usage) return undefined
  251. return new Usage({
  252. inputTokens: usage.promptTokenCount,
  253. outputTokens: usage.candidatesTokenCount,
  254. reasoningTokens: usage.thoughtsTokenCount,
  255. cacheReadInputTokens: usage.cachedContentTokenCount,
  256. totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount),
  257. native: usage,
  258. })
  259. }
  260. const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
  261. if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
  262. if (finishReason === "MAX_TOKENS") return "length"
  263. if (
  264. finishReason === "IMAGE_SAFETY" ||
  265. finishReason === "RECITATION" ||
  266. finishReason === "SAFETY" ||
  267. finishReason === "BLOCKLIST" ||
  268. finishReason === "PROHIBITED_CONTENT" ||
  269. finishReason === "SPII"
  270. )
  271. return "content-filter"
  272. if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
  273. return "unknown"
  274. }
  275. const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
  276. state.finishReason || state.usage
  277. ? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })]
  278. : []
  279. const step = (state: ParserState, event: GeminiEvent) => {
  280. const nextState = {
  281. ...state,
  282. usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
  283. }
  284. const candidate = event.candidates?.[0]
  285. if (!candidate?.content)
  286. return Effect.succeed([
  287. { ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
  288. [],
  289. ] as const)
  290. const events: LLMEvent[] = []
  291. let hasToolCalls = nextState.hasToolCalls
  292. let nextToolCallId = nextState.nextToolCallId
  293. for (const part of candidate.content.parts) {
  294. if ("text" in part && part.text.length > 0) {
  295. events.push(
  296. part.thought
  297. ? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text })
  298. : LLMEvent.textDelta({ id: "text-0", text: part.text }),
  299. )
  300. continue
  301. }
  302. if ("functionCall" in part) {
  303. const input = part.functionCall.args
  304. const id = `tool_${nextToolCallId++}`
  305. events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input }))
  306. hasToolCalls = true
  307. }
  308. }
  309. return Effect.succeed([
  310. {
  311. ...nextState,
  312. hasToolCalls,
  313. nextToolCallId,
  314. finishReason: candidate.finishReason ?? nextState.finishReason,
  315. },
  316. events,
  317. ] as const)
  318. }
  319. // =============================================================================
  320. // Protocol And Gemini Route
  321. // =============================================================================
  322. /**
  323. * The Gemini protocol — request body construction, body schema, and the
  324. * streaming-event state machine. Used by Google AI Studio Gemini and (once
  325. * registered) Vertex Gemini.
  326. */
  327. export const protocol = Protocol.make({
  328. id: ADAPTER,
  329. body: {
  330. schema: GeminiBody,
  331. from: fromRequest,
  332. },
  333. stream: {
  334. event: Protocol.jsonEvent(GeminiEvent),
  335. initial: () => ({ hasToolCalls: false, nextToolCallId: 0 }),
  336. step,
  337. onHalt: finish,
  338. },
  339. })
  340. export const route = Route.make({
  341. id: ADAPTER,
  342. protocol,
  343. // Gemini's path embeds the model id and pins SSE framing at the URL level.
  344. endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`),
  345. auth: Auth.apiKeyHeader("x-goog-api-key"),
  346. framing: Framing.sse,
  347. })
  348. // =============================================================================
  349. // Model Helper
  350. // =============================================================================
  351. export const model = Route.model(route, {
  352. provider: "google",
  353. baseURL: DEFAULT_BASE_URL,
  354. })
  355. export * as Gemini from "./gemini"