@codilore/llm 1.15.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/AGENTS.md +321 -0
  2. package/README.md +131 -0
  3. package/example/call-sites.md +591 -0
  4. package/example/tutorial.ts +255 -0
  5. package/package.json +50 -0
  6. package/script/recording-cost-report.ts +250 -0
  7. package/script/setup-recording-env.ts +542 -0
  8. package/src/cache-policy.ts +111 -0
  9. package/src/index.ts +32 -0
  10. package/src/llm.ts +186 -0
  11. package/src/protocols/anthropic-messages.ts +841 -0
  12. package/src/protocols/bedrock-converse.ts +649 -0
  13. package/src/protocols/bedrock-event-stream.ts +87 -0
  14. package/src/protocols/gemini.ts +465 -0
  15. package/src/protocols/index.ts +6 -0
  16. package/src/protocols/openai-chat.ts +431 -0
  17. package/src/protocols/openai-compatible-chat.ts +24 -0
  18. package/src/protocols/openai-responses.ts +987 -0
  19. package/src/protocols/shared.ts +283 -0
  20. package/src/protocols/utils/bedrock-auth.ts +70 -0
  21. package/src/protocols/utils/bedrock-cache.ts +37 -0
  22. package/src/protocols/utils/bedrock-media.ts +80 -0
  23. package/src/protocols/utils/cache.ts +16 -0
  24. package/src/protocols/utils/gemini-tool-schema.ts +101 -0
  25. package/src/protocols/utils/lifecycle.ts +102 -0
  26. package/src/protocols/utils/openai-options.ts +84 -0
  27. package/src/protocols/utils/tool-stream.ts +218 -0
  28. package/src/provider.ts +37 -0
  29. package/src/providers/amazon-bedrock.ts +43 -0
  30. package/src/providers/anthropic.ts +35 -0
  31. package/src/providers/azure.ts +110 -0
  32. package/src/providers/cloudflare.ts +127 -0
  33. package/src/providers/github-copilot.ts +66 -0
  34. package/src/providers/google.ts +35 -0
  35. package/src/providers/index.ts +11 -0
  36. package/src/providers/openai-compatible-profile.ts +20 -0
  37. package/src/providers/openai-compatible.ts +65 -0
  38. package/src/providers/openai-options.ts +81 -0
  39. package/src/providers/openai.ts +63 -0
  40. package/src/providers/openrouter.ts +98 -0
  41. package/src/providers/xai.ts +56 -0
  42. package/src/route/auth-options.ts +57 -0
  43. package/src/route/auth.ts +156 -0
  44. package/src/route/client.ts +434 -0
  45. package/src/route/endpoint.ts +53 -0
  46. package/src/route/executor.ts +374 -0
  47. package/src/route/framing.ts +27 -0
  48. package/src/route/index.ts +25 -0
  49. package/src/route/protocol.ts +84 -0
  50. package/src/route/transport/http.ts +108 -0
  51. package/src/route/transport/index.ts +33 -0
  52. package/src/route/transport/websocket.ts +280 -0
  53. package/src/schema/errors.ts +203 -0
  54. package/src/schema/events.ts +370 -0
  55. package/src/schema/ids.ts +43 -0
  56. package/src/schema/index.ts +5 -0
  57. package/src/schema/messages.ts +404 -0
  58. package/src/schema/options.ts +221 -0
  59. package/src/tool-runtime.ts +78 -0
  60. package/src/tool.ts +241 -0
  61. package/src/utils/record.ts +3 -0
  62. package/sst-env.d.ts +10 -0
  63. package/test/adapter.test.ts +164 -0
  64. package/test/auth-options.types.ts +168 -0
  65. package/test/auth.test.ts +103 -0
  66. package/test/cache-policy.test.ts +262 -0
  67. package/test/continuation-scenarios.ts +104 -0
  68. package/test/endpoint.test.ts +58 -0
  69. package/test/executor.test.ts +418 -0
  70. package/test/exports.test.ts +62 -0
  71. package/test/fixtures/media/restroom.png +0 -0
  72. package/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json +29 -0
  73. package/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json +43 -0
  74. package/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json +56 -0
  75. package/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json +29 -0
  76. package/test/fixtures/recordings/anthropic-messages/streams-text.json +29 -0
  77. package/test/fixtures/recordings/anthropic-messages/streams-tool-call.json +29 -0
  78. package/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json +48 -0
  79. package/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json +55 -0
  80. package/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json +29 -0
  81. package/test/fixtures/recordings/bedrock-converse/streams-text.json +29 -0
  82. package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
  83. package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json +32 -0
  84. package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
  85. package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json +32 -0
  86. package/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json +32 -0
  87. package/test/fixtures/recordings/gemini/streams-text.json +28 -0
  88. package/test/fixtures/recordings/gemini/streams-tool-call.json +28 -0
  89. package/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json +46 -0
  90. package/test/fixtures/recordings/openai-chat/continues-after-tool-result.json +28 -0
  91. package/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json +46 -0
  92. package/test/fixtures/recordings/openai-chat/streams-text.json +28 -0
  93. package/test/fixtures/recordings/openai-chat/streams-tool-call.json +28 -0
  94. package/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json +28 -0
  95. package/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json +53 -0
  96. package/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json +28 -0
  97. package/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json +28 -0
  98. package/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json +54 -0
  99. package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json +53 -0
  100. package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json +54 -0
  101. package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json +28 -0
  102. package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json +28 -0
  103. package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json +28 -0
  104. package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json +28 -0
  105. package/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json +54 -0
  106. package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json +28 -0
  107. package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json +28 -0
  108. package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +42 -0
  109. package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +58 -0
  110. package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json +32 -0
  111. package/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json +46 -0
  112. package/test/generate-object.test.ts +184 -0
  113. package/test/lib/effect.ts +50 -0
  114. package/test/lib/http.ts +98 -0
  115. package/test/lib/openai-chunks.ts +27 -0
  116. package/test/lib/sse.ts +17 -0
  117. package/test/lib/tool-runtime.ts +146 -0
  118. package/test/llm.test.ts +167 -0
  119. package/test/provider/anthropic-messages-cache.recorded.test.ts +54 -0
  120. package/test/provider/anthropic-messages.recorded.test.ts +46 -0
  121. package/test/provider/anthropic-messages.test.ts +829 -0
  122. package/test/provider/bedrock-converse-cache.recorded.test.ts +54 -0
  123. package/test/provider/bedrock-converse.test.ts +707 -0
  124. package/test/provider/cloudflare.test.ts +230 -0
  125. package/test/provider/gemini-cache.recorded.test.ts +48 -0
  126. package/test/provider/gemini.test.ts +476 -0
  127. package/test/provider/golden.recorded.test.ts +219 -0
  128. package/test/provider/openai-chat.test.ts +446 -0
  129. package/test/provider/openai-compatible-chat.test.ts +238 -0
  130. package/test/provider/openai-responses-cache.recorded.test.ts +46 -0
  131. package/test/provider/openai-responses.test.ts +1322 -0
  132. package/test/provider/openrouter.test.ts +56 -0
  133. package/test/provider.types.ts +41 -0
  134. package/test/recorded-golden.ts +97 -0
  135. package/test/recorded-runner.ts +100 -0
  136. package/test/recorded-scenarios.ts +531 -0
  137. package/test/recorded-test.ts +74 -0
  138. package/test/recorded-utils.ts +56 -0
  139. package/test/recorded-websocket.ts +26 -0
  140. package/test/route.test.ts +43 -0
  141. package/test/schema.test.ts +97 -0
  142. package/test/tool-runtime.test.ts +802 -0
  143. package/test/tool-stream.test.ts +99 -0
  144. package/test/tool.types.ts +40 -0
  145. package/tsconfig.json +15 -0
