Skip to content

Commit 4328a73

Browse files
committed
feat: support claude web_search
1 parent 772fa69 commit 4328a73

File tree

6 files changed

+400
-39
lines changed

6 files changed

+400
-39
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ GEMINI.md
3030
.vscode/*
3131
.claude/*
3232
.serena/*
33+
34+
/refs/*
35+
.DS_Store

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/router-for-me/CLIProxyAPI/v6
33
go 1.24.0
44

55
require (
6+
github.com/andybalholm/brotli v1.0.6
67
github.com/fsnotify/fsnotify v1.9.0
78
github.com/gin-gonic/gin v1.10.1
89
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
@@ -28,7 +29,6 @@ require (
2829
cloud.google.com/go/compute/metadata v0.3.0 // indirect
2930
github.com/Microsoft/go-winio v0.6.2 // indirect
3031
github.com/ProtonMail/go-crypto v1.3.0 // indirect
31-
github.com/andybalholm/brotli v1.0.6 // indirect
3232
github.com/bytedance/sonic v1.11.6 // indirect
3333
github.com/bytedance/sonic/loader v0.1.1 // indirect
3434
github.com/cloudflare/circl v1.6.1 // indirect

internal/api/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl
662662
// Returns:
663663
// - error: An error if the server fails to start
664664
func (s *Server) Start() error {
665-
log.Debugf("Starting API server on %s", s.server.Addr)
665+
log.Infof("Starting API server on %s", s.server.Addr)
666666

667667
// Start the HTTP server.
668668
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {

internal/runtime/executor/openai_compat_executor.go

Lines changed: 97 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,16 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
5858
}
5959
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated)
6060

61-
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
61+
// Check if this is a web search request (has special marker we added in translator)
62+
isWebSearch := isWebSearchRequest(translated)
63+
64+
var url string
65+
if isWebSearch {
66+
url = strings.TrimSuffix(baseURL, "/") + "/chat/retrieve"
67+
} else {
68+
url = strings.TrimSuffix(baseURL, "/") + "/chat/completions"
69+
}
70+
6271
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
6372
if err != nil {
6473
return resp, err
@@ -116,12 +125,23 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
116125
return resp, err
117126
}
118127
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
128+
129+
// Handle web search responses differently from standard OpenAI responses
130+
var out string
123131
var param any
124-
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, &param)
132+
if isWebSearch {
133+
// For web search responses, we need to format them properly for Claude
134+
// The /chat/retrieve endpoint returns a different format than OpenAI
135+
out = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, &param)
136+
} else {
137+
// Standard OpenAI response handling
138+
reporter.publish(ctx, parseOpenAIUsage(body))
139+
// Ensure we at least record the request even if upstream doesn't return usage
140+
reporter.ensurePublished(ctx)
141+
// Translate response back to source format when needed
142+
out = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, &param)
143+
}
144+
125145
resp = cliproxyexecutor.Response{Payload: []byte(out)}
126146
return resp, nil
127147
}
@@ -143,7 +163,16 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
143163
}
144164
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated)
145165

146-
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
166+
// Check if this is a web search request (has special marker we added in translator)
167+
isWebSearch := isWebSearchRequest(translated)
168+
169+
var url string
170+
if isWebSearch {
171+
url = strings.TrimSuffix(baseURL, "/") + "/chat/retrieve"
172+
} else {
173+
url = strings.TrimSuffix(baseURL, "/") + "/chat/completions"
174+
}
175+
147176
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
148177
if err != nil {
149178
return nil, err
@@ -158,8 +187,12 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
158187
attrs = auth.Attributes
159188
}
160189
util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
161-
httpReq.Header.Set("Accept", "text/event-stream")
162-
httpReq.Header.Set("Cache-Control", "no-cache")
190+
191+
// For web search, we don't want stream headers as it returns a complete response
192+
if !isWebSearch {
193+
httpReq.Header.Set("Accept", "text/event-stream")
194+
httpReq.Header.Set("Cache-Control", "no-cache")
195+
}
163196
var authID, authLabel, authType, authValue string
164197
if auth != nil {
165198
authID = auth.ID
@@ -195,6 +228,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
195228
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
196229
return nil, err
197230
}
231+
198232
out := make(chan cliproxyexecutor.StreamChunk)
199233
stream = out
200234
go func() {
@@ -204,33 +238,57 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
204238
log.Errorf("openai compat executor: close response body error: %v", errClose)
205239
}
206240
}()
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
241+
242+
// For web search requests, the response is a single JSON rather than an SSE stream
243+
if isWebSearch {
244+
// Read the complete response body at once, since /chat/retrieve returns complete JSON
245+
body, err := io.ReadAll(httpResp.Body)
246+
if err != nil {
247+
recordAPIResponseError(ctx, e.cfg, err)
248+
reporter.publishFailure(ctx)
249+
out <- cliproxyexecutor.StreamChunk{Err: err}
250+
return
219251
}
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)
252+
253+
appendAPIResponseChunk(ctx, e.cfg, body)
254+
255+
// Translate the single web search response
256+
// The response translator should handle web search response format
257+
var param any
258+
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, &param)
223259
for i := range chunks {
224260
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
225261
}
262+
} else {
263+
// For regular OpenAI-compatible streaming responses
264+
scanner := bufio.NewScanner(httpResp.Body)
265+
buf := make([]byte, 20_971_520)
266+
scanner.Buffer(buf, 20_971_520)
267+
var param any
268+
for scanner.Scan() {
269+
line := scanner.Bytes()
270+
appendAPIResponseChunk(ctx, e.cfg, line)
271+
if detail, ok := parseOpenAIStreamUsage(line); ok {
272+
reporter.publish(ctx, detail)
273+
}
274+
if len(line) == 0 {
275+
continue
276+
}
277+
// OpenAI-compatible streams are SSE: lines typically prefixed with "data: ".
278+
// Pass through translator; it yields one or more chunks for the target schema.
279+
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, bytes.Clone(line), &param)
280+
for i := range chunks {
281+
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
282+
}
283+
}
284+
if errScan := scanner.Err(); errScan != nil {
285+
recordAPIResponseError(ctx, e.cfg, errScan)
286+
reporter.publishFailure(ctx)
287+
out <- cliproxyexecutor.StreamChunk{Err: errScan}
288+
}
289+
// Ensure we record the request if no usage chunk was ever seen
290+
reporter.ensurePublished(ctx)
226291
}
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)
234292
}()
235293
return stream, nil
236294
}
@@ -352,3 +410,11 @@ func (e statusErr) Error() string {
352410
return fmt.Sprintf("status %d", e.code)
353411
}
354412
func (e statusErr) StatusCode() int { return e.code }
413+
414+
// isWebSearchRequest checks if the translated request is a web search request
415+
// by looking for the special marker we add in ConvertClaudeRequestToOpenAI
416+
func isWebSearchRequest(translated []byte) bool {
417+
// Check if the translated request has the web search marker
418+
// This looks for the "_web_search_request":true field we add
419+
return bytes.Contains(translated, []byte("\"_web_search_request\":true"))
420+
}

0 commit comments

Comments
 (0)