Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions src/api/providers/__tests__/anthropic-vertex.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1248,7 +1248,7 @@ describe("VertexHandler", () => {

expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
thinking: { type: "adaptive" },
thinking: { type: "adaptive", display: "summarized" },
}),
undefined,
)
Expand Down Expand Up @@ -1279,7 +1279,7 @@ describe("VertexHandler", () => {

expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
thinking: { type: "adaptive" },
thinking: { type: "adaptive", display: "summarized" },
}),
undefined,
)
Expand Down Expand Up @@ -1312,7 +1312,7 @@ describe("VertexHandler", () => {

expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
thinking: { type: "adaptive" },
thinking: { type: "adaptive", display: "summarized" },
}),
undefined,
)
Expand Down Expand Up @@ -1343,7 +1343,7 @@ describe("VertexHandler", () => {

expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
thinking: { type: "adaptive" },
thinking: { type: "adaptive", display: "summarized" },
}),
undefined,
)
Expand Down
94 changes: 86 additions & 8 deletions src/api/providers/__tests__/anthropic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { AnthropicHandler } from "../anthropic"
import { ApiHandlerOptions } from "../../../shared/api"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"
import type { ApiStreamChunk } from "../../../api/transform/stream"
import { clearAllMocks } from "../../../test-utils/reset"

// Mock TelemetryService
Expand Down Expand Up @@ -247,7 +248,7 @@ describe("AnthropicHandler", () => {
await collectStream(stream)

const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
expect(requestBody?.thinking).toEqual({ type: "adaptive", display: "summarized" })
expect(requestBody?.max_tokens).toBe(16384)
})

Expand Down Expand Up @@ -290,7 +291,7 @@ describe("AnthropicHandler", () => {
await collectStream(stream)

const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
expect(requestBody?.thinking).toEqual({ type: "adaptive", display: "summarized" })
expect(requestBody?.max_tokens).toBe(32768)
})

Expand Down Expand Up @@ -334,7 +335,7 @@ describe("AnthropicHandler", () => {
await collectStream(stream)

const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
expect(requestBody?.thinking).toEqual({ type: "adaptive", display: "summarized" })
expect(requestBody?.max_tokens).toBe(16384)
})

Expand Down Expand Up @@ -377,7 +378,7 @@ describe("AnthropicHandler", () => {
await collectStream(stream)

const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
expect(requestBody?.thinking).toEqual({ type: "adaptive", display: "summarized" })
expect(requestBody?.max_tokens).toBe(32768)
})

Expand All @@ -400,7 +401,7 @@ describe("AnthropicHandler", () => {

const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
const requestOptions = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[1]
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
expect(requestBody?.thinking).toEqual({ type: "adaptive", display: "summarized" })
expect(requestBody?.temperature).toBeUndefined()
expect(requestBody?.max_tokens).toBe(32768)
expect(requestOptions?.headers?.["anthropic-beta"]).toContain("prompt-caching-2024-07-31")
Expand All @@ -425,7 +426,7 @@ describe("AnthropicHandler", () => {

const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
const requestOptions = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[1]
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
expect(requestBody?.thinking).toEqual({ type: "adaptive", display: "summarized" })
expect(requestBody?.temperature).toBeUndefined()
expect(requestBody?.max_tokens).toBe(32768)
expect(requestOptions?.headers?.["anthropic-beta"]).toContain("prompt-caching-2024-07-31")
Expand All @@ -450,7 +451,7 @@ describe("AnthropicHandler", () => {

const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
const requestOptions = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[1]
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
expect(requestBody?.thinking).toEqual({ type: "adaptive", display: "summarized" })
expect(requestBody?.temperature).toBeUndefined()
expect(requestBody?.max_tokens).toBe(32768)
expect(requestOptions?.headers?.["anthropic-beta"]).toContain("prompt-caching-2024-07-31")
Expand All @@ -474,7 +475,84 @@ describe("AnthropicHandler", () => {

const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
expect(requestBody?.model).toBe("claude-sonnet-5-bf")
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
expect(requestBody?.thinking).toEqual({ type: "adaptive", display: "summarized" })
})

it("should surface thinking_tokens from output_tokens_details in usage chunks", async () => {
// Adaptive models report reasoning tokens inside
// `output_tokens_details.thinking_tokens` (message_start snapshot is
// typically absent; message_delta carries the final decomposition).
mockCreate.mockImplementationOnce(async () =>
asyncStreamFrom([
{
type: "message_start",
message: {
usage: {
input_tokens: 100,
output_tokens: 50,
},
},
},
{
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "Hello",
},
},
{
type: "content_block_delta",
index: 0,
delta: {
type: "text_delta",
text: " world",
},
},
{
type: "message_delta",
usage: {
output_tokens: 200,
output_tokens_details: {
thinking_tokens: 150,
},
},
delta: {
stop_reason: "end_turn",
stop_sequence: null,
},
},
]),
)

const adaptiveHandler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-7",
enableReasoningEffort: true,
})

const stream = adaptiveHandler.createMessage("prompt", [
{
role: "user",
content: [{ type: "text", text: "Hi" }],
},
])

const chunks: ApiStreamChunk[] = await collectStream(stream)

// message_start snapshot carries no thinking decomposition
const usageChunks = chunks.filter(
(chunk): chunk is Extract<ApiStreamChunk, { type: "usage" }> => chunk.type === "usage",
)
const startUsage = usageChunks.find((chunk) => chunk.inputTokens > 0)
expect(startUsage).toBeDefined()
expect(startUsage?.reasoningTokens).toBeUndefined()

// message_delta surfaces the final reasoning token count
const deltaUsage = usageChunks.find((chunk) => chunk.inputTokens === 0)
expect(deltaUsage).toBeDefined()
expect(deltaUsage?.outputTokens).toBe(200)
expect(deltaUsage?.reasoningTokens).toBe(150)
})
})

Expand Down
9 changes: 9 additions & 0 deletions src/api/providers/anthropic-vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,21 +123,30 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple
case "message_start": {
const usage = chunk.message!.usage

// `output_tokens_details.thinking_tokens` decomposes the billed
// output into internal reasoning tokens; surfaced for per-turn
// thinking telemetry (billing already includes it in output_tokens).
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
...(typeof usage.output_tokens_details?.thinking_tokens === "number"
? { reasoningTokens: usage.output_tokens_details!.thinking_tokens }
: {}),
}

break
}
case "message_delta": {
const deltaThinkingTokens = chunk.usage!.output_tokens_details?.thinking_tokens

yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage!.output_tokens || 0,
...(typeof deltaThinkingTokens === "number" ? { reasoningTokens: deltaThinkingTokens } : {}),
}

