297 lines
7.6 KiB
Go
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)
|
|
}
|