@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,431 @@
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 { HttpTransport } from "../route/transport"
6
+ import { Protocol } from "../route/protocol"
7
+ import {
8
+ LLMEvent,
9
+ Usage,
10
+ type FinishReason,
11
+ type LLMRequest,
12
+ type ReasoningPart,
13
+ type TextPart,
14
+ type ToolCallPart,
15
+ type ToolDefinition,
16
+ } from "../schema"
17
+ import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
18
+ import { OpenAIOptions } from "./utils/openai-options"
19
+ import { Lifecycle } from "./utils/lifecycle"
20
+ import { ToolStream } from "./utils/tool-stream"
21
+
22
+ const ADAPTER = "openai-chat"
23
+ export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
24
+ export const PATH = "/chat/completions"
25
+
26
+ // =============================================================================
27
+ // Request Body Schema
28
+ // =============================================================================
29
+ // The body schema is the provider-native JSON body. `fromRequest` below builds
30
+ // this shape from the common `LLMRequest`, then `Route.make` validates and
31
+ // JSON-encodes it before transport.
32
+ const OpenAIChatFunction = Schema.Struct({
33
+ name: Schema.String,
34
+ description: Schema.String,
35
+ parameters: JsonObject,
36
+ })
37
+
38
+ const OpenAIChatTool = Schema.Struct({
39
+ type: Schema.tag("function"),
40
+ function: OpenAIChatFunction,
41
+ })
42
+ type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
43
+
44
+ const OpenAIChatAssistantToolCall = Schema.Struct({
45
+ id: Schema.String,
46
+ type: Schema.tag("function"),
47
+ function: Schema.Struct({
48
+ name: Schema.String,
49
+ arguments: Schema.String,
50
+ }),
51
+ })
52
+ type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
53
+
54
+ const OpenAIChatMessage = Schema.Union([
55
+ Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
56
+ Schema.Struct({ role: Schema.Literal("user"), content: Schema.String }),
57
+ Schema.Struct({
58
+ role: Schema.Literal("assistant"),
59
+ content: Schema.NullOr(Schema.String),
60
+ tool_calls: optionalArray(OpenAIChatAssistantToolCall),
61
+ reasoning_content: Schema.optional(Schema.String),
62
+ }),
63
+ Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
64
+ ]).pipe(Schema.toTaggedUnion("role"))
65
+ type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
66
+
67
+ const OpenAIChatToolChoice = Schema.Union([
68
+ Schema.Literals(["auto", "none", "required"]),
69
+ Schema.Struct({
70
+ type: Schema.tag("function"),
71
+ function: Schema.Struct({ name: Schema.String }),
72
+ }),
73
+ ])
74
+
75
+ export const bodyFields = {
76
+ model: Schema.String,
77
+ messages: Schema.Array(OpenAIChatMessage),
78
+ tools: optionalArray(OpenAIChatTool),
79
+ tool_choice: Schema.optional(OpenAIChatToolChoice),
80
+ stream: Schema.Literal(true),
81
+ stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
82
+ store: Schema.optional(Schema.Boolean),
83
+ reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
84
+ max_tokens: Schema.optional(Schema.Number),
85
+ temperature: Schema.optional(Schema.Number),
86
+ top_p: Schema.optional(Schema.Number),
87
+ frequency_penalty: Schema.optional(Schema.Number),
88
+ presence_penalty: Schema.optional(Schema.Number),
89
+ seed: Schema.optional(Schema.Number),
90
+ stop: optionalArray(Schema.String),
91
+ }
92
+ const OpenAIChatBody = Schema.Struct(bodyFields)
93
+ export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
94
+
95
+ // =============================================================================
96
+ // Streaming Event Schema
97
+ // =============================================================================
98
+ // The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the
99
+ // byte stream into strings, then `Protocol.jsonEvent` decodes each string into
100
+ // this provider-native event shape.
101
+ const OpenAIChatUsage = Schema.Struct({
102
+ prompt_tokens: Schema.optional(Schema.Number),
103
+ completion_tokens: Schema.optional(Schema.Number),
104
+ total_tokens: Schema.optional(Schema.Number),
105
+ prompt_tokens_details: optionalNull(
106
+ Schema.Struct({
107
+ cached_tokens: Schema.optional(Schema.Number),
108
+ }),
109
+ ),
110
+ completion_tokens_details: optionalNull(
111
+ Schema.Struct({
112
+ reasoning_tokens: Schema.optional(Schema.Number),
113
+ }),
114
+ ),
115
+ })
116
+
117
+ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
118
+ name: optionalNull(Schema.String),
119
+ arguments: optionalNull(Schema.String),
120
+ })
121
+
122
+ const OpenAIChatToolCallDelta = Schema.Struct({
123
+ index: Schema.Number,
124
+ id: optionalNull(Schema.String),
125
+ function: optionalNull(OpenAIChatToolCallDeltaFunction),
126
+ })
127
+ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
128
+
129
+ const OpenAIChatDelta = Schema.Struct({
130
+ content: optionalNull(Schema.String),
131
+ reasoning_content: optionalNull(Schema.String),
132
+ tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
133
+ })
134
+
135
+ const OpenAIChatChoice = Schema.Struct({
136
+ delta: optionalNull(OpenAIChatDelta),
137
+ finish_reason: optionalNull(Schema.String),
138
+ })
139
+
140
+ const OpenAIChatEvent = Schema.Struct({
141
+ choices: Schema.Array(OpenAIChatChoice),
142
+ usage: optionalNull(OpenAIChatUsage),
143
+ })
144
+ type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
145
+ type OpenAIChatRequestMessage = LLMRequest["messages"][number]
146
+
147
+ interface ParserState {
148
+ readonly tools: ToolStream.State<number>
149
+ readonly toolCallEvents: ReadonlyArray<LLMEvent>
150
+ readonly usage?: Usage
151
+ readonly finishReason?: FinishReason
152
+ readonly lifecycle: Lifecycle.State
153
+ }
154
+
155
+ const invalid = ProviderShared.invalidRequest
156
+
157
+ // =============================================================================
158
+ // Request Lowering
159
+ // =============================================================================
160
+ // Lowering is the only place that knows how common LLM messages map onto the
161
+ // OpenAI Chat wire format. Keep provider quirks here instead of leaking native
162
+ // fields into `LLMRequest`.
163
+ const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({
164
+ type: "function",
165
+ function: {
166
+ name: tool.name,
167
+ description: tool.description,
168
+ parameters: tool.inputSchema,
169
+ },
170
+ })
171
+
172
+ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
173
+ ProviderShared.matchToolChoice("OpenAI Chat", toolChoice, {
174
+ auto: () => "auto" as const,
175
+ none: () => "none" as const,
176
+ required: () => "required" as const,
177
+ tool: (name) => ({ type: "function" as const, function: { name } }),
178
+ })
179
+
180
+ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
181
+ id: part.id,
182
+ type: "function",
183
+ function: {
184
+ name: part.name,
185
+ arguments: ProviderShared.encodeJson(part.input),
186
+ },
187
+ })
188
+
189
+ const openAICompatibleReasoningContent = (native: unknown) =>
190
+ isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
191
+
192
+ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
193
+ const content: TextPart[] = []
194
+ for (const part of message.content) {
195
+ if (!ProviderShared.supportsContent(part, ["text"]))
196
+ return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text"])
197
+ content.push(part)
198
+ }
199
+ return { role: "user" as const, content: ProviderShared.joinText(content) }
200
+ })
201
+
202
+ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
203
+ message: OpenAIChatRequestMessage,
204
+ ) {
205
+ const content: TextPart[] = []
206
+ const reasoning: ReasoningPart[] = []
207
+ const toolCalls: OpenAIChatAssistantToolCall[] = []
208
+ for (const part of message.content) {
209
+ if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
210
+ return yield* ProviderShared.unsupportedContent("OpenAI Chat", "assistant", ["text", "reasoning", "tool-call"])
211
+ if (part.type === "text") {
212
+ content.push(part)
213
+ continue
214
+ }
215
+ if (part.type === "reasoning") {
216
+ reasoning.push(part)
217
+ continue
218
+ }
219
+ if (part.type === "tool-call") {
220
+ toolCalls.push(lowerToolCall(part))
221
+ continue
222
+ }
223
+ }
224
+ return {
225
+ role: "assistant" as const,
226
+ content: content.length === 0 ? null : ProviderShared.joinText(content),
227
+ tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
228
+ reasoning_content:
229
+ reasoning.length > 0
230
+ ? reasoning.map((part) => part.text).join("")
231
+ : openAICompatibleReasoningContent(message.native?.openaiCompatible),
232
+ }
233
+ })
234
+
235
+ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
236
+ const messages: OpenAIChatMessage[] = []
237
+ for (const part of message.content) {
238
+ if (!ProviderShared.supportsContent(part, ["tool-result"]))
239
+ return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
240
+ messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
241
+ }
242
+ return messages
243
+ })
244
+
245
+ const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) {
246
+ if (message.role === "user") return [yield* lowerUserMessage(message)]
247
+ if (message.role === "assistant") return [yield* lowerAssistantMessage(message)]
248
+ return yield* lowerToolMessages(message)
249
+ })
250
+
251
+ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
252
+ const system: OpenAIChatMessage[] =
253
+ request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
254
+ const messages = [...system]
255
+ for (const message of request.messages) {
256
+ if (message.role === "system") {
257
+ const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
258
+ const previous = messages.at(-1)
259
+ if (previous?.role === "user")
260
+ messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` }
261
+ else messages.push({ role: "user", content: part.text })
262
+ continue
263
+ }
264
+ messages.push(...(yield* lowerMessage(message)))
265
+ }
266
+ return messages
267
+ })
268
+
269
+ const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
270
+ const store = OpenAIOptions.store(request)
271
+ const reasoningEffort = OpenAIOptions.reasoningEffort(request)
272
+ if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
273
+ return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
274
+ return {
275
+ ...(store !== undefined ? { store } : {}),
276
+ ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
277
+ }
278
+ })
279
+
280
+ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
281
+ // `fromRequest` returns the provider body only. Endpoint, auth, framing,
282
+ // validation, and HTTP execution are composed by `Route.make`.
283
+ const generation = request.generation
284
+ return {
285
+ model: request.model.id,
286
+ messages: yield* lowerMessages(request),
287
+ tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool),
288
+ tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
289
+ stream: true as const,
290
+ stream_options: { include_usage: true },
291
+ max_tokens: generation?.maxTokens,
292
+ temperature: generation?.temperature,
293
+ top_p: generation?.topP,
294
+ frequency_penalty: generation?.frequencyPenalty,
295
+ presence_penalty: generation?.presencePenalty,
296
+ seed: generation?.seed,
297
+ stop: generation?.stop,
298
+ ...(yield* lowerOptions(request)),
299
+ }
300
+ })
301
+
302
+ // =============================================================================
303
+ // Stream Parsing
304
+ // =============================================================================
305
+ // Streaming parsers are small state machines: every event returns a new state
306
+ // plus the common `LLMEvent`s produced by that event. Tool calls are accumulated
307
+ // because OpenAI streams JSON arguments across multiple deltas.
308
+ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
309
+ if (reason === "stop") return "stop"
310
+ if (reason === "length") return "length"
311
+ if (reason === "content_filter") return "content-filter"
312
+ if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
313
+ return "unknown"
314
+ }
315
+
316
+ // OpenAI Chat reports `prompt_tokens` (inclusive total) with a
317
+ // `cached_tokens` subset, and `completion_tokens` (inclusive total) with
318
+ // a `reasoning_tokens` subset. We pass the inclusive totals through and
319
+ // derive the non-cached breakdown so the `LLM.Usage` contract is
320
+ // satisfied on both sides.
321
+ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
322
+ if (!usage) return undefined
323
+ const cached = usage.prompt_tokens_details?.cached_tokens
324
+ const reasoning = usage.completion_tokens_details?.reasoning_tokens
325
+ const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
326
+ return new Usage({
327
+ inputTokens: usage.prompt_tokens,
328
+ outputTokens: usage.completion_tokens,
329
+ nonCachedInputTokens: nonCached,
330
+ cacheReadInputTokens: cached,
331
+ reasoningTokens: reasoning,
332
+ totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
333
+ providerMetadata: { openai: usage },
334
+ })
335
+ }
336
+
337
+ const step = (state: ParserState, event: OpenAIChatEvent) =>
338
+ Effect.gen(function* () {
339
+ const events: LLMEvent[] = []
340
+ const usage = mapUsage(event.usage) ?? state.usage
341
+ const choice = event.choices[0]
342
+ const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
343
+ const delta = choice?.delta
344
+ const toolDeltas = delta?.tool_calls ?? []
345
+ let tools = state.tools
346
+
347
+ let lifecycle = state.lifecycle
348
+
349
+ if (delta?.reasoning_content)
350
+ lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
351
+
352
+ if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
353
+
354
+ for (const tool of toolDeltas) {
355
+ const result = ToolStream.appendOrStart(
356
+ ADAPTER,
357
+ tools,
358
+ tool.index,
359
+ { id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" },
360
+ "OpenAI Chat tool call delta is missing id or name",
361
+ )
362
+ if (ToolStream.isError(result)) return yield* result
363
+ tools = result.tools
364
+ if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events)
365
+ events.push(...result.events)
366
+ }
367
+
368
+ // Finalize accumulated tool inputs eagerly when finish_reason arrives so
369
+ // JSON parse failures fail the stream at the boundary rather than at halt.
370
+ const finished =
371
+ finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
372
+ ? yield* ToolStream.finishAll(ADAPTER, tools)
373
+ : undefined
374
+
375
+ return [
376
+ {
377
+ tools: finished?.tools ?? tools,
378
+ toolCallEvents: finished?.events ?? state.toolCallEvents,
379
+ usage,
380
+ finishReason,
381
+ lifecycle,
382
+ },
383
+ events,
384
+ ] as const
385
+ })
386
+
387
+ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
388
+ const events: LLMEvent[] = []
389
+ const hasToolCalls = state.toolCallEvents.length > 0
390
+ const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
391
+ const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
392
+ events.push(...state.toolCallEvents)
393
+ if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
394
+ return events
395
+ }
396
+
397
+ // =============================================================================
398
+ // Protocol And OpenAI Route
399
+ // =============================================================================
400
+ /**
401
+ * The OpenAI Chat protocol — request body construction, body schema, and the
402
+ * streaming-event state machine. Reused by every route that speaks OpenAI Chat
403
+ * over HTTP+SSE: native OpenAI, DeepSeek, TogetherAI, Cerebras, Baseten,
404
+ * Fireworks, DeepInfra, and (once added) Azure OpenAI Chat.
405
+ */
406
+ export const protocol = Protocol.make({
407
+ id: ADAPTER,
408
+ body: {
409
+ schema: OpenAIChatBody,
410
+ from: fromRequest,
411
+ },
412
+ stream: {
413
+ event: Protocol.jsonEvent(OpenAIChatEvent),
414
+ initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
415
+ step,
416
+ onHalt: finishEvents,
417
+ },
418
+ })
419
+
420
+ export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>()
421
+
422
+ export const route = Route.make({
423
+ id: ADAPTER,
424
+ provider: "openai",
425
+ protocol,
426
+ endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
427
+ auth: Auth.none,
428
+ transport: httpTransport,
429
+ })
430
+
431
+ export * as OpenAIChat from "./openai-chat"
@@ -0,0 +1,24 @@
1
+ import { Route, type RouteRoutedModelInput } from "../route/client"
2
+ import { Endpoint } from "../route/endpoint"
3
+ import { Framing } from "../route/framing"
4
+ import * as OpenAIChat from "./openai-chat"
5
+
6
+ const ADAPTER = "openai-compatible-chat"
7
+
8
+ export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
9
+
10
+ /**
11
+ * Route for non-OpenAI providers that expose an OpenAI Chat-compatible
12
+ * `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and
13
+ * overrides only the route id so providers can be resolved per-family without
14
+ * colliding with native OpenAI. Provider helpers configure the route endpoint
15
+ * before model selection.
16
+ */
17
+ export const route = Route.make({
18
+ id: ADAPTER,
19
+ protocol: OpenAIChat.protocol,
20
+ endpoint: Endpoint.path("/chat/completions"),
21
+ framing: Framing.sse,
22
+ })
23
+
24
+ export * as OpenAICompatibleChat from "./openai-compatible-chat"