Skip to content
Merged
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
16 changes: 15 additions & 1 deletion apps/vscode-e2e/fixtures/claude-opus-4-7.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@
"fixtures": [
{
"match": {
"userMessage": "opus47-e2e: what is 2+2? Reply with only the number."
"userMessage": "opus47-e2e:reasoning-on: what is 2+2? Reply with only the number."
},
"response": {
"toolCalls": [
{
"name": "attempt_completion",
"arguments": "{\"result\": \"4\"}",
"id": "toolu_014MmgmKQV9c2DmffmF8bKm3"
}
]
}
},
{
"match": {
"userMessage": "opus47-e2e:reasoning-off: what is 2+2? Reply with only the number."
},
"response": {
"toolCalls": [
Expand Down
233 changes: 202 additions & 31 deletions apps/vscode-e2e/src/suite/anthropic-opus-4-7.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,161 @@
import * as assert from "assert"
import { createServer, type IncomingMessage, type ServerResponse } from "http"

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

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

type CapturedAnthropicRequest = {
model?: string
thinkingType?: string
lastUserMessage: string
}

const ALLOWED_PROXY_HOSTS = new Set(["127.0.0.1", "localhost", "api.anthropic.com"])
const ANTHROPIC_MESSAGES_PATH = "/v1/messages"

function isMessagesUrl(rawUrl: string): boolean {
try {
return new URL(rawUrl).pathname.endsWith(ANTHROPIC_MESSAGES_PATH)
} catch {
return false
}
}

function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
req.on("error", reject)
})
}

function writeResponseHeaders(target: ServerResponse, source: Response) {
const headers: Record<string, string> = {}
source.headers.forEach((value, key) => {
if (key.toLowerCase() !== "content-length") {
headers[key] = value
}
})
target.writeHead(source.status, headers)
}

async function pipeFetchResponse(target: ServerResponse, source: Response) {
writeResponseHeaders(target, source)

if (!source.body) {
target.end()
return
}

const reader = source.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
target.write(value)
}

target.end()
}

function resolveAllowedUpstreamUrl(baseUrl: string): URL {
const upstreamBase = new URL(baseUrl)
const isLocalProxy = upstreamBase.hostname === "127.0.0.1" || upstreamBase.hostname === "localhost"

if (
!ALLOWED_PROXY_HOSTS.has(upstreamBase.hostname) ||
(isLocalProxy ? upstreamBase.protocol !== "http:" : baseUrl !== "https://api.anthropic.com")
) {
throw new Error(`Unexpected Anthropic proxy target: ${upstreamBase.origin}`)
}

return new URL(ANTHROPIC_MESSAGES_PATH, upstreamBase)
}

