Files
openteam/server/internal/proxy/convert/json_chat.go
T
Sakurasan 04dfb4285d feat: 会话亲和粘住缓存渠道;透传 opaque 字段;用量记思考/1h 缓存写/实际模型
会话亲和(proxy/affinity.go):同一会话粘住上次应答的渠道,命中上游提示词
缓存而非被加权随机打散后全价重算。会话身份 = 密钥 + 模型 + 请求文本稳定前缀
摘要,不依赖各客户端会话头;TTL 默认 5m(OT_PROXY_AFFINITY_TTL,0=关闭),
成功应答刷新。亲和渠道必须在候选集内(健康/未冷却/绑定),否则回退加权随机。
proxyOne 增加 served 返回值区分「应答成功」与「已终结但失败」。

opaque 透传(convert):chat↔responses 互转原样携带 metadata、service_tier、
parallel_tool_calls。此前逐字段重建请求体会丢它们,而中转上游会校验/使用。

用量细化(proxy+store):usage_logs 新增 reasoning_tokens(展示口径,不重复
计费)、cache_write_1h_tokens(Anthropic 1h 缓存写按 2× 输入价,此前统一
1.25× 少算 60%)、served_model(上游实际应答模型,映射/聚合商场景对账)。
schema v6→v7。

验证:go vet ./... 干净;go test ./... 与 go test -race ./... 全绿;
新增测试覆盖亲和粘性与冷却回退、opaque 双向透传、reasoning 不重复计费、
1h/5m 缓存写分开计价、served_model 提取(非流式与流式)。
2026-10-30 11:41:51 +08:00

