44 "bufio"
55 "bytes"
66 "context"
7+ "encoding/json"
78 "fmt"
89 "io"
910 "net/http"
@@ -58,8 +59,23 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
5859 }
5960 translated = applyPayloadConfigWithRoot (e .cfg , req .Model , to .String (), "" , translated )
6061
61- url := strings .TrimSuffix (baseURL , "/" ) + "/chat/completions"
62- httpReq , err := http .NewRequestWithContext (ctx , http .MethodPost , url , bytes .NewReader (translated ))
62+ // Check if this is a web search request (has special marker we added in translator)
63+ isWebSearch := isWebSearchRequest (translated )
64+
65+ // Store the marker flag but clean the payload before sending
66+ sendPayload := translated
67+ if isWebSearch {
68+ sendPayload = pickWebSearchFields (sendPayload )
69+ }
70+
71+ var url string
72+ if isWebSearch {
73+ url = strings .TrimSuffix (baseURL , "/" ) + "/chat/retrieve"
74+ } else {
75+ url = strings .TrimSuffix (baseURL , "/" ) + "/chat/completions"
76+ }
77+
78+ httpReq , err := http .NewRequestWithContext (ctx , http .MethodPost , url , bytes .NewReader (sendPayload ))
6379 if err != nil {
6480 return resp , err
6581 }
@@ -103,10 +119,11 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
103119 }
104120 }()
105121 recordAPIResponseMetadata (ctx , e .cfg , httpResp .StatusCode , httpResp .Header .Clone ())
122+ log .Debugf ("OpenAICompatExecutor Execute: HTTP Response status: %d, headers: %v" , httpResp .StatusCode , httpResp .Header )
106123 if httpResp .StatusCode < 200 || httpResp .StatusCode >= 300 {
107124 b , _ := io .ReadAll (httpResp .Body )
108125 appendAPIResponseChunk (ctx , e .cfg , b )
109- log .Debugf ("request error, error status: %d, error body: %s" , httpResp .StatusCode , summarizeErrorBody (httpResp .Header .Get ("Content-Type" ), b ))
126+ log .Debugf ("OpenAICompatExecutor Execute: request error, error status: %d, error body: %s" , httpResp .StatusCode , summarizeErrorBody (httpResp .Header .Get ("Content-Type" ), b ))
110127 err = statusErr {code : httpResp .StatusCode , msg : string (b )}
111128 return resp , err
112129 }
@@ -116,12 +133,27 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
116133 return resp , err
117134 }
118135 appendAPIResponseChunk (ctx , e .cfg , body )
119- reporter .publish (ctx , parseOpenAIUsage (body ))
120- // Ensure we at least record the request even if upstream doesn't return usage
121- reporter .ensurePublished (ctx )
122- // Translate response back to source format when needed
136+
137+ // Handle web search responses differently from standard OpenAI responses
138+ var out string
123139 var param any
124- out := sdktranslator .TranslateNonStream (ctx , to , from , req .Model , bytes .Clone (opts .OriginalRequest ), translated , body , & param )
140+ if isWebSearch {
141+ log .Debugf ("OpenAICompatExecutor Execute: Web search response received, request model: %s, raw response: %s" , req .Model , string (body ))
142+ // For web search responses, we need to format them properly for Claude
143+ // The /chat/retrieve endpoint returns a different format than OpenAI
144+ translatedOut := sdktranslator .TranslateNonStream (ctx , to , from , req .Model , bytes .Clone (opts .OriginalRequest ), translated , body , & param )
145+ log .Debugf ("OpenAICompatExecutor Execute: Web search response translated to: %s" , translatedOut )
146+ out = translatedOut
147+ } else {
148+ // Standard OpenAI response handling
149+ reporter .publish (ctx , parseOpenAIUsage (body ))
150+ // Ensure we at least record the request even if upstream doesn't return usage
151+ reporter .ensurePublished (ctx )
152+ // Translate response back to source format when needed
153+ out = sdktranslator .TranslateNonStream (ctx , to , from , req .Model , bytes .Clone (opts .OriginalRequest ), translated , body , & param )
154+ }
155+ log .Debugf ("OpenAICompatExecutor Execute: Response translated to: %s" , out )
156+
125157 resp = cliproxyexecutor.Response {Payload : []byte (out )}
126158 return resp , nil
127159}
@@ -143,8 +175,23 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
143175 }
144176 translated = applyPayloadConfigWithRoot (e .cfg , req .Model , to .String (), "" , translated )
145177
146- url := strings .TrimSuffix (baseURL , "/" ) + "/chat/completions"
147- httpReq , err := http .NewRequestWithContext (ctx , http .MethodPost , url , bytes .NewReader (translated ))
178+ // Check if this is a web search request (has special marker we added in translator)
179+ isWebSearch := isWebSearchRequest (translated )
180+
181+ // Store the marker flag but clean the payload before sending
182+ sendPayload := translated
183+ if isWebSearch {
184+ sendPayload = pickWebSearchFields (sendPayload )
185+ }
186+
187+ var url string
188+ if isWebSearch {
189+ url = strings .TrimSuffix (baseURL , "/" ) + "/chat/retrieve"
190+ } else {
191+ url = strings .TrimSuffix (baseURL , "/" ) + "/chat/completions"
192+ }
193+
194+ httpReq , err := http .NewRequestWithContext (ctx , http .MethodPost , url , bytes .NewReader (sendPayload ))
148195 if err != nil {
149196 return nil , err
150197 }
@@ -158,8 +205,12 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
158205 attrs = auth .Attributes
159206 }
160207 util .ApplyCustomHeadersFromAttrs (httpReq , attrs )
161- httpReq .Header .Set ("Accept" , "text/event-stream" )
162- httpReq .Header .Set ("Cache-Control" , "no-cache" )
208+
209+ // For web search, we don't want stream headers as it returns a complete response
210+ if ! isWebSearch {
211+ httpReq .Header .Set ("Accept" , "text/event-stream" )
212+ httpReq .Header .Set ("Cache-Control" , "no-cache" )
213+ }
163214 var authID , authLabel , authType , authValue string
164215 if auth != nil {
165216 authID = auth .ID
@@ -185,16 +236,18 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
185236 return nil , err
186237 }
187238 recordAPIResponseMetadata (ctx , e .cfg , httpResp .StatusCode , httpResp .Header .Clone ())
239+ log .Debugf ("OpenAICompatExecutor ExecuteStream: HTTP Response status: %d, headers: %v" , httpResp .StatusCode , httpResp .Header )
188240 if httpResp .StatusCode < 200 || httpResp .StatusCode >= 300 {
189241 b , _ := io .ReadAll (httpResp .Body )
190242 appendAPIResponseChunk (ctx , e .cfg , b )
191- log .Debugf ("request error, error status: %d, error body: %s" , httpResp .StatusCode , summarizeErrorBody (httpResp .Header .Get ("Content-Type" ), b ))
243+ log .Debugf ("OpenAICompatExecutor ExecuteStream: request error, error status: %d, error body: %s" , httpResp .StatusCode , summarizeErrorBody (httpResp .Header .Get ("Content-Type" ), b ))
192244 if errClose := httpResp .Body .Close (); errClose != nil {
193245 log .Errorf ("openai compat executor: close response body error: %v" , errClose )
194246 }
195247 err = statusErr {code : httpResp .StatusCode , msg : string (b )}
196248 return nil , err
197249 }
250+
198251 out := make (chan cliproxyexecutor.StreamChunk )
199252 stream = out
200253 go func () {
@@ -204,33 +257,59 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
204257 log .Errorf ("openai compat executor: close response body error: %v" , errClose )
205258 }
206259 }()
207- scanner := bufio .NewScanner (httpResp .Body )
208- buf := make ([]byte , 20_971_520 )
209- scanner .Buffer (buf , 20_971_520 )
210- var param any
211- for scanner .Scan () {
212- line := scanner .Bytes ()
213- appendAPIResponseChunk (ctx , e .cfg , line )
214- if detail , ok := parseOpenAIStreamUsage (line ); ok {
215- reporter .publish (ctx , detail )
216- }
217- if len (line ) == 0 {
218- continue
260+
261+ // For web search requests, the response is a single JSON rather than an SSE stream
262+ if isWebSearch {
263+ // Read the complete response body at once, since /chat/retrieve returns complete JSON
264+ body , err := io .ReadAll (httpResp .Body )
265+ if err != nil {
266+ recordAPIResponseError (ctx , e .cfg , err )
267+ reporter .publishFailure (ctx )
268+ out <- cliproxyexecutor.StreamChunk {Err : err }
269+ return
219270 }
220- // OpenAI-compatible streams are SSE: lines typically prefixed with "data: ".
221- // Pass through translator; it yields one or more chunks for the target schema.
222- chunks := sdktranslator .TranslateStream (ctx , to , from , req .Model , bytes .Clone (opts .OriginalRequest ), translated , bytes .Clone (line ), & param )
271+
272+ log .Debugf ("OpenAICompatExecutor ExecuteStream: Web search response received, raw response: %s" , string (body ))
273+ appendAPIResponseChunk (ctx , e .cfg , body )
274+
275+ // Translate the single web search response to SSE events
276+ // The response translator should handle web search response format and generate SSE events
277+ var param any
278+ chunks := sdktranslator .TranslateStream (ctx , to , from , req .Model , bytes .Clone (opts .OriginalRequest ), translated , body , & param )
223279 for i := range chunks {
280+ log .Debugf ("OpenAICompatExecutor ExecuteStream: Web search SSE event chunk: %s" , chunks [i ])
224281 out <- cliproxyexecutor.StreamChunk {Payload : []byte (chunks [i ])}
225282 }
283+ } else {
284+ // For regular OpenAI-compatible streaming responses
285+ scanner := bufio .NewScanner (httpResp .Body )
286+ buf := make ([]byte , 20_971_520 )
287+ scanner .Buffer (buf , 20_971_520 )
288+ var param any
289+ for scanner .Scan () {
290+ line := scanner .Bytes ()
291+ appendAPIResponseChunk (ctx , e .cfg , line )
292+ if detail , ok := parseOpenAIStreamUsage (line ); ok {
293+ reporter .publish (ctx , detail )
294+ }
295+ if len (line ) == 0 {
296+ continue
297+ }
298+ // OpenAI-compatible streams are SSE: lines typically prefixed with "data: ".
299+ // Pass through translator; it yields one or more chunks for the target schema.
300+ chunks := sdktranslator .TranslateStream (ctx , to , from , req .Model , bytes .Clone (opts .OriginalRequest ), translated , bytes .Clone (line ), & param )
301+ for i := range chunks {
302+ out <- cliproxyexecutor.StreamChunk {Payload : []byte (chunks [i ])}
303+ }
304+ }
305+ if errScan := scanner .Err (); errScan != nil {
306+ recordAPIResponseError (ctx , e .cfg , errScan )
307+ reporter .publishFailure (ctx )
308+ out <- cliproxyexecutor.StreamChunk {Err : errScan }
309+ }
310+ // Ensure we record the request if no usage chunk was ever seen
311+ reporter .ensurePublished (ctx )
226312 }
227- if errScan := scanner .Err (); errScan != nil {
228- recordAPIResponseError (ctx , e .cfg , errScan )
229- reporter .publishFailure (ctx )
230- out <- cliproxyexecutor.StreamChunk {Err : errScan }
231- }
232- // Ensure we record the request if no usage chunk was ever seen
233- reporter .ensurePublished (ctx )
234313 }()
235314 return stream , nil
236315}
@@ -352,3 +431,71 @@ func (e statusErr) Error() string {
352431 return fmt .Sprintf ("status %d" , e .code )
353432}
354433func (e statusErr ) StatusCode () int { return e .code }
434+
435+ // isWebSearchRequest checks if the translated request is a web search request
436+ // by checking if it has exactly one tool that matches /^web_search/ or if it has the special marker
437+ func isWebSearchRequest (translated []byte ) bool {
438+ // First check for the special marker that the translator adds
439+ if bytes .Contains (translated , []byte ("\" _web_search_request\" :true" )) {
440+ return true
441+ }
442+
443+ var req map [string ]interface {}
444+ if err := json .Unmarshal (translated , & req ); err != nil {
445+ return false
446+ }
447+
448+ // Check if tools exist and is an array
449+ tools , ok := req ["tools" ].([]interface {})
450+ if ! ok || len (tools ) != 1 {
451+ return false
452+ }
453+
454+ // Check if the single tool has a type that matches /^web_search/
455+ if tool , ok := tools [0 ].(map [string ]interface {}); ok {
456+ if toolType , ok := tool ["type" ].(string ); ok {
457+ return strings .HasPrefix (toolType , "web_search" )
458+ }
459+ }
460+
461+ return false
462+ }
463+
464+ // pickWebSearchFields extracts only the required fields for /chat/retrieve endpoint
465+ func pickWebSearchFields (payload []byte ) []byte {
466+ var data map [string ]interface {}
467+ if err := json .Unmarshal (payload , & data ); err != nil {
468+ return payload
469+ }
470+
471+ // Create new map with only the 6 required fields for /chat/retrieve
472+ cleaned := make (map [string ]interface {})
473+
474+ // Only extract these specific fields (model is required, enableIntention and enableQueryRewrite should be false)
475+ if model , ok := data ["model" ].(string ); ok {
476+ cleaned ["model" ] = model
477+ }
478+ if phase , ok := data ["phase" ].(string ); ok {
479+ cleaned ["phase" ] = phase
480+ }
481+ if query , ok := data ["query" ].(string ); ok {
482+ cleaned ["query" ] = query
483+ }
484+ if enableIntention , ok := data ["enableIntention" ].(bool ); ok {
485+ cleaned ["enableIntention" ] = enableIntention
486+ }
487+ if appCode , ok := data ["appCode" ].(string ); ok {
488+ cleaned ["appCode" ] = appCode
489+ }
490+ if enableQueryRewrite , ok := data ["enableQueryRewrite" ].(bool ); ok {
491+ cleaned ["enableQueryRewrite" ] = enableQueryRewrite
492+ }
493+
494+ // Re-encode with only the required fields
495+ result , err := json .Marshal (cleaned )
496+ if err != nil {
497+ return payload
498+ }
499+
500+ return result
501+ }
0 commit comments