async function withAnthropicProxy<T>(
baseUrl: string,
run: (args: { proxyUrl: string; requests: CapturedAnthropicRequest[] }) => Promise<T>,
): Promise<T> {
const requests: CapturedAnthropicRequest[] = []
let proxyError: Error | undefined
const server = createServer(async (req, res) => {
try {
const requestUrl = req.url ?? "/"

if (!isMessagesUrl(`http://127.0.0.1${requestUrl}`)) {
res.writeHead(404)
res.end("Not found")
return
}

const bodyText = await readRequestBody(req)
const body = JSON.parse(bodyText) as {
model?: string
thinking?: { type?: string }
messages?: Array<{ role?: string; content?: unknown }>
}

const lastUser = [...(body.messages ?? [])].reverse().find((message) => message.role === "user")
const lastUserMessage =
typeof lastUser?.content === "string" ? lastUser.content : JSON.stringify(lastUser?.content ?? "")

requests.push({
model: body.model,
thinkingType: body.thinking?.type,
lastUserMessage,
})

const forwardHeaders: Record<string, string> = {}
for (const [key, value] of Object.entries(req.headers)) {
if (
key.toLowerCase() !== "host" &&
key.toLowerCase() !== "content-length" &&
typeof value === "string"
) {
forwardHeaders[key] = value
}
}

const upstreamUrl = resolveAllowedUpstreamUrl(baseUrl)
const upstream = await fetch(upstreamUrl, {
method: req.method,
headers: forwardHeaders,
body: bodyText,
})
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

await pipeFetchResponse(res, upstream)
} catch (error) {
proxyError = error instanceof Error ? error : new Error(String(error))
console.error("Anthropic proxy request failed:", proxyError)
res.writeHead(500)
res.end("Anthropic proxy request failed")
}
})

await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
const address = server.address()
if (!address || typeof address === "string") {
server.close()
throw new Error("Failed to start Anthropic proxy server")
}

const proxyUrl = `http://127.0.0.1:${address.port}`

try {
const result = await run({ proxyUrl, requests })
if (proxyError) {
throw proxyError
}
return result
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
}

suite("Claude Opus 4.7 (Anthropic)", function () {
setDefaultSuiteTimeout(this)

Expand All @@ -20,43 +171,63 @@ suite("Claude Opus 4.7 (Anthropic)", function () {
})
})

test("Should complete a task end-to-end using claude-opus-4-7 via Anthropic provider", async function () {
const api = globalThis.api
const aimockUrl = process.env.AIMOCK_URL
const isRecord = process.env.AIMOCK_RECORD === "true"
for (const reasoningEnabled of [true, false] as const) {
test(`Should complete a task end-to-end using claude-opus-4-7 via Anthropic provider with reasoning ${
reasoningEnabled ? "enabled" : "disabled"
}`, async function () {
const api = globalThis.api
const aimockUrl = process.env.AIMOCK_URL
const isRecord = process.env.AIMOCK_RECORD === "true"

if (!aimockUrl && !process.env.ANTHROPIC_API_KEY) {
this.skip()
}
if (!aimockUrl && !process.env.ANTHROPIC_API_KEY) {
this.skip()
}

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

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

api.on(RooCodeEventName.Message, ({ message }) => {
if (message.type === "say" && message.partial === false) {
messages.push(message)
}
})
const messages: ClineMessage[] = []

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

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

const completionMessage = messages.find(
({ say, text }) => (say === "completion_result" || say === "text") && text?.trim() === "4",
)
await waitUntilCompleted({ api, taskId })

assert.ok(completionMessage, "Task should complete with the expected Claude Opus 4.7 response")
})
const firstRequest = requests[0]
assert.ok(firstRequest, "Anthropic provider should issue at least one /v1/messages request")
assert.strictEqual(firstRequest.model, "claude-opus-4-7")

if (reasoningEnabled) {
assert.strictEqual(firstRequest.thinkingType, "adaptive")
} else {
assert.strictEqual(firstRequest.thinkingType, undefined)
}

const completionMessage = messages.find(
({ say, text }) => (say === "completion_result" || say === "text") && text?.trim() === "4",
)

assert.ok(completionMessage, "Task should complete with the expected Claude Opus 4.7 response")
})
})
}
})
5 changes: 5 additions & 0 deletions packages/types/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,12 @@ export const anthropicModels = {
outputPrice: 25.0, // $25 per million output tokens
cacheWritesPrice: 6.25, // $6.25 per million tokens
cacheReadsPrice: 0.5, // $0.50 per million tokens
// Keep the hybrid-reasoning capability so Anthropic token-cap handling and
// stored max-token overrides behave the same as before.
supportsReasoningBudget: true,
// Direct Anthropic Opus 4.7 no longer accepts budget-token thinking payloads,
// so the UI should still present a simple on/off toggle on this provider path.
supportsReasoningBinary: true,
Comment thread
roomote[bot] marked this conversation as resolved.
supportsTemperature: false,
},
"claude-opus-4-5-20251101": {
Expand Down
73 changes: 73 additions & 0 deletions src/api/providers/__tests__/anthropic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,76 @@ describe("AnthropicHandler", () => {
expect(requestOptions?.headers?.["anthropic-beta"]).toContain("prompt-caching-2024-07-31")
expect(requestOptions?.headers?.["anthropic-beta"]).not.toContain("context-1m-2025-08-07")
})

it("should use adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => {
const opus47Handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-7",
enableReasoningEffort: true,
})

const stream = opus47Handler.createMessage(systemPrompt, [
{
role: "user",
content: [{ type: "text" as const, text: "Hello" }],
},
])

for await (const _chunk of stream) {
// Consume stream
}

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

it("should omit thinking for Claude Opus 4.7 when reasoning is disabled", async () => {
const opus47Handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-7",
enableReasoningEffort: false,
})

const stream = opus47Handler.createMessage(systemPrompt, [
{
role: "user",
content: [{ type: "text" as const, text: "Hello" }],
},
])

for await (const _chunk of stream) {
// Consume stream
}

const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
expect(requestBody?.thinking).toBeUndefined()
expect(requestBody?.max_tokens).toBe(8192)
})

it("should preserve custom maxTokens for Claude Opus 4.7 when reasoning is enabled", async () => {
const opus47Handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-7",
enableReasoningEffort: true,
modelMaxTokens: 32768,
})

const stream = opus47Handler.createMessage(systemPrompt, [
{
role: "user",
content: [{ type: "text" as const, text: "Hello" }],
},
])

for await (const _chunk of stream) {
// Consume stream
}

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

describe("completePrompt", () => {
Expand Down Expand Up @@ -354,8 +424,11 @@ describe("AnthropicHandler", () => {
expect(model.id).toBe("claude-opus-4-7")
expect(model.info.maxTokens).toBe(128000)
expect(model.info.contextWindow).toBe(1000000)
expect(model.maxTokens).toBe(8192)
expect(model.info.supportsReasoningBinary).toBe(true)
expect(model.info.supportsReasoningBudget).toBe(true)
expect(model.info.supportsPromptCache).toBe(true)
expect(model.reasoningBudget).toBeUndefined()
})

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