536 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package convert
import (
"encoding/json"
"strings"
)
// ---------------------------------------------------------------------------
// 请求:Chat → Messages
type chatTool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"function"`
}
type chatMsg struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ToolCallID string `json:"tool_call_id"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
}
type chatReq struct {
Model string `json:"model"`
Messages []chatMsg `json:"messages"`
Tools []chatTool `json:"tools"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
MaxTokens *int `json:"max_tokens"`
Stop []string `json:"stop"`
Stream bool `json:"stream"`
// OpenAI 两协议同形的 opaque 字段:转换到 Responses 时原样透传。
// 上游可能是另一层中转,会校验/使用它们(service_tier 影响优先级,
// metadata 承载 Codex 等客户端的会话/安装标识)。
ServiceTier string `json:"service_tier"`
Metadata map[string]string `json:"metadata"`
ParallelToolCalls *bool `json:"parallel_tool_calls"`
}
// chatToMessagesReq 将 OpenAI Chat 请求转为 Anthropic Messages 请求。
func chatToMessagesReq(body []byte) ([]byte, error) {
var req chatReq
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
out := map[string]any{
"model": req.Model,
"max_tokens": intOrNil(req.MaxTokens, 1024), // Anthropic 必填
}
if req.Stream {
out["stream"] = true
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if len(req.Stop) > 0 {
out["stop_sequences"] = req.Stop
}
var system []string
msgs := make([]any, 0, len(req.Messages))
for _, m := range req.Messages {
if m.Role == "system" {
if s := str(m.Content); s != "" {
system = append(system, s)
}
continue
}
msgs = append(msgs, chatMsgToAnthropic(m))
}
if len(system) > 0 {
out["system"] = strings.Join(system, "\n")
}
out["messages"] = msgs
if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools))
for _, t := range req.Tools {
var params any
if len(t.Function.Parameters) > 0 && string(t.Function.Parameters) != "null" {
_ = json.Unmarshal(t.Function.Parameters, &params)
}
tools = append(tools, map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": params,
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// chatMsgToAnthropic 单条消息转 Anthropic 内容。
func chatMsgToAnthropic(m chatMsg) any {
switch m.Role {
case "assistant":
content := make([]any, 0, 2)
if s := str(m.Content); s != "" {
content = append(content, map[string]any{"type": "text", "text": s})
}
for _, tc := range m.ToolCalls {
var input any
if tc.Function.Arguments != "" {
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
}
content = append(content, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
return map[string]any{"role": "assistant", "content": content}
case "tool":
return map[string]any{"role": "user", "content": []any{
map[string]any{"type": "tool_result", "tool_use_id": m.ToolCallID, "content": str(m.Content)},
}}
default: // user
var arr []map[string]any
if json.Unmarshal(m.Content, &arr) == nil && arr != nil {
blocks := make([]any, 0, len(arr))
for _, b := range arr {
switch b["type"] {
case "text", "input_text":
if t, _ := b["text"].(string); t != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": t})
}
case "image_url":
var url string
if iu, ok := b["image_url"].(map[string]any); ok {
url, _ = iu["url"].(string)
} else if s, ok := b["image_url"].(string); ok {
url = s
}
if url != "" {
blocks = append(blocks, anthropicImageBlock(url))
}
}
}
if len(blocks) > 0 {
return map[string]any{"role": "user", "content": blocks}
}
}
return map[string]any{"role": "user", "content": str(m.Content)}
}
}
// ---------------------------------------------------------------------------
// 请求:Messages → Chat
type messagesReq struct {
Model string `json:"model"`
System json.RawMessage `json:"system"`
Messages []struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
} `json:"messages"`
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
} `json:"tools"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
MaxTokens *int `json:"max_tokens"`
StopSequence []string `json:"stop_sequences"`
Stream bool `json:"stream"`
}
// messagesToChatReq 将 Anthropic Messages 请求转为 OpenAI Chat 请求。
func messagesToChatReq(body []byte) ([]byte, error) {
var req messagesReq
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
out := map[string]any{"model": req.Model}
if req.Stream {
out["stream"] = true
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if req.MaxTokens != nil {
out["max_tokens"] = *req.MaxTokens
}
if len(req.StopSequence) > 0 {
out["stop"] = req.StopSequence
}
msgs := make([]any, 0, len(req.Messages)+1)
if s := str(req.System); s != "" {
msgs = append(msgs, map[string]any{"role": "system", "content": s})
}
for _, m := range req.Messages {
msgs = append(msgs, anthropicMsgToChat(m.Role, m.Content)...)
}
out["messages"] = msgs
if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools))
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": rawOrObject(t.InputSchema),
},
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// anthropicMsgToChat 将一条 Anthropic 消息拆成 0..N 条 Chat 消息。
func anthropicMsgToChat(role string, content json.RawMessage) []any {
// 块数组优先(tool_use / tool_result 需要分块解析)
var blocks []map[string]any
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
var out []any
var toolMsgs []any // tool_result 单独收集,保证排在 assistant(tool_calls) 之后
var textParts []string
var contentBlocks []any // text / image_url 块,保留原始顺序
var toolCalls []any
for _, b := range blocks {
switch b["type"] {
case "text":
if t, _ := b["text"].(string); t != "" {
textParts = append(textParts, t)
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
}
case "image":
if cb := chatImageBlock(b); cb != nil {
contentBlocks = append(contentBlocks, cb)
}
case "tool_use":
id, _ := b["id"].(string)
name, _ := b["name"].(string)
args, _ := json.Marshal(b["input"])
toolCalls = append(toolCalls, map[string]any{
"id": id,
"type": "function",
"function": map[string]any{
"name": name,
"arguments": string(args),
},
})
case "tool_result":
callID, _ := b["tool_use_id"].(string)
res := strField(b["content"])
toolMsgs = append(toolMsgs, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
}
}
hasImage := false
for _, cb := range contentBlocks {
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
hasImage = true
break
}
}
if hasImage || len(textParts) > 0 || len(toolCalls) > 0 {
msg := map[string]any{"role": role}
switch {
case hasImage:
msg["content"] = contentBlocks
case len(textParts) > 0:
msg["content"] = strings.Join(textParts, "")
}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
out = append(out, msg)
}
out = append(out, toolMsgs...)
if len(out) > 0 {
return out
}
}
// 纯文本
if s := str(content); s != "" {
return []any{map[string]any{"role": role, "content": s}}
}
return nil
}
// ---------------------------------------------------------------------------
// 响应:Messages → Chat
type messagesResp struct {
ID string `json:"id"`
Model string `json:"model"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
} `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage"`
}
// messagesToChatResp 将 Anthropic Messages 响应(非流式)转为 Chat 响应。
func messagesToChatResp(body []byte) ([]byte, error) {
var r messagesResp
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
var text string
var toolCalls []any
for _, c := range r.Content {
switch c.Type {
case "text":
text += c.Text
case "tool_use":
args, _ := json.Marshal(c.Input)
toolCalls = append(toolCalls, map[string]any{
"id": c.ID,
"type": "function",
"function": map[string]any{
"name": c.Name,
"arguments": string(args),
},
})
}
}
msg := map[string]any{"role": "assistant", "content": text}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
return json.Marshal(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(r.ID, "msg_"),
"object": "chat.completion",
"model": r.Model,
"created": 0,
"choices": []any{map[string]any{
"index": 0,
"message": msg,
"finish_reason": messagesStopToChat(r.StopReason),
}},
"usage": map[string]any{
"prompt_tokens": r.Usage.InputTokens,
"completion_tokens": r.Usage.OutputTokens,
"total_tokens": r.Usage.InputTokens + r.Usage.OutputTokens,
},
})
}
// ---------------------------------------------------------------------------
// 响应:Chat → Messages
type chatResp struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
} `json:"usage"`
}
// chatToMessagesResp 将 Chat 响应(非流式)转为 Messages 响应。
func chatToMessagesResp(body []byte) ([]byte, error) {
var r chatResp
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
content := make([]any, 0, 2)
var finish = "end_turn"
if len(r.Choices) > 0 {
msg := r.Choices[0].Message
if msg.Content != "" {
content = append(content, map[string]any{"type": "text", "text": msg.Content})
}
for _, tc := range msg.ToolCalls {
var input any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
content = append(content, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
finish = chatStopToMessages(r.Choices[0].FinishReason)
}
return json.Marshal(map[string]any{
"id": "msg_" + strings.TrimPrefix(r.ID, "chatcmpl-"),
"type": "message",
"role": "assistant",
"model": r.Model,
"content": content,
"stop_reason": finish,
"usage": map[string]any{
"input_tokens": r.Usage.PromptTokens,
"output_tokens": r.Usage.CompletionTokens,
},
})
}
// ---------------------------------------------------------------------------
// 辅助
func intOrNil(p *int, def int) any {
if p == nil {
return def
}
return *p
}
// splitDataURL 解析 data:media_type;base64,data 形式的 URL;非该形式返回 ok=false。
func splitDataURL(url string) (media, data string, ok bool) {
if !strings.HasPrefix(url, "data:") {
return "", "", false
}
i := strings.Index(url, ";base64,")
if i < 0 {
return "", "", false
}
return url[len("data:"):i], url[i+len(";base64,"):], true
}
// chatImageBlock 把 Anthropic image 块转 OpenAI image_url 块。
// 仅支持 base64 与 url source;其他类型(如 Files API 的 file_id)不支持,跳过。
func chatImageBlock(b map[string]any) any {
src, ok := b["source"].(map[string]any)
if !ok {
return nil
}
switch src["type"] {
case "base64":
media, _ := src["media_type"].(string)
data, _ := src["data"].(string)
if data == "" {
return nil
}
if media == "" {
media = "image/png"
}
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:" + media + ";base64," + data}}
case "url":
url, _ := src["url"].(string)
if url == "" {
return nil
}
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}}
}
return nil
}
// anthropicImageBlock 把 OpenAI image_url 的 url 转 Anthropic image 块。
// data URL → base64 source;http(s) URL → url source。
func anthropicImageBlock(url string) any {
if media, data, ok := splitDataURL(url); ok {
if media == "" {
media = "image/png"
}
return map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": media, "data": data}}
}
return map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}
}
func rawOrObject(raw json.RawMessage) any {
if len(raw) == 0 || string(raw) == "null" {
return map[string]any{}
}
var m map[string]any
if json.Unmarshal(raw, &m) == nil {
return m
}
return map[string]any{}
}
func strField(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func messagesStopToChat(s string) string {
switch s {
case "tool_use":
return "tool_calls"
case "max_tokens":
return "length"
default:
return "stop"
}
}
func chatStopToMessages(s string) string {
switch s {
case "tool_calls":
return "tool_use"
case "length":
return "max_tokens"
default:
return "end_turn"
}
}