Skip to content

Commit db6aade

Browse files
committed
feat: implement web search functionality for Claude -> OpenAI
- Added web search detection in request handling - Routes web search requests to /chat/retrieve endpoint - Implemented streaming and non-streaming response conversion - Added SSE event simulation for Claude Code compatibility - Updated dependencies and gitignore Files changed: - internal/runtime/executor/openai_compat_executor.go (+217 lines) - internal/translator/openai/claude/openai_claude_request.go (+167 lines) - internal/translator/openai/claude/openai_claude_response.go (+228 lines) - go.mod (dependency updates) - .gitignore (added /refs/* and .DS_Store)
1 parent 4eab141 commit db6aade

File tree

5 files changed

+578
-41
lines changed

5 files changed

+578
-41
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Binaries
2-
cli-proxy-api
2+
/cli-proxy-api*
33
*.exe
44

55
# Configuration
@@ -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/runtime/executor/openai_compat_executor.go

Lines changed: 182 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
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
}
354433
func (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

Comments
 (0)