@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,146 @@
1
+ import { Effect, Stream } from "effect"
2
+ import { LLMClient } from "../../src/route"
3
+ import {
4
+ LLMEvent,
5
+ LLMRequest,
6
+ Message,
7
+ type ContentPart,
8
+ type ProviderMetadata,
9
+ type ToolCallPart,
10
+ ToolResultPart,
11
+ type ToolResultValue,
12
+ type Usage,
13
+ } from "../../src/schema"
14
+ import { type Tools, toDefinitions } from "../../src/tool"
15
+ import { ToolRuntime } from "../../src/tool-runtime"
16
+
17
+ interface RunOptions<T extends Tools> {
18
+ readonly request: LLMRequest
19
+ readonly tools: T
20
+ readonly maxSteps?: number
21
+ }
22
+
23
+ /** Test-owned continuation loop. Production callers must own durable history. */
24
+ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
25
+ Stream.unwrap(
26
+ Effect.gen(function* () {
27
+ const names = new Set(Object.keys(options.tools))
28
+ let request = LLMRequest.update(options.request, {
29
+ tools: [...options.request.tools.filter((tool) => !names.has(tool.name)), ...toDefinitions(options.tools)],
30
+ })
31
+ let usage: Usage | undefined
32
+ const events: LLMEvent[] = []
33
+
34
+ for (let step = 0; step < (options.maxSteps ?? 10); step++) {
35
+ const streamed = Array.from(yield* LLMClient.stream(request).pipe(Stream.runCollect))
36
+ const state = stepState(streamed)
37
+ usage = addUsage(usage, state.usage)
38
+ events.push(...streamed.filter((event) => event.type !== "finish").map((event) => indexStep(event, step)))
39
+
40
+ if (state.toolCalls.length === 0) {
41
+ events.push(LLMEvent.finish({ reason: state.reason, usage, providerMetadata: state.providerMetadata }))
42
+ return Stream.fromIterable(events)
43
+ }
44
+
45
+ const dispatched = yield* Effect.forEach(
46
+ state.toolCalls,
47
+ (call) => ToolRuntime.dispatch(options.tools, call).pipe(Effect.map((result) => [call, result] as const)),
48
+ { concurrency: 10 },
49
+ )
50
+ events.push(...dispatched.flatMap(([, result]) => result.events))
51
+
52
+ if (step + 1 >= (options.maxSteps ?? 10)) {
53
+ events.push(LLMEvent.finish({ reason: state.reason, usage, providerMetadata: state.providerMetadata }))
54
+ return Stream.fromIterable(events)
55
+ }
56
+
57
+ request = LLMRequest.update(request, {
58
+ messages: [
59
+ ...request.messages,
60
+ Message.assistant(state.assistantContent),
61
+ ...dispatched.map(([call, dispatched]) =>
62
+ Message.tool({ id: call.id, name: call.name, result: dispatched.result }),
63
+ ),
64
+ ],
65
+ })
66
+ }
67
+
68
+ return Stream.fromIterable(events)
69
+ }),
70
+ )
71
+
72
+ const indexStep = (event: LLMEvent, index: number): LLMEvent => {
73
+ if (event.type === "step-start") return LLMEvent.stepStart({ index })
74
+ if (event.type === "step-finish") return LLMEvent.stepFinish({ ...event, index })
75
+ return event
76
+ }
77
+
78
+ const stepState = (events: ReadonlyArray<LLMEvent>) => {
79
+ const assistantContent: ContentPart[] = []
80
+ const toolCalls: ToolCallPart[] = []
81
+ let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = "unknown"
82
+ let usage: Usage | undefined
83
+ let providerMetadata: ProviderMetadata | undefined
84
+
85
+ for (const event of events) {
86
+ if (event.type === "text-delta" || event.type === "reasoning-delta") {
87
+ appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text)
88
+ } else if (event.type === "text-end" || event.type === "reasoning-end") {
89
+ appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata)
90
+ } else if (event.type === "tool-call") {
91
+ assistantContent.push(event)
92
+ if (!event.providerExecuted) toolCalls.push(event)
93
+ } else if (event.type === "tool-result" && event.providerExecuted && event.result !== undefined) {
94
+ assistantContent.push(
95
+ ToolResultPart.make({
96
+ id: event.id,
97
+ name: event.name,
98
+ result: event.result,
99
+ providerExecuted: true,
100
+ providerMetadata: event.providerMetadata,
101
+ }),
102
+ )
103
+ } else if (event.type === "finish") {
104
+ reason = event.reason
105
+ usage = event.usage
106
+ providerMetadata = event.providerMetadata
107
+ }
108
+ }
109
+ return { assistantContent, toolCalls, reason, usage, providerMetadata }
110
+ }
111
+
112
+ const appendText = (
113
+ content: ContentPart[],
114
+ type: "text" | "reasoning",
115
+ text: string,
116
+ providerMetadata?: ProviderMetadata,
117
+ ) => {
118
+ const last = content.at(-1)
119
+ if (last?.type === type) {
120
+ content[content.length - 1] = {
121
+ ...last,
122
+ text: `${last.text}${text}`,
123
+ providerMetadata: providerMetadata ?? last.providerMetadata,
124
+ }
125
+ return
126
+ }
127
+ content.push({ type, text, providerMetadata })
128
+ }
129
+
130
+ const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
131
+ if (!left) return right
132
+ if (!right) return left
133
+ const sum = (key: keyof Usage) =>
134
+ typeof left[key] !== "number" && typeof right[key] !== "number"
135
+ ? undefined
136
+ : ((left[key] as number | undefined) ?? 0) + ((right[key] as number | undefined) ?? 0)
137
+ return {
138
+ inputTokens: sum("inputTokens"),
139
+ outputTokens: sum("outputTokens"),
140
+ nonCachedInputTokens: sum("nonCachedInputTokens"),
141
+ cacheReadInputTokens: sum("cacheReadInputTokens"),
142
+ cacheWriteInputTokens: sum("cacheWriteInputTokens"),
143
+ reasoningTokens: sum("reasoningTokens"),
144
+ totalTokens: sum("totalTokens"),
145
+ } as Usage
146
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { CacheHint, LLM, LLMResponse } from "../src"
3
+ import * as OpenAIChat from "../src/protocols/openai-chat"
4
+ import * as OpenAIResponses from "../src/protocols/openai-responses"
5
+ import { LLMRequest, Message, Model, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart } from "../src/schema"
6
+
7
+ const chatRoute = OpenAIChat.route
8
+ const responsesRoute = OpenAIResponses.route
9
+
10
+ describe("llm constructors", () => {
11
+ test("builds canonical schema classes from ergonomic input", () => {
12
+ const request = LLM.request({
13
+ id: "req_1",
14
+ model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
15
+ system: "You are concise.",
16
+ prompt: "Say hello.",
17
+ })
18
+
19
+ expect(request).toBeInstanceOf(LLMRequest)
20
+ expect(request.model).toBeInstanceOf(Model)
21
+ expect(request.messages[0]).toBeInstanceOf(Message)
22
+ expect(request.system).toEqual([{ type: "text", text: "You are concise." }])
23
+ expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }])
24
+ expect(request.generation).toBeUndefined()
25
+ expect(request.tools).toEqual([])
26
+ })
27
+
28
+ test("updates requests without spreading schema class instances", () => {
29
+ const base = LLM.request({
30
+ id: "req_1",
31
+ model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
32
+ prompt: "Say hello.",
33
+ })
34
+ const updated = LLM.updateRequest(base, {
35
+ generation: { maxTokens: 20 },
36
+ messages: [...base.messages, Message.assistant("Hi.")],
37
+ })
38
+
39
+ expect(updated).toBeInstanceOf(LLMRequest)
40
+ expect(updated.id).toBe("req_1")
41
+ expect(updated.model).toEqual(base.model)
42
+ expect(updated.generation).toEqual({ maxTokens: 20 })
43
+ expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"])
44
+ })
45
+
46
+ test("keeps request options separate from route defaults", () => {
47
+ const request = LLM.request({
48
+ model: Model.make({
49
+ id: "fake-model",
50
+ provider: "fake",
51
+ route: chatRoute.with({
52
+ generation: { maxTokens: 100, temperature: 1 },
53
+ providerOptions: { openai: { store: false, metadata: { model: true } } },
54
+ http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } },
55
+ }),
56
+ }),
57
+ prompt: "Say hello.",
58
+ generation: { temperature: 0 },
59
+ providerOptions: { openai: { store: true, metadata: { request: true } } },
60
+ http: { body: { metadata: { request: true } }, headers: { "x-shared": "request" }, query: { request: "1" } },
61
+ })
62
+
63
+ expect(request.generation).toEqual({ temperature: 0 })
64
+ expect(request.providerOptions).toEqual({ openai: { store: true, metadata: { request: true } } })
65
+ expect(request.http).toEqual({
66
+ body: { metadata: { request: true } },
67
+ headers: { "x-shared": "request" },
68
+ query: { request: "1" },
69
+ })
70
+ })
71
+
72
+ test("updates canonical requests from the request datatype", () => {
73
+ const base = LLM.request({
74
+ id: "req_1",
75
+ model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
76
+ prompt: "Say hello.",
77
+ })
78
+ const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] })
79
+
80
+ expect(updated).toBeInstanceOf(LLMRequest)
81
+ expect(updated.id).toBe("req_1")
82
+ expect(LLMRequest.input(updated).id).toBe("req_1")
83
+ expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"])
84
+ expect(LLMRequest.update(updated, {})).toBe(updated)
85
+ })
86
+
87
+ test("updates canonical models from the model datatype", () => {
88
+ const base = Model.make({
89
+ id: "fake-model",
90
+ provider: "fake",
91
+ route: chatRoute,
92
+ })
93
+ const updated = Model.update(base, { route: responsesRoute })
94
+
95
+ expect(updated).toBeInstanceOf(Model)
96
+ expect(String(updated.id)).toBe("fake-model")
97
+ expect(updated.route).toBe(responsesRoute)
98
+ expect(String(Model.input(updated).provider)).toBe("fake")
99
+ expect(Model.update(updated, {})).toBe(updated)
100
+ })
101
+
102
+ test("builds tool choices from names and tools", () => {
103
+ const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })
104
+
105
+ expect(tool).toBeInstanceOf(ToolDefinition)
106
+ expect(ToolChoice.make("lookup")).toEqual(new ToolChoice({ type: "tool", name: "lookup" }))
107
+ expect(ToolChoice.named("required")).toEqual(new ToolChoice({ type: "tool", name: "required" }))
108
+ expect(ToolChoice.make(tool)).toEqual(new ToolChoice({ type: "tool", name: "lookup" }))
109
+ })
110
+
111
+ test("builds tool choice modes from reserved strings", () => {
112
+ expect(ToolChoice.make("auto")).toEqual(new ToolChoice({ type: "auto" }))
113
+ expect(ToolChoice.make("none")).toEqual(new ToolChoice({ type: "none" }))
114
+ expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" }))
115
+ expect(
116
+ LLM.request({
117
+ model: Model.make({
118
+ id: "fake-model",
119
+ provider: "fake",
120
+ route: chatRoute,
121
+ }),
122
+ prompt: "Use tools if needed.",
123
+ toolChoice: "required",
124
+ }).toolChoice,
125
+ ).toEqual(new ToolChoice({ type: "required" }))
126
+ })
127
+
128
+ test("builds assistant tool calls and tool result messages", () => {
129
+ const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })
130
+ const result = ToolResultPart.make({ id: "call_1", name: "lookup", result: { temperature: 72 } })
131
+
132
+ expect(Message.assistant([call]).content).toEqual([call])
133
+ expect(Message.tool(result).content).toEqual([
134
+ { type: "tool-result", id: "call_1", name: "lookup", result: { type: "json", value: { temperature: 72 } } },
135
+ ])
136
+ })
137
+
138
+ test("builds chronological text-only system updates separately from the initial system prompt", () => {
139
+ const update = Message.system([
140
+ { type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) },
141
+ ])
142
+ const request = LLM.request({
143
+ model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
144
+ system: "Initial operator prompt.",
145
+ messages: [Message.user("Review this."), update],
146
+ })
147
+
148
+ expect(update).toBeInstanceOf(Message)
149
+ expect(update).toEqual({
150
+ role: "system",
151
+ content: [{ type: "text", text: "Use parameterized SQL.", cache: { type: "ephemeral" } }],
152
+ })
153
+ expect(request.system).toEqual([{ type: "text", text: "Initial operator prompt." }])
154
+ expect(request.messages.map((message) => message.role)).toEqual(["user", "system"])
155
+ })
156
+
157
+ test("extracts output text from response events", () => {
158
+ expect(
159
+ LLMResponse.text({
160
+ events: [
161
+ { type: "text-delta", id: "text-0", text: "hi" },
162
+ { type: "finish", reason: "stop" },
163
+ ],
164
+ }),
165
+ ).toBe("hi")
166
+ })
167
+ })
@@ -0,0 +1,54 @@
1
+ import { Redactor } from "@codilore/http-recorder"
2
+ import { describe, expect } from "bun:test"
3
+ import { Effect } from "effect"
4
+ import { CacheHint, LLM } from "../../src"
5
+ import { LLMClient } from "../../src/route"
6
+ import * as Anthropic from "../../src/providers/anthropic"
7
+ import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
8
+ import { recordedTests } from "../recorded-test"
9
+
10
+ const model = Anthropic.configure({
11
+ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
12
+ }).model("claude-haiku-4-5-20251001")
13
+
14
+ // Two identical generations in a row. The first call writes the prefix into
15
+ // Anthropic's cache; the second should report a cache read against the same
16
+ // prefix. Cassette captures both interactions in order.
17
+ const cacheRequest = LLM.request({
18
+ id: "recorded_anthropic_cache",
19
+ model,
20
+ system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }],
21
+ prompt: "Say hi.",
22
+ // Manual hint on the system part is the only marker we want here — skip the
23
+ // auto-policy's latest-user-message breakpoint so the cassette body matches.
24
+ cache: "none",
25
+ generation: { maxTokens: 16, temperature: 0 },
26
+ })
27
+
28
+ const recorded = recordedTests({
29
+ prefix: "anthropic-messages-cache",
30
+ provider: "anthropic",
31
+ protocol: "anthropic-messages",
32
+ requires: ["ANTHROPIC_API_KEY"],
33
+ // Two identical requests in one cassette — replay walks the cassette in
34
+ // recording order so the second call replays the cached-hit interaction.
35
+ options: {
36
+ redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }),
37
+ },
38
+ })
39
+
40
+ describe("Anthropic Messages cache recorded", () => {
41
+ recorded.effect.with("writes then reads cache_control on identical second call", { tags: ["cache"] }, () =>
42
+ Effect.gen(function* () {
43
+ const first = yield* LLMClient.generate(cacheRequest)
44
+ // The first call may write the cache (cacheWriteInputTokens > 0) or it
45
+ // may be a fresh miss (both fields 0) depending on whether the prefix is
46
+ // already warm on Anthropic's side. The assertion that matters is that
47
+ // the SECOND call reports a non-zero cache read.
48
+ expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
49
+
50
+ const second = yield* LLMClient.generate(cacheRequest)
51
+ expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
52
+ }),
53
+ )
54
+ })
@@ -0,0 +1,46 @@
1
+ import { Redactor } from "@codilore/http-recorder"
2
+ import { describe, expect } from "bun:test"
3
+ import { Effect } from "effect"
4
+ import { LLM, LLMError, Message, ToolCallPart } from "../../src"
5
+ import { LLMClient } from "../../src/route"
6
+ import * as Anthropic from "../../src/providers/anthropic"
7
+ import { weatherToolName } from "../recorded-scenarios"
8
+ import { recordedTests } from "../recorded-test"
9
+
10
+ const model = Anthropic.configure({
11
+ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
12
+ }).model("claude-haiku-4-5-20251001")
13
+
14
+ const malformedToolOrderRequest = LLM.request({
15
+ id: "recorded_anthropic_malformed_tool_order",
16
+ model,
17
+ messages: [
18
+ Message.assistant([
19
+ ToolCallPart.make({ id: "call_1", name: weatherToolName, input: { city: "Paris" } }),
20
+ { type: "text", text: "I will check the weather." },
21
+ ]),
22
+ Message.tool({ id: "call_1", name: weatherToolName, result: { temperature: "72F" } }),
23
+ Message.user("Use that result to answer briefly."),
24
+ ],
25
+ tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }],
26
+ })
27
+
28
+ const recorded = recordedTests({
29
+ prefix: "anthropic-messages",
30
+ provider: "anthropic",
31
+ protocol: "anthropic-messages",
32
+ requires: ["ANTHROPIC_API_KEY"],
33
+ options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
34
+ })
35
+
36
+ describe("Anthropic Messages sad-path recorded", () => {
37
+ recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () =>
38
+ Effect.gen(function* () {
39
+ const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
40
+
41
+ expect(error).toBeInstanceOf(LLMError)
42
+ expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
43
+ expect(error.message).toContain("HTTP 400")
44
+ }),
45
+ )
46
+ })