diff --git a/packages/core/src/plugin/provider/claude-agent/language-model/convert-claude-stream.ts b/packages/core/src/plugin/provider/claude-agent/language-model/convert-claude-stream.ts index eb1f4fb333d..1cc8d9d0e84 100644 --- a/packages/core/src/plugin/provider/claude-agent/language-model/convert-claude-stream.ts +++ b/packages/core/src/plugin/provider/claude-agent/language-model/convert-claude-stream.ts @@ -1,7 +1,7 @@ export * as ConvertClaudeStream from "./convert-claude-stream.js" import type { LanguageModelV3FunctionTool, LanguageModelV3StreamPart } from "@ai-sdk/provider" -import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk" +import type { SDKMessage, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk" import { Provider } from "@opencode/schema/provider" import { ClaudeUsage } from "./usage.js" import { ToolNames } from "../tools/names.js" @@ -15,11 +15,15 @@ export function make(tools: readonly LanguageModelV3FunctionTool[]) { let messageID = "" let calledTools = false let metadata: Record | undefined + // Plugins read plan usage from step metadata instead of polling Anthropic for it. + let rateLimit: SDKRateLimitInfo | undefined + const finishMetadata = () => (rateLimit ? { [Provider.ID.claudeAgent]: { rateLimit } } : undefined) return (message: SDKMessage): LanguageModelV3StreamPart[] => { const parts: LanguageModelV3StreamPart[] = [] const error = failure(message) if (error) return [{ type: "error", error }] + if (message.type === "rate_limit_event") rateLimit = message.rate_limit_info if (message.type === "stream_event" && message.parent_tool_use_id === null) { const event = message.event switch (event.type) { @@ -74,6 +78,7 @@ export function make(tools: readonly LanguageModelV3FunctionTool[]) { type: "finish", finishReason: { unified: "tool-calls", raw: "tool_use" }, usage: usage.snapshot(), + providerMetadata: finishMetadata(), }) } break @@ -126,6 +131,7 @@ export function make(tools: readonly LanguageModelV3FunctionTool[]) { raw: message.stop_reason ?? undefined, }, usage: usage.snapshot(), + providerMetadata: finishMetadata(), }) } return parts diff --git a/packages/core/test/plugin/provider/claude-agent/language-model/convert.test.ts b/packages/core/test/plugin/provider/claude-agent/language-model/convert.test.ts index 2bd7b14006a..8bea22c6a8e 100644 --- a/packages/core/test/plugin/provider/claude-agent/language-model/convert.test.ts +++ b/packages/core/test/plugin/provider/claude-agent/language-model/convert.test.ts @@ -28,6 +28,22 @@ describe("Claude conversions", () => { expect(unknown.some((part) => part.type === "tool-call" || part.type === "finish")).toBe(false) }) + test("a step's finish carries the plan usage Claude Code reported during it", () => { + const recorded = Recorder.load("tools").find( + (line) => line.from === "claude" && line.message.type === "rate_limit_event", + ) + if (recorded?.from !== "claude" || recorded.message.type !== "rate_limit_event") + throw new Error("Expected a recorded rate limit event") + expect(firstToolReply(TestTools.tools).at(-1)).toMatchObject({ + type: "finish", + providerMetadata: { "claude-agent": { rateLimit: recorded.message.rate_limit_info } }, + }) + expect(ConvertClaudeStream.make([])(Recorder.result("text")).at(-1)).toMatchObject({ + type: "finish", + providerMetadata: undefined, + }) + }) + test("utility calls keep system instructions separate from conversation content and omit thinking", () => { const prompt: LanguageModelV3Prompt = [ { role: "system", content: "Summarize this conversation." },