From 895423852f3751a38f0e822da8bd2163180fb32b Mon Sep 17 00:00:00 2001 From: CJACK Date: Wed, 18 Feb 2026 23:35:37 +0800 Subject: [PATCH] refactor: extract Claude and OpenAI response rendering into new `util/render` package --- internal/adapter/claude/handler.go | 46 ++----- internal/adapter/openai/handler.go | 32 +---- internal/adapter/openai/responses_handler.go | 61 +-------- internal/util/render.go | 136 +++++++++++++++++++ internal/util/render_test.go | 72 ++++++++++ 5 files changed, 221 insertions(+), 126 deletions(-) create mode 100644 internal/util/render.go create mode 100644 internal/util/render_test.go diff --git a/internal/adapter/claude/handler.go b/internal/adapter/claude/handler.go index 44240af..bac315f 100644 --- a/internal/adapter/claude/handler.go +++ b/internal/adapter/claude/handler.go @@ -98,43 +98,15 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) { return } result := sse.CollectStream(resp, stdReq.Thinking, true) - fullText := result.Text - fullThinking := result.Thinking - detected := util.ParseToolCalls(fullText, stdReq.ToolNames) - content := make([]map[string]any, 0, 4) - if fullThinking != "" { - content = append(content, map[string]any{"type": "thinking", "thinking": fullThinking}) - } - stopReason := "end_turn" - if len(detected) > 0 { - stopReason = "tool_use" - for i, tc := range detected { - content = append(content, map[string]any{ - "type": "tool_use", - "id": fmt.Sprintf("toolu_%d_%d", time.Now().Unix(), i), - "name": tc.Name, - "input": tc.Input, - }) - } - } else { - if fullText == "" { - fullText = "抱歉,没有生成有效的响应内容。" - } - content = append(content, map[string]any{"type": "text", "text": fullText}) - } - writeJSON(w, http.StatusOK, map[string]any{ - "id": fmt.Sprintf("msg_%d", time.Now().UnixNano()), - "type": "message", - "role": "assistant", - "model": stdReq.ResponseModel, - "content": content, - "stop_reason": stopReason, - "stop_sequence": nil, - "usage": map[string]any{ - "input_tokens": util.EstimateTokens(fmt.Sprintf("%v", norm.NormalizedMessages)), - "output_tokens": util.EstimateTokens(fullThinking) + util.EstimateTokens(fullText), - }, - }) + respBody := util.BuildClaudeMessageResponse( + fmt.Sprintf("msg_%d", time.Now().UnixNano()), + stdReq.ResponseModel, + norm.NormalizedMessages, + result.Thinking, + result.Text, + stdReq.ToolNames, + ) + writeJSON(w, http.StatusOK, respBody) } func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) { diff --git a/internal/adapter/openai/handler.go b/internal/adapter/openai/handler.go index fadca38..ce90804 100644 --- a/internal/adapter/openai/handler.go +++ b/internal/adapter/openai/handler.go @@ -136,36 +136,8 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re finalThinking := result.Thinking finalText := result.Text - detected := util.ParseToolCalls(finalText, toolNames) - finishReason := "stop" - messageObj := map[string]any{"role": "assistant", "content": finalText} - if thinkingEnabled && finalThinking != "" { - messageObj["reasoning_content"] = finalThinking - } - if len(detected) > 0 { - finishReason = "tool_calls" - messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected) - messageObj["content"] = nil - } - promptTokens := util.EstimateTokens(finalPrompt) - reasoningTokens := util.EstimateTokens(finalThinking) - completionTokens := util.EstimateTokens(finalText) - - writeJSON(w, http.StatusOK, map[string]any{ - "id": completionID, - "object": "chat.completion", - "created": time.Now().Unix(), - "model": model, - "choices": []map[string]any{{"index": 0, "message": messageObj, "finish_reason": finishReason}}, - "usage": map[string]any{ - "prompt_tokens": promptTokens, - "completion_tokens": reasoningTokens + completionTokens, - "total_tokens": promptTokens + reasoningTokens + completionTokens, - "completion_tokens_details": map[string]any{ - "reasoning_tokens": reasoningTokens, - }, - }, - }) + respBody := util.BuildOpenAIChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames) + writeJSON(w, http.StatusOK, respBody) } func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) { diff --git a/internal/adapter/openai/responses_handler.go b/internal/adapter/openai/responses_handler.go index b70fe0b..92dd891 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/adapter/openai/responses_handler.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "strings" - "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -115,7 +114,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res return } result := sse.CollectStream(resp, thinkingEnabled, true) - responseObj := buildResponseObject(responseID, model, finalPrompt, result.Thinking, result.Text, toolNames) + responseObj := util.BuildOpenAIResponseObject(responseID, model, finalPrompt, result.Thinking, result.Text, toolNames) h.getResponseStore().put(owner, responseID, responseObj) writeJSON(w, http.StatusOK, responseObj) } @@ -189,7 +188,7 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, } } } - obj := buildResponseObject(responseID, model, finalPrompt, finalThinking, finalText, toolNames) + obj := util.BuildOpenAIResponseObject(responseID, model, finalPrompt, finalThinking, finalText, toolNames) if toolCallsEmitted { obj["status"] = "completed" } @@ -282,62 +281,6 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, } } -func buildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { - detected := util.ParseToolCalls(finalText, toolNames) - output := make([]any, 0, 2) - if len(detected) > 0 { - toolCalls := make([]any, 0, len(detected)) - for _, tc := range detected { - toolCalls = append(toolCalls, map[string]any{ - "type": "tool_call", - "name": tc.Name, - "arguments": tc.Input, - }) - } - output = append(output, map[string]any{ - "type": "tool_calls", - "tool_calls": toolCalls, - }) - } else { - content := []any{ - map[string]any{ - "type": "output_text", - "text": finalText, - }, - } - if finalThinking != "" { - content = append([]any{map[string]any{ - "type": "reasoning", - "text": finalThinking, - }}, content...) - } - output = append(output, map[string]any{ - "type": "message", - "id": "msg_" + strings.ReplaceAll(uuid.NewString(), "-", ""), - "role": "assistant", - "content": content, - }) - } - promptTokens := util.EstimateTokens(finalPrompt) - reasoningTokens := util.EstimateTokens(finalThinking) - completionTokens := util.EstimateTokens(finalText) - return map[string]any{ - "id": responseID, - "type": "response", - "object": "response", - "created_at": time.Now().Unix(), - "status": "completed", - "model": model, - "output": output, - "output_text": finalText, - "usage": map[string]any{ - "input_tokens": promptTokens, - "output_tokens": reasoningTokens + completionTokens, - "total_tokens": promptTokens + reasoningTokens + completionTokens, - }, - } -} - func responsesMessagesFromRequest(req map[string]any) []any { if msgs, ok := req["messages"].([]any); ok && len(msgs) > 0 { return prependInstructionMessage(msgs, req["instructions"]) diff --git a/internal/util/render.go b/internal/util/render.go new file mode 100644 index 0000000..ffb8128 --- /dev/null +++ b/internal/util/render.go @@ -0,0 +1,136 @@ +package util + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +func BuildOpenAIChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { + detected := ParseToolCalls(finalText, toolNames) + finishReason := "stop" + messageObj := map[string]any{"role": "assistant", "content": finalText} + if strings.TrimSpace(finalThinking) != "" { + messageObj["reasoning_content"] = finalThinking + } + if len(detected) > 0 { + finishReason = "tool_calls" + messageObj["tool_calls"] = FormatOpenAIToolCalls(detected) + messageObj["content"] = nil + } + promptTokens := EstimateTokens(finalPrompt) + reasoningTokens := EstimateTokens(finalThinking) + completionTokens := EstimateTokens(finalText) + + return map[string]any{ + "id": completionID, + "object": "chat.completion", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]any{{"index": 0, "message": messageObj, "finish_reason": finishReason}}, + "usage": map[string]any{ + "prompt_tokens": promptTokens, + "completion_tokens": reasoningTokens + completionTokens, + "total_tokens": promptTokens + reasoningTokens + completionTokens, + "completion_tokens_details": map[string]any{ + "reasoning_tokens": reasoningTokens, + }, + }, + } +} + +func BuildOpenAIResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { + detected := ParseToolCalls(finalText, toolNames) + output := make([]any, 0, 2) + if len(detected) > 0 { + toolCalls := make([]any, 0, len(detected)) + for _, tc := range detected { + toolCalls = append(toolCalls, map[string]any{ + "type": "tool_call", + "name": tc.Name, + "arguments": tc.Input, + }) + } + output = append(output, map[string]any{ + "type": "tool_calls", + "tool_calls": toolCalls, + }) + } else { + content := []any{ + map[string]any{ + "type": "output_text", + "text": finalText, + }, + } + if finalThinking != "" { + content = append([]any{map[string]any{ + "type": "reasoning", + "text": finalThinking, + }}, content...) + } + output = append(output, map[string]any{ + "type": "message", + "id": "msg_" + strings.ReplaceAll(uuid.NewString(), "-", ""), + "role": "assistant", + "content": content, + }) + } + promptTokens := EstimateTokens(finalPrompt) + reasoningTokens := EstimateTokens(finalThinking) + completionTokens := EstimateTokens(finalText) + return map[string]any{ + "id": responseID, + "type": "response", + "object": "response", + "created_at": time.Now().Unix(), + "status": "completed", + "model": model, + "output": output, + "output_text": finalText, + "usage": map[string]any{ + "input_tokens": promptTokens, + "output_tokens": reasoningTokens + completionTokens, + "total_tokens": promptTokens + reasoningTokens + completionTokens, + }, + } +} + +func BuildClaudeMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any { + detected := ParseToolCalls(finalText, toolNames) + content := make([]map[string]any, 0, 4) + if finalThinking != "" { + content = append(content, map[string]any{"type": "thinking", "thinking": finalThinking}) + } + stopReason := "end_turn" + if len(detected) > 0 { + stopReason = "tool_use" + for i, tc := range detected { + content = append(content, map[string]any{ + "type": "tool_use", + "id": fmt.Sprintf("toolu_%d_%d", time.Now().Unix(), i), + "name": tc.Name, + "input": tc.Input, + }) + } + } else { + if finalText == "" { + finalText = "抱歉,没有生成有效的响应内容。" + } + content = append(content, map[string]any{"type": "text", "text": finalText}) + } + return map[string]any{ + "id": messageID, + "type": "message", + "role": "assistant", + "model": model, + "content": content, + "stop_reason": stopReason, + "stop_sequence": nil, + "usage": map[string]any{ + "input_tokens": EstimateTokens(fmt.Sprintf("%v", normalizedMessages)), + "output_tokens": EstimateTokens(finalThinking) + EstimateTokens(finalText), + }, + } +} diff --git a/internal/util/render_test.go b/internal/util/render_test.go new file mode 100644 index 0000000..1ee296b --- /dev/null +++ b/internal/util/render_test.go @@ -0,0 +1,72 @@ +package util + +import "testing" + +func TestBuildOpenAIChatCompletionWithToolCalls(t *testing.T) { + out := BuildOpenAIChatCompletion( + "cid1", + "deepseek-chat", + "prompt", + "", + `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`, + []string{"search"}, + ) + if out["object"] != "chat.completion" { + t.Fatalf("unexpected object: %#v", out["object"]) + } + choices, _ := out["choices"].([]map[string]any) + if len(choices) == 0 { + // json-like map from generic marshalling may be []any in some paths + rawChoices, _ := out["choices"].([]any) + if len(rawChoices) == 0 { + t.Fatalf("expected choices") + } + c0, _ := rawChoices[0].(map[string]any) + if c0["finish_reason"] != "tool_calls" { + t.Fatalf("expected finish_reason=tool_calls, got %#v", c0["finish_reason"]) + } + return + } + if choices[0]["finish_reason"] != "tool_calls" { + t.Fatalf("expected finish_reason=tool_calls, got %#v", choices[0]["finish_reason"]) + } +} + +func TestBuildOpenAIResponseObjectWithText(t *testing.T) { + out := BuildOpenAIResponseObject( + "resp_1", + "gpt-4o", + "prompt", + "reasoning", + "text", + nil, + ) + if out["object"] != "response" { + t.Fatalf("unexpected object: %#v", out["object"]) + } + output, _ := out["output"].([]any) + if len(output) == 0 { + t.Fatalf("expected output entries") + } + first, _ := output[0].(map[string]any) + if first["type"] != "message" { + t.Fatalf("expected first output type message, got %#v", first["type"]) + } +} + +func TestBuildClaudeMessageResponseToolUse(t *testing.T) { + out := BuildClaudeMessageResponse( + "msg_1", + "claude-sonnet-4-5", + []any{map[string]any{"role": "user", "content": "hi"}}, + "", + `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`, + []string{"search"}, + ) + if out["type"] != "message" { + t.Fatalf("unexpected type: %#v", out["type"]) + } + if out["stop_reason"] != "tool_use" { + t.Fatalf("expected stop_reason=tool_use, got %#v", out["stop_reason"]) + } +}