diff --git a/.gitignore b/.gitignore index ef2d935a..c51d4a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -cli-proxy-api +/cli-proxy-api* *.exe # Configuration @@ -30,3 +30,6 @@ GEMINI.md .vscode/* .claude/* .serena/* + +refs/* +.DS_Store \ No newline at end of file diff --git a/go.mod b/go.mod index 50b04920..c7660c96 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/router-for-me/CLIProxyAPI/v6 go 1.24.0 require ( + github.com/andybalholm/brotli v1.0.6 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-gonic/gin v1.10.1 github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 @@ -28,7 +29,6 @@ require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect - github.com/andybalholm/brotli v1.0.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 42af9240..a904cb71 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -58,8 +59,23 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A } translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated) - url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) + // Check if this is a web search request (has special marker we added in translator) + isWebSearch := isWebSearchRequest(translated) + + // Store the marker flag but clean the payload before sending + sendPayload := translated + if isWebSearch { + sendPayload = pickWebSearchFields(sendPayload) + } + + var url string + if isWebSearch { + url = strings.TrimSuffix(baseURL, "/") + "/chat/retrieve" + } else { + url = strings.TrimSuffix(baseURL, "/") + "/chat/completions" + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(sendPayload)) if err != nil { return resp, err } @@ -103,10 +119,11 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A } }() recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + log.Debugf("OpenAICompatExecutor Execute: HTTP Response status: %d, headers: %v", httpResp.StatusCode, httpResp.Header) if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { b, _ := io.ReadAll(httpResp.Body) appendAPIResponseChunk(ctx, e.cfg, b) - log.Debugf("request error, error status: %d, error body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) + log.Debugf("OpenAICompatExecutor Execute: request error, error status: %d, error body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) err = statusErr{code: httpResp.StatusCode, msg: string(b)} return resp, err } @@ -116,12 +133,27 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A return resp, err } appendAPIResponseChunk(ctx, e.cfg, body) - reporter.publish(ctx, parseOpenAIUsage(body)) - // Ensure we at least record the request even if upstream doesn't return usage - reporter.ensurePublished(ctx) - // Translate response back to source format when needed + + // Handle web search responses differently from standard OpenAI responses + var out string var param any - out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, ¶m) + if isWebSearch { + log.Debugf("OpenAICompatExecutor Execute: Web search response received, request model: %s, raw response: %s", req.Model, string(body)) + // For web search responses, we need to format them properly for Claude + // The /chat/retrieve endpoint returns a different format than OpenAI + translatedOut := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, ¶m) + log.Debugf("OpenAICompatExecutor Execute: Web search response translated to: %s", translatedOut) + out = translatedOut + } else { + // Standard OpenAI response handling + reporter.publish(ctx, parseOpenAIUsage(body)) + // Ensure we at least record the request even if upstream doesn't return usage + reporter.ensurePublished(ctx) + // Translate response back to source format when needed + out = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, ¶m) + } + log.Debugf("OpenAICompatExecutor Execute: Response translated to: %s", out) + resp = cliproxyexecutor.Response{Payload: []byte(out)} return resp, nil } @@ -143,8 +175,23 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy } translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated) - url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) + // Check if this is a web search request (has special marker we added in translator) + isWebSearch := isWebSearchRequest(translated) + + // Store the marker flag but clean the payload before sending + sendPayload := translated + if isWebSearch { + sendPayload = pickWebSearchFields(sendPayload) + } + + var url string + if isWebSearch { + url = strings.TrimSuffix(baseURL, "/") + "/chat/retrieve" + } else { + url = strings.TrimSuffix(baseURL, "/") + "/chat/completions" + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(sendPayload)) if err != nil { return nil, err } @@ -158,8 +205,12 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(httpReq, attrs) - httpReq.Header.Set("Accept", "text/event-stream") - httpReq.Header.Set("Cache-Control", "no-cache") + + // For web search, we don't want stream headers as it returns a complete response + if !isWebSearch { + httpReq.Header.Set("Accept", "text/event-stream") + httpReq.Header.Set("Cache-Control", "no-cache") + } var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -185,16 +236,18 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy return nil, err } recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + log.Debugf("OpenAICompatExecutor ExecuteStream: HTTP Response status: %d, headers: %v", httpResp.StatusCode, httpResp.Header) if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { b, _ := io.ReadAll(httpResp.Body) appendAPIResponseChunk(ctx, e.cfg, b) - log.Debugf("request error, error status: %d, error body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) + log.Debugf("OpenAICompatExecutor ExecuteStream: request error, error status: %d, error body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) if errClose := httpResp.Body.Close(); errClose != nil { log.Errorf("openai compat executor: close response body error: %v", errClose) } err = statusErr{code: httpResp.StatusCode, msg: string(b)} return nil, err } + out := make(chan cliproxyexecutor.StreamChunk) stream = out go func() { @@ -204,32 +257,59 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy log.Errorf("openai compat executor: close response body error: %v", errClose) } }() - scanner := bufio.NewScanner(httpResp.Body) - scanner.Buffer(nil, 20_971_520) - var param any - for scanner.Scan() { - line := scanner.Bytes() - appendAPIResponseChunk(ctx, e.cfg, line) - if detail, ok := parseOpenAIStreamUsage(line); ok { - reporter.publish(ctx, detail) - } - if len(line) == 0 { - continue + + // For web search requests, the response is a single JSON rather than an SSE stream + if isWebSearch { + // Read the complete response body at once, since /chat/retrieve returns complete JSON + body, err := io.ReadAll(httpResp.Body) + if err != nil { + recordAPIResponseError(ctx, e.cfg, err) + reporter.publishFailure(ctx) + out <- cliproxyexecutor.StreamChunk{Err: err} + return } - // OpenAI-compatible streams are SSE: lines typically prefixed with "data: ". - // Pass through translator; it yields one or more chunks for the target schema. - chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, bytes.Clone(line), ¶m) + + log.Debugf("OpenAICompatExecutor ExecuteStream: Web search response received, raw response: %s", string(body)) + appendAPIResponseChunk(ctx, e.cfg, body) + + // Translate the single web search response to SSE events + // The response translator should handle web search response format and generate SSE events + var param any + chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, ¶m) for i := range chunks { + log.Debugf("OpenAICompatExecutor ExecuteStream: Web search SSE event chunk: %s", chunks[i]) out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} } + } else { + // For regular OpenAI-compatible streaming responses + scanner := bufio.NewScanner(httpResp.Body) + buf := make([]byte, 20_971_520) + scanner.Buffer(buf, 20_971_520) + var param any + for scanner.Scan() { + line := scanner.Bytes() + appendAPIResponseChunk(ctx, e.cfg, line) + if detail, ok := parseOpenAIStreamUsage(line); ok { + reporter.publish(ctx, detail) + } + if len(line) == 0 { + continue + } + // OpenAI-compatible streams are SSE: lines typically prefixed with "data: ". + // Pass through translator; it yields one or more chunks for the target schema. + chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, bytes.Clone(line), ¶m) + for i := range chunks { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} + } + } + if errScan := scanner.Err(); errScan != nil { + recordAPIResponseError(ctx, e.cfg, errScan) + reporter.publishFailure(ctx) + out <- cliproxyexecutor.StreamChunk{Err: errScan} + } + // Ensure we record the request if no usage chunk was ever seen + reporter.ensurePublished(ctx) } - if errScan := scanner.Err(); errScan != nil { - recordAPIResponseError(ctx, e.cfg, errScan) - reporter.publishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} - } - // Ensure we record the request if no usage chunk was ever seen - reporter.ensurePublished(ctx) }() return stream, nil } @@ -351,3 +431,71 @@ func (e statusErr) Error() string { return fmt.Sprintf("status %d", e.code) } func (e statusErr) StatusCode() int { return e.code } + +// isWebSearchRequest checks if the translated request is a web search request +// by checking if it has exactly one tool that matches /^web_search/ or if it has the special marker +func isWebSearchRequest(translated []byte) bool { + // First check for the special marker that the translator adds + if bytes.Contains(translated, []byte("\"_web_search_request\":true")) { + return true + } + + var req map[string]interface{} + if err := json.Unmarshal(translated, &req); err != nil { + return false + } + + // Check if tools exist and is an array + tools, ok := req["tools"].([]interface{}) + if !ok || len(tools) != 1 { + return false + } + + // Check if the single tool has a type that matches /^web_search/ + if tool, ok := tools[0].(map[string]interface{}); ok { + if toolType, ok := tool["type"].(string); ok { + return strings.HasPrefix(toolType, "web_search") + } + } + + return false +} + +// pickWebSearchFields extracts only the required fields for /chat/retrieve endpoint +func pickWebSearchFields(payload []byte) []byte { + var data map[string]interface{} + if err := json.Unmarshal(payload, &data); err != nil { + return payload + } + + // Create new map with only the 6 required fields for /chat/retrieve + cleaned := make(map[string]interface{}) + + // Only extract these specific fields (model is required, enableIntention and enableQueryRewrite should be false) + if model, ok := data["model"].(string); ok { + cleaned["model"] = model + } + if phase, ok := data["phase"].(string); ok { + cleaned["phase"] = phase + } + if query, ok := data["query"].(string); ok { + cleaned["query"] = query + } + if enableIntention, ok := data["enableIntention"].(bool); ok { + cleaned["enableIntention"] = enableIntention + } + if appCode, ok := data["appCode"].(string); ok { + cleaned["appCode"] = appCode + } + if enableQueryRewrite, ok := data["enableQueryRewrite"].(bool); ok { + cleaned["enableQueryRewrite"] = enableQueryRewrite + } + + // Re-encode with only the required fields + result, err := json.Marshal(cleaned) + if err != nil { + return payload + } + + return result +} diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index bff306cc..a5746c1f 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -8,6 +8,7 @@ package claude import ( "bytes" "encoding/json" + "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -18,11 +19,24 @@ import ( // from the raw JSON request and returns them in the format expected by the OpenAI API. func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte { rawJSON := bytes.Clone(inputRawJSON) - // Base OpenAI Chat Completions API template - out := `{"model":"","messages":[]}` - root := gjson.ParseBytes(rawJSON) + // Check if this is a web search request first + if isWebSearchRequest(root) { + // For web search requests, return the format needed for the /chat/retrieve endpoint + // Add a special indicator that executors can use to route this differently + webSearchRequest := createWebSearchRequestJSON(root) + // Add a metadata field to indicate this is a special web search request + result := make([]byte, len(webSearchRequest)+30) + copy(result, webSearchRequest[:len(webSearchRequest)-1]) // Copy everything except the closing brace + metadata := `,"_web_search_request":true}` + copy(result[len(webSearchRequest)-1:], metadata) + return result + } + + // Base OpenAI Chat Completions API template for non-web-search requests + out := `{"model":"","messages":[]}` + // Model mapping out, _ = sjson.Set(out, "model", modelName) @@ -286,3 +300,150 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) { return "", false } } + +// isWebSearchRequest checks if the Claude request is for a web search by checking for web search tools. +func isWebSearchRequest(root gjson.Result) bool { + tools := root.Get("tools") + if !tools.Exists() || !tools.IsArray() { + return false + } + + found := false + tools.ForEach(func(_, tool gjson.Result) bool { + if tool.Get("type").String() == "web_search_20250305" { + // Found a web search tool + found = true + return false // stop iteration + } + return true + }) + + return found +} + +// createWebSearchRequestJSON creates the JSON for /chat/retrieve endpoint +func createWebSearchRequestJSON(root gjson.Result) []byte { + query := extractWebSearchQuery(root) + if query == "" { + // Default query if extraction fails + query = "web search" + } + + // Create the JSON structure for the chat/retrieve endpoint (enableIntention and enableQueryRewrite should be false) + webSearchJSON := `{"phase":"UNIFY","query":"","enableIntention":false,"appCode":"COMPLEX_CHATBOT","enableQueryRewrite":false}` + webSearchJSON, _ = sjson.Set(webSearchJSON, "query", query) + + return []byte(webSearchJSON) +} + +// extractWebSearchQuery extracts the search query from Claude messages +func extractWebSearchQuery(root gjson.Result) string { + messages := root.Get("messages") + if !messages.Exists() || !messages.IsArray() { + return "" + } + + query := "" + messages.ForEach(func(_, message gjson.Result) bool { + // Only look for the first user message that might contain the query + role := message.Get("role").String() + if role != "user" { + return true // continue to next message + } + + content := message.Get("content") + if !content.Exists() || !content.IsArray() { + return true + } + + content.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "text" { + text := part.Get("text").String() + // Extract query from message like "Perform a web search for the query: " + if strings.Contains(text, "web search for the query:") { + parts := strings.SplitN(text, "web search for the query:", 2) + if len(parts) > 1 { + query = strings.TrimSpace(parts[1]) + return false // stop iteration + } + } + // Alternative extraction: if the entire text looks like a search query, use it + if query == "" { + // Try to find text after common search phrases + searchPhrases := []string{ + "perform a web search for the query:", + "perform a web search for:", + "web search for the query:", + "web search for:", + "search for the query:", + "search for:", + "query:", + "search query:", + } + for _, phrase := range searchPhrases { + phraseLower := strings.ToLower(phrase) + if idx := strings.Index(strings.ToLower(text), phraseLower); idx >= 0 { + query = strings.TrimSpace(text[idx+len(phrase):]) + // Remove any trailing punctuation that might be part of the instruction + query = strings.TrimRight(query, ".!?") + if query != "" { + return false // stop iteration + } + } + } + + // If still no query found, check if the entire text is a search-like query + if query == "" && (strings.Contains(strings.ToLower(text), "search") || + strings.Contains(strings.ToLower(text), "find") || + strings.Contains(strings.ToLower(text), "what") || + strings.Contains(strings.ToLower(text), "how") || + strings.Contains(strings.ToLower(text), "why") || + strings.Contains(strings.ToLower(text), "when") || + strings.Contains(strings.ToLower(text), "where")) { + trimmed := strings.TrimSpace(text) + if len(trimmed) > 5 { // Basic sanity check + query = trimmed + return false // stop iteration + } + } + } + } + return true + }) + + // Stop after processing user message if a query was found + return query == "" + }) + + // Fallback: if no query found but this is marked as a web search, + // try to use any user message content as the query + if query == "" { + messages.ForEach(func(_, message gjson.Result) bool { + role := message.Get("role").String() + if role != "user" { + return true + } + + content := message.Get("content") + if content.Exists() && content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "text" && query == "" { + text := part.Get("text").String() + if text != "" { + query = strings.TrimSpace(text) + // Limit query length to be reasonable + if len(query) > 200 { + query = query[:200] + } + return false // stop iteration + } + } + return true + }) + } + return query == "" + }) + } + + return query +} diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index dac4c970..4373c0c7 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" @@ -73,7 +74,7 @@ type ToolCallAccumulator struct { // // Returns: // - []string: A slice of strings, each containing an Anthropic-compatible JSON response. -func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string { +func ConvertOpenAIResponseToClaude(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string { if *param == nil { *param = &ConvertOpenAIResponseToAnthropicParams{ MessageID: "", @@ -93,6 +94,38 @@ func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestR } } + // Check if this is a web search response (non-streaming case) + // When handling streaming, the web search response should come as a single chunk + if isWebSearchResponse(rawJSON) || (bytes.HasPrefix(rawJSON, dataTag) && isWebSearchResponse(bytes.TrimSpace(rawJSON[5:]))) { + // For web search responses in streaming context, we need to generate SSE events + // If this is a data-tag prefixed response, process as a streaming chunk + if bytes.HasPrefix(rawJSON, dataTag) { + webSearchRaw := bytes.TrimSpace(rawJSON[5:]) + // Also check the original request was streaming to determine format + streamResult := gjson.GetBytes(originalRequestRawJSON, "stream") + isStream := streamResult.Exists() && streamResult.Type != gjson.False + if isStream { + return convertWebSearchResponseToClaudeSSE(webSearchRaw, modelName, (*param).(*ConvertOpenAIResponseToAnthropicParams)) + } else { + // Non-streaming context, return the complete Claude message + converted := convertWebSearchResponseToClaude(webSearchRaw, modelName) + return []string{converted} + } + } else { + // This is unprefixed web search response - check if original request was streaming + streamResult := gjson.GetBytes(originalRequestRawJSON, "stream") + isStream := streamResult.Exists() && streamResult.Type != gjson.False + if isStream { + // Original request was streaming, convert to SSE events + return convertWebSearchResponseToClaudeSSE(rawJSON, modelName, (*param).(*ConvertOpenAIResponseToAnthropicParams)) + } else { + // Non-streaming context, return the complete Claude message + converted := convertWebSearchResponseToClaude(rawJSON, modelName) + return []string{converted} + } + } + } + if !bytes.HasPrefix(rawJSON, dataTag) { return []string{} } @@ -863,3 +896,196 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina func ClaudeTokenCount(ctx context.Context, count int64) string { return fmt.Sprintf(`{"input_tokens":%d}`, count) } + +// isWebSearchResponse checks if the response is a web search response by looking at its structure +func isWebSearchResponse(rawJSON []byte) bool { + root := gjson.ParseBytes(rawJSON) + + // Check for web search response structure (different from OpenAI format) + if root.Get("query").Exists() && root.Get("status").Exists() && root.Get("results").Exists() { + return true + } + + // Check for data array field which contains the search results + if root.Get("data").Exists() && root.Get("data").IsArray() { + return true + } + + // Check for result message field which contains the final answer + if root.Get("result_message").Exists() { + return true + } + + return false +} + +// extractWebSearchResult extracts the web search result as a JSON string +func extractWebSearchResult(rawJSON []byte) string { + root := gjson.ParseBytes(rawJSON) + + // Try to extract from the data array first (most common format) + if data := root.Get("data"); data.Exists() && data.IsArray() { + // Return the raw JSON string of the data array + return data.String() + } + + // Fallback: try other formats + if resultMessage := root.Get("result_message"); resultMessage.Exists() { + return resultMessage.String() + } + + // Last resort: check if there's a result string + if resultText := root.Get("result"); resultText.Exists() && resultText.Type == gjson.String { + return resultText.String() + } + + // Return the raw JSON as a string if nothing else matches + return string(rawJSON) +} + +// convertWebSearchResponseToClaude converts a web search response to Claude format +func convertWebSearchResponseToClaude(rawJSON []byte, modelName string) string { + resultText := extractWebSearchResult(rawJSON) + + // Build Claude response + response := map[string]interface{}{ + "id": generateMessageID(), + "type": "message", + "role": "assistant", + "model": modelName, + "stop_reason": "end_turn", + "stop_sequence": nil, + "usage": map[string]interface{}{ + "input_tokens": 0, + "output_tokens": 0, + }, + } + + // Create content blocks with the result + contentBlocks := []interface{}{ + map[string]interface{}{ + "type": "text", + "text": resultText, + }, + } + + response["content"] = contentBlocks + + // Marshal to JSON + responseJSON, err := json.Marshal(response) + if err != nil { + return "" + } + return string(responseJSON) +} + +// convertWebSearchResponseToClaudeSSE simulates SSE events for web search responses +// This is necessary because /chat/retrieve returns complete JSON, but Claude Code expects SSE format +func convertWebSearchResponseToClaudeSSE(rawJSON []byte, modelName string, param *ConvertOpenAIResponseToAnthropicParams) []string { + var results []string + + // Generate message ID and model if not set + if param.MessageID == "" { + param.MessageID = generateMessageID() + } + if param.Model == "" { + param.Model = modelName + } + + // Send message_start event + messageStart := map[string]interface{}{ + "type": "message_start", + "message": map[string]interface{}{ + "id": param.MessageID, + "type": "message", + "role": "assistant", + "model": param.Model, + "content": []interface{}{}, + "stop_reason": nil, + "stop_sequence": nil, + "usage": map[string]interface{}{ + "input_tokens": 0, + "output_tokens": 0, + }, + }, + } + messageStartJSON, _ := json.Marshal(messageStart) + results = append(results, "event: message_start\ndata: "+string(messageStartJSON)+"\n\n") + + // Extract the result from web search response + resultText := extractWebSearchResult(rawJSON) + + // Split the result into chunks for streaming simulation + if resultText != "" { + // Start content block + param.TextContentBlockIndex = param.NextContentBlockIndex + param.NextContentBlockIndex++ + + contentBlockStart := map[string]interface{}{ + "type": "content_block_start", + "index": param.TextContentBlockIndex, + "content_block": map[string]interface{}{ + "type": "text", + "text": "", + }, + } + contentBlockStartJSON, _ := json.Marshal(contentBlockStart) + results = append(results, "event: content_block_start\ndata: "+string(contentBlockStartJSON)+"\n\n") + + // Send the result text in chunks (simulate streaming) + chunkSize := 200 // Characters per chunk for simulation + for i := 0; i < len(resultText); i += chunkSize { + end := i + chunkSize + if end > len(resultText) { + end = len(resultText) + } + chunk := resultText[i:end] + + contentDelta := map[string]interface{}{ + "type": "content_block_delta", + "index": param.TextContentBlockIndex, + "delta": map[string]interface{}{ + "type": "text_delta", + "text": chunk, + }, + } + contentDeltaJSON, _ := json.Marshal(contentDelta) + results = append(results, "event: content_block_delta\ndata: "+string(contentDeltaJSON)+"\n\n") + } + + // End content block + contentBlockStop := map[string]interface{}{ + "type": "content_block_stop", + "index": param.TextContentBlockIndex, + } + contentBlockStopJSON, _ := json.Marshal(contentBlockStop) + results = append(results, "event: content_block_stop\ndata: "+string(contentBlockStopJSON)+"\n\n") + } + + // Send message_delta with stop reason + messageDelta := map[string]interface{}{ + "type": "message_delta", + "delta": map[string]interface{}{ + "stop_reason": "end_turn", + "stop_sequence": nil, + }, + "usage": map[string]interface{}{ + "input_tokens": 0, + "output_tokens": 0, + }, + } + messageDeltaJSON, _ := json.Marshal(messageDelta) + results = append(results, "event: message_delta\ndata: "+string(messageDeltaJSON)+"\n\n") + + // Send message_stop + results = append(results, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") + + return results +} + +// generateMessageID generates a unique message ID +func generateMessageID() string { + // Simple ID generation - using timestamp should be sufficient + // In production, you might want to use UUID or better ID generation + return fmt.Sprintf("msg_%d", time.Now().UnixNano())[:18] +}