break
Expand Down
15 changes: 14 additions & 1 deletion src/api/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,19 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
cache_read_input_tokens,
} = chunk.message.usage

// `output_tokens_details.thinking_tokens` decomposes the billed
// output into internal reasoning tokens (0 ⇒ the model skipped
// thinking this turn). `output_tokens` already includes them for
// billing; this field exists for per-turn thinking telemetry.
const startThinkingTokens = chunk.message.usage.output_tokens_details?.thinking_tokens

yield {
type: "usage",
inputTokens: input_tokens,
outputTokens: output_tokens,
cacheWriteTokens: cache_creation_input_tokens || undefined,
cacheReadTokens: cache_read_input_tokens || undefined,
...(typeof startThinkingTokens === "number" ? { reasoningTokens: startThinkingTokens } : {}),
}

inputTokens += input_tokens
Expand All @@ -270,16 +277,22 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa

break
}
case "message_delta":
case "message_delta": {
// Tells us stop_reason, stop_sequence, and output tokens
// along the way and at the end of the message.
// Carries the final `thinking_tokens` decomposition for the
// whole message (the `message_start` snapshot typically has none).
const deltaThinkingTokens = chunk.usage.output_tokens_details?.thinking_tokens

yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
...(typeof deltaThinkingTokens === "number" ? { reasoningTokens: deltaThinkingTokens } : {}),
}

break
}
case "message_stop":
// No usage data, just an indicator that the message is done.
break
Expand Down
4 changes: 2 additions & 2 deletions src/api/transform/__tests__/reasoning.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ describe("reasoning.ts", () => {
reasoningBudget: undefined,
})

expect(result).toEqual({ type: "adaptive" })
expect(result).toEqual({ type: "adaptive", display: "summarized" })
})

it("should omit thinking for Claude Opus 4.7 when reasoning is disabled", () => {
Expand Down Expand Up @@ -1241,7 +1241,7 @@ describe("reasoning.ts", () => {
reasoningBudget: undefined,
})

expect(result).toEqual({ type: "adaptive" })
expect(result).toEqual({ type: "adaptive", display: "summarized" })
})

it("should return correct types for OpenAI reasoning params", () => {
Expand Down
16 changes: 14 additions & 2 deletions src/api/transform/reasoning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,19 @@ export type RooReasoningParams = {
}

export type AnthropicReasoningParams = BetaThinkingConfigParam
export type AnthropicProviderReasoningParams = AnthropicReasoningParams | { type: "adaptive" }
/**
* Adaptive thinking models (the `supportsReasoningBinary` class) reject
* `budget_tokens` payloads and use `thinking: { type: "adaptive" }`.
*
* `display` is carried explicitly because the API default for this model
* class is `"omitted"` — thinking blocks then arrive as empty text (still
* billed, but invisible to the user). `"summarized"` returns the
* server-side summarized thinking text with no extra cost (billing is on
* full thinking tokens, not the summary).
*/
export type AnthropicProviderReasoningParams =
| AnthropicReasoningParams
| { type: "adaptive"; display?: "summarized" | "omitted" }

export type OpenAiReasoningParams = { reasoning_effort: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"] }

Expand Down Expand Up @@ -118,7 +130,7 @@ export const getAnthropicProviderReasoning = ({
settings,
}: GetModelReasoningOptions): AnthropicProviderReasoningParams | undefined => {
if (model.supportsReasoningBinary && settings.enableReasoningEffort) {
return { type: "adaptive" }
return { type: "adaptive", display: "summarized" }
}

return getAnthropicReasoning({ model, reasoningBudget, settings })
Expand Down
Loading