@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.
- package/AGENTS.md +321 -0
- package/README.md +131 -0
- package/example/call-sites.md +591 -0
- package/example/tutorial.ts +255 -0
- package/package.json +50 -0
- package/script/recording-cost-report.ts +250 -0
- package/script/setup-recording-env.ts +542 -0
- package/src/cache-policy.ts +111 -0
- package/src/index.ts +32 -0
- package/src/llm.ts +186 -0
- package/src/protocols/anthropic-messages.ts +841 -0
- package/src/protocols/bedrock-converse.ts +649 -0
- package/src/protocols/bedrock-event-stream.ts +87 -0
- package/src/protocols/gemini.ts +465 -0
- package/src/protocols/index.ts +6 -0
- package/src/protocols/openai-chat.ts +431 -0
- package/src/protocols/openai-compatible-chat.ts +24 -0
- package/src/protocols/openai-responses.ts +987 -0
- package/src/protocols/shared.ts +283 -0
- package/src/protocols/utils/bedrock-auth.ts +70 -0
- package/src/protocols/utils/bedrock-cache.ts +37 -0
- package/src/protocols/utils/bedrock-media.ts +80 -0
- package/src/protocols/utils/cache.ts +16 -0
- package/src/protocols/utils/gemini-tool-schema.ts +101 -0
- package/src/protocols/utils/lifecycle.ts +102 -0
- package/src/protocols/utils/openai-options.ts +84 -0
- package/src/protocols/utils/tool-stream.ts +218 -0
- package/src/provider.ts +37 -0
- package/src/providers/amazon-bedrock.ts +43 -0
- package/src/providers/anthropic.ts +35 -0
- package/src/providers/azure.ts +110 -0
- package/src/providers/cloudflare.ts +127 -0
- package/src/providers/github-copilot.ts +66 -0
- package/src/providers/google.ts +35 -0
- package/src/providers/index.ts +11 -0
- package/src/providers/openai-compatible-profile.ts +20 -0
- package/src/providers/openai-compatible.ts +65 -0
- package/src/providers/openai-options.ts +81 -0
- package/src/providers/openai.ts +63 -0
- package/src/providers/openrouter.ts +98 -0
- package/src/providers/xai.ts +56 -0
- package/src/route/auth-options.ts +57 -0
- package/src/route/auth.ts +156 -0
- package/src/route/client.ts +434 -0
- package/src/route/endpoint.ts +53 -0
- package/src/route/executor.ts +374 -0
- package/src/route/framing.ts +27 -0
- package/src/route/index.ts +25 -0
- package/src/route/protocol.ts +84 -0
- package/src/route/transport/http.ts +108 -0
- package/src/route/transport/index.ts +33 -0
- package/src/route/transport/websocket.ts +280 -0
- package/src/schema/errors.ts +203 -0
- package/src/schema/events.ts +370 -0
- package/src/schema/ids.ts +43 -0
- package/src/schema/index.ts +5 -0
- package/src/schema/messages.ts +404 -0
- package/src/schema/options.ts +221 -0
- package/src/tool-runtime.ts +78 -0
- package/src/tool.ts +241 -0
- package/src/utils/record.ts +3 -0
- package/sst-env.d.ts +10 -0
- package/test/adapter.test.ts +164 -0
- package/test/auth-options.types.ts +168 -0
- package/test/auth.test.ts +103 -0
- package/test/cache-policy.test.ts +262 -0
- package/test/continuation-scenarios.ts +104 -0
- package/test/endpoint.test.ts +58 -0
- package/test/executor.test.ts +418 -0
- package/test/exports.test.ts +62 -0
- package/test/fixtures/media/restroom.png +0 -0
- package/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json +29 -0
- package/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json +43 -0
- package/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json +56 -0
- package/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json +29 -0
- package/test/fixtures/recordings/anthropic-messages/streams-text.json +29 -0
- package/test/fixtures/recordings/anthropic-messages/streams-tool-call.json +29 -0
- package/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json +48 -0
- package/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json +55 -0
- package/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json +29 -0
- package/test/fixtures/recordings/bedrock-converse/streams-text.json +29 -0
- package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
- package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json +32 -0
- package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
- package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json +32 -0
- package/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json +32 -0
- package/test/fixtures/recordings/gemini/streams-text.json +28 -0
- package/test/fixtures/recordings/gemini/streams-tool-call.json +28 -0
- package/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json +46 -0
- package/test/fixtures/recordings/openai-chat/continues-after-tool-result.json +28 -0
- package/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json +46 -0
- package/test/fixtures/recordings/openai-chat/streams-text.json +28 -0
- package/test/fixtures/recordings/openai-chat/streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json +53 -0
- package/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json +54 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json +53 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json +54 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json +54 -0
- package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +42 -0
- package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +58 -0
- package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json +32 -0
- package/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json +46 -0
- package/test/generate-object.test.ts +184 -0
- package/test/lib/effect.ts +50 -0
- package/test/lib/http.ts +98 -0
- package/test/lib/openai-chunks.ts +27 -0
- package/test/lib/sse.ts +17 -0
- package/test/lib/tool-runtime.ts +146 -0
- package/test/llm.test.ts +167 -0
- package/test/provider/anthropic-messages-cache.recorded.test.ts +54 -0
- package/test/provider/anthropic-messages.recorded.test.ts +46 -0
- package/test/provider/anthropic-messages.test.ts +829 -0
- package/test/provider/bedrock-converse-cache.recorded.test.ts +54 -0
- package/test/provider/bedrock-converse.test.ts +707 -0
- package/test/provider/cloudflare.test.ts +230 -0
- package/test/provider/gemini-cache.recorded.test.ts +48 -0
- package/test/provider/gemini.test.ts +476 -0
- package/test/provider/golden.recorded.test.ts +219 -0
- package/test/provider/openai-chat.test.ts +446 -0
- package/test/provider/openai-compatible-chat.test.ts +238 -0
- package/test/provider/openai-responses-cache.recorded.test.ts +46 -0
- package/test/provider/openai-responses.test.ts +1322 -0
- package/test/provider/openrouter.test.ts +56 -0
- package/test/provider.types.ts +41 -0
- package/test/recorded-golden.ts +97 -0
- package/test/recorded-runner.ts +100 -0
- package/test/recorded-scenarios.ts +531 -0
- package/test/recorded-test.ts +74 -0
- package/test/recorded-utils.ts +56 -0
- package/test/recorded-websocket.ts +26 -0
- package/test/route.test.ts +43 -0
- package/test/schema.test.ts +97 -0
- package/test/tool-runtime.test.ts +802 -0
- package/test/tool-stream.test.ts +99 -0
- package/test/tool.types.ts +40 -0
- 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
|
+
}
|
package/test/llm.test.ts
ADDED
|
@@ -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
|
+
})
|