CrossRelay stage 2: Codex /v1/responses relay, config.toml manager, Codex page
This commit is contained in:
1 parent
404704171d
commit
5112f7ca06
30 files changed
+3539
-72
No files matched your search
@@ -0,0 +1,296 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user