diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 2edfcce975..70f2c96ec8 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -303,6 +303,7 @@ type AnthropicEvent = Schema.Schema.Type interface ParserState { readonly tools: ToolStream.State + readonly hasLocalToolCalls: boolean readonly reasoningSignatures: Readonly> readonly usage?: Usage readonly pendingFinish?: { @@ -668,7 +669,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques // ============================================================================= // Stream Parsing // ============================================================================= -const mapFinishReason = (reason: string | null | undefined): FinishReason => { +const mapFinishReason = (reason: string | null | undefined, hasLocalToolCalls: boolean): FinishReason => { + if ( + hasLocalToolCalls && + (reason === undefined || reason === null || reason === "end_turn" || reason === "stop_sequence") + ) + return "tool-calls" if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop" if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length" if (reason === "tool_use") return "tool-calls" @@ -931,7 +937,17 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun events.push(...resultEvents) const reasoningSignatures = { ...state.reasoningSignatures } delete reasoningSignatures[event.index] - return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult + return [ + { + ...state, + hasLocalToolCalls: + state.hasLocalToolCalls || resultEvents.some((item) => item.type === "tool-call" && !item.providerExecuted), + lifecycle, + tools: result.tools, + reasoningSignatures, + }, + events, + ] satisfies StepResult }) const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => { @@ -942,7 +958,7 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult = usage, pendingFinish: { reason: { - normalized: mapFinishReason(event.delta?.stop_reason), + normalized: mapFinishReason(event.delta?.stop_reason, state.hasLocalToolCalls), raw: event.delta?.stop_reason ?? undefined, }, providerMetadata: @@ -1013,6 +1029,7 @@ export const protocol = Protocol.make({ event: Protocol.jsonEvent(AnthropicEvent), initial: () => ({ tools: ToolStream.empty(), + hasLocalToolCalls: false, reasoningSignatures: {}, lifecycle: Lifecycle.initial(), }), diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 3e5d399377..cc9a044fea 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -915,6 +915,30 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("maps a local tool call with end_turn as tool-calls", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "lookup" } }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' }, + }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ) + const response = yield* LLMClient.generate( + LLMRequest.update(request, { + tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" }) + }), + ) + it.effect("keeps malformed server tool input terminal", () => Effect.gen(function* () { const body = sseEvents(