From 91cbf8401c13f9c9ba9e42fabc068b79e74a583d Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 16 Aug 2026 17:57:58 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix(relay):=E5=AE=8C=E5=96=84openai?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/codex/adaptor.go | 5 +- relay/channel/ollama/adaptor.go | 129 ++++++++- relay/channel/ollama/converter.go | 155 ++++++++++- relay/channel/ollama/dto.go | 3 + relay/channel/openai/converter.go | 18 ++ relay/channel/openai/converter_test.go | 55 ++++ relay/dto/openai_responses.go | 2 + relaykit/dto/ollama.go | 40 ++- .../internal/ollama_chat/ollama_chat_test.go | 250 +++++++++++++++++- .../ollama_chat/ollama_to_openai_response.go | 79 +++++- .../ollama_chat/ollama_to_openai_stream.go | 46 +++- .../ollama_chat/openai_to_ollama_request.go | 151 ++++++++++- 12 files changed, 899 insertions(+), 34 deletions(-) diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index 37372fd..7688684 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -95,10 +95,13 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re // 官方兼容性字段处理(对齐 Codex CLI 行为): // - store 必须为 false(chatgpt.com 官方后端硬性要求) - // - 移除 max_output_tokens / temperature(上游不支持这两个字段,透传会被拒) + // - 移除 max_output_tokens / temperature(上游不支持,透传会被拒) + // - 移除 frequency_penalty / presence_penalty(非官方 Responses 参数,防御性剥离) rawMap["store"] = json.RawMessage("false") delete(rawMap, "max_output_tokens") delete(rawMap, "temperature") + delete(rawMap, "frequency_penalty") + delete(rawMap, "presence_penalty") // instructions 字段必须存在,缺省时默认空串 if _, ok := rawMap["instructions"]; !ok { rawMap["instructions"] = json.RawMessage(`""`) diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go index f3406bf..ab6bfc0 100644 --- a/relay/channel/ollama/adaptor.go +++ b/relay/channel/ollama/adaptor.go @@ -184,7 +184,20 @@ func (a *Adaptor) handleChatNonStreamResponse(ctx context.Context, resp *http.Re } // 转换为 OpenAI ChatCompletion 格式 - finishReason := "stop" + message := dto.Message{ + Role: ollamaResp.Message.Role, + Content: ollamaResp.Message.Content, + } + if reasoning := ollamaThinkingToOpenAI(ollamaResp.Message.Thinking); reasoning != nil { + message.ReasoningContent = reasoning + } + if toolCalls := legacyOllamaToolCallsToOpenAI(ollamaResp.Message.ToolCalls); len(toolCalls) > 0 { + message.ToolCalls = toolCalls + } + finishReason := ollamaDoneReasonToFinishReason(ollamaResp.DoneReason) + if len(message.ToolCalls) > 0 { + finishReason = "tool_calls" + } openaiResp := dto.ChatCompletionResponse{ ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), Object: "chat.completion", @@ -192,11 +205,8 @@ func (a *Adaptor) handleChatNonStreamResponse(ctx context.Context, resp *http.Re Model: info.OriginModelName, Choices: []dto.Choice{ { - Index: 0, - Message: dto.Message{ - Role: ollamaResp.Message.Role, - Content: ollamaResp.Message.Content, - }, + Index: 0, + Message: message, FinishReason: finishReason, }, }, @@ -244,6 +254,8 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo var usage common.Usage var transferredTextLen int // 已转发的文本长度,供流中断输出估算 + sawToolCalls := false + nextToolCallIndex := 0 for scanner.Scan() { select { case <-ctx.Done(): @@ -273,7 +285,10 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo usage.TotalTokens = ollamaResp.PromptEvalCount + ollamaResp.EvalCount // 发送带 finish_reason 的结束 chunk - finishReason := "stop" + finishReason := ollamaDoneReasonToFinishReason(ollamaResp.DoneReason) + if sawToolCalls { + finishReason = "tool_calls" + } endChunk := dto.ChatCompletionStreamResponse{ ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), Object: "chat.completion.chunk", @@ -297,6 +312,17 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo // 构建 OpenAI 流式 chunk transferredTextLen += len(ollamaResp.Message.Content) + delta := dto.Message{ + Role: ollamaResp.Message.Role, + Content: ollamaResp.Message.Content, + } + if thinking := ollamaThinkingToOpenAI(ollamaResp.Message.Thinking); thinking != nil { + delta.ReasoningContent = thinking + } + if toolCalls := legacyOllamaStreamToolCalls(ollamaResp.Message.ToolCalls, &nextToolCallIndex); len(toolCalls) > 0 { + delta.ToolCalls = toolCalls + sawToolCalls = true + } chunk := dto.ChatCompletionStreamResponse{ ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), Object: "chat.completion.chunk", @@ -304,10 +330,7 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo Choices: []dto.StreamChoice{ { Index: 0, - Delta: dto.Message{ - Role: ollamaResp.Message.Role, - Content: ollamaResp.Message.Content, - }, + Delta: delta, }, }, } @@ -522,3 +545,87 @@ func writeStreamChunk(w http.ResponseWriter, chunk any) { data, _ := json.Marshal(chunk) _ = helper.WriteSSEData(w, string(data)) } + +// ollamaThinkingToOpenAI 将 Ollama thinking 字段(JSON 字符串)转为 OpenAI reasoning_content。 +// 字段缺失、null、空串或非字符串时返回 nil,避免产出空的 reasoning_content。 +func ollamaThinkingToOpenAI(thinking json.RawMessage) *string { + if len(thinking) == 0 { + return nil + } + var s string + if err := json.Unmarshal(thinking, &s); err != nil { + return nil + } + // JSON null 反序列化不报错且保持零值,空思考内容同样不产出字段 + if s == "" { + return nil + } + return &s +} + +// ollamaDoneReasonToFinishReason 将 Ollama done_reason 映射为 OpenAI finish_reason。 +// stop/length 均为 OpenAI 合法取值,其余(如 load 等运行态原因)归一为 stop。 +func ollamaDoneReasonToFinishReason(doneReason string) string { + if doneReason == "length" { + return "length" + } + return "stop" +} + +// legacyOllamaToolCallsToOpenAI 将 Ollama tool_calls 转换为 OpenAI tool_calls(非流式回退路径)。 +func legacyOllamaToolCallsToOpenAI(toolCalls []OllamaToolCall) []dto.ToolCall { + if len(toolCalls) == 0 { + return nil + } + out := make([]dto.ToolCall, 0, len(toolCalls)) + for i, tc := range toolCalls { + id := tc.ID + if id == "" { + id = fmt.Sprintf("call_%d", i) + } + argsBytes := []byte("{}") + if tc.Function.Arguments != nil { + if b, err := json.Marshal(tc.Function.Arguments); err == nil { + argsBytes = b + } + } + out = append(out, dto.ToolCall{ + ID: id, + Type: "function", + Function: dto.FunctionCall{ + Name: tc.Function.Name, + Arguments: string(argsBytes), + }, + }) + } + return out +} + +// legacyOllamaStreamToolCalls 将 Ollama tool_calls 转换为 OpenAI 流式增量 delta(回退路径), +// 为每个调用分配稳定递增的 index。 +func legacyOllamaStreamToolCalls(toolCalls []OllamaToolCall, nextIndex *int) []dto.ToolCall { + out := make([]dto.ToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + id := tc.ID + if id == "" { + id = fmt.Sprintf("call_%d", *nextIndex) + } + argsBytes := []byte("{}") + if tc.Function.Arguments != nil { + if b, err := json.Marshal(tc.Function.Arguments); err == nil { + argsBytes = b + } + } + out = append(out, dto.ToolCall{ + Index: *nextIndex, + ID: id, + Type: "function", + Function: dto.FunctionCall{ + Name: tc.Function.Name, + Arguments: string(argsBytes), + }, + }) + *nextIndex++ + } + return out +} diff --git a/relay/channel/ollama/converter.go b/relay/channel/ollama/converter.go index c30ad2a..2c4f288 100644 --- a/relay/channel/ollama/converter.go +++ b/relay/channel/ollama/converter.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" ) // convertChatRequest 将 OpenAI Chat Completions 请求转换为 Ollama Chat 格式 @@ -22,8 +23,12 @@ func convertChatRequest(requestBody []byte, info *common.RelayInfo) ([]byte, err // 解析消息 var openaiMessages []struct { - Role string `json:"role"` - Content any `json:"content"` + Role string `json:"role"` + Content any `json:"content"` + ToolCallID string `json:"tool_call_id"` + Name string `json:"name"` + ReasoningContent *string `json:"reasoning_content"` + ToolCalls []dto.ToolCall `json:"tool_calls"` } if raw, ok := rawReq["messages"]; ok { if err := json.Unmarshal(raw, &openaiMessages); err != nil { @@ -32,11 +37,12 @@ func convertChatRequest(requestBody []byte, info *common.RelayInfo) ([]byte, err } ollamaMessages := make([]OllamaMessage, 0, len(openaiMessages)) + toolNamesByCallID := make(map[string]string) for _, msg := range openaiMessages { ollamaMsg := OllamaMessage{ Role: msg.Role, } - // content 可以是 string 或 []ContentPart + // content 可以是 string 或 []ContentPart(多模态) switch c := msg.Content.(type) { case string: ollamaMsg.Content = c @@ -47,8 +53,7 @@ func convertChatRequest(requestBody []byte, info *common.RelayInfo) ([]byte, err if !ok { continue } - partType, _ := partMap["type"].(string) - if partType == "text" { + if partType, _ := partMap["type"].(string); partType == "text" { if text, ok := partMap["text"].(string); ok { ollamaMsg.Content += text } @@ -61,6 +66,45 @@ func convertChatRequest(requestBody []byte, info *common.RelayInfo) ([]byte, err ollamaMsg.Content = string(b) } } + // 保留跨轮次的工具调用与推理上下文(与 relaykit 转换器行为对齐) + if msg.Role == "tool" { + ollamaMsg.ToolCallID = msg.ToolCallID + ollamaMsg.ToolName = msg.Name + if ollamaMsg.ToolName == "" { + // 消息未带 name 时按 tool_call_id 从前面 assistant 的 tool_calls 反查工具名; + // Ollama 的 tool 消息靠 tool_name 关联结果(tool_call_id 仅原样透传)。 + ollamaMsg.ToolName = toolNamesByCallID[msg.ToolCallID] + } + } + if msg.Role == "assistant" { + if msg.ReasoningContent != nil { + if b, err := json.Marshal(*msg.ReasoningContent); err == nil { + ollamaMsg.Thinking = b + } + } + if len(msg.ToolCalls) > 0 { + calls := make([]OllamaToolCall, 0, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + oc := OllamaToolCall{ID: tc.ID} + oc.Function.Name = tc.Function.Name + var args any + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + return nil, fmt.Errorf("parse tool call arguments: %w", err) + } + } + if args == nil { + args = map[string]any{} + } + oc.Function.Arguments = args + calls = append(calls, oc) + if tc.ID != "" { + toolNamesByCallID[tc.ID] = tc.Function.Name + } + } + ollamaMsg.ToolCalls = calls + } + } ollamaMessages = append(ollamaMessages, ollamaMsg) } @@ -75,6 +119,9 @@ func convertChatRequest(requestBody []byte, info *common.RelayInfo) ([]byte, err Messages: ollamaMessages, Stream: stream, Options: options, + Tools: legacyOllamaTools(rawReq), + Format: legacyOllamaFormat(rawReq), + Think: legacyOllamaThink(rawReq), } return json.Marshal(ollamaReq) @@ -218,3 +265,101 @@ func buildOptions(rawReq map[string]json.RawMessage) map[string]any { } return options } + +// legacyOllamaTools 将原始 OpenAI tools 映射为 Ollama tools(回退路径)。 +func legacyOllamaTools(rawReq map[string]json.RawMessage) []OllamaTool { + raw, ok := rawReq["tools"] + if !ok { + return nil + } + var tools []struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` + } `json:"function"` + } + if err := json.Unmarshal(raw, &tools); err != nil { + return nil + } + out := make([]OllamaTool, 0, len(tools)) + for _, t := range tools { + if t.Type != "function" { + continue + } + out = append(out, OllamaTool{ + Type: "function", + Function: OllamaToolFunction{ + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: t.Function.Parameters, + }, + }) + } + if len(out) == 0 { + return nil + } + return out +} + +// legacyOllamaFormat 将原始 response_format 映射为 Ollama format(回退路径)。 +func legacyOllamaFormat(rawReq map[string]json.RawMessage) any { + raw, ok := rawReq["response_format"] + if !ok { + return nil + } + var rf struct { + Type string `json:"type"` + JSONSchema any `json:"json_schema,omitempty"` + } + if err := json.Unmarshal(raw, &rf); err != nil { + return nil + } + switch rf.Type { + case "json", "json_object": + return "json" + case "json_schema": + return legacyExtractJSONSchema(rf.JSONSchema) + default: + return nil + } +} + +// legacyOllamaThink 将原始 reasoning_effort 映射为 Ollama think(回退路径)。 +// "none" 关闭思考;low/medium/high/max 作为思考档位透传;未知取值跳过。 +func legacyOllamaThink(rawReq map[string]json.RawMessage) json.RawMessage { + raw, ok := rawReq["reasoning_effort"] + if !ok { + return nil + } + var effort string + if err := json.Unmarshal(raw, &effort); err != nil || effort == "" { + return nil + } + var value any + switch effort { + case "none": + value = false + case "low", "medium", "high", "max": + value = effort + default: + return nil + } + b, err := json.Marshal(value) + if err != nil { + return nil + } + return b +} + +// legacyExtractJSONSchema 从 OpenAI json_schema 包装结构({name, description, schema, strict}) +// 中提取裸 schema;Ollama 的 format 字段期望 schema 对象本身。 +func legacyExtractJSONSchema(v any) any { + if m, ok := v.(map[string]any); ok { + if schema, ok := m["schema"]; ok { + return schema + } + } + return v +} diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go index 52bf081..7b36baa 100644 --- a/relay/channel/ollama/dto.go +++ b/relay/channel/ollama/dto.go @@ -6,6 +6,9 @@ import relaykitdto "github.com/qianfree/team-api/relaykit/dto" type OllamaChatRequest = relaykitdto.OllamaChatRequest type OllamaMessage = relaykitdto.OllamaMessage type OllamaChatResponse = relaykitdto.OllamaChatResponse +type OllamaTool = relaykitdto.OllamaTool +type OllamaToolFunction = relaykitdto.OllamaToolFunction +type OllamaToolCall = relaykitdto.OllamaToolCall // OllamaGenerateRequest Ollama Generate(文本补全)请求 type OllamaGenerateRequest struct { diff --git a/relay/channel/openai/converter.go b/relay/channel/openai/converter.go index 306ce7a..6f48aa4 100644 --- a/relay/channel/openai/converter.go +++ b/relay/channel/openai/converter.go @@ -655,6 +655,18 @@ func ConvertResponsesToOpenAI(requestBody []byte, info *common.RelayInfo) (io.Re if req.PromptCacheKey != "" { chatReq["prompt_cache_key"] = req.PromptCacheKey } + if len(req.FrequencyPenalty) > 0 { + var v float64 + if err := json.Unmarshal(req.FrequencyPenalty, &v); err == nil { + chatReq["frequency_penalty"] = v + } + } + if len(req.PresencePenalty) > 0 { + var v float64 + if err := json.Unmarshal(req.PresencePenalty, &v); err == nil { + chatReq["presence_penalty"] = v + } + } if len(req.Metadata) > 0 { chatReq["metadata"] = json.RawMessage(req.Metadata) } @@ -835,6 +847,12 @@ func ConvertOpenAIToResponses(body []byte, info *common.RelayInfo) ([]byte, erro if req.TopP != nil { respReq["top_p"] = *req.TopP } + // prompt_cache_key 是官方 Responses API 参数,原样透传; + // presence/frequency penalty 不属于官方 Responses API,透传会被严格上游 + // (如 api.openai.com)以未知参数拒绝,chat 入站时不注入、静默丢弃。 + if req.PromptCacheKey != "" { + respReq["prompt_cache_key"] = req.PromptCacheKey + } if maxTokens := c2rGetMaxTokens(req); maxTokens > 0 { respReq["max_output_tokens"] = maxTokens } diff --git a/relay/channel/openai/converter_test.go b/relay/channel/openai/converter_test.go index 5849ed7..e29f978 100644 --- a/relay/channel/openai/converter_test.go +++ b/relay/channel/openai/converter_test.go @@ -2,8 +2,10 @@ package openai import ( "encoding/json" + "io" "testing" + "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/dto" ) @@ -199,3 +201,56 @@ func TestConvertClaudeUserMessage_StringContent(t *testing.T) { t.Errorf("got %+v", msgs[0]) } } + +// TestConvertResponsesToOpenAI_PenaltyPassthrough Responses 入站转 chat 出站时 +// 透传 presence/frequency penalty 与 prompt_cache_key(vLLM 等 OpenAI 兼容上游接受)。 +func TestConvertResponsesToOpenAI_PenaltyPassthrough(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + body := []byte(`{"model":"gpt-4o","input":"hi","frequency_penalty":0.5,"presence_penalty":0.2}`) + out, err := ConvertResponsesToOpenAI(body, info) + if err != nil { + t.Fatalf("ConvertResponsesToOpenAI: %v", err) + } + raw, _ := io.ReadAll(out) + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("bad json: %v\n%s", err, raw) + } + if m["frequency_penalty"] != 0.5 { + t.Errorf("frequency_penalty = %v, want 0.5", m["frequency_penalty"]) + } + if m["presence_penalty"] != 0.2 { + t.Errorf("presence_penalty = %v, want 0.2", m["presence_penalty"]) + } + if _, ok := m["messages"]; !ok { + t.Error("messages should be present") + } +} + +// TestConvertOpenAIToResponses_PenaltyDropped chat 入站转 Responses 出站时 +// 丢弃官方不支持的 presence/frequency penalty(透传会被严格上游拒绝), +// 保留官方参数 prompt_cache_key。 +func TestConvertOpenAIToResponses_PenaltyDropped(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"frequency_penalty":0.5,"presence_penalty":0.2,"prompt_cache_key":"abc"}`) + out, err := ConvertOpenAIToResponses(body, info) + if err != nil { + t.Fatalf("ConvertOpenAIToResponses: %v", err) + } + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("bad json: %v\n%s", err, out) + } + if _, ok := m["frequency_penalty"]; ok { + t.Error("frequency_penalty should be dropped (not official Responses API)") + } + if _, ok := m["presence_penalty"]; ok { + t.Error("presence_penalty should be dropped (not official Responses API)") + } + if m["prompt_cache_key"] != "abc" { + t.Errorf("prompt_cache_key = %v, want abc", m["prompt_cache_key"]) + } + if _, ok := m["input"]; !ok { + t.Error("input should be present") + } +} diff --git a/relay/dto/openai_responses.go b/relay/dto/openai_responses.go index c1951c2..baf7251 100644 --- a/relay/dto/openai_responses.go +++ b/relay/dto/openai_responses.go @@ -37,6 +37,8 @@ type OpenAIResponsesRequest struct { PromptCacheKey string `json:"prompt_cache_key,omitempty"` PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` SafetyIdentifier string `json:"safety_identifier,omitempty"` + FrequencyPenalty json.RawMessage `json:"frequency_penalty,omitempty"` + PresencePenalty json.RawMessage `json:"presence_penalty,omitempty"` } // Reasoning 推理参数(用于 o1/o3 等推理模型) diff --git a/relaykit/dto/ollama.go b/relaykit/dto/ollama.go index d709428..a655409 100644 --- a/relaykit/dto/ollama.go +++ b/relaykit/dto/ollama.go @@ -6,31 +6,63 @@ // 不依赖任何宿主类型(RelayInfo / GoFrame),仅含协议字段。 package dto +import "encoding/json" + // OllamaChatRequest Ollama Chat 请求(/api/chat)。 type OllamaChatRequest struct { Model string `json:"model"` Messages []OllamaMessage `json:"messages"` Stream bool `json:"stream"` Options map[string]any `json:"options,omitempty"` + Tools []OllamaTool `json:"tools,omitempty"` + Format any `json:"format,omitempty"` + Think json.RawMessage `json:"think,omitempty"` } // OllamaMessage Ollama 消息格式。Content 为纯文本(多模态图片经 base64 放入 Images)。 type OllamaMessage struct { - Role string `json:"role"` - Content string `json:"content"` - Images []string `json:"images,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + Images []string `json:"images,omitempty"` + ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Thinking json.RawMessage `json:"thinking,omitempty"` } // OllamaChatResponse Ollama Chat 响应(流式与非流式共用)。 // // 流式(NDJSON)每行一个本结构;非流式为单个本结构。 -// 最后一条(Done==true)携带 PromptEvalCount / EvalCount 用量。 +// 最后一条(Done==true)携带 PromptEvalCount / EvalCount 用量与 DoneReason 结束原因。 type OllamaChatResponse struct { Model string `json:"model"` CreatedAt string `json:"created_at"` Message OllamaMessage `json:"message"` Done bool `json:"done"` + DoneReason string `json:"done_reason,omitempty"` TotalDuration int64 `json:"total_duration,omitempty"` PromptEvalCount int `json:"prompt_eval_count,omitempty"` EvalCount int `json:"eval_count,omitempty"` } + +// OllamaTool Ollama 工具定义(OpenAI function 格式一致)。 +type OllamaTool struct { + Type string `json:"type"` + Function OllamaToolFunction `json:"function"` +} + +// OllamaToolFunction 工具函数定义。 +type OllamaToolFunction struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` +} + +// OllamaToolCall Ollama 工具调用。Arguments 为 JSON 对象(非字符串,区别于 OpenAI 的字符串编码)。 +type OllamaToolCall struct { + ID string `json:"id,omitempty"` + Function struct { + Name string `json:"name"` + Arguments any `json:"arguments"` + } `json:"function"` +} diff --git a/relaykit/relayconvert/internal/ollama_chat/ollama_chat_test.go b/relaykit/relayconvert/internal/ollama_chat/ollama_chat_test.go index fe0a45b..f8cf54e 100644 --- a/relaykit/relayconvert/internal/ollama_chat/ollama_chat_test.go +++ b/relaykit/relayconvert/internal/ollama_chat/ollama_chat_test.go @@ -3,6 +3,7 @@ package ollama_chat import ( "bytes" "context" + "encoding/json" "testing" "github.com/qianfree/team-api/relaykit/dto" @@ -27,8 +28,8 @@ func TestOpenAIToOllamaRequestConverter_BasicWithOptions(t *testing.T) { c := &OpenAIToOllamaRequestConverter{} temp, topP, maxTok, freq, pres := 0.5, 0.8, 100, 0.1, 0.2 req := &dto.GeneralOpenAIRequest{ - Model: "openai-model", - Messages: []dto.Message{{Role: "user", Content: "hi"}}, + Model: "openai-model", + Messages: []dto.Message{{Role: "user", Content: "hi"}}, Temperature: &temp, TopP: &topP, MaxTokens: &maxTok, FrequencyPenalty: &freq, PresencePenalty: &pres, Stop: "STOP", } @@ -66,7 +67,7 @@ func TestOpenAIToOllamaRequestConverter_MaxCompletionTokens(t *testing.T) { c := &OpenAIToOllamaRequestConverter{} mct := 256 req := &dto.GeneralOpenAIRequest{ - Messages: []dto.Message{{Role: "user", Content: "hi"}}, + Messages: []dto.Message{{Role: "user", Content: "hi"}}, MaxCompletionTokens: &mct, } res, _ := c.ConvertRequest(context.Background(), nil, req) @@ -191,3 +192,246 @@ func TestOllamaToOpenAIStreamConverter_NoDoneFallback(t *testing.T) { t.Error("expected terminal finish chunk when no done line") } } + +// ---------- 请求转换:tools / format / think ---------- + +func TestOpenAIToOllamaRequestConverter_ToolsFormatThink(t *testing.T) { + c := &OpenAIToOllamaRequestConverter{} + req := &dto.GeneralOpenAIRequest{ + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + Tools: []dto.Tool{{ + Type: "function", + Function: dto.FunctionDef{ + Name: "get_weather", + Description: "get weather", + Parameters: map[string]any{"type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}}, + }, + }}, + ResponseFormat: &dto.ResponseFormat{Type: "json_schema", JSONSchema: map[string]any{"name": "weather", "schema": map[string]any{"type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}}}}, + ReasoningEffort: "high", + } + res, err := c.ConvertRequest(context.Background(), nil, req) + if err != nil { + t.Fatalf("ConvertRequest: %v", err) + } + r := res.(*dto.OllamaChatRequest) + if len(r.Tools) != 1 || r.Tools[0].Type != "function" || r.Tools[0].Function.Name != "get_weather" { + t.Errorf("Tools = %+v", r.Tools) + } + // json_schema 应解包为裸 schema,不带外层包装字段 + format, ok := r.Format.(map[string]any) + if !ok || format["type"] != "object" { + t.Errorf("Format = %#v, want bare schema {type:object}", r.Format) + } + if _, hasName := format["name"]; hasName { + t.Errorf("Format should not keep json_schema wrapper name, got %#v", r.Format) + } + if string(r.Think) != `"high"` { + t.Errorf("Think = %s, want \"high\"", r.Think) + } +} + +func TestOpenAIToOllamaRequestConverter_JSONFormatAndThinkNone(t *testing.T) { + c := &OpenAIToOllamaRequestConverter{} + req := &dto.GeneralOpenAIRequest{ + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + ResponseFormat: &dto.ResponseFormat{Type: "json_object"}, + ReasoningEffort: "none", + } + res, err := c.ConvertRequest(context.Background(), nil, req) + if err != nil { + t.Fatalf("ConvertRequest: %v", err) + } + r := res.(*dto.OllamaChatRequest) + if r.Format != "json" { + t.Errorf("Format = %#v, want json", r.Format) + } + if string(r.Think) != "false" { + t.Errorf("Think = %s, want false", r.Think) + } +} + +func TestOpenAIToOllamaRequestConverter_ToolCallContext(t *testing.T) { + c := &OpenAIToOllamaRequestConverter{} + rc := "thinking text" + req := &dto.GeneralOpenAIRequest{ + Messages: []dto.Message{ + { + Role: "assistant", + Content: "", + ReasoningContent: &rc, + ToolCalls: []dto.ToolCall{{ + ID: "call_abc", + Type: "function", + Function: dto.FunctionCall{ + Name: "get_weather", + Arguments: `{"city":"Paris"}`, + }, + }}, + }, + { + Role: "tool", + Content: "sunny", + ToolCallID: "call_abc", + }, + }, + } + res, err := c.ConvertRequest(context.Background(), nil, req) + if err != nil { + t.Fatalf("ConvertRequest: %v", err) + } + r := res.(*dto.OllamaChatRequest) + asst := r.Messages[0] + if string(asst.Thinking) != `"thinking text"` { + t.Errorf("assistant Thinking = %s, want \"thinking text\"", asst.Thinking) + } + if len(asst.ToolCalls) != 1 || asst.ToolCalls[0].ID != "call_abc" { + t.Fatalf("assistant ToolCalls = %+v", asst.ToolCalls) + } + args, ok := asst.ToolCalls[0].Function.Arguments.(map[string]any) + if !ok || args["city"] != "Paris" { + t.Errorf("tool call arguments = %#v", asst.ToolCalls[0].Function.Arguments) + } + toolMsg := r.Messages[1] + if toolMsg.ToolCallID != "call_abc" { + t.Errorf("tool ToolCallID = %q, want call_abc", toolMsg.ToolCallID) + } + if toolMsg.ToolName != "get_weather" { + t.Errorf("tool ToolName = %q, want get_weather (fallback from tool_calls)", toolMsg.ToolName) + } +} + +// ---------- 非流式响应转换:tool_calls / thinking ---------- + +func TestOllamaToOpenAIResponseConverter_ToolCallsAndThinking(t *testing.T) { + c := &OllamaToOpenAIResponseConverter{} + resp := &dto.OllamaChatResponse{ + Message: dto.OllamaMessage{ + Role: "assistant", + Content: "", + Thinking: json.RawMessage(`"step by step"`), + ToolCalls: []dto.OllamaToolCall{{ + ID: "call_upstream", + }}, + }, + PromptEvalCount: 5, + EvalCount: 7, + } + resp.Message.ToolCalls[0].Function.Name = "get_weather" + resp.Message.ToolCalls[0].Function.Arguments = map[string]any{"city": "Paris"} + + res, err := c.ConvertResponse(context.Background(), nil, resp) + if err != nil { + t.Fatalf("ConvertResponse: %v", err) + } + o := res.(*dto.ChatCompletionResponse) + msg := o.Choices[0].Message + if o.Choices[0].FinishReason != "tool_calls" { + t.Errorf("finish reason = %q, want tool_calls", o.Choices[0].FinishReason) + } + if msg.ReasoningContent == nil || *msg.ReasoningContent != "step by step" { + t.Errorf("reasoning content = %v", msg.ReasoningContent) + } + if len(msg.ToolCalls) != 1 { + t.Fatalf("tool calls = %+v", msg.ToolCalls) + } + tc := msg.ToolCalls[0] + if tc.ID != "call_upstream" || tc.Type != "function" { + t.Errorf("tool call = %+v", tc) + } + if tc.Function.Name != "get_weather" || tc.Function.Arguments != `{"city":"Paris"}` { + t.Errorf("tool call function = %+v", tc.Function) + } +} + +func TestOllamaToOpenAIResponseConverter_ThinkingOnly(t *testing.T) { + c := &OllamaToOpenAIResponseConverter{} + resp := &dto.OllamaChatResponse{ + Message: dto.OllamaMessage{Role: "assistant", Content: "answer", Thinking: json.RawMessage(`"draft"`)}, + } + res, err := c.ConvertResponse(context.Background(), nil, resp) + if err != nil { + t.Fatalf("ConvertResponse: %v", err) + } + o := res.(*dto.ChatCompletionResponse) + if o.Choices[0].FinishReason != "stop" { + t.Errorf("finish reason = %q, want stop", o.Choices[0].FinishReason) + } + if o.Choices[0].Message.ReasoningContent == nil || *o.Choices[0].Message.ReasoningContent != "draft" { + t.Errorf("reasoning content = %v", o.Choices[0].Message.ReasoningContent) + } + if len(o.Choices[0].Message.ToolCalls) != 0 { + t.Errorf("tool calls should be empty, got %+v", o.Choices[0].Message.ToolCalls) + } +} + +func TestOllamaToOpenAIResponseConverter_ThinkingNullOrEmpty(t *testing.T) { + c := &OllamaToOpenAIResponseConverter{} + for _, raw := range []string{"null", `""`} { + resp := &dto.OllamaChatResponse{ + Message: dto.OllamaMessage{Role: "assistant", Content: "answer", Thinking: json.RawMessage(raw)}, + } + res, err := c.ConvertResponse(context.Background(), nil, resp) + if err != nil { + t.Fatalf("ConvertResponse(thinking=%s): %v", raw, err) + } + o := res.(*dto.ChatCompletionResponse) + if o.Choices[0].Message.ReasoningContent != nil { + t.Errorf("thinking=%s: reasoning content = %q, want nil", raw, *o.Choices[0].Message.ReasoningContent) + } + } +} + +func TestOllamaToOpenAIResponseConverter_DoneReasonLength(t *testing.T) { + c := &OllamaToOpenAIResponseConverter{} + resp := &dto.OllamaChatResponse{ + Message: dto.OllamaMessage{Role: "assistant", Content: "partial"}, + Done: true, + DoneReason: "length", + } + res, err := c.ConvertResponse(context.Background(), nil, resp) + if err != nil { + t.Fatalf("ConvertResponse: %v", err) + } + o := res.(*dto.ChatCompletionResponse) + if o.Choices[0].FinishReason != "length" { + t.Errorf("finish reason = %q, want length", o.Choices[0].FinishReason) + } +} + +// ---------- 流式响应转换:tool_calls ---------- + +func TestOllamaToOpenAIStreamConverter_ToolCalls(t *testing.T) { + c := &OllamaToOpenAIStreamConverter{} + ndjson := `{"model":"llama3","message":{"role":"assistant","content":"","tool_calls":[{"id":"call_1","function":{"name":"get_weather","arguments":{"city":"Paris"}}}]},"done":false}` + "\n" + + `{"model":"llama3","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":10,"eval_count":5}` + "\n" + var chunks []*dto.ChatCompletionStreamResponse + err := c.ConvertStreamResponse(context.Background(), nil, bytes.NewReader([]byte(ndjson)), func(chunk any) error { + if sc, ok := chunk.(*dto.ChatCompletionStreamResponse); ok { + chunks = append(chunks, sc) + } + return nil + }) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + // role 首包 + tool_calls 增量 + finish(usage) 共三段 + if len(chunks) != 3 { + t.Fatalf("chunks = %d, want 3", len(chunks)) + } + toolDelta := chunks[1].Choices[0].Delta + if len(toolDelta.ToolCalls) != 1 { + t.Fatalf("tool delta = %+v", toolDelta.ToolCalls) + } + tc := toolDelta.ToolCalls[0] + if tc.Index != 0 || tc.ID != "call_1" || tc.Type != "function" { + t.Errorf("tool call = %+v", tc) + } + if tc.Function.Name != "get_weather" || tc.Function.Arguments != `{"city":"Paris"}` { + t.Errorf("tool call function = %+v", tc.Function) + } + last := chunks[2] + if last.Choices[0].FinishReason == nil || *last.Choices[0].FinishReason != "tool_calls" { + t.Errorf("finish reason = %v, want tool_calls", last.Choices[0].FinishReason) + } +} diff --git a/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_response.go b/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_response.go index 2921041..f16decb 100644 --- a/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_response.go +++ b/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_response.go @@ -2,6 +2,7 @@ package ollama_chat import ( "context" + "encoding/json" "fmt" "github.com/qianfree/team-api/relaykit/dto" @@ -46,18 +47,31 @@ func (c *OllamaToOpenAIResponseConverter) ConvertResponse( modelName = info.GetOriginModelName() } + message := dto.Message{ + Role: ollamaResp.Message.Role, + Content: ollamaResp.Message.Content, + } + if reasoning := ollamaThinkingToOpenAI(ollamaResp.Message.Thinking); reasoning != nil { + message.ReasoningContent = reasoning + } + if toolCalls := ollamaToolCallsToOpenAI(ollamaResp.Message.ToolCalls); len(toolCalls) > 0 { + message.ToolCalls = toolCalls + } + + finishReason := ollamaDoneReasonToFinishReason(ollamaResp.DoneReason) + if len(message.ToolCalls) > 0 { + finishReason = "tool_calls" + } + openaiResp := &dto.ChatCompletionResponse{ ID: fmt.Sprintf("chatcmpl-%d", getCurrentTimestamp()), Object: "chat.completion", Created: 0, Model: modelName, Choices: []dto.Choice{{ - Index: 0, - Message: dto.Message{ - Role: ollamaResp.Message.Role, - Content: ollamaResp.Message.Content, - }, - FinishReason: "stop", + Index: 0, + Message: message, + FinishReason: finishReason, }}, Usage: dto.UsageWithDetails{ PromptTokens: ollamaResp.PromptEvalCount, @@ -68,3 +82,56 @@ func (c *OllamaToOpenAIResponseConverter) ConvertResponse( return openaiResp, nil } + +// ollamaToolCallsToOpenAI 将 Ollama tool_calls(对象形式 arguments)转换为 +// OpenAI tool_calls(字符串编码 arguments)。 +func ollamaToolCallsToOpenAI(toolCalls []dto.OllamaToolCall) []dto.ToolCall { + out := make([]dto.ToolCall, 0, len(toolCalls)) + for i, tc := range toolCalls { + id := tc.ID + if id == "" { + id = fmt.Sprintf("call_%d", i) + } + argsBytes := []byte("{}") + if tc.Function.Arguments != nil { + if b, err := json.Marshal(tc.Function.Arguments); err == nil { + argsBytes = b + } + } + out = append(out, dto.ToolCall{ + ID: id, + Type: "function", + Function: dto.FunctionCall{ + Name: tc.Function.Name, + Arguments: string(argsBytes), + }, + }) + } + return out +} + +// ollamaThinkingToOpenAI 将 Ollama thinking 字段(JSON 字符串)转为 OpenAI reasoning_content。 +// 字段缺失、null、空串或非字符串时返回 nil,避免产出空的 reasoning_content。 +func ollamaThinkingToOpenAI(thinking json.RawMessage) *string { + if len(thinking) == 0 { + return nil + } + var s string + if err := json.Unmarshal(thinking, &s); err != nil { + return nil + } + // JSON null 反序列化不报错且保持零值,空思考内容同样不产出字段 + if s == "" { + return nil + } + return &s +} + +// ollamaDoneReasonToFinishReason 将 Ollama done_reason 映射为 OpenAI finish_reason。 +// stop/length 均为 OpenAI 合法取值,其余(如 load 等运行态原因)归一为 stop。 +func ollamaDoneReasonToFinishReason(doneReason string) string { + if doneReason == "length" { + return "length" + } + return "stop" +} diff --git a/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_stream.go b/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_stream.go index 862bf07..8f4a4af 100644 --- a/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_stream.go +++ b/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_stream.go @@ -78,6 +78,8 @@ func (c *OllamaToOpenAIStreamConverter) ConvertStreamResponse( roleChunkSent := false finishEmitted := false + sawToolCalls := false + nextToolCallIndex := 0 for scanner.Scan() { select { @@ -102,7 +104,10 @@ func (c *OllamaToOpenAIStreamConverter) ConvertStreamResponse( CompletionTokens: ollamaResp.EvalCount, TotalTokens: ollamaResp.PromptEvalCount + ollamaResp.EvalCount, } - reason := "stop" + reason := ollamaDoneReasonToFinishReason(ollamaResp.DoneReason) + if sawToolCalls { + reason = "tool_calls" + } if err := chunkWriter(newChunk(dto.Message{}, &reason, usage)); err != nil { return err } @@ -114,6 +119,13 @@ func (c *OllamaToOpenAIStreamConverter) ConvertStreamResponse( delta := dto.Message{ Content: ollamaResp.Message.Content, } + if thinking := ollamaThinkingToOpenAI(ollamaResp.Message.Thinking); thinking != nil { + delta.ReasoningContent = thinking + } + if toolCalls := ollamaStreamToolCalls(ollamaResp.Message.ToolCalls, &nextToolCallIndex); len(toolCalls) > 0 { + delta.ToolCalls = toolCalls + sawToolCalls = true + } if !roleChunkSent { delta.Role = "assistant" emptyContent := "" @@ -137,6 +149,9 @@ func (c *OllamaToOpenAIStreamConverter) ConvertStreamResponse( } } reason := "stop" + if sawToolCalls { + reason = "tool_calls" + } if err := chunkWriter(newChunk(dto.Message{}, &reason, nil)); err != nil { return err } @@ -147,3 +162,32 @@ func (c *OllamaToOpenAIStreamConverter) ConvertStreamResponse( } return nil } + +// ollamaStreamToolCalls 将 Ollama tool_calls 转换为 OpenAI 流式增量 delta, +// 为每个调用分配稳定递增的 index。 +func ollamaStreamToolCalls(toolCalls []dto.OllamaToolCall, nextIndex *int) []dto.ToolCall { + out := make([]dto.ToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + id := tc.ID + if id == "" { + id = fmt.Sprintf("call_%d", *nextIndex) + } + argsBytes := []byte("{}") + if tc.Function.Arguments != nil { + if b, err := json.Marshal(tc.Function.Arguments); err == nil { + argsBytes = b + } + } + out = append(out, dto.ToolCall{ + Index: *nextIndex, + ID: id, + Type: "function", + Function: dto.FunctionCall{ + Name: tc.Function.Name, + Arguments: string(argsBytes), + }, + }) + *nextIndex++ + } + return out +} diff --git a/relaykit/relayconvert/internal/ollama_chat/openai_to_ollama_request.go b/relaykit/relayconvert/internal/ollama_chat/openai_to_ollama_request.go index 8bc176f..9220548 100644 --- a/relaykit/relayconvert/internal/ollama_chat/openai_to_ollama_request.go +++ b/relaykit/relayconvert/internal/ollama_chat/openai_to_ollama_request.go @@ -6,6 +6,7 @@ package ollama_chat import ( "context" + "encoding/json" "fmt" "strings" @@ -53,13 +54,48 @@ func (c *OpenAIToOllamaRequestConverter) ConvertRequest( } } - // 消息转换:Ollama Content 为纯文本(多模态文本部分拼接) + // 消息转换:Ollama Content 为纯文本(多模态文本部分拼接)。 + // 保留跨轮次的工具调用与推理上下文: + // assistant 消息保留 tool_calls(含 id 与对象形式的 arguments), + // tool 消息保留 tool_call_id/tool_name,reasoning_content 转为 thinking 回传。 ollamaMessages := make([]dto.OllamaMessage, 0, len(openaiReq.Messages)) + toolNamesByCallID := make(map[string]string) for _, msg := range openaiReq.Messages { - ollamaMessages = append(ollamaMessages, dto.OllamaMessage{ + om := dto.OllamaMessage{ Role: msg.Role, Content: extractOllamaContent(msg.Content), - }) + } + if msg.Role == "tool" { + om.ToolCallID = msg.ToolCallID + om.ToolName = msg.Name + if om.ToolName == "" { + // 消息未带 name 时按 tool_call_id 从前面 assistant 的 tool_calls 反查工具名; + // Ollama 的 tool 消息靠 tool_name 关联结果(tool_call_id 仅原样透传)。 + om.ToolName = toolNamesByCallID[msg.ToolCallID] + } + } + if msg.Role == "assistant" { + if msg.ReasoningContent != nil { + thinking, err := json.Marshal(*msg.ReasoningContent) + if err != nil { + return nil, fmt.Errorf("marshal ollama thinking: %w", err) + } + om.Thinking = thinking + } + if len(msg.ToolCalls) > 0 { + calls, err := toOllamaToolCalls(msg.ToolCalls) + if err != nil { + return nil, err + } + for _, tc := range msg.ToolCalls { + if tc.ID != "" { + toolNamesByCallID[tc.ID] = tc.Function.Name + } + } + om.ToolCalls = calls + } + } + ollamaMessages = append(ollamaMessages, om) } stream := false @@ -67,11 +103,24 @@ func (c *OpenAIToOllamaRequestConverter) ConvertRequest( stream = info.GetIsStream() } + think, err := toOllamaThink(openaiReq.ReasoningEffort) + if err != nil { + return nil, err + } + + format, err := toOllamaResponseFormat(openaiReq.ResponseFormat) + if err != nil { + return nil, err + } + ollamaReq := &dto.OllamaChatRequest{ Model: model, Messages: ollamaMessages, Stream: stream, Options: buildOllamaOptions(openaiReq), + Tools: toOllamaTools(openaiReq.Tools), + Format: format, + Think: think, } return ollamaReq, nil @@ -139,3 +188,99 @@ func buildOllamaOptions(req *dto.GeneralOpenAIRequest) map[string]any { } return options } + +// toOllamaTools 将 OpenAI tools 转换为 Ollama tools(两者 function 格式一致)。 +func toOllamaTools(tools []dto.Tool) []dto.OllamaTool { + if len(tools) == 0 { + return nil + } + out := make([]dto.OllamaTool, 0, len(tools)) + for _, t := range tools { + if t.Type != "function" { + continue + } + out = append(out, dto.OllamaTool{ + Type: "function", + Function: dto.OllamaToolFunction{ + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: t.Function.Parameters, + }, + }) + } + if len(out) == 0 { + return nil + } + return out +} + +// toOllamaToolCalls 将 OpenAI tool_calls 转换为 Ollama tool_calls, +// 把字符串编码的 arguments 解码为 JSON 对象。 +func toOllamaToolCalls(toolCalls []dto.ToolCall) ([]dto.OllamaToolCall, error) { + out := make([]dto.OllamaToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + oc := dto.OllamaToolCall{ID: tc.ID} + oc.Function.Name = tc.Function.Name + var args any + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + return nil, fmt.Errorf("parse tool call arguments: %w", err) + } + } + if args == nil { + args = map[string]any{} + } + oc.Function.Arguments = args + out = append(out, oc) + } + return out, nil +} + +// toOllamaThink 将 reasoning_effort 映射为 Ollama think 参数。 +// "none" 关闭思考;low/medium/high/max 作为思考档位原样透传; +// 未知取值直接跳过(不注入 think)而非报错。 +func toOllamaThink(effort string) (json.RawMessage, error) { + if effort == "" { + return nil, nil + } + var value any + switch effort { + case "none": + value = false + case "low", "medium", "high", "max": + value = effort + default: + return nil, nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("marshal ollama think: %w", err) + } + return raw, nil +} + +// toOllamaResponseFormat 将 OpenAI response_format 映射为 Ollama format。 +func toOllamaResponseFormat(responseFormat *dto.ResponseFormat) (any, error) { + if responseFormat == nil { + return nil, nil + } + switch responseFormat.Type { + case "json", "json_object": + return "json", nil + case "json_schema": + return extractJSONSchema(responseFormat.JSONSchema), nil + default: + return nil, nil + } +} + +// extractJSONSchema 从 OpenAI json_schema 包装结构({name, description, schema, strict}) +// 中提取裸 schema;Ollama 的 format 字段期望 schema 对象本身。 +func extractJSONSchema(v any) any { + if m, ok := v.(map[string]any); ok { + if schema, ok := m["schema"]; ok { + return schema + } + } + return v +} From 5d25673c06aa883c43f336485d074b83499f7498 Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 16 Aug 2026 19:58:20 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(relay):=E5=AE=8C=E5=96=84responses?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/cmd.go | 4 + internal/handler/relay/responses.go | 53 +++++ .../channel/openai/adaptor_responses_test.go | 182 +++++++++++++++ relay/channel/openai/converter.go | 118 +++++++++- relay/channel/openai/converter_test.go | 215 ++++++++++++++++++ relay/channel/openai/responses.go | 103 +++++++-- relay/common/relay_info.go | 6 + relay/common/response_route.go | 107 +++++++++ relay/common/response_route_test.go | 31 +++ relay/constant/errors.go | 5 + relay/handler/dispatch_glue.go | 20 +- relay/handler/relay_handler.go | 17 ++ relay/handler/responses_lifecycle.go | 141 ++++++++++++ relay/handler/responses_lifecycle_test.go | 38 ++++ relaykit/dispatch/classify_test.go | 1 + relaykit/types/error.go | 4 + 16 files changed, 1016 insertions(+), 29 deletions(-) create mode 100644 internal/handler/relay/responses.go create mode 100644 relay/common/response_route.go create mode 100644 relay/common/response_route_test.go create mode 100644 relay/handler/responses_lifecycle.go create mode 100644 relay/handler/responses_lifecycle_test.go diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index e976bf7..986442d 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -431,6 +431,10 @@ func registerRelayRoutes(server *ghttp.Server) { group.POST("/completions", relay.HandleCompletions) group.POST("/responses", relay.HandleResponses) group.POST("/responses/compact", relay.HandleResponses) + // Responses 生命周期端点(Redis 路由还原渠道后透传上游) + group.GET("/responses/{id}", relay.HandleResponsesRetrieve) + group.POST("/responses/{id}/cancel", relay.HandleResponsesCancel) + group.DELETE("/responses/{id}", relay.HandleResponsesDelete) group.POST("/messages", relay.HandleMessages) group.POST("/audio/speech", relay.HandleAudioSpeech) group.POST("/audio/transcriptions", relay.HandleAudioTranscription) diff --git a/internal/handler/relay/responses.go b/internal/handler/relay/responses.go new file mode 100644 index 0000000..8e45b25 --- /dev/null +++ b/internal/handler/relay/responses.go @@ -0,0 +1,53 @@ +package relay + +import ( + "net/http" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + + relay_constant "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/handler" +) + +// HandleResponsesRetrieve 处理 GET /v1/responses/{id}(retrieve,background 模式轮询用) +func HandleResponsesRetrieve(r *ghttp.Request) { + handleResponsesLifecycle(r, http.MethodGet) +} + +// HandleResponsesCancel 处理 POST /v1/responses/{id}/cancel(取消进行中的响应) +func HandleResponsesCancel(r *ghttp.Request) { + handleResponsesLifecycle(r, http.MethodPost) +} + +// HandleResponsesDelete 处理 DELETE /v1/responses/{id}(删除已存储的响应) +func HandleResponsesDelete(r *ghttp.Request) { + handleResponsesLifecycle(r, http.MethodDelete) +} + +// handleResponsesLifecycle Responses 生命周期端点入口: +// 无请求体(ApiKeyAuth 纯 header 鉴权、ContentFilter 空 body 放行), +// 不走 validateRelayRequest(无 model),scope/IP 校验对齐 HandleModels 先例。 +func handleResponsesLifecycle(r *ghttp.Request, method string) { + responseID := r.Get("id").String() + if responseID == "" { + r.Response.WriteStatus(http.StatusBadRequest, g.Map{ + "error": g.Map{"type": "invalid_request_error", "message": "response id is required"}, + }) + return + } + + rc := buildRelayContext(r) + if !billingProvider.CheckScope(rc.Scope, "responses") { + handler.WriteRelayError(r.Response.Writer, relay_constant.NewAuthError("API key scope denied")) + return + } + if !billingProvider.CheckIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) { + handler.WriteRelayError(r.Response.Writer, relay_constant.NewAuthError("IP address is not allowed")) + return + } + + if err := handler.HandleResponsesLifecycle(r.Context(), method, responseID, rc, dataProvider); err != nil { + handler.WriteRelayError(r.Response.Writer, err) + } +} diff --git a/relay/channel/openai/adaptor_responses_test.go b/relay/channel/openai/adaptor_responses_test.go index 1077818..8125e44 100644 --- a/relay/channel/openai/adaptor_responses_test.go +++ b/relay/channel/openai/adaptor_responses_test.go @@ -257,6 +257,188 @@ func TestAdaptor_DoResponse_ResponsesUpstreamStream(t *testing.T) { } } +// fakeResponseRouteStore 路由存储 fake,用于断言 Record 调用 +type fakeResponseRouteStore struct { + calls []fakeRouteCall +} + +type fakeRouteCall struct { + tenantID int64 + responseID string + route common.ResponseRoute +} + +func (f *fakeResponseRouteStore) Record(_ context.Context, tenantID int64, responseID string, route common.ResponseRoute) { + f.calls = append(f.calls, fakeRouteCall{tenantID: tenantID, responseID: responseID, route: route}) +} + +func (f *fakeResponseRouteStore) Lookup(context.Context, int64, string) (common.ResponseRoute, bool) { + return common.ResponseRoute{}, false +} + +func (f *fakeResponseRouteStore) Delete(context.Context, int64, string) {} + +// TestChatCompletionToResponsesResponse_StoreFalseAndEcho 合成响应的保真度: +// store 恒为 false(合成响应不落上游存储,不可 retrieve); +// temperature/top_p/max_output_tokens/instructions 从请求快照 echo,快照缺失回退默认值。 +func TestChatCompletionToResponsesResponse_StoreFalseAndEcho(t *testing.T) { + buildChatResp := func() *dto.ChatCompletionResponse { + return &dto.ChatCompletionResponse{ + ID: "abc123", + Created: 1700000000, + Model: "gpt-4o", + Choices: []dto.Choice{{ + Index: 0, + Message: dto.Message{Role: "assistant", Content: "hello"}, + FinishReason: "stop", + }}, + Usage: dto.UsageWithDetails{ + PromptTokensDetails: &dto.TokenDetails{}, + CompletionTokenDetails: &dto.TokenDetails{}, + }, + } + } + + t.Run("echo from request snapshot", func(t *testing.T) { + temp, topP := 0.7, 0.9 + maxOut := uint(1024) + info := &common.RelayInfo{ + OriginModelName: "gpt-4o", + ResponsesRequest: &dto.OpenAIResponsesRequest{ + Temperature: &temp, + TopP: &topP, + MaxOutputTokens: &maxOut, + Instructions: json.RawMessage(`"be brief"`), + }, + } + resp := chatCompletionToResponsesResponse(buildChatResp(), info) + if resp.Store { + t.Error("store should be false(合成响应不可 retrieve)") + } + if resp.Temperature == nil || *resp.Temperature != 0.7 { + t.Errorf("temperature = %v, want 0.7", resp.Temperature) + } + if resp.TopP == nil || *resp.TopP != 0.9 { + t.Errorf("top_p = %v, want 0.9", resp.TopP) + } + if resp.MaxOutputTokens == nil || *resp.MaxOutputTokens != 1024 { + t.Errorf("max_output_tokens = %v, want 1024", resp.MaxOutputTokens) + } + if instr, ok := resp.Instructions.(json.RawMessage); !ok || string(instr) != `"be brief"` { + t.Errorf("instructions = %v, want echo", resp.Instructions) + } + }) + + t.Run("nil snapshot falls back to defaults", func(t *testing.T) { + info := &common.RelayInfo{OriginModelName: "gpt-4o"} + resp := chatCompletionToResponsesResponse(buildChatResp(), info) + if resp.Store { + t.Error("store should be false") + } + if resp.Temperature == nil || *resp.Temperature != 1.0 { + t.Errorf("temperature = %v, want default 1.0", resp.Temperature) + } + if resp.TopP == nil || *resp.TopP != 1.0 { + t.Errorf("top_p = %v, want default 1.0", resp.TopP) + } + }) +} + +// TestBuildResponsesObjectMap_StoreFalseEcho 流式合成 response 对象同样 store:false + echo。 +func TestBuildResponsesObjectMap_StoreFalseEcho(t *testing.T) { + temp := 0.5 + info := &common.RelayInfo{ + ResponsesRequest: &dto.OpenAIResponsesRequest{Temperature: &temp}, + } + m := buildResponsesObjectMap("resp_1", 1700000000, "completed", "gpt-4o", []any{}, nil, nil, info) + if m["store"] != false { + t.Errorf("store = %v, want false", m["store"]) + } + if m["temperature"] != 0.5 { + t.Errorf("temperature = %v, want 0.5", m["temperature"]) + } +} + +// TestAdaptor_DoResponse_ResponsesUpstreamNonStream_RecordsRoute 直连非流式响应: +// 解析出 response id 后记录 response_id → 渠道路由(tenant 隔离、lookupModel 口径)。 +func TestAdaptor_DoResponse_ResponsesUpstreamNonStream_RecordsRoute(t *testing.T) { + fake := &fakeResponseRouteStore{} + old := common.DefaultResponseRouteStore + common.DefaultResponseRouteStore = fake + t.Cleanup(func() { common.DefaultResponseRouteStore = old }) + + respBody := `{"id":"resp_route1","object":"response","status":"completed","output":[]}` + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(respBody)), + } + info := responsesUpstreamInfo(constant.RelayModeResponses, false) + info.TenantID = 42 + info.BaseModelName = "gpt-4o" + info.ChannelMeta.ChannelID = 7 + + rec := httptest.NewRecorder() + a := &Adaptor{} + if _, err := a.DoResponse(context.Background(), resp, info, rec); err != nil { + t.Fatalf("DoResponse error: %v", err) + } + if len(fake.calls) != 1 { + t.Fatalf("Record calls = %d, want 1", len(fake.calls)) + } + call := fake.calls[0] + if call.tenantID != 42 || call.responseID != "resp_route1" { + t.Errorf("call = %+v", call) + } + if call.route.ChannelID != 7 || call.route.ModelName != "gpt-4o" { + t.Errorf("route = %+v", call.route) + } +} + +// TestAdaptor_DoResponse_ResponsesUpstreamStream_RecordsRoute 直连流式响应: +// response.created 事件即记录路由(cancel 尽早可用),completed 再刷新。 +func TestAdaptor_DoResponse_ResponsesUpstreamStream_RecordsRoute(t *testing.T) { + fake := &fakeResponseRouteStore{} + old := common.DefaultResponseRouteStore + common.DefaultResponseRouteStore = fake + t.Cleanup(func() { common.DefaultResponseRouteStore = old }) + + ss := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_s1","object":"response","status":"in_progress"}}`, + "", + "event: response.completed", + `data: {"type":"response.completed","response":{"id":"resp_s1","status":"completed","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`, + "", + "data: [DONE]", + "", + }, "\n") + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(ss)), + } + info := responsesUpstreamInfo(constant.RelayModeResponses, true) + info.TenantID = 42 + info.BaseModelName = "gpt-4o" + info.ChannelMeta.ChannelID = 7 + + rec := httptest.NewRecorder() + a := &Adaptor{} + if _, err := a.DoResponse(context.Background(), resp, info, rec); err != nil { + t.Fatalf("DoResponse error: %v", err) + } + if len(fake.calls) < 2 { + t.Fatalf("Record calls = %d, want >=2(created + completed)", len(fake.calls)) + } + if fake.calls[0].responseID != "resp_s1" || fake.calls[0].tenantID != 42 { + t.Errorf("first call = %+v", fake.calls[0]) + } + if fake.calls[len(fake.calls)-1].route.ChannelID != 7 { + t.Errorf("last call route = %+v", fake.calls[len(fake.calls)-1].route) + } +} + // TestAdaptor_DoResponse_ResponsesUpstreamNonStream_Error 非 200 上游响应: // 错误体透传给客户端的同时必须返回 upstream error(驱动重试/渠道健康上报)。 func TestAdaptor_DoResponse_ResponsesUpstreamNonStream_Error(t *testing.T) { diff --git a/relay/channel/openai/converter.go b/relay/channel/openai/converter.go index 6f48aa4..2d95e0c 100644 --- a/relay/channel/openai/converter.go +++ b/relay/channel/openai/converter.go @@ -594,6 +594,18 @@ func ConvertResponsesToOpenAI(requestBody []byte, info *common.RelayInfo) (io.Re return nil, fmt.Errorf("parse responses request: %w", err) } + // 有状态请求快速失败:previous_response_id 的会话历史存储在上游 Responses 服务侧, + // chat-only 渠道无法还原(本网关不存储响应体),降级转换会静默丢失全部上下文。 + // 返回哨兵错误,由 relay_handler 按渠道级致命上报调度 FSM 换渠道。 + if req.PreviousResponseID != "" { + return nil, fmt.Errorf("stateful responses (previous_response_id) not supported by chat-only channels: %w", constant.ErrStatefulResponsesUnsupported) + } + + // stash 请求快照,供上游 chat 响应合成回 Responses 格式时 echo 请求参数 + if info != nil { + info.ResponsesRequest = &req + } + chatReq := make(map[string]any) if info.ChannelMeta.IsModelMapped { chatReq["model"] = info.ChannelMeta.UpstreamModelName @@ -655,6 +667,11 @@ func ConvertResponsesToOpenAI(requestBody []byte, info *common.RelayInfo) (io.Re if req.PromptCacheKey != "" { chatReq["prompt_cache_key"] = req.PromptCacheKey } + if len(req.Text) > 0 { + if rf := r2cParseTextFormat(req.Text); rf != nil { + chatReq["response_format"] = rf + } + } if len(req.FrequencyPenalty) > 0 { var v float64 if err := json.Unmarshal(req.FrequencyPenalty, &v); err == nil { @@ -693,6 +710,18 @@ type r2cContentPart struct { ImageURL string `json:"image_url,omitempty"` URL string `json:"url,omitempty"` Detail string `json:"detail,omitempty"` + // input_audio:Responses 为 {"type":"input_audio","input_audio":{"data","format"}}, + // chat 同形,原样透传 + InputAudio *r2cInputAudio `json:"input_audio,omitempty"` + // input_file:Responses 为扁平 {"type":"input_file","file_data","filename"}, + // 转换为 chat 的 {"type":"file","file":{"file_data","filename"}} + FileData string `json:"file_data,omitempty"` + Filename string `json:"filename,omitempty"` +} + +type r2cInputAudio struct { + Data string `json:"data,omitempty"` + Format string `json:"format,omitempty"` } func r2cConvertInputToMessages(input json.RawMessage) ([]map[string]any, error) { @@ -752,6 +781,24 @@ func r2cConvertMessage(item r2cInputItem) map[string]any { switch part.Type { case "input_text": chatParts = append(chatParts, map[string]any{"type": "text", "text": part.Text}) + case "input_audio": + // 音频输入:chat 的 input_audio 与 Responses 同形(input_audio:{data,format}) + if part.InputAudio != nil && part.InputAudio.Data != "" { + audio := map[string]any{"data": part.InputAudio.Data} + if part.InputAudio.Format != "" { + audio["format"] = part.InputAudio.Format + } + chatParts = append(chatParts, map[string]any{"type": "input_audio", "input_audio": audio}) + } + case "input_file": + // 文件输入:Responses 扁平 {file_data,filename} → chat 的 file:{file_data,filename} + if part.FileData != "" { + file := map[string]any{"file_data": part.FileData} + if part.Filename != "" { + file["filename"] = part.Filename + } + chatParts = append(chatParts, map[string]any{"type": "file", "file": file}) + } case "input_image": imageURL := part.ImageURL if imageURL == "" { @@ -817,6 +864,44 @@ func r2cConvertToolChoice(tcRaw json.RawMessage) any { return tc } +// r2cParseTextFormat 解析 Responses text.format(扁平 {type,name,schema,strict}) +// 为 chat 的 response_format(json_schema 时嵌套为 json_schema:{name,schema,strict})。 +// text 或未知类型返回 nil(chat 无对应字段,不映射)。 +func r2cParseTextFormat(raw json.RawMessage) map[string]any { + var textCfg struct { + Format struct { + Type string `json:"type"` + Name string `json:"name"` + Schema json.RawMessage `json:"schema"` + Strict *bool `json:"strict"` + } `json:"format"` + } + if err := json.Unmarshal(raw, &textCfg); err != nil { + return nil + } + switch textCfg.Format.Type { + case "json_object": + return map[string]any{"type": "json_object"} + case "json_schema": + jsonSchema := make(map[string]any, 3) + if textCfg.Format.Name != "" { + jsonSchema["name"] = textCfg.Format.Name + } + if len(textCfg.Format.Schema) > 0 { + var schema any + if err := json.Unmarshal(textCfg.Format.Schema, &schema); err == nil { + jsonSchema["schema"] = schema + } + } + if textCfg.Format.Strict != nil { + jsonSchema["strict"] = *textCfg.Format.Strict + } + return map[string]any{"type": "json_schema", "json_schema": jsonSchema} + default: + return nil + } +} + // ===== OpenAI → Responses 请求转换(Responses API Bridge) ===== // ConvertOpenAIToResponses 将 Chat Completions 请求体转换为 Responses API 请求体 @@ -857,8 +942,13 @@ func ConvertOpenAIToResponses(body []byte, info *common.RelayInfo) ([]byte, erro respReq["max_output_tokens"] = maxTokens } if req.ResponseFormat != nil { - respReq["text"] = map[string]any{"format": req.ResponseFormat} + if tf := c2rBuildTextFormat(req.ResponseFormat); tf != nil { + respReq["text"] = tf + } } + // 桥接的响应不可被 chat 客户端经 previous_response_id 引用(chat 协议无此概念), + // 显式 store:false 避免上游无谓存储;渠道配置 DisableStore 时 SanitizeFields 会删掉该字段 + respReq["store"] = false if req.ReasoningEffort != "" { respReq["reasoning"] = map[string]any{"effort": req.ReasoningEffort, "summary": "detailed"} } @@ -1077,6 +1167,32 @@ func c2rGetMaxTokens(req dto.GeneralOpenAIRequest) int { return max } +// c2rBuildTextFormat 将 chat 的 response_format 转换为 Responses 的 text 配置。 +// chat 的 json_schema 为嵌套 {type,json_schema:{name,schema,strict}}, +// Responses 的 format 为扁平 {type,name,schema,strict}——需解包提升,不能原样塞入。 +// 其余类型(json_object)两侧同形;无法识别时返回 nil 不映射。 +func c2rBuildTextFormat(rf *dto.ResponseFormat) map[string]any { + if rf == nil { + return nil + } + switch rf.Type { + case "json_object": + return map[string]any{"format": map[string]any{"type": "json_object"}} + case "json_schema": + format := map[string]any{"type": "json_schema"} + if js, ok := rf.JSONSchema.(map[string]any); ok { + for _, k := range []string{"name", "schema", "strict"} { + if v, ok := js[k]; ok { + format[k] = v + } + } + } + return map[string]any{"format": format} + default: + return nil + } +} + // ===== Responses → Chat 响应转换 ===== // ResponsesResponseToChatCompletions 将 Responses API 非流式响应转换为 Chat Completions 响应 diff --git a/relay/channel/openai/converter_test.go b/relay/channel/openai/converter_test.go index e29f978..bfc7781 100644 --- a/relay/channel/openai/converter_test.go +++ b/relay/channel/openai/converter_test.go @@ -2,10 +2,12 @@ package openai import ( "encoding/json" + "errors" "io" "testing" "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" ) @@ -254,3 +256,216 @@ func TestConvertOpenAIToResponses_PenaltyDropped(t *testing.T) { t.Error("input should be present") } } + +// TestConvertResponsesToOpenAI_PreviousResponseIDRejected 有状态请求(previous_response_id) +// 落在 chat-only 渠道时必须快速失败:降级转换会静默丢失全部会话上下文。 +// 错误须携带哨兵以供 relay_handler 识别并驱动调度 FSM 换渠道。 +func TestConvertResponsesToOpenAI_PreviousResponseIDRejected(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + body := []byte(`{"model":"gpt-4o","input":"hi","previous_response_id":"resp_prev"}`) + _, err := ConvertResponsesToOpenAI(body, info) + if err == nil { + t.Fatal("previous_response_id should be rejected on chat-only conversion") + } + if !errors.Is(err, constant.ErrStatefulResponsesUnsupported) { + t.Errorf("error should wrap ErrStatefulResponsesUnsupported, got: %v", err) + } +} + +// TestConvertResponsesToOpenAI_TextFormat Responses text.format(扁平结构)转 chat +// response_format(json_schema 嵌套结构);text 类型不映射。 +func TestConvertResponsesToOpenAI_TextFormat(t *testing.T) { + cases := []struct { + name string + text string + want map[string]any + }{ + { + name: "json_object", + text: `{"format":{"type":"json_object"}}`, + want: map[string]any{"type": "json_object"}, + }, + { + name: "json_schema", + text: `{"format":{"type":"json_schema","name":"out","schema":{"type":"object"},"strict":true}}`, + want: map[string]any{ + "type": "json_schema", + "json_schema": map[string]any{ + "name": "out", + "schema": map[string]any{"type": "object"}, + "strict": true, + }, + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + body := []byte(`{"model":"gpt-4o","input":"hi","text":` + c.text + `}`) + out, err := ConvertResponsesToOpenAI(body, info) + if err != nil { + t.Fatalf("ConvertResponsesToOpenAI: %v", err) + } + raw, _ := io.ReadAll(out) + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("bad json: %v\n%s", err, raw) + } + got, ok := m["response_format"].(map[string]any) + if !ok { + t.Fatalf("response_format missing or wrong type: %v", m["response_format"]) + } + wantJSON, _ := json.Marshal(c.want) + gotJSON, _ := json.Marshal(got) + if string(gotJSON) != string(wantJSON) { + t.Errorf("response_format = %s, want %s", gotJSON, wantJSON) + } + }) + } + + t.Run("text format not mapped", func(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + body := []byte(`{"model":"gpt-4o","input":"hi","text":{"format":{"type":"text"}}}`) + out, err := ConvertResponsesToOpenAI(body, info) + if err != nil { + t.Fatalf("ConvertResponsesToOpenAI: %v", err) + } + raw, _ := io.ReadAll(out) + var m map[string]any + _ = json.Unmarshal(raw, &m) + if _, ok := m["response_format"]; ok { + t.Error("text format should not map to response_format") + } + }) +} + +// TestConvertResponsesToOpenAI_StashesResponsesRequest 转换时 stash 请求快照, +// 供上游 chat 响应合成回 Responses 格式时 echo 请求参数。 +func TestConvertResponsesToOpenAI_StashesResponsesRequest(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + temp := 0.7 + // 先填充再转换,断言转换覆盖为最新请求体 + info.ResponsesRequest = &dto.OpenAIResponsesRequest{Model: "stale"} + body := []byte(`{"model":"gpt-4o","input":"hi","temperature":0.7}`) + if _, err := ConvertResponsesToOpenAI(body, info); err != nil { + t.Fatalf("ConvertResponsesToOpenAI: %v", err) + } + if info.ResponsesRequest == nil { + t.Fatal("ResponsesRequest should be stashed") + } + if info.ResponsesRequest.Model != "gpt-4o" { + t.Errorf("stashed model = %q, want gpt-4o", info.ResponsesRequest.Model) + } + if info.ResponsesRequest.Temperature == nil || *info.ResponsesRequest.Temperature != temp { + t.Errorf("stashed temperature = %v, want 0.7", info.ResponsesRequest.Temperature) + } +} + +// TestConvertResponsesToOpenAI_InputAudioAndFile 多模态输入透传: +// input_audio 与 Responses 同形透传;input_file 扁平结构转 chat 的 file 嵌套结构。 +func TestConvertResponsesToOpenAI_InputAudioAndFile(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + body := []byte(`{"model":"gpt-4o","input":[{"type":"message","role":"user","content":[` + + `{"type":"input_text","text":"listen"},` + + `{"type":"input_audio","input_audio":{"data":"QUJD","format":"wav"}},` + + `{"type":"input_file","file_data":"data:text/plain;base64,aGk=","filename":"a.txt"}` + + `]}]}`) + out, err := ConvertResponsesToOpenAI(body, info) + if err != nil { + t.Fatalf("ConvertResponsesToOpenAI: %v", err) + } + raw, _ := io.ReadAll(out) + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("bad json: %v\n%s", err, raw) + } + msgs, _ := m["messages"].([]any) + if len(msgs) != 1 { + t.Fatalf("messages = %v", msgs) + } + parts, _ := msgs[0].(map[string]any)["content"].([]any) + if len(parts) != 3 { + t.Fatalf("content parts = %v, want 3(audio/file 不再被丢弃)", parts) + } + audio, _ := parts[1].(map[string]any) + if audio["type"] != "input_audio" { + t.Errorf("audio part = %v", audio) + } + ia, _ := audio["input_audio"].(map[string]any) + if ia["data"] != "QUJD" || ia["format"] != "wav" { + t.Errorf("input_audio = %v", ia) + } + file, _ := parts[2].(map[string]any) + if file["type"] != "file" { + t.Errorf("file part = %v", file) + } + f, _ := file["file"].(map[string]any) + if f["file_data"] != "data:text/plain;base64,aGk=" || f["filename"] != "a.txt" { + t.Errorf("file = %v", f) + } +} + +// TestConvertOpenAIToResponses_TextFormatUnpack chat response_format 转 Responses +// text.format:json_schema 需解包为扁平结构(不能原样塞入嵌套形状)。 +func TestConvertOpenAIToResponses_TextFormatUnpack(t *testing.T) { + t.Run("json_schema", func(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],` + + `"response_format":{"type":"json_schema","json_schema":{"name":"out","schema":{"type":"object"},"strict":true}}}`) + out, err := ConvertOpenAIToResponses(body, info) + if err != nil { + t.Fatalf("ConvertOpenAIToResponses: %v", err) + } + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("bad json: %v\n%s", err, out) + } + text, _ := m["text"].(map[string]any) + format, _ := text["format"].(map[string]any) + if format["type"] != "json_schema" { + t.Fatalf("format = %v", format) + } + if format["name"] != "out" { + t.Errorf("format.name = %v, want out", format["name"]) + } + if _, nested := format["json_schema"]; nested { + t.Error("format should be flat(json_schema 嵌套必须解包)") + } + if s, _ := format["schema"].(map[string]any); s == nil { + t.Errorf("format.schema = %v", format["schema"]) + } + }) + t.Run("json_object", func(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_object"}}`) + out, err := ConvertOpenAIToResponses(body, info) + if err != nil { + t.Fatalf("ConvertOpenAIToResponses: %v", err) + } + var m map[string]any + _ = json.Unmarshal(out, &m) + text, _ := m["text"].(map[string]any) + format, _ := text["format"].(map[string]any) + if format["type"] != "json_object" { + t.Errorf("format = %v", format) + } + }) +} + +// TestConvertOpenAIToResponses_StoreFalse 桥接方向显式 store:false: +// chat 客户端无法经 previous_response_id 引用响应,无需上游存储。 +func TestConvertOpenAIToResponses_StoreFalse(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`) + out, err := ConvertOpenAIToResponses(body, info) + if err != nil { + t.Fatalf("ConvertOpenAIToResponses: %v", err) + } + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("bad json: %v\n%s", err, out) + } + if v, ok := m["store"]; !ok || v != false { + t.Errorf("store = %v(%T), want explicit false", m["store"], m["store"]) + } +} diff --git a/relay/channel/openai/responses.go b/relay/channel/openai/responses.go index 748e988..392d825 100644 --- a/relay/channel/openai/responses.go +++ b/relay/channel/openai/responses.go @@ -72,9 +72,42 @@ func (a *Adaptor) handleResponsesInboundNonStream(ctx context.Context, resp *htt }, nil } +// responsesRequestEcho 合成 Responses 响应时需回显(echo)的请求参数 +type responsesRequestEcho struct { + temperature *float64 + topP *float64 + maxOutputTokens *int + instructions any +} + +// extractResponsesRequestEcho 从 info.ResponsesRequest 提取合成响应应 echo 的请求参数。 +// 快照缺失(直连路径/异常)时回退 OpenAI 默认值(temperature=1.0 / top_p=1.0,其余 nil)。 +func extractResponsesRequestEcho(info *common.RelayInfo) responsesRequestEcho { + echo := responsesRequestEcho{temperature: float64Ptr(1.0), topP: float64Ptr(1.0)} + if info == nil || info.ResponsesRequest == nil { + return echo + } + rr := info.ResponsesRequest + if rr.Temperature != nil { + echo.temperature = rr.Temperature + } + if rr.TopP != nil { + echo.topP = rr.TopP + } + if rr.MaxOutputTokens != nil { + m := int(*rr.MaxOutputTokens) + echo.maxOutputTokens = &m + } + if len(rr.Instructions) > 0 { + echo.instructions = json.RawMessage(rr.Instructions) + } + return echo +} + // chatCompletionToResponsesResponse 将 Chat Completions 响应转换为 Responses API 响应 func chatCompletionToResponsesResponse(chatResp *dto.ChatCompletionResponse, info *common.RelayInfo) *dto.OpenAIResponsesResponse { modelName := info.OriginModelName + echo := extractResponsesRequestEcho(info) // 构建 output output := make([]dto.ResponsesOutput, 0) @@ -129,22 +162,23 @@ func chatCompletionToResponsesResponse(chatResp *dto.ChatCompletionResponse, inf Status: json.RawMessage(`"completed"`), Error: nil, IncompleteDetails: nil, - Instructions: nil, - MaxOutputTokens: nil, + Instructions: echo.instructions, + MaxOutputTokens: echo.maxOutputTokens, Model: modelName, Output: output, ParallelToolCalls: true, PreviousResponseID: nil, Reasoning: &dto.ResponsesReasoning{Effort: nil, Summary: nil}, - Store: true, - Temperature: float64Ptr(1.0), - Text: &dto.ResponsesText{Format: dto.ResponsesTextFormat{Type: "text"}}, - ToolChoice: "auto", - Tools: make([]any, 0), - TopP: float64Ptr(1.0), - Truncation: "disabled", - User: nil, - Metadata: make(map[string]any), + // store:false 是真实语义:合成响应不落上游存储,客户端不可经 GET /v1/responses/{id} retrieve + Store: false, + Temperature: echo.temperature, + Text: &dto.ResponsesText{Format: dto.ResponsesTextFormat{Type: "text"}}, + ToolChoice: "auto", + Tools: make([]any, 0), + TopP: echo.topP, + Truncation: "disabled", + User: nil, + Metadata: make(map[string]any), Usage: &dto.ResponsesUsage{ InputTokens: chatResp.Usage.PromptTokens, OutputTokens: chatResp.Usage.CompletionTokens, @@ -279,7 +313,7 @@ func (a *Adaptor) handleResponsesInboundStream(ctx context.Context, resp *http.R // response.created emitResponsesSSE(writer, "response.created", map[string]any{ "type": "response.created", - "response": buildResponsesObjectMap(respID, createdAt, "in_progress", modelName, []any{}, nil, nil), + "response": buildResponsesObjectMap(respID, createdAt, "in_progress", modelName, []any{}, nil, nil, info), }) // response.output_item.added @@ -567,7 +601,7 @@ func (a *Adaptor) handleResponsesInboundStream(ctx context.Context, resp *http.R completedAt := int(time.Now().Unix()) emitResponsesSSE(writer, "response.completed", map[string]any{ "type": "response.completed", - "response": buildResponsesObjectMap(respID, createdAt, "completed", modelName, finalOutput, buildResponsesUsageMap(&usage), &completedAt), + "response": buildResponsesObjectMap(respID, createdAt, "completed", modelName, finalOutput, buildResponsesUsageMap(&usage), &completedAt, info), }) if info.StreamStatus.GetEndReason() == "" { @@ -609,8 +643,11 @@ func emitResponsesSSE(w http.ResponseWriter, eventType string, data any) { } } -// buildResponsesObjectMap 构建 Responses API response 对象的完整字段 map -func buildResponsesObjectMap(respID string, createdAt int, status string, model string, output any, usageObj map[string]any, completedAt *int) map[string]any { +// buildResponsesObjectMap 构建 Responses API response 对象的完整字段 map。 +// 请求参数从 info.ResponsesRequest echo(快照缺失时回退默认值); +// store 恒为 false——合成响应不落上游存储,客户端不可经生命周期端点 retrieve。 +func buildResponsesObjectMap(respID string, createdAt int, status string, model string, output any, usageObj map[string]any, completedAt *int, info *common.RelayInfo) map[string]any { + echo := extractResponsesRequestEcho(info) m := map[string]any{ "id": respID, "object": "response", @@ -618,19 +655,19 @@ func buildResponsesObjectMap(respID string, createdAt int, status string, model "status": status, "error": nil, "incomplete_details": nil, - "instructions": nil, - "max_output_tokens": nil, + "instructions": echo.instructions, + "max_output_tokens": echo.maxOutputTokens, "model": model, "output": output, "parallel_tool_calls": true, "previous_response_id": nil, "reasoning": map[string]any{"effort": nil, "summary": nil}, - "store": true, - "temperature": 1.0, + "store": false, + "temperature": *echo.temperature, "text": map[string]any{"format": map[string]any{"type": "text"}}, "tool_choice": "auto", "tools": []any{}, - "top_p": 1.0, + "top_p": *echo.topP, "truncation": "disabled", "user": nil, "metadata": map[string]any{}, @@ -708,6 +745,19 @@ func responsesUsageToCommon(u *dto.ResponsesUsage) *common.Usage { return usage } +// recordResponseRoute 记录 response_id → 渠道路由(Redis),供 GET/DELETE/cancel +// 生命周期端点还原原始请求落到的渠道。ModelName 存 lookupModel 口径(BaseModelName), +// 与 MaterializeSelection 入参一致。 +func recordResponseRoute(ctx context.Context, info *common.RelayInfo, responseID string) { + if responseID == "" || info == nil || info.ChannelMeta == nil { + return + } + common.DefaultResponseRouteStore.Record(ctx, info.TenantID, responseID, common.ResponseRoute{ + ChannelID: info.ChannelMeta.ChannelID, + ModelName: info.BaseModelName, + }) +} + // handleResponsesUpstreamNonStream 上游为 Responses 协议时的非流式响应: // 解析 usage 后原样透传上游响应体(模型映射时回写客户端请求的模型名)。 func (a *Adaptor) handleResponsesUpstreamNonStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { @@ -740,6 +790,7 @@ func (a *Adaptor) handleResponsesUpstreamNonStream(ctx context.Context, resp *ht // 解析 usage(计费用),响应体仍透传上游原始内容 var responsesResp dto.OpenAIResponsesResponse if err := json.Unmarshal(body, &responsesResp); err == nil { + recordResponseRoute(ctx, info, responsesResp.ID) return responsesUsageToCommon(responsesResp.Usage), nil } return &common.Usage{}, nil @@ -817,10 +868,14 @@ func (a *Adaptor) handleResponsesUpstreamStream(ctx context.Context, resp *http. var streamResp dto.ResponsesStreamResponse if err := json.Unmarshal([]byte(data), &streamResp); err == nil { switch streamResp.Type { - case "response.completed", "response.done": - if r := streamResp.Response; r != nil && r.Usage != nil { - if u := responsesUsageToCommon(r.Usage); u != nil { - usage = *u + case "response.created", "response.completed", "response.done": + if r := streamResp.Response; r != nil { + // 路由记录:created 即记录使 cancel 尽早可用,completed/done 刷新 TTL(SET 幂等) + recordResponseRoute(ctx, info, r.ID) + if streamResp.Type != "response.created" && r.Usage != nil { + if u := responsesUsageToCommon(r.Usage); u != nil { + usage = *u + } } } case "response.output_text.delta": diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index acfb6c3..78c5c61 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -6,6 +6,7 @@ import ( "time" "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" "github.com/qianfree/team-api/relaykit/types" ) @@ -129,6 +130,11 @@ type RelayInfo struct { // UseResponsesAPI 桥接标志:客户端发送 Chat Completions,但请求应通过 Responses API 发送到上游 UseResponsesAPI bool + // ResponsesRequest responses 入站请求快照(ConvertResponsesToOpenAI 解析后 stash), + // 供 chat 上游响应合成回 Responses 格式时 echo temperature/top_p/max_output_tokens/instructions。 + // 直连(原生 Responses 上游)路径不填充,合成时保持默认值。 + ResponsesRequest *dto.OpenAIResponsesRequest + // Thinking 后缀路由(从模型名解析,供适配器消费) ThinkingEnabled bool // 是否有 -thinking 后缀 ThinkingDisabled bool // 是否有 -nothinking 后缀 diff --git a/relay/common/response_route.go b/relay/common/response_route.go new file mode 100644 index 0000000..2460d53 --- /dev/null +++ b/relay/common/response_route.go @@ -0,0 +1,107 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/gogf/gf/v2/database/gredis" + "github.com/gogf/gf/v2/frame/g" +) + +// ResponseRoute response_id → 渠道路由记录,供生命周期端点 +// (GET /v1/responses/{id}、POST .../cancel、DELETE)还原原始请求落到的渠道。 +// ModelName 存 lookupModel 口径(去 thinking 后缀),与 MaterializeSelection 入参一致。 +type ResponseRoute struct { + ChannelID int64 `json:"channel_id"` + ModelName string `json:"model_name"` +} + +// ResponseRouteStore 路由存储接口(测试可注入 fake) +type ResponseRouteStore interface { + // Record 记录路由(幂等 SET,重复调用刷新 TTL)。热路径调用,失败只记日志不中断。 + Record(ctx context.Context, tenantID int64, responseID string, route ResponseRoute) + // Lookup 查询路由;miss 或存储不可用返回 false(调用方按 404 处理) + Lookup(ctx context.Context, tenantID int64, responseID string) (ResponseRoute, bool) + // Delete 删除路由(DELETE 生命周期端点成功后调用) + Delete(ctx context.Context, tenantID int64, responseID string) +} + +// DefaultResponseRouteStore 包级单例,adaptor 响应处理与 lifecycle handler 共用 +var DefaultResponseRouteStore ResponseRouteStore = &redisResponseRouteStore{} + +// responseRouteTTL 路由保留期,与 OpenAI Responses 存储保留期(30 天)对齐; +// 超期后上游本身也不再可 retrieve,路由条目自然过期即可。 +const responseRouteTTL = 30 * 24 * time.Hour + +// responseRouteKey 路由按租户隔离,防止跨租户探测 response_id +func responseRouteKey(tenantID int64, responseID string) string { + return fmt.Sprintf("relay:resp:route:%d:%s", tenantID, responseID) +} + +// redisResponseRouteStore Redis 实现。Redis 未配置/不可用时全程降级 no-op: +// Record 静默丢弃、Lookup 恒 miss、Delete 静默——生命周期端点退化为"不可用"而非报错。 +type redisResponseRouteStore struct{} + +var ( + redisProbeOnce sync.Once + redisClient *gredis.Redis +) + +// redisClientOrNil 返回 Redis 客户端。注意 gf v2 的 g.Redis() 在未配置 redis 时 +// 会 panic(gins 懒加载抛错)而非返回 nil,故以 recover 探测并缓存结果(仅探测一次, +// 配置在进程内不动态增删 redis 节点,探测失败后全程按不可用处理)。 +func redisClientOrNil() *gredis.Redis { + redisProbeOnce.Do(func() { + defer func() { + // g.Redis() panic → 保持 redisClient = nil + _ = recover() + }() + redisClient = g.Redis() + }) + return redisClient +} + +func (s *redisResponseRouteStore) Record(ctx context.Context, tenantID int64, responseID string, route ResponseRoute) { + r := redisClientOrNil() + if r == nil || responseID == "" { + return + } + val, err := json.Marshal(route) + if err != nil { + g.Log().Debugf(ctx, "[ResponseRoute] marshal route failed: %v", err) + return + } + if _, err := r.Do(ctx, "SET", responseRouteKey(tenantID, responseID), string(val), "EX", int(responseRouteTTL.Seconds())); err != nil { + // 路由记录失败不影响响应转发,仅损失后续 retrieve/cancel 能力 + g.Log().Debugf(ctx, "[ResponseRoute] record failed: responseID=%s err=%v", responseID, err) + } +} + +func (s *redisResponseRouteStore) Lookup(ctx context.Context, tenantID int64, responseID string) (ResponseRoute, bool) { + r := redisClientOrNil() + if r == nil || responseID == "" { + return ResponseRoute{}, false + } + res, err := r.Do(ctx, "GET", responseRouteKey(tenantID, responseID)) + if err != nil || res.IsNil() { + return ResponseRoute{}, false + } + var route ResponseRoute + if err := json.Unmarshal([]byte(res.String()), &route); err != nil { + return ResponseRoute{}, false + } + return route, true +} + +func (s *redisResponseRouteStore) Delete(ctx context.Context, tenantID int64, responseID string) { + r := redisClientOrNil() + if r == nil || responseID == "" { + return + } + if _, err := r.Do(ctx, "DEL", responseRouteKey(tenantID, responseID)); err != nil { + g.Log().Debugf(ctx, "[ResponseRoute] delete failed: responseID=%s err=%v", responseID, err) + } +} diff --git a/relay/common/response_route_test.go b/relay/common/response_route_test.go new file mode 100644 index 0000000..03fe694 --- /dev/null +++ b/relay/common/response_route_test.go @@ -0,0 +1,31 @@ +package common + +import ( + "context" + "testing" +) + +// TestResponseRouteKey_Format 路由 key 按租户隔离、包含 response_id。 +func TestResponseRouteKey_Format(t *testing.T) { + got := responseRouteKey(42, "resp_abc") + want := "relay:resp:route:42:resp_abc" + if got != want { + t.Errorf("responseRouteKey = %q, want %q", got, want) + } +} + +// TestResponseRouteStore_NilRedisNoop Redis 未配置(单测环境)时全程降级 no-op: +// Record/Delete 不 panic,Lookup 恒 miss。 +func TestResponseRouteStore_NilRedisNoop(t *testing.T) { + store := DefaultResponseRouteStore + ctx := context.Background() + + // 不应 panic + store.Record(ctx, 1, "resp_x", ResponseRoute{ChannelID: 1, ModelName: "gpt-4o"}) + store.Delete(ctx, 1, "resp_x") + + // Lookup 在无 Redis(或 Redis 不可达)时返回 miss,不报错 + if _, ok := store.Lookup(ctx, 1, "resp_missing"); ok { + t.Error("Lookup should miss when redis is unavailable") + } +} diff --git a/relay/constant/errors.go b/relay/constant/errors.go index 58b1095..652f5df 100644 --- a/relay/constant/errors.go +++ b/relay/constant/errors.go @@ -17,6 +17,11 @@ import ( // Relay 层哨兵错误 var ErrAllChannelsFailed = gerror.New("all channels failed") +// ErrStatefulResponsesUnsupported responses 有状态请求(previous_response_id)落在 +// 不支持 Responses 协议的 chat-only 渠道上,降级转换会静默丢失会话上下文,必须快速失败。 +// 由 ConvertResponsesToOpenAI 返回,relay_handler 捕获后按渠道级致命上报调度 FSM 换渠道。 +var ErrStatefulResponsesUnsupported = gerror.New("previous_response_id requires a responses-native channel") + // RelayError 包装 relay 层错误,携带上游返回的状态码和信息 type RelayError struct { StatusCode int diff --git a/relay/handler/dispatch_glue.go b/relay/handler/dispatch_glue.go index 8dafef6..d92fd1a 100644 --- a/relay/handler/dispatch_glue.go +++ b/relay/handler/dispatch_glue.go @@ -66,15 +66,27 @@ func deliveryStateOfRequestErr(err error) dispatch.DeliveryState { return dispatch.DeliveryMaybeSent } -// reportMaterializeFailure 选择物化失败(Key 解密失败/目录缺失)时按渠道级致命上报, -// 让 FSM 决定换渠道或终止。不发起过上游请求,送达状态为 NotSent。 -func reportMaterializeFailure(ctx context.Context, sess *dispatch.RouteSession, mErr error) dispatch.RetryDecision { - failErr := types.NewError(mErr, types.ErrorCodeChannelNoAvailableKey) +// reportChannelFatal 渠道级致命错误上报(请求未送达上游),让 FSM 决定换渠道或终止。 +// code 必须为 channel: 前缀错误码(classify 归 ErrClassChannelFatal → failover)。 +func reportChannelFatal(ctx context.Context, sess *dispatch.RouteSession, err error, code types.ErrorCode) dispatch.RetryDecision { + failErr := types.NewError(err, code) decision, _ := sess.Report(ctx, 0, failErr, dispatch.DeliveryNotSent, 0, 0) trackRetryDecision(0, failErr, dispatch.DeliveryNotSent, decision) return decision } +// reportMaterializeFailure 选择物化失败(Key 解密失败/目录缺失)时按渠道级致命上报, +// 让 FSM 决定换渠道或终止。不发起过上游请求,送达状态为 NotSent。 +func reportMaterializeFailure(ctx context.Context, sess *dispatch.RouteSession, mErr error) dispatch.RetryDecision { + return reportChannelFatal(ctx, sess, mErr, types.ErrorCodeChannelNoAvailableKey) +} + +// reportProtocolMismatch 渠道协议能力不匹配(如 responses 有状态请求落在 chat-only +// 渠道)时上报:换渠道可能得到支持 Responses 协议的渠道,不等于渠道本身故障。 +func reportProtocolMismatch(ctx context.Context, sess *dispatch.RouteSession, err error) dispatch.RetryDecision { + return reportChannelFatal(ctx, sess, err, types.ErrorCodeChannelProtocolMismatch) +} + // sleepBackoff 执行退避等待,客户端断开时立即返回。 func sleepBackoff(ctx context.Context, d time.Duration) { if d <= 0 { diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index fe284e9..ef0495e 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -586,6 +586,23 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He // 转换请求(直连模式跳过协议转换和参数改写) convertedBody, err := convertRequestBody(ctx, info, body, adaptor) if err != nil { + // responses 有状态请求(previous_response_id)落在 chat-only 渠道:协议能力不匹配 + // 而非请求错误,换渠道可能命中 Responses 原生渠道——按渠道级致命上报驱动 failover, + // 而非直接返回(直接返回会让有能力渠道存在时也失败) + if errors.Is(err, constant.ErrStatefulResponsesUnsupported) { + channelErrors = append(channelErrors, fmt.Sprintf("attempt=%d channel=%d(%s) model=%s convert_error=[%v]", + attempt, selection.ChannelID, selection.ChannelName, v.modelName, err)) + g.Log().Warningf(ctx, "[RelayHandler] Stateful responses not supported by chat-only channel, failing over: channel=%d(%s) model=%s attempt=%d", + selection.ChannelID, selection.ChannelName, v.modelName, attempt) + if reportProtocolMismatch(ctx, sess, err) == dispatch.DecisionAbort { + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + } + return nil, v.billingResult, constant.NewRequestError( + "previous_response_id is only supported on responses-native channels; no eligible channel remains for this model. Resend the full conversation input without previous_response_id.", nil) + } + continue + } if billing != nil && preDeductAmount > 0 { _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) } diff --git a/relay/handler/responses_lifecycle.go b/relay/handler/responses_lifecycle.go new file mode 100644 index 0000000..835237d --- /dev/null +++ b/relay/handler/responses_lifecycle.go @@ -0,0 +1,141 @@ +package handler + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/channel" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Responses 生命周期动作(POST {id}/cancel 以 method 区分,其余按 HTTP method 透传) +const ( + responsesLifecycleCancel = "/cancel" +) + +// HandleResponsesLifecycle 处理 Responses 生命周期端点: +// GET /v1/responses/{id}(retrieve)、POST /v1/responses/{id}/cancel、DELETE /v1/responses/{id}。 +// 经 Redis 路由表(response_id → 渠道,Record 于直连转发时写入)还原原始渠道后透传上游。 +// 无请求体、无计费(对齐异步任务查询端点先例);错误以 *constant.RelayError 返回, +// 由调用方经 WriteRelayError 写为 OpenAI 格式。 +func HandleResponsesLifecycle(ctx context.Context, method string, responseID string, rc *RelayContext, provider common.DataProvider) error { + if responseID == "" { + return constant.NewRequestError("response id is required", nil) + } + + // 1. 查路由:miss(未记录/已过期/Redis 不可用)或渠道已不可物化均按 404 处理 + route, ok := common.DefaultResponseRouteStore.Lookup(ctx, rc.TenantID, responseID) + if !ok { + return &constant.RelayError{StatusCode: http.StatusNotFound, Message: "Response not found", Type: "invalid_request_error"} + } + + selection, err := provider.MaterializeSelection(ctx, route.ChannelID, 0, route.ModelName) + if err != nil { + g.Log().Debugf(ctx, "[ResponsesLifecycle] materialize channel failed: responseID=%s channel=%d err=%v", responseID, route.ChannelID, err) + return &constant.RelayError{StatusCode: http.StatusNotFound, Message: "Response not found", Type: "invalid_request_error"} + } + + adaptor := channel.GetAdaptor(selection.ChannelType) + if adaptor == nil { + return constant.NewUpstreamError(http.StatusInternalServerError, fmt.Sprintf("unsupported channel type: %d", selection.ChannelType), nil) + } + + info := buildLifecycleRelayInfo(rc, selection) + info.RequestURLPath = "/responses/" + responseID + // 守卫:路由记录只产生于 UpstreamSpeaksResponses 渠道,此处防御 chat-only 渠道 + //(GetRequestURL 会返回 /v1/chat/completions 导致拼错 URL) + if !info.ChannelMeta.UpstreamSpeaksResponses() { + return &constant.RelayError{StatusCode: http.StatusNotFound, Message: "Response not found", Type: "invalid_request_error"} + } + adaptor.Init(info) + + upstreamURL, err := buildLifecycleUpstreamURL(adaptor, info, responseID, method == http.MethodPost) + if err != nil { + return constant.NewUpstreamError(http.StatusInternalServerError, err.Error(), nil) + } + + httpReq, err := http.NewRequestWithContext(ctx, method, upstreamURL, nil) + if err != nil { + return constant.NewUpstreamError(http.StatusInternalServerError, "create lifecycle request failed", err) + } + if err := adaptor.SetupRequestHeader(httpReq.Header, info); err != nil { + return constant.NewUpstreamError(http.StatusInternalServerError, "setup request header failed", err) + } + // 与标准转发链路一致:应用渠道级 Header Override + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + client := common.NewPooledClient(info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode), info.ChannelMeta.Settings.UseProxy) + resp, err := client.Do(httpReq) + if err != nil { + return constant.NewUpstreamError(http.StatusBadGateway, "upstream lifecycle request failed", err) + } + defer resp.Body.Close() + + // 原样透传上游响应(状态码 + Content-Type + body) + if ct := resp.Header.Get("Content-Type"); ct != "" { + rc.Writer.Header().Set("Content-Type", ct) + } + rc.Writer.WriteHeader(resp.StatusCode) + _, _ = io.Copy(rc.Writer, resp.Body) + + // DELETE 成功后清理路由记录(上游已不可 retrieve) + if method == http.MethodDelete && resp.StatusCode >= 200 && resp.StatusCode < 300 { + common.DefaultResponseRouteStore.Delete(ctx, rc.TenantID, responseID) + } + return nil +} + +// buildLifecycleRelayInfo 构建生命周期转发的最小 RelayInfo(不经调度 FSM, +// 字段拷贝自路由记录物化的 ChannelSelection,照 buildRelayInfo 的 ChannelMeta 部分; +// RequestURLPath 由调用方按目标 responseID 覆写) +func buildLifecycleRelayInfo(rc *RelayContext, selection *common.ChannelSelection) *common.RelayInfo { + return &common.RelayInfo{ + Context: context.Background(), + TenantID: rc.TenantID, + UserID: rc.UserID, + ApiKeyID: rc.ApiKeyID, + ProjectID: rc.ProjectID, + RequestID: rc.RequestID, + RelayMode: int(constant.RelayModeResponses), + OriginModelName: selection.UpstreamModelName, + StartTime: time.Now(), + ChannelMeta: &common.ChannelMeta{ + ChannelID: selection.ChannelID, + ChannelType: selection.ChannelType, + ChannelName: selection.ChannelName, + BaseURL: selection.BaseURL, + ApiKey: selection.ApiKey, + UpstreamModelName: selection.UpstreamModelName, + IsModelMapped: selection.IsModelMapped, + Settings: selection.Settings, + + SupportsResponses: selection.SupportsResponses, + ChatViaResponses: selection.ChatViaResponses, + }, + } +} + +// buildLifecycleUpstreamURL 构建生命周期端点的上游 URL: +// 复用 adaptor.GetRequestURL(RelayModeResponses + UpstreamSpeaksResponses 时返回 +// POST 基址,如 openai 的 /v1/responses、codex 的 /backend-api/codex/responses), +// 追加 /{responseID}(cancel 再追加 /cancel),对各 Responses 上游天然形状正确。 +func buildLifecycleUpstreamURL(adaptor common.Adaptor, info *common.RelayInfo, responseID string, isCancel bool) (string, error) { + base, err := adaptor.GetRequestURL(info) + if err != nil { + return "", err + } + url := base + "/" + responseID + if isCancel { + url += responsesLifecycleCancel + } + return url, nil +} diff --git a/relay/handler/responses_lifecycle_test.go b/relay/handler/responses_lifecycle_test.go new file mode 100644 index 0000000..5a71754 --- /dev/null +++ b/relay/handler/responses_lifecycle_test.go @@ -0,0 +1,38 @@ +package handler + +import ( + "testing" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// TestBuildLifecycleUpstreamURL 生命周期端点上游 URL:复用 adaptor 的 Responses +// POST 基址追加 /{id}(cancel 再追加 /cancel),对 openai 形状的渠道天然正确。 +func TestBuildLifecycleUpstreamURL(t *testing.T) { + info := &common.RelayInfo{ + RelayMode: int(constant.RelayModeResponses), + ChannelMeta: &common.ChannelMeta{ + BaseURL: "https://up.example.com", + SupportsResponses: true, + }, + } + a := &openai.Adaptor{} + + got, err := buildLifecycleUpstreamURL(a, info, "resp_1", false) + if err != nil { + t.Fatalf("buildLifecycleUpstreamURL: %v", err) + } + if want := "https://up.example.com/v1/responses/resp_1"; got != want { + t.Errorf("retrieve URL = %q, want %q", got, want) + } + + got, err = buildLifecycleUpstreamURL(a, info, "resp_1", true) + if err != nil { + t.Fatalf("buildLifecycleUpstreamURL(cancel): %v", err) + } + if want := "https://up.example.com/v1/responses/resp_1/cancel"; got != want { + t.Errorf("cancel URL = %q, want %q", got, want) + } +} diff --git a/relaykit/dispatch/classify_test.go b/relaykit/dispatch/classify_test.go index 02ef042..3916d7f 100644 --- a/relaykit/dispatch/classify_test.go +++ b/relaykit/dispatch/classify_test.go @@ -49,6 +49,7 @@ func TestClassify_NewAPIError错误码(t *testing.T) { }{ {"invalid_key 归凭证", types.ErrorCodeChannelInvalidKey, ErrClassCredential}, {"no_available_key 归渠道级", types.ErrorCodeChannelNoAvailableKey, ErrClassChannelFatal}, + {"protocol_mismatch 归渠道级(驱动换渠道)", types.ErrorCodeChannelProtocolMismatch, ErrClassChannelFatal}, {"model_not_found 归渠道级", types.ErrorCodeModelNotFound, ErrClassChannelFatal}, {"response_time_exceeded 归超时", types.ErrorCodeChannelResponseTimeExceeded, ErrClassTimeout}, {"aws_client_error 归渠道级", types.ErrorCodeChannelAwsClientError, ErrClassChannelFatal}, diff --git a/relaykit/types/error.go b/relaykit/types/error.go index 5cfc097..b53bdca 100644 --- a/relaykit/types/error.go +++ b/relaykit/types/error.go @@ -59,6 +59,10 @@ const ( ErrorCodeChannelAwsClientError ErrorCode = "channel:aws_client_error" ErrorCodeChannelInvalidKey ErrorCode = "channel:invalid_key" ErrorCodeChannelResponseTimeExceeded ErrorCode = "channel:response_time_exceeded" + // ErrorCodeChannelProtocolMismatch 渠道协议能力不匹配:如 responses 有状态请求 + // (previous_response_id)落在仅支持 chat 协议的渠道上,无法经转换等价表达。 + // 归类渠道级致命(channel: 前缀规则),驱动调度 FSM 换渠道。 + ErrorCodeChannelProtocolMismatch ErrorCode = "channel:protocol_mismatch" // 客户端请求错误 ErrorCodeReadRequestBodyFailed ErrorCode = "read_request_body_failed" From bb0de90181b6836d06ab1553030014f2b7026978 Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 16 Aug 2026 20:11:49 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(log):=E4=BF=AE=E5=A4=8D=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E5=86=99=E5=85=A5=E9=81=87=E5=88=B0=E7=89=B9=E6=AE=8A?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E6=8A=A5=E9=94=99=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/common/usage_log_writer.go | 23 +++++++++- internal/logic/relay/provider.go | 50 ++++++++++++-------- internal/logic/relay/provider_test.go | 56 +++++++++++++++++++++++ 3 files changed, 107 insertions(+), 22 deletions(-) diff --git a/internal/logic/common/usage_log_writer.go b/internal/logic/common/usage_log_writer.go index e8ff3a9..1850fc5 100644 --- a/internal/logic/common/usage_log_writer.go +++ b/internal/logic/common/usage_log_writer.go @@ -239,8 +239,27 @@ func (w *UsageLogWriter) flushBatch(records []any) { ctx := gctx.New() _, err := w.dbInstance().Model(w.table).Ctx(ctx).Data(batch).Batch(len(batch)).Insert() if err != nil { - w.failed.Add(int64(len(batch))) - g.Log().Errorf(ctx, "usage_log_writer: batch insert %d records failed: %v", len(batch), err) + // 批量写入失败(如某条记录含非法 UTF-8 字节被 PG 整体拒绝)时降级为逐条写入, + // 避免一条脏数据拖垮整批记录(usage_logs 为计费数据,audit_logs 不允许丢失); + // 单条仍失败才丢弃该条,并汇总打日志。 + g.Log().Errorf(ctx, "usage_log_writer: batch insert %d records failed: %v, fallback to per-record insert", len(batch), err) + saved := 0 + var firstErr error + for _, rec := range batch { + if _, e := w.dbInstance().Model(w.table).Ctx(ctx).Data(rec).Insert(); e != nil { + if firstErr == nil { + firstErr = e + } + continue + } + saved++ + } + w.completed.Add(int64(saved)) + dropped := len(batch) - saved + w.failed.Add(int64(dropped)) + if firstErr != nil { + g.Log().Errorf(ctx, "usage_log_writer: %d/%d records dropped in per-record fallback, first error: %v", dropped, len(batch), firstErr) + } return } w.completed.Add(int64(len(batch))) diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 180495e..27f9696 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -346,6 +346,16 @@ func (p *DataProviderImpl) RecordUsage(ctx context.Context, record *common.Usage }) } +// sanitizeUTF8 清洗字符串字段:将非法 UTF-8 字节替换为 U+FFFD 替换符。 +// 上游错误文本/请求头/回显模型名等可能夹带 0xa0 等单字节脏数据,PostgreSQL 在协议层 +// 会以 invalid byte sequence 拒绝整批插入,必须在落库前清洗保证字符串合法。 +func sanitizeUTF8(s string) string { + if utf8.ValidString(s) { + return s + } + return strings.ToValidUTF8(s, "�") +} + // buildUsageLogDO 将 UsageRecord 转换为 DO 对象 func buildUsageLogDO(record *common.UsageRecord) do.BilUsageLogs { requestType := record.RequestType @@ -363,23 +373,23 @@ func buildUsageLogDO(record *common.UsageRecord) do.BilUsageLogs { ApiKeyId: record.ApiKeyID, ProjectId: record.ProjectID, ChannelId: record.ChannelID, - ModelName: record.ModelName, - RequestId: record.RequestID, + ModelName: sanitizeUTF8(record.ModelName), + RequestId: sanitizeUTF8(record.RequestID), RelayMode: record.RelayMode, InputTokens: record.PromptTokens, OutputTokens: record.CompletionTokens, TotalCost: record.TotalCost, LatencyMs: int(record.LatencyMs), - Status: record.Status, - ErrorMessage: record.ErrorMessage, - ClientIp: record.ClientIP, + Status: sanitizeUTF8(record.Status), + ErrorMessage: sanitizeUTF8(record.ErrorMessage), + ClientIp: sanitizeUTF8(record.ClientIP), InputCost: record.InputCost, OutputCost: record.OutputCost, CacheCreationCost: record.CacheCreationCost, CacheReadCost: record.CacheReadCost, ActualCost: record.ActualCost, - Currency: record.Currency, + Currency: sanitizeUTF8(record.Currency), PreDeductAmount: record.PreDeductAmount, RefundAmount: record.RefundAmount, SupplementAmount: record.SupplementAmount, @@ -392,32 +402,32 @@ func buildUsageLogDO(record *common.UsageRecord) do.BilUsageLogs { ImageOutputTokens: record.ImageOutputTokens, ReasoningTokens: record.ReasoningTokens, - RequestedModel: record.RequestedModel, - UpstreamModel: record.UpstreamModel, + RequestedModel: sanitizeUTF8(record.RequestedModel), + UpstreamModel: sanitizeUTF8(record.UpstreamModel), RequestType: requestType, - UserAgent: record.UserAgent, + UserAgent: sanitizeUTF8(record.UserAgent), FirstTokenMs: record.FirstTokenMs, - ServiceTier: record.ServiceTier, - ReasoningEffort: record.ReasoningEffort, - InboundEndpoint: record.InboundEndpoint, + ServiceTier: sanitizeUTF8(record.ServiceTier), + ReasoningEffort: sanitizeUTF8(record.ReasoningEffort), + InboundEndpoint: sanitizeUTF8(record.InboundEndpoint), - ChannelName: record.ChannelName, + ChannelName: sanitizeUTF8(record.ChannelName), ChannelType: record.ChannelType, - BillingMode: record.BillingMode, - BillingSource: record.BillingSource, + BillingMode: sanitizeUTF8(record.BillingMode), + BillingSource: sanitizeUTF8(record.BillingSource), RateMultiplier: record.RateMultiplier, RetryIndex: record.RetryIndex, - StreamEndReason: record.StreamEndReason, + StreamEndReason: sanitizeUTF8(record.StreamEndReason), ImageCount: record.ImageCount, - ImageSize: record.ImageSize, + ImageSize: sanitizeUTF8(record.ImageSize), - BillingSnapshot: jsonNullIfEmpty(record.BillingSnapshot), - BillingSummary: record.BillingSummary, - TaskId: record.TaskID, + BillingSnapshot: jsonNullIfEmpty(sanitizeUTF8(record.BillingSnapshot)), + BillingSummary: sanitizeUTF8(record.BillingSummary), + TaskId: sanitizeUTF8(record.TaskID), } } diff --git a/internal/logic/relay/provider_test.go b/internal/logic/relay/provider_test.go index 45c3f77..db2a274 100644 --- a/internal/logic/relay/provider_test.go +++ b/internal/logic/relay/provider_test.go @@ -2,6 +2,7 @@ package relay import ( "fmt" + "reflect" "strings" "testing" "time" @@ -224,3 +225,58 @@ func TestBuildUsageLogDO_FieldMapping(t *testing.T) { t.Errorf("non-empty BillingSnapshot should pass through, got %v", got) } } + +func TestBuildUsageLogDO_SanitizesInvalidUTF8(t *testing.T) { + // 构造带原始 0xa0 字节(非法 UTF-8,PG 协议层会整体拒绝插入)的 UsageRecord, + // 验证 buildUsageLogDO 已对所有字符串字段清洗为合法 UTF-8。 + bad := "pre\xa0post" + rec := &common.UsageRecord{ + TenantID: 7, + ModelName: bad, + RequestID: bad, + Status: bad, + ErrorMessage: bad, + ClientIP: bad, + Currency: bad, + RequestedModel: bad, + UpstreamModel: bad, + UserAgent: bad, + ServiceTier: bad, + ReasoningEffort: bad, + InboundEndpoint: bad, + ChannelName: bad, + BillingMode: bad, + BillingSource: bad, + StreamEndReason: bad, + ImageSize: bad, + BillingSnapshot: `{"err":"` + bad + `"}`, + BillingSummary: bad, + TaskID: bad, + } + + d := buildUsageLogDO(rec) + + // 逐一校验所有字符串字段 + v := reflect.ValueOf(d) + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if field.Kind() != reflect.Interface || field.IsNil() { + continue + } + s, ok := field.Interface().(string) + if !ok { + continue + } + if !utf8.ValidString(s) { + t.Errorf("field %s still contains invalid UTF-8: %q", v.Type().Field(i).Name, s) + } + if strings.Contains(s, "\xa0") { + t.Errorf("field %s still contains raw 0xa0 byte: %q", v.Type().Field(i).Name, s) + } + } + + // 校验清洗结果:0xa0 被替换为 U+FFFD + if got, _ := d.ErrorMessage.(string); got != "pre�post" { + t.Errorf("ErrorMessage = %q, want %q", got, "pre�post") + } +} From f482cadac95de856820a8a954f9589b8e94fc194 Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 16 Aug 2026 23:24:19 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix(log):=E4=BF=AE=E5=A4=8D=E5=90=84?= =?UTF-8?q?=E4=B8=AA=E5=8D=8F=E8=AE=AE=E8=BD=AC=E6=8D=A2=E6=97=B6=E7=BC=93?= =?UTF-8?q?=E5=AD=98token=E6=95=B0=E4=B8=A2=E5=A4=B1=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/goframe-conventions.md | 12 ++ internal/cmd/cmd.go | 1 + internal/handler/setup/setup_test.go | 10 +- internal/middleware/request_id.go | 10 ++ internal/middleware/request_id_test.go | 58 ++++++++ internal/response/response_test.go | 8 +- internal/testutil/gfserver.go | 38 ++++++ relay/channel/gemini/response.go | 3 + relay/channel/gemini/response_test.go | 29 ++++ .../channel/openai/adaptor_responses_test.go | 3 + relay/channel/openai/claude_response.go | 42 +++++- relay/channel/openai/claude_response_test.go | 126 ++++++++++++++++++ relay/channel/openai/converter.go | 16 ++- relay/channel/openai/converter_test.go | 31 +++++ relay/relaykit_bridge/response.go | 16 ++- relay/relaykit_bridge/response_test.go | 15 ++- relay/relaykit_bridge/stream.go | 9 +- relay/relaykit_bridge/stream_test.go | 52 +++++++- .../oai_chat/claude_to_openai_response.go | 6 + .../oai_chat/claude_to_openai_stream.go | 6 + .../oai_gemini/gemini_to_openai_response.go | 10 ++ .../oai_gemini/gemini_to_openai_stream.go | 6 + 22 files changed, 480 insertions(+), 27 deletions(-) create mode 100644 internal/middleware/request_id_test.go create mode 100644 internal/testutil/gfserver.go create mode 100644 relay/channel/openai/claude_response_test.go diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index 4bc5d68..9667650 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -699,3 +699,15 @@ err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error { | 长流水线编排(relay handler) | 入口与各昂贵阶段前 `ctx.Err()` fail-fast;下游报错先判 `errors.Is(err, context.Canceled)`,勿映射为业务错误("无可用渠道"/"余额不足"等) | 排查信号:同一 request_id 在日志里留下一串不同模块、不同语义的报错,尾部都是 `context canceled`——说明缺 fail-fast 入口闸门,且各模块把取消误当成了自己领域的故障。 + +### 2026-08-16:httptest.NewServer 直接包裹未 Start 的 g.Server,每个请求都在 Session.Close() panic + +**问题**:`go test -v` 下凡是用 `httptest.NewServer(g.Server(guid.S()))` 方式测 HTTP 处理逻辑的用例,每个请求都向 stderr 刷一段 `http: panic serving ... nil pointer dereference`(`gsession.(*Session).Close`)。测试仍然 PASS——响应在 panic 前已完整写出、panic 发生在 deferred `handleAfterRequestDone` 中且被 net/http recover——导致问题长期被掩盖(不带 `-v` 时 `go test` 丢弃通过用例的输出)。涉及 `internal/middleware`、`internal/response`、`internal/handler/setup` 三处。 + +**原因**:gf v2.10.2 只在 `Server.Start()` 中初始化 `sessionManager`(ghttp_server.go),而 `ServeHTTP` 对每个请求无条件执行 `request.Session.Close()`,后者第一行就解引用 `s.manager.storage`。未经 `Start()` 的 server 其 `sessionManager` 为 nil → `Session.manager` 为 nil → 必 panic。无导出 API 可以单独初始化 manager,绕不开。 + +**修复**:新增共享测试辅助 `internal/testutil.StartGFServer(t, s)`——把 server 绑定到 `127.0.0.1:0`(内核随机分配端口避免冲突,绑定回环地址避免 Windows 防火墙弹窗)走真实 `s.Start()`,返回 `http://127.0.0.1:{port}` 供用例直接请求,`t.Cleanup` 注册 `s.Shutdown()`。`Start()` 返回时 listener 已创建完毕(`startServer` 内 `wg.Wait()` 等待 `CreateListener` 完成后才返回),`GetListenedPort()` 立即可用,无需轮询。三处测试全部改用该辅助。 + +**正确做法**:测试中需要真实走 ghttp 请求链路(中间件、handler、hook)时,一律用 `testutil.StartGFServer(t, s)` 启动真实 server;**禁止**把 `g.Server` 直接塞进 `httptest.NewServer`——那只会执行 `ServeHTTP` 路径而不执行 `Start()` 的初始化(session manager、graceful listener 等),属于未定义用法。`httptest.NewServer` 仅用于包装自建的普通 `http.Handler`。 + +排查信号:`go test -v` 输出中出现成段的 `http: panic serving` + `gsession.(*Session).Close` 栈。 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 986442d..eb2a3b1 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -149,6 +149,7 @@ var ( // Global middleware s.Use(middleware.Recovery) s.Use(middleware.RequestId) + s.Use(middleware.ServiceName) // Setup mode guard: block all requests until initialization is complete s.Use(func(r *ghttp.Request) { diff --git a/internal/handler/setup/setup_test.go b/internal/handler/setup/setup_test.go index 3bbd618..72b0aa1 100644 --- a/internal/handler/setup/setup_test.go +++ b/internal/handler/setup/setup_test.go @@ -3,7 +3,6 @@ package setup_test import ( "encoding/json" "net/http" - "net/http/httptest" "strings" "testing" @@ -12,15 +11,14 @@ import ( "github.com/qianfree/team-api/internal/consts" setuphandler "github.com/qianfree/team-api/internal/handler/setup" + "github.com/qianfree/team-api/internal/testutil" ) func TestHandleSetupInitializeValidationErrors(t *testing.T) { s := g.Server(guid.S()) - s.SetDumpRouterMap(false) s.BindHandler("/initialize", setuphandler.HandleSetupInitialize) - server := httptest.NewServer(s) - defer server.Close() + baseURL := testutil.StartGFServer(t, s) tests := []struct { name string @@ -46,8 +44,8 @@ func TestHandleSetupInitializeValidationErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - res, err := server.Client().Post( - server.URL+"/initialize", + res, err := http.Post( + baseURL+"/initialize", "application/json", strings.NewReader(tt.body), ) diff --git a/internal/middleware/request_id.go b/internal/middleware/request_id.go index c3741fd..05c6add 100644 --- a/internal/middleware/request_id.go +++ b/internal/middleware/request_id.go @@ -26,3 +26,13 @@ func RequestId(r *ghttp.Request) { r.Middleware.Next() } + +// serviceName 随响应头 X-Service-Name 返回的稳定服务标识。 +const serviceName = "team-api" + +// ServiceName 在每个响应上写入服务标识头 X-Service-Name, +// 与 RequestId 同属全局注册的响应头中间件。 +func ServiceName(r *ghttp.Request) { + r.Response.Header().Set("X-Service-Name", serviceName) + r.Middleware.Next() +} diff --git a/internal/middleware/request_id_test.go b/internal/middleware/request_id_test.go new file mode 100644 index 0000000..e991518 --- /dev/null +++ b/internal/middleware/request_id_test.go @@ -0,0 +1,58 @@ +package middleware + +import ( + "net/http" + "testing" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/util/guid" + + "github.com/qianfree/team-api/internal/testutil" +) + +// TestGlobalResponseHeaders 验证两个全局响应头中间件: +// RequestId 生成/透传 X-Request-Id,ServiceName 在所有响应上写入 X-Service-Name。 +func TestGlobalResponseHeaders(t *testing.T) { + server := g.Server(guid.S()) + server.Use(RequestId) + server.Use(ServiceName) + server.BindHandler("/ping", func(r *ghttp.Request) { + r.Response.WriteHeader(http.StatusNoContent) + }) + + baseURL := testutil.StartGFServer(t, server) + + t.Run("服务端生成请求 ID 并写入服务标识头", func(t *testing.T) { + resp, err := http.Get(baseURL + "/ping") + if err != nil { + t.Fatalf("GET /ping: %v", err) + } + defer resp.Body.Close() + + if got := resp.Header.Get("X-Service-Name"); got != serviceName { + t.Errorf("X-Service-Name = %q, want %q", got, serviceName) + } + if got := resp.Header.Get("X-Request-Id"); !clientRequestIdRegexp.MatchString(got) { + t.Errorf("X-Request-Id = %q, want 服务端生成的合法 ID", got) + } + }) + + t.Run("合法的客户端请求 ID 被透传", func(t *testing.T) { + const clientID = "client-req-123" + req, err := http.NewRequest(http.MethodGet, baseURL+"/ping", nil) + if err != nil { + t.Fatalf("构造请求失败: %v", err) + } + req.Header.Set("X-Request-Id", clientID) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET /ping: %v", err) + } + defer resp.Body.Close() + + if got := resp.Header.Get("X-Request-Id"); got != clientID { + t.Errorf("X-Request-Id = %q, want %q", got, clientID) + } + }) +} diff --git a/internal/response/response_test.go b/internal/response/response_test.go index 31468de..4c8567c 100644 --- a/internal/response/response_test.go +++ b/internal/response/response_test.go @@ -3,7 +3,6 @@ package response_test import ( "encoding/json" "net/http" - "net/http/httptest" "testing" "github.com/gogf/gf/v2/frame/g" @@ -11,11 +10,11 @@ import ( "github.com/gogf/gf/v2/util/guid" apiresponse "github.com/qianfree/team-api/internal/response" + "github.com/qianfree/team-api/internal/testutil" ) func TestErrorResponseStatusAndCode(t *testing.T) { s := g.Server(guid.S()) - s.SetDumpRouterMap(false) s.BindHandler("/standard", func(r *ghttp.Request) { apiresponse.ErrorMsg(r, http.StatusBadRequest, "bad request") }) @@ -26,8 +25,7 @@ func TestErrorResponseStatusAndCode(t *testing.T) { apiresponse.ErrorWithCode(r, 10079, 10079, "invalid username") }) - server := httptest.NewServer(s) - defer server.Close() + baseURL := testutil.StartGFServer(t, s) tests := []struct { name string @@ -57,7 +55,7 @@ func TestErrorResponseStatusAndCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - res, err := server.Client().Get(server.URL + tt.path) + res, err := http.Get(baseURL + tt.path) if err != nil { t.Fatalf("GET %s: %v", tt.path, err) } diff --git a/internal/testutil/gfserver.go b/internal/testutil/gfserver.go new file mode 100644 index 0000000..74482be --- /dev/null +++ b/internal/testutil/gfserver.go @@ -0,0 +1,38 @@ +// Package testutil 提供跨包共享的测试辅助工具。 +package testutil + +import ( + "fmt" + "testing" + + "github.com/gogf/gf/v2/net/ghttp" +) + +// StartGFServer 在 127.0.0.1 的随机端口上真实启动 ghttp.Server, +// 返回可访问的基础 URL,并通过 t.Cleanup 在测试结束后关闭服务。 +// +// 必须走真实 Start(),禁止把 g.Server 直接塞进 httptest.NewServer: +// gf 只在 Start() 中初始化 sessionManager(ghttp_server.go),未经 Start 的 +// server 处理请求时会在 handleAfterRequestDone 的 Session.Close() 中解引用 +// nil manager 而 panic——响应虽已写出、panic 被 net/http recover,测试侥幸 +// 通过,但每个请求都会向 stderr 刷一段 panic 栈。 +// +// 绑定 127.0.0.1 避免触发 Windows 防火墙弹窗;端口 0 由内核分配避免冲突。 +func StartGFServer(t *testing.T, s *ghttp.Server) string { + t.Helper() + s.SetAddr("127.0.0.1:0") + s.SetDumpRouterMap(false) + if err := s.Start(); err != nil { + t.Fatalf("启动测试 server 失败: %v", err) + } + port := s.GetListenedPort() + if port <= 0 { + t.Fatalf("测试 server 未监听端口: %d", port) + } + t.Cleanup(func() { + if err := s.Shutdown(); err != nil { + t.Logf("关闭测试 server 失败: %v", err) + } + }) + return fmt.Sprintf("http://127.0.0.1:%d", port) +} diff --git a/relay/channel/gemini/response.go b/relay/channel/gemini/response.go index 23a1a01..e16ac59 100644 --- a/relay/channel/gemini/response.go +++ b/relay/channel/gemini/response.go @@ -648,6 +648,9 @@ func geminiUsageToCommon(um *dto.GeminiUsageMetadata) *common.Usage { PromptTokens: um.PromptTokenCount, CompletionTokens: um.CandidatesTokenCount, TotalTokens: um.TotalTokenCount, + // Gemini 的 promptTokenCount 已含 cachedContentTokenCount(cached 为其子集), + // 置 true 让计费先扣减缓存部分,避免「input 全价 + cache 价」双重计费 + CacheIncludedInPrompt: true, PromptTokensDetails: &common.TokenDetails{ CachedTokens: um.CachedContentTokenCount, }, diff --git a/relay/channel/gemini/response_test.go b/relay/channel/gemini/response_test.go index 04e4679..7ac54a1 100644 --- a/relay/channel/gemini/response_test.go +++ b/relay/channel/gemini/response_test.go @@ -3,6 +3,8 @@ package gemini import ( "strings" "testing" + + "github.com/qianfree/team-api/relay/dto" ) // TestBuildGeminiUpstreamError_ResourceExhausted 验证真实 Gemini 429(区域配额耗尽) @@ -39,3 +41,30 @@ func TestBuildGeminiUpstreamError_FallbackStatusCode(t *testing.T) { t.Errorf("StatusCode = %d, want 503 (fallback)", err.StatusCode) } } + +// TestGeminiUsageToCommon_CacheSemantics 验证 Gemini usage 转换的缓存语义: +// Gemini 的 promptTokenCount 已含 cachedContentTokenCount(cached 为其子集), +// 必须置 CacheIncludedInPrompt=true 让计费扣减缓存部分,否则缓存 token 会被 +// 「input 全价 + cache 价」双重计费。 +func TestGeminiUsageToCommon_CacheSemantics(t *testing.T) { + usage := geminiUsageToCommon(&dto.GeminiUsageMetadata{ + PromptTokenCount: 414, + CandidatesTokenCount: 219, + TotalTokenCount: 633, + CachedContentTokenCount: 231, + ThoughtsTokenCount: 100, + }) + + if !usage.CacheIncludedInPrompt { + t.Error("CacheIncludedInPrompt = false, want true (cachedContentTokenCount 是 promptTokenCount 的子集)") + } + if usage.PromptTokens != 414 || usage.CompletionTokens != 219 || usage.TotalTokens != 633 { + t.Errorf("token counts = %+v, want prompt=414 completion=219 total=633", usage) + } + if usage.PromptTokensDetails == nil || usage.PromptTokensDetails.CachedTokens != 231 { + t.Errorf("cached tokens = %+v, want 231", usage.PromptTokensDetails) + } + if usage.CompletionTokenDetails == nil || usage.CompletionTokenDetails.ReasoningTokens != 100 { + t.Errorf("reasoning tokens = %+v, want 100", usage.CompletionTokenDetails) + } +} diff --git a/relay/channel/openai/adaptor_responses_test.go b/relay/channel/openai/adaptor_responses_test.go index 8125e44..fd797a2 100644 --- a/relay/channel/openai/adaptor_responses_test.go +++ b/relay/channel/openai/adaptor_responses_test.go @@ -255,6 +255,9 @@ func TestAdaptor_DoResponse_ResponsesUpstreamStream(t *testing.T) { if usage.PromptTokens != 10 || usage.CompletionTokens != 20 || usage.TotalTokens != 30 { t.Errorf("usage = %+v, want prompt=10 completion=20 total=30", usage) } + if usage.PromptTokensDetails == nil || usage.PromptTokensDetails.CachedTokens != 5 { + t.Errorf("cached usage = %+v, want cached=5", usage) + } } // fakeResponseRouteStore 路由存储 fake,用于断言 Record 调用 diff --git a/relay/channel/openai/claude_response.go b/relay/channel/openai/claude_response.go index 99283af..5a6a5c7 100644 --- a/relay/channel/openai/claude_response.go +++ b/relay/channel/openai/claude_response.go @@ -102,6 +102,7 @@ func handleClaudeInboundStream(ctx context.Context, resp *http.Response, info *c contentIndex int inputTokens int outputTokens int + cachedTokens int // prompt_tokens_details.cached_tokens(OpenAI 口径:已含于 prompt_tokens) currentBlockType string // 跟踪当前 block 类型: "text" 或 "tool_use" transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算 ) @@ -146,15 +147,23 @@ func handleClaudeInboundStream(ctx context.Context, resp *http.Response, info *c currentBlockType = "" } - // 发送 message_delta(stop_reason)+ message_stop + // 发送 message_delta(stop_reason)+ message_stop。 + // Claude 语义:input_tokens 不含 cache_read(OpenAI 的 prompt_tokens 含 cached,扣减后映射); + // OpenAI 的 usage 在流尾才返回、message_start 时不可得,最终用量在此补报 reason := common.OpenAIFinishReasonToClaude(finishReason) + deltaInput := inputTokens - cachedTokens + if deltaInput < 0 { + deltaInput = 0 + } delta := dto.ClaudeResponse{ Type: "message_delta", Delta: &dto.ClaudeDelta{ StopReason: strPtr(reason), }, Usage: &dto.ClaudeUsage{ - OutputTokens: outputTokens, + InputTokens: deltaInput, + CacheReadInputTokens: cachedTokens, + OutputTokens: outputTokens, }, } writeClaudeSSE(writer, "message_delta", &delta) @@ -175,6 +184,14 @@ func handleClaudeInboundStream(ctx context.Context, resp *http.Response, info *c if streamResp.Usage != nil { inputTokens = streamResp.Usage.PromptTokens outputTokens = streamResp.Usage.CompletionTokens + if streamResp.Usage.PromptTokensDetails != nil { + cachedTokens = streamResp.Usage.PromptTokensDetails.CachedTokens + } + // 缓存/思考明细透传给计费;OpenAI 的 prompt_tokens 含 cached(子集语义), + // 置 CacheIncludedInPrompt 让计费按明细扣减缓存部分,避免缓存按 input 全价计费 + usage.PromptTokensDetails = common.DtoTokenDetailsToCommon(streamResp.Usage.PromptTokensDetails) + usage.CompletionTokenDetails = common.DtoTokenDetailsToCommon(streamResp.Usage.CompletionTokenDetails) + usage.CacheIncludedInPrompt = true } // 发送 message_start(首块数据时) @@ -327,6 +344,11 @@ func handleClaudeInboundStream(ctx context.Context, resp *http.Response, info *c if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) + // 读流出错:带上已收到的 usage,避免整段按零值计费漏计 + usage.PromptTokens = inputTokens + usage.CompletionTokens = outputTokens + usage.TotalTokens = inputTokens + outputTokens + usage.CacheIncludedInPrompt = true return &usage, fmt.Errorf("stream scanner error: %w", err) } @@ -423,6 +445,18 @@ func openAIToClaudeResponse(openaiResp *dto.ChatCompletionResponse, info *common stopReason = common.OpenAIFinishReasonToClaude(openaiResp.Choices[0].FinishReason) } + // OpenAI 的 prompt_tokens 已含 cached(子集语义),Claude 的 input_tokens 不含 cache_read, + // 需扣减后映射,否则 Claude 客户端按本协议语义计账时缓存部分被重复计入。 + // PromptTokensDetails 可能为 nil(多数 OpenAI 兼容上游不返回该字段),必须判空。 + cachedTokens := 0 + if openaiResp.Usage.PromptTokensDetails != nil { + cachedTokens = openaiResp.Usage.PromptTokensDetails.CachedTokens + } + inputTokens := openaiResp.Usage.PromptTokens - cachedTokens + if inputTokens < 0 { + inputTokens = 0 + } + return dto.ClaudeResponse{ ID: fmt.Sprintf("msg_%s", info.RequestID), Type: "message", @@ -432,9 +466,9 @@ func openAIToClaudeResponse(openaiResp *dto.ChatCompletionResponse, info *common StopSequence: nil, Model: modelName, Usage: &dto.ClaudeUsage{ - InputTokens: openaiResp.Usage.PromptTokens, + InputTokens: inputTokens, OutputTokens: openaiResp.Usage.CompletionTokens, - CacheReadInputTokens: openaiResp.Usage.PromptTokensDetails.CachedTokens, + CacheReadInputTokens: cachedTokens, }, } } diff --git a/relay/channel/openai/claude_response_test.go b/relay/channel/openai/claude_response_test.go new file mode 100644 index 0000000..1a558dd --- /dev/null +++ b/relay/channel/openai/claude_response_test.go @@ -0,0 +1,126 @@ +package openai + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" +) + +// newClaudeInboundTestInfo 构造 Claude 入站转换测试用的 RelayInfo。 +func newClaudeInboundTestInfo() *common.RelayInfo { + return &common.RelayInfo{ + RequestID: "req-claude-inbound", + OriginModelName: "gpt-4o", + StreamStatus: common.NewStreamStatus(), + ChannelMeta: &common.ChannelMeta{}, + } +} + +// TestOpenAIToClaudeResponse_UsageNilDetails 上游不返回 prompt_tokens_details(多数 OpenAI 兼容上游) +// 时不得空指针 panic,且无缓存可扣减。 +func TestOpenAIToClaudeResponse_UsageNilDetails(t *testing.T) { + info := newClaudeInboundTestInfo() + openaiResp := &dto.ChatCompletionResponse{ + ID: "chatcmpl-1", + Model: "gpt-4o", + Choices: []dto.Choice{{Index: 0, Message: dto.Message{Role: "assistant", Content: "hi"}, FinishReason: "stop"}}, + Usage: dto.UsageWithDetails{PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120}, + } + + resp := openAIToClaudeResponse(openaiResp, info) // PromptTokensDetails 为 nil,此处原会 panic + + if resp.Usage.InputTokens != 100 { + t.Errorf("InputTokens = %d, want 100 (no cache to deduct)", resp.Usage.InputTokens) + } + if resp.Usage.CacheReadInputTokens != 0 { + t.Errorf("CacheReadInputTokens = %d, want 0", resp.Usage.CacheReadInputTokens) + } + if resp.Usage.OutputTokens != 20 { + t.Errorf("OutputTokens = %d, want 20", resp.Usage.OutputTokens) + } +} + +// TestOpenAIToClaudeResponse_UsageWithCache 上游返回缓存明细时,usage 需按 Claude 语义映射: +// OpenAI 的 prompt_tokens 含 cached(子集),Claude 的 input_tokens 不含 cache_read, +// 直接透传会导致客户端按本协议语义计账时缓存部分重复计入。 +func TestOpenAIToClaudeResponse_UsageWithCache(t *testing.T) { + info := newClaudeInboundTestInfo() + openaiResp := &dto.ChatCompletionResponse{ + ID: "chatcmpl-2", + Model: "gpt-4o", + Choices: []dto.Choice{{Index: 0, Message: dto.Message{Role: "assistant", Content: "hi"}, FinishReason: "stop"}}, + Usage: dto.UsageWithDetails{ + PromptTokens: 100, + CompletionTokens: 20, + TotalTokens: 120, + PromptTokensDetails: &dto.TokenDetails{CachedTokens: 30}, + }, + } + + resp := openAIToClaudeResponse(openaiResp, info) + + if resp.Usage.InputTokens != 70 { + t.Errorf("InputTokens = %d, want 70 (prompt 100 - cached 30)", resp.Usage.InputTokens) + } + if resp.Usage.CacheReadInputTokens != 30 { + t.Errorf("CacheReadInputTokens = %d, want 30", resp.Usage.CacheReadInputTokens) + } +} + +// TestHandleClaudeInboundStream_CacheUsage 流式路径缓存用量: +// 1. 计费 usage 携带缓存明细并置 CacheIncludedInPrompt(计费扣减缓存部分,避免按 input 全价计费); +// 2. 最终 message_delta 按 Claude 语义补报扣减后的 input_tokens 与 cache_read_input_tokens +// (OpenAI 的 usage 在流尾才返回,message_start 无法提供真实值)。 +func TestHandleClaudeInboundStream_CacheUsage(t *testing.T) { + sse := `data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"}}]} + +data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,"prompt_tokens_details":{"cached_tokens":30},"completion_tokens_details":{"reasoning_tokens":5}}} + +data: [DONE] + +` + upstream := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(sse)), + Header: http.Header{}, + } + + info := newClaudeInboundTestInfo() + rec := httptest.NewRecorder() + + usage, err := handleClaudeInboundStream(context.Background(), upstream, info, rec) + if err != nil { + t.Fatalf("handleClaudeInboundStream error: %v", err) + } + + // 计费侧:OpenAI 原始口径(prompt 含 cached)+ 明细 + 扣减标记 + if usage.PromptTokens != 100 || usage.CompletionTokens != 20 || usage.TotalTokens != 120 { + t.Errorf("billing usage = %+v, want prompt=100 completion=20 total=120", usage) + } + if usage.PromptTokensDetails == nil || usage.PromptTokensDetails.CachedTokens != 30 { + t.Errorf("cache details = %+v, want cached=30", usage.PromptTokensDetails) + } + if !usage.CacheIncludedInPrompt { + t.Error("CacheIncludedInPrompt = false, want true (OpenAI prompt 含 cached)") + } + + // 客户端侧:message_delta 补报 Claude 语义 usage + body := rec.Body.String() + if !strings.Contains(body, `"input_tokens":70`) { + t.Errorf("message_delta missing deducted input_tokens=70, got: %s", body) + } + if !strings.Contains(body, `"cache_read_input_tokens":30`) { + t.Errorf("message_delta missing cache_read_input_tokens=30, got: %s", body) + } + if !strings.Contains(body, `"output_tokens":20`) { + t.Errorf("message_delta missing output_tokens=20, got: %s", body) + } +} diff --git a/relay/channel/openai/converter.go b/relay/channel/openai/converter.go index 2d95e0c..36a1d78 100644 --- a/relay/channel/openai/converter.go +++ b/relay/channel/openai/converter.go @@ -175,6 +175,18 @@ func convertClaudeUserMessage(msg dto.ClaudeMessage) []dto.Message { if b, err := json.Marshal(cMap); err == nil { toolContent = string(b) } + } else if cArr, ok := m["content"].([]any); ok { + // 内容块数组(Claude 规范形式,如 [{type:"text",text:"..."}]): + // 拼接文本块,否则工具结果会被替换为空字符串导致内容丢失 + var parts []string + for _, item := range cArr { + if b, ok := item.(map[string]any); ok && b["type"] == "text" { + if text, ok := b["text"].(string); ok { + parts = append(parts, text) + } + } + } + toolContent = c2oJoinParts(parts) } if len(contentParts) > 0 { if len(contentParts) == 1 && contentParts[0].Type == "text" { @@ -1454,9 +1466,7 @@ func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info createAt = int64(streamResp.Response.CreatedAt) } if streamResp.Response.Usage != nil { - totalUsage.PromptTokens = streamResp.Response.Usage.InputTokens - totalUsage.CompletionTokens = streamResp.Response.Usage.OutputTokens - totalUsage.TotalTokens = streamResp.Response.Usage.TotalTokens + totalUsage = *responsesUsageToCommon(streamResp.Response.Usage) if totalUsage.TotalTokens == 0 { totalUsage.TotalTokens = totalUsage.PromptTokens + totalUsage.CompletionTokens } diff --git a/relay/channel/openai/converter_test.go b/relay/channel/openai/converter_test.go index bfc7781..cab7c47 100644 --- a/relay/channel/openai/converter_test.go +++ b/relay/channel/openai/converter_test.go @@ -204,6 +204,37 @@ func TestConvertClaudeUserMessage_StringContent(t *testing.T) { } } +// TestConvertClaudeUserMessage_ToolResultArrayContent tool_result.content 为内容块数组 +// (Claude 规范形式 [{type:"text",text:"..."}])时,文本块须拼接保留, +// 否则工具结果会被替换为空字符串导致内容丢失。 +func TestConvertClaudeUserMessage_ToolResultArrayContent(t *testing.T) { + msgs := convertClaudeUserMessage(dto.ClaudeMessage{ + Role: "user", + Content: []any{ + map[string]any{ + "type": "tool_result", + "tool_use_id": "toolu_123", + "content": []any{ + map[string]any{"type": "text", "text": "result-a"}, + map[string]any{"type": "text", "text": "result-b"}, + }, + }, + }, + }) + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1", len(msgs)) + } + if msgs[0].Role != "tool" { + t.Errorf("role = %q, want tool", msgs[0].Role) + } + if msgs[0].ToolCallID != "toolu_123" { + t.Errorf("ToolCallID = %q, want toolu_123", msgs[0].ToolCallID) + } + if msgs[0].Content != "result-a\nresult-b" { + t.Errorf("content = %q, want %q", msgs[0].Content, "result-a\nresult-b") + } +} + // TestConvertResponsesToOpenAI_PenaltyPassthrough Responses 入站转 chat 出站时 // 透传 presence/frequency penalty 与 prompt_cache_key(vLLM 等 OpenAI 兼容上游接受)。 func TestConvertResponsesToOpenAI_PenaltyPassthrough(t *testing.T) { diff --git a/relay/relaykit_bridge/response.go b/relay/relaykit_bridge/response.go index dde6ae6..3451820 100644 --- a/relay/relaykit_bridge/response.go +++ b/relay/relaykit_bridge/response.go @@ -109,12 +109,22 @@ func UsageFromConvertedChatResponse(body []byte) (*common.Usage, bool) { return nil, false } return &common.Usage{ - PromptTokens: resp.Usage.PromptTokens, - CompletionTokens: resp.Usage.CompletionTokens, - TotalTokens: resp.Usage.TotalTokens, + PromptTokens: resp.Usage.PromptTokens, + CompletionTokens: resp.Usage.CompletionTokens, + TotalTokens: resp.Usage.TotalTokens, + CacheCreationTokens: convertedCacheCreationTokens(resp.Usage.PromptTokensDetails), + PromptTokensDetails: common.DtoTokenDetailsToCommon(resp.Usage.PromptTokensDetails), + CompletionTokenDetails: common.DtoTokenDetailsToCommon(resp.Usage.CompletionTokenDetails), }, true } +func convertedCacheCreationTokens(details *dto.TokenDetails) int { + if details == nil { + return 0 + } + return details.CachedCreationTokens +} + // relaykitResponseConverterID 根据 (上游原生格式, 客户端格式) 返回响应转换器 ID。 // 返回空串表示没有匹配的 relaykit 响应转换器(调用方回退旧路径)。 func relaykitResponseConverterID(upstream, clientFormat constant.RelayFormat) string { diff --git a/relay/relaykit_bridge/response_test.go b/relay/relaykit_bridge/response_test.go index 3e7a528..b63f688 100644 --- a/relay/relaykit_bridge/response_test.go +++ b/relay/relaykit_bridge/response_test.go @@ -23,8 +23,8 @@ func TestRelaykitResponseConverterID(t *testing.T) { {constant.RelayFormatCoze, constant.RelayFormatOpenAI, relayconvert.ConverterOpenAIChatToCoze}, {constant.RelayFormatDify, constant.RelayFormatOpenAI, relayconvert.ConverterOpenAIChatToDify}, {constant.RelayFormatOllama, constant.RelayFormatOpenAI, relayconvert.ConverterOpenAIChatToOllama}, - {constant.RelayFormatOpenAI, constant.RelayFormatOpenAI, ""}, // 同格式 - {constant.RelayFormatClaude, constant.RelayFormatGemini, ""}, // 未知方向 + {constant.RelayFormatOpenAI, constant.RelayFormatOpenAI, ""}, // 同格式 + {constant.RelayFormatClaude, constant.RelayFormatGemini, ""}, // 未知方向 } for _, c := range cases { if got := relaykitResponseConverterID(c.upstream, c.client); got != c.want { @@ -37,13 +37,20 @@ func TestRelaykitResponseConverterID(t *testing.T) { func TestUsageFromConvertedChatResponse(t *testing.T) { body, _ := json.Marshal(dto.ChatCompletionResponse{ - Usage: dto.UsageWithDetails{PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8}, + Usage: dto.UsageWithDetails{ + PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8, + PromptTokensDetails: &dto.TokenDetails{CachedTokens: 2, CachedCreationTokens: 1}, + CompletionTokenDetails: &dto.TokenDetails{ReasoningTokens: 4}, + }, }) usage, ok := UsageFromConvertedChatResponse(body) if !ok { t.Fatal("expected ok=true") } - if usage == nil || usage.PromptTokens != 5 || usage.CompletionTokens != 3 { + if usage == nil || usage.PromptTokens != 5 || usage.CompletionTokens != 3 || + usage.CacheCreationTokens != 1 || usage.PromptTokensDetails == nil || + usage.PromptTokensDetails.CachedTokens != 2 || usage.CompletionTokenDetails == nil || + usage.CompletionTokenDetails.ReasoningTokens != 4 { t.Errorf("unexpected usage: %+v", usage) } diff --git a/relay/relaykit_bridge/stream.go b/relay/relaykit_bridge/stream.go index 2a267ca..7f056aa 100644 --- a/relay/relaykit_bridge/stream.go +++ b/relay/relaykit_bridge/stream.go @@ -11,6 +11,7 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/qianfree/team-api/internal/logic/monitor" "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" "github.com/qianfree/team-api/relay/helper" "github.com/qianfree/team-api/relaykit/relayconvert" @@ -62,7 +63,10 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr safeWriter := helper.NewSafeWriter(writer) defer helper.PingTicker(safeWriter, 15*time.Second)() - capturedUsage := &common.Usage{} + // Gemini 的 promptTokenCount 已含 cachedContentTokenCount(cached 为其子集), + // 置 CacheIncludedInPrompt 让计费扣减缓存部分避免双重计费; + // Claude 的 input_tokens 与缓存桶独立,保持默认 false 由缓存部分单独计价 + capturedUsage := &common.Usage{CacheIncludedInPrompt: upstream == constant.RelayFormatGemini} var ( gotFinish bool // 转换器是否已产出带 finish_reason 的结束 chunk firstChunk bool @@ -84,6 +88,9 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr capturedUsage.PromptTokens = streamChunk.Usage.PromptTokens capturedUsage.CompletionTokens = streamChunk.Usage.CompletionTokens capturedUsage.TotalTokens = streamChunk.Usage.TotalTokens + capturedUsage.CacheCreationTokens = convertedCacheCreationTokens(streamChunk.Usage.PromptTokensDetails) + capturedUsage.PromptTokensDetails = common.DtoTokenDetailsToCommon(streamChunk.Usage.PromptTokensDetails) + capturedUsage.CompletionTokenDetails = common.DtoTokenDetailsToCommon(streamChunk.Usage.CompletionTokenDetails) } if len(streamChunk.Choices) > 0 && streamChunk.Choices[0].FinishReason != nil { gotFinish = true diff --git a/relay/relaykit_bridge/stream_test.go b/relay/relaykit_bridge/stream_test.go index a4d17ef..e84b051 100644 --- a/relay/relaykit_bridge/stream_test.go +++ b/relay/relaykit_bridge/stream_test.go @@ -30,7 +30,7 @@ func newStreamTestRelayInfo(channelType constant.ProviderType, clientFormat cons // 并正确提取 usage、写入 [DONE] 收尾、设置正常结束原因。 func TestConvertStreamViaRelaykit_ClaudeToOpenAI(t *testing.T) { // 复用 relaykit 内 claude_to_openai_stream_test.go 的 BasicStream 报文 - claudeStream := `data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-3-opus-20240229","usage":{"input_tokens":10,"output_tokens":0}}} + claudeStream := `data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-3-opus-20240229","usage":{"input_tokens":10,"output_tokens":0,"cache_read_input_tokens":4,"cache_creation_input_tokens":3}}} data: {"type":"content_block_start","index":0,"content_block":{"type":"text"}} @@ -62,6 +62,13 @@ data: {"type":"message_stop"} if usage.CompletionTokens != 7 { t.Errorf("CompletionTokens = %d, want 7", usage.CompletionTokens) } + if usage.PromptTokensDetails == nil || usage.PromptTokensDetails.CachedTokens != 4 || usage.CacheCreationTokens != 3 { + t.Errorf("cache usage = %+v, want read=4 creation=3", usage) + } + // Claude 的 input_tokens 与缓存桶独立:不得扣减(保持 false),缓存部分按 cache 价单独计 + if usage.CacheIncludedInPrompt { + t.Error("CacheIncludedInPrompt = true, want false for Claude upstream") + } body := rec.Body.String() if !strings.Contains(body, "chat.completion.chunk") { @@ -84,6 +91,49 @@ data: {"type":"message_stop"} } } +// TestConvertStreamViaRelaykit_GeminiToOpenAI 验证 Gemini SSE 流经 relaykit 转换为 OpenAI SSE, +// 缓存/思考 token 明细正确透出,且因 Gemini 的 promptTokenCount 已含 cached(子集语义) +// 计费标记 CacheIncludedInPrompt=true(计费时扣减缓存部分,避免双重计费)。 +func TestConvertStreamViaRelaykit_GeminiToOpenAI(t *testing.T) { + // 报文格式同 relaykit golden 用例 03_basic_streaming,末 chunk 带缓存/思考用量 + geminiStream := `data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Hello!"}]}}]} + +data: {"candidates":[{"index":0,"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":5,"totalTokenCount":25,"cachedContentTokenCount":8,"thoughtsTokenCount":3}} + +` + + info := newStreamTestRelayInfo(constant.ProviderGemini, constant.RelayFormatOpenAI) + rec := httptest.NewRecorder() + + usage, ok := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(geminiStream), rec) + if !ok { + t.Fatal("expected ok=true (handled), got false") + } + if usage == nil { + t.Fatal("expected non-nil usage") + } + if usage.PromptTokens != 20 || usage.CompletionTokens != 5 || usage.TotalTokens != 25 { + t.Errorf("token counts = %+v, want prompt=20 completion=5 total=25", usage) + } + if usage.PromptTokensDetails == nil || usage.PromptTokensDetails.CachedTokens != 8 { + t.Errorf("cached tokens = %+v, want 8", usage.PromptTokensDetails) + } + if usage.CompletionTokenDetails == nil || usage.CompletionTokenDetails.ReasoningTokens != 3 { + t.Errorf("reasoning tokens = %+v, want 3", usage.CompletionTokenDetails) + } + if !usage.CacheIncludedInPrompt { + t.Error("CacheIncludedInPrompt = false, want true for Gemini upstream (cached ⊆ prompt)") + } + + body := rec.Body.String() + if !strings.Contains(body, "Hello!") { + t.Errorf("output missing streamed text, got: %s", body) + } + if !strings.HasSuffix(strings.TrimSpace(body), "data: [DONE]") { + t.Errorf("output should end with [DONE], got tail: %q", tail(body, 40)) + } +} + // TestConvertStreamViaRelaykit_SameFormatFallback 同格式(OpenAI→OpenAI)无需转换,应回退(ok=false)。 func TestConvertStreamViaRelaykit_SameFormatFallback(t *testing.T) { info := newStreamTestRelayInfo(constant.ProviderOpenAI, constant.RelayFormatOpenAI) diff --git a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_response.go b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_response.go index 28236f8..03f64a2 100644 --- a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_response.go +++ b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_response.go @@ -70,6 +70,12 @@ func (c *ClaudeToOpenAIResponseConverter) ConvertResponse( CompletionTokens: claudeResp.Usage.OutputTokens, TotalTokens: claudeResp.Usage.InputTokens + claudeResp.Usage.OutputTokens, } + if claudeResp.Usage.CacheReadInputTokens > 0 || claudeResp.Usage.CacheCreationInputTokens > 0 { + openaiResp.Usage.PromptTokensDetails = &dto.TokenDetails{ + CachedTokens: claudeResp.Usage.CacheReadInputTokens, + CachedCreationTokens: claudeResp.Usage.CacheCreationInputTokens, + } + } } return openaiResp, nil diff --git a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_stream.go b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_stream.go index 60d25f1..582e048 100644 --- a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_stream.go +++ b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_stream.go @@ -232,6 +232,12 @@ func (c *ClaudeToOpenAIStreamConverter) ConvertStreamResponse( CompletionTokens: usage.OutputTokens, TotalTokens: usage.InputTokens + usage.OutputTokens, } + if usage.CacheReadInputTokens > 0 || usage.CacheCreationInputTokens > 0 { + usageObj.PromptTokensDetails = &dto.TokenDetails{ + CachedTokens: usage.CacheReadInputTokens, + CachedCreationTokens: usage.CacheCreationInputTokens, + } + } if usageObj.CompletionTokens == 0 { // 未提供时根据文本长度估算 estimated := responseTextBuf.Len() / 4 diff --git a/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_response.go b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_response.go index a11ae20..e4ea26c 100644 --- a/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_response.go +++ b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_response.go @@ -162,6 +162,16 @@ func (c *GeminiToOpenAIResponseConverter) ConvertResponse( CompletionTokens: geminiResp.UsageMetadata.CandidatesTokenCount, TotalTokens: geminiResp.UsageMetadata.TotalTokenCount, } + if geminiResp.UsageMetadata.CachedContentTokenCount > 0 { + openaiResp.Usage.PromptTokensDetails = &dto.TokenDetails{ + CachedTokens: geminiResp.UsageMetadata.CachedContentTokenCount, + } + } + if geminiResp.UsageMetadata.ThoughtsTokenCount > 0 { + openaiResp.Usage.CompletionTokenDetails = &dto.TokenDetails{ + ReasoningTokens: geminiResp.UsageMetadata.ThoughtsTokenCount, + } + } } return openaiResp, nil diff --git a/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_stream.go b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_stream.go index 38f90ef..e6762cf 100644 --- a/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_stream.go +++ b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_stream.go @@ -245,6 +245,12 @@ func (c *GeminiToOpenAIStreamConverter) ConvertStreamResponse( CompletionTokens: totalUsage.CandidatesTokenCount, TotalTokens: totalUsage.TotalTokenCount, } + if totalUsage.CachedContentTokenCount > 0 { + finalChunk.Usage.PromptTokensDetails = &dto.TokenDetails{CachedTokens: totalUsage.CachedContentTokenCount} + } + if totalUsage.ThoughtsTokenCount > 0 { + finalChunk.Usage.CompletionTokenDetails = &dto.TokenDetails{ReasoningTokens: totalUsage.ThoughtsTokenCount} + } } if err := chunkWriter(finalChunk); err != nil { From 6778c84ace342fd1360c5f7efcb6b2b6ab001440 Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 16 Aug 2026 23:59:00 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat(model):=E6=96=B0=E5=A2=9E=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E7=9B=91=E6=8E=A7=E6=8C=87=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/dao/internal/bil_usage_daily.go | 70 +++--- internal/dao/internal/mdl_tenant_models.go | 2 +- internal/logic/common/model_perf_metrics.go | 49 +++-- internal/logic/monitor/api_metrics.go | 60 ++++-- internal/logic/monitor/api_metrics_test.go | 57 ++++- internal/logic/relay/provider.go | 3 + internal/logic/task/usage_aggregation.go | 24 ++- internal/model/do/bil_usage_daily.go | 37 ++-- internal/model/do/mdl_tenant_models.go | 2 +- internal/model/entity/bil_usage_daily.go | 35 +-- internal/model/entity/mdl_tenant_models.go | 2 +- ...6_add_cache_columns_to_bil_usage_daily.sql | 20 ++ .../views/monitor/ModelPerformancePage.vue | 204 +++++++++++++++++- 13 files changed, 454 insertions(+), 111 deletions(-) create mode 100644 migrations/000016_add_cache_columns_to_bil_usage_daily.sql diff --git a/internal/dao/internal/bil_usage_daily.go b/internal/dao/internal/bil_usage_daily.go index 3b2ceb0..41210b8 100644 --- a/internal/dao/internal/bil_usage_daily.go +++ b/internal/dao/internal/bil_usage_daily.go @@ -21,42 +21,48 @@ type BilUsageDailyDao struct { // BilUsageDailyColumns defines and stores column names for the table bil_usage_daily. type BilUsageDailyColumns struct { - Id string // - StatDate string // 统计日期(按 created_at 的日期分桶) - TenantId string // 租户ID - ProjectId string // 项目ID(0=无项目/个人Key) - ModelName string // 模型名称 - ChannelId string // 渠道ID(0=无渠道) - Status string // 请求状态:success/error/timeout/cancelled - RequestCount string // 请求数(COUNT(*)) - InputTokens string // 输入Token合计 - OutputTokens string // 输出Token合计 - TotalCost string // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) - AccountCost string // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) - SumLatencyMs string // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) - SumFirstTokenMs string // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) - CreatedAt string // - UpdatedAt string // + Id string // + StatDate string // 统计日期(按 created_at 的日期分桶) + TenantId string // 租户ID + ProjectId string // 项目ID(0=无项目/个人Key) + ModelName string // 模型名称 + ChannelId string // 渠道ID(0=无渠道) + Status string // 请求状态:success/error/timeout/cancelled + RequestCount string // 请求数(COUNT(*)) + InputTokens string // 输入Token合计 + OutputTokens string // 输出Token合计 + TotalCost string // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) + AccountCost string // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) + SumLatencyMs string // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) + SumFirstTokenMs string // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) + CreatedAt string // + UpdatedAt string // + CacheCreationTokens string // 缓存创建 token 数(Claude cache_creation,SUM 聚合) + CacheReadTokens string // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合) + CacheHitRequestCount string // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行) } // bilUsageDailyColumns holds the columns for the table bil_usage_daily. var bilUsageDailyColumns = BilUsageDailyColumns{ - Id: "id", - StatDate: "stat_date", - TenantId: "tenant_id", - ProjectId: "project_id", - ModelName: "model_name", - ChannelId: "channel_id", - Status: "status", - RequestCount: "request_count", - InputTokens: "input_tokens", - OutputTokens: "output_tokens", - TotalCost: "total_cost", - AccountCost: "account_cost", - SumLatencyMs: "sum_latency_ms", - SumFirstTokenMs: "sum_first_token_ms", - CreatedAt: "created_at", - UpdatedAt: "updated_at", + Id: "id", + StatDate: "stat_date", + TenantId: "tenant_id", + ProjectId: "project_id", + ModelName: "model_name", + ChannelId: "channel_id", + Status: "status", + RequestCount: "request_count", + InputTokens: "input_tokens", + OutputTokens: "output_tokens", + TotalCost: "total_cost", + AccountCost: "account_cost", + SumLatencyMs: "sum_latency_ms", + SumFirstTokenMs: "sum_first_token_ms", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + CacheCreationTokens: "cache_creation_tokens", + CacheReadTokens: "cache_read_tokens", + CacheHitRequestCount: "cache_hit_request_count", } // NewBilUsageDailyDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/mdl_tenant_models.go b/internal/dao/internal/mdl_tenant_models.go index 4f603b1..885b674 100644 --- a/internal/dao/internal/mdl_tenant_models.go +++ b/internal/dao/internal/mdl_tenant_models.go @@ -27,7 +27,7 @@ type MdlTenantModelsColumns struct { Enabled string // 是否启用(禁用后该租户无法调用此模型) CustomInputPrice string // 租户自定义输入价格(NULL 表示使用默认定价) CustomOutputPrice string // 租户自定义输出价格(NULL 表示使用默认定价) - Multiplier string // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率) + Multiplier string // 租户价格倍率(VIP 折扣)。作为 tenant_multiplier 参与最终价格计算:最终价格 = 基础价格 × 租户倍率。倍率来源优先级:discount_ratio > multiplier > 租户等级 price_multiplier CreatedAt string // 创建时间 UpdatedAt string // 更新时间 BillingMode string // 覆盖模型计费方式(NULL表示跟随模型默认) diff --git a/internal/logic/common/model_perf_metrics.go b/internal/logic/common/model_perf_metrics.go index 4d453eb..0568bab 100644 --- a/internal/logic/common/model_perf_metrics.go +++ b/internal/logic/common/model_perf_metrics.go @@ -23,6 +23,8 @@ import ( // SUM(latency_ms) 对 NULL 跳过、但 SUM/COUNT 用全部请求数作分母的语义一致) // - 平均首 Token = Σttft / Σttft_n(首 Token 单独计数,避免非流式/无首Token请求计入分母拉低均值) // - 成本以整数 micro-USD 累加(对齐 Redis 钱包 micro 规范,杜绝 HINCRBYFLOAT 漂移) +// - 缓存指标与日聚合对齐:cache 命中请求数(chit)按「该次请求 cache_read>0」计 1, +// 仅写入缓存(creation>0、read=0)不计命中 // // 可靠性为 best-effort:Redis 不可用时计数丢弃、查询返回空,不影响请求主链路,页面退化为历史日表。 const ( @@ -41,6 +43,9 @@ type ModelPerfRecord struct { InputTokens int OutputTokens int CostUSD float64 + // 缓存 token(命中判定由本模块从 CacheReadTokens>0 推导,调用方无需单独传标志) + CacheCreationTokens int + CacheReadTokens int } // ModelPerfCounter 当日某模型的累计计数(Redis 小时桶聚合结果)。 @@ -54,6 +59,10 @@ type ModelPerfCounter struct { Tin int64 Tout int64 CostMicro int64 + // 缓存累计:creation/read token 与 命中缓存的请求数(与 bil_usage_daily 同口径) + CacheCreation int64 + CacheRead int64 + CacheHitReq int64 } func modelPerfModelsKey(date string) string { @@ -84,7 +93,8 @@ func microFromUSD(usd float64) int64 { // modelPerfIncrLua 一次 RTT 把样本原子累加到「当日模型集合 + 小时桶」并续期。 // KEYS[1] = perf_models:{date};KEYS[2] = perf_model:{hex}:{hour} // ARGV[1] = 模型名;ARGV[2] = TTL 秒;ARGV[3] = ok(1/0);ARGV[4] = lat ms; -// ARGV[5] = ttft ms;ARGV[6] = input tokens;ARGV[7] = output tokens;ARGV[8] = cost micro-USD。 +// ARGV[5] = ttft ms;ARGV[6] = input tokens;ARGV[7] = output tokens;ARGV[8] = cost micro-USD; +// ARGV[9] = cache creation tokens;ARGV[10] = cache read tokens(>0 时 chit 同步 +1)。 const modelPerfIncrLua = ` redis.call("SADD", KEYS[1], ARGV[1]) redis.call("EXPIRE", KEYS[1], ARGV[2]) @@ -98,14 +108,20 @@ end if tonumber(ARGV[6]) > 0 then redis.call("HINCRBY", KEYS[2], "tin", tonumber(ARGV[6])) end if tonumber(ARGV[7]) > 0 then redis.call("HINCRBY", KEYS[2], "tout", tonumber(ARGV[7])) end if tonumber(ARGV[8]) > 0 then redis.call("HINCRBY", KEYS[2], "cost", tonumber(ARGV[8])) end +if tonumber(ARGV[9]) > 0 then redis.call("HINCRBY", KEYS[2], "ccr", tonumber(ARGV[9])) end +if tonumber(ARGV[10]) > 0 then + redis.call("HINCRBY", KEYS[2], "crd", tonumber(ARGV[10])) + redis.call("HINCRBY", KEYS[2], "chit", 1) +end redis.call("EXPIRE", KEYS[2], ARGV[2]) return 1 ` // modelPerfReadLua 累加某模型当日所有小时桶计数。KEYS = 当日各小时桶 key。 -// 返回扁平数组 [req, ok, lat, ttft, ttft_n, tin, tout, cost]。 +// 返回扁平数组 [req, ok, lat, ttft, ttft_n, tin, tout, cost, ccr, crd, chit]。 +// 旧版本桶缺失 ccr/crd/chit 字段时 tonumber(nil) or 0 兜底为 0,平滑上线。 const modelPerfReadLua = ` -local req,ok,lat,ttft,ttftn,tin,tout,cost = 0,0,0,0,0,0,0,0 +local req,ok,lat,ttft,ttftn,tin,tout,cost,ccr,crd,chit = 0,0,0,0,0,0,0,0,0,0,0 for i = 1, #KEYS do local h = redis.call("HGETALL", KEYS[i]) for j = 1, #h, 2 do @@ -118,10 +134,13 @@ for i = 1, #KEYS do elseif f == "ttft_n" then ttftn = ttftn + v elseif f == "tin" then tin = tin + v elseif f == "tout" then tout = tout + v - elseif f == "cost" then cost = cost + v end + elseif f == "cost" then cost = cost + v + elseif f == "ccr" then ccr = ccr + v + elseif f == "crd" then crd = crd + v + elseif f == "chit" then chit = chit + v end end end -return {req, ok, lat, ttft, ttftn, tin, tout, cost} +return {req, ok, lat, ttft, ttftn, tin, tout, cost, ccr, crd, chit} ` // RecordModelPerfMetrics 将一次请求的模型性能采样累加到 Redis 当日小时桶。 @@ -153,6 +172,7 @@ func RecordModelPerfMetrics(ctx context.Context, r ModelPerfRecord) { boolToInt(r.Success), int64(math.Round(r.LatencyMs)), int64(r.FirstTokenMs), int64(r.InputTokens), int64(r.OutputTokens), microFromUSD(r.CostUSD), + int64(r.CacheCreationTokens), int64(r.CacheReadTokens), } if _, err := g.Redis().Do(bgCtx, "EVAL", args...); err != nil { // 指标是 best-effort,Redis 抖动只记 debug,不刷告警 @@ -203,14 +223,17 @@ func GetModelPerfMetricsToday(ctx context.Context, date string) (map[string]Mode } vals := res.Vars() out[model] = ModelPerfCounter{ - Req: vals[0].Int64(), - Ok: vals[1].Int64(), - Lat: vals[2].Int64(), - Ttft: vals[3].Int64(), - TtftN: vals[4].Int64(), - Tin: vals[5].Int64(), - Tout: vals[6].Int64(), - CostMicro: vals[7].Int64(), + Req: vals[0].Int64(), + Ok: vals[1].Int64(), + Lat: vals[2].Int64(), + Ttft: vals[3].Int64(), + TtftN: vals[4].Int64(), + Tin: vals[5].Int64(), + Tout: vals[6].Int64(), + CostMicro: vals[7].Int64(), + CacheCreation: vals[8].Int64(), + CacheRead: vals[9].Int64(), + CacheHitReq: vals[10].Int64(), } } return out, nil diff --git a/internal/logic/monitor/api_metrics.go b/internal/logic/monitor/api_metrics.go index 2015a43..9433ea1 100644 --- a/internal/logic/monitor/api_metrics.go +++ b/internal/logic/monitor/api_metrics.go @@ -121,6 +121,10 @@ type modelPerfRow struct { TotalCost float64 `json:"total_cost"` SumLatencyMs int64 `json:"sum_latency_ms"` SumFirstTokenMs int64 `json:"sum_first_token_ms"` + // 缓存聚合列(000016 迁移新增,口径与 bil_usage_logs 明细一致) + CacheCreationTokens int64 `json:"cache_creation_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheHitRequestCount int64 `json:"cache_hit_request_count"` } // gradeSuccessRate 按成功率返回分级。阈值:≥99 excellent、≥95 good、≥90 warning、<90 critical。 @@ -146,6 +150,10 @@ type perfModelAgg struct { sumLatencyMs int64 sumFirstTokenMs int64 totalCost float64 + // 缓存累计(历史日表 + 当日热桶同口径累加) + cacheCreationTokens int64 + cacheReadTokens int64 + cacheHitRequests int64 } // GetModelPerformance 从 bil_usage_daily 按模型聚合历史性能指标,并合并当日 Redis 实时热桶 @@ -164,7 +172,10 @@ func GetModelPerformance(ctx context.Context, startDate, endDate string) (any, e COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(total_cost), 0) AS total_cost, COALESCE(SUM(sum_latency_ms), 0) AS sum_latency_ms, - COALESCE(SUM(sum_first_token_ms), 0) AS sum_first_token_ms + COALESCE(SUM(sum_first_token_ms), 0) AS sum_first_token_ms, + COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_hit_request_count), 0) AS cache_hit_request_count FROM bil_usage_daily WHERE stat_date >= $1 AND stat_date <= $2 AND stat_date < $3 AND model_name <> 'unknown' GROUP BY model_name @@ -189,7 +200,7 @@ func GetModelPerformance(ctx context.Context, startDate, endDate string) (any, e } // buildModelPerformanceList 将历史日表聚合行与当日 Redis 热桶合并为统一的模型性能列表, -// 统一重算派生指标(成功率/均延迟/均首Token/TPS/分级)并按 request_count 降序输出。 +// 统一重算派生指标(成功率/均延迟/均首Token/TPS/分级/缓存命中率)并按模型名称升序输出。 func buildModelPerformanceList(rows []modelPerfRow, todayCounts map[string]common.ModelPerfCounter) []map[string]any { agg := make(map[string]*perfModelAgg) ensure := func(model string) *perfModelAgg { @@ -212,6 +223,9 @@ func buildModelPerformanceList(rows []modelPerfRow, todayCounts map[string]commo a.sumLatencyMs += r.SumLatencyMs a.sumFirstTokenMs += r.SumFirstTokenMs a.totalCost += r.TotalCost + a.cacheCreationTokens += r.CacheCreationTokens + a.cacheReadTokens += r.CacheReadTokens + a.cacheHitRequests += r.CacheHitRequestCount } for model, c := range todayCounts { if c.Req <= 0 || model == "" || model == "unknown" { @@ -225,6 +239,9 @@ func buildModelPerformanceList(rows []modelPerfRow, todayCounts map[string]commo a.sumLatencyMs += c.Lat a.sumFirstTokenMs += c.Ttft a.totalCost += billing.FromMicro(c.CostMicro).InexactFloat64() + a.cacheCreationTokens += c.CacheCreation + a.cacheReadTokens += c.CacheRead + a.cacheHitRequests += c.CacheHitReq } list := make([]map[string]any, 0, len(agg)) @@ -238,19 +255,34 @@ func buildModelPerformanceList(rows []modelPerfRow, todayCounts map[string]commo if a.sumLatencyMs > 0 { tps = float64(a.outputTokens) / (float64(a.sumLatencyMs) / 1000) } + // 缓存命中率(Token 级)= 命中读取 / 归一化总输入(input + creation + read)。 + // Claude 口径(input 不含缓存)精确;OpenAI 原生口径(input 已含 cached)分母偏大, + // 命中率为保守值,趋势仍可比。请求级命中率分母为 request_count,不受该口径影响。 + var cacheHitRate, cacheHitReqRate float64 + if totalInput := a.inputTokens + a.cacheCreationTokens + a.cacheReadTokens; totalInput > 0 { + cacheHitRate = float64(a.cacheReadTokens) * 100 / float64(totalInput) + } + if a.requestCount > 0 { + cacheHitReqRate = float64(a.cacheHitRequests) * 100 / float64(a.requestCount) + } list = append(list, map[string]any{ - "model_name": model, - "request_count": a.requestCount, - "success_count": a.successCount, - "success_rate": math.Round(successRate*100) / 100, - "grade": gradeSuccessRate(successRate), - "input_tokens": a.inputTokens, - "output_tokens": a.outputTokens, - "total_tokens": a.inputTokens + a.outputTokens, - "total_cost": a.totalCost, - "avg_latency_ms": math.Round(avgLatency*100) / 100, - "avg_first_token_ms": math.Round(avgTTFT*100) / 100, - "tps": math.Round(tps*100) / 100, + "model_name": model, + "request_count": a.requestCount, + "success_count": a.successCount, + "success_rate": math.Round(successRate*100) / 100, + "grade": gradeSuccessRate(successRate), + "input_tokens": a.inputTokens, + "output_tokens": a.outputTokens, + "total_tokens": a.inputTokens + a.outputTokens, + "total_cost": a.totalCost, + "avg_latency_ms": math.Round(avgLatency*100) / 100, + "avg_first_token_ms": math.Round(avgTTFT*100) / 100, + "tps": math.Round(tps*100) / 100, + "cache_creation_tokens": a.cacheCreationTokens, + "cache_read_tokens": a.cacheReadTokens, + "cache_hit_rate": math.Round(cacheHitRate*100) / 100, + "cache_hit_request_count": a.cacheHitRequests, + "cache_hit_request_rate": math.Round(cacheHitReqRate*100) / 100, }) } // 默认按模型名称升序排列,便于运营按名称定位具体模型;用户可在前端表格点任意列切换排序。 diff --git a/internal/logic/monitor/api_metrics_test.go b/internal/logic/monitor/api_metrics_test.go index e84079f..aa4414a 100644 --- a/internal/logic/monitor/api_metrics_test.go +++ b/internal/logic/monitor/api_metrics_test.go @@ -37,8 +37,10 @@ func assertPerfField(t *testing.T, list []map[string]any, model, field string, w func TestBuildModelPerformanceList_HistoryOnly(t *testing.T) { rows := []modelPerfRow{ - {ModelName: "gpt-4o", RequestCount: 100, SuccessCount: 90, InputTokens: 1000, OutputTokens: 2000, TotalCost: 1.5, SumLatencyMs: 50000, SumFirstTokenMs: 8000}, - {ModelName: "claude-3-5-sonnet", RequestCount: 50, SuccessCount: 1, InputTokens: 100, OutputTokens: 50, TotalCost: 0.1, SumLatencyMs: 30000, SumFirstTokenMs: 0}, + {ModelName: "gpt-4o", RequestCount: 100, SuccessCount: 90, InputTokens: 1000, OutputTokens: 2000, TotalCost: 1.5, SumLatencyMs: 50000, SumFirstTokenMs: 8000, + CacheReadTokens: 800, CacheHitRequestCount: 80}, + {ModelName: "claude-3-5-sonnet", RequestCount: 50, SuccessCount: 1, InputTokens: 100, OutputTokens: 50, TotalCost: 0.1, SumLatencyMs: 30000, SumFirstTokenMs: 0, + CacheCreationTokens: 200, CacheReadTokens: 700, CacheHitRequestCount: 40}, } list := buildModelPerformanceList(rows, nil) @@ -54,20 +56,32 @@ func TestBuildModelPerformanceList_HistoryOnly(t *testing.T) { assertPerfField(t, list, "gpt-4o", "total_tokens", 0, 3000) assertPerfField(t, list, "gpt-4o", "total_cost", 1.5, 0) + // 缓存:无写入,命中率 = 800/(1000+0+800) = 44.44%;请求级 = 80/100 = 80% + assertPerfField(t, list, "gpt-4o", "cache_read_tokens", 0, 800) + assertPerfField(t, list, "gpt-4o", "cache_creation_tokens", 0, 0) + assertPerfField(t, list, "gpt-4o", "cache_hit_rate", 44.44, 0) + assertPerfField(t, list, "gpt-4o", "cache_hit_request_count", 0, 80) + assertPerfField(t, list, "gpt-4o", "cache_hit_request_rate", 80.0, 0) + // claude 成功率 2%(1/50)→ critical assertPerfField(t, list, "claude-3-5-sonnet", "success_rate", 2.0, 0) assertGrade(t, list, "claude-3-5-sonnet", "critical") assertPerfField(t, list, "claude-3-5-sonnet", "avg_latency_ms", 600.0, 0) assertPerfField(t, list, "claude-3-5-sonnet", "tps", 1.67, 0) // 50/(30000/1000)=1.6666 + + // 缓存:命中率 = 700/(100+200+700) = 70%;请求级 = 40/50 = 80% + assertPerfField(t, list, "claude-3-5-sonnet", "cache_hit_rate", 70.0, 0) + assertPerfField(t, list, "claude-3-5-sonnet", "cache_hit_request_rate", 80.0, 0) } func TestBuildModelPerformanceList_MergeToday(t *testing.T) { rows := []modelPerfRow{ - {ModelName: "gpt-4o", RequestCount: 100, SuccessCount: 90, InputTokens: 1000, OutputTokens: 2000, TotalCost: 1.5, SumLatencyMs: 50000, SumFirstTokenMs: 8000}, + {ModelName: "gpt-4o", RequestCount: 100, SuccessCount: 90, InputTokens: 1000, OutputTokens: 2000, TotalCost: 1.5, SumLatencyMs: 50000, SumFirstTokenMs: 8000, + CacheCreationTokens: 100, CacheReadTokens: 800, CacheHitRequestCount: 70}, } today := map[string]common.ModelPerfCounter{ - "gpt-4o": {Req: 10, Ok: 9, Lat: 6000, Ttft: 500, TtftN: 10, Tin: 50, Tout: 150, CostMicro: 500000}, // 500000 micro = 0.5 USD - "new-model-day": {Req: 1, Ok: 0, Lat: 8000, Ttft: 0, TtftN: 0, Tin: 100, Tout: 0, CostMicro: 12345}, + "gpt-4o": {Req: 10, Ok: 9, Lat: 6000, Ttft: 500, TtftN: 10, Tin: 50, Tout: 150, CostMicro: 500000, CacheCreation: 20, CacheRead: 180, CacheHitReq: 8}, // 500000 micro = 0.5 USD + "new-model-day": {Req: 1, Ok: 0, Lat: 8000, Ttft: 0, TtftN: 0, Tin: 100, Tout: 0, CostMicro: 12345, CacheRead: 60, CacheHitReq: 1}, } list := buildModelPerformanceList(rows, today) @@ -79,6 +93,14 @@ func TestBuildModelPerformanceList_MergeToday(t *testing.T) { assertPerfField(t, list, "gpt-4o", "total_tokens", 0, 3200) // (1000+50)+(2000+150) assertPerfField(t, list, "gpt-4o", "total_cost", 2.0, 0) + // 缓存合并:read 800+180=980、creation 100+20=120、命中请求 70+8=78; + // 命中率 = 980/((1000+50)+120+980) = 980/2150 = 45.58%;请求级 = 78/110 = 70.91% + assertPerfField(t, list, "gpt-4o", "cache_read_tokens", 0, 980) + assertPerfField(t, list, "gpt-4o", "cache_creation_tokens", 0, 120) + assertPerfField(t, list, "gpt-4o", "cache_hit_rate", 45.58, 0) + assertPerfField(t, list, "gpt-4o", "cache_hit_request_count", 0, 78) + assertPerfField(t, list, "gpt-4o", "cache_hit_request_rate", 70.91, 0) + // 仅当日模型 assertPerfField(t, list, "new-model-day", "request_count", 0, 1) assertPerfField(t, list, "new-model-day", "total_cost", 0.012345, 0) @@ -86,10 +108,35 @@ func TestBuildModelPerformanceList_MergeToday(t *testing.T) { assertPerfField(t, list, "new-model-day", "avg_latency_ms", 8000.0, 0) assertPerfField(t, list, "new-model-day", "tps", 0.0, 0) // 无输出 token + // 仅当日缓存:命中率 = 60/(100+0+60) = 37.5%;请求级 = 1/1 = 100% + assertPerfField(t, list, "new-model-day", "cache_hit_rate", 37.5, 0) + assertPerfField(t, list, "new-model-day", "cache_hit_request_rate", 100.0, 0) + // 按模型名称升序:gpt-4o('g')在 new-model-day('n')前 _ = assertOrder(t, list, "gpt-4o", "new-model-day") } +// 缓存边界:仅写入未命中(creation>0、read=0)不算命中请求;无缓存活动时分母为 0 不 panic、比率为 0。 +func TestBuildModelPerformanceList_CacheEdgeCases(t *testing.T) { + rows := []modelPerfRow{ + // 首次写入缓存,尚未命中:creation 500 / read 0 / 命中请求 0 + {ModelName: "write-only", RequestCount: 10, SuccessCount: 10, InputTokens: 500, OutputTokens: 100, + CacheCreationTokens: 500}, + // 完全无缓存活动(embedding 类模型) + {ModelName: "text-embedding-3", RequestCount: 20, SuccessCount: 20, InputTokens: 4000, OutputTokens: 0}, + } + list := buildModelPerformanceList(rows, nil) + + assertPerfField(t, list, "write-only", "cache_creation_tokens", 0, 500) + assertPerfField(t, list, "write-only", "cache_read_tokens", 0, 0) + assertPerfField(t, list, "write-only", "cache_hit_rate", 0.0, 0) // 0/(500+500+0) + assertPerfField(t, list, "write-only", "cache_hit_request_count", 0, 0) // 仅写入不计命中 + assertPerfField(t, list, "write-only", "cache_hit_request_rate", 0.0, 0) // 0/10 + + assertPerfField(t, list, "text-embedding-3", "cache_hit_rate", 0.0, 0) + assertPerfField(t, list, "text-embedding-3", "cache_hit_request_rate", 0.0, 0) +} + func TestBuildModelPerformanceList_FilterUnknownAndEmpty(t *testing.T) { rows := []modelPerfRow{ {ModelName: "unknown", RequestCount: 999, SuccessCount: 0}, diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 27f9696..a7f7e0c 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -343,6 +343,9 @@ func (p *DataProviderImpl) RecordUsage(ctx context.Context, record *common.Usage InputTokens: record.PromptTokens, OutputTokens: record.CompletionTokens, CostUSD: record.TotalCost, + // 缓存 token 透传给热桶(命中请求判定由热桶模块从 read>0 推导) + CacheCreationTokens: record.CacheCreationTokens, + CacheReadTokens: record.CacheReadTokens, }) } diff --git a/internal/logic/task/usage_aggregation.go b/internal/logic/task/usage_aggregation.go index 8c4e108..b792951 100644 --- a/internal/logic/task/usage_aggregation.go +++ b/internal/logic/task/usage_aggregation.go @@ -13,7 +13,9 @@ const aggregateUsageSQL = ` INSERT INTO bil_usage_daily (stat_date, tenant_id, project_id, model_name, channel_id, status, request_count, input_tokens, output_tokens, total_cost, account_cost, - sum_latency_ms, sum_first_token_ms, updated_at) + sum_latency_ms, sum_first_token_ms, + cache_creation_tokens, cache_read_tokens, cache_hit_request_count, + updated_at) SELECT DATE(created_at) AS stat_date, tenant_id AS tenant_id, @@ -28,6 +30,9 @@ SELECT COALESCE(SUM(account_cost), 0) AS account_cost, COALESCE(SUM(latency_ms), 0) AS sum_latency_ms, COALESCE(SUM(first_token_ms), 0) AS sum_first_token_ms, + COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(CASE WHEN cache_read_tokens > 0 THEN 1 ELSE 0 END), 0) AS cache_hit_request_count, now() FROM bil_usage_logs WHERE created_at >= $1 AND created_at < $2 @@ -39,14 +44,17 @@ GROUP BY COALESCE(channel_id, 0), COALESCE(NULLIF(status, ''), 'unknown') ON CONFLICT (stat_date, tenant_id, project_id, model_name, channel_id, status) DO UPDATE SET - request_count = EXCLUDED.request_count, - input_tokens = EXCLUDED.input_tokens, - output_tokens = EXCLUDED.output_tokens, - total_cost = EXCLUDED.total_cost, - account_cost = EXCLUDED.account_cost, - sum_latency_ms = EXCLUDED.sum_latency_ms, + request_count = EXCLUDED.request_count, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + total_cost = EXCLUDED.total_cost, + account_cost = EXCLUDED.account_cost, + sum_latency_ms = EXCLUDED.sum_latency_ms, sum_first_token_ms = EXCLUDED.sum_first_token_ms, - updated_at = now() + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + cache_hit_request_count = EXCLUDED.cache_hit_request_count, + updated_at = now() ` // AggregateUsageRange 将 bil_usage_logs 在 [startDate, endDate)(半开区间)的用量 diff --git a/internal/model/do/bil_usage_daily.go b/internal/model/do/bil_usage_daily.go index 4a5efc8..09720bd 100644 --- a/internal/model/do/bil_usage_daily.go +++ b/internal/model/do/bil_usage_daily.go @@ -11,21 +11,24 @@ import ( // BilUsageDaily is the golang structure of table bil_usage_daily for DAO operations like Where/Data. type BilUsageDaily struct { - g.Meta `orm:"table:bil_usage_daily, do:true"` - Id any // - StatDate *gtime.Time // 统计日期(按 created_at 的日期分桶) - TenantId any // 租户ID - ProjectId any // 项目ID(0=无项目/个人Key) - ModelName any // 模型名称 - ChannelId any // 渠道ID(0=无渠道) - Status any // 请求状态:success/error/timeout/cancelled - RequestCount any // 请求数(COUNT(*)) - InputTokens any // 输入Token合计 - OutputTokens any // 输出Token合计 - TotalCost any // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) - AccountCost any // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) - SumLatencyMs any // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) - SumFirstTokenMs any // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) - CreatedAt *gtime.Time // - UpdatedAt *gtime.Time // + g.Meta `orm:"table:bil_usage_daily, do:true"` + Id any // + StatDate *gtime.Time // 统计日期(按 created_at 的日期分桶) + TenantId any // 租户ID + ProjectId any // 项目ID(0=无项目/个人Key) + ModelName any // 模型名称 + ChannelId any // 渠道ID(0=无渠道) + Status any // 请求状态:success/error/timeout/cancelled + RequestCount any // 请求数(COUNT(*)) + InputTokens any // 输入Token合计 + OutputTokens any // 输出Token合计 + TotalCost any // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) + AccountCost any // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) + SumLatencyMs any // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) + SumFirstTokenMs any // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) + CreatedAt *gtime.Time // + UpdatedAt *gtime.Time // + CacheCreationTokens any // 缓存创建 token 数(Claude cache_creation,SUM 聚合) + CacheReadTokens any // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合) + CacheHitRequestCount any // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行) } diff --git a/internal/model/do/mdl_tenant_models.go b/internal/model/do/mdl_tenant_models.go index c849def..e510c84 100644 --- a/internal/model/do/mdl_tenant_models.go +++ b/internal/model/do/mdl_tenant_models.go @@ -19,7 +19,7 @@ type MdlTenantModels struct { Enabled any // 是否启用(禁用后该租户无法调用此模型) CustomInputPrice *decimal.Decimal // 租户自定义输入价格(NULL 表示使用默认定价) CustomOutputPrice *decimal.Decimal // 租户自定义输出价格(NULL 表示使用默认定价) - Multiplier any // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率) + Multiplier any // 租户价格倍率(VIP 折扣)。作为 tenant_multiplier 参与最终价格计算:最终价格 = 基础价格 × 租户倍率。倍率来源优先级:discount_ratio > multiplier > 租户等级 price_multiplier CreatedAt *gtime.Time // 创建时间 UpdatedAt *gtime.Time // 更新时间 BillingMode any // 覆盖模型计费方式(NULL表示跟随模型默认) diff --git a/internal/model/entity/bil_usage_daily.go b/internal/model/entity/bil_usage_daily.go index 96a3937..77d3330 100644 --- a/internal/model/entity/bil_usage_daily.go +++ b/internal/model/entity/bil_usage_daily.go @@ -11,20 +11,23 @@ import ( // BilUsageDaily is the golang structure for table bil_usage_daily. type BilUsageDaily struct { - Id int64 `json:"id" orm:"id" description:""` // - StatDate *gtime.Time `json:"stat_date" orm:"stat_date" description:"统计日期(按 created_at 的日期分桶)"` // 统计日期(按 created_at 的日期分桶) - TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID - ProjectId int64 `json:"project_id" orm:"project_id" description:"项目ID(0=无项目/个人Key)"` // 项目ID(0=无项目/个人Key) - ModelName string `json:"model_name" orm:"model_name" description:"模型名称"` // 模型名称 - ChannelId int64 `json:"channel_id" orm:"channel_id" description:"渠道ID(0=无渠道)"` // 渠道ID(0=无渠道) - Status string `json:"status" orm:"status" description:"请求状态:success/error/timeout/cancelled"` // 请求状态:success/error/timeout/cancelled - RequestCount int `json:"request_count" orm:"request_count" description:"请求数(COUNT(*))"` // 请求数(COUNT(*)) - InputTokens int64 `json:"input_tokens" orm:"input_tokens" description:"输入Token合计"` // 输入Token合计 - OutputTokens int64 `json:"output_tokens" orm:"output_tokens" description:"输出Token合计"` // 输出Token合计 - TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"客户侧成本合计(USD,源自 bil_usage_logs.total_cost)"` // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) - AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)"` // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) - SumLatencyMs int64 `json:"sum_latency_ms" orm:"sum_latency_ms" description:"总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值)"` // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) - SumFirstTokenMs int64 `json:"sum_first_token_ms" orm:"sum_first_token_ms" description:"首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)"` // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) - CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` // - UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` // + Id int64 `json:"id" orm:"id" description:""` // + StatDate *gtime.Time `json:"stat_date" orm:"stat_date" description:"统计日期(按 created_at 的日期分桶)"` // 统计日期(按 created_at 的日期分桶) + TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID + ProjectId int64 `json:"project_id" orm:"project_id" description:"项目ID(0=无项目/个人Key)"` // 项目ID(0=无项目/个人Key) + ModelName string `json:"model_name" orm:"model_name" description:"模型名称"` // 模型名称 + ChannelId int64 `json:"channel_id" orm:"channel_id" description:"渠道ID(0=无渠道)"` // 渠道ID(0=无渠道) + Status string `json:"status" orm:"status" description:"请求状态:success/error/timeout/cancelled"` // 请求状态:success/error/timeout/cancelled + RequestCount int `json:"request_count" orm:"request_count" description:"请求数(COUNT(*))"` // 请求数(COUNT(*)) + InputTokens int64 `json:"input_tokens" orm:"input_tokens" description:"输入Token合计"` // 输入Token合计 + OutputTokens int64 `json:"output_tokens" orm:"output_tokens" description:"输出Token合计"` // 输出Token合计 + TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"客户侧成本合计(USD,源自 bil_usage_logs.total_cost)"` // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) + AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)"` // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) + SumLatencyMs int64 `json:"sum_latency_ms" orm:"sum_latency_ms" description:"总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值)"` // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) + SumFirstTokenMs int64 `json:"sum_first_token_ms" orm:"sum_first_token_ms" description:"首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)"` // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` // + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` // + CacheCreationTokens int64 `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数(Claude cache_creation,SUM 聚合)"` // 缓存创建 token 数(Claude cache_creation,SUM 聚合) + CacheReadTokens int64 `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)"` // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合) + CacheHitRequestCount int64 `json:"cache_hit_request_count" orm:"cache_hit_request_count" description:"命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)"` // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行) } diff --git a/internal/model/entity/mdl_tenant_models.go b/internal/model/entity/mdl_tenant_models.go index ca7b682..4ef62d5 100644 --- a/internal/model/entity/mdl_tenant_models.go +++ b/internal/model/entity/mdl_tenant_models.go @@ -17,7 +17,7 @@ type MdlTenantModels struct { Enabled bool `json:"enabled" orm:"enabled" description:"是否启用(禁用后该租户无法调用此模型)"` // 是否启用(禁用后该租户无法调用此模型) CustomInputPrice *decimal.Decimal `json:"custom_input_price" orm:"custom_input_price" description:"租户自定义输入价格(NULL 表示使用默认定价)"` // 租户自定义输入价格(NULL 表示使用默认定价) CustomOutputPrice *decimal.Decimal `json:"custom_output_price" orm:"custom_output_price" description:"租户自定义输出价格(NULL 表示使用默认定价)"` // 租户自定义输出价格(NULL 表示使用默认定价) - Multiplier decimal.Decimal `json:"multiplier" orm:"multiplier" description:"租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)"` // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率) + Multiplier decimal.Decimal `json:"multiplier" orm:"multiplier" description:"租户价格倍率(VIP 折扣)。作为 tenant_multiplier 参与最终价格计算:最终价格 = 基础价格 × 租户倍率。倍率来源优先级:discount_ratio > multiplier > 租户等级 price_multiplier"` // 租户价格倍率(VIP 折扣)。作为 tenant_multiplier 参与最终价格计算:最终价格 = 基础价格 × 租户倍率。倍率来源优先级:discount_ratio > multiplier > 租户等级 price_multiplier CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 BillingMode string `json:"billing_mode" orm:"billing_mode" description:"覆盖模型计费方式(NULL表示跟随模型默认)"` // 覆盖模型计费方式(NULL表示跟随模型默认) diff --git a/migrations/000016_add_cache_columns_to_bil_usage_daily.sql b/migrations/000016_add_cache_columns_to_bil_usage_daily.sql new file mode 100644 index 0000000..3b3c60b --- /dev/null +++ b/migrations/000016_add_cache_columns_to_bil_usage_daily.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- 模型性能监控:bil_usage_daily 增加缓存聚合列(数据源自 bil_usage_logs 已有的缓存明细字段)。 +-- cache_creation_tokens 缓存创建 token 数(Claude cache_creation_input_tokens) +-- cache_read_tokens 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens) +-- cache_hit_request_count 命中缓存的请求数(明细行 cache_read_tokens > 0 计 1,与 request_count 同分母) +-- 上线后需用 aggregate-usage 命令回填历史区间(ON CONFLICT 幂等)。 + +ALTER TABLE bil_usage_daily ADD COLUMN cache_creation_tokens BIGINT DEFAULT 0 NOT NULL; +COMMENT ON COLUMN bil_usage_daily.cache_creation_tokens IS '缓存创建 token 数(Claude cache_creation,SUM 聚合)'; + +ALTER TABLE bil_usage_daily ADD COLUMN cache_read_tokens BIGINT DEFAULT 0 NOT NULL; +COMMENT ON COLUMN bil_usage_daily.cache_read_tokens IS '缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)'; + +ALTER TABLE bil_usage_daily ADD COLUMN cache_hit_request_count BIGINT DEFAULT 0 NOT NULL; +COMMENT ON COLUMN bil_usage_daily.cache_hit_request_count IS '命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)'; + +-- +goose Down +ALTER TABLE bil_usage_daily DROP COLUMN IF EXISTS cache_creation_tokens; +ALTER TABLE bil_usage_daily DROP COLUMN IF EXISTS cache_read_tokens; +ALTER TABLE bil_usage_daily DROP COLUMN IF EXISTS cache_hit_request_count; diff --git a/web/admin/src/views/monitor/ModelPerformancePage.vue b/web/admin/src/views/monitor/ModelPerformancePage.vue index c11169b..c2121fe 100644 --- a/web/admin/src/views/monitor/ModelPerformancePage.vue +++ b/web/admin/src/views/monitor/ModelPerformancePage.vue @@ -1,6 +1,7 @@