@effect-uai/anthropic 0.10.0 → 0.12.0
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/dist/Anthropic.d.mts +21 -3
- package/dist/Anthropic.d.mts.map +1 -1
- package/dist/Anthropic.mjs +40 -13
- package/dist/Anthropic.mjs.map +1 -1
- package/dist/AnthropicTools.d.mts +70 -0
- package/dist/AnthropicTools.d.mts.map +1 -0
- package/dist/AnthropicTools.mjs +94 -0
- package/dist/AnthropicTools.mjs.map +1 -0
- package/dist/anthropicTools.test.d.mts +1 -0
- package/dist/anthropicTools.test.mjs +156 -0
- package/dist/anthropicTools.test.mjs.map +1 -0
- package/dist/codec.d.mts +16 -2
- package/dist/codec.d.mts.map +1 -1
- package/dist/codec.mjs +24 -10
- package/dist/codec.mjs.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/magic-string.es-CaWgF-3p.mjs +1013 -0
- package/dist/magic-string.es-CaWgF-3p.mjs.map +1 -0
- package/dist/test.DNmyFkvJ-CPKukVWS.mjs +13633 -0
- package/dist/test.DNmyFkvJ-CPKukVWS.mjs.map +1 -0
- package/dist/usage.test.d.mts +1 -0
- package/dist/usage.test.mjs +46 -0
- package/dist/usage.test.mjs.map +1 -0
- package/package.json +4 -4
- package/src/Anthropic.ts +79 -14
- package/src/AnthropicTools.ts +128 -0
- package/src/anthropicTools.test.ts +147 -0
- package/src/codec.ts +46 -15
- package/src/index.ts +1 -0
- package/src/usage.test.ts +54 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { emptyAccumulator, mergeUsage } from "./codec.mjs";
|
|
2
|
+
import { applyEvent } from "./streamEvents.mjs";
|
|
3
|
+
import { n as describe, r as it, t as globalExpect } from "./test.DNmyFkvJ-CPKukVWS.mjs";
|
|
4
|
+
//#region src/usage.test.ts
|
|
5
|
+
const wire = {
|
|
6
|
+
input_tokens: 94,
|
|
7
|
+
output_tokens: 210,
|
|
8
|
+
cache_read_input_tokens: 31851,
|
|
9
|
+
cache_creation_input_tokens: 604
|
|
10
|
+
};
|
|
11
|
+
describe("mergeUsage", () => {
|
|
12
|
+
it("keeps all three input buckets, not just cache reads", () => {
|
|
13
|
+
const usage = mergeUsage(emptyAccumulator, wire).usage;
|
|
14
|
+
globalExpect(usage.input_tokens).toBe(94);
|
|
15
|
+
globalExpect(usage.input_tokens_details?.cached_tokens).toBe(31851);
|
|
16
|
+
globalExpect(usage.input_tokens_details?.cache_write_tokens).toBe(604);
|
|
17
|
+
});
|
|
18
|
+
it("totals every input bucket plus output", () => {
|
|
19
|
+
const usage = mergeUsage(emptyAccumulator, wire).usage;
|
|
20
|
+
globalExpect(usage.total_tokens).toBe(32759);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe("streamed usage", () => {
|
|
24
|
+
it("computes a non-stale total across message_start and message_delta", () => {
|
|
25
|
+
const usage = applyEvent(applyEvent(emptyAccumulator, {
|
|
26
|
+
type: "message_start",
|
|
27
|
+
message: { usage: {
|
|
28
|
+
input_tokens: 94,
|
|
29
|
+
output_tokens: 1,
|
|
30
|
+
cache_read_input_tokens: 31851,
|
|
31
|
+
cache_creation_input_tokens: 604
|
|
32
|
+
} }
|
|
33
|
+
}), {
|
|
34
|
+
type: "message_delta",
|
|
35
|
+
delta: { stop_reason: "end_turn" },
|
|
36
|
+
usage: { output_tokens: 210 }
|
|
37
|
+
}).usage;
|
|
38
|
+
globalExpect(usage.output_tokens).toBe(210);
|
|
39
|
+
globalExpect(usage.input_tokens_details?.cache_write_tokens).toBe(604);
|
|
40
|
+
globalExpect(usage.total_tokens).toBe(32759);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
//#endregion
|
|
44
|
+
export {};
|
|
45
|
+
|
|
46
|
+
//# sourceMappingURL=usage.test.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"usage.test.mjs","names":[],"sources":["../src/usage.test.ts"],"sourcesContent":["import { describe, expect, it } from \"vitest\"\nimport { type WireUsage, emptyAccumulator, mergeUsage } from \"./codec.js\"\nimport { type ProviderEvent, applyEvent } from \"./streamEvents.js\"\n\n// A completed cached turn: input split across post-breakpoint tokens, cache\n// reads, and cache writes, all billed separately.\nconst wire: WireUsage = {\n input_tokens: 94,\n output_tokens: 210,\n cache_read_input_tokens: 31851,\n cache_creation_input_tokens: 604,\n}\n\ndescribe(\"mergeUsage\", () => {\n it(\"keeps all three input buckets, not just cache reads\", () => {\n const usage = mergeUsage(emptyAccumulator, wire).usage\n expect(usage.input_tokens).toBe(94)\n expect(usage.input_tokens_details?.cached_tokens).toBe(31851)\n expect(usage.input_tokens_details?.cache_write_tokens).toBe(604)\n })\n\n it(\"totals every input bucket plus output\", () => {\n const usage = mergeUsage(emptyAccumulator, wire).usage\n expect(usage.total_tokens).toBe(94 + 31851 + 604 + 210)\n })\n})\n\ndescribe(\"streamed usage\", () => {\n // Anthropic sends input + cache on message_start and the final output_tokens\n // on message_delta. total_tokens must reflect the accumulated buckets, not\n // the (input + initial output) figure frozen at message_start.\n it(\"computes a non-stale total across message_start and message_delta\", () => {\n const start: ProviderEvent = {\n type: \"message_start\",\n message: {\n usage: {\n input_tokens: 94,\n output_tokens: 1,\n cache_read_input_tokens: 31851,\n cache_creation_input_tokens: 604,\n },\n },\n }\n const delta: ProviderEvent = {\n type: \"message_delta\",\n delta: { stop_reason: \"end_turn\" },\n usage: { output_tokens: 210 },\n }\n const usage = applyEvent(applyEvent(emptyAccumulator, start), delta).usage\n expect(usage.output_tokens).toBe(210)\n expect(usage.input_tokens_details?.cache_write_tokens).toBe(604)\n expect(usage.total_tokens).toBe(94 + 31851 + 604 + 210)\n })\n})\n"],"mappings":";;;;AAMA,MAAM,OAAkB;CACtB,cAAc;CACd,eAAe;CACf,yBAAyB;CACzB,6BAA6B;CAC9B;AAED,SAAS,oBAAoB;AAC3B,IAAG,6DAA6D;EAC9D,MAAM,QAAQ,WAAW,kBAAkB,KAAK,CAAC;AACjD,eAAO,MAAM,aAAa,CAAC,KAAK,GAAG;AACnC,eAAO,MAAM,sBAAsB,cAAc,CAAC,KAAK,MAAM;AAC7D,eAAO,MAAM,sBAAsB,mBAAmB,CAAC,KAAK,IAAI;GAChE;AAEF,IAAG,+CAA+C;EAChD,MAAM,QAAQ,WAAW,kBAAkB,KAAK,CAAC;AACjD,eAAO,MAAM,aAAa,CAAC,KAAK,MAAuB;GACvD;EACF;AAEF,SAAS,wBAAwB;AAI/B,IAAG,2EAA2E;EAiB5E,MAAM,QAAQ,WAAW,WAAW,kBAAkB;GAfpD,MAAM;GACN,SAAS,EACP,OAAO;IACL,cAAc;IACd,eAAe;IACf,yBAAyB;IACzB,6BAA6B;IAC9B,EACF;GAOwD,CAAC,EAAE;GAJ5D,MAAM;GACN,OAAO,EAAE,aAAa,YAAY;GAClC,OAAO,EAAE,eAAe,KAAK;GAEoC,CAAC,CAAC;AACrE,eAAO,MAAM,cAAc,CAAC,KAAK,IAAI;AACrC,eAAO,MAAM,sBAAsB,mBAAmB,CAAC,KAAK,IAAI;AAChE,eAAO,MAAM,aAAa,CAAC,KAAK,MAAuB;GACvD;EACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-uai/anthropic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Anthropic Messages API provider for @effect-uai/core.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"effect": "4.0.0-beta.
|
|
46
|
+
"effect": "4.0.0-beta.94",
|
|
47
47
|
"typescript": "^6.0.3",
|
|
48
|
-
"@effect-uai/core": "0.
|
|
48
|
+
"@effect-uai/core": "0.12.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"@effect-uai/core": ">=0.2.0 <1",
|
|
52
|
-
"effect": "4.0.0-beta.
|
|
52
|
+
"effect": ">=4.0.0-beta.94 <5.0.0"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"build": "tsdown",
|
package/src/Anthropic.ts
CHANGED
|
@@ -19,10 +19,13 @@ import {
|
|
|
19
19
|
turnFromStream,
|
|
20
20
|
} from "@effect-uai/core/LanguageModel"
|
|
21
21
|
import * as SSE from "@effect-uai/core/SSE"
|
|
22
|
-
import { descriptorsOf } from "@effect-uai/core/Tool"
|
|
22
|
+
import { descriptorsOf, providerToolsOf } from "@effect-uai/core/Tool"
|
|
23
23
|
import { type Turn, TurnEvent } from "@effect-uai/core/Turn"
|
|
24
|
+
import { renderProviderTools } from "./AnthropicTools.js"
|
|
24
25
|
import {
|
|
25
26
|
type Accumulator,
|
|
27
|
+
type CacheControl,
|
|
28
|
+
type CacheTtl,
|
|
26
29
|
type ThinkingConfig,
|
|
27
30
|
accumulatorToTurn,
|
|
28
31
|
buildRequestBody,
|
|
@@ -31,6 +34,13 @@ import {
|
|
|
31
34
|
import type { AnthropicModel } from "./models.js"
|
|
32
35
|
import { KnownProviderEvent, ProviderEvent, applyEvent } from "./streamEvents.js"
|
|
33
36
|
|
|
37
|
+
export {
|
|
38
|
+
codeExecutionTool,
|
|
39
|
+
webSearchTool,
|
|
40
|
+
type WebSearchOptions,
|
|
41
|
+
type UserLocation,
|
|
42
|
+
} from "./AnthropicTools.js"
|
|
43
|
+
|
|
34
44
|
// ---------------------------------------------------------------------------
|
|
35
45
|
// Public types
|
|
36
46
|
// ---------------------------------------------------------------------------
|
|
@@ -111,6 +121,21 @@ export type Config = {
|
|
|
111
121
|
* 4096 if neither is set.
|
|
112
122
|
*/
|
|
113
123
|
readonly defaultMaxTokens?: number
|
|
124
|
+
/**
|
|
125
|
+
* Prompt caching. Off by default, so the library stays unopinionated about
|
|
126
|
+
* a setting that changes billing. `true` is `{}`: cache with the 5-minute
|
|
127
|
+
* default TTL.
|
|
128
|
+
*
|
|
129
|
+
* Anthropic caches the `tools` → `system` → `messages` prefix and moves the
|
|
130
|
+
* breakpoint forward as the conversation grows, so a long system prompt and
|
|
131
|
+
* tool definitions are paid for once rather than on every turn. Read
|
|
132
|
+
* `usage.input_tokens_details.cached_tokens` to see it working.
|
|
133
|
+
*
|
|
134
|
+
* Mutating earlier history (rewriting an old message, compaction) breaks
|
|
135
|
+
* the prefix from that point on. Anthropic falls back to the longest
|
|
136
|
+
* unchanged prefix, so this costs savings but never correctness.
|
|
137
|
+
*/
|
|
138
|
+
readonly promptCaching?: boolean | { readonly ttl?: CacheTtl }
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
const ANTHROPIC_VERSION = "2023-06-01"
|
|
@@ -131,19 +156,44 @@ const outputConfig = (request: AnthropicRequest): Option.Option<Record<string, u
|
|
|
131
156
|
const resolvedMaxTokens = (cfg: Config, request: AnthropicRequest): number =>
|
|
132
157
|
request.maxOutputTokens ?? cfg.defaultMaxTokens ?? FALLBACK_MAX_TOKENS
|
|
133
158
|
|
|
134
|
-
|
|
159
|
+
/**
|
|
160
|
+
* One request-level breakpoint when caching is on. Anthropic places it on the
|
|
161
|
+
* last cacheable block itself, so nothing has to be marked per-block and the
|
|
162
|
+
* breakpoint stays correct as history grows.
|
|
163
|
+
*/
|
|
164
|
+
const cacheControlOf = (cfg: Config): Option.Option<CacheControl> =>
|
|
165
|
+
pipe(
|
|
166
|
+
Option.fromUndefinedOr(cfg.promptCaching),
|
|
167
|
+
Option.flatMap((opt) =>
|
|
168
|
+
opt === false
|
|
169
|
+
? Option.none<CacheControl>()
|
|
170
|
+
: Option.some<CacheControl>({
|
|
171
|
+
type: "ephemeral",
|
|
172
|
+
...(opt !== true && opt.ttl !== undefined && { ttl: opt.ttl }),
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
const functionToolDescriptors = (
|
|
135
178
|
request: AnthropicRequest,
|
|
179
|
+
): ReadonlyArray<Record<string, unknown>> =>
|
|
180
|
+
descriptorsOf(request.tools).map((t) => ({
|
|
181
|
+
name: t.name,
|
|
182
|
+
description: t.description,
|
|
183
|
+
input_schema: t.inputSchema,
|
|
184
|
+
}))
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Merge the custom function-tool declarations with the native hosted-tool
|
|
188
|
+
* entries into the single `tools` array Anthropic expects. `providerWire` is
|
|
189
|
+
* pre-rendered (and validated) by the caller.
|
|
190
|
+
*/
|
|
191
|
+
const toolsWire = (
|
|
192
|
+
request: AnthropicRequest,
|
|
193
|
+
providerWire: ReadonlyArray<Record<string, unknown>>,
|
|
136
194
|
): Option.Option<ReadonlyArray<Record<string, unknown>>> => {
|
|
137
|
-
const
|
|
138
|
-
return
|
|
139
|
-
? Option.some(
|
|
140
|
-
descriptors.map((t) => ({
|
|
141
|
-
name: t.name,
|
|
142
|
-
description: t.description,
|
|
143
|
-
input_schema: t.inputSchema,
|
|
144
|
-
})),
|
|
145
|
-
)
|
|
146
|
-
: Option.none()
|
|
195
|
+
const tools = [...functionToolDescriptors(request), ...providerWire]
|
|
196
|
+
return tools.length > 0 ? Option.some(tools) : Option.none()
|
|
147
197
|
}
|
|
148
198
|
|
|
149
199
|
const toolChoiceWire = (request: AnthropicRequest): Option.Option<Record<string, unknown>> =>
|
|
@@ -260,6 +310,20 @@ const buildNativeStream = (cfg: Config) => {
|
|
|
260
310
|
Stream.unwrap(
|
|
261
311
|
Effect.gen(function* () {
|
|
262
312
|
const structured = outputConfig(request)
|
|
313
|
+
const providerWire = yield* Result.match(
|
|
314
|
+
renderProviderTools(providerToolsOf(request.tools)),
|
|
315
|
+
{
|
|
316
|
+
onFailure: (e) =>
|
|
317
|
+
Effect.fail(
|
|
318
|
+
new AiError.Unsupported({
|
|
319
|
+
provider: "anthropic",
|
|
320
|
+
capability: e.capability,
|
|
321
|
+
reason: e.reason,
|
|
322
|
+
}),
|
|
323
|
+
),
|
|
324
|
+
onSuccess: (wire) => Effect.succeed(wire),
|
|
325
|
+
},
|
|
326
|
+
)
|
|
263
327
|
const bodyResult = buildRequestBody({
|
|
264
328
|
model: request.model,
|
|
265
329
|
history: request.history,
|
|
@@ -269,10 +333,11 @@ const buildNativeStream = (cfg: Config) => {
|
|
|
269
333
|
topK: Option.fromUndefinedOr(request.topK),
|
|
270
334
|
stopSequences: Option.fromUndefinedOr(request.stopSequences),
|
|
271
335
|
thinking: Option.fromUndefinedOr(request.thinking),
|
|
272
|
-
tools:
|
|
336
|
+
tools: toolsWire(request, providerWire),
|
|
273
337
|
toolChoice: toolChoiceWire(request),
|
|
274
338
|
userId: Option.fromUndefinedOr(request.user),
|
|
275
339
|
outputConfig: structured,
|
|
340
|
+
cacheControl: cacheControlOf(cfg),
|
|
276
341
|
})
|
|
277
342
|
|
|
278
343
|
const body = yield* Result.match(bodyResult, {
|
|
@@ -354,7 +419,7 @@ export const toCanonical = <E, R>(
|
|
|
354
419
|
* Build an `AnthropicService` value. For Layer-based setup, prefer `layer`.
|
|
355
420
|
*/
|
|
356
421
|
export const make = (cfg: Config): Effect.Effect<AnthropicService, never, HttpClient.HttpClient> =>
|
|
357
|
-
Effect.map(HttpClient.HttpClient
|
|
422
|
+
Effect.map(HttpClient.HttpClient, (client) => {
|
|
358
423
|
const streamNative: AnthropicService["streamNative"] = (request) =>
|
|
359
424
|
buildNativeStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client))
|
|
360
425
|
const streamTurn: AnthropicService["streamTurn"] = (request) =>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Match, Result, Schema } from "effect"
|
|
2
|
+
import * as Tool from "@effect-uai/core/Tool"
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Anthropic provider-hosted tools. Each is a `Tool.provider` whose `config`
|
|
6
|
+
// carries the version-dated wire `type` string plus its options; the renderer
|
|
7
|
+
// maps it to a native entry appended to the `tools` array alongside custom
|
|
8
|
+
// tools. The `inputSchema` is never sent (provider tools are partitioned out
|
|
9
|
+
// of the custom-tool declarations), so `Tool.noInput` suffices.
|
|
10
|
+
// Reference: https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const WEB_SEARCH_TYPE = "web_search_20250305"
|
|
14
|
+
const CODE_EXECUTION_TYPE = "code_execution_20250522"
|
|
15
|
+
|
|
16
|
+
const UserLocation = Schema.Struct({
|
|
17
|
+
city: Schema.optional(Schema.String),
|
|
18
|
+
region: Schema.optional(Schema.String),
|
|
19
|
+
country: Schema.optional(Schema.String),
|
|
20
|
+
timezone: Schema.optional(Schema.String),
|
|
21
|
+
})
|
|
22
|
+
export type UserLocation = typeof UserLocation.Type
|
|
23
|
+
|
|
24
|
+
const WebSearchConfig = Schema.Struct({
|
|
25
|
+
kind: Schema.Literal("web_search"),
|
|
26
|
+
type: Schema.String,
|
|
27
|
+
maxUses: Schema.optional(Schema.Number),
|
|
28
|
+
allowedDomains: Schema.optional(Schema.Array(Schema.String)),
|
|
29
|
+
blockedDomains: Schema.optional(Schema.Array(Schema.String)),
|
|
30
|
+
userLocation: Schema.optional(UserLocation),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const CodeExecutionConfig = Schema.Struct({
|
|
34
|
+
kind: Schema.Literal("code_execution"),
|
|
35
|
+
type: Schema.String,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** Discriminated config for an Anthropic hosted tool. */
|
|
39
|
+
export const AnthropicToolConfig = Schema.Union([WebSearchConfig, CodeExecutionConfig])
|
|
40
|
+
export type AnthropicToolConfig = typeof AnthropicToolConfig.Type
|
|
41
|
+
|
|
42
|
+
export type WebSearchOptions = {
|
|
43
|
+
readonly maxUses?: number
|
|
44
|
+
readonly allowedDomains?: ReadonlyArray<string>
|
|
45
|
+
readonly blockedDomains?: ReadonlyArray<string>
|
|
46
|
+
readonly userLocation?: UserLocation
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Anthropic-hosted web search. */
|
|
50
|
+
export const webSearchTool = (opts: WebSearchOptions = {}) =>
|
|
51
|
+
Tool.provider({
|
|
52
|
+
name: "web_search",
|
|
53
|
+
description: "Search the web, executed by Anthropic.",
|
|
54
|
+
inputSchema: Tool.noInput,
|
|
55
|
+
provider: "anthropic",
|
|
56
|
+
config: { kind: "web_search", type: WEB_SEARCH_TYPE, ...opts } as const,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** Anthropic-hosted code execution (Python sandbox). */
|
|
60
|
+
export const codeExecutionTool = Tool.provider({
|
|
61
|
+
name: "code_execution",
|
|
62
|
+
description: "Run Python in a sandbox, executed by Anthropic.",
|
|
63
|
+
inputSchema: Tool.noInput,
|
|
64
|
+
provider: "anthropic",
|
|
65
|
+
config: { kind: "code_execution", type: CODE_EXECUTION_TYPE } as const,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A provider tool this adapter cannot render — a foreign `provider` or an
|
|
70
|
+
* unrecognized `config`. Carried as data so `Anthropic.ts` can lift it into a
|
|
71
|
+
* typed `AiError.Unsupported`.
|
|
72
|
+
*/
|
|
73
|
+
export type ToolRenderError = {
|
|
74
|
+
readonly capability: string
|
|
75
|
+
readonly reason: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const userLocationWire = (loc: UserLocation): Record<string, unknown> => ({
|
|
79
|
+
type: "approximate",
|
|
80
|
+
...(loc.city !== undefined && { city: loc.city }),
|
|
81
|
+
...(loc.region !== undefined && { region: loc.region }),
|
|
82
|
+
...(loc.country !== undefined && { country: loc.country }),
|
|
83
|
+
...(loc.timezone !== undefined && { timezone: loc.timezone }),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const configToWire = Match.type<AnthropicToolConfig>().pipe(
|
|
87
|
+
Match.discriminatorsExhaustive("kind")({
|
|
88
|
+
web_search: (c): Record<string, unknown> => ({
|
|
89
|
+
type: c.type,
|
|
90
|
+
name: "web_search",
|
|
91
|
+
...(c.maxUses !== undefined && { max_uses: c.maxUses }),
|
|
92
|
+
...(c.allowedDomains !== undefined && { allowed_domains: c.allowedDomains }),
|
|
93
|
+
...(c.blockedDomains !== undefined && { blocked_domains: c.blockedDomains }),
|
|
94
|
+
...(c.userLocation !== undefined && { user_location: userLocationWire(c.userLocation) }),
|
|
95
|
+
}),
|
|
96
|
+
code_execution: (c): Record<string, unknown> => ({ type: c.type, name: "code_execution" }),
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const decodeConfig = Schema.decodeUnknownResult(AnthropicToolConfig)
|
|
101
|
+
|
|
102
|
+
const renderOne = (
|
|
103
|
+
tool: Tool.ProviderTool<string, any, string, any>,
|
|
104
|
+
): Result.Result<Record<string, unknown>, ToolRenderError> =>
|
|
105
|
+
tool.provider !== "anthropic"
|
|
106
|
+
? Result.fail({
|
|
107
|
+
capability: `providerTool:${tool.provider}`,
|
|
108
|
+
reason: `tool "${tool.name}" is hosted by "${tool.provider}", not anthropic`,
|
|
109
|
+
})
|
|
110
|
+
: decodeConfig(tool.config).pipe(
|
|
111
|
+
Result.map(configToWire),
|
|
112
|
+
Result.mapError(
|
|
113
|
+
(): ToolRenderError => ({
|
|
114
|
+
capability: "providerTool",
|
|
115
|
+
reason: `unrecognized Anthropic provider tool config on "${tool.name}"`,
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render a toolkit's Anthropic provider tools to native `tools` entries. Fails
|
|
122
|
+
* with a `ToolRenderError` on the first foreign or unrecognized tool rather
|
|
123
|
+
* than dropping it.
|
|
124
|
+
*/
|
|
125
|
+
export const renderProviderTools = (
|
|
126
|
+
tools: ReadonlyArray<Tool.ProviderTool<string, any, string, any>>,
|
|
127
|
+
): Result.Result<ReadonlyArray<Record<string, unknown>>, ToolRenderError> =>
|
|
128
|
+
Result.all(tools.map(renderOne))
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Effect, Option, Result, Schema } from "effect"
|
|
2
|
+
import * as Tool from "@effect-uai/core/Tool"
|
|
3
|
+
import { descriptorsOf, providerToolsOf } from "@effect-uai/core/Tool"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import { codeExecutionTool, renderProviderTools, webSearchTool } from "./AnthropicTools.js"
|
|
6
|
+
import { accumulatorToTurn, buildRequestBody, emptyAccumulator } from "./codec.js"
|
|
7
|
+
import { KnownProviderEvent, applyEvent } from "./streamEvents.js"
|
|
8
|
+
|
|
9
|
+
const localTool = Tool.make({
|
|
10
|
+
name: "get_weather",
|
|
11
|
+
description: "Look up the weather.",
|
|
12
|
+
inputSchema: Tool.fromEffectSchema(Schema.Struct({ city: Schema.String })),
|
|
13
|
+
run: ({ city }) => Effect.succeed(`sunny in ${city}`),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const wireFor = (toolkit: Record<string, Tool.AnyTool>) =>
|
|
17
|
+
Result.getOrThrow(renderProviderTools(providerToolsOf(toolkit)))
|
|
18
|
+
|
|
19
|
+
const functionWire = (toolkit: Record<string, Tool.AnyTool>) =>
|
|
20
|
+
descriptorsOf(toolkit).map((t) => ({
|
|
21
|
+
name: t.name,
|
|
22
|
+
description: t.description,
|
|
23
|
+
input_schema: t.inputSchema,
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
const buildWith = (
|
|
27
|
+
toolkit: Record<string, Tool.AnyTool>,
|
|
28
|
+
toolChoice: Option.Option<Record<string, unknown>> = Option.none(),
|
|
29
|
+
) => {
|
|
30
|
+
const tools = [...functionWire(toolkit), ...wireFor(toolkit)]
|
|
31
|
+
return Result.getOrThrow(
|
|
32
|
+
buildRequestBody({
|
|
33
|
+
model: "claude-sonnet-4-5",
|
|
34
|
+
history: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
|
35
|
+
maxTokens: 1024,
|
|
36
|
+
temperature: Option.none(),
|
|
37
|
+
topP: Option.none(),
|
|
38
|
+
topK: Option.none(),
|
|
39
|
+
stopSequences: Option.none(),
|
|
40
|
+
thinking: Option.none(),
|
|
41
|
+
tools: tools.length > 0 ? Option.some(tools) : Option.none(),
|
|
42
|
+
toolChoice,
|
|
43
|
+
userId: Option.none(),
|
|
44
|
+
outputConfig: Option.none(),
|
|
45
|
+
cacheControl: Option.none(),
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("Anthropic provider tools", () => {
|
|
51
|
+
it("renders web_search to the native wire entry with options", () => {
|
|
52
|
+
const wire = wireFor({
|
|
53
|
+
web_search: webSearchTool({ maxUses: 3, allowedDomains: ["example.com"] }),
|
|
54
|
+
})
|
|
55
|
+
expect(wire).toEqual([
|
|
56
|
+
{
|
|
57
|
+
type: "web_search_20250305",
|
|
58
|
+
name: "web_search",
|
|
59
|
+
max_uses: 3,
|
|
60
|
+
allowed_domains: ["example.com"],
|
|
61
|
+
},
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("renders code_execution to its native wire entry", () => {
|
|
66
|
+
expect(wireFor({ code_execution: codeExecutionTool })).toEqual([
|
|
67
|
+
{ type: "code_execution_20250522", name: "code_execution" },
|
|
68
|
+
])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("emits both a custom tool and the native entry in the same tools array", () => {
|
|
72
|
+
const body = buildWith({ get_weather: localTool, web_search: webSearchTool() })
|
|
73
|
+
expect(body.tools).toEqual([
|
|
74
|
+
expect.objectContaining({ name: "get_weather" }),
|
|
75
|
+
{ type: "web_search_20250305", name: "web_search" },
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("omits tool_choice for a native-only request", () => {
|
|
80
|
+
const body = buildWith({ web_search: webSearchTool() })
|
|
81
|
+
expect(body.tools).toHaveLength(1)
|
|
82
|
+
expect(body.tool_choice).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("fails on a foreign provider tool", () => {
|
|
86
|
+
const foreign = Tool.provider({
|
|
87
|
+
name: "google_search",
|
|
88
|
+
description: "Gemini grounding.",
|
|
89
|
+
inputSchema: Tool.noInput,
|
|
90
|
+
provider: "gemini",
|
|
91
|
+
config: { kind: "google_search" },
|
|
92
|
+
})
|
|
93
|
+
expect(Result.isFailure(renderProviderTools(providerToolsOf({ google_search: foreign })))).toBe(
|
|
94
|
+
true,
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("fails on an unrecognized config", () => {
|
|
99
|
+
const bogus = Tool.provider({
|
|
100
|
+
name: "mystery",
|
|
101
|
+
description: "Unknown Anthropic tool.",
|
|
102
|
+
inputSchema: Tool.noInput,
|
|
103
|
+
provider: "anthropic",
|
|
104
|
+
config: { kind: "telepathy" },
|
|
105
|
+
})
|
|
106
|
+
expect(Result.isFailure(renderProviderTools(providerToolsOf({ mystery: bogus })))).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe("Anthropic hosted-tool response tolerance", () => {
|
|
111
|
+
it("does not decode a server_tool_use content block as a known event", () => {
|
|
112
|
+
const serverToolUse = {
|
|
113
|
+
type: "content_block_start",
|
|
114
|
+
index: 0,
|
|
115
|
+
content_block: { type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: {} },
|
|
116
|
+
}
|
|
117
|
+
const decoded = Schema.decodeUnknownResult(KnownProviderEvent)(serverToolUse)
|
|
118
|
+
// Fails to decode → the SSE layer synthesizes `_unknown`, which is ignored.
|
|
119
|
+
expect(Result.isFailure(decoded)).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("builds the text turn when hosted-tool blocks arrive as _unknown", () => {
|
|
123
|
+
// Sequence: server_tool_use + web_search_tool_result blocks flow through as
|
|
124
|
+
// `_unknown` (no accumulator change); the assistant's text answer is a
|
|
125
|
+
// normal text block and survives into the turn.
|
|
126
|
+
const events = [
|
|
127
|
+
{ type: "_unknown", raw: { type: "content_block_start", content_block: "server_tool_use" } },
|
|
128
|
+
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
|
|
129
|
+
{
|
|
130
|
+
type: "content_block_delta",
|
|
131
|
+
index: 1,
|
|
132
|
+
delta: { type: "text_delta", text: "Grounded answer." },
|
|
133
|
+
},
|
|
134
|
+
{ type: "content_block_stop", index: 1 },
|
|
135
|
+
{ type: "message_stop" },
|
|
136
|
+
] as const
|
|
137
|
+
const acc = events.reduce(applyEvent, emptyAccumulator)
|
|
138
|
+
const turn = accumulatorToTurn(acc)
|
|
139
|
+
expect(turn.items).toEqual([
|
|
140
|
+
{
|
|
141
|
+
type: "message",
|
|
142
|
+
role: "assistant",
|
|
143
|
+
content: [{ type: "output_text", text: "Grounded answer." }],
|
|
144
|
+
},
|
|
145
|
+
])
|
|
146
|
+
})
|
|
147
|
+
})
|
package/src/codec.ts
CHANGED
|
@@ -324,6 +324,20 @@ export type ThinkingConfig = {
|
|
|
324
324
|
readonly budget_tokens: number
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
/** Cache lifetime. `1h` writes cost 2x base input; `5m` costs 1.25x. */
|
|
328
|
+
export type CacheTtl = "5m" | "1h"
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Request-level cache breakpoint. Anthropic applies it to the last cacheable
|
|
332
|
+
* block and moves it forward as the conversation grows, so the cached prefix
|
|
333
|
+
* (`tools` → `system` → `messages`) follows the conversation without the
|
|
334
|
+
* caller re-placing anything.
|
|
335
|
+
*/
|
|
336
|
+
export type CacheControl = {
|
|
337
|
+
readonly type: "ephemeral"
|
|
338
|
+
readonly ttl?: CacheTtl
|
|
339
|
+
}
|
|
340
|
+
|
|
327
341
|
export type RequestBody = {
|
|
328
342
|
readonly model: string
|
|
329
343
|
readonly messages: ReadonlyArray<RequestMessage>
|
|
@@ -338,6 +352,7 @@ export type RequestBody = {
|
|
|
338
352
|
readonly tool_choice?: Record<string, unknown>
|
|
339
353
|
readonly metadata?: { readonly user_id: string }
|
|
340
354
|
readonly output_config?: Record<string, unknown>
|
|
355
|
+
readonly cache_control?: CacheControl
|
|
341
356
|
readonly stream: true
|
|
342
357
|
}
|
|
343
358
|
|
|
@@ -354,6 +369,7 @@ export const buildRequestBody = (params: {
|
|
|
354
369
|
readonly toolChoice: Option.Option<Record<string, unknown>>
|
|
355
370
|
readonly userId: Option.Option<string>
|
|
356
371
|
readonly outputConfig: Option.Option<Record<string, unknown>>
|
|
372
|
+
readonly cacheControl: Option.Option<CacheControl>
|
|
357
373
|
}): Result.Result<RequestBody, JsonParseError> =>
|
|
358
374
|
pipe(
|
|
359
375
|
groupedMessages(params.history),
|
|
@@ -402,6 +418,10 @@ export const buildRequestBody = (params: {
|
|
|
402
418
|
onNone: () => ({}),
|
|
403
419
|
onSome: (output_config) => ({ output_config }),
|
|
404
420
|
}),
|
|
421
|
+
...Option.match(params.cacheControl, {
|
|
422
|
+
onNone: () => ({}),
|
|
423
|
+
onSome: (cache_control) => ({ cache_control }),
|
|
424
|
+
}),
|
|
405
425
|
stream: true,
|
|
406
426
|
}),
|
|
407
427
|
),
|
|
@@ -503,23 +523,34 @@ export const setStopReason = (acc: Accumulator, reason: string): Accumulator =>
|
|
|
503
523
|
stopReason: Option.some(reason),
|
|
504
524
|
})
|
|
505
525
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
526
|
+
// Anthropic splits input across three billed buckets: `input_tokens`
|
|
527
|
+
// (post-breakpoint), cache reads, and cache writes. All three are kept;
|
|
528
|
+
// `input_tokens` is not the input total. Each field carries over from the
|
|
529
|
+
// prior value since a streamed turn spreads usage across events (input +
|
|
530
|
+
// cache on message_start, output_tokens on message_delta), so total_tokens is
|
|
531
|
+
// summed from the accumulated buckets, not one event.
|
|
509
532
|
export const mergeUsage = (acc: Accumulator, wire: WireUsage): Accumulator => {
|
|
510
|
-
const
|
|
533
|
+
const prev = acc.usage
|
|
534
|
+
const input_tokens = wire.input_tokens ?? prev.input_tokens
|
|
535
|
+
const output_tokens = wire.output_tokens ?? prev.output_tokens
|
|
536
|
+
const cached_tokens = wire.cache_read_input_tokens ?? prev.input_tokens_details?.cached_tokens
|
|
537
|
+
const cache_write_tokens =
|
|
538
|
+
wire.cache_creation_input_tokens ?? prev.input_tokens_details?.cache_write_tokens
|
|
539
|
+
|
|
540
|
+
const buckets = [input_tokens, cached_tokens, cache_write_tokens, output_tokens]
|
|
541
|
+
const total_tokens = buckets.every((v) => v === undefined)
|
|
542
|
+
? undefined
|
|
543
|
+
: buckets.reduce<number>((sum, v) => sum + (v ?? 0), 0)
|
|
544
|
+
|
|
545
|
+
const details: Items.InputTokensDetails = {
|
|
546
|
+
...(cached_tokens !== undefined && { cached_tokens }),
|
|
547
|
+
...(cache_write_tokens !== undefined && { cache_write_tokens }),
|
|
548
|
+
}
|
|
511
549
|
const usage: Items.Usage = {
|
|
512
|
-
...
|
|
513
|
-
...(
|
|
514
|
-
...(
|
|
515
|
-
...(
|
|
516
|
-
wire.output_tokens !== undefined && {
|
|
517
|
-
total_tokens: wire.input_tokens + wire.output_tokens,
|
|
518
|
-
}),
|
|
519
|
-
...Option.match(cached, {
|
|
520
|
-
onNone: () => ({}),
|
|
521
|
-
onSome: (cached_tokens) => ({ input_tokens_details: { cached_tokens } }),
|
|
522
|
-
}),
|
|
550
|
+
...(input_tokens !== undefined && { input_tokens }),
|
|
551
|
+
...(output_tokens !== undefined && { output_tokens }),
|
|
552
|
+
...(total_tokens !== undefined && { total_tokens }),
|
|
553
|
+
...(Object.keys(details).length > 0 && { input_tokens_details: details }),
|
|
523
554
|
}
|
|
524
555
|
return { ...acc, usage }
|
|
525
556
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { type WireUsage, emptyAccumulator, mergeUsage } from "./codec.js"
|
|
3
|
+
import { type ProviderEvent, applyEvent } from "./streamEvents.js"
|
|
4
|
+
|
|
5
|
+
// A completed cached turn: input split across post-breakpoint tokens, cache
|
|
6
|
+
// reads, and cache writes, all billed separately.
|
|
7
|
+
const wire: WireUsage = {
|
|
8
|
+
input_tokens: 94,
|
|
9
|
+
output_tokens: 210,
|
|
10
|
+
cache_read_input_tokens: 31851,
|
|
11
|
+
cache_creation_input_tokens: 604,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("mergeUsage", () => {
|
|
15
|
+
it("keeps all three input buckets, not just cache reads", () => {
|
|
16
|
+
const usage = mergeUsage(emptyAccumulator, wire).usage
|
|
17
|
+
expect(usage.input_tokens).toBe(94)
|
|
18
|
+
expect(usage.input_tokens_details?.cached_tokens).toBe(31851)
|
|
19
|
+
expect(usage.input_tokens_details?.cache_write_tokens).toBe(604)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("totals every input bucket plus output", () => {
|
|
23
|
+
const usage = mergeUsage(emptyAccumulator, wire).usage
|
|
24
|
+
expect(usage.total_tokens).toBe(94 + 31851 + 604 + 210)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("streamed usage", () => {
|
|
29
|
+
// Anthropic sends input + cache on message_start and the final output_tokens
|
|
30
|
+
// on message_delta. total_tokens must reflect the accumulated buckets, not
|
|
31
|
+
// the (input + initial output) figure frozen at message_start.
|
|
32
|
+
it("computes a non-stale total across message_start and message_delta", () => {
|
|
33
|
+
const start: ProviderEvent = {
|
|
34
|
+
type: "message_start",
|
|
35
|
+
message: {
|
|
36
|
+
usage: {
|
|
37
|
+
input_tokens: 94,
|
|
38
|
+
output_tokens: 1,
|
|
39
|
+
cache_read_input_tokens: 31851,
|
|
40
|
+
cache_creation_input_tokens: 604,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
const delta: ProviderEvent = {
|
|
45
|
+
type: "message_delta",
|
|
46
|
+
delta: { stop_reason: "end_turn" },
|
|
47
|
+
usage: { output_tokens: 210 },
|
|
48
|
+
}
|
|
49
|
+
const usage = applyEvent(applyEvent(emptyAccumulator, start), delta).usage
|
|
50
|
+
expect(usage.output_tokens).toBe(210)
|
|
51
|
+
expect(usage.input_tokens_details?.cache_write_tokens).toBe(604)
|
|
52
|
+
expect(usage.total_tokens).toBe(94 + 31851 + 604 + 210)
|
|
53
|
+
})
|
|
54
|
+
})
|