@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,542 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { NodeFileSystem } from "@effect/platform-node"
4
+ import * as path from "node:path"
5
+ import * as prompts from "@clack/prompts"
6
+ import { AwsV4Signer } from "aws4fetch"
7
+ import { Config, ConfigProvider, Effect, FileSystem, PlatformError, Redacted } from "effect"
8
+ import { FetchHttpClient, HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
9
+ import * as ProviderShared from "../src/protocols/shared"
10
+ import * as Cloudflare from "../src/providers/cloudflare"
11
+
12
+ type Provider = {
13
+ readonly id: string
14
+ readonly label: string
15
+ readonly tier: "core" | "canary" | "compatible" | "optional"
16
+ readonly note: string
17
+ readonly vars: ReadonlyArray<{
18
+ readonly name: string
19
+ readonly label?: string
20
+ readonly optional?: boolean
21
+ readonly secret?: boolean
22
+ }>
23
+ readonly validate?: (env: Env) => Effect.Effect<string | undefined, unknown, HttpClient.HttpClient>
24
+ }
25
+
26
+ type Env = Record<string, string>
27
+
28
+ const PROVIDERS: ReadonlyArray<Provider> = [
29
+ {
30
+ id: "openai",
31
+ label: "OpenAI",
32
+ tier: "core",
33
+ note: "Native OpenAI Chat / Responses recorded tests",
34
+ vars: [{ name: "OPENAI_API_KEY" }],
35
+ validate: (env) => validateBearer("https://api.openai.com/v1/models", Redacted.make(env.OPENAI_API_KEY)),
36
+ },
37
+ {
38
+ id: "anthropic",
39
+ label: "Anthropic",
40
+ tier: "core",
41
+ note: "Native Anthropic Messages recorded tests",
42
+ vars: [{ name: "ANTHROPIC_API_KEY" }],
43
+ validate: (env) =>
44
+ HttpClientRequest.get("https://api.anthropic.com/v1/models").pipe(
45
+ HttpClientRequest.setHeaders({
46
+ "anthropic-version": "2023-06-01",
47
+ "x-api-key": Redacted.value(Redacted.make(env.ANTHROPIC_API_KEY)),
48
+ }),
49
+ executeRequest,
50
+ ),
51
+ },
52
+ {
53
+ id: "google",
54
+ label: "Google Gemini",
55
+ tier: "core",
56
+ note: "Native Gemini recorded tests",
57
+ vars: [{ name: "GOOGLE_GENERATIVE_AI_API_KEY" }],
58
+ validate: (env) =>
59
+ HttpClientRequest.get(
60
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(env.GOOGLE_GENERATIVE_AI_API_KEY)}`,
61
+ ).pipe(executeRequest),
62
+ },
63
+ {
64
+ id: "bedrock",
65
+ label: "Amazon Bedrock",
66
+ tier: "core",
67
+ note: "Native Bedrock Converse recorded tests",
68
+ vars: [
69
+ { name: "AWS_ACCESS_KEY_ID" },
70
+ { name: "AWS_SECRET_ACCESS_KEY" },
71
+ { name: "AWS_SESSION_TOKEN", optional: true },
72
+ { name: "BEDROCK_RECORDING_REGION", optional: true },
73
+ { name: "BEDROCK_MODEL_ID", optional: true },
74
+ ],
75
+ validate: (env) => validateBedrock(env),
76
+ },
77
+ {
78
+ id: "groq",
79
+ label: "Groq",
80
+ tier: "canary",
81
+ note: "Fast OpenAI-compatible canary for text/tool streaming",
82
+ vars: [{ name: "GROQ_API_KEY" }],
83
+ validate: (env) => validateBearer("https://api.groq.com/openai/v1/models", Redacted.make(env.GROQ_API_KEY)),
84
+ },
85
+ {
86
+ id: "openrouter",
87
+ label: "OpenRouter",
88
+ tier: "canary",
89
+ note: "Router canary for OpenAI-compatible text/tool streaming",
90
+ vars: [{ name: "OPENROUTER_API_KEY" }],
91
+ validate: (env) =>
92
+ validateChat({
93
+ url: "https://openrouter.ai/api/v1/chat/completions",
94
+ token: Redacted.make(env.OPENROUTER_API_KEY),
95
+ model: "openai/gpt-4o-mini",
96
+ }),
97
+ },
98
+ {
99
+ id: "xai",
100
+ label: "xAI",
101
+ tier: "canary",
102
+ note: "OpenAI-compatible xAI chat endpoint",
103
+ vars: [{ name: "XAI_API_KEY" }],
104
+ validate: (env) => validateBearer("https://api.x.ai/v1/models", Redacted.make(env.XAI_API_KEY)),
105
+ },
106
+ {
107
+ id: "cloudflare-ai-gateway",
108
+ label: "Cloudflare AI Gateway",
109
+ tier: "canary",
110
+ note: "Cloudflare Unified/OpenAI-compatible gateway; supports provider/model ids like workers-ai/@cf/...",
111
+ vars: [
112
+ { name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false },
113
+ {
114
+ name: "CLOUDFLARE_GATEWAY_ID",
115
+ label: "Cloudflare AI Gateway ID (defaults to default)",
116
+ optional: true,
117
+ secret: false,
118
+ },
119
+ { name: "CLOUDFLARE_API_TOKEN", label: "Cloudflare AI Gateway token" },
120
+ ],
121
+ validate: (env) =>
122
+ validateChat({
123
+ url: `${Cloudflare.aiGatewayBaseURL({
124
+ accountId: env.CLOUDFLARE_ACCOUNT_ID,
125
+ gatewayId: env.CLOUDFLARE_GATEWAY_ID || undefined,
126
+ })}/chat/completions`,
127
+ token: Redacted.make(envValue(env, Cloudflare.aiGatewayAuthEnvVars)),
128
+ tokenHeader: "cf-aig-authorization",
129
+ model: "workers-ai/@cf/meta/llama-3.1-8b-instruct",
130
+ }),
131
+ },
132
+ {
133
+ id: "cloudflare-workers-ai",
134
+ label: "Cloudflare Workers AI",
135
+ tier: "canary",
136
+ note: "Direct Workers AI OpenAI-compatible endpoint; supports model ids like @cf/meta/...",
137
+ vars: [
138
+ { name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false },
139
+ { name: "CLOUDFLARE_API_KEY", label: "Cloudflare Workers AI API token" },
140
+ ],
141
+ validate: (env) =>
142
+ validateChat({
143
+ url: `${Cloudflare.workersAIBaseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`,
144
+ token: Redacted.make(envValue(env, Cloudflare.workersAIAuthEnvVars)),
145
+ model: "@cf/meta/llama-3.1-8b-instruct",
146
+ }),
147
+ },
148
+ {
149
+ id: "deepseek",
150
+ label: "DeepSeek",
151
+ tier: "compatible",
152
+ note: "Existing OpenAI-compatible recorded tests",
153
+ vars: [{ name: "DEEPSEEK_API_KEY" }],
154
+ validate: (env) => validateBearer("https://api.deepseek.com/models", Redacted.make(env.DEEPSEEK_API_KEY)),
155
+ },
156
+ {
157
+ id: "togetherai",
158
+ label: "TogetherAI",
159
+ tier: "compatible",
160
+ note: "Existing OpenAI-compatible text/tool recorded tests",
161
+ vars: [{ name: "TOGETHER_AI_API_KEY" }],
162
+ validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
163
+ },
164
+ {
165
+ id: "mistral",
166
+ label: "Mistral",
167
+ tier: "optional",
168
+ note: "OpenAI-compatible bridge; native reasoning parity is follow-up work",
169
+ vars: [{ name: "MISTRAL_API_KEY" }],
170
+ validate: (env) => validateBearer("https://api.mistral.ai/v1/models", Redacted.make(env.MISTRAL_API_KEY)),
171
+ },
172
+ {
173
+ id: "perplexity",
174
+ label: "Perplexity",
175
+ tier: "optional",
176
+ note: "OpenAI-compatible bridge; citations/search metadata are follow-up work",
177
+ vars: [{ name: "PERPLEXITY_API_KEY" }],
178
+ validate: (env) => validateBearer("https://api.perplexity.ai/models", Redacted.make(env.PERPLEXITY_API_KEY)),
179
+ },
180
+ {
181
+ id: "venice",
182
+ label: "Venice",
183
+ tier: "optional",
184
+ note: "OpenAI-compatible bridge",
185
+ vars: [{ name: "VENICE_API_KEY" }],
186
+ validate: (env) => validateBearer("https://api.venice.ai/api/v1/models", Redacted.make(env.VENICE_API_KEY)),
187
+ },
188
+ {
189
+ id: "cerebras",
190
+ label: "Cerebras",
191
+ tier: "optional",
192
+ note: "OpenAI-compatible bridge",
193
+ vars: [{ name: "CEREBRAS_API_KEY" }],
194
+ validate: (env) => validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY)),
195
+ },
196
+ {
197
+ id: "deepinfra",
198
+ label: "DeepInfra",
199
+ tier: "optional",
200
+ note: "OpenAI-compatible bridge",
201
+ vars: [{ name: "DEEPINFRA_API_KEY" }],
202
+ validate: (env) =>
203
+ validateBearer("https://api.deepinfra.com/v1/openai/models", Redacted.make(env.DEEPINFRA_API_KEY)),
204
+ },
205
+ {
206
+ id: "fireworks",
207
+ label: "Fireworks",
208
+ tier: "optional",
209
+ note: "OpenAI-compatible bridge",
210
+ vars: [{ name: "FIREWORKS_API_KEY" }],
211
+ validate: (env) =>
212
+ validateBearer("https://api.fireworks.ai/inference/v1/models", Redacted.make(env.FIREWORKS_API_KEY)),
213
+ },
214
+ {
215
+ id: "baseten",
216
+ label: "Baseten",
217
+ tier: "optional",
218
+ note: "OpenAI-compatible bridge",
219
+ vars: [{ name: "BASETEN_API_KEY" }],
220
+ },
221
+ ]
222
+
223
+ const args = process.argv.slice(2)
224
+ const hasFlag = (name: string) => args.includes(name)
225
+ const option = (name: string) => {
226
+ const index = args.indexOf(name)
227
+ if (index === -1) return undefined
228
+ return args[index + 1]
229
+ }
230
+
231
+ const envPath = path.resolve(process.cwd(), option("--env") ?? ".env.local")
232
+ const checkOnly = hasFlag("--check")
233
+ const providerOption = option("--providers")
234
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
235
+
236
+ const envNames = Array.from(new Set(PROVIDERS.flatMap((provider) => provider.vars.map((item) => item.name))))
237
+
238
+ const providersForOption = (value: string | undefined) => {
239
+ if (!value || value === "recommended")
240
+ return PROVIDERS.filter((provider) => provider.tier === "core" || provider.tier === "canary")
241
+ if (value === "recorded") return PROVIDERS.filter((provider) => provider.tier !== "optional")
242
+ if (value === "all") return PROVIDERS
243
+ const ids = new Set(
244
+ value
245
+ .split(",")
246
+ .map((item) => item.trim())
247
+ .filter(Boolean),
248
+ )
249
+ return PROVIDERS.filter((provider) => ids.has(provider.id))
250
+ }
251
+
252
+ const chooseProviders = async () => {
253
+ if (providerOption) return providersForOption(providerOption)
254
+ return providersForOption("recommended")
255
+ }
256
+
257
+ const catchMissingFile = (error: PlatformError.PlatformError) => {
258
+ if (error.reason._tag === "NotFound") return Effect.succeed("")
259
+ return Effect.fail(error)
260
+ }
261
+
262
+ const readEnvFile = Effect.fn("RecordingEnv.readFile")(function* () {
263
+ const fileSystem = yield* FileSystem.FileSystem
264
+ return yield* fileSystem.readFileString(envPath).pipe(Effect.catch(catchMissingFile))
265
+ })
266
+
267
+ const readConfigString = (provider: ConfigProvider.ConfigProvider, name: string) =>
268
+ Config.string(name)
269
+ .parse(provider)
270
+ .pipe(
271
+ Effect.match({
272
+ onFailure: () => undefined,
273
+ onSuccess: (value) => value,
274
+ }),
275
+ )
276
+
277
+ const parseEnv = Effect.fn("RecordingEnv.parseEnv")(function* (contents: string) {
278
+ const provider = ConfigProvider.fromDotEnvContents(contents)
279
+ return Object.fromEntries(
280
+ (yield* Effect.forEach(envNames, (name) =>
281
+ readConfigString(provider, name).pipe(Effect.map((value) => [name, value] as const)),
282
+ )).filter((entry): entry is readonly [string, string] => entry[1] !== undefined),
283
+ )
284
+ })
285
+
286
+ const quote = (value: string) => JSON.stringify(value)
287
+
288
+ const status = (name: string, fileEnv: Env) => {
289
+ if (fileEnv[name]) return "file"
290
+ if (process.env[name]) return "shell"
291
+ return "missing"
292
+ }
293
+
294
+ const statusLine = (provider: Provider, fileEnv: Env) =>
295
+ [
296
+ `${provider.label} (${provider.tier})`,
297
+ provider.note,
298
+ ...provider.vars.map((item) => {
299
+ const value = status(item.name, fileEnv)
300
+ const suffix = item.optional ? " optional" : ""
301
+ return ` ${value === "missing" ? "missing" : "set"} ${item.name}${suffix}${value === "shell" ? " (shell only)" : ""}`
302
+ }),
303
+ ].join("\n")
304
+
305
+ const printStatus = (providers: ReadonlyArray<Provider>, fileEnv: Env) => {
306
+ prompts.note(providers.map((provider) => statusLine(provider, fileEnv)).join("\n\n"), `Recording env: ${envPath}`)
307
+ }
308
+
309
+ const exitIfCancel = <A>(value: A | symbol): A => {
310
+ if (!prompts.isCancel(value)) return value as A
311
+ prompts.cancel("Cancelled")
312
+ process.exit(130)
313
+ }
314
+
315
+ const upsertEnv = (contents: string, values: Env) => {
316
+ const names = Object.keys(values)
317
+ const seen = new Set<string>()
318
+ const lines = contents.split(/\r?\n/).map((line) => {
319
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)
320
+ if (!match || !names.includes(match[1])) return line
321
+ seen.add(match[1])
322
+ return `${match[1]}=${quote(values[match[1]])}`
323
+ })
324
+ const missing = names.filter((name) => !seen.has(name))
325
+ if (missing.length === 0) return lines.join("\n").replace(/\n*$/, "\n")
326
+ const prefix = lines.join("\n").trimEnd()
327
+ const block = [
328
+ "",
329
+ "# Added by bun run setup:recording-env",
330
+ ...missing.map((name) => `${name}=${quote(values[name])}`),
331
+ ].join("\n")
332
+ return `${prefix}${block}\n`
333
+ }
334
+
335
+ const providerRequiredStatus = (provider: Provider, fileEnv: Env) => {
336
+ const required = requiredVars(provider)
337
+ if (required.some((item) => status(item.name, fileEnv) === "missing")) return "missing"
338
+ if (required.some((item) => status(item.name, fileEnv) === "shell")) return "set in shell"
339
+ return "already added"
340
+ }
341
+
342
+ const requiredVars = (provider: Provider) => provider.vars.filter((item) => !item.optional)
343
+
344
+ const promptVars = (provider: Provider) => provider.vars.filter((item) => !item.optional || item.secret === false)
345
+
346
+ const processEnv = (): Env =>
347
+ Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined))
348
+
349
+ const envValue = (env: Env, names: ReadonlyArray<string>) => names.map((name) => env[name]).find(Boolean) ?? ""
350
+
351
+ const envWithValues = (fileEnv: Env, values: Env): Env => ({
352
+ ...processEnv(),
353
+ ...fileEnv,
354
+ ...values,
355
+ })
356
+
357
+ const responseError = Effect.fn("RecordingEnv.responseError")(function* (
358
+ response: HttpClientResponse.HttpClientResponse,
359
+ ) {
360
+ if (response.status >= 200 && response.status < 300) return undefined
361
+ const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
362
+ return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
363
+ })
364
+
365
+ const executeRequest = Effect.fn("RecordingEnv.executeRequest")(function* (
366
+ request: HttpClientRequest.HttpClientRequest,
367
+ ) {
368
+ const http = yield* HttpClient.HttpClient
369
+ return yield* http.execute(request).pipe(Effect.flatMap(responseError))
370
+ })
371
+
372
+ const validateBearer = (url: string, token: Redacted.Redacted<string>, headers: Record<string, string> = {}) =>
373
+ HttpClientRequest.get(url).pipe(
374
+ HttpClientRequest.setHeaders({ ...headers, authorization: `Bearer ${Redacted.value(token)}` }),
375
+ executeRequest,
376
+ )
377
+
378
+ const validateChat = (input: {
379
+ readonly url: string
380
+ readonly token: Redacted.Redacted<string>
381
+ readonly tokenHeader?: string
382
+ readonly model: string
383
+ readonly headers?: Record<string, string>
384
+ }) =>
385
+ ProviderShared.jsonPost({
386
+ url: input.url,
387
+ headers: { ...input.headers, [input.tokenHeader ?? "authorization"]: `Bearer ${Redacted.value(input.token)}` },
388
+ body: ProviderShared.encodeJson({
389
+ model: input.model,
390
+ messages: [{ role: "user", content: "Reply with exactly: ok" }],
391
+ max_tokens: 3,
392
+ temperature: 0,
393
+ }),
394
+ }).pipe(executeRequest)
395
+
396
+ const validateBedrock = (env: Env) =>
397
+ Effect.gen(function* () {
398
+ const request = yield* Effect.promise(() =>
399
+ new AwsV4Signer({
400
+ url: `https://bedrock.${env.BEDROCK_RECORDING_REGION || "us-east-1"}.amazonaws.com/foundation-models`,
401
+ method: "GET",
402
+ service: "bedrock",
403
+ region: env.BEDROCK_RECORDING_REGION || "us-east-1",
404
+ accessKeyId: env.AWS_ACCESS_KEY_ID,
405
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
406
+ sessionToken: env.AWS_SESSION_TOKEN || undefined,
407
+ }).sign(),
408
+ )
409
+ return yield* HttpClientRequest.get(request.url.toString()).pipe(
410
+ HttpClientRequest.setHeaders(Object.fromEntries(request.headers.entries())),
411
+ executeRequest,
412
+ )
413
+ })
414
+
415
+ const validateProvider = Effect.fn("RecordingEnv.validateProvider")(function* (provider: Provider, env: Env) {
416
+ return yield* (provider.validate?.(env) ?? Effect.succeed("no lightweight validator")).pipe(
417
+ Effect.catch((error) => {
418
+ if (error instanceof Error) return Effect.succeed(error.message)
419
+ return Effect.succeed(String(error))
420
+ }),
421
+ )
422
+ })
423
+
424
+ const validateProviders = Effect.fn("RecordingEnv.validateProviders")(function* (
425
+ providers: ReadonlyArray<Provider>,
426
+ env: Env,
427
+ ) {
428
+ const spinner = prompts.spinner()
429
+ spinner.start("Validating credentials")
430
+ const results = yield* Effect.forEach(
431
+ providers,
432
+ (provider) => validateProvider(provider, env).pipe(Effect.map((error) => ({ provider, error }))),
433
+ { concurrency: 4 },
434
+ )
435
+ spinner.stop("Validation complete")
436
+ prompts.note(
437
+ results
438
+ .map(
439
+ (result) =>
440
+ `${result.error ? "failed" : "ok"} ${result.provider.label}${result.error ? ` - ${result.error}` : ""}`,
441
+ )
442
+ .join("\n"),
443
+ "Credential validation",
444
+ )
445
+ })
446
+
447
+ const writeEnvFile = Effect.fn("RecordingEnv.writeFile")(function* (contents: string) {
448
+ const fileSystem = yield* FileSystem.FileSystem
449
+ yield* fileSystem.makeDirectory(path.dirname(envPath), { recursive: true })
450
+ yield* fileSystem.writeFileString(envPath, contents, { mode: 0o600 })
451
+ })
452
+
453
+ const prompt = <A>(run: () => Promise<A | symbol>) => Effect.promise(run).pipe(Effect.map(exitIfCancel))
454
+
455
+ const chooseConfigurableProviders = Effect.fn("RecordingEnv.chooseConfigurableProviders")(function* (
456
+ providers: ReadonlyArray<Provider>,
457
+ fileEnv: Env,
458
+ ) {
459
+ const configurable = providers.filter((provider) => requiredVars(provider).length > 0)
460
+ const selected = yield* prompt<ReadonlyArray<string>>(() =>
461
+ prompts.multiselect({
462
+ message: "Select provider credentials to add or override",
463
+ options: configurable.map((provider) => ({
464
+ value: provider.id,
465
+ label: provider.label,
466
+ hint: `${providerRequiredStatus(provider, fileEnv)} - ${requiredVars(provider)
467
+ .map((item) => item.name)
468
+ .join(", ")}`,
469
+ })),
470
+ initialValues: configurable
471
+ .filter((provider) => providerRequiredStatus(provider, fileEnv) === "missing")
472
+ .map((provider) => provider.id),
473
+ }),
474
+ )
475
+ return configurable.filter((provider) => selected.includes(provider.id))
476
+ })
477
+
478
+ const promptEnvVar = (item: Provider["vars"][number]) =>
479
+ prompt<string>(() => {
480
+ const input = {
481
+ message: item.label ?? item.name,
482
+ validate: (input: string | undefined) => {
483
+ if (item.optional) return undefined
484
+ return !input || input.length === 0 ? "Leave blank by pressing Esc/cancel, or paste a value" : undefined
485
+ },
486
+ }
487
+ return item.secret === false ? prompts.text(input) : prompts.password(input)
488
+ })
489
+
490
+ const promptProviderValues = Effect.fn("RecordingEnv.promptProviderValues")(function* (
491
+ providers: ReadonlyArray<Provider>,
492
+ ) {
493
+ const values: Env = {}
494
+ for (const provider of providers) {
495
+ prompts.log.info(`${provider.label}: ${provider.note}`)
496
+ for (const item of promptVars(provider)) {
497
+ if (values[item.name]) continue
498
+ const value = yield* promptEnvVar(item)
499
+ if (value !== "") values[item.name] = value
500
+ }
501
+ }
502
+ return values
503
+ })
504
+
505
+ const main = Effect.fn("RecordingEnv.main")(function* () {
506
+ prompts.intro("LLM recording credentials")
507
+ const contents = yield* readEnvFile()
508
+ const fileEnv = yield* parseEnv(contents)
509
+ const providers = yield* Effect.promise(() => chooseProviders())
510
+ printStatus(providers, fileEnv)
511
+ if (checkOnly) {
512
+ prompts.outro("Check complete")
513
+ return
514
+ }
515
+ if (!interactive) {
516
+ prompts.outro("Run this command in a terminal to enter credentials")
517
+ return
518
+ }
519
+
520
+ const selectedProviders = yield* chooseConfigurableProviders(providers, fileEnv)
521
+ const values = yield* promptProviderValues(selectedProviders)
522
+
523
+ if (Object.keys(values).length === 0) {
524
+ prompts.outro("No changes")
525
+ return
526
+ }
527
+
528
+ if (
529
+ interactive &&
530
+ (yield* prompt(() => prompts.confirm({ message: "Validate credentials before saving?", initialValue: true })))
531
+ ) {
532
+ yield* validateProviders(selectedProviders, envWithValues(fileEnv, values))
533
+ }
534
+
535
+ yield* writeEnvFile(upsertEnv(contents, values))
536
+ prompts.log.success(
537
+ `Saved ${Object.keys(values).length} value${Object.keys(values).length === 1 ? "" : "s"} to ${envPath}`,
538
+ )
539
+ prompts.outro("Keep .env.local local. Store shared team credentials in a password manager or vault.")
540
+ })
541
+
542
+ await Effect.runPromise(main().pipe(Effect.provide(NodeFileSystem.layer), Effect.provide(FetchHttpClient.layer)))
@@ -0,0 +1,111 @@
1
+ // Apply an `LLMRequest.cache` policy by injecting `CacheHint`s onto the parts
2
+ // the policy designates. Runs once at compile time, before the per-protocol
3
+ // body builder, so the existing inline-hint lowering path handles the rest.
4
+ //
5
+ // The default `"auto"` shape places one breakpoint at the last tool definition,
6
+ // one at the last system part, and one at the latest user message. This
7
+ // matches what production agent harnesses (LangChain's caching middleware,
8
+ // kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
9
+ // latest user message stays put while a single turn explodes into many
10
+ // assistant/tool round-trips, so caching at that boundary lets every
11
+ // intra-turn API call hit the prefix.
12
+ //
13
+ // Manual `cache: CacheHint` placements on individual parts are preserved —
14
+ // this function only fills gaps the caller left empty.
15
+ import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
16
+ import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
17
+
18
+ const AUTO: CachePolicyObject = {
19
+ tools: true,
20
+ system: true,
21
+ messages: "latest-user-message",
22
+ }
23
+
24
+ const NONE: CachePolicyObject = {}
25
+
26
+ // Resolution rules:
27
+ // - undefined → "auto" — caching is on by default. The math favors it:
28
+ // Anthropic 5m-cache write is 1.25x base, read is 0.1x,
29
+ // so a single reuse within 5 minutes already wins.
30
+ // - "auto" → tools + system + latest user msg.
31
+ // - "none" → no auto placement; manual `CacheHint`s still flow.
32
+ // - object form → exactly what the caller asked for.
33
+ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
34
+ if (policy === undefined || policy === "auto") return AUTO
35
+ if (policy === "none") return NONE
36
+ return policy
37
+ }
38
+
39
+ // Protocols whose wire format ignores inline cache markers (OpenAI's implicit
40
+ // prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
41
+ // whole policy pass for these — emitting hints would be harmless but pointless.
42
+ const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
43
+
44
+ const makeHint = (ttlSeconds: number | undefined): CacheHint =>
45
+ ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
46
+
47
+ const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
48
+ if (tools.length === 0) return tools
49
+ const last = tools.length - 1
50
+ if (tools[last]!.cache) return tools
51
+ return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
52
+ }
53
+
54
+ const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
55
+ if (system.length === 0) return system
56
+ const last = system.length - 1
57
+ if (system[last]!.cache) return system
58
+ return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
59
+ }
60
+
61
+ const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
62
+ messages.findLastIndex((m) => m.role === role)
63
+
64
+ // Mark the last text part of `messages[index]`. If no text part exists, mark
65
+ // the last content part regardless of type — that's the breakpoint position
66
+ // in tool-result-only messages too.
67
+ const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
68
+ if (index < 0 || index >= messages.length) return messages
69
+ const target = messages[index]!
70
+ if (target.content.length === 0) return messages
71
+ const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
72
+ const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
73
+ const existing = target.content[markAt]!
74
+ if ("cache" in existing && existing.cache) return messages
75
+ const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
76
+ const next = new Message({ ...target, content: nextContent })
77
+ // Single pass over `messages`, substituting the one updated entry. Long
78
+ // conversations call this on every request, so avoid `.map()` here — its
79
+ // closure dispatch and identity copies show up in profiling.
80
+ const result = messages.slice()
81
+ result[index] = next
82
+ return result
83
+ }
84
+
85
+ const markMessages = (
86
+ messages: ReadonlyArray<Message>,
87
+ strategy: NonNullable<CachePolicyObject["messages"]>,
88
+ hint: CacheHint,
89
+ ): ReadonlyArray<Message> => {
90
+ if (messages.length === 0) return messages
91
+ if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
92
+ if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
93
+ const start = Math.max(0, messages.length - strategy.tail)
94
+ let next = messages
95
+ for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
96
+ return next
97
+ }
98
+
99
+ export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
100
+ if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
101
+ const policy = resolve(request.cache)
102
+ if (!policy.tools && !policy.system && !policy.messages) return request
103
+
104
+ const hint = makeHint(policy.ttlSeconds)
105
+ const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
106
+ const system = policy.system ? markLastSystem(request.system, hint) : request.system
107
+ const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
108
+
109
+ if (tools === request.tools && system === request.system && messages === request.messages) return request
110
+ return LLMRequest.update(request, { tools, system, messages })
111
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ export { LLMClient } from "./route/client"
2
+ export { Auth } from "./route/auth"
3
+ export { Provider } from "./provider"
4
+ export type {
5
+ RouteModelInput,
6
+ RouteRoutedModelInput,
7
+ Interface as LLMClientShape,
8
+ Service as LLMClientService,
9
+ } from "./route/client"
10
+ export * from "./schema"
11
+ export { Tool, ToolFailure, toDefinitions } from "./tool"
12
+ export { ToolRuntime } from "./tool-runtime"
13
+ export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime"
14
+ export type {
15
+ AnyExecutableTool,
16
+ AnyTool,
17
+ ExecutableTool,
18
+ ExecutableTools,
19
+ Tool as ToolShape,
20
+ ToolExecute,
21
+ ToolExecuteContext,
22
+ ToolModelOutputInput,
23
+ Tools,
24
+ ToolSchema,
25
+ ToolToModelOutput,
26
+ } from "./tool"
27
+ export * as LLM from "./llm"
28
+ export type {
29
+ Definition as ProviderDefinition,
30
+ ModelFactory as ProviderModelFactory,
31
+ ModelOptions as ProviderModelOptions,
32
+ } from "./provider"