@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,102 @@
1
+ import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
2
+
3
+ export interface State {
4
+ readonly stepStarted: boolean
5
+ readonly text: ReadonlySet<string>
6
+ readonly reasoning: ReadonlySet<string>
7
+ }
8
+
9
+ export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() })
10
+
11
+ export const stepStart = (state: State, events: LLMEvent[]): State => {
12
+ if (state.stepStarted) return state
13
+ events.push(LLMEvent.stepStart({ index: 0 }))
14
+ return { ...state, stepStarted: true }
15
+ }
16
+
17
+ export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
18
+ const stepped = stepStart(state, events)
19
+ if (stepped.text.has(id)) {
20
+ events.push(LLMEvent.textDelta({ id, text }))
21
+ return stepped
22
+ }
23
+ events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
24
+ return { ...stepped, text: new Set([...stepped.text, id]) }
25
+ }
26
+
27
+ export const reasoningStart = (
28
+ state: State,
29
+ events: LLMEvent[],
30
+ id: string,
31
+ providerMetadata?: ProviderMetadata,
32
+ ): State => {
33
+ if (state.reasoning.has(id)) return state
34
+ const stepped = stepStart(state, events)
35
+ events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
36
+ return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
37
+ }
38
+
39
+ export const reasoningDelta = (
40
+ state: State,
41
+ events: LLMEvent[],
42
+ id: string,
43
+ text: string,
44
+ providerMetadata?: ProviderMetadata,
45
+ ): State => {
46
+ const started = reasoningStart(state, events, id, providerMetadata)
47
+ events.push(LLMEvent.reasoningDelta({ id, text }))
48
+ return started
49
+ }
50
+
51
+ export const reasoningEnd = (
52
+ state: State,
53
+ events: LLMEvent[],
54
+ id: string,
55
+ providerMetadata?: ProviderMetadata,
56
+ ): State => {
57
+ if (!state.reasoning.has(id)) return state
58
+ const stepped = stepStart(state, events)
59
+ events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
60
+ const reasoning = new Set(stepped.reasoning)
61
+ reasoning.delete(id)
62
+ return { ...stepped, reasoning }
63
+ }
64
+
65
+ export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
66
+ if (!state.text.has(id)) return state
67
+ const stepped = stepStart(state, events)
68
+ events.push(LLMEvent.textEnd({ id, providerMetadata }))
69
+ const text = new Set(stepped.text)
70
+ text.delete(id)
71
+ return { ...stepped, text }
72
+ }
73
+
74
+ const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
75
+ for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id }))
76
+ for (const id of state.text) events.push(LLMEvent.textEnd({ id }))
77
+ return { ...state, text: new Set(), reasoning: new Set() }
78
+ }
79
+
80
+ export const finish = (
81
+ state: State,
82
+ events: LLMEvent[],
83
+ input: {
84
+ readonly reason: FinishReason
85
+ readonly usage?: Usage
86
+ readonly providerMetadata?: ProviderMetadata
87
+ },
88
+ ): State => {
89
+ const stepped = closeOpenBlocks(stepStart(state, events), events)
90
+ events.push(
91
+ LLMEvent.stepFinish({
92
+ index: 0,
93
+ reason: input.reason,
94
+ usage: input.usage,
95
+ providerMetadata: input.providerMetadata,
96
+ }),
97
+ LLMEvent.finish(input),
98
+ )
99
+ return { ...stepped, stepStarted: false }
100
+ }
101
+
102
+ export * as Lifecycle from "./lifecycle"
@@ -0,0 +1,84 @@
1
+ import { Schema } from "effect"
2
+ import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
3
+ import { ReasoningEfforts, TextVerbosity } from "../../schema"
4
+
5
+ export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
6
+ (effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
7
+ )
8
+ export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
9
+
10
+ // Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
11
+ // in lockstep with `openai-node/src/resources/responses/responses.ts`.
12
+ export const OpenAIResponseIncludables = [
13
+ "file_search_call.results",
14
+ "web_search_call.results",
15
+ "web_search_call.action.sources",
16
+ "message.input_image.image_url",
17
+ "computer_call_output.output.image_url",
18
+ "code_interpreter_call.outputs",
19
+ "reasoning.encrypted_content",
20
+ "message.output_text.logprobs",
21
+ ] as const
22
+ export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
23
+
24
+ const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
25
+ const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
26
+ const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
27
+ const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
28
+
29
+ export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
30
+ export const OpenAITextVerbosity = TextVerbosity
31
+ export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
32
+
33
+ const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
34
+ typeof effort === "string" && REASONING_EFFORTS.has(effort)
35
+
36
+ export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
37
+ typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
38
+
39
+ const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
40
+ typeof value === "string" && TEXT_VERBOSITY.has(value)
41
+
42
+ const options = (request: LLMRequest) => request.providerOptions?.openai
43
+
44
+ export const store = (request: LLMRequest): boolean | undefined => {
45
+ const value = options(request)?.store
46
+ return typeof value === "boolean" ? value : undefined
47
+ }
48
+
49
+ export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => {
50
+ const value = options(request)?.reasoningEffort
51
+ return isAnyReasoningEffort(value) ? value : undefined
52
+ }
53
+
54
+ export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
55
+ options(request)?.reasoningSummary === "auto" ? "auto" : undefined
56
+
57
+ // Resolve the OpenAI Responses `include` field. Filters out unknown
58
+ // includable values defensively so a typo in upstream config drops the
59
+ // invalid entry instead of poisoning the wire body. An empty array (either
60
+ // passed directly or produced by filtering) is treated as "no include" and
61
+ // returns undefined so the request body omits the field entirely.
62
+ export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
63
+ const value = options(request)?.include
64
+ if (!Array.isArray(value)) return undefined
65
+ const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
66
+ return filtered.length > 0 ? filtered : undefined
67
+ }
68
+
69
+ export const promptCacheKey = (request: LLMRequest) => {
70
+ const value = options(request)?.promptCacheKey
71
+ return typeof value === "string" ? value : undefined
72
+ }
73
+
74
+ export const textVerbosity = (request: LLMRequest) => {
75
+ const value = options(request)?.textVerbosity
76
+ return isTextVerbosity(value) ? value : undefined
77
+ }
78
+
79
+ export const instructions = (request: LLMRequest) => {
80
+ const value = options(request)?.instructions
81
+ return typeof value === "string" ? value : undefined
82
+ }
83
+
84
+ export * as OpenAIOptions from "./openai-options"
@@ -0,0 +1,218 @@
1
+ import { Effect } from "effect"
2
+ import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
3
+ import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
4
+
5
+ type StreamKey = string | number
6
+
7
+ /**
8
+ * One pending streamed tool call. Providers emit the tool identity and JSON
9
+ * argument text across separate chunks; `input` is the raw JSON string collected
10
+ * so far, not the parsed object.
11
+ */
12
+ export interface PendingTool extends ToolAccumulator {
13
+ readonly providerExecuted?: boolean
14
+ readonly providerMetadata?: ProviderMetadata
15
+ }
16
+
17
+ /**
18
+ * Sparse parser state keyed by the provider's stream-local tool identifier.
19
+ *
20
+ * This key is not the final tool-call id (`call_...`). It is the id/index the
21
+ * provider uses while streaming a partial call: OpenAI Chat / Anthropic /
22
+ * Bedrock use numeric content indexes, while OpenAI Responses uses string
23
+ * `item_id`s. The generic keeps each protocol internally consistent.
24
+ */
25
+ export type State<K extends StreamKey> = Partial<Record<K, PendingTool>>
26
+
27
+ /**
28
+ * Result of adding argument text to one pending tool call. It returns both the
29
+ * next `tools` state and the updated `tool` because parsers often need the
30
+ * current id/name immediately. `events` contains lifecycle and delta events
31
+ * produced by the append; metadata-only deltas update identity without output.
32
+ */
33
+ export interface AppendOutcome<K extends StreamKey> {
34
+ readonly tools: State<K>
35
+ readonly tool: PendingTool
36
+ readonly events: ReadonlyArray<LLMEvent>
37
+ }
38
+
39
+ /** Create empty accumulator state for one provider stream. */
40
+ export const empty = <K extends StreamKey>(): State<K> => ({})
41
+
42
+ const withTool = <K extends StreamKey>(tools: State<K>, key: K, tool: PendingTool): State<K> => {
43
+ return { ...tools, [key]: tool }
44
+ }
45
+
46
+ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> => {
47
+ const next = { ...tools }
48
+ delete next[key]
49
+ return next
50
+ }
51
+
52
+ const inputStart = (tool: PendingTool) =>
53
+ LLMEvent.toolInputStart({
54
+ id: tool.id,
55
+ name: tool.name,
56
+ providerMetadata: tool.providerMetadata,
57
+ })
58
+
59
+ const inputDelta = (tool: PendingTool, text: string) =>
60
+ LLMEvent.toolInputDelta({
61
+ id: tool.id,
62
+ name: tool.name,
63
+ text,
64
+ })
65
+
66
+ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
67
+ parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
68
+ Effect.map(
69
+ (input): ToolCall =>
70
+ LLMEvent.toolCall({
71
+ id: tool.id,
72
+ name: tool.name,
73
+ input,
74
+ providerExecuted: tool.providerExecuted ? true : undefined,
75
+ providerMetadata: tool.providerMetadata,
76
+ }),
77
+ ),
78
+ )
79
+
80
+ /** Store the updated tool and produce the optional public delta event. */
81
+ const appendTool = <K extends StreamKey>(
82
+ tools: State<K>,
83
+ key: K,
84
+ tool: PendingTool,
85
+ text: string,
86
+ ): AppendOutcome<K> => {
87
+ const events: LLMEvent[] = []
88
+ if (!tools[key]) events.push(inputStart(tool))
89
+ if (text.length > 0) events.push(inputDelta(tool, text))
90
+ return {
91
+ tools: withTool(tools, key, tool),
92
+ tool,
93
+ events,
94
+ }
95
+ }
96
+
97
+ export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
98
+ result instanceof LLMError
99
+
100
+ /**
101
+ * Register a tool call whose start event arrived before any argument deltas.
102
+ * Used by Anthropic `content_block_start`, Bedrock `contentBlockStart`, and
103
+ * OpenAI Responses `response.output_item.added`.
104
+ */
105
+ export const start = <K extends StreamKey>(
106
+ tools: State<K>,
107
+ key: K,
108
+ tool: Omit<PendingTool, "input"> & { readonly input?: string },
109
+ ) => withTool(tools, key, { ...tool, input: tool.input ?? "" })
110
+
111
+ /**
112
+ * Append a streamed argument delta, starting the tool if this provider encodes
113
+ * identity on the first delta instead of a separate start event. OpenAI Chat has
114
+ * this shape: `tool_calls[].index` is the stream key, and `id` / `name` may only
115
+ * appear on the first delta for that index.
116
+ */
117
+ export const appendOrStart = <K extends StreamKey>(
118
+ route: string,
119
+ tools: State<K>,
120
+ key: K,
121
+ delta: { readonly id?: string; readonly name?: string; readonly text: string },
122
+ missingToolMessage: string,
123
+ ): AppendOutcome<K> | LLMError => {
124
+ const current = tools[key]
125
+ const id = delta.id ?? current?.id
126
+ const name = delta.name ?? current?.name
127
+ if (!id || !name) return eventError(route, missingToolMessage)
128
+
129
+ const tool = {
130
+ id,
131
+ name,
132
+ input: `${current?.input ?? ""}${delta.text}`,
133
+ providerExecuted: current?.providerExecuted,
134
+ providerMetadata: current?.providerMetadata,
135
+ }
136
+ if (current && delta.text.length === 0 && current.id === id && current.name === name)
137
+ return { tools, tool: current, events: [] }
138
+ return appendTool(tools, key, tool, delta.text)
139
+ }
140
+
141
+ /**
142
+ * Append argument text to a tool that must already have been started. This keeps
143
+ * protocols honest when their stream grammar promises a start event before any
144
+ * argument delta.
145
+ */
146
+ export const appendExisting = <K extends StreamKey>(
147
+ route: string,
148
+ tools: State<K>,
149
+ key: K,
150
+ text: string,
151
+ missingToolMessage: string,
152
+ ): AppendOutcome<K> | LLMError => {
153
+ const current = tools[key]
154
+ if (!current) return eventError(route, missingToolMessage)
155
+ if (text.length === 0) return { tools, tool: current, events: [] }
156
+ return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
157
+ }
158
+
159
+ /**
160
+ * Finalize one pending tool call: parse the accumulated raw JSON, remove it
161
+ * from state, and return the optional public `tool-call` event. Missing keys are
162
+ * a no-op because some providers emit stop events for non-tool content blocks.
163
+ */
164
+ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
165
+ Effect.gen(function* () {
166
+ const tool = tools[key]
167
+ if (!tool) return { tools }
168
+ return {
169
+ tools: withoutTool(tools, key),
170
+ events: [
171
+ LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
172
+ yield* toolCall(route, tool),
173
+ ],
174
+ }
175
+ })
176
+
177
+ /**
178
+ * Finalize one pending tool call with an authoritative final input string.
179
+ * OpenAI Responses can send accumulated deltas and then repeat the completed
180
+ * arguments on `response.output_item.done`; the final value wins.
181
+ */
182
+ export const finishWithInput = <K extends StreamKey>(route: string, tools: State<K>, key: K, input: string) =>
183
+ Effect.gen(function* () {
184
+ const tool = tools[key]
185
+ if (!tool) return { tools }
186
+ return {
187
+ tools: withoutTool(tools, key),
188
+ events: [
189
+ LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
190
+ yield* toolCall(route, tool, input),
191
+ ],
192
+ }
193
+ })
194
+
195
+ /**
196
+ * Finalize every pending tool call at once. OpenAI Chat has this shape: it does
197
+ * not emit per-tool stop events, so all accumulated calls finish when the choice
198
+ * receives a terminal `finish_reason`.
199
+ */
200
+ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
201
+ Effect.gen(function* () {
202
+ const pending = Object.values<PendingTool | undefined>(tools).filter(
203
+ (tool): tool is PendingTool => tool !== undefined,
204
+ )
205
+ return {
206
+ tools: empty<K>(),
207
+ events: yield* Effect.forEach(pending, (tool) =>
208
+ toolCall(route, tool).pipe(
209
+ Effect.map((call) => [
210
+ LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
211
+ call,
212
+ ]),
213
+ ),
214
+ ).pipe(Effect.map((events) => events.flat())),
215
+ }
216
+ })
217
+
218
+ export * as ToolStream from "./tool-stream"
@@ -0,0 +1,37 @@
1
+ import type { RouteDefaultsInput } from "./route/client"
2
+ import type { Model, ModelID, ProviderID } from "./schema"
3
+
4
+ export type ModelOptions = RouteDefaultsInput
5
+
6
+ /**
7
+ * Advanced structural provider definition helper. Built-in providers should
8
+ * prefer explicit `configure(options).model(id)` facades so deployment config is
9
+ * chosen before model selection. The optional `apis` map remains for external
10
+ * structural providers that expose multiple route selectors behind one provider.
11
+ */
12
+ export type ModelFactory<Options extends ModelOptions = ModelOptions> = (
13
+ id: string | ModelID,
14
+ options?: Options,
15
+ ) => Model
16
+
17
+ type AnyModelFactory = (...args: never[]) => Model
18
+
19
+ export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
20
+ readonly id: ProviderID
21
+ readonly model: Factory
22
+ readonly apis?: Record<string, AnyModelFactory>
23
+ }
24
+
25
+ type DefinitionShape = {
26
+ readonly id: ProviderID
27
+ readonly model: (...args: never[]) => Model
28
+ readonly apis?: Record<string, (...args: never[]) => Model>
29
+ }
30
+
31
+ type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
32
+
33
+ export const make = <DefinitionType extends DefinitionShape>(
34
+ definition: NoExtraFields<DefinitionType, DefinitionShape>,
35
+ ) => definition
36
+
37
+ export * as Provider from "./provider"
@@ -0,0 +1,43 @@
1
+ import type { RouteDefaultsInput } from "../route/client"
2
+ import { Auth } from "../route/auth"
3
+ import { ProviderID, type ModelID } from "../schema"
4
+ import * as BedrockConverse from "../protocols/bedrock-converse"
5
+ import type { BedrockCredentials } from "../protocols/bedrock-converse"
6
+
7
+ export const id = ProviderID.make("amazon-bedrock")
8
+
9
+ export type Config = RouteDefaultsInput & {
10
+ readonly apiKey?: string
11
+ readonly headers?: Record<string, string>
12
+ readonly credentials?: BedrockCredentials
13
+ /** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
14
+ readonly region?: string
15
+ /** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
16
+ readonly baseURL?: string
17
+ }
18
+ export const routes = [BedrockConverse.route]
19
+
20
+ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
21
+
22
+ const configuredRoute = (input: Config) => {
23
+ const { apiKey, credentials, region, baseURL, ...rest } = input
24
+ const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
25
+ return BedrockConverse.route.with({
26
+ ...rest,
27
+ provider: id,
28
+ endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
29
+ auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
30
+ })
31
+ }
32
+
33
+ export const configure = (input: Config = {}) => {
34
+ const route = configuredRoute(input)
35
+ return {
36
+ id,
37
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
38
+ configure,
39
+ }
40
+ }
41
+
42
+ export const provider = configure()
43
+ export const model = provider.model
@@ -0,0 +1,35 @@
1
+ import type { RouteDefaultsInput } from "../route/client"
2
+ import { Auth } from "../route/auth"
3
+ import type { ProviderAuthOption } from "../route/auth-options"
4
+ import { ProviderID, type ModelID } from "../schema"
5
+ import * as AnthropicMessages from "../protocols/anthropic-messages"
6
+
7
+ export const id = ProviderID.make("anthropic")
8
+
9
+ export const routes = [AnthropicMessages.route]
10
+
11
+ export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
12
+
13
+ const auth = (options: ProviderAuthOption<"optional">) => {
14
+ if ("auth" in options && options.auth) return options.auth
15
+ return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
16
+ .orElse(Auth.config("ANTHROPIC_API_KEY"))
17
+ .pipe(Auth.header("x-api-key"))
18
+ }
19
+
20
+ const configuredRoute = (input: Config) => {
21
+ const { apiKey: _, auth: _auth, baseURL, ...rest } = input
22
+ return AnthropicMessages.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
23
+ }
24
+
25
+ export const configure = (input: Config = {}) => {
26
+ const route = configuredRoute(input)
27
+ return {
28
+ id,
29
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
30
+ configure,
31
+ }
32
+ }
33
+
34
+ export const provider = configure()
35
+ export const model = provider.model
@@ -0,0 +1,110 @@
1
+ import { Auth } from "../route/auth"
2
+ import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
3
+ import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
4
+ import { ProviderID, type ModelID } from "../schema"
5
+ import * as OpenAIChat from "../protocols/openai-chat"
6
+ import * as OpenAIResponses from "../protocols/openai-responses"
7
+ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
8
+
9
+ export const id = ProviderID.make("azure")
10
+ const routeAuth = Auth.remove("authorization")
11
+
12
+ // Azure needs the customer's resource URL; supply either `resourceName`
13
+ // (helper builds the URL) or `baseURL` directly.
14
+ type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }>
15
+
16
+ export type ModelOptions = AzureURL &
17
+ RouteDefaultsInput &
18
+ ProviderAuthOption<"optional"> & {
19
+ readonly apiVersion?: string
20
+ readonly queryParams?: Record<string, string>
21
+ readonly useCompletionUrls?: boolean
22
+ readonly providerOptions?: OpenAIProviderOptionsInput
23
+ }
24
+ export type Config = ModelOptions
25
+
26
+ const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
27
+
28
+ const responsesRoute = OpenAIResponses.route.with({
29
+ id: "azure-openai-responses",
30
+ provider: id,
31
+ auth: routeAuth,
32
+ endpoint: {
33
+ query: { "api-version": "v1" },
34
+ },
35
+ })
36
+
37
+ const chatRoute = OpenAIChat.route.with({
38
+ id: "azure-openai-chat",
39
+ provider: id,
40
+ auth: routeAuth,
41
+ endpoint: {
42
+ query: { "api-version": "v1" },
43
+ },
44
+ })
45
+
46
+ export const routes = [responsesRoute, chatRoute]
47
+
48
+ const defaults = (input: Config) => {
49
+ const {
50
+ apiKey: _,
51
+ apiVersion: _apiVersion,
52
+ resourceName: _resourceName,
53
+ useCompletionUrls: _useCompletionUrls,
54
+ baseURL: _baseURL,
55
+ queryParams: _queryParams,
56
+ ...rest
57
+ } = input
58
+ if ("auth" in rest) {
59
+ const { auth: _, ...withoutAuth } = rest
60
+ return withoutAuth
61
+ }
62
+ return rest
63
+ }
64
+
65
+ const auth = (input: Config) => {
66
+ if ("auth" in input && input.auth) return input.auth
67
+ return Auth.remove("authorization").andThen(
68
+ Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey")
69
+ .orElse(Auth.config("AZURE_OPENAI_API_KEY"))
70
+ .pipe(Auth.header("api-key")),
71
+ )
72
+ }
73
+
74
+ const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
75
+ route.with({
76
+ auth: auth(input),
77
+ endpoint: {
78
+ // AtLeastOne guarantees at least one is set; baseURL wins if both are.
79
+ baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
80
+ query: {
81
+ ...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
82
+ ...input.queryParams,
83
+ },
84
+ },
85
+ })
86
+
87
+ export const configure = (input: Config) => {
88
+ const configuredResponsesRoute = configuredRoute(responsesRoute, input)
89
+ const configuredChatRoute = configuredRoute(chatRoute, input)
90
+ const modelDefaults = defaults(input)
91
+
92
+ const responses = (modelID: string | ModelID) =>
93
+ configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
94
+
95
+ const chat = (modelID: string | ModelID) =>
96
+ configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
97
+
98
+ return {
99
+ id,
100
+ model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
101
+ responses,
102
+ chat,
103
+ configure,
104
+ }
105
+ }
106
+
107
+ export const provider = {
108
+ id,
109
+ configure,
110
+ }