@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.
- package/AGENTS.md +321 -0
- package/README.md +131 -0
- package/example/call-sites.md +591 -0
- package/example/tutorial.ts +255 -0
- package/package.json +50 -0
- package/script/recording-cost-report.ts +250 -0
- package/script/setup-recording-env.ts +542 -0
- package/src/cache-policy.ts +111 -0
- package/src/index.ts +32 -0
- package/src/llm.ts +186 -0
- package/src/protocols/anthropic-messages.ts +841 -0
- package/src/protocols/bedrock-converse.ts +649 -0
- package/src/protocols/bedrock-event-stream.ts +87 -0
- package/src/protocols/gemini.ts +465 -0
- package/src/protocols/index.ts +6 -0
- package/src/protocols/openai-chat.ts +431 -0
- package/src/protocols/openai-compatible-chat.ts +24 -0
- package/src/protocols/openai-responses.ts +987 -0
- package/src/protocols/shared.ts +283 -0
- package/src/protocols/utils/bedrock-auth.ts +70 -0
- package/src/protocols/utils/bedrock-cache.ts +37 -0
- package/src/protocols/utils/bedrock-media.ts +80 -0
- package/src/protocols/utils/cache.ts +16 -0
- package/src/protocols/utils/gemini-tool-schema.ts +101 -0
- package/src/protocols/utils/lifecycle.ts +102 -0
- package/src/protocols/utils/openai-options.ts +84 -0
- package/src/protocols/utils/tool-stream.ts +218 -0
- package/src/provider.ts +37 -0
- package/src/providers/amazon-bedrock.ts +43 -0
- package/src/providers/anthropic.ts +35 -0
- package/src/providers/azure.ts +110 -0
- package/src/providers/cloudflare.ts +127 -0
- package/src/providers/github-copilot.ts +66 -0
- package/src/providers/google.ts +35 -0
- package/src/providers/index.ts +11 -0
- package/src/providers/openai-compatible-profile.ts +20 -0
- package/src/providers/openai-compatible.ts +65 -0
- package/src/providers/openai-options.ts +81 -0
- package/src/providers/openai.ts +63 -0
- package/src/providers/openrouter.ts +98 -0
- package/src/providers/xai.ts +56 -0
- package/src/route/auth-options.ts +57 -0
- package/src/route/auth.ts +156 -0
- package/src/route/client.ts +434 -0
- package/src/route/endpoint.ts +53 -0
- package/src/route/executor.ts +374 -0
- package/src/route/framing.ts +27 -0
- package/src/route/index.ts +25 -0
- package/src/route/protocol.ts +84 -0
- package/src/route/transport/http.ts +108 -0
- package/src/route/transport/index.ts +33 -0
- package/src/route/transport/websocket.ts +280 -0
- package/src/schema/errors.ts +203 -0
- package/src/schema/events.ts +370 -0
- package/src/schema/ids.ts +43 -0
- package/src/schema/index.ts +5 -0
- package/src/schema/messages.ts +404 -0
- package/src/schema/options.ts +221 -0
- package/src/tool-runtime.ts +78 -0
- package/src/tool.ts +241 -0
- package/src/utils/record.ts +3 -0
- package/sst-env.d.ts +10 -0
- package/test/adapter.test.ts +164 -0
- package/test/auth-options.types.ts +168 -0
- package/test/auth.test.ts +103 -0
- package/test/cache-policy.test.ts +262 -0
- package/test/continuation-scenarios.ts +104 -0
- package/test/endpoint.test.ts +58 -0
- package/test/executor.test.ts +418 -0
- package/test/exports.test.ts +62 -0
- package/test/fixtures/media/restroom.png +0 -0
- package/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json +29 -0
- package/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json +43 -0
- package/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json +56 -0
- package/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json +29 -0
- package/test/fixtures/recordings/anthropic-messages/streams-text.json +29 -0
- package/test/fixtures/recordings/anthropic-messages/streams-tool-call.json +29 -0
- package/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json +48 -0
- package/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json +55 -0
- package/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json +29 -0
- package/test/fixtures/recordings/bedrock-converse/streams-text.json +29 -0
- package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
- package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json +32 -0
- package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
- package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json +32 -0
- package/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json +32 -0
- package/test/fixtures/recordings/gemini/streams-text.json +28 -0
- package/test/fixtures/recordings/gemini/streams-tool-call.json +28 -0
- package/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json +46 -0
- package/test/fixtures/recordings/openai-chat/continues-after-tool-result.json +28 -0
- package/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json +46 -0
- package/test/fixtures/recordings/openai-chat/streams-text.json +28 -0
- package/test/fixtures/recordings/openai-chat/streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json +53 -0
- package/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json +54 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json +53 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json +54 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json +54 -0
- package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +42 -0
- package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +58 -0
- package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json +32 -0
- package/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json +46 -0
- package/test/generate-object.test.ts +184 -0
- package/test/lib/effect.ts +50 -0
- package/test/lib/http.ts +98 -0
- package/test/lib/openai-chunks.ts +27 -0
- package/test/lib/sse.ts +17 -0
- package/test/lib/tool-runtime.ts +146 -0
- package/test/llm.test.ts +167 -0
- package/test/provider/anthropic-messages-cache.recorded.test.ts +54 -0
- package/test/provider/anthropic-messages.recorded.test.ts +46 -0
- package/test/provider/anthropic-messages.test.ts +829 -0
- package/test/provider/bedrock-converse-cache.recorded.test.ts +54 -0
- package/test/provider/bedrock-converse.test.ts +707 -0
- package/test/provider/cloudflare.test.ts +230 -0
- package/test/provider/gemini-cache.recorded.test.ts +48 -0
- package/test/provider/gemini.test.ts +476 -0
- package/test/provider/golden.recorded.test.ts +219 -0
- package/test/provider/openai-chat.test.ts +446 -0
- package/test/provider/openai-compatible-chat.test.ts +238 -0
- package/test/provider/openai-responses-cache.recorded.test.ts +46 -0
- package/test/provider/openai-responses.test.ts +1322 -0
- package/test/provider/openrouter.test.ts +56 -0
- package/test/provider.types.ts +41 -0
- package/test/recorded-golden.ts +97 -0
- package/test/recorded-runner.ts +100 -0
- package/test/recorded-scenarios.ts +531 -0
- package/test/recorded-test.ts +74 -0
- package/test/recorded-utils.ts +56 -0
- package/test/recorded-websocket.ts +26 -0
- package/test/route.test.ts +43 -0
- package/test/schema.test.ts +97 -0
- package/test/tool-runtime.test.ts +802 -0
- package/test/tool-stream.test.ts +99 -0
- package/test/tool.types.ts +40 -0
- 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"
|