Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/fair-tools-recover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@effect/ai-anthropic": patch
"@effect/ai-openai": patch
"@effect/ai-openai-compat": patch
"@effect/ai-openrouter": patch
"effect": patch
---

Return unknown tools, invalid tool parameters, and malformed provider tool-call
JSON as model-visible tool call errors instead of failing the language model
operation. Tool call errors retain the original call and become failed tool
results in chat history, allowing a model to correct and retry its request.

Centralize provider parameter decoding in `LanguageModel`, add
`Response.ToolCallErrorPart`, expose errors through
`GenerateTextResponse.toolCallErrors`, and execute validated parameters through
a composable Toolkit boundary. `HandlerResult` is discriminated by `isFailure`,
so successful and failed results remain narrow after toolkit composition.
Parameter validation errors also accept arbitrary runtime values while retaining
a JSON-encoded representation, so reporting non-JSON invalid parameters cannot
become a defect.
83 changes: 11 additions & 72 deletions packages/ai/anthropic/src/AnthropicLanguageModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import * as Predicate from "effect/Predicate"
import * as Redactable from "effect/Redactable"
import * as Schema from "effect/Schema"
import type * as Schema from "effect/Schema"
import * as SchemaAST from "effect/SchemaAST"
import * as SchemaIssue from "effect/SchemaIssue"
import * as Stream from "effect/Stream"
import type { Span } from "effect/Tracer"
import type { Mutable, Simplify } from "effect/Types"
Expand All @@ -30,7 +29,7 @@ import * as IdGenerator from "effect/unstable/ai/IdGenerator"
import * as LanguageModel from "effect/unstable/ai/LanguageModel"
import * as AiModel from "effect/unstable/ai/Model"
import type * as Prompt from "effect/unstable/ai/Prompt"
import type * as Response from "effect/unstable/ai/Response"
import * as Response from "effect/unstable/ai/Response"
import * as Tool from "effect/unstable/ai/Tool"
import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
Expand All @@ -40,8 +39,6 @@ import type { AnthropicTool } from "./AnthropicTool.ts"
import type * as Generated from "./Generated.ts"
import * as InternalUtilities from "./internal/utilities.ts"

const formatIssue = SchemaIssue.makeFormatterDefault()

