refactor: 实现 ConversionEngine 协议转换引擎,替代旧 protocol 包
引入 Canonical Model 和 ProtocolAdapter 架构,支持 OpenAI/Anthropic 协议间 无缝转换,统一 ProxyHandler 替代分散的 OpenAI/Anthropic Handler,简化 ProviderClient 为协议无关的 HTTP 发送器,Provider 新增 protocol 字段。
This commit is contained in:
532
backend/internal/conversion/openai/encoder.go
Normal file
532
backend/internal/conversion/openai/encoder.go
Normal file
@@ -0,0 +1,532 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"nex/backend/internal/conversion"
|
||||
"nex/backend/internal/conversion/canonical"
|
||||
)
|
||||
|
||||
// encodeRequest 将 Canonical 请求编码为 OpenAI 请求
|
||||
func encodeRequest(req *canonical.CanonicalRequest, provider *conversion.TargetProvider) ([]byte, error) {
|
||||
result := map[string]any{
|
||||
"model": provider.ModelName,
|
||||
"stream": req.Stream,
|
||||
}
|
||||
|
||||
// 系统消息 + 消息
|
||||
messages := encodeSystemAndMessages(req)
|
||||
result["messages"] = messages
|
||||
|
||||
// 参数
|
||||
encodeParametersInto(req, result)
|
||||
|
||||
// 工具
|
||||
if len(req.Tools) > 0 {
|
||||
tools := make([]map[string]any, len(req.Tools))
|
||||
for i, t := range req.Tools {
|
||||
tools[i] = map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": t.InputSchema,
|
||||
},
|
||||
}
|
||||
}
|
||||
result["tools"] = tools
|
||||
}
|
||||
if req.ToolChoice != nil {
|
||||
result["tool_choice"] = encodeToolChoice(req.ToolChoice)
|
||||
}
|
||||
|
||||
// 公共字段
|
||||
if req.UserID != "" {
|
||||
result["user"] = req.UserID
|
||||
}
|
||||
if req.OutputFormat != nil {
|
||||
result["response_format"] = encodeOutputFormat(req.OutputFormat)
|
||||
}
|
||||
if req.ParallelToolUse != nil {
|
||||
result["parallel_tool_calls"] = *req.ParallelToolUse
|
||||
}
|
||||
if req.Thinking != nil {
|
||||
switch req.Thinking.Type {
|
||||
case "disabled":
|
||||
result["reasoning_effort"] = "none"
|
||||
default:
|
||||
if req.Thinking.Effort != "" {
|
||||
result["reasoning_effort"] = req.Thinking.Effort
|
||||
} else {
|
||||
result["reasoning_effort"] = "medium"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, conversion.NewConversionError(conversion.ErrorCodeEncodingFailure, "编码 OpenAI 请求失败").WithCause(err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// encodeSystemAndMessages 编码系统消息和消息列表
|
||||
func encodeSystemAndMessages(req *canonical.CanonicalRequest) []map[string]any {
|
||||
var messages []map[string]any
|
||||
|
||||
// 系统消息
|
||||
switch v := req.System.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
messages = append(messages, map[string]any{
|
||||
"role": "system",
|
||||
"content": v,
|
||||
})
|
||||
}
|
||||
case []canonical.SystemBlock:
|
||||
var parts []string
|
||||
for _, b := range v {
|
||||
parts = append(parts, b.Text)
|
||||
}
|
||||
text := joinStrings(parts, "\n\n")
|
||||
if text != "" {
|
||||
messages = append(messages, map[string]any{
|
||||
"role": "system",
|
||||
"content": text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 消息
|
||||
for _, msg := range req.Messages {
|
||||
encoded := encodeMessage(msg)
|
||||
messages = append(messages, encoded...)
|
||||
}
|
||||
|
||||
// 合并连续同角色消息
|
||||
return mergeConsecutiveRoles(messages)
|
||||
}
|
||||
|
||||
// encodeMessage 编码单条消息
|
||||
func encodeMessage(msg canonical.CanonicalMessage) []map[string]any {
|
||||
switch msg.Role {
|
||||
case canonical.RoleUser:
|
||||
return []map[string]any{{
|
||||
"role": "user",
|
||||
"content": encodeUserContent(msg.Content),
|
||||
}}
|
||||
case canonical.RoleAssistant:
|
||||
m := map[string]any{"role": "assistant"}
|
||||
var textParts []string
|
||||
var toolUses []canonical.ContentBlock
|
||||
|
||||
for _, b := range msg.Content {
|
||||
switch b.Type {
|
||||
case "text":
|
||||
textParts = append(textParts, b.Text)
|
||||
case "tool_use":
|
||||
toolUses = append(toolUses, b)
|
||||
}
|
||||
}
|
||||
|
||||
if len(toolUses) > 0 {
|
||||
if len(textParts) > 0 {
|
||||
m["content"] = joinStrings(textParts, "")
|
||||
} else {
|
||||
m["content"] = nil
|
||||
}
|
||||
tcs := make([]map[string]any, len(toolUses))
|
||||
for i, tu := range toolUses {
|
||||
tcs[i] = map[string]any{
|
||||
"id": tu.ID,
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": tu.Name,
|
||||
"arguments": string(tu.Input),
|
||||
},
|
||||
}
|
||||
}
|
||||
m["tool_calls"] = tcs
|
||||
} else if len(textParts) > 0 {
|
||||
m["content"] = joinStrings(textParts, "")
|
||||
} else {
|
||||
m["content"] = ""
|
||||
}
|
||||
return []map[string]any{m}
|
||||
|
||||
case canonical.RoleTool:
|
||||
for _, b := range msg.Content {
|
||||
if b.Type == "tool_result" {
|
||||
var contentStr string
|
||||
if b.Content != nil {
|
||||
var s string
|
||||
if json.Unmarshal(b.Content, &s) == nil {
|
||||
contentStr = s
|
||||
} else {
|
||||
contentStr = string(b.Content)
|
||||
}
|
||||
}
|
||||
return []map[string]any{{
|
||||
"role": "tool",
|
||||
"tool_call_id": b.ToolUseID,
|
||||
"content": contentStr,
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeUserContent 编码用户内容
|
||||
func encodeUserContent(blocks []canonical.ContentBlock) any {
|
||||
if len(blocks) == 1 && blocks[0].Type == "text" {
|
||||
return blocks[0].Text
|
||||
}
|
||||
parts := make([]map[string]any, 0, len(blocks))
|
||||
for _, b := range blocks {
|
||||
switch b.Type {
|
||||
case "text":
|
||||
parts = append(parts, map[string]any{"type": "text", "text": b.Text})
|
||||
case "image":
|
||||
parts = append(parts, map[string]any{"type": "image_url"})
|
||||
case "audio":
|
||||
parts = append(parts, map[string]any{"type": "input_audio"})
|
||||
case "file":
|
||||
parts = append(parts, map[string]any{"type": "file"})
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// encodeToolChoice 编码工具选择
|
||||
func encodeToolChoice(choice *canonical.ToolChoice) any {
|
||||
switch choice.Type {
|
||||
case "auto":
|
||||
return "auto"
|
||||
case "none":
|
||||
return "none"
|
||||
case "any":
|
||||
return "required"
|
||||
case "tool":
|
||||
return map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": choice.Name,
|
||||
},
|
||||
}
|
||||
}
|
||||
return "auto"
|
||||
}
|
||||
|
||||
// encodeParametersInto 编码参数到结果 map
|
||||
func encodeParametersInto(req *canonical.CanonicalRequest, result map[string]any) {
|
||||
if req.Parameters.MaxTokens != nil {
|
||||
result["max_completion_tokens"] = *req.Parameters.MaxTokens
|
||||
}
|
||||
if req.Parameters.Temperature != nil {
|
||||
result["temperature"] = *req.Parameters.Temperature
|
||||
}
|
||||
if req.Parameters.TopP != nil {
|
||||
result["top_p"] = *req.Parameters.TopP
|
||||
}
|
||||
if req.Parameters.FrequencyPenalty != nil {
|
||||
result["frequency_penalty"] = *req.Parameters.FrequencyPenalty
|
||||
}
|
||||
if req.Parameters.PresencePenalty != nil {
|
||||
result["presence_penalty"] = *req.Parameters.PresencePenalty
|
||||
}
|
||||
if len(req.Parameters.StopSequences) > 0 {
|
||||
result["stop"] = req.Parameters.StopSequences
|
||||
}
|
||||
}
|
||||
|
||||
// encodeOutputFormat 编码输出格式
|
||||
func encodeOutputFormat(format *canonical.OutputFormat) map[string]any {
|
||||
switch format.Type {
|
||||
case "json_object":
|
||||
return map[string]any{"type": "json_object"}
|
||||
case "json_schema":
|
||||
m := map[string]any{"type": "json_schema"}
|
||||
schema := map[string]any{
|
||||
"name": format.Name,
|
||||
}
|
||||
if format.Schema != nil {
|
||||
schema["schema"] = format.Schema
|
||||
}
|
||||
if format.Strict != nil {
|
||||
schema["strict"] = *format.Strict
|
||||
}
|
||||
m["json_schema"] = schema
|
||||
return m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeResponse 将 Canonical 响应编码为 OpenAI 响应
|
||||
func encodeResponse(resp *canonical.CanonicalResponse) ([]byte, error) {
|
||||
var textParts []string
|
||||
var thinkingParts []string
|
||||
var toolUses []canonical.ContentBlock
|
||||
|
||||
for _, b := range resp.Content {
|
||||
switch b.Type {
|
||||
case "text":
|
||||
textParts = append(textParts, b.Text)
|
||||
case "thinking":
|
||||
thinkingParts = append(thinkingParts, b.Thinking)
|
||||
case "tool_use":
|
||||
toolUses = append(toolUses, b)
|
||||
}
|
||||
}
|
||||
|
||||
message := map[string]any{"role": "assistant"}
|
||||
if len(toolUses) > 0 {
|
||||
if len(textParts) > 0 {
|
||||
message["content"] = joinStrings(textParts, "")
|
||||
} else {
|
||||
message["content"] = nil
|
||||
}
|
||||
tcs := make([]map[string]any, len(toolUses))
|
||||
for i, tu := range toolUses {
|
||||
tcs[i] = map[string]any{
|
||||
"id": tu.ID,
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": tu.Name,
|
||||
"arguments": string(tu.Input),
|
||||
},
|
||||
}
|
||||
}
|
||||
message["tool_calls"] = tcs
|
||||
} else if len(textParts) > 0 {
|
||||
message["content"] = joinStrings(textParts, "")
|
||||
} else {
|
||||
message["content"] = ""
|
||||
}
|
||||
|
||||
if len(thinkingParts) > 0 {
|
||||
message["reasoning_content"] = joinStrings(thinkingParts, "")
|
||||
}
|
||||
|
||||
var finishReason *string
|
||||
if resp.StopReason != nil {
|
||||
fr := mapCanonicalToFinishReason(*resp.StopReason)
|
||||
finishReason = &fr
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"id": resp.ID,
|
||||
"object": "chat.completion",
|
||||
"created": time.Now().Unix(),
|
||||
"model": resp.Model,
|
||||
"choices": []map[string]any{{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": finishReason,
|
||||
}},
|
||||
"usage": encodeUsage(resp.Usage),
|
||||
}
|
||||
|
||||
body, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, conversion.NewConversionError(conversion.ErrorCodeEncodingFailure, "编码 OpenAI 响应失败").WithCause(err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// mapCanonicalToFinishReason 映射 Canonical 停止原因到 OpenAI finish_reason
|
||||
func mapCanonicalToFinishReason(reason canonical.StopReason) string {
|
||||
switch reason {
|
||||
case canonical.StopReasonEndTurn:
|
||||
return "stop"
|
||||
case canonical.StopReasonMaxTokens:
|
||||
return "length"
|
||||
case canonical.StopReasonToolUse:
|
||||
return "tool_calls"
|
||||
case canonical.StopReasonContentFilter:
|
||||
return "content_filter"
|
||||
case canonical.StopReasonStopSequence:
|
||||
return "stop"
|
||||
case canonical.StopReasonRefusal:
|
||||
return "stop"
|
||||
default:
|
||||
return "stop"
|
||||
}
|
||||
}
|
||||
|
||||
// encodeUsage 编码用量
|
||||
func encodeUsage(usage canonical.CanonicalUsage) map[string]any {
|
||||
result := map[string]any{
|
||||
"prompt_tokens": usage.InputTokens,
|
||||
"completion_tokens": usage.OutputTokens,
|
||||
"total_tokens": usage.InputTokens + usage.OutputTokens,
|
||||
}
|
||||
if usage.CacheReadTokens != nil && *usage.CacheReadTokens > 0 {
|
||||
result["prompt_tokens_details"] = map[string]any{
|
||||
"cached_tokens": *usage.CacheReadTokens,
|
||||
}
|
||||
}
|
||||
if usage.ReasoningTokens != nil && *usage.ReasoningTokens > 0 {
|
||||
result["completion_tokens_details"] = map[string]any{
|
||||
"reasoning_tokens": *usage.ReasoningTokens,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// encodeModelsResponse 编码模型列表响应
|
||||
func encodeModelsResponse(list *canonical.CanonicalModelList) ([]byte, error) {
|
||||
data := make([]map[string]any, len(list.Models))
|
||||
for i, m := range list.Models {
|
||||
created := int64(0)
|
||||
if m.Created != 0 {
|
||||
created = m.Created
|
||||
}
|
||||
ownedBy := "unknown"
|
||||
if m.OwnedBy != "" {
|
||||
ownedBy = m.OwnedBy
|
||||
}
|
||||
data[i] = map[string]any{
|
||||
"id": m.ID,
|
||||
"object": "model",
|
||||
"created": created,
|
||||
"owned_by": ownedBy,
|
||||
}
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"object": "list",
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
// encodeModelInfoResponse 编码模型详情响应
|
||||
func encodeModelInfoResponse(info *canonical.CanonicalModelInfo) ([]byte, error) {
|
||||
created := int64(0)
|
||||
if info.Created != 0 {
|
||||
created = info.Created
|
||||
}
|
||||
ownedBy := "unknown"
|
||||
if info.OwnedBy != "" {
|
||||
ownedBy = info.OwnedBy
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"id": info.ID,
|
||||
"object": "model",
|
||||
"created": created,
|
||||
"owned_by": ownedBy,
|
||||
})
|
||||
}
|
||||
|
||||
// encodeEmbeddingRequest 编码嵌入请求
|
||||
func encodeEmbeddingRequest(req *canonical.CanonicalEmbeddingRequest, provider *conversion.TargetProvider) ([]byte, error) {
|
||||
result := map[string]any{
|
||||
"model": provider.ModelName,
|
||||
"input": req.Input,
|
||||
}
|
||||
if req.EncodingFormat != "" {
|
||||
result["encoding_format"] = req.EncodingFormat
|
||||
}
|
||||
if req.Dimensions != nil {
|
||||
result["dimensions"] = *req.Dimensions
|
||||
}
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
// encodeEmbeddingResponse 编码嵌入响应
|
||||
func encodeEmbeddingResponse(resp *canonical.CanonicalEmbeddingResponse) ([]byte, error) {
|
||||
data := make([]map[string]any, len(resp.Data))
|
||||
for i, d := range resp.Data {
|
||||
data[i] = map[string]any{
|
||||
"index": d.Index,
|
||||
"embedding": d.Embedding,
|
||||
}
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"object": "list",
|
||||
"data": data,
|
||||
"model": resp.Model,
|
||||
"usage": resp.Usage,
|
||||
})
|
||||
}
|
||||
|
||||
// encodeRerankRequest 编码重排序请求
|
||||
func encodeRerankRequest(req *canonical.CanonicalRerankRequest, provider *conversion.TargetProvider) ([]byte, error) {
|
||||
result := map[string]any{
|
||||
"model": provider.ModelName,
|
||||
"query": req.Query,
|
||||
"documents": req.Documents,
|
||||
}
|
||||
if req.TopN != nil {
|
||||
result["top_n"] = *req.TopN
|
||||
}
|
||||
if req.ReturnDocuments != nil {
|
||||
result["return_documents"] = *req.ReturnDocuments
|
||||
}
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
// encodeRerankResponse 编码重排序响应
|
||||
func encodeRerankResponse(resp *canonical.CanonicalRerankResponse) ([]byte, error) {
|
||||
results := make([]map[string]any, len(resp.Results))
|
||||
for i, r := range resp.Results {
|
||||
m := map[string]any{
|
||||
"index": r.Index,
|
||||
"relevance_score": r.RelevanceScore,
|
||||
}
|
||||
if r.Document != nil {
|
||||
m["document"] = *r.Document
|
||||
}
|
||||
results[i] = m
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"results": results,
|
||||
"model": resp.Model,
|
||||
})
|
||||
}
|
||||
|
||||
// joinStrings 拼接字符串切片
|
||||
func joinStrings(parts []string, sep string) string {
|
||||
result := ""
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
result += sep
|
||||
}
|
||||
result += p
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeConsecutiveRoles 合并连续同角色消息(拼接内容)
|
||||
func mergeConsecutiveRoles(messages []map[string]any) []map[string]any {
|
||||
if len(messages) <= 1 {
|
||||
return messages
|
||||
}
|
||||
var result []map[string]any
|
||||
for _, msg := range messages {
|
||||
if len(result) > 0 {
|
||||
lastRole := result[len(result)-1]["role"]
|
||||
currRole := msg["role"]
|
||||
if lastRole == currRole {
|
||||
lastContent := result[len(result)-1]["content"]
|
||||
currContent := msg["content"]
|
||||
switch lv := lastContent.(type) {
|
||||
case string:
|
||||
if cv, ok := currContent.(string); ok {
|
||||
result[len(result)-1]["content"] = lv + cv
|
||||
}
|
||||
case []any:
|
||||
if cv, ok := currContent.([]any); ok {
|
||||
result[len(result)-1]["content"] = append(lv, cv...)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
result = append(result, msg)
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user