@@ -0,0 +1,87 @@
1
+ import { EventStreamCodec } from "@smithy/eventstream-codec"
2
+ import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
3
+ import { Effect, Stream } from "effect"
4
+ import type { Framing } from "../route/framing"
5
+ import { ProviderShared } from "./shared"
6
+
7
+ // Bedrock streams responses using the AWS event stream binary protocol — each
8
+ // frame is `[length:4][headers-length:4][prelude-crc:4][headers][payload][crc:4]`.
9
+ // We use `@smithy/eventstream-codec` to validate framing and CRCs, then
10
+ // reconstruct the JSON wrapping by `:event-type` so the chunk schema can match.
11
+ const eventCodec = new EventStreamCodec(toUtf8, fromUtf8)
12
+ const utf8 = new TextDecoder()
13
+
14
+ // Cursor-tracking buffer state. Bytes accumulate in `buffer`; `offset` is the
15
+ // read position. Reading by `subarray` is zero-copy. We only allocate a fresh
16
+ // buffer when a new network chunk arrives and we need to append.
17
+ interface FrameBufferState {
18
+ readonly buffer: Uint8Array
19
+ readonly offset: number
20
+ }
21
+
22
+ const initialFrameBuffer: FrameBufferState = { buffer: new Uint8Array(0), offset: 0 }
23
+
24
+ const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferState => {
25
+ const remaining = state.buffer.length - state.offset
26
+ // Compact: drop the consumed prefix and append the new chunk in one alloc.
27
+ // This bounds buffer growth to at most one network chunk past the live
28
+ // window, regardless of stream length.
29
+ const next = new Uint8Array(remaining + chunk.length)
30
+ next.set(state.buffer.subarray(state.offset), 0)
31
+ next.set(chunk, remaining)
32
+ return { buffer: next, offset: 0 }
33
+ }
34
+
35
+ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8Array) =>
36
+ Effect.gen(function* () {
37
+ let cursor = appendChunk(state, chunk)
38
+ const out: object[] = []
39
+ while (cursor.buffer.length - cursor.offset >= 4) {
40
+ const view = cursor.buffer.subarray(cursor.offset)
41
+ const totalLength = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0, false)
42
+ if (view.length < totalLength) break
43
+
44
+ const decoded = yield* Effect.try({
45
+ try: () => eventCodec.decode(view.subarray(0, totalLength)),
46
+ catch: (error) =>
47
+ ProviderShared.eventError(
48
+ route,
49
+ `Failed to decode Bedrock Converse event-stream frame: ${
50
+ error instanceof Error ? error.message : String(error)
51
+ }`,
52
+ ),
53
+ })
54
+ cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
55
+
56
+ if (decoded.headers[":message-type"]?.value !== "event") continue
57
+ const eventType = decoded.headers[":event-type"]?.value
58
+ if (typeof eventType !== "string") continue
59
+ const payload = utf8.decode(decoded.body)
60
+ if (!payload) continue
61
+ // The AWS event stream pads short payloads with a `p` field. Drop it
62
+ // before handing the object to the chunk schema. JSON decode goes
63
+ // through the shared Schema-driven codec to satisfy the package rule
64
+ // against ad-hoc `JSON.parse` calls.
65
+ const parsed = (yield* ProviderShared.parseJson(
66
+ route,
67
+ payload,
68
+ "Failed to parse Bedrock Converse event-stream payload",
69
+ )) as Record<string, unknown>
70
+ delete parsed.p
71
+ out.push({ [eventType]: parsed })
72
+ }
73
+ return [cursor, out] as const
74
+ })
75
+
76
+ /**
77
+ * AWS event-stream framing for Bedrock Converse. Each frame is decoded by
78
+ * `@smithy/eventstream-codec` (length + header + payload + CRC) and rewrapped
79
+ * under its `:event-type` header so the chunk schema can match the JSON
80
+ * payload directly.
81
+ */
82
+ export const framing = (route: string): Framing<object> => ({
83
+ id: "aws-event-stream",
84
+ frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))),
85
+ })
86
+
87
+ export * as BedrockEventStream from "./bedrock-event-stream"
@@ -0,0 +1,465 @@
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 ProviderMetadata,
14
+ type TextPart,
15
+ type ToolCallPart,
16
+ type ToolDefinition,
17
+ } from "../schema"
18
+ import { JsonObject, optionalArray, ProviderShared } from "./shared"
19
+ import { GeminiToolSchema } from "./utils/gemini-tool-schema"
20
+ import { Lifecycle } from "./utils/lifecycle"
21
+
22
+ const ADAPTER = "gemini"
23
+ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
24
+
25
+ // =============================================================================
26
+ // Request Body Schema
27
+ // =============================================================================
28
+ const GeminiTextPart = Schema.Struct({
29
+ text: Schema.String,
30
+ thought: Schema.optional(Schema.Boolean),
31
+ thoughtSignature: Schema.optional(Schema.String),
32
+ })
33
+
34
+ const GeminiInlineDataPart = Schema.Struct({
35
+ inlineData: Schema.Struct({
36
+ mimeType: Schema.String,
37
+ data: Schema.String,
38
+ }),
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
+
49
+ const GeminiFunctionResponsePart = Schema.Struct({
50
+ functionResponse: Schema.Struct({
51
+ name: Schema.String,
52
+ response: Schema.Unknown,
53
+ }),
54
+ })
55
+
56
+ const GeminiContentPart = Schema.Union([
57
+ GeminiTextPart,
58
+ GeminiInlineDataPart,
59
+ GeminiFunctionCallPart,
60
+ GeminiFunctionResponsePart,
61
+ ])
62
+
63
+ const GeminiContent = Schema.Struct({
64
+ role: Schema.Literals(["user", "model"]),
65
+ parts: Schema.Array(GeminiContentPart),
66
+ })
67
+ type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
68
+
69
+ const GeminiSystemInstruction = Schema.Struct({
70
+ parts: Schema.Array(Schema.Struct({ text: Schema.String })),
71
+ })
72
+
73
+ const GeminiFunctionDeclaration = Schema.Struct({
74
+ name: Schema.String,
75
+ description: Schema.String,
76
+ parameters: Schema.optional(JsonObject),
77
+ })
78
+
79
+ const GeminiTool = Schema.Struct({
80
+ functionDeclarations: Schema.Array(GeminiFunctionDeclaration),
81
+ })
82
+
83
+ const GeminiToolConfig = Schema.Struct({
84
+ functionCallingConfig: Schema.Struct({
85
+ mode: Schema.Literals(["AUTO", "NONE", "ANY"]),
86
+ allowedFunctionNames: optionalArray(Schema.String),
87
+ }),
88
+ })
89
+
90
+ const GeminiThinkingConfig = Schema.Struct({
91
+ thinkingBudget: Schema.optional(Schema.Number),
92
+ includeThoughts: Schema.optional(Schema.Boolean),
93
+ })
94
+
95
+ const GeminiGenerationConfig = Schema.Struct({
96
+ maxOutputTokens: Schema.optional(Schema.Number),
97
+ temperature: Schema.optional(Schema.Number),
98
+ topP: Schema.optional(Schema.Number),
99
+ topK: Schema.optional(Schema.Number),
100
+ stopSequences: optionalArray(Schema.String),
101
+ thinkingConfig: Schema.optional(GeminiThinkingConfig),
102
+ })
103
+
104
+ const GeminiBodyFields = {
105
+ contents: Schema.Array(GeminiContent),
106
+ systemInstruction: Schema.optional(GeminiSystemInstruction),
107
+ tools: optionalArray(GeminiTool),
108
+ toolConfig: Schema.optional(GeminiToolConfig),
109
+ generationConfig: Schema.optional(GeminiGenerationConfig),
110
+ }
111
+ const GeminiBody = Schema.Struct(GeminiBodyFields)
112
+ export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
113
+
114
+ const GeminiUsage = Schema.Struct({
115
+ cachedContentTokenCount: Schema.optional(Schema.Number),
116
+ thoughtsTokenCount: Schema.optional(Schema.Number),
117
+ promptTokenCount: Schema.optional(Schema.Number),
118
+ candidatesTokenCount: Schema.optional(Schema.Number),
119
+ totalTokenCount: Schema.optional(Schema.Number),
120
+ })
121
+ type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
122
+
123
+ const GeminiCandidate = Schema.Struct({
124
+ content: Schema.optional(GeminiContent),
125
+ finishReason: Schema.optional(Schema.String),
126
+ })
127
+
128
+ const GeminiEvent = Schema.Struct({
129
+ candidates: optionalArray(GeminiCandidate),
130
+ usageMetadata: Schema.optional(GeminiUsage),
131
+ })
132
+ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
133
+
134
+ interface ParserState {
135
+ readonly finishReason?: string
136
+ readonly hasToolCalls: boolean
137
+ readonly nextToolCallId: number
138
+ readonly usage?: Usage
139
+ readonly lifecycle: Lifecycle.State
140
+ readonly reasoningSignature?: string
141
+ }
142
+
143
+ const mediaData = ProviderShared.mediaBytes
144
+
145
+ // =============================================================================
146
+ // Tool Schema Conversion
147
+ // =============================================================================
148
+ // Tool-schema conversion has two distinct concerns:
149
+ //
150
+ // 1. Sanitize — fix common authoring mistakes Gemini rejects: integer/number
151
+ // enums (must be strings), `required` entries that don't match a property,
152
+ // untyped arrays (`items` must be present), and `properties`/`required`
153
+ // keys on non-object scalars. Mirrors Codilore's historical Gemini rules.
154
+ //
155
+ // 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
156
+ // drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
157
+ // coerce `const` to `[const]` enum, recurse properties/items, propagate
158
+ // only an allowlisted set of keys (description, required, format, type,
159
+ // properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
160
+ // allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
161
+ //
162
+ // Sanitize runs first, then project. The implementation lives in
163
+ // `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
164
+ // provider protocols.
165
+
166
+ // =============================================================================
167
+ // Request Lowering
168
+ // =============================================================================
169
+ const lowerTool = (tool: ToolDefinition) => ({
170
+ name: tool.name,
171
+ description: tool.description,
172
+ parameters: GeminiToolSchema.convert(tool.inputSchema),
173
+ })
174
+
175
+ const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
176
+ ProviderShared.matchToolChoice("Gemini", toolChoice, {
177
+ auto: () => ({ functionCallingConfig: { mode: "AUTO" as const } }),
178
+ none: () => ({ functionCallingConfig: { mode: "NONE" as const } }),
179
+ required: () => ({ functionCallingConfig: { mode: "ANY" as const } }),
180
+ tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }),
181
+ })
182
+
183
+ const lowerUserPart = (part: TextPart | MediaPart) =>
184
+ part.type === "text" ? { text: part.text } : { inlineData: { mimeType: part.mediaType, data: mediaData(part) } }
185
+
186
+ const googleMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ google: metadata })
187
+
188
+ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
189
+ const google = providerMetadata?.google
190
+ return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string"
191
+ ? google.thoughtSignature
192
+ : undefined
193
+ }
194
+
195
+ const lowerToolCall = (part: ToolCallPart) => ({
196
+ functionCall: { name: part.name, args: part.input },
197
+ thoughtSignature: thoughtSignature(part.providerMetadata),
198
+ })
199
+
200
+ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
201
+ const contents: GeminiContent[] = []
202
+
203
+ for (const message of request.messages) {
204
+ if (message.role === "system") {
205
+ const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
206
+ const previous = contents.at(-1)
207
+ if (previous?.role === "user")
208
+ contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
209
+ else contents.push({ role: "user", parts: [{ text: part.text }] })
210
+ continue
211
+ }
212
+
213
+ if (message.role === "user") {
214
+ const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
215
+ for (const part of message.content) {
216
+ if (!ProviderShared.supportsContent(part, ["text", "media"]))
217
+ return yield* ProviderShared.unsupportedContent("Gemini", "user", ["text", "media"])
218
+ parts.push(lowerUserPart(part))
219
+ }
220
+ contents.push({ role: "user", parts })
221
+ continue
222
+ }
223
+
224
+ if (message.role === "assistant") {
225
+ const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
226
+ for (const part of message.content) {
227
+ if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
228
+ return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
229
+ if (part.type === "text") {
230
+ parts.push({ text: part.text })
231
+ continue
232
+ }
233
+ if (part.type === "reasoning") {
234
+ parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
235
+ continue
236
+ }
237
+ if (part.type === "tool-call") {
238
+ parts.push(lowerToolCall(part))
239
+ continue
240
+ }
241
+ }
242
+ contents.push({ role: "model", parts })
243
+ continue
244
+ }
245
+
246
+ const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
247
+ for (const part of message.content) {
248
+ if (!ProviderShared.supportsContent(part, ["tool-result"]))
249
+ return yield* ProviderShared.unsupportedContent("Gemini", "tool", ["tool-result"])
250
+ parts.push({
251
+ functionResponse: {
252
+ name: part.name,
253
+ response: {
254
+ name: part.name,
255
+ content: ProviderShared.toolResultText(part),
256
+ },
257
+ },
258
+ })
259
+ }
260
+ contents.push({ role: "user", parts })
261
+ }
262
+
263
+ return contents
264
+ })
265
+
266
+ const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini
267
+
268
+ const thinkingConfig = (request: LLMRequest) => {
269
+ const value = geminiOptions(request)?.thinkingConfig
270
+ if (!ProviderShared.isRecord(value)) return undefined
271
+ const result = {
272
+ thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
273
+ includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
274
+ }
275
+ return Object.values(result).some((item) => item !== undefined) ? result : undefined
276
+ }
277
+
278
+ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
279
+ const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
280
+ const generation = request.generation
281
+ const generationConfig = {
282
+ maxOutputTokens: generation?.maxTokens,
283
+ temperature: generation?.temperature,
284
+ topP: generation?.topP,
285
+ topK: generation?.topK,
286
+ stopSequences: generation?.stop,
287
+ thinkingConfig: thinkingConfig(request),
288
+ }
289
+
290
+ return {
291
+ contents: yield* lowerMessages(request),
292
+ systemInstruction:
293
+ request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
294
+ tools: toolsEnabled ? [{ functionDeclarations: request.tools.map(lowerTool) }] : undefined,
295
+ toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
296
+ generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
297
+ ? generationConfig
298
+ : undefined,
299
+ }
300
+ })
301
+
302
+ // =============================================================================
303
+ // Stream Parsing
304
+ // =============================================================================
305
+ // Gemini reports `promptTokenCount` (inclusive total) with a
306
+ // `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
307
+ // of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
308
+ // to produce the inclusive `outputTokens` the rest of the contract expects.
309
+ const mapUsage = (usage: GeminiUsage | undefined) => {
310
+ if (!usage) return undefined
311
+ const cached = usage.cachedContentTokenCount
312
+ const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
313
+ // `candidatesTokenCount` is visible-only; sum with thoughts to produce the
314
+ // inclusive `outputTokens` the contract expects. Only compute the total
315
+ // when the visible component is reported — otherwise we'd fabricate an
316
+ // inclusive number from a partial breakdown.
317
+ const outputTokens =
318
+ usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
319
+ return new Usage({
320
+ inputTokens: usage.promptTokenCount,
321
+ outputTokens,
322
+ nonCachedInputTokens: nonCached,
323
+ cacheReadInputTokens: cached,
324
+ reasoningTokens: usage.thoughtsTokenCount,
325
+ totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
326
+ providerMetadata: { google: usage },
327
+ })
328
+ }
329
+
330
+ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
331
+ if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
332
+ if (finishReason === "MAX_TOKENS") return "length"
333
+ if (
334
+ finishReason === "IMAGE_SAFETY" ||
335
+ finishReason === "RECITATION" ||
336
+ finishReason === "SAFETY" ||
337
+ finishReason === "BLOCKLIST" ||
338
+ finishReason === "PROHIBITED_CONTENT" ||
339
+ finishReason === "SPII"
340
+ )
341
+ return "content-filter"
342
+ if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
343
+ return "unknown"
344
+ }
345
+
346
+ const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
347
+ state.finishReason || state.usage
348
+ ? (() => {
349
+ const events: LLMEvent[] = []
350
+ const lifecycle = state.reasoningSignature
351
+ ? Lifecycle.reasoningEnd(
352
+ state.lifecycle,
353
+ events,
354
+ "reasoning-0",
355
+ googleMetadata({ thoughtSignature: state.reasoningSignature }),
356
+ )
357
+ : state.lifecycle
358
+ Lifecycle.finish(lifecycle, events, {
359
+ reason: mapFinishReason(state.finishReason, state.hasToolCalls),
360
+ usage: state.usage,
361
+ })
362
+ return events
363
+ })()
364
+ : []
365
+
366
+ const step = (state: ParserState, event: GeminiEvent) => {
367
+ const nextState = {
368
+ ...state,
369
+ usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
370
+ }
371
+ const candidate = event.candidates?.[0]
372
+ if (!candidate?.content)
373
+ return Effect.succeed([
374
+ { ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
375
+ [],
376
+ ] as const)
377
+
378
+ const events: LLMEvent[] = []
379
+ let hasToolCalls = nextState.hasToolCalls
380
+ let lifecycle = nextState.lifecycle
381
+ let nextToolCallId = nextState.nextToolCallId
382
+ let reasoningSignature = nextState.reasoningSignature
383
+
384
+ for (const part of candidate.content.parts) {
385
+ if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
386
+ reasoningSignature = part.thoughtSignature
387
+ if ("text" in part && part.text.length > 0) {
388
+ lifecycle = part.thought
389
+ ? Lifecycle.reasoningDelta(
390
+ lifecycle,
391
+ events,
392
+ "reasoning-0",
393
+ part.text,
394
+ part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
395
+ )
396
+ : Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
397
+ continue
398
+ }
399
+
400
+ if ("functionCall" in part) {
401
+ const input = part.functionCall.args
402
+ const id = `tool_${nextToolCallId++}`
403
+ lifecycle = Lifecycle.stepStart(lifecycle, events)
404
+ events.push(
405
+ LLMEvent.toolCall({
406
+ id,
407
+ name: part.functionCall.name,
408
+ input,
409
+ providerMetadata: part.thoughtSignature
410
+ ? googleMetadata({ thoughtSignature: part.thoughtSignature })
411
+ : undefined,
412
+ }),
413
+ )
414
+ hasToolCalls = true
415
+ }
416
+ }
417
+
418
+ return Effect.succeed([
419
+ {
420
+ ...nextState,
421
+ hasToolCalls,
422
+ lifecycle,
423
+ nextToolCallId,
424
+ reasoningSignature,
425
+ finishReason: candidate.finishReason ?? nextState.finishReason,
426
+ },
427
+ events,
428
+ ] as const)
429
+ }
430
+
431
+ // =============================================================================
432
+ // Protocol And Gemini Route
433
+ // =============================================================================
434
+ /**
435
+ * The Gemini protocol — request body construction, body schema, and the
436
+ * streaming-event state machine. Used by Google AI Studio Gemini and (once
437
+ * registered) Vertex Gemini.
438
+ */
439
+ export const protocol = Protocol.make({
440
+ id: ADAPTER,
441
+ body: {
442
+ schema: GeminiBody,
443
+ from: fromRequest,
444
+ },
445
+ stream: {
446
+ event: Protocol.jsonEvent(GeminiEvent),
447
+ initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
448
+ step,
449
+ onHalt: finish,
450
+ },
451
+ })
452
+
453
+ export const route = Route.make({
454
+ id: ADAPTER,
455
+ provider: "google",
456
+ protocol,
457
+ // Gemini's path embeds the model id and pins SSE framing at the URL level.
458
+ endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, {
459
+ baseURL: DEFAULT_BASE_URL,
460
+ }),
461
+ auth: Auth.none,
462
+ framing: Framing.sse,
463
+ })
464
+
465
+ export * as Gemini from "./gemini"
@@ -0,0 +1,6 @@
1
+ export * as AnthropicMessages from "./anthropic-messages"
2
+ export * as BedrockConverse from "./bedrock-converse"
3
+ export * as Gemini from "./gemini"
4
+ export * as OpenAIChat from "./openai-chat"
5
+ export * as OpenAICompatibleChat from "./openai-compatible-chat"
6
+ export * as OpenAIResponses from "./openai-responses"