11import * as assert from "assert"
2+ import { createServer , type IncomingMessage , type ServerResponse } from "http"
23
34import { RooCodeEventName , type ClineMessage } from "@roo-code/types"
45
56import { waitUntilCompleted } from "./utils"
67import { 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+
8159suite ( "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} )
0 commit comments