package openai // claude_bridge.go 将 OpenAI 格式的请求/响应自动转换为 Anthropic Claude Messages API 格式。 // 当 config.Provider == "claude" 时,Client 自动走此桥接层,对上层调用方完全透明。 // // 转换规则: // Request: OpenAI /chat/completions → Claude /v1/messages // Response: Claude /v1/messages → OpenAI /chat/completions 格式 // Stream: Claude SSE (event: content_block_delta / message_delta) → OpenAI SSE 格式 // Auth: Bearer → x-api-key // Tools: OpenAI tools[] → Claude tools[] (input_schema) // // Extended thinking: 顶层 `thinking` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为 // `reasoning_content`(可读前缀 + 内部 JSON 尾缀以保留 signature,供多轮工具续跑;UI 用 openai.DisplayReasoningContent 剥离)。 import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "cyberstrike-ai/internal/config" "go.uber.org/zap" ) // ============================================================ // Claude Request Types // ============================================================ // claudeRequest 表示 Anthropic Messages API 的请求体。 type claudeRequest struct { Model string `json:"model"` MaxTokens int `json:"max_tokens"` System string `json:"system,omitempty"` Messages []claudeMessage `json:"messages"` Tools []claudeTool `json:"tools,omitempty"` Stream bool `json:"stream,omitempty"` Thinking json.RawMessage `json:"thinking,omitempty"` } type claudeMessage struct { Role string `json:"role"` Content claudeMessageContent `json:"content"` } // claudeMessageContent 可以是纯字符串或 content block 数组。 // MarshalJSON / UnmarshalJSON 自动处理两种形式。 type claudeMessageContent struct { Text string // 纯文本形式(简写) Blocks []claudeContentBlock // 多 block 形式(tool_use / tool_result 必须用这种) } func (c claudeMessageContent) MarshalJSON() ([]byte, error) { if len(c.Blocks) > 0 { return json.Marshal(c.Blocks) } return json.Marshal(c.Text) } func (c *claudeMessageContent) UnmarshalJSON(data []byte) error { // 尝试字符串 var s string if err := json.Unmarshal(data, &s); err == nil { c.Text = s return nil } // 尝试数组 return json.Unmarshal(data, &c.Blocks) } type claudeContentBlock struct { Type string `json:"type"` // text block Text string `json:"text,omitempty"` // thinking block (extended thinking) Thinking string `json:"thinking,omitempty"` Signature string `json:"signature,omitempty"` // tool_use block (assistant 返回) ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input json.RawMessage `json:"input,omitempty"` // tool_result block (user 提交) ToolUseID string `json:"tool_use_id,omitempty"` Content string `json:"content,omitempty"` IsError bool `json:"is_error,omitempty"` } type claudeTool struct { Name string `json:"name"` Description string `json:"description,omitempty"` InputSchema map[string]interface{} `json:"input_schema"` } // ============================================================ // Claude Response Types // ============================================================ type claudeResponse struct { ID string `json:"id"` Type string `json:"type"` Role string `json:"role"` Content []claudeContentBlock `json:"content"` Model string `json:"model"` StopReason string `json:"stop_reason"` StopSequence *string `json:"stop_sequence"` Usage *claudeUsage `json:"usage,omitempty"` Error *claudeError `json:"error,omitempty"` } type claudeUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } type claudeError struct { Type string `json:"type"` Message string `json:"message"` } // ============================================================ // Conversion: OpenAI Request → Claude Request // ============================================================ // convertOpenAIToClaude 将任意 OpenAI payload (map 或 struct) 转换为 claudeRequest。 func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) { // 先统一序列化为 JSON,再以 map 反序列化,方便处理各种输入形式 raw, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("claude bridge: marshal payload: %w", err) } var oai map[string]interface{} if err := json.Unmarshal(raw, &oai); err != nil { return nil, fmt.Errorf("claude bridge: unmarshal payload: %w", err) } req := &claudeRequest{} // model if m, ok := oai["model"].(string); ok { req.Model = m } // max_tokens (Claude 必需) if mt, ok := oai["max_tokens"].(float64); ok && mt > 0 { req.MaxTokens = int(mt) } else { req.MaxTokens = 8192 // Claude 默认最大输出(兼容 Haiku/Sonnet/Opus) } // stream if s, ok := oai["stream"].(bool); ok { req.Stream = s } // messages msgs, _ := oai["messages"].([]interface{}) for i := 0; i < len(msgs); i++ { mm, ok := msgs[i].(map[string]interface{}) if !ok { continue } role, _ := mm["role"].(string) content, _ := mm["content"].(string) // system message → 提取到顶级 system 字段 if role == "system" { if req.System != "" { req.System += "\n\n" } req.System += content continue } // tool_calls (assistant 消息中包含工具调用) if role == "assistant" { rc, _ := mm["reasoning_content"].(string) _, thinkingReplay := parseClaudeReasoningAssistantBlocks(rc) var blocks []claudeContentBlock for _, tb := range thinkingReplay { blocks = append(blocks, tb) } if content != "" { blocks = append(blocks, claudeContentBlock{Type: "text", Text: content}) } if tcs, ok := mm["tool_calls"].([]interface{}); ok { for _, tc := range tcs { tcMap, ok := tc.(map[string]interface{}) if !ok { continue } tcID, _ := tcMap["id"].(string) fn, _ := tcMap["function"].(map[string]interface{}) fnName, _ := fn["name"].(string) fnArgs, _ := fn["arguments"] // 防御:缺少 name 或 id 的 tool_call 会被 Claude 拒绝 if strings.TrimSpace(fnName) == "" { fnName = "unknown_function" } if strings.TrimSpace(tcID) == "" { tcID = fmt.Sprintf("call_%d", time.Now().UnixNano()) } var inputRaw json.RawMessage switch v := fnArgs.(type) { case string: inputRaw = json.RawMessage(v) default: inputRaw, _ = json.Marshal(v) } // 防止空字符串/非法 JSON 导致 Marshal 失败 if len(inputRaw) == 0 || !json.Valid(inputRaw) { inputRaw = json.RawMessage("{}") } blocks = append(blocks, claudeContentBlock{ Type: "tool_use", ID: tcID, Name: fnName, Input: inputRaw, }) } } if len(blocks) > 0 { req.Messages = append(req.Messages, claudeMessage{ Role: "assistant", Content: claudeMessageContent{Blocks: blocks}, }) } continue } // tool result (role == "tool" in OpenAI) // Claude 要求同一轮的多个 tool_result 合并为一个 user 消息(多 block), // 否则违反 user/assistant 交替规则。 if role == "tool" { var toolBlocks []claudeContentBlock // 收集当前及后续连续的 tool 消息 for ; i < len(msgs); i++ { tmm, ok := msgs[i].(map[string]interface{}) if !ok { break } tr, _ := tmm["role"].(string) if tr != "tool" { break } tcID, _ := tmm["tool_call_id"].(string) tcContent, _ := tmm["content"].(string) toolBlocks = append(toolBlocks, claudeContentBlock{ Type: "tool_result", ToolUseID: tcID, Content: tcContent, }) } i-- // 外层 for 会 i++,回退一步 req.Messages = append(req.Messages, claudeMessage{ Role: "user", Content: claudeMessageContent{Blocks: toolBlocks}, }) continue } // 普通 user/assistant 消息 req.Messages = append(req.Messages, claudeMessage{ Role: role, Content: claudeMessageContent{Text: content}, }) } // tools if tools, ok := oai["tools"].([]interface{}); ok { for _, t := range tools { tMap, ok := t.(map[string]interface{}) if !ok { continue } fn, ok := tMap["function"].(map[string]interface{}) if !ok { continue } ct := claudeTool{} ct.Name, _ = fn["name"].(string) ct.Description, _ = fn["description"].(string) if params, ok := fn["parameters"].(map[string]interface{}); ok { ct.InputSchema = params } else { ct.InputSchema = map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} } req.Tools = append(req.Tools, ct) } } // Extended thinking (Anthropic top-level); merged from Eino ExtraFields / admin extras. if th, ok := oai["thinking"]; ok && th != nil { if raw, err := json.Marshal(th); err == nil && len(raw) > 0 && string(raw) != "null" { req.Thinking = json.RawMessage(raw) } } return req, nil } // ============================================================ // Conversion: Claude Response → OpenAI Response (non-streaming) // ============================================================ // claudeToOpenAIResponseJSON 将 Claude 响应 JSON 转为 OpenAI 兼容的 JSON。 func claudeToOpenAIResponseJSON(claudeBody []byte) ([]byte, error) { var cr claudeResponse if err := json.Unmarshal(claudeBody, &cr); err != nil { return nil, fmt.Errorf("claude bridge: unmarshal response: %w", err) } if cr.Error != nil { return nil, fmt.Errorf("claude api error: [%s] %s", cr.Error.Type, cr.Error.Message) } // 构建 OpenAI 格式的 response oaiResp := map[string]interface{}{ "id": cr.ID, "object": "chat.completion", "model": cr.Model, "choices": []interface{}{}, } var textContent string var toolCalls []interface{} var thinkingBlocks []claudeContentBlock for _, block := range cr.Content { switch block.Type { case "thinking": thinkingBlocks = append(thinkingBlocks, block) case "text": textContent += block.Text case "tool_use": argsStr := string(block.Input) toolCalls = append(toolCalls, map[string]interface{}{ "id": block.ID, "type": "function", "function": map[string]interface{}{ "name": block.Name, "arguments": argsStr, }, }) } } finishReason := claudeStopReasonToOpenAI(cr.StopReason) message := map[string]interface{}{ "role": "assistant", "content": textContent, } if len(toolCalls) > 0 { message["tool_calls"] = toolCalls } if len(thinkingBlocks) > 0 { var parts []string for _, tb := range thinkingBlocks { if strings.TrimSpace(tb.Thinking) != "" { parts = append(parts, tb.Thinking) } } rc := appendClaudeReasoningRoundTrip(strings.Join(parts, "\n\n"), thinkingBlocks) if rc != "" { message["reasoning_content"] = rc } } choice := map[string]interface{}{ "index": 0, "message": message, "finish_reason": finishReason, } oaiResp["choices"] = []interface{}{choice} if cr.Usage != nil { oaiResp["usage"] = map[string]interface{}{ "prompt_tokens": cr.Usage.InputTokens, "completion_tokens": cr.Usage.OutputTokens, "total_tokens": cr.Usage.InputTokens + cr.Usage.OutputTokens, } } return json.Marshal(oaiResp) } func claudeStopReasonToOpenAI(reason string) string { switch reason { case "end_turn": return "stop" case "tool_use": return "tool_calls" case "max_tokens": return "length" case "stop_sequence": return "stop" default: return "stop" } } // ============================================================ // Claude HTTP Calls (non-streaming & streaming) // ============================================================ // claudeChatCompletion 执行非流式 Claude API 调用,返回转换后的 OpenAI 格式 JSON。 func (c *Client) claudeChatCompletion(ctx context.Context, payload interface{}, out interface{}) error { claudeReq, err := convertOpenAIToClaude(payload) if err != nil { return err } claudeReq.Stream = false body, err := json.Marshal(claudeReq) if err != nil { return fmt.Errorf("claude bridge: marshal: %w", err) } baseURL := strings.TrimSuffix(c.config.BaseURL, "/") if baseURL == "" { baseURL = "https://api.anthropic.com" } c.logger.Debug("sending Claude chat completion request", zap.String("model", claudeReq.Model), zap.Int("payloadSizeKB", len(body)/1024)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/messages", bytes.NewReader(body)) if err != nil { return fmt.Errorf("claude bridge: build request: %w", err) } c.setClaudeHeaders(req) requestStart := time.Now() resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("claude bridge: call api: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("claude bridge: read response: %w", err) } c.logger.Debug("received Claude response", zap.Int("status", resp.StatusCode), zap.Duration("duration", time.Since(requestStart)), zap.Int("responseSizeKB", len(respBody)/1024), ) if resp.StatusCode != http.StatusOK { c.logger.Warn("Claude chat completion returned non-200", zap.Int("status", resp.StatusCode), zap.String("body", string(respBody)), ) return &APIError{ StatusCode: resp.StatusCode, Body: string(respBody), } } // 转换为 OpenAI 格式 oaiJSON, err := claudeToOpenAIResponseJSON(respBody) if err != nil { return err } if out != nil { if err := json.Unmarshal(oaiJSON, out); err != nil { return fmt.Errorf("claude bridge: unmarshal converted response: %w", err) } } return nil } // claudeChatCompletionStream 流式调用 Claude API,将 Claude SSE 转换为 OpenAI 兼容的 delta 回调。 func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interface{}, onDelta func(delta string) error) (string, error) { claudeReq, err := convertOpenAIToClaude(payload) if err != nil { return "", err } claudeReq.Stream = true body, err := json.Marshal(claudeReq) if err != nil { return "", fmt.Errorf("claude bridge: marshal: %w", err) } baseURL := strings.TrimSuffix(c.config.BaseURL, "/") if baseURL == "" { baseURL = "https://api.anthropic.com" } req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/messages", bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("claude bridge: build request: %w", err) } c.setClaudeHeaders(req) requestStart := time.Now() resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("claude bridge: call api: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { return "", fmt.Errorf("claude bridge: read error response: %w", readErr) } return "", &APIError{ StatusCode: resp.StatusCode, Body: string(respBody), } } reader := bufio.NewReader(resp.Body) var full strings.Builder fullText := "" for { line, readErr := reader.ReadString('\n') if readErr != nil { if readErr == io.EOF { break } return full.String(), fmt.Errorf("claude bridge: read stream: %w", readErr) } trimmed := strings.TrimSpace(line) if trimmed == "" || !strings.HasPrefix(trimmed, "data:") { continue } dataStr := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) if dataStr == "[DONE]" { break } var event map[string]interface{} if err := json.Unmarshal([]byte(dataStr), &event); err != nil { continue } eventType, _ := event["type"].(string) switch eventType { case "content_block_delta": delta, _ := event["delta"].(map[string]interface{}) deltaType, _ := delta["type"].(string) if deltaType == "text_delta" { text, _ := delta["text"].(string) if text != "" { var textOut string fullText, textOut = normalizeStreamingDelta(fullText, text) if textOut == "" { continue } full.WriteString(textOut) if onDelta != nil { if err := onDelta(textOut); err != nil { return full.String(), err } } } } case "error": errData, _ := event["error"].(map[string]interface{}) msg, _ := errData["message"].(string) return full.String(), fmt.Errorf("claude stream error: %s", msg) } } c.logger.Debug("received Claude stream completion", zap.Duration("duration", time.Since(requestStart)), zap.Int("contentLen", full.Len()), ) return full.String(), nil } // claudeChatCompletionStreamWithToolCalls 流式调用 Claude API,同时处理 content delta 和 tool_calls, // 返回值与 OpenAI 版本完全一致:(content, toolCalls, finishReason, error)。 func (c *Client) claudeChatCompletionStreamWithToolCalls( ctx context.Context, payload interface{}, onContentDelta func(delta string) error, ) (string, []StreamToolCall, string, error) { claudeReq, err := convertOpenAIToClaude(payload) if err != nil { return "", nil, "", err } claudeReq.Stream = true body, err := json.Marshal(claudeReq) if err != nil { return "", nil, "", fmt.Errorf("claude bridge: marshal: %w", err) } baseURL := strings.TrimSuffix(c.config.BaseURL, "/") if baseURL == "" { baseURL = "https://api.anthropic.com" } req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/messages", bytes.NewReader(body)) if err != nil { return "", nil, "", fmt.Errorf("claude bridge: build request: %w", err) } c.setClaudeHeaders(req) requestStart := time.Now() resp, err := c.httpClient.Do(req) if err != nil { return "", nil, "", fmt.Errorf("claude bridge: call api: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { return "", nil, "", fmt.Errorf("claude bridge: read error response: %w", readErr) } return "", nil, "", &APIError{ StatusCode: resp.StatusCode, Body: string(respBody), } } reader := bufio.NewReader(resp.Body) var full strings.Builder fullText := "" finishReason := "" // 追踪当前正在构建的 content blocks type toolAccum struct { id string name string args strings.Builder index int } var currentToolCalls []toolAccum currentBlockIndex := -1 currentBlockType := "" for { line, readErr := reader.ReadString('\n') if readErr != nil { if readErr == io.EOF { break } return full.String(), nil, finishReason, fmt.Errorf("claude bridge: read stream: %w", readErr) } trimmed := strings.TrimSpace(line) if trimmed == "" || !strings.HasPrefix(trimmed, "data:") { continue } dataStr := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) if dataStr == "[DONE]" { break } var event map[string]interface{} if err := json.Unmarshal([]byte(dataStr), &event); err != nil { continue } eventType, _ := event["type"].(string) switch eventType { case "content_block_start": idx, _ := event["index"].(float64) currentBlockIndex = int(idx) cb, _ := event["content_block"].(map[string]interface{}) blockType, _ := cb["type"].(string) currentBlockType = blockType if blockType == "tool_use" { id, _ := cb["id"].(string) name, _ := cb["name"].(string) currentToolCalls = append(currentToolCalls, toolAccum{ id: id, name: name, index: currentBlockIndex, }) } case "content_block_delta": delta, _ := event["delta"].(map[string]interface{}) deltaType, _ := delta["type"].(string) if deltaType == "text_delta" { text, _ := delta["text"].(string) if text != "" { var textOut string fullText, textOut = normalizeStreamingDelta(fullText, text) if textOut == "" { continue } full.WriteString(textOut) if onContentDelta != nil { if err := onContentDelta(textOut); err != nil { return full.String(), nil, finishReason, err } } } } else if deltaType == "input_json_delta" { partialJSON, _ := delta["partial_json"].(string) if partialJSON != "" && currentBlockType == "tool_use" && len(currentToolCalls) > 0 { currentToolCalls[len(currentToolCalls)-1].args.WriteString(partialJSON) } } case "content_block_stop": // block 完成,不需要特殊处理 case "message_delta": delta, _ := event["delta"].(map[string]interface{}) if sr, ok := delta["stop_reason"].(string); ok { finishReason = claudeStopReasonToOpenAI(sr) } case "message_stop": // 消息完成 case "error": errData, _ := event["error"].(map[string]interface{}) msg, _ := errData["message"].(string) return full.String(), nil, finishReason, fmt.Errorf("claude stream error: %s", msg) } } // 转换 tool calls 为 OpenAI 格式的 StreamToolCall var toolCalls []StreamToolCall for i, tc := range currentToolCalls { toolCalls = append(toolCalls, StreamToolCall{ Index: i, ID: tc.id, Type: "function", FunctionName: tc.name, FunctionArgsStr: tc.args.String(), }) } if finishReason == "" { finishReason = "stop" } c.logger.Debug("received Claude stream completion (tool_calls)", zap.Duration("duration", time.Since(requestStart)), zap.Int("contentLen", full.Len()), zap.Int("toolCalls", len(toolCalls)), zap.String("finishReason", finishReason), ) return full.String(), toolCalls, finishReason, nil } // ============================================================ // Helpers // ============================================================ // setClaudeHeaders 设置 Anthropic API 要求的请求头。 func (c *Client) setClaudeHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", c.config.APIKey) req.Header.Set("anthropic-version", "2023-06-01") } // isClaude 判断当前配置是否为 Claude provider。 func (c *Client) isClaude() bool { return isClaudeProvider(c.config) } func isClaudeProvider(cfg *config.OpenAIConfig) bool { if cfg == nil { return false } return strings.EqualFold(strings.TrimSpace(cfg.Provider), "claude") || strings.EqualFold(strings.TrimSpace(cfg.Provider), "anthropic") } // ============================================================ // Eino HTTP Client Bridge // ============================================================ // NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含两层 transport 包装: // 1. 当 cfg.Provider 为 claude 时,最内层套 claudeRoundTripper,把 OpenAI /chat/completions 透明 // 桥接为 Anthropic /v1/messages(并把 Claude SSE 翻译回 OpenAI SSE 格式)。 // 2. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行 // (": keepalive" / "event: ping" / "retry: 3000" 等),避免 Eino 用的 meguminnnnnnnnn/go-openai // SDK 在累计超过 300 个非 "data:" 行后抛 "stream has sent too many empty messages"。 // // 两层都对调用方完全透明:普通 JSON 响应原样透传,仅当响应 Content-Type 为 text/event-stream 时 // sanitizer 才会接管 body;data: payload (含 [DONE]、{"error":...}) 一字节不改。 func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client { if base == nil { base = http.DefaultClient } cloned := *base transport := base.Transport if transport == nil { transport = http.DefaultTransport } if isClaudeProvider(cfg) { transport = &claudeRoundTripper{ base: transport, config: cfg, } } transport = &einoSSESanitizingRoundTripper{base: transport} cloned.Transport = transport return &cloned } // claudeRoundTripper 是一个 http.RoundTripper,用于将 OpenAI 协议透明桥接到 Claude API。 type claudeRoundTripper struct { base http.RoundTripper config *config.OpenAIConfig } func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { // 只拦截 chat completions if !strings.HasSuffix(req.URL.Path, "/chat/completions") { return rt.base.RoundTrip(req) } // 读取原请求体 body, err := io.ReadAll(req.Body) if err != nil { return nil, fmt.Errorf("claude bridge: read request body: %w", err) } _ = req.Body.Close() var payload interface{} if err := json.Unmarshal(body, &payload); err != nil { return nil, fmt.Errorf("claude bridge: unmarshal request: %w", err) } // 转换为 Claude 请求 claudeReq, err := convertOpenAIToClaude(payload) if err != nil { return nil, err } // 构造 Claude 请求 baseURL := strings.TrimSuffix(rt.config.BaseURL, "/") if baseURL == "" { baseURL = "https://api.anthropic.com" } claudeBody, err := json.Marshal(claudeReq) if err != nil { return nil, fmt.Errorf("claude bridge: marshal claude request: %w", err) } newReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, baseURL+"/v1/messages", bytes.NewReader(claudeBody)) if err != nil { return nil, fmt.Errorf("claude bridge: build request: %w", err) } newReq.Header.Set("Content-Type", "application/json") newReq.Header.Set("x-api-key", rt.config.APIKey) newReq.Header.Set("anthropic-version", "2023-06-01") resp, err := rt.base.RoundTrip(newReq) if err != nil { return nil, err } // 非 200:尝试把 Claude 错误格式转成 OpenAI 错误格式,便于 Eino 解析 if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { resp.Body.Close() return nil, fmt.Errorf("claude bridge: read error response: %w", readErr) } resp.Body.Close() converted := rt.tryConvertClaudeErrorToOpenAI(bodyBytes) return &http.Response{ StatusCode: resp.StatusCode, Header: resp.Header.Clone(), Body: io.NopCloser(bytes.NewReader(converted)), ContentLength: int64(len(converted)), Request: req, }, nil } // 非流式:一次性转换响应体 if !claudeReq.Stream { respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { resp.Body.Close() return nil, fmt.Errorf("claude bridge: read response: %w", readErr) } resp.Body.Close() oaiJSON, err := claudeToOpenAIResponseJSON(respBody) if err != nil { return nil, err } return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(oaiJSON)), ContentLength: int64(len(oaiJSON)), Request: req, }, nil } // 流式:通过 pipe 实时转换 SSE pr, pw := io.Pipe() // writeLine 将数据写入 pipe,返回 false 表示 pipe 已关闭(消费端断开),应立即退出。 writeLine := func(data string) bool { _, err := pw.Write([]byte(data)) return err == nil } go func() { defer resp.Body.Close() reader := bufio.NewReader(resp.Body) blockToToolIndex := make(map[int]int) blockIndexToType := make(map[int]string) nextToolIndex := 0 type thinkingAcc struct { text strings.Builder sig strings.Builder } thinkingByIndex := make(map[int]*thinkingAcc) var finishedThinking []claudeContentBlock for { line, readErr := reader.ReadString('\n') if readErr != nil { if readErr == io.EOF { writeLine("data: [DONE]\n\n") } else { // 非 EOF 错误:写入错误事件并通知消费端 oaiErr := map[string]interface{}{ "error": map[string]interface{}{ "message": readErr.Error(), "type": "claude_stream_read_error", }, } b, _ := json.Marshal(oaiErr) writeLine("data: " + string(b) + "\n\n") writeLine("data: [DONE]\n\n") } pw.Close() return } trimmed := strings.TrimSpace(line) if trimmed == "" || !strings.HasPrefix(trimmed, "data:") { continue } dataStr := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) if dataStr == "[DONE]" { writeLine("data: [DONE]\n\n") pw.Close() return } var event map[string]interface{} if err := json.Unmarshal([]byte(dataStr), &event); err != nil { continue } eventType, _ := event["type"].(string) switch eventType { case "content_block_start": blockIdxFlt, _ := event["index"].(float64) blockIdx := int(blockIdxFlt) cb, _ := event["content_block"].(map[string]interface{}) bt, _ := cb["type"].(string) blockIndexToType[blockIdx] = bt if bt == "thinking" { thinkingByIndex[blockIdx] = &thinkingAcc{} } if bt == "tool_use" { id, _ := cb["id"].(string) name, _ := cb["name"].(string) blockToToolIndex[blockIdx] = nextToolIndex toolIdx := nextToolIndex nextToolIndex++ oaiChunk := map[string]interface{}{ "choices": []map[string]interface{}{ { "delta": map[string]interface{}{ "tool_calls": []map[string]interface{}{ { "index": toolIdx, "id": id, "type": "function", "function": map[string]interface{}{ "name": name, }, }, }, }, }, }, } b, _ := json.Marshal(oaiChunk) if !writeLine("data: " + string(b) + "\n\n") { pw.Close() return } } case "content_block_delta": blockIdxFlt, _ := event["index"].(float64) blockIdx := int(blockIdxFlt) delta, _ := event["delta"].(map[string]interface{}) dt, _ := delta["type"].(string) if dt == "thinking_delta" { tPart, _ := delta["thinking"].(string) if tPart != "" { if acc := thinkingByIndex[blockIdx]; acc != nil { acc.text.WriteString(tPart) } oaiChunk := map[string]interface{}{ "choices": []map[string]interface{}{ { "delta": map[string]interface{}{ "reasoning_content": tPart, }, }, }, } b, _ := json.Marshal(oaiChunk) if !writeLine("data: " + string(b) + "\n\n") { pw.Close() return } } } else if dt == "signature_delta" { sigPart, _ := delta["signature"].(string) if sigPart != "" { if acc := thinkingByIndex[blockIdx]; acc != nil { acc.sig.WriteString(sigPart) } } } else if dt == "text_delta" { text, _ := delta["text"].(string) oaiChunk := map[string]interface{}{ "choices": []map[string]interface{}{ { "delta": map[string]interface{}{ "content": text, }, }, }, } b, _ := json.Marshal(oaiChunk) if !writeLine("data: " + string(b) + "\n\n") { pw.Close() return } } else if dt == "input_json_delta" { partial, _ := delta["partial_json"].(string) if partial != "" { if toolIdx, ok := blockToToolIndex[blockIdx]; ok { oaiChunk := map[string]interface{}{ "choices": []map[string]interface{}{ { "delta": map[string]interface{}{ "tool_calls": []map[string]interface{}{ { "index": toolIdx, "function": map[string]interface{}{ "arguments": partial, }, }, }, }, }, }, } b, _ := json.Marshal(oaiChunk) if !writeLine("data: " + string(b) + "\n\n") { pw.Close() return } } } } case "content_block_stop": blockIdxFlt, _ := event["index"].(float64) blockIdx := int(blockIdxFlt) bt := blockIndexToType[blockIdx] if bt == "thinking" { if acc := thinkingByIndex[blockIdx]; acc != nil { finishedThinking = append(finishedThinking, claudeContentBlock{ Type: "thinking", Thinking: acc.text.String(), Signature: acc.sig.String(), }) delete(thinkingByIndex, blockIdx) } } case "message_delta": d, _ := event["delta"].(map[string]interface{}) if sr, ok := d["stop_reason"].(string); ok { finishReason := claudeStopReasonToOpenAI(sr) oaiChunk := map[string]interface{}{ "choices": []map[string]interface{}{ { "delta": map[string]interface{}{}, "finish_reason": finishReason, }, }, } b, _ := json.Marshal(oaiChunk) if !writeLine("data: " + string(b) + "\n\n") { pw.Close() return } } case "message_stop": if len(finishedThinking) > 0 { suffix := appendClaudeReasoningRoundTrip("", finishedThinking) if strings.TrimSpace(suffix) != "" { oaiChunk := map[string]interface{}{ "choices": []map[string]interface{}{ { "delta": map[string]interface{}{ "reasoning_content": suffix, }, }, }, } b, _ := json.Marshal(oaiChunk) if !writeLine("data: " + string(b) + "\n\n") { pw.Close() return } } } writeLine("data: [DONE]\n\n") pw.Close() return case "error": errData, _ := event["error"].(map[string]interface{}) msg, _ := errData["message"].(string) oaiChunk := map[string]interface{}{ "error": map[string]interface{}{ "message": msg, "type": "claude_stream_error", }, } b, _ := json.Marshal(oaiChunk) writeLine("data: " + string(b) + "\n\n") writeLine("data: [DONE]\n\n") pw.Close() return } } }() return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{ "Content-Type": []string{"text/event-stream"}, }, Body: pr, Request: req, }, nil } // tryConvertClaudeErrorToOpenAI 尝试把 Claude 错误格式转换为 OpenAI 错误格式 JSON。 func (rt *claudeRoundTripper) tryConvertClaudeErrorToOpenAI(body []byte) []byte { var ce struct { Type string `json:"type"` Error struct { Type string `json:"type"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(body, &ce); err != nil || ce.Error.Message == "" { return body } oaiErr := map[string]interface{}{ "error": map[string]interface{}{ "message": ce.Error.Message, "type": ce.Error.Type, "code": ce.Type, }, } b, _ := json.Marshal(oaiErr) return b }