@neurocode-ai/llm 1.18.8

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