Skip to content

Commit d2fb21e

Browse files
[Fix] Anthropic Opus 4.7 fails when reasoning is enabled (Zoo-Code-Org#111)
* fix: use adaptive reasoning for anthropic opus 4.7 * fix: relax anthropic request typing for adaptive thinking * fix: preserve opus 4.7 anthropic token handling * fix: constrain anthropic e2e proxy path * Address follow-up Anthropic review feedback --------- Co-authored-by: Roomote <roomote@roocode.com>
1 parent d87937b commit d2fb21e

8 files changed

Lines changed: 444 additions & 61 deletions

File tree

apps/vscode-e2e/fixtures/claude-opus-4-7.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,21 @@
22
"fixtures": [
33
{
44
"match": {
5-
"userMessage": "opus47-e2e: what is 2+2? Reply with only the number."
5+
"userMessage": "opus47-e2e:reasoning-on: what is 2+2? Reply with only the number."
6+
},
7+
"response": {
8+
"toolCalls": [
9+
{
10+
"name": "attempt_completion",
11+
"arguments": "{\"result\": \"4\"}",
12+
"id": "toolu_014MmgmKQV9c2DmffmF8bKm3"
13+
}
14+
]
15+
}
16+
},
17+
{
18+
"match": {
19+
"userMessage": "opus47-e2e:reasoning-off: what is 2+2? Reply with only the number."
620
},
721
"response": {
822
"toolCalls": [
Lines changed: 202 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,161 @@
11
import * as assert from "assert"
2+
import { createServer, type IncomingMessage, type ServerResponse } from "http"
23

34
import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
45

56
import { waitUntilCompleted } from "./utils"
67
import { setDefaultSuiteTimeout } from "./test-utils"
78

9+
type CapturedAnthropicRequest = {
10+
model?: string
11+
thinkingType?: string
12+
lastUserMessage: string
13+
}
14+
15+
const ALLOWED_PROXY_HOSTS = new Set(["127.0.0.1", "localhost", "api.anthropic.com"])
16+
const ANTHROPIC_MESSAGES_PATH = "/v1/messages"
17+
18+
function isMessagesUrl(rawUrl: string): boolean {
19+
try {
20+
return new URL(rawUrl).pathname.endsWith(ANTHROPIC_MESSAGES_PATH)
21+
} catch {
22+
return false
23+
}
24+
}
25+
26+
function readRequestBody(req: IncomingMessage): Promise<string> {
27+
return new Promise((resolve, reject) => {
28+
const chunks: Buffer[] = []
29+
req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
30+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
31+
req.on("error", reject)
32+
})
33+
}
34+
35+
function writeResponseHeaders(target: ServerResponse, source: Response) {
36+
const headers: Record<string, string> = {}
37+
source.headers.forEach((value, key) => {
38+
if (key.toLowerCase() !== "content-length") {
39+
headers[key] = value
40+
}
41+
})
42+
target.writeHead(source.status, headers)
43+
}
44+
45+
async function pipeFetchResponse(target: ServerResponse, source: Response) {
46+
writeResponseHeaders(target, source)
47+
48+
if (!source.body) {
49+
target.end()
50+
return
51+
}
52+
53+
const reader = source.body.getReader()
54+
while (true) {
55+
const { done, value } = await reader.read()
56+
if (done) {
57+
break
58+
}
59+
target.write(value)
60+
}
61+
62+
target.end()
63+
}
64+
65+
function resolveAllowedUpstreamUrl(baseUrl: string): URL {
66+
const upstreamBase = new URL(baseUrl)
67+
const isLocalProxy = upstreamBase.hostname === "127.0.0.1" || upstreamBase.hostname === "localhost"
68+
69+
if (
70+
!ALLOWED_PROXY_HOSTS.has(upstreamBase.hostname) ||
71+
(isLocalProxy ? upstreamBase.protocol !== "http:" : baseUrl !== "https://api.anthropic.com")
72+
) {
73+
throw new Error(`Unexpected Anthropic proxy target: ${upstreamBase.origin}`)
74+
}
75+
76+
return new URL(ANTHROPIC_MESSAGES_PATH, upstreamBase)
77+
}
78+
79+
async function withAnthropicProxy<T>(
80+
baseUrl: string,
81+
run: (args: { proxyUrl: string; requests: CapturedAnthropicRequest[] }) => Promise<T>,
82+
): Promise<T> {
83+
const requests: CapturedAnthropicRequest[] = []
84+
let proxyError: Error | undefined
85+
const server = createServer(async (req, res) => {
86+
try {
87+
const requestUrl = req.url ?? "/"
88+
89+
if (!isMessagesUrl(`http://127.0.0.1${requestUrl}`)) {
90+
res.writeHead(404)
91+
res.end("Not found")
92+
return
93+
}
94+
95+
const bodyText = await readRequestBody(req)
96+
const body = JSON.parse(bodyText) as {
97+
model?: string
98+
thinking?: { type?: string }
99+
messages?: Array<{ role?: string; content?: unknown }>
100+
}
101+
102+
const lastUser = [...(body.messages ?? [])].reverse().find((message) => message.role === "user")
103+
const lastUserMessage =
104+
typeof lastUser?.content === "string" ? lastUser.content : JSON.stringify(lastUser?.content ?? "")
105+
106+
requests.push({
107+
model: body.model,
108+
thinkingType: body.thinking?.type,
109+
lastUserMessage,
110+
})
111+
112+
const forwardHeaders: Record<string, string> = {}
113+
for (const [key, value] of Object.entries(req.headers)) {
114+
if (
115+
key.toLowerCase() !== "host" &&
116+
key.toLowerCase() !== "content-length" &&
117+
typeof value === "string"
118+
) {
119+
forwardHeaders[key] = value
120+
}
121+
}
122+
123+
const upstreamUrl = resolveAllowedUpstreamUrl(baseUrl)
124+
const upstream = await fetch(upstreamUrl, {
125+
method: req.method,
126+
headers: forwardHeaders,
127+
body: bodyText,
128+
})
129+
130+
await pipeFetchResponse(res, upstream)
131+
} catch (error) {
132+
proxyError = error instanceof Error ? error : new Error(String(error))
133+
console.error("Anthropic proxy request failed:", proxyError)
134+
res.writeHead(500)
135+
res.end("Anthropic proxy request failed")
136+
}
137+
})
138+
139+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
140+
const address = server.address()
141+
if (!address || typeof address === "string") {
142+
server.close()
143+
throw new Error("Failed to start Anthropic proxy server")
144+
}
145+
146+
const proxyUrl = `http://127.0.0.1:${address.port}`
147+
148+
try {
149+
const result = await run({ proxyUrl, requests })
150+
if (proxyError) {
151+
throw proxyError
152+
}
153+
return result
154+
} finally {
155+
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
156+
}
157+
}
158+
8159
suite("Claude Opus 4.7 (Anthropic)", function () {
9160
setDefaultSuiteTimeout(this)
10161

@@ -20,43 +171,63 @@ suite("Claude Opus 4.7 (Anthropic)", function () {
20171
})
21172
})
22173

23-
test("Should complete a task end-to-end using claude-opus-4-7 via Anthropic provider", async function () {
24-
const api = globalThis.api
25-
const aimockUrl = process.env.AIMOCK_URL
26-
const isRecord = process.env.AIMOCK_RECORD === "true"
174+
for (const reasoningEnabled of [true, false] as const) {
175+
test(`Should complete a task end-to-end using claude-opus-4-7 via Anthropic provider with reasoning ${
176+
reasoningEnabled ? "enabled" : "disabled"
177+
}`, async function () {
178+
const api = globalThis.api
179+
const aimockUrl = process.env.AIMOCK_URL
180+
const isRecord = process.env.AIMOCK_RECORD === "true"
27181

28-
if (!aimockUrl && !process.env.ANTHROPIC_API_KEY) {
29-
this.skip()
30-
}
182+
if (!aimockUrl && !process.env.ANTHROPIC_API_KEY) {
183+
this.skip()
184+
}
31185

32-
// aimock handles /v1/messages natively and serves Anthropic-format SSE responses.
33-
// In record mode the real x-api-key is forwarded so aimock can proxy to api.anthropic.com.
34-
await api.setConfiguration({
35-
apiProvider: "anthropic" as const,
36-
apiKey: aimockUrl && !isRecord ? "mock-key" : process.env.ANTHROPIC_API_KEY!,
37-
apiModelId: "claude-opus-4-7",
38-
...(aimockUrl && { anthropicBaseUrl: aimockUrl }),
39-
})
186+
const captureBaseUrl = aimockUrl || "https://api.anthropic.com"
187+
await withAnthropicProxy(captureBaseUrl, async ({ proxyUrl, requests }) => {
188+
const promptTag = reasoningEnabled ? "opus47-e2e:reasoning-on" : "opus47-e2e:reasoning-off"
40189

41-
const messages: ClineMessage[] = []
190+
// aimock handles /v1/messages natively and serves Anthropic-format SSE responses.
191+
// In record mode the real x-api-key is forwarded so aimock can proxy to api.anthropic.com.
192+
await api.setConfiguration({
193+
apiProvider: "anthropic" as const,
194+
apiKey: aimockUrl && !isRecord ? "mock-key" : process.env.ANTHROPIC_API_KEY!,
195+
apiModelId: "claude-opus-4-7",
196+
enableReasoningEffort: reasoningEnabled,
197+
anthropicBaseUrl: proxyUrl,
198+
})
42199

43-
api.on(RooCodeEventName.Message, ({ message }) => {
44-
if (message.type === "say" && message.partial === false) {
45-
messages.push(message)
46-
}
47-
})
200+
const messages: ClineMessage[] = []
48201

49-
const taskId = await api.startNewTask({
50-
configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true },
51-
text: "opus47-e2e: what is 2+2? Reply with only the number.",
52-
})
202+
api.on(RooCodeEventName.Message, ({ message }) => {
203+
if (message.type === "say" && message.partial === false) {
204+
messages.push(message)
205+
}
206+
})
53207

54-
await waitUntilCompleted({ api, taskId })
208+
const taskId = await api.startNewTask({
209+
configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true },
210+
text: `${promptTag}: what is 2+2? Reply with only the number.`,
211+
})
55212

56-
const completionMessage = messages.find(
57-
({ say, text }) => (say === "completion_result" || say === "text") && text?.trim() === "4",
58-
)
213+
await waitUntilCompleted({ api, taskId })
59214

60-
assert.ok(completionMessage, "Task should complete with the expected Claude Opus 4.7 response")
61-
})
215+
const firstRequest = requests[0]
216+
assert.ok(firstRequest, "Anthropic provider should issue at least one /v1/messages request")
217+
assert.strictEqual(firstRequest.model, "claude-opus-4-7")
218+
219+
if (reasoningEnabled) {
220+
assert.strictEqual(firstRequest.thinkingType, "adaptive")
221+
} else {
222+
assert.strictEqual(firstRequest.thinkingType, undefined)
223+
}
224+
225+
const completionMessage = messages.find(
226+
({ say, text }) => (say === "completion_result" || say === "text") && text?.trim() === "4",
227+
)
228+
229+
assert.ok(completionMessage, "Task should complete with the expected Claude Opus 4.7 response")
230+
})
231+
})
232+
}
62233
})

packages/types/src/providers/anthropic.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,12 @@ export const anthropicModels = {
100100
outputPrice: 25.0, // $25 per million output tokens
101101
cacheWritesPrice: 6.25, // $6.25 per million tokens
102102
cacheReadsPrice: 0.5, // $0.50 per million tokens
103+
// Keep the hybrid-reasoning capability so Anthropic token-cap handling and
104+
// stored max-token overrides behave the same as before.
103105
supportsReasoningBudget: true,
106+
// Direct Anthropic Opus 4.7 no longer accepts budget-token thinking payloads,
107+
// so the UI should still present a simple on/off toggle on this provider path.
108+
supportsReasoningBinary: true,
104109
supportsTemperature: false,
105110
},
106111
"claude-opus-4-5-20251101": {

src/api/providers/__tests__/anthropic.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,76 @@ describe("AnthropicHandler", () => {
234234
expect(requestOptions?.headers?.["anthropic-beta"]).toContain("prompt-caching-2024-07-31")
235235
expect(requestOptions?.headers?.["anthropic-beta"]).not.toContain("context-1m-2025-08-07")
236236
})
237+
238+
it("should use adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => {
239+
const opus47Handler = new AnthropicHandler({
240+
apiKey: "test-api-key",
241+
apiModelId: "claude-opus-4-7",
242+
enableReasoningEffort: true,
243+
})
244+
245+
const stream = opus47Handler.createMessage(systemPrompt, [
246+
{
247+
role: "user",
248+
content: [{ type: "text" as const, text: "Hello" }],
249+
},
250+
])
251+
252+
for await (const _chunk of stream) {
253+
// Consume stream
254+
}
255+
256+
const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
257+
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
258+
expect(requestBody?.max_tokens).toBe(16384)
259+
})
260+
261+
it("should omit thinking for Claude Opus 4.7 when reasoning is disabled", async () => {
262+
const opus47Handler = new AnthropicHandler({
263+
apiKey: "test-api-key",
264+
apiModelId: "claude-opus-4-7",
265+
enableReasoningEffort: false,
266+
})
267+
268+
const stream = opus47Handler.createMessage(systemPrompt, [
269+
{
270+
role: "user",
271+
content: [{ type: "text" as const, text: "Hello" }],
272+
},
273+
])
274+
275+
for await (const _chunk of stream) {
276+
// Consume stream
277+
}
278+
279+
const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
280+
expect(requestBody?.thinking).toBeUndefined()
281+
expect(requestBody?.max_tokens).toBe(8192)
282+
})
283+
284+
it("should preserve custom maxTokens for Claude Opus 4.7 when reasoning is enabled", async () => {
285+
const opus47Handler = new AnthropicHandler({
286+
apiKey: "test-api-key",
287+
apiModelId: "claude-opus-4-7",
288+
enableReasoningEffort: true,
289+
modelMaxTokens: 32768,
290+
})
291+
292+
const stream = opus47Handler.createMessage(systemPrompt, [
293+
{
294+
role: "user",
295+
content: [{ type: "text" as const, text: "Hello" }],
296+
},
297+
])
298+
299+
for await (const _chunk of stream) {
300+
// Consume stream
301+
}
302+
303+
const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
304+
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
305+
expect(requestBody?.max_tokens).toBe(32768)
306+
})
237307
})
238308

239309
describe("completePrompt", () => {
@@ -354,8 +424,11 @@ describe("AnthropicHandler", () => {
354424
expect(model.id).toBe("claude-opus-4-7")
355425
expect(model.info.maxTokens).toBe(128000)
356426
expect(model.info.contextWindow).toBe(1000000)
427+
expect(model.maxTokens).toBe(8192)
428+
expect(model.info.supportsReasoningBinary).toBe(true)
357429
expect(model.info.supportsReasoningBudget).toBe(true)
358430
expect(model.info.supportsPromptCache).toBe(true)
431+
expect(model.reasoningBudget).toBeUndefined()
359432
})
360433

361434
it("should enable 1M context for Claude 4.5 Sonnet when beta flag is set", () => {

0 commit comments

Comments
 (0)