@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,56 @@
1
+ import { describe, expect } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { LLM } from "../../src"
4
+ import { LLMClient } from "../../src/route"
5
+ import * as OpenRouter from "../../src/providers/openrouter"
6
+ import { it } from "../lib/effect"
7
+
8
+ describe("OpenRouter", () => {
9
+ it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
10
+ Effect.gen(function* () {
11
+ const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
12
+
13
+ expect(model).toMatchObject({
14
+ id: "openai/gpt-4o-mini",
15
+ provider: "openrouter",
16
+ route: { id: "openrouter" },
17
+ })
18
+ expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
19
+
20
+ const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
21
+
22
+ expect(prepared.route).toBe("openrouter")
23
+ expect(prepared.body).toMatchObject({
24
+ model: "openai/gpt-4o-mini",
25
+ messages: [{ role: "user", content: "Say hello." }],
26
+ stream: true,
27
+ })
28
+ }),
29
+ )
30
+
31
+ it.effect("applies OpenRouter payload options from the model helper", () =>
32
+ Effect.gen(function* () {
33
+ const prepared = yield* LLMClient.prepare(
34
+ LLM.request({
35
+ model: OpenRouter.configure({
36
+ apiKey: "test-key",
37
+ providerOptions: {
38
+ openrouter: {
39
+ usage: true,
40
+ reasoning: { effort: "high" },
41
+ promptCacheKey: "session_123",
42
+ },
43
+ },
44
+ }).model("anthropic/claude-3.7-sonnet:thinking"),
45
+ prompt: "Think briefly.",
46
+ }),
47
+ )
48
+
49
+ expect(prepared.body).toMatchObject({
50
+ usage: { include: true },
51
+ reasoning: { effort: "high" },
52
+ prompt_cache_key: "session_123",
53
+ })
54
+ }),
55
+ )
56
+ })
@@ -0,0 +1,41 @@
1
+ import { Provider } from "../src/provider"
2
+ import { ProviderID, type Model } from "../src/schema"
3
+
4
+ declare const model: (id: string) => Model
5
+ declare const requiredModel: (id: string, options: { readonly baseURL: string }) => Model
6
+ declare const chat: (id: string, options: { readonly apiKey: string }) => Model
7
+
8
+ Provider.make({
9
+ id: ProviderID.make("example"),
10
+ model,
11
+ })
12
+
13
+ Provider.make({
14
+ id: ProviderID.make("bad"),
15
+ model,
16
+ // @ts-expect-error provider definitions should not grow accidental top-level fields.
17
+ routes: [],
18
+ })
19
+
20
+ const requiredProvider = Provider.make({
21
+ id: ProviderID.make("required"),
22
+ model: requiredModel,
23
+ })
24
+
25
+ // Provider.make is advanced structural typing coverage; built-in providers use
26
+ // configure(...).model(id) facades instead of second-argument selectors.
27
+ requiredProvider.model("custom", { baseURL: "https://example.com/v1" })
28
+
29
+ // @ts-expect-error Provider.make preserves required model options.
30
+ requiredProvider.model("custom")
31
+
32
+ const multiApiProvider = Provider.make({
33
+ id: ProviderID.make("multi-api"),
34
+ model,
35
+ apis: { chat },
36
+ })
37
+
38
+ multiApiProvider.apis.chat("chat-model", { apiKey: "key" })
39
+
40
+ // @ts-expect-error Provider.make preserves API-specific option types.
41
+ multiApiProvider.apis.chat("chat-model")
@@ -0,0 +1,97 @@
1
+ import type { HttpRecorder } from "@codilore/http-recorder"
2
+ import { describe } from "bun:test"
3
+ import { Effect } from "effect"
4
+ import type { Model } from "../src"
5
+ import { goldenScenarioTags, goldenScenarioTitle, runGoldenScenario, type GoldenScenarioID } from "./recorded-scenarios"
6
+ import { recordedTests } from "./recorded-test"
7
+ import { kebab } from "./recorded-utils"
8
+
9
+ type Transport = "http" | "websocket"
10
+
11
+ type ScenarioInput =
12
+ | GoldenScenarioID
13
+ | {
14
+ readonly id: GoldenScenarioID
15
+ readonly name?: string
16
+ readonly cassette?: string
17
+ readonly tags?: ReadonlyArray<string>
18
+ readonly maxTokens?: number
19
+ readonly temperature?: number | false
20
+ readonly timeout?: number
21
+ }
22
+
23
+ type TargetInput = {
24
+ readonly name: string
25
+ readonly model: Model
26
+ readonly protocol?: string
27
+ readonly requires?: ReadonlyArray<string>
28
+ readonly transport?: Transport
29
+ readonly prefix?: string
30
+ readonly tags?: ReadonlyArray<string>
31
+ readonly metadata?: Record<string, unknown>
32
+ readonly options?: HttpRecorder.RecordReplayOptions
33
+ readonly scenarios: ReadonlyArray<ScenarioInput>
34
+ }
35
+
36
+ const scenarioInput = (input: ScenarioInput) => (typeof input === "string" ? { id: input } : input)
37
+
38
+ const defaultPrefix = (target: TargetInput) => {
39
+ if (target.prefix) return target.prefix
40
+ const transport = target.transport === "websocket" ? "-websocket" : ""
41
+ return `${target.model.provider}-${target.protocol ?? target.model.route.id}${transport}`
42
+ }
43
+
44
+ const metadata = (target: TargetInput) => ({
45
+ provider: target.model.provider,
46
+ protocol: target.protocol,
47
+ route: target.model.route.id,
48
+ transport: target.transport ?? "http",
49
+ model: target.model.id,
50
+ ...target.metadata,
51
+ })
52
+
53
+ const tags = (target: TargetInput) => [
54
+ ...(target.transport === "websocket" ? ["transport:websocket"] : []),
55
+ ...(target.tags ?? []),
56
+ ]
57
+
58
+ const runTarget = (target: TargetInput) => {
59
+ const recorded = recordedTests({
60
+ prefix: defaultPrefix(target),
61
+ provider: target.model.provider,
62
+ protocol: target.protocol,
63
+ requires: target.requires,
64
+ tags: tags(target),
65
+ metadata: metadata(target),
66
+ options: target.options,
67
+ })
68
+
69
+ describe(`${target.name} recorded`, () => {
70
+ target.scenarios.forEach((raw) => {
71
+ const input = scenarioInput(raw)
72
+ const name = input.name ?? goldenScenarioTitle(input.id)
73
+ recorded.effect.with(
74
+ name,
75
+ {
76
+ cassette: input.cassette,
77
+ id: `${kebab(target.name)}-${input.id}`,
78
+ tags: [...goldenScenarioTags(input.id), ...(input.tags ?? [])],
79
+ },
80
+ () =>
81
+ Effect.gen(function* () {
82
+ yield* runGoldenScenario(input.id, {
83
+ id: `recorded_${kebab(target.name).replaceAll("-", "_")}_${input.id.replaceAll("-", "_")}`,
84
+ model: target.model,
85
+ maxTokens: input.maxTokens,
86
+ temperature: input.temperature,
87
+ })
88
+ }),
89
+ input.timeout,
90
+ )
91
+ })
92
+ })
93
+ }
94
+
95
+ export const describeRecordedGoldenScenarios = (targets: ReadonlyArray<TargetInput>) => {
96
+ targets.forEach(runTarget)
97
+ }
@@ -0,0 +1,100 @@
1
+ import { test, type TestOptions } from "bun:test"
2
+ import { Effect, type Layer } from "effect"
3
+ import { testEffect } from "./lib/effect"
4
+ import { cassetteName, classifiedTags, matchesSelected, missingEnv, unique } from "./recorded-utils"
5
+
6
+ export type RecordedBody<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
7
+
8
+ export type RecordedGroupOptions = {
9
+ readonly prefix: string
10
+ readonly provider?: string
11
+ readonly protocol?: string
12
+ readonly requires?: ReadonlyArray<string>
13
+ readonly tags?: ReadonlyArray<string>
14
+ readonly metadata?: Record<string, unknown>
15
+ }
16
+
17
+ export type RecordedCaseOptions = {
18
+ readonly cassette?: string
19
+ readonly id?: string
20
+ readonly provider?: string
21
+ readonly protocol?: string
22
+ readonly requires?: ReadonlyArray<string>
23
+ readonly tags?: ReadonlyArray<string>
24
+ readonly metadata?: Record<string, unknown>
25
+ }
26
+
27
+ export const recordedEffectGroup = <
28
+ R,
29
+ E,
30
+ Options extends RecordedGroupOptions,
31
+ CaseOptions extends RecordedCaseOptions,
32
+ >(input: {
33
+ readonly duplicateLabel: string
34
+ readonly options: Options
35
+ readonly cassetteExists: (cassette: string) => boolean
36
+ readonly layer: (input: {
37
+ readonly cassette: string
38
+ readonly tags: ReadonlyArray<string>
39
+ readonly metadata: Record<string, unknown>
40
+ readonly recording: boolean
41
+ readonly options: Options
42
+ readonly caseOptions: CaseOptions
43
+ }) => Layer.Layer<R, E>
44
+ }) => {
45
+ const cassettes = new Set<string>()
46
+
47
+ const run = <A, E2>(
48
+ name: string,
49
+ caseOptions: CaseOptions,
50
+ body: RecordedBody<A, E2, R>,
51
+ testOptions?: number | TestOptions,
52
+ ) => {
53
+ const cassette = cassetteName(input.options.prefix, name, caseOptions)
54
+ if (cassettes.has(cassette)) throw new Error(`Duplicate ${input.duplicateLabel} "${cassette}"`)
55
+ cassettes.add(cassette)
56
+ const tags = unique([
57
+ ...classifiedTags(input.options),
58
+ ...classifiedTags({
59
+ provider: caseOptions.provider,
60
+ protocol: caseOptions.protocol,
61
+ tags: caseOptions.tags,
62
+ }),
63
+ ])
64
+
65
+ if (!matchesSelected({ prefix: input.options.prefix, name, cassette, tags }))
66
+ return test.skip(name, () => {}, testOptions)
67
+
68
+ const recording = process.env.RECORD === "true"
69
+ if (recording) {
70
+ if (missingEnv([...(input.options.requires ?? []), ...(caseOptions.requires ?? [])]).length > 0) {
71
+ return test.skip(name, () => {}, testOptions)
72
+ }
73
+ } else if (!input.cassetteExists(cassette)) {
74
+ return test.skip(name, () => {}, testOptions)
75
+ }
76
+
77
+ return testEffect(
78
+ input.layer({
79
+ cassette,
80
+ tags,
81
+ metadata: { ...input.options.metadata, ...caseOptions.metadata, tags },
82
+ recording,
83
+ options: input.options,
84
+ caseOptions,
85
+ }),
86
+ ).live(name, body, testOptions)
87
+ }
88
+
89
+ const effect = <A, E2>(name: string, body: RecordedBody<A, E2, R>, testOptions?: number | TestOptions) =>
90
+ run(name, {} as CaseOptions, body, testOptions)
91
+
92
+ effect.with = <A, E2>(
93
+ name: string,
94
+ caseOptions: CaseOptions,
95
+ body: RecordedBody<A, E2, R>,
96
+ testOptions?: number | TestOptions,
97
+ ) => run(name, caseOptions, body, testOptions)
98
+
99
+ return { effect }
100
+ }