M0-M4: 推倒重来基线(基建+用户/密钥/核心代理+前端+管理后台+三协议互转)
- 后端 Go+Gin+GORM: 配置(OT_ env)/SQLite/Postgres 双驱动、用户体系(argon2id+JWT access/refresh)、 API Key(sk- 48位, 仅存 SHA-256 哈希) - 代理网关: /v1/chat/completions、/v1/responses、/v1/messages、/v1/models;错误按客户端协议返回 - 三协议互转(convert 包): Chat↔Messages↔Responses 请求/响应 + 流式 SSE 逐事件转换(直通优先) - 用量计费: 异步批量记账、余额扣减、balance_logs、usage_daily 日聚合 - 管理 API: 用户/渠道 CRUD+测试+模型导入/模型定价+绑定/统计/系统配置 - 前端 Vue3+TS+Tailwind(taste-skill 设计 tokens): Landing/登录注册/控制台/管理后台, 自建组件+Phosphor 图标+自建 SVG 趋势图, 已过 web-design-guidelines 复查 - mock 上游: OpenAI+Anthropic 双协议模拟(含流式) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// Package convert 三协议互转:OpenAI Chat / OpenAI Responses / Anthropic Messages。
|
||||
// 网关以 OpenAI Chat 形状作为标准中间模型(PLANNING §5.1.1)。
|
||||
// 请求与响应(非流式)走 JSON 转换;流式走逐行 SSE 转换(见 stream.go)。
|
||||
package convert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 协议标识。
|
||||
const (
|
||||
ProtoChat = "chat"
|
||||
ProtoMessages = "messages"
|
||||
ProtoResponses = "responses"
|
||||
)
|
||||
|
||||
// ConvertRequest 转换请求体。from==to 时原样返回。
|
||||
func ConvertRequest(body []byte, from, to string) ([]byte, error) {
|
||||
if from == to {
|
||||
return body, nil
|
||||
}
|
||||
switch {
|
||||
case from == ProtoMessages && to == ProtoChat:
|
||||
return messagesToChatReq(body)
|
||||
case from == ProtoChat && to == ProtoMessages:
|
||||
return chatToMessagesReq(body)
|
||||
case from == ProtoResponses && to == ProtoChat:
|
||||
return responsesToChatReq(body)
|
||||
case from == ProtoChat && to == ProtoResponses:
|
||||
return chatToResponsesReq(body)
|
||||
case from == ProtoResponses && to == ProtoMessages:
|
||||
mid, err := responsesToChatReq(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return chatToMessagesReq(mid)
|
||||
case from == ProtoMessages && to == ProtoResponses:
|
||||
mid, err := messagesToChatReq(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return chatToResponsesReq(mid)
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported request conversion %s->%s", from, to)
|
||||
}
|
||||
|
||||
// ConvertResponse 转换响应体(非流式)。from==to 时原样返回。
|
||||
func ConvertResponse(body []byte, from, to string) ([]byte, error) {
|
||||
if from == to {
|
||||
return body, nil
|
||||
}
|
||||
switch {
|
||||
case from == ProtoMessages && to == ProtoChat:
|
||||
return messagesToChatResp(body)
|
||||
case from == ProtoChat && to == ProtoMessages:
|
||||
return chatToMessagesResp(body)
|
||||
case from == ProtoResponses && to == ProtoChat:
|
||||
return responsesToChatResp(body)
|
||||
case from == ProtoChat && to == ProtoResponses:
|
||||
return chatToResponsesResp(body)
|
||||
case from == ProtoResponses && to == ProtoMessages:
|
||||
mid, err := responsesToChatResp(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return chatToMessagesResp(mid)
|
||||
case from == ProtoMessages && to == ProtoResponses:
|
||||
mid, err := messagesToChatResp(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return chatToResponsesResp(mid)
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported response conversion %s->%s", from, to)
|
||||
}
|
||||
|
||||
// NewStreamTransformer 构造流式逐行转换器:输入上游 SSE 一行,返回客户端 SSE 行。
|
||||
// 返回 nil 表示丢弃该行;(from==to 时无需转换)。
|
||||
func NewStreamTransformer(from, to string) func([]byte) []byte {
|
||||
switch {
|
||||
case from == ProtoMessages && to == ProtoChat:
|
||||
return newMessagesToChat().line
|
||||
case from == ProtoChat && to == ProtoMessages:
|
||||
return newChatToMessages().line
|
||||
case from == ProtoResponses && to == ProtoChat:
|
||||
return newResponsesToChat().line
|
||||
case from == ProtoChat && to == ProtoResponses:
|
||||
return newChatToResponses().line
|
||||
case from == ProtoResponses && to == ProtoMessages:
|
||||
return newResponsesToMessages().line
|
||||
case from == ProtoMessages && to == ProtoResponses:
|
||||
return newMessagesToResponses().line
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工具函数
|
||||
|
||||
// str 返回字符串字段;json.RawMessage 为字符串字面量时去引号。
|
||||
func str(raw json.RawMessage) string {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s
|
||||
}
|
||||
// 数组/对象:尝试取 type=text 的 text
|
||||
var arr []map[string]any
|
||||
if json.Unmarshal(raw, &arr) == nil {
|
||||
var parts []string
|
||||
for _, b := range arr {
|
||||
if t, _ := b["type"].(string); t == "text" || t == "input_text" || t == "output_text" {
|
||||
if txt, _ := b["text"].(string); txt != "" {
|
||||
parts = append(parts, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return joinNonEmpty(parts, "\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func joinNonEmpty(parts []string, sep string) string {
|
||||
out := ""
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if out != "" {
|
||||
out += sep
|
||||
}
|
||||
out += p
|
||||
_ = i
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rawJSON 安全取字段;不存在或 null 返回 nil。
|
||||
func rawJSON(m map[string]json.RawMessage, key string) json.RawMessage {
|
||||
raw, ok := m[key]
|
||||
if !ok || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// decode 把 RawMessage 解到 map。
|
||||
func decode(raw json.RawMessage) (map[string]any, error) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestChatToMessagesReq(t *testing.T) {
|
||||
in := `{
|
||||
"model":"claude-sonnet-5",
|
||||
"messages":[
|
||||
{"role":"system","content":"你是助手"},
|
||||
{"role":"user","content":"hi"},
|
||||
{"role":"assistant","content":"hello","tool_calls":[{"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"sz\"}"}}]},
|
||||
{"role":"tool","tool_call_id":"call_1","content":"sunny"}
|
||||
],
|
||||
"tools":[{"type":"function","function":{"name":"get_weather","description":"查天气","parameters":{"type":"object"}}}],
|
||||
"max_tokens":100,
|
||||
"stream":true
|
||||
}`
|
||||
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoMessages)
|
||||
if err != nil {
|
||||
t.Fatalf("convert: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(out, &m); err != nil {
|
||||
t.Fatalf("unmarshal out: %v\n%s", err, out)
|
||||
}
|
||||
if m["system"] != "你是助手" {
|
||||
t.Fatalf("system = %v", m["system"])
|
||||
}
|
||||
if m["max_tokens"] != float64(100) {
|
||||
t.Fatalf("max_tokens = %v", m["max_tokens"])
|
||||
}
|
||||
msgs := m["messages"].([]any)
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("messages len = %d", len(msgs))
|
||||
}
|
||||
// assistant 含 tool_use 块
|
||||
assistant := msgs[1].(map[string]any)
|
||||
content := assistant["content"].([]any)
|
||||
foundToolUse := false
|
||||
for _, c := range content {
|
||||
cm := c.(map[string]any)
|
||||
if cm["type"] == "tool_use" {
|
||||
foundToolUse = true
|
||||
if cm["name"] != "get_weather" || cm["id"] != "call_1" {
|
||||
t.Fatalf("tool_use mismatch: %v", cm)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundToolUse {
|
||||
t.Fatal("expected tool_use block")
|
||||
}
|
||||
// tool 消息 → user 消息的 tool_result 块
|
||||
tool := msgs[2].(map[string]any)
|
||||
if tool["role"] != "user" {
|
||||
t.Fatalf("tool message role = %v", tool["role"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToChatReq(t *testing.T) {
|
||||
in := `{
|
||||
"model":"gpt-4o-mini",
|
||||
"system":"你是助手",
|
||||
"messages":[
|
||||
{"role":"user","content":"hi"},
|
||||
{"role":"assistant","content":[{"type":"text","text":"hello"},{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"sz"}}]},
|
||||
{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"sunny"}]}
|
||||
],
|
||||
"tools":[{"name":"get_weather","description":"查天气","input_schema":{"type":"object"}}],
|
||||
"max_tokens":100,
|
||||
"stream":false
|
||||
}`
|
||||
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoChat)
|
||||
if err != nil {
|
||||
t.Fatalf("convert: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(out, &m)
|
||||
msgs := m["messages"].([]any)
|
||||
// system + user + assistant + tool = 4 条
|
||||
if len(msgs) != 4 {
|
||||
t.Fatalf("messages len = %d: %s", len(msgs), out)
|
||||
}
|
||||
if msgs[0].(map[string]any)["role"] != "system" {
|
||||
t.Fatal("expected system message first")
|
||||
}
|
||||
assistant := msgs[2].(map[string]any)
|
||||
if tc := assistant["tool_calls"]; tc == nil {
|
||||
t.Fatalf("expected tool_calls in assistant: %s", out)
|
||||
}
|
||||
tool := msgs[3].(map[string]any)
|
||||
if tool["role"] != "tool" || tool["tool_call_id"] != "call_1" {
|
||||
t.Fatalf("tool message mismatch: %v", tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesToChatReq(t *testing.T) {
|
||||
in := `{
|
||||
"model":"claude-sonnet-5",
|
||||
"instructions":"你是助手",
|
||||
"input":"hello",
|
||||
"tools":[{"type":"function","name":"get_weather","description":"查天气","parameters":{"type":"object"}}],
|
||||
"max_output_tokens":200,
|
||||
"stream":false
|
||||
}`
|
||||
out, err := ConvertRequest([]byte(in), ProtoResponses, ProtoChat)
|
||||
if err != nil {
|
||||
t.Fatalf("convert: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(out, &m)
|
||||
msgs := m["messages"].([]any)
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("messages len = %d: %s", len(msgs), out)
|
||||
}
|
||||
if msgs[0].(map[string]any)["role"] != "system" {
|
||||
t.Fatal("expected system from instructions")
|
||||
}
|
||||
if m["max_tokens"] != float64(200) {
|
||||
t.Fatalf("max_tokens = %v", m["max_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatToResponsesReq(t *testing.T) {
|
||||
in := mustJSON(t, map[string]any{
|
||||
"model": "gpt-4o",
|
||||
"messages": []any{
|
||||
map[string]any{"role": "system", "content": "sys"},
|
||||
map[string]any{"role": "user", "content": "hi"},
|
||||
},
|
||||
"max_tokens": 300,
|
||||
})
|
||||
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoResponses)
|
||||
if err != nil {
|
||||
t.Fatalf("convert: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(out, &m)
|
||||
if m["instructions"] != "sys" {
|
||||
t.Fatalf("instructions = %v", m["instructions"])
|
||||
}
|
||||
if m["max_output_tokens"] != float64(300) {
|
||||
t.Fatalf("max_output_tokens = %v", m["max_output_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToChatResp(t *testing.T) {
|
||||
in := `{"id":"msg_abc","type":"message","role":"assistant","model":"claude-sonnet-5",
|
||||
"content":[{"type":"text","text":"你好"},{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"sz"}}],
|
||||
"stop_reason":"tool_use","usage":{"input_tokens":10,"output_tokens":5}}`
|
||||
out, err := ConvertResponse([]byte(in), ProtoMessages, ProtoChat)
|
||||
if err != nil {
|
||||
t.Fatalf("convert: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(out, &m)
|
||||
choices := m["choices"].([]any)
|
||||
msg := choices[0].(map[string]any)["message"].(map[string]any)
|
||||
if msg["content"] != "你好" {
|
||||
t.Fatalf("content = %v", msg["content"])
|
||||
}
|
||||
if msg["tool_calls"] == nil {
|
||||
t.Fatal("expected tool_calls")
|
||||
}
|
||||
if choices[0].(map[string]any)["finish_reason"] != "tool_calls" {
|
||||
t.Fatalf("finish_reason = %v", choices[0].(map[string]any)["finish_reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatToMessagesResp(t *testing.T) {
|
||||
in := `{"id":"chatcmpl-xyz","object":"chat.completion","model":"gpt-4o",
|
||||
"choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`
|
||||
out, err := ConvertResponse([]byte(in), ProtoChat, ProtoMessages)
|
||||
if err != nil {
|
||||
t.Fatalf("convert: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(out, &m)
|
||||
if m["stop_reason"] != "end_turn" {
|
||||
t.Fatalf("stop_reason = %v", m["stop_reason"])
|
||||
}
|
||||
content := m["content"].([]any)
|
||||
if content[0].(map[string]any)["text"] != "hi" {
|
||||
t.Fatalf("content = %v", content)
|
||||
}
|
||||
usage := m["usage"].(map[string]any)
|
||||
if usage["input_tokens"] != float64(3) || usage["output_tokens"] != float64(2) {
|
||||
t.Fatalf("usage = %v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 流式转换
|
||||
|
||||
func feedLines(t *testing.T, transformer func([]byte) []byte, lines []string) string {
|
||||
t.Helper()
|
||||
var sb strings.Builder
|
||||
for _, l := range lines {
|
||||
if out := transformer([]byte(l)); out != nil {
|
||||
sb.Write(out)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func TestStreamMessagesToChat(t *testing.T) {
|
||||
tf := newMessagesToChat().line
|
||||
out := feedLines(t, tf, []string{
|
||||
"event: message_start\n",
|
||||
`data: {"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-5"}}` + "\n\n",
|
||||
"event: content_block_delta\n",
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}` + "\n\n",
|
||||
"event: message_delta\n",
|
||||
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":5}}` + "\n\n",
|
||||
"event: message_stop\n",
|
||||
`data: {"type":"message_stop"}` + "\n\n",
|
||||
})
|
||||
if !strings.Contains(out, `"content":"你好"`) {
|
||||
t.Fatalf("missing content chunk: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"finish_reason":"stop"`) {
|
||||
t.Fatalf("missing finish chunk: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"usage"`) {
|
||||
t.Fatalf("missing usage chunk: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "data: [DONE]") {
|
||||
t.Fatalf("missing [DONE]: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamChatToMessages(t *testing.T) {
|
||||
tf := newChatToMessages().line
|
||||
out := feedLines(t, tf, []string{
|
||||
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}` + "\n\n",
|
||||
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]}` + "\n\n",
|
||||
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}` + "\n\n",
|
||||
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9}}` + "\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
})
|
||||
if !strings.Contains(out, "event: message_start") {
|
||||
t.Fatalf("missing message_start: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"text":"你好"`) || !strings.Contains(out, `"type":"text_delta"`) {
|
||||
t.Fatalf("missing content delta: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"stop_reason":"end_turn"`) {
|
||||
t.Fatalf("missing message_delta: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "event: message_stop") {
|
||||
t.Fatalf("missing message_stop: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamResponsesToMessages(t *testing.T) {
|
||||
tf := newResponsesToMessages().line
|
||||
out := feedLines(t, tf, []string{
|
||||
"event: response.created\n",
|
||||
`data: {"type":"response.created","response":{"id":"resp_1","model":"claude-sonnet-5"}}` + "\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
`data: {"type":"response.output_text.delta","delta":"hi"}` + "\n\n",
|
||||
"event: response.completed\n",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":7,"output_tokens":8}}}` + "\n\n",
|
||||
})
|
||||
if !strings.Contains(out, "event: message_start") {
|
||||
t.Fatalf("missing message_start: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"text":"hi"`) {
|
||||
t.Fatalf("missing content: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "event: message_stop") {
|
||||
t.Fatalf("missing message_stop: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamMessagesToResponses(t *testing.T) {
|
||||
tf := newMessagesToResponses().line
|
||||
out := feedLines(t, tf, []string{
|
||||
"event: message_start\n",
|
||||
`data: {"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-5"}}` + "\n\n",
|
||||
"event: content_block_delta\n",
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}` + "\n\n",
|
||||
"event: message_stop\n",
|
||||
`data: {"type":"message_stop"}` + "\n\n",
|
||||
})
|
||||
if !strings.Contains(out, "event: response.created") {
|
||||
t.Fatalf("missing response.created: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "event: response.output_text.delta") {
|
||||
t.Fatalf("missing output_text.delta: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "event: response.completed") {
|
||||
t.Fatalf("missing response.completed: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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, ¶ms)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if url != "" {
|
||||
blocks = append(blocks, map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": 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 textParts []string
|
||||
var toolCalls []any
|
||||
for _, b := range blocks {
|
||||
switch b["type"] {
|
||||
case "text":
|
||||
if t, _ := b["text"].(string); t != "" {
|
||||
textParts = append(textParts, t)
|
||||
}
|
||||
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"])
|
||||
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
|
||||
}
|
||||
}
|
||||
if len(textParts) > 0 || len(toolCalls) > 0 {
|
||||
msg := map[string]any{"role": role}
|
||||
if len(textParts) > 0 {
|
||||
msg["content"] = strings.Join(textParts, "")
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
msg["tool_calls"] = toolCalls
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 请求:Responses → Chat
|
||||
|
||||
// responsesToChatReq 将 OpenAI Responses 请求转为 Chat 请求。
|
||||
func responsesToChatReq(body []byte) ([]byte, error) {
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"model": str(rawJSON(m, "model"))}
|
||||
if v, ok := m["stream"]; ok && string(v) == "true" {
|
||||
out["stream"] = true
|
||||
}
|
||||
if v, ok := m["temperature"]; ok {
|
||||
out["temperature"] = v
|
||||
}
|
||||
if v, ok := m["top_p"]; ok {
|
||||
out["top_p"] = v
|
||||
}
|
||||
if v, ok := m["max_output_tokens"]; ok {
|
||||
out["max_tokens"] = v
|
||||
}
|
||||
|
||||
var msgs []any
|
||||
if ins := str(rawJSON(m, "instructions")); ins != "" {
|
||||
msgs = append(msgs, map[string]any{"role": "system", "content": ins})
|
||||
}
|
||||
msgs = append(msgs, responsesInputToChat(rawJSON(m, "input"))...)
|
||||
out["messages"] = msgs
|
||||
|
||||
if raw := rawJSON(m, "tools"); raw != nil {
|
||||
var tools []map[string]any
|
||||
if json.Unmarshal(raw, &tools) == nil {
|
||||
chatTools := make([]any, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
chatTools = append(chatTools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t["name"],
|
||||
"description": t["description"],
|
||||
"parameters": t["parameters"],
|
||||
},
|
||||
})
|
||||
}
|
||||
out["tools"] = chatTools
|
||||
}
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// responsesInputToChat 把 Responses input 转成 Chat messages。
|
||||
func responsesInputToChat(raw json.RawMessage) []any {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
// 字符串输入
|
||||
if s := str(raw); s != "" {
|
||||
return []any{map[string]any{"role": "user", "content": s}}
|
||||
}
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal(raw, &items); err != nil || items == nil {
|
||||
return nil
|
||||
}
|
||||
var out []any
|
||||
for _, item := range items {
|
||||
switch item["type"] {
|
||||
case "function_call":
|
||||
var args any
|
||||
_ = json.Unmarshal([]byte(strField(item["arguments"])), &args)
|
||||
out = append(out, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": []any{map[string]any{
|
||||
"id": strField(item["call_id"]),
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": strField(item["name"]),
|
||||
"arguments": strField(item["arguments"]),
|
||||
},
|
||||
}},
|
||||
})
|
||||
case "function_call_output":
|
||||
out = append(out, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": strField(item["call_id"]),
|
||||
"content": strField(item["output"]),
|
||||
})
|
||||
default: // message 条目
|
||||
role, _ := item["role"].(string)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if content, ok := item["content"].(string); ok {
|
||||
out = append(out, map[string]any{"role": role, "content": content})
|
||||
} else if blocks, ok := item["content"].([]any); ok {
|
||||
var text []string
|
||||
for _, b := range blocks {
|
||||
if bm, ok := b.(map[string]any); ok {
|
||||
if t, _ := bm["text"].(string); t != "" {
|
||||
text = append(text, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 请求:Chat → Responses
|
||||
|
||||
// chatToResponsesReq 将 Chat 请求转为 Responses 请求。
|
||||
func chatToResponsesReq(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}
|
||||
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_output_tokens"] = *req.MaxTokens
|
||||
}
|
||||
|
||||
var system []string
|
||||
var input []any
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == "system" {
|
||||
if s := str(m.Content); s != "" {
|
||||
system = append(system, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch m.Role {
|
||||
case "tool":
|
||||
input = append(input, map[string]any{
|
||||
"type": "function_call_output",
|
||||
"call_id": m.ToolCallID,
|
||||
"output": str(m.Content),
|
||||
})
|
||||
case "assistant":
|
||||
if len(m.ToolCalls) > 0 {
|
||||
for _, tc := range m.ToolCalls {
|
||||
input = append(input, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
} else if s := str(m.Content); s != "" {
|
||||
input = append(input, map[string]any{"type": "message", "role": "assistant", "content": []any{
|
||||
map[string]any{"type": "input_text", "text": s},
|
||||
}})
|
||||
}
|
||||
default:
|
||||
if s := str(m.Content); s != "" {
|
||||
input = append(input, map[string]any{"type": "message", "role": "user", "content": []any{
|
||||
map[string]any{"type": "input_text", "text": s},
|
||||
}})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(system) > 0 {
|
||||
out["instructions"] = strings.Join(system, "\n")
|
||||
}
|
||||
if len(input) == 1 {
|
||||
out["input"] = input[0] // 单条消息项
|
||||
} else {
|
||||
out["input"] = input
|
||||
}
|
||||
|
||||
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",
|
||||
"name": t.Function.Name,
|
||||
"description": t.Function.Description,
|
||||
"parameters": rawOrObject(t.Function.Parameters),
|
||||
})
|
||||
}
|
||||
out["tools"] = tools
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 响应:Responses → Chat
|
||||
|
||||
// responsesToChatResp 将 Responses 响应(非流式)转为 Chat 响应。
|
||||
func responsesToChatResp(body []byte) ([]byte, error) {
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var text string
|
||||
var toolCalls []any
|
||||
if raw := rawJSON(m, "output"); raw != nil {
|
||||
var outputs []map[string]any
|
||||
if json.Unmarshal(raw, &outputs) == nil {
|
||||
for _, o := range outputs {
|
||||
switch o["type"] {
|
||||
case "message":
|
||||
if content, ok := o["content"].([]any); ok {
|
||||
for _, c := range content {
|
||||
if cm, ok := c.(map[string]any); ok {
|
||||
if t, _ := cm["text"].(string); t != "" {
|
||||
text += t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "function_call":
|
||||
toolCalls = append(toolCalls, map[string]any{
|
||||
"id": strField(o["call_id"]),
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": strField(o["name"]),
|
||||
"arguments": strField(o["arguments"]),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
msg := map[string]any{"role": "assistant", "content": text}
|
||||
if len(toolCalls) > 0 {
|
||||
msg["tool_calls"] = toolCalls
|
||||
}
|
||||
finish := "stop"
|
||||
if string(rawJSON(m, "status")) == `"incomplete"` {
|
||||
finish = "length"
|
||||
}
|
||||
var prompt, completion int64
|
||||
if u := rawJSON(m, "usage"); u != nil {
|
||||
var us struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
}
|
||||
_ = json.Unmarshal(u, &us)
|
||||
prompt, completion = us.InputTokens, us.OutputTokens
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(str(rawJSON(m, "id")), "resp_"),
|
||||
"object": "chat.completion",
|
||||
"model": str(rawJSON(m, "model")),
|
||||
"choices": []any{map[string]any{"index": 0, "message": msg, "finish_reason": finish}},
|
||||
"usage": map[string]any{
|
||||
"prompt_tokens": prompt,
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": prompt + completion,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 响应:Chat → Responses
|
||||
|
||||
// chatToResponsesResp 将 Chat 响应(非流式)转为 Responses 响应。
|
||||
func chatToResponsesResp(body []byte) ([]byte, error) {
|
||||
var r chatResp
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := make([]any, 0, 2)
|
||||
var finish = "completed"
|
||||
if len(r.Choices) > 0 {
|
||||
msg := r.Choices[0].Message
|
||||
if msg.Content != "" {
|
||||
output = append(output, map[string]any{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []any{map[string]any{"type": "output_text", "text": msg.Content}},
|
||||
})
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
output = append(output, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
if r.Choices[0].FinishReason == "length" {
|
||||
finish = "incomplete"
|
||||
}
|
||||
}
|
||||
status := finish
|
||||
return json.Marshal(map[string]any{
|
||||
"id": "resp_" + strings.TrimPrefix(r.ID, "chatcmpl-"),
|
||||
"object": "response",
|
||||
"model": r.Model,
|
||||
"status": status,
|
||||
"output": output,
|
||||
"usage": map[string]any{
|
||||
"input_tokens": r.Usage.PromptTokens,
|
||||
"output_tokens": r.Usage.CompletionTokens,
|
||||
"total_tokens": r.Usage.PromptTokens + r.Usage.CompletionTokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sseState 记录上一行 event 名与通用状态。
|
||||
type sseState struct {
|
||||
event string
|
||||
}
|
||||
|
||||
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
|
||||
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
|
||||
str := strings.TrimRight(string(line), "\r\n")
|
||||
switch {
|
||||
case strings.HasPrefix(str, "event: "):
|
||||
s.event = strings.TrimSpace(strings.TrimPrefix(str, "event: "))
|
||||
return false, "", false
|
||||
case str == "data: [DONE]":
|
||||
return true, "[DONE]", true
|
||||
case strings.HasPrefix(str, "data: "):
|
||||
return true, strings.TrimPrefix(str, "data: "), false
|
||||
default:
|
||||
return false, "", false
|
||||
}
|
||||
}
|
||||
|
||||
func eventData(line string) map[string]any {
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal([]byte(line), &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func dataLine(obj any) []byte {
|
||||
b, _ := json.Marshal(obj)
|
||||
return append(append([]byte("data: "), b...), '\n', '\n')
|
||||
}
|
||||
|
||||
func eventLine(name string, obj any) []byte {
|
||||
b, _ := json.Marshal(obj)
|
||||
out := append([]byte("event: "+name+"\ndata: "), b...)
|
||||
return append(out, '\n', '\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Messages → Chat
|
||||
|
||||
type messagesToChat struct {
|
||||
sseState
|
||||
id, model string
|
||||
}
|
||||
|
||||
func newMessagesToChat() *messagesToChat { return &messagesToChat{} }
|
||||
|
||||
func (t *messagesToChat) line(line []byte) []byte {
|
||||
isData, data, done := t.parseLine(line)
|
||||
if !isData {
|
||||
return nil
|
||||
}
|
||||
if done {
|
||||
return []byte("data: [DONE]\n\n")
|
||||
}
|
||||
m := eventData(data)
|
||||
evt, _ := m["type"].(string)
|
||||
switch evt {
|
||||
case "message_start":
|
||||
msg, _ := m["message"].(map[string]any)
|
||||
t.id, _ = msg["id"].(string)
|
||||
t.model, _ = msg["model"].(string)
|
||||
return dataLine(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
|
||||
})
|
||||
case "content_block_delta":
|
||||
delta, _ := m["delta"].(map[string]any)
|
||||
text, _ := delta["text"].(string)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return dataLine(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": text}, "finish_reason": nil}},
|
||||
})
|
||||
case "message_delta":
|
||||
delta, _ := m["delta"].(map[string]any)
|
||||
stop, _ := delta["stop_reason"].(string)
|
||||
var out [][]byte
|
||||
out = append(out, dataLine(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": messagesStopToChat(stop)}},
|
||||
}))
|
||||
if u, ok := m["usage"]; ok {
|
||||
out = append(out, dataLine(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||
"choices": []any{}, "usage": u,
|
||||
}))
|
||||
}
|
||||
return joinLines(out)
|
||||
case "message_stop":
|
||||
return []byte("data: [DONE]\n\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func joinLines(lines [][]byte) []byte {
|
||||
return []byte(strings.Join(func() []string {
|
||||
var s []string
|
||||
for _, l := range lines {
|
||||
s = append(s, string(l))
|
||||
}
|
||||
return s
|
||||
}(), ""))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat → Messages
|
||||
|
||||
type chatToMessages struct {
|
||||
sseState
|
||||
started bool
|
||||
blockStarted bool
|
||||
model string
|
||||
stopReason string
|
||||
usage any
|
||||
}
|
||||
|
||||
func newChatToMessages() *chatToMessages { return &chatToMessages{} }
|
||||
|
||||
func (t *chatToMessages) line(line []byte) []byte {
|
||||
isData, data, done := t.parseLine(line)
|
||||
if !isData {
|
||||
return nil
|
||||
}
|
||||
if done {
|
||||
// 汇聚最终 message_delta + content_block_stop + message_stop
|
||||
md := map[string]any{"type": "message_delta", "delta": map[string]any{
|
||||
"stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil,
|
||||
}}
|
||||
if t.usage != nil {
|
||||
md["usage"] = t.usage
|
||||
}
|
||||
var out [][]byte
|
||||
out = append(out, eventLine("message_delta", md))
|
||||
if t.blockStarted {
|
||||
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
|
||||
}
|
||||
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
||||
return joinLines(out)
|
||||
}
|
||||
m := eventData(data)
|
||||
// chat 块:delta / finish_reason 在 choices[0] 内
|
||||
delta := map[string]any{}
|
||||
if choices, ok := m["choices"].([]any); ok && len(choices) > 0 {
|
||||
if c0, ok := choices[0].(map[string]any); ok {
|
||||
if d, ok := c0["delta"].(map[string]any); ok {
|
||||
delta = d
|
||||
}
|
||||
if fr, _ := c0["finish_reason"].(string); fr != "" {
|
||||
t.stopReason = fr
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.model == "" {
|
||||
t.model, _ = m["model"].(string)
|
||||
}
|
||||
id, _ := m["id"].(string)
|
||||
|
||||
var out [][]byte
|
||||
// 首个包含内容或角色的块前,先发 message_start + content_block_start
|
||||
if !t.started {
|
||||
role, _ := delta["role"].(string)
|
||||
content, _ := delta["content"].(string)
|
||||
if role == "assistant" || content != "" {
|
||||
t.started = true
|
||||
out = append(out, eventLine("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant",
|
||||
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
|
||||
},
|
||||
}))
|
||||
out = append(out, eventLine("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
||||
}))
|
||||
t.blockStarted = true
|
||||
}
|
||||
}
|
||||
if content, _ := delta["content"].(string); content != "" {
|
||||
if !t.started {
|
||||
t.started = true
|
||||
t.blockStarted = true
|
||||
out = append(out, eventLine("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant", "model": t.model, "content": []any{}},
|
||||
}))
|
||||
out = append(out, eventLine("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
||||
}))
|
||||
}
|
||||
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": content},
|
||||
}))
|
||||
}
|
||||
if u, ok := m["usage"]; ok {
|
||||
t.usage = u
|
||||
}
|
||||
return joinLines(out)
|
||||
}
|
||||
|
||||
func stopReasonOrEnd(s string) string {
|
||||
if s == "" {
|
||||
return "end_turn"
|
||||
}
|
||||
return chatStopToMessages(s)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Responses → Messages
|
||||
|
||||
type responsesToMessages struct {
|
||||
sseState
|
||||
started bool
|
||||
model string
|
||||
usage any
|
||||
}
|
||||
|
||||
func newResponsesToMessages() *responsesToMessages { return &responsesToMessages{} }
|
||||
|
||||
func (t *responsesToMessages) line(line []byte) []byte {
|
||||
isData, data, done := t.parseLine(line)
|
||||
if !isData || done {
|
||||
return nil
|
||||
}
|
||||
m := eventData(data)
|
||||
evt, _ := m["type"].(string)
|
||||
if resp, ok := m["response"].(map[string]any); ok {
|
||||
if t.model == "" {
|
||||
t.model, _ = resp["model"].(string)
|
||||
}
|
||||
if u, ok := resp["usage"]; ok {
|
||||
t.usage = u
|
||||
}
|
||||
}
|
||||
var out [][]byte
|
||||
switch evt {
|
||||
case "response.created":
|
||||
if !t.started {
|
||||
t.started = true
|
||||
id, _ := m["response"].(map[string]any)
|
||||
rid := ""
|
||||
if id != nil {
|
||||
rid, _ = id["id"].(string)
|
||||
}
|
||||
out = append(out, eventLine("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant",
|
||||
"model": t.model, "content": []any{},
|
||||
},
|
||||
}))
|
||||
out = append(out, eventLine("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
||||
}))
|
||||
}
|
||||
case "response.output_text.delta":
|
||||
delta, _ := m["delta"].(string)
|
||||
if delta != "" {
|
||||
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": delta},
|
||||
}))
|
||||
}
|
||||
case "response.completed":
|
||||
out = append(out, eventLine("message_delta", map[string]any{
|
||||
"type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
|
||||
}))
|
||||
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
|
||||
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
||||
}
|
||||
return joinLines(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Messages → Responses
|
||||
|
||||
type messagesToResponses struct {
|
||||
sseState
|
||||
model string
|
||||
usage any
|
||||
done bool
|
||||
}
|
||||
|
||||
func newMessagesToResponses() *messagesToResponses { return &messagesToResponses{} }
|
||||
|
||||
func (t *messagesToResponses) line(line []byte) []byte {
|
||||
isData, data, done := t.parseLine(line)
|
||||
if !isData || done {
|
||||
return nil
|
||||
}
|
||||
m := eventData(data)
|
||||
evt, _ := m["type"].(string)
|
||||
if msg, ok := m["message"].(map[string]any); ok {
|
||||
if t.model == "" {
|
||||
t.model, _ = msg["model"].(string)
|
||||
}
|
||||
if u, ok := msg["usage"]; ok {
|
||||
t.usage = u
|
||||
}
|
||||
}
|
||||
if u, ok := m["usage"]; ok {
|
||||
t.usage = u
|
||||
}
|
||||
var out [][]byte
|
||||
switch evt {
|
||||
case "message_start":
|
||||
id, _ := m["message"].(map[string]any)
|
||||
rid := ""
|
||||
if id != nil {
|
||||
rid, _ = id["id"].(string)
|
||||
}
|
||||
out = append(out, eventLine("response.created", map[string]any{
|
||||
"type": "response.created",
|
||||
"response": map[string]any{
|
||||
"id": "resp_" + strings.TrimPrefix(rid, "msg_"), "object": "response", "model": t.model, "status": "in_progress",
|
||||
},
|
||||
}))
|
||||
case "content_block_delta":
|
||||
delta, _ := m["delta"].(map[string]any)
|
||||
text, _ := delta["text"].(string)
|
||||
if text != "" {
|
||||
out = append(out, eventLine("response.output_text.delta", map[string]any{
|
||||
"type": "response.output_text.delta", "delta": text, "item_id": "msg_1", "output_index": 0, "content_index": 0,
|
||||
}))
|
||||
}
|
||||
case "message_stop":
|
||||
if !t.done {
|
||||
t.done = true
|
||||
out = append(out, eventLine("response.completed", map[string]any{
|
||||
"type": "response.completed",
|
||||
"response": map[string]any{
|
||||
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
return joinLines(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Responses → Chat
|
||||
|
||||
type responsesToChat struct {
|
||||
sseState
|
||||
id, model string
|
||||
}
|
||||
|
||||
func newResponsesToChat() *responsesToChat { return &responsesToChat{} }
|
||||
|
||||
func (t *responsesToChat) line(line []byte) []byte {
|
||||
isData, data, done := t.parseLine(line)
|
||||
if !isData {
|
||||
return nil
|
||||
}
|
||||
if done {
|
||||
return nil
|
||||
}
|
||||
m := eventData(data)
|
||||
evt, _ := m["type"].(string)
|
||||
if resp, ok := m["response"].(map[string]any); ok {
|
||||
if t.model == "" {
|
||||
t.model, _ = resp["model"].(string)
|
||||
}
|
||||
if t.id == "" {
|
||||
t.id, _ = resp["id"].(string)
|
||||
}
|
||||
}
|
||||
var out [][]byte
|
||||
switch evt {
|
||||
case "response.created":
|
||||
out = append(out, dataLine(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
|
||||
}))
|
||||
case "response.output_text.delta":
|
||||
delta, _ := m["delta"].(string)
|
||||
if delta != "" {
|
||||
out = append(out, dataLine(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": delta}, "finish_reason": nil}},
|
||||
}))
|
||||
}
|
||||
case "response.completed":
|
||||
out = append(out, dataLine(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}},
|
||||
}))
|
||||
if u, ok := m["response"].(map[string]any); ok {
|
||||
if usage, ok := u["usage"]; ok {
|
||||
out = append(out, dataLine(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
|
||||
"choices": []any{}, "usage": usage,
|
||||
}))
|
||||
}
|
||||
}
|
||||
out = append(out, []byte("data: [DONE]\n\n"))
|
||||
}
|
||||
return joinLines(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat → Responses
|
||||
|
||||
type chatToResponses struct {
|
||||
sseState
|
||||
model string
|
||||
usage any
|
||||
done bool
|
||||
}
|
||||
|
||||
func newChatToResponses() *chatToResponses { return &chatToResponses{} }
|
||||
|
||||
func (t *chatToResponses) line(line []byte) []byte {
|
||||
isData, data, done := t.parseLine(line)
|
||||
if !isData {
|
||||
return nil
|
||||
}
|
||||
if done {
|
||||
if !t.done {
|
||||
t.done = true
|
||||
return eventLine("response.completed", map[string]any{
|
||||
"type": "response.completed",
|
||||
"response": map[string]any{
|
||||
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
m := eventData(data)
|
||||
if t.model == "" {
|
||||
t.model, _ = m["model"].(string)
|
||||
}
|
||||
if u, ok := m["usage"]; ok {
|
||||
t.usage = u
|
||||
}
|
||||
delta := map[string]any{}
|
||||
var finish string
|
||||
if choices, ok := m["choices"].([]any); ok && len(choices) > 0 {
|
||||
if c0, ok := choices[0].(map[string]any); ok {
|
||||
if d, ok := c0["delta"].(map[string]any); ok {
|
||||
delta = d
|
||||
}
|
||||
finish, _ = c0["finish_reason"].(string)
|
||||
}
|
||||
}
|
||||
var out [][]byte
|
||||
if role, _ := delta["role"].(string); role == "assistant" {
|
||||
out = append(out, eventLine("response.created", map[string]any{
|
||||
"type": "response.created",
|
||||
"response": map[string]any{"id": "resp_stream", "object": "response", "model": t.model, "status": "in_progress"},
|
||||
}))
|
||||
}
|
||||
if content, _ := delta["content"].(string); content != "" {
|
||||
out = append(out, eventLine("response.output_text.delta", map[string]any{
|
||||
"type": "response.output_text.delta", "delta": content, "item_id": "msg_1", "output_index": 0, "content_index": 0,
|
||||
}))
|
||||
}
|
||||
if finish != "" && !t.done {
|
||||
t.done = true
|
||||
out = append(out, eventLine("response.completed", map[string]any{
|
||||
"type": "response.completed",
|
||||
"response": map[string]any{
|
||||
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
|
||||
},
|
||||
}))
|
||||
}
|
||||
return joinLines(out)
|
||||
}
|
||||
Reference in New Issue
Block a user