@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,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,93 @@
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
+ export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const
24
+ export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
25
+
26
+ const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
27
+ const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
28
+ const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
29
+ const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
30
+ const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
31
+
32
+ export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
33
+ export const OpenAITextVerbosity = TextVerbosity
34
+ export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
35
+ export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
36
+
37
+ const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
38
+ typeof effort === "string" && REASONING_EFFORTS.has(effort)
39
+
40
+ export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
41
+ typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
42
+
43
+ const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
44
+ typeof value === "string" && TEXT_VERBOSITY.has(value)
45
+
46
+ const options = (request: LLMRequest) => request.providerOptions?.openai
47
+
48
+ export const store = (request: LLMRequest): boolean | undefined => {
49
+ const value = options(request)?.store
50
+ return typeof value === "boolean" ? value : undefined
51
+ }
52
+
53
+ export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => {
54
+ const value = options(request)?.reasoningEffort
55
+ return isAnyReasoningEffort(value) ? value : undefined
56
+ }
57
+
58
+ export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
59
+ options(request)?.reasoningSummary === "auto" ? "auto" : undefined
60
+
61
+ // Resolve the OpenAI Responses `include` field. Filters out unknown
62
+ // includable values defensively so a typo in upstream config drops the
63
+ // invalid entry instead of poisoning the wire body. An empty array (either
64
+ // passed directly or produced by filtering) is treated as "no include" and
65
+ // returns undefined so the request body omits the field entirely.
66
+ export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
67
+ const value = options(request)?.include
68
+ if (!Array.isArray(value)) return undefined
69
+ const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
70
+ return filtered.length > 0 ? filtered : undefined
71
+ }
72
+
73
+ export const promptCacheKey = (request: LLMRequest) => {
74
+ const value = options(request)?.promptCacheKey
75
+ return typeof value === "string" ? value : undefined
76
+ }
77
+
78
+ export const textVerbosity = (request: LLMRequest) => {
79
+ const value = options(request)?.textVerbosity
80
+ return isTextVerbosity(value) ? value : undefined
81
+ }
82
+
83
+ export const serviceTier = (request: LLMRequest) => {
84
+ const value = options(request)?.serviceTier
85
+ return typeof value === "string" && SERVICE_TIERS.has(value) ? (value as OpenAIServiceTier) : undefined
86
+ }
87
+
88
+ export const instructions = (request: LLMRequest) => {
89
+ const value = options(request)?.instructions
90
+ return typeof value === "string" ? value : undefined
91
+ }
92
+
93
+ export * as OpenAIOptions from "./openai-options"
@@ -0,0 +1,86 @@
1
+ import type { JsonSchema, ModelToolSchemaCompatibility } from "../../schema"
2
+ import { isRecord } from "../../utils/record"
3
+ import { GeminiToolSchema } from "./gemini-tool-schema"
4
+
5
+ const removeNullSchemas = (value: unknown): unknown => {
6
+ if (Array.isArray(value)) return value.map(removeNullSchemas)
7
+ if (!isRecord(value)) return value
8
+ const fields = Object.fromEntries(
9
+ Object.entries(value)
10
+ .filter(([key]) => key !== "anyOf")
11
+ .map(([key, field]) => [key, removeNullSchemas(field)]),
12
+ )
13
+ if (!Array.isArray(value.anyOf)) return fields
14
+ const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
15
+ if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
16
+ return { ...fields, anyOf: variants }
17
+ }
18
+
19
+ const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
20
+ const projected = items.map(moonshotNode)
21
+ if (projected.length === 0) return {}
22
+ if (projected.length === 1) return projected[0]
23
+ return { anyOf: projected }
24
+ }
25
+
26
+ const moonshotNode = (schema: unknown): unknown => {
27
+ if (Array.isArray(schema)) return schema.map(moonshotNode)
28
+ if (!isRecord(schema)) return schema
29
+ if (typeof schema.$ref === "string") return { $ref: schema.$ref }
30
+ return Object.fromEntries(
31
+ Object.entries(schema).flatMap(([key, value]) => {
32
+ if (key === "items" && Array.isArray(value)) return [[key, tupleItemsSchema(value)]]
33
+ if (key === "prefixItems") {
34
+ if ("items" in schema) return []
35
+ return [["items", tupleItemsSchema(Array.isArray(value) ? value : [])]]
36
+ }
37
+ if (key === "unevaluatedItems") return []
38
+ return [[key, moonshotNode(value)]]
39
+ }),
40
+ )
41
+ }
42
+
43
+ const moonshot = (schema: JsonSchema): JsonSchema => {
44
+ const projected = moonshotNode(schema)
45
+ return isRecord(projected) ? projected : {}
46
+ }
47
+
48
+ const openAI = (schema: JsonSchema): JsonSchema => {
49
+ const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
50
+ const flattened =
51
+ variants.length === 0
52
+ ? { ...schema, type: "object" }
53
+ : {
54
+ ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
55
+ type: "object",
56
+ properties: variants.reduce(
57
+ (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
58
+ {},
59
+ ),
60
+ additionalProperties: false,
61
+ }
62
+ const normalized = removeNullSchemas(flattened)
63
+ return isRecord(normalized) ? normalized : { type: "object" }
64
+ }
65
+
66
+ const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
67
+
68
+ const modelCompatibility = (
69
+ schema: JsonSchema,
70
+ compatibility: ModelToolSchemaCompatibility | undefined,
71
+ ): JsonSchema => {
72
+ if (compatibility === undefined) return schema
73
+ switch (compatibility) {
74
+ case "gemini":
75
+ return gemini(schema)
76
+ case "moonshot":
77
+ return moonshot(schema)
78
+ }
79
+ }
80
+
81
+ export const ToolSchemaProjection = {
82
+ gemini,
83
+ modelCompatibility,
84
+ moonshot,
85
+ openAI,
86
+ } as const
@@ -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,43 @@
1
+ import { Schema } from "effect"
2
+ import { LLMError, ProviderErrorEvent } from "./schema"
3
+
4
+ const patterns = [
5
+ /prompt is too long/i,
6
+ /request_too_large/i,
7
+ /input is too long for requested model/i,
8
+ /exceeds the context window/i,
9
+ /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
10
+ /input token count.*exceeds the maximum/i,
11
+ /tokens in request more than max tokens allowed/i,
12
+ /maximum prompt length is \d+/i,
13
+ /reduce the length of the messages/i,
14
+ /maximum context length is \d+ tokens/i,
15
+ /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
16
+ /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
17
+ /exceeds the limit of \d+/i,
18
+ /exceeds the available context size/i,
19
+ /greater than the context length/i,
20
+ /context window exceeds limit/i,
21
+ /exceeded model token limit/i,
22
+ /context[_ ]length[_ ]exceeded/i,
23
+ /request entity too large/i,
24
+ /context length is only \d+ tokens/i,
25
+ /input length.*exceeds.*context length/i,
26
+ /prompt too long; exceeded (?:max )?context length/i,
27
+ /too large for model with \d+ maximum context length/i,
28
+ /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
29
+ /model_context_window_exceeded/i,
30
+ /too many tokens/i,
31
+ /token limit exceeded/i,
32
+ ]
33
+
34
+ const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
35
+
36
+ export const isContextOverflow = (message: string) =>
37
+ !exclusions.some((pattern) => pattern.test(message)) &&
38
+ (patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
39
+
40
+ export const isContextOverflowFailure = (failure: unknown) =>
41
+ failure instanceof LLMError
42
+ ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
43
+ : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
@@ -0,0 +1,36 @@
1
+ import type { Model, ModelID, ProviderID } from "./schema"
2
+
3
+ export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility">
4
+
5
+ /**
6
+ * Advanced structural provider definition helper. Built-in providers should
7
+ * prefer explicit `configure(options).model(id)` facades so deployment config is
8
+ * chosen before model selection. The optional `apis` map remains for external
9
+ * structural providers that expose multiple route selectors behind one provider.
10
+ */
11
+ export type ModelFactory<Options extends ModelOptions = ModelOptions> = (
12
+ id: string | ModelID,
13
+ options?: Options,
14
+ ) => Model
15
+
16
+ type AnyModelFactory = (...args: never[]) => Model
17
+
18
+ export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
19
+ readonly id: ProviderID
20
+ readonly model: Factory
21
+ readonly apis?: Record<string, AnyModelFactory>
22
+ }
23
+
24
+ type DefinitionShape = {
25
+ readonly id: ProviderID
26
+ readonly model: (...args: never[]) => Model
27
+ readonly apis?: Record<string, (...args: never[]) => Model>
28
+ }
29
+
30
+ type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
31
+
32
+ export const make = <DefinitionType extends DefinitionShape>(
33
+ definition: NoExtraFields<DefinitionType, DefinitionShape>,
34
+ ) => definition
35
+
36
+ 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