@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,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"
|