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