Files

297 lines
7.6 KiB
Go

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)
}