@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,103 @@
1
+ import { describe, expect } from "bun:test"
2
+ import { ConfigProvider, Effect } from "effect"
3
+ import { Headers } from "effect/unstable/http"
4
+ import { LLM } from "../src"
5
+ import { Auth } from "../src/route/auth"
6
+ import * as OpenAIChat from "../src/protocols/openai-chat"
7
+ import { Model } from "../src/schema"
8
+ import { it } from "./lib/effect"
9
+
10
+ const request = LLM.request({
11
+ id: "req_auth",
12
+ model: Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }),
13
+ prompt: "hello",
14
+ })
15
+
16
+ const input = {
17
+ request,
18
+ method: "POST" as const,
19
+ url: "https://example.test/v1/chat",
20
+ body: "{}",
21
+ headers: Headers.fromInput({ "x-existing": "yes" }),
22
+ }
23
+
24
+ const withEnv = (env: Record<string, string>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
25
+
26
+ describe("Auth", () => {
27
+ it.effect("renders a config credential as bearer auth", () =>
28
+ Effect.gen(function* () {
29
+ const headers = yield* Auth.config("OPENAI_API_KEY")
30
+ .bearer()
31
+ .apply(input)
32
+ .pipe(withEnv({ OPENAI_API_KEY: "sk-test" }))
33
+
34
+ expect(headers.authorization).toBe("Bearer sk-test")
35
+ expect(headers["x-existing"]).toBe("yes")
36
+ }),
37
+ )
38
+
39
+ it.effect("falls back between credential sources before rendering", () =>
40
+ Effect.gen(function* () {
41
+ const headers = yield* Auth.config("PRIMARY_KEY")
42
+ .orElse(Auth.value("fallback-key"))
43
+ .pipe(Auth.header("x-api-key"))
44
+ .apply(input)
45
+ .pipe(withEnv({}))
46
+
47
+ expect(headers["x-api-key"]).toBe("fallback-key")
48
+ expect(headers["x-existing"]).toBe("yes")
49
+ }),
50
+ )
51
+
52
+ it.effect("composes header auth in sequence", () =>
53
+ Effect.gen(function* () {
54
+ const headers = yield* Auth.headers({ "x-tenant-id": "tenant-1" })
55
+ .andThen(Auth.bearer("gateway-token"))
56
+ .apply(input)
57
+
58
+ expect(headers["x-tenant-id"]).toBe("tenant-1")
59
+ expect(headers.authorization).toBe("Bearer gateway-token")
60
+ expect(headers["x-existing"]).toBe("yes")
61
+ }),
62
+ )
63
+
64
+ it.effect("renders a direct secret as a custom header", () =>
65
+ Effect.gen(function* () {
66
+ const headers = yield* Auth.header("api-key", "direct-key").apply(input)
67
+
68
+ expect(headers["api-key"]).toBe("direct-key")
69
+ expect(headers["x-existing"]).toBe("yes")
70
+ }),
71
+ )
72
+
73
+ it.effect("renders bearer auth into a custom header", () =>
74
+ Effect.gen(function* () {
75
+ const headers = yield* Auth.bearerHeader("cf-aig-authorization", "gateway-token").apply(input)
76
+
77
+ expect(headers["cf-aig-authorization"]).toBe("Bearer gateway-token")
78
+ expect(headers["x-existing"]).toBe("yes")
79
+ }),
80
+ )
81
+
82
+ it.effect("falls back between full auth values", () =>
83
+ Effect.gen(function* () {
84
+ const headers = yield* Auth.config("OPENAI_API_KEY")
85
+ .bearer()
86
+ .orElse(Auth.headers({ authorization: "Bearer supplied" }))
87
+ .apply(input)
88
+ .pipe(withEnv({}))
89
+
90
+ expect(headers.authorization).toBe("Bearer supplied")
91
+ expect(headers["x-existing"]).toBe("yes")
92
+ }),
93
+ )
94
+
95
+ it.effect("can intentionally leave auth untouched", () =>
96
+ Effect.gen(function* () {
97
+ const headers = yield* Auth.none.apply(input)
98
+
99
+ expect(headers.authorization).toBeUndefined()
100
+ expect(headers["x-existing"]).toBe("yes")
101
+ }),
102
+ )
103
+ })
@@ -0,0 +1,262 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { CacheHint, LLM, Message } from "../src"
4
+ import { Auth, LLMClient } from "../src/route"
5
+ import { AmazonBedrock } from "../src/providers"
6
+ import * as AnthropicMessages from "../src/protocols/anthropic-messages"
7
+ import * as Gemini from "../src/protocols/gemini"
8
+ import * as OpenAIChat from "../src/protocols/openai-chat"
9
+ import { applyCachePolicy } from "../src/cache-policy"
10
+ import { it } from "./lib/effect"
11
+
12
+ const anthropicModel = AnthropicMessages.route
13
+ .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
14
+ .model({ id: "claude-sonnet-4-5" })
15
+
16
+ const bedrockModel = AmazonBedrock.configure({
17
+ credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" },
18
+ }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
19
+
20
+ const openaiModel = OpenAIChat.route
21
+ .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
22
+ .model({ id: "gpt-4o-mini" })
23
+
24
+ const geminiModel = Gemini.route
25
+ .with({
26
+ endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
27
+ auth: Auth.header("x-goog-api-key", "test"),
28
+ })
29
+ .model({ id: "gemini-2.5-flash" })
30
+
31
+ describe("applyCachePolicy", () => {
32
+ it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
33
+ Effect.gen(function* () {
34
+ const prepared = yield* LLMClient.prepare(
35
+ LLM.request({
36
+ model: anthropicModel,
37
+ system: "You are concise.",
38
+ prompt: "hi",
39
+ }),
40
+ )
41
+
42
+ // No explicit cache field → auto policy fires → last system part + latest
43
+ // user message both get cache_control markers.
44
+ expect(prepared.body).toMatchObject({
45
+ system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
46
+ messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
47
+ })
48
+ }),
49
+ )
50
+
51
+ it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
52
+ Effect.gen(function* () {
53
+ const prepared = yield* LLMClient.prepare(
54
+ LLM.request({
55
+ model: anthropicModel,
56
+ system: "Sys A",
57
+ tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
58
+ messages: [
59
+ Message.user("first user"),
60
+ Message.assistant("assistant reply"),
61
+ Message.user("latest user message"),
62
+ ],
63
+ cache: "auto",
64
+ }),
65
+ )
66
+
67
+ expect(prepared.body).toMatchObject({
68
+ tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
69
+ system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
70
+ messages: [
71
+ { role: "user", content: [{ type: "text", text: "first user" }] },
72
+ { role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
73
+ {
74
+ role: "user",
75
+ content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }],
76
+ },
77
+ ],
78
+ })
79
+ }),
80
+ )
81
+
82
+ it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
83
+ Effect.gen(function* () {
84
+ const prepared = yield* LLMClient.prepare(
85
+ LLM.request({
86
+ model: openaiModel,
87
+ system: "Sys",
88
+ prompt: "hi",
89
+ cache: "auto",
90
+ }),
91
+ )
92
+
93
+ const body = prepared.body as { messages: Array<{ content: unknown }> }
94
+ // OpenAI doesn't accept cache_control on messages — policy must skip.
95
+ const flat = JSON.stringify(body)
96
+ expect(flat).not.toContain("cache_control")
97
+ expect(flat).not.toContain("cachePoint")
98
+ }),
99
+ )
100
+
101
+ it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
102
+ Effect.gen(function* () {
103
+ const prepared = yield* LLMClient.prepare(
104
+ LLM.request({
105
+ model: geminiModel,
106
+ system: "Sys",
107
+ prompt: "hi",
108
+ cache: "auto",
109
+ }),
110
+ )
111
+
112
+ const flat = JSON.stringify(prepared.body)
113
+ expect(flat).not.toContain("cache_control")
114
+ expect(flat).not.toContain("cachePoint")
115
+ }),
116
+ )
117
+
118
+ it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
119
+ Effect.gen(function* () {
120
+ const prepared = yield* LLMClient.prepare(
121
+ LLM.request({
122
+ model: bedrockModel,
123
+ system: "Sys",
124
+ tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
125
+ messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
126
+ cache: "auto",
127
+ }),
128
+ )
129
+
130
+ expect(prepared.body).toMatchObject({
131
+ toolConfig: {
132
+ tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
133
+ },
134
+ system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
135
+ messages: [
136
+ { role: "user", content: [{ text: "first user" }] },
137
+ { role: "assistant", content: [{ text: "reply" }] },
138
+ { role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] },
139
+ ],
140
+ })
141
+ }),
142
+ )
143
+
144
+ it.effect("'none' disables auto placement even when manual hints exist", () =>
145
+ Effect.gen(function* () {
146
+ const prepared = yield* LLMClient.prepare(
147
+ LLM.request({
148
+ model: anthropicModel,
149
+ system: "Sys",
150
+ tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
151
+ prompt: "hi",
152
+ cache: "none",
153
+ }),
154
+ )
155
+
156
+ expect(prepared.body).toMatchObject({
157
+ tools: [{ name: "t1", cache_control: undefined }],
158
+ system: [{ type: "text", text: "Sys", cache_control: undefined }],
159
+ })
160
+ }),
161
+ )
162
+
163
+ it.effect("granular object form: tools-only marks just tools", () =>
164
+ Effect.gen(function* () {
165
+ const prepared = yield* LLMClient.prepare(
166
+ LLM.request({
167
+ model: anthropicModel,
168
+ system: "Sys",
169
+ tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
170
+ prompt: "hi",
171
+ cache: { tools: true },
172
+ }),
173
+ )
174
+
175
+ expect(prepared.body).toMatchObject({
176
+ tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
177
+ system: [{ type: "text", text: "Sys", cache_control: undefined }],
178
+ })
179
+ }),
180
+ )
181
+
182
+ it.effect("auto policy preserves manual CacheHints on other parts", () =>
183
+ Effect.gen(function* () {
184
+ const prepared = yield* LLMClient.prepare(
185
+ LLM.request({
186
+ model: anthropicModel,
187
+ system: [
188
+ { type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
189
+ { type: "text", text: "last system" },
190
+ ],
191
+ prompt: "hi",
192
+ cache: "auto",
193
+ }),
194
+ )
195
+
196
+ const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
197
+ expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
198
+ expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
199
+ }),
200
+ )
201
+
202
+ it.effect("ttlSeconds in the policy flows through to wire markers", () =>
203
+ Effect.gen(function* () {
204
+ const prepared = yield* LLMClient.prepare(
205
+ LLM.request({
206
+ model: anthropicModel,
207
+ system: "Sys",
208
+ prompt: "hi",
209
+ cache: { system: true, ttlSeconds: 3600 },
210
+ }),
211
+ )
212
+
213
+ expect(prepared.body).toMatchObject({
214
+ system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }],
215
+ })
216
+ }),
217
+ )
218
+
219
+ it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
220
+ Effect.gen(function* () {
221
+ const prepared = yield* LLMClient.prepare(
222
+ LLM.request({
223
+ model: anthropicModel,
224
+ messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
225
+ cache: { messages: { tail: 2 } },
226
+ }),
227
+ )
228
+
229
+ const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
230
+ expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
231
+ expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined()
232
+ expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
233
+ expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
234
+ }),
235
+ )
236
+
237
+ it.effect("'latest-assistant' marks the last assistant message", () =>
238
+ Effect.gen(function* () {
239
+ const prepared = yield* LLMClient.prepare(
240
+ LLM.request({
241
+ model: anthropicModel,
242
+ messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
243
+ cache: { messages: "latest-assistant" },
244
+ }),
245
+ )
246
+
247
+ const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
248
+ expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
249
+ expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
250
+ expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined()
251
+ }),
252
+ )
253
+
254
+ test("returns the same request reference when policy is a no-op (pure function)", () => {
255
+ const request = LLM.request({
256
+ model: anthropicModel,
257
+ prompt: "hi",
258
+ cache: "none",
259
+ })
260
+ expect(applyCachePolicy(request)).toBe(request)
261
+ })
262
+ })
@@ -0,0 +1,104 @@
1
+ import { LLM, Message, ToolCallPart, ToolDefinition, ToolResultPart, type ContentPart, type Model } from "../src"
2
+
3
+ export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const
4
+ export const toolContinuation = ["tool-call", "tool-result"] as const
5
+ export const reasoningContinuation = ["assistant-reasoning", "encrypted-reasoning"] as const
6
+ export const mediaContinuation = ["user-image"] as const
7
+ export const maximalContinuation = [
8
+ ...basicContinuation,
9
+ ...toolContinuation,
10
+ ...reasoningContinuation,
11
+ ...mediaContinuation,
12
+ ] as const
13
+
14
+ export type ContinuationFeature = (typeof maximalContinuation)[number]
15
+
16
+ export const nativeOpenAIResponsesContinuation = [
17
+ ...basicContinuation,
18
+ ...toolContinuation,
19
+ "encrypted-reasoning",
20
+ ...mediaContinuation,
21
+ ] as const satisfies ReadonlyArray<ContinuationFeature>
22
+
23
+ export const nativeAnthropicMessagesContinuation = [
24
+ ...basicContinuation,
25
+ ...toolContinuation,
26
+ "assistant-reasoning",
27
+ ...mediaContinuation,
28
+ ] as const satisfies ReadonlyArray<ContinuationFeature>
29
+
30
+ export const continuationTool = ToolDefinition.make({
31
+ name: "get_weather",
32
+ description: "Get current weather for a city.",
33
+ inputSchema: {
34
+ type: "object",
35
+ properties: { city: { type: "string" } },
36
+ required: ["city"],
37
+ additionalProperties: false,
38
+ },
39
+ })
40
+
41
+ export function continuationRequest(input: {
42
+ readonly id: string
43
+ readonly model: Model
44
+ readonly features: ReadonlyArray<ContinuationFeature>
45
+ readonly image?: string
46
+ }) {
47
+ const features = new Set(input.features)
48
+ const messages = []
49
+ const firstUser: ContentPart[] = []
50
+ const firstAssistant: ContentPart[] = []
51
+
52
+ if (features.has("user-text")) firstUser.push({ type: "text", text: "What is shown here?" })
53
+ if (features.has("user-image"))
54
+ firstUser.push({ type: "media", mediaType: "image/png", data: input.image ?? "AAECAw==" })
55
+ if (firstUser.length > 0) messages.push(Message.user(firstUser))
56
+
57
+ if (features.has("assistant-reasoning"))
58
+ firstAssistant.push({
59
+ type: "reasoning",
60
+ text: "I inspected the previous turn.",
61
+ providerMetadata: { anthropic: { signature: "sig_continuation_1" } },
62
+ })
63
+ if (features.has("encrypted-reasoning"))
64
+ firstAssistant.push({
65
+ type: "reasoning",
66
+ text: "I inspected the previous turn.",
67
+ providerMetadata: {
68
+ openai: {
69
+ itemId: "rs_continuation_1",
70
+ reasoningEncryptedContent: "encrypted-continuation-state",
71
+ },
72
+ },
73
+ })
74
+ if (features.has("assistant-text")) firstAssistant.push({ type: "text", text: "It shows a small test image." })
75
+ if (firstAssistant.length > 0) messages.push(Message.assistant(firstAssistant))
76
+
77
+ if (features.has("tool-call")) {
78
+ messages.push(Message.user("Check the weather in Paris before continuing."))
79
+ messages.push(
80
+ Message.assistant([ToolCallPart.make({ id: "call_weather_1", name: "get_weather", input: { city: "Paris" } })]),
81
+ )
82
+ }
83
+ if (features.has("tool-result")) {
84
+ messages.push(
85
+ Message.tool(ToolResultPart.make({ id: "call_weather_1", name: "get_weather", result: { temperature: 22 } })),
86
+ )
87
+ if (features.has("assistant-text")) messages.push(Message.assistant("Paris is 22 degrees."))
88
+ }
89
+ if (features.has("user-follow-up"))
90
+ messages.push(Message.user("Continue from this conversation in one short sentence."))
91
+
92
+ return LLM.request({
93
+ id: input.id,
94
+ model: input.model,
95
+ system: features.has("system") ? "You are concise. Continue from the provided history." : undefined,
96
+ messages,
97
+ tools: features.has("tool-call") ? [continuationTool] : [],
98
+ cache: "none",
99
+ providerOptions: features.has("encrypted-reasoning")
100
+ ? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } }
101
+ : undefined,
102
+ generation: { maxTokens: 80, temperature: 0 },
103
+ })
104
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { LLM } from "../src"
3
+ import * as OpenAIChat from "../src/protocols/openai-chat"
4
+ import { Endpoint } from "../src/route"
5
+ import { Model } from "../src/schema"
6
+
7
+ const request = () =>
8
+ LLM.request({
9
+ model: Model.make({
10
+ id: "model-1",
11
+ provider: "test",
12
+ route: OpenAIChat.route,
13
+ }),
14
+ prompt: "hello",
15
+ })
16
+
17
+ describe("Endpoint", () => {
18
+ test("appends a static path to the model's baseURL", () => {
19
+ const url = Endpoint.render(Endpoint.path("/chat", { baseURL: "https://api.example.test/v1/" }), {
20
+ request: request(),
21
+ body: {},
22
+ })
23
+
24
+ expect(url.toString()).toBe("https://api.example.test/v1/chat")
25
+ })
26
+
27
+ test("endpoint query params are appended to the rendered URL", () => {
28
+ const url = Endpoint.render(
29
+ Endpoint.path("/chat?alt=sse", {
30
+ baseURL: "https://custom.example.test/root/",
31
+ query: { "api-version": "2026-01-01", alt: "json" },
32
+ }),
33
+ {
34
+ request: request(),
35
+ body: {},
36
+ },
37
+ )
38
+
39
+ expect(url.toString()).toBe("https://custom.example.test/root/chat?alt=json&api-version=2026-01-01")
40
+ })
41
+
42
+ test("path may be a function of the validated body", () => {
43
+ const url = Endpoint.render(
44
+ Endpoint.path<{ readonly modelId: string }>(
45
+ ({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`,
46
+ { baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" },
47
+ ),
48
+ {
49
+ request: request(),
50
+ body: { modelId: "us.amazon.nova-micro-v1:0" },
51
+ },
52
+ )
53
+
54
+ expect(url.toString()).toBe(
55
+ "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream",
56
+ )
57
+ })
58
+ })