package service import ( "encoding/json" "fmt" "strings" "oci-portal/internal/aiwire" ) // Anthropic Messages ↔ OCI OpenAI 兼容面(/actions/v1/responses)直通转换。 // typed chat 面剔除后 Messages 入口的唯一上游通路。语义损失(README 已披露): // stop_sequences / top_k / metadata / thinking 无对应字段,忽略;上游 reasoning // 输出项与增量事件丢弃(Anthropic thinking 块含签名语义,不伪造)。 // ErrAiUnsupportedBlock 表示请求含网关无法承接的内容块(仅支持文本/图片/工具块)。 var ErrAiUnsupportedBlock = fmt.Errorf("暂不支持文本与图片以外的内容块") // AnthropicToResponsesBody 把 Messages 请求转为直通 body(强制 store:false)。 func AnthropicToResponsesBody(req aiwire.MessagesRequest) ([]byte, error) { input, err := anthInputItems(req.Messages) if err != nil { return nil, err } body := map[string]any{"model": req.Model, "max_output_tokens": req.MaxTokens, "input": input, "store": false} if sys := req.SystemText(); sys != "" { body["instructions"] = sys } if req.Temperature != nil { body["temperature"] = *req.Temperature } if req.TopP != nil { body["top_p"] = *req.TopP } if tools := anthRespTools(req.Tools); tools != nil { body["tools"] = tools } if tc := anthRespToolChoice(req.ToolChoice); tc != nil { body["tool_choice"] = tc } if req.OutputConfig != nil && req.OutputConfig.Effort != "" { body["reasoning"] = map[string]string{"effort": strings.ToLower(req.OutputConfig.Effort)} } if req.Stream { body["stream"] = true } return json.Marshal(body) } // anthInputItems 把消息序列展开为 Responses input 项:tool_use / tool_result 为 // 独立 function_call / function_call_output 项,其余聚合为 message 项;遇独立项时 // 先冲刷已聚合部件,保持块间相对顺序。 func anthInputItems(messages []aiwire.AnthMessage) ([]any, error) { var items []any for _, m := range messages { var parts []map[string]any flush := func() { if len(parts) > 0 { items = append(items, map[string]any{"role": m.Role, "content": parts}) parts = nil } } for _, b := range m.Content.AllBlocks() { item, part, err := anthBlockItem(m.Role, b) if err != nil { return nil, err } if part != nil { parts = append(parts, part) } if item != nil { flush() items = append(items, item) } } flush() } return items, nil } // anthBlockItem 把单个内容块转为独立项或消息部件(thinking 忽略,未知块拒绝)。 func anthBlockItem(role string, b aiwire.AnthBlock) (item, part map[string]any, err error) { switch b.Type { case "text": return nil, anthTextPart(role, b.Text), nil case "image": part, err = anthImageInput(b.Source) return nil, part, err case "tool_use": return map[string]any{"type": "function_call", "call_id": b.ID, "name": b.Name, "arguments": string(b.Input)}, nil, nil case "tool_result": return map[string]any{"type": "function_call_output", "call_id": b.ToolUseID, "output": b.ResultText()}, nil, nil case "thinking", "redacted_thinking": return nil, nil, nil default: return nil, nil, ErrAiUnsupportedBlock } } // anthTextPart 按角色选择 Responses 文本部件类型(assistant 历史为 output_text)。 func anthTextPart(role, text string) map[string]any { if role == "assistant" { return map[string]any{"type": "output_text", "text": text} } return map[string]any{"type": "input_text", "text": text} } // anthImageInput 把 Anthropic image source 转为 Responses input_image 部件。 func anthImageInput(source json.RawMessage) (map[string]any, error) { var src struct { Type string `json:"type"` MediaType string `json:"media_type"` Data string `json:"data"` URL string `json:"url"` } if err := json.Unmarshal(source, &src); err != nil { return nil, fmt.Errorf("image source 解析失败: %w", err) } switch src.Type { case "base64": if src.MediaType == "" || src.Data == "" { return nil, fmt.Errorf("image source 缺少 media_type 或 data") } return map[string]any{"type": "input_image", "image_url": "data:" + src.MediaType + ";base64," + src.Data}, nil case "url": if src.URL == "" { return nil, fmt.Errorf("image source 缺少 url") } return map[string]any{"type": "input_image", "image_url": src.URL}, nil } return nil, fmt.Errorf("不支持的 image source 类型 %q", src.Type) } func anthRespTools(tools []aiwire.AnthTool) []map[string]any { if len(tools) == 0 { return nil } out := make([]map[string]any, 0, len(tools)) for _, t := range tools { out = append(out, map[string]any{"type": "function", "name": t.Name, "description": t.Description, "parameters": t.InputSchema}) } return out } // anthRespToolChoice 映射 {type:auto|any|tool|none,name} → Responses 形态。 func anthRespToolChoice(raw json.RawMessage) any { if len(raw) == 0 { return nil } var tc struct { Type string `json:"type"` Name string `json:"name"` } if json.Unmarshal(raw, &tc) != nil { return nil } switch tc.Type { case "auto": return "auto" case "any": return "required" case "none": return "none" case "tool": return map[string]string{"type": "function", "name": tc.Name} } return nil } // respPayload 是直通响应中本转换关心的子集(未知字段忽略)。 type respPayload struct { Model string `json:"model"` Status string `json:"status"` IncompleteDetails *respIncomplete `json:"incomplete_details"` Output []respOutputItem `json:"output"` Usage *aiwire.RespUsage `json:"usage"` Error *map[string]string `json:"error"` } type respIncomplete struct { Reason string `json:"reason"` } type respOutputItem struct { Type string `json:"type"` CallID string `json:"call_id"` Name string `json:"name"` Arguments string `json:"arguments"` Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` } // ResponsesToAnthropic 把直通非流式响应转为 Anthropic Messages 响应。 func ResponsesToAnthropic(payload []byte, msgID string) (*aiwire.MessagesResponse, error) { var resp respPayload if err := json.Unmarshal(payload, &resp); err != nil { return nil, fmt.Errorf("解析上游响应: %w", err) } out := &aiwire.MessagesResponse{ID: msgID, Type: "message", Role: "assistant", Model: resp.Model, Content: []aiwire.AnthBlock{}, StopReason: "end_turn"} for _, item := range resp.Output { switch item.Type { case "message": for _, part := range item.Content { if part.Type == "output_text" && part.Text != "" { out.Content = append(out.Content, aiwire.AnthBlock{Type: "text", Text: part.Text}) } } case "function_call": out.Content = append(out.Content, aiwire.AnthBlock{Type: "tool_use", ID: item.CallID, Name: item.Name, Input: argsToJSON(item.Arguments)}) out.StopReason = "tool_use" } } if resp.Status == "incomplete" && resp.IncompleteDetails != nil && resp.IncompleteDetails.Reason == "max_output_tokens" { out.StopReason = "max_tokens" } out.Usage = anthUsageFromResp(resp.Usage) return out, nil } func anthUsageFromResp(u *aiwire.RespUsage) aiwire.AnthUsage { if u == nil { return aiwire.AnthUsage{} } return aiwire.AnthUsage{InputTokens: u.InputTokens, OutputTokens: u.OutputTokens, CacheReadInputTokens: u.InputTokensDetails.CachedTokens} } // argsToJSON 保证 tool_use.input 是合法 JSON 对象(模型可能产出非法片段)。 func argsToJSON(args string) json.RawMessage { trimmed := strings.TrimSpace(args) if trimmed == "" { return json.RawMessage(`{}`) } if json.Valid([]byte(trimmed)) { return json.RawMessage(trimmed) } b, _ := json.Marshal(map[string]string{"_raw": args}) return b } // ---- Anthropic 流式桥:Responses SSE 事件 → Anthropic 事件序列 ---- // AnthEvent 是一条待写出的 Anthropic SSE 事件。 type AnthEvent struct { Event string Data any } // AnthRespBridge 把直通 SSE 事件流桥接为 Anthropic 事件序列: // message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。 // reasoning 系列事件丢弃。 type AnthRespBridge struct { id, model string started bool blockOpen bool blockIsTool bool blockIndex int stopReason string usage aiwire.AnthUsage } // NewAnthRespBridge 构造桥;id 为响应消息 ID。 func NewAnthRespBridge(id, model string) *AnthRespBridge { return &AnthRespBridge{id: id, model: model, blockIndex: -1, stopReason: "end_turn"} } // respStreamEvent 是直通 SSE data JSON 中桥关心的子集。 type respStreamEvent struct { Type string `json:"type"` Delta string `json:"delta"` Item *respOutputItem `json:"item"` Response *respPayload `json:"response"` } // Feed 消费一行 SSE data JSON,返回应立即写出的事件。 func (st *AnthRespBridge) Feed(data []byte) []AnthEvent { var ev respStreamEvent if json.Unmarshal(data, &ev) != nil { return nil } var events []AnthEvent if !st.started { st.started = true events = append(events, st.startEvent()) } switch ev.Type { case "response.output_item.added": if ev.Item != nil && ev.Item.Type == "function_call" { events = append(events, st.openBlock(true, ev.Item.CallID, ev.Item.Name)...) st.stopReason = "tool_use" } case "response.output_text.delta": events = append(events, st.textDelta(ev.Delta)...) case "response.function_call_arguments.delta": events = append(events, st.argsDelta(ev.Delta)...) case "response.completed", "response.incomplete", "response.failed": st.finishFrom(ev.Response) } return events } func (st *AnthRespBridge) textDelta(delta string) []AnthEvent { var events []AnthEvent if !st.blockOpen || st.blockIsTool { events = append(events, st.openBlock(false, "", "")...) } events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{ "type": "content_block_delta", "index": st.blockIndex, "delta": map[string]string{"type": "text_delta", "text": delta}, }}) return events } func (st *AnthRespBridge) argsDelta(delta string) []AnthEvent { if !st.blockOpen || !st.blockIsTool { return nil } return []AnthEvent{{Event: "content_block_delta", Data: map[string]any{ "type": "content_block_delta", "index": st.blockIndex, "delta": map[string]string{"type": "input_json_delta", "partial_json": delta}, }}} } // finishFrom 记录终态:usage 与 stop_reason(max_output_tokens 截断 → max_tokens)。 func (st *AnthRespBridge) finishFrom(resp *respPayload) { if resp == nil { return } st.usage = anthUsageFromResp(resp.Usage) if resp.Status == "incomplete" && resp.IncompleteDetails != nil && resp.IncompleteDetails.Reason == "max_output_tokens" { st.stopReason = "max_tokens" } } func (st *AnthRespBridge) startEvent() AnthEvent { return AnthEvent{Event: "message_start", Data: map[string]any{ "type": "message_start", "message": map[string]any{ "id": st.id, "type": "message", "role": "assistant", "model": st.model, "content": []any{}, "stop_reason": nil, "usage": map[string]int{"input_tokens": 0, "output_tokens": 0}, }, }} } // openBlock 关闭当前块并打开新块(text 或 tool_use)。 func (st *AnthRespBridge) openBlock(isTool bool, toolID, toolName string) []AnthEvent { var events []AnthEvent if st.blockOpen { events = append(events, st.closeBlockEvent()) } st.blockOpen, st.blockIsTool = true, isTool st.blockIndex++ block := map[string]any{"type": "text", "text": ""} if isTool { block = map[string]any{"type": "tool_use", "id": toolID, "name": toolName, "input": map[string]any{}} } events = append(events, AnthEvent{Event: "content_block_start", Data: map[string]any{ "type": "content_block_start", "index": st.blockIndex, "content_block": block, }}) return events } func (st *AnthRespBridge) closeBlockEvent() AnthEvent { return AnthEvent{Event: "content_block_stop", Data: map[string]any{ "type": "content_block_stop", "index": st.blockIndex, }} } // Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。 func (st *AnthRespBridge) Finish() []AnthEvent { var events []AnthEvent if !st.started { st.started = true events = append(events, st.startEvent()) } if st.blockOpen { events = append(events, st.closeBlockEvent()) st.blockOpen = false } usage := map[string]int{"output_tokens": st.usage.OutputTokens} if st.usage.InputTokens > 0 { usage["input_tokens"] = st.usage.InputTokens } if st.usage.CacheReadInputTokens > 0 { usage["cache_read_input_tokens"] = st.usage.CacheReadInputTokens } events = append(events, AnthEvent{Event: "message_delta", Data: map[string]any{ "type": "message_delta", "delta": map[string]any{"stop_reason": st.stopReason, "stop_sequence": nil}, "usage": usage, }}, AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}}, ) return events } // Usage 返回聚合到的用量(供调用日志)。 func (st *AnthRespBridge) Usage() aiwire.AnthUsage { return st.usage }