/**
* Known Anthropic Claude model identifiers exposed by the generated Anthropic schema.
*
Expand Down Expand Up @@ -1647,13 +1644,11 @@ const makeResponse = Effect.fnUntraced(
// Map the provider wire name (e.g. "memory") back to the tool's
// custom name (e.g. "AnthropicMemory") that the toolkit is keyed by
const toolName = toolNameMapper.getCustomName(part.name)
const params = yield* transformToolCallParams(options.tools, toolName, part.input)

parts.push({
type: "tool-call",
id: part.id,
name: toolName,
params,
params: part.input,
...(Predicate.isNotUndefined(callerInfo)
? { metadata: { anthropic: { caller: callerInfo } } }
: undefined)
Expand Down Expand Up @@ -2087,13 +2082,11 @@ const makeStreamResponse = Effect.fnUntraced(
id: part.id
})

const params = yield* transformToolCallParams(options.tools, toolName, part.input)

parts.push({
type: "tool-call",
id: part.id,
name: toolName,
params,
params: part.input,
...(Predicate.isNotUndefined(callerInfo)
? { metadata: { anthropic: { caller: callerInfo } } }
: undefined)
Expand Down Expand Up @@ -2675,26 +2668,15 @@ const makeStreamResponse = Effect.fnUntraced(
}
}

const params = contentBlock.providerExecuted === true
? Tool.unsafeSecureJsonParse(finalParams)
: yield* transformToolCallParams(
options.tools,
contentBlock.name,
Tool.unsafeSecureJsonParse(finalParams)
)

parts.push({
type: "tool-call",
parts.push(Response.toolCallPartFromJson({
id: contentBlock.id,
name: contentBlock.name,
params,
...(Predicate.isNotUndefined(contentBlock.providerExecuted)
? { providerExecuted: contentBlock.providerExecuted }
: undefined),
...(Predicate.isNotUndefined(contentBlock.caller)
? { metadata: { anthropic: { caller: contentBlock.caller } } }
: undefined)
})
params: finalParams,
metadata: Predicate.isNotUndefined(contentBlock.caller)
? { anthropic: { caller: contentBlock.caller } }
: undefined,
providerExecuted: contentBlock.providerExecuted
}))
}
}

Expand Down Expand Up @@ -3054,12 +3036,6 @@ const unsupportedSchemaError = (error: unknown, method: string): AiError.AiError
})
})

const tryCodecTransform = <S extends Schema.Constraint>(schema: S, method: string) =>
Effect.try({
try: () => toCodecAnthropic(schema),
catch: (error) => unsupportedSchemaError(error, method)
})

const tryJsonSchema = <S extends Schema.Constraint>(schema: S, method: string) =>
Effect.try({
try: () => Tool.getJsonSchemaFromSchema(schema, { transformer: toCodecAnthropic }),
Expand All @@ -3085,40 +3061,3 @@ const getOutputFormat = Effect.fnUntraced(function*({ capabilities, options }: {
}
return undefined
})

const transformToolCallParams = Effect.fnUntraced(function*<Tools extends ReadonlyArray<Tool.Any>>(
tools: Tools,
toolName: string,
toolParams: unknown
): Effect.fn.Return<unknown, AiError.AiError> {
const tool = tools.find((tool) => tool.name === toolName)

if (Predicate.isUndefined(tool)) {
return yield* AiError.make({
module: "AnthropicLanguageModel",
method: "makeResponse",
reason: new AiError.ToolNotFoundError({
toolName,
availableTools: tools.map((tool) => tool.name)
})
})
}

const { codec } = yield* tryCodecTransform(tool.parametersSchema, "makeResponse")

const transform = Schema.decodeEffect(codec)

return yield* (
transform(toolParams) as Effect.Effect<unknown, Schema.SchemaError>
).pipe(Effect.mapError((error) =>
AiError.make({
module: "AnthropicLanguageModel",
method: "makeResponse",
reason: new AiError.ToolParameterValidationError({
toolName,
toolParams,
description: formatIssue(error.issue)
})
})
))
})
63 changes: 63 additions & 0 deletions packages/ai/anthropic/test/AnthropicLanguageModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,69 @@ describe("AnthropicLanguageModel", () => {
})

describe("generateText", () => {
const TestTool = Tool.make("TestTool", {
parameters: Schema.Struct({ input: Schema.String }),
success: Schema.String
})
const TestToolkit = Toolkit.make(TestTool)
const TestToolkitLayer = TestToolkit.toLayer({
TestTool: ({ input }) => Effect.succeed(input)
})
const toolCallLayer = (name: string, input: unknown) =>
AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
Layer.provide(Layer.succeed(
HttpClient.HttpClient,
makeHttpClient((request) =>
Effect.succeed(jsonResponse(request, {
id: "msg_test_1",
type: "message",
role: "assistant",
model: "claude-sonnet-4-20250514",
content: [{ type: "tool_use", id: "toolu_test_1", name, input }],
stop_reason: "tool_use",
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
}
}))
)
))
)

it.effect("returns invalid tool parameters as a tool call error", () =>
Effect.gen(function*() {
const response = yield* LanguageModel.generateText({
prompt: "Use the test tool",
toolkit: TestToolkit
})

assert.strictEqual(response.toolCallErrors[0]?.error._tag, "ToolParameterValidationError")
}).pipe(
Effect.provide(AnthropicLanguageModel.model("claude-sonnet-4-20250514")),
Effect.provide(TestToolkitLayer),
Effect.provide(toolCallLayer("TestTool", {}))
))

it.effect("returns unknown tool names as a tool call error", () =>
Effect.gen(function*() {
const response = yield* LanguageModel.generateText({
prompt: "Use a tool",
toolkit: TestToolkit
})

assert.strictEqual(response.toolCallErrors[0]?.error._tag, "ToolNotFoundError")
}).pipe(
Effect.provide(AnthropicLanguageModel.model("claude-sonnet-4-20250514")),
Effect.provide(TestToolkitLayer),
Effect.provide(toolCallLayer("UnknownTool", { input: "test" }))
))

it.effect("encodes dynamic tools", () =>
Effect.gen(function*() {
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined = undefined
Expand Down
Loading