393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
|
|
import { assert, describe, it } from "@effect/vitest"
|
|
import { Effect, Layer, Redacted, Schema, Stream } from "effect"
|
|
import { LanguageModel, Tool, Toolkit } from "effect/unstable/ai"
|
|
import { HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
|
|
describe("AnthropicLanguageModel", () => {
|
|
describe("streamText", () => {
|
|
it.effect("decodes tool call params in content_block_stop", () =>
|
|
Effect.gen(function*() {
|
|
const toolParams = { pattern: "*.ts" }
|
|
|
|
const layer = AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(sseResponse(request, [
|
|
{
|
|
type: "message_start",
|
|
message: {
|
|
id: "msg_test_1",
|
|
type: "message",
|
|
role: "assistant",
|
|
model: "claude-sonnet-4-20250514",
|
|
content: [],
|
|
stop_reason: null,
|
|
stop_sequence: null,
|
|
usage: {
|
|
cache_creation: null,
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
inference_geo: null,
|
|
input_tokens: 10,
|
|
output_tokens: 0,
|
|
service_tier: null
|
|
}
|
|
}
|
|
},
|
|
{
|
|
type: "content_block_start",
|
|
index: 0,
|
|
content_block: {
|
|
type: "tool_use",
|
|
id: "toolu_test_1",
|
|
name: "GlobTool",
|
|
input: {}
|
|
}
|
|
},
|
|
{
|
|
type: "content_block_delta",
|
|
index: 0,
|
|
delta: {
|
|
type: "input_json_delta",
|
|
partial_json: JSON.stringify(toolParams)
|
|
}
|
|
},
|
|
{
|
|
type: "content_block_stop",
|
|
index: 0
|
|
},
|
|
{
|
|
type: "message_delta",
|
|
delta: {
|
|
stop_reason: "tool_use",
|
|
stop_sequence: null
|
|
},
|
|
usage: {
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
input_tokens: null,
|
|
output_tokens: 5
|
|
}
|
|
},
|
|
{
|
|
type: "message_stop"
|
|
}
|
|
]))
|
|
)
|
|
))
|
|
)
|
|
|
|
const GlobTool = Tool.make("GlobTool", {
|
|
description: "Search for files",
|
|
parameters: Schema.Struct({ pattern: Schema.String }),
|
|
success: Schema.String
|
|
})
|
|
|
|
const toolkit = Toolkit.make(GlobTool)
|
|
const toolkitLayer = toolkit.toLayer({
|
|
GlobTool: () => Effect.succeed("found.ts")
|
|
})
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({
|
|
prompt: "find ts files",
|
|
toolkit,
|
|
disableToolCallResolution: true
|
|
}).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(AnthropicLanguageModel.model("claude-sonnet-4-20250514")),
|
|
Effect.provide(toolkitLayer),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const parts = globalThis.Array.from(partsChunk)
|
|
const toolCall = parts.find((part) => part.type === "tool-call")
|
|
assert.isDefined(toolCall)
|
|
if (toolCall?.type !== "tool-call") {
|
|
return
|
|
}
|
|
|
|
assert.strictEqual(toolCall.name, "GlobTool")
|
|
assert.deepStrictEqual(toolCall.params, toolParams)
|
|
}))
|
|
|
|
// `Model` is an open enum in Anthropic's spec (`anyOf: [{ type: string }, ...consts]`), and it is
|
|
// $ref'd by response schemas. Responses must therefore decode for model ids that are newer than the
|
|
// generated literals, and the id must survive decoding unchanged.
|
|
it.effect("decodes responses for a model id that is not a known literal", () =>
|
|
Effect.gen(function*() {
|
|
const layer = AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(sseResponse(request, [
|
|
{
|
|
type: "message_start",
|
|
message: {
|
|
id: "msg_test_1",
|
|
type: "message",
|
|
role: "assistant",
|
|
model: "claude-not-a-known-model-id",
|
|
content: [],
|
|
stop_reason: null,
|
|
stop_sequence: null,
|
|
usage: {
|
|
cache_creation: null,
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
inference_geo: null,
|
|
input_tokens: 10,
|
|
output_tokens: 0,
|
|
service_tier: null
|
|
}
|
|
}
|
|
},
|
|
{
|
|
type: "content_block_start",
|
|
index: 0,
|
|
content_block: { type: "text", text: "" }
|
|
},
|
|
{
|
|
type: "content_block_delta",
|
|
index: 0,
|
|
delta: { type: "text_delta", text: "Hello" }
|
|
},
|
|
{
|
|
type: "content_block_stop",
|
|
index: 0
|
|
},
|
|
{
|
|
type: "message_delta",
|
|
delta: {
|
|
stop_reason: "end_turn",
|
|
stop_sequence: null
|
|
},
|
|
usage: {
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
input_tokens: null,
|
|
output_tokens: 5
|
|
}
|
|
},
|
|
{
|
|
type: "message_stop"
|
|
}
|
|
]))
|
|
)
|
|
))
|
|
)
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({
|
|
prompt: "say hello"
|
|
}).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(AnthropicLanguageModel.model("claude-not-a-known-model-id")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const parts = globalThis.Array.from(partsChunk)
|
|
const metadata = parts.find((part) => part.type === "response-metadata")
|
|
assert.isDefined(metadata)
|
|
if (metadata?.type !== "response-metadata") {
|
|
return
|
|
}
|
|
|
|
assert.strictEqual(metadata.modelId, "claude-not-a-known-model-id")
|
|
|
|
const text = parts.find((part) => part.type === "text-delta")
|
|
assert.isDefined(text)
|
|
if (text?.type !== "text-delta") {
|
|
return
|
|
}
|
|
|
|
assert.strictEqual(text.delta, "Hello")
|
|
}))
|
|
})
|
|
|
|
describe("generateText", () => {
|
|
it.effect("encodes dynamic tools", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined = undefined
|
|
const layer = AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(request, {
|
|
id: "msg_test_1",
|
|
type: "message",
|
|
role: "assistant",
|
|
model: "claude-sonnet-4-20250514",
|
|
content: [{ type: "text", text: "Done" }],
|
|
stop_reason: "end_turn",
|
|
stop_sequence: null,
|
|
usage: {
|
|
cache_creation: null,
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
inference_geo: null,
|
|
input_tokens: 10,
|
|
output_tokens: 5,
|
|
service_tier: null
|
|
}
|
|
}))
|
|
})
|
|
))
|
|
)
|
|
|
|
const inputSchema = {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string" },
|
|
limit: { type: "number" }
|
|
},
|
|
required: ["query"],
|
|
additionalProperties: false
|
|
} as const
|
|
|
|
const DynamicTool = Tool.dynamic("DynamicTool", {
|
|
description: "A dynamic tool",
|
|
parameters: inputSchema
|
|
})
|
|
|
|
yield* LanguageModel.generateText({
|
|
prompt: "Use the dynamic tool",
|
|
toolkit: Toolkit.make(DynamicTool),
|
|
disableToolCallResolution: true
|
|
}).pipe(
|
|
Effect.provide(AnthropicLanguageModel.model("claude-sonnet-4-20250514")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const body = yield* getRequestBody(capturedRequest)
|
|
const dynamicTool = body.tools.find((tool: any) => tool.name === "DynamicTool")
|
|
|
|
assert.isDefined(dynamicTool)
|
|
if (dynamicTool === undefined) {
|
|
return
|
|
}
|
|
|
|
assert.strictEqual(dynamicTool.description, "A dynamic tool")
|
|
assert.deepStrictEqual(dynamicTool.input_schema, inputSchema)
|
|
}))
|
|
})
|
|
|
|
describe("generateObject", () => {
|
|
// A model that supports native structured output requests it via `output_config.format` (json_schema)
|
|
// rather than falling back to a forced JSON tool.
|
|
const assertNativeStructuredOutput = (model: string) =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined = undefined
|
|
const layer = AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(request, {
|
|
id: "msg_test_1",
|
|
type: "message",
|
|
role: "assistant",
|
|
model,
|
|
content: [{ type: "text", text: JSON.stringify({ name: "John", age: 30 }) }],
|
|
stop_reason: "end_turn",
|
|
stop_sequence: null,
|
|
usage: {
|
|
cache_creation: null,
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
inference_geo: null,
|
|
input_tokens: 10,
|
|
output_tokens: 5,
|
|
service_tier: null
|
|
}
|
|
}))
|
|
})
|
|
))
|
|
)
|
|
|
|
// Assert the request shape; the response outcome is irrelevant here.
|
|
yield* LanguageModel.generateObject({
|
|
prompt: "Give me a person",
|
|
schema: Schema.Struct({ name: Schema.String, age: Schema.Number })
|
|
}).pipe(
|
|
Effect.provide(AnthropicLanguageModel.model(model)),
|
|
Effect.provide(layer),
|
|
Effect.ignore
|
|
)
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const body = yield* getRequestBody(capturedRequest)
|
|
assert.strictEqual(body.output_config?.format?.type, "json_schema")
|
|
})
|
|
|
|
it.effect("uses native json_schema output for claude-opus-4-6", () =>
|
|
assertNativeStructuredOutput("claude-opus-4-6"))
|
|
|
|
it.effect("uses native json_schema output for claude-sonnet-4-6", () =>
|
|
assertNativeStructuredOutput("claude-sonnet-4-6"))
|
|
})
|
|
})
|
|
|
|
const makeHttpClient = (
|
|
handler: (
|
|
request: HttpClientRequest.HttpClientRequest
|
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
|
|
) =>
|
|
HttpClient.makeWith(
|
|
Effect.fnUntraced(function*(requestEffect) {
|
|
const request = yield* requestEffect
|
|
return yield* handler(request)
|
|
}),
|
|
Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
|
|
)
|
|
|
|
const sseResponse = (
|
|
request: HttpClientRequest.HttpClientRequest,
|
|
events: ReadonlyArray<unknown>
|
|
): HttpClientResponse.HttpClientResponse =>
|
|
HttpClientResponse.fromWeb(
|
|
request,
|
|
new Response(toSseBody(events), {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "text/event-stream"
|
|
}
|
|
})
|
|
)
|
|
|
|
const jsonResponse = (
|
|
request: HttpClientRequest.HttpClientRequest,
|
|
body: unknown
|
|
): HttpClientResponse.HttpClientResponse =>
|
|
HttpClientResponse.fromWeb(
|
|
request,
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/json"
|
|
}
|
|
})
|
|
)
|
|
|
|
const getRequestBody = (request: HttpClientRequest.HttpClientRequest) =>
|
|
Effect.gen(function*() {
|
|
const body = request.body
|
|
if (body._tag !== "Uint8Array") {
|
|
return yield* Effect.die(new Error("Expected Uint8Array body"))
|
|
}
|
|
return JSON.parse(new TextDecoder().decode(body.body))
|
|
})
|
|
|
|
const toSseBody = (events: ReadonlyArray<unknown>): string =>
|
|
events.map((event) => `event: message_stream\ndata: ${JSON.stringify(event)}\n\n`).join("")
|