package responses import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "strings" "time" "gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl" ) // ConvertRequest converts a Responses request into a Chat Completions // request addressed to the given upstream model. func ConvertRequest(req *Request, model string) (*chatcmpl.Request, error) { out := &chatcmpl.Request{ Model: model, MaxTokens: req.MaxOutputTokens, Temperature: req.Temperature, TopP: req.TopP, Stream: req.Stream, } items, err := ParseInput(req.Input) if err != nil { return nil, fmt.Errorf("parse input: %w", err) } var systemParts []string if s := strings.TrimSpace(req.Instructions); s != "" { systemParts = append(systemParts, s) } var assistantText []string var assistantCalls []chatcmpl.ToolCall flushAssistant := func() { if len(assistantText) == 0 && len(assistantCalls) == 0 { return } msg := chatcmpl.Message{Role: "assistant"} if len(assistantText) > 0 { msg.Content = strings.Join(assistantText, "\n\n") } msg.ToolCalls = assistantCalls out.Messages = append(out.Messages, msg) assistantText = nil assistantCalls = nil } for _, it := range items { switch it.Type { case "reasoning": // Upstream Chat Completions has no reasoning channel; the // item is dropped rather than replayed as spoken text. continue case "message": parts, err := ParseContent(it.Content) if err != nil { return nil, fmt.Errorf("parse message content: %w", err) } switch it.Role { case "system", "developer": flushAssistant() for _, p := range parts { if isTextPart(p) { systemParts = append(systemParts, p.Text) } } case "assistant": for _, p := range parts { if isTextPart(p) { assistantText = append(assistantText, p.Text) } } default: flushAssistant() out.Messages = append(out.Messages, userMessage(parts)) } case "function_call": args := it.Arguments if strings.TrimSpace(args) == "" { args = "{}" } assistantCalls = append(assistantCalls, chatcmpl.ToolCall{ ID: it.CallID, Type: "function", Function: chatcmpl.ToolCallFunction{Name: it.Name, Arguments: args}, }) case "function_call_output": flushAssistant() out.Messages = append(out.Messages, chatcmpl.Message{ Role: "tool", ToolCallID: it.CallID, Content: OutputText(it.Output), }) } } flushAssistant() if len(systemParts) > 0 { system := chatcmpl.Message{Role: "system", Content: strings.Join(systemParts, "\n\n")} out.Messages = append([]chatcmpl.Message{system}, out.Messages...) } for _, t := range req.Tools { params := t.Parameters if t.Type != "function" { // Freeform tool forms (custom, local_shell) become function // tools with a permissive schema so upstreams that only // speak function calling still work. params = json.RawMessage(`{"type":"object","additionalProperties":true}`) } out.Tools = append(out.Tools, chatcmpl.Tool{ Type: "function", Function: chatcmpl.ToolFunction{ Name: t.Name, Description: t.Description, Parameters: params, }, }) } choice, err := convertToolChoice(req.ToolChoice) if err != nil { return nil, err } out.ToolChoice = choice if req.Stream { out.StreamOptions = &chatcmpl.StreamOptions{IncludeUsage: true} } return out, nil } func isTextPart(p ContentPart) bool { switch p.Type { case "input_text", "output_text", "text", "refusal": return true default: return false } } func userMessage(parts []ContentPart) chatcmpl.Message { var cps []chatcmpl.ContentPart for _, p := range parts { switch { case p.Type == "input_image" && p.ImageURL != "": cps = append(cps, chatcmpl.ContentPart{Type: "image_url", ImageURL: &chatcmpl.ImageURL{URL: p.ImageURL}}) case isTextPart(p) && p.Text != "": cps = append(cps, chatcmpl.ContentPart{Type: "text", Text: p.Text}) } } if len(cps) == 0 { return chatcmpl.Message{Role: "user", Content: ""} } if len(cps) == 1 && cps[0].Type == "text" { return chatcmpl.Message{Role: "user", Content: cps[0].Text} } return chatcmpl.Message{Role: "user", Content: cps} } func convertToolChoice(raw json.RawMessage) (any, error) { if len(raw) == 0 { return nil, nil } var s string if json.Unmarshal(raw, &s) == nil { switch s { case "auto", "none", "required": return s, nil default: return nil, nil } } var obj struct { Type string `json:"type"` Name string `json:"name"` } if json.Unmarshal(raw, &obj) == nil && obj.Type == "function" && obj.Name != "" { return map[string]any{ "type": "function", "function": map[string]any{"name": obj.Name}, }, nil } // Unknown tool choice shape: let the upstream decide. return nil, nil } // ConvertResponse converts a Chat Completions response into a Responses // response. requestedModel is echoed back to the client. func ConvertResponse(resp *chatcmpl.Response, requestedModel string) *Response { out := &Response{ ID: "resp_" + resp.ID, Object: "response", CreatedAt: time.Now().Unix(), Status: "completed", Model: requestedModel, Output: []OutItem{}, } var texts []string if len(resp.Choices) > 0 { c := resp.Choices[0] if txt := messageText(c.Message.Content); txt != "" { texts = append(texts, txt) out.Output = append(out.Output, OutItem{ ID: "msg_" + randHex(12), Type: "message", Role: "assistant", Status: "completed", Content: []ContentPart{{Type: "output_text", Text: txt}}, }) } for _, tc := range c.Message.ToolCalls { args := tc.Function.Arguments if strings.TrimSpace(args) == "" { args = "{}" } out.Output = append(out.Output, OutItem{ ID: "fc_" + randHex(12), Type: "function_call", Status: "completed", CallID: tc.ID, Name: tc.Function.Name, Arguments: args, }) } out.Status, out.IncompleteDetails = MapStatus(c.FinishReason) } out.OutputText = strings.Join(texts, "\n\n") if resp.Usage != nil { out.Usage = &Usage{ InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens, TotalTokens: resp.Usage.TotalTokens, } if out.Usage.TotalTokens == 0 { out.Usage.TotalTokens = out.Usage.InputTokens + out.Usage.OutputTokens } } return out } func messageText(content any) string { switch v := content.(type) { case string: return v case []chatcmpl.ContentPart: var sb strings.Builder for _, p := range v { if p.Type == "text" { sb.WriteString(p.Text) } } return sb.String() default: return "" } } // MapStatus maps a Chat Completions finish_reason onto a Responses // status plus incomplete reason. func MapStatus(finish string) (string, *Incomplete) { switch finish { case "length": return "incomplete", &Incomplete{Reason: "max_output_tokens"} case "content_filter": return "incomplete", &Incomplete{Reason: "content_filter"} default: return "completed", nil } } // EstimateTokens returns a rough input-token estimate for a converted // request. It fills in usage when the upstream stream carries none. func EstimateTokens(req *chatcmpl.Request) int { total := len(req.Model) / 4 for _, m := range req.Messages { total += len(m.Role) + 4 total += len(messageText(m.Content)) / 4 for _, tc := range m.ToolCalls { total += len(tc.Function.Name)/4 + len(tc.Function.Arguments)/4 } } for _, t := range req.Tools { total += len(t.Function.Name)/4 + len(t.Function.Description)/4 + len(t.Function.Parameters)/4 } if total < 1 { total = 1 } return total } func randHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("%x", n) } return hex.EncodeToString(b) }