CrossRelay stage 2: Codex /v1/responses relay, config.toml manager, Codex page

This commit is contained in:
kate522 committed 2026-10-18 18:35:01 +08:00
1 parent 404704171d
commit 5112f7ca06
30 files changed
+3539 -72

No files matched your search

+296
View File
@@ -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)
}
+354
View File
@@ -0,0 +1,354 @@
package responses
import (
"encoding/json"
"testing"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
func normalizeJSON(t *testing.T, s string) string {
t.Helper()
var v any
if err := json.Unmarshal([]byte(s), &v); err != nil {
t.Fatalf("want is not JSON: %v (%s)", err, s)
}
out, err := json.Marshal(v)
if err != nil {
t.Fatalf("re-marshal want: %v", err)
}
return string(out)
}
func TestConvertRequest(t *testing.T) {
cases := []struct {
name string
req string
want string
}{
{
name: "instructions become the system message",
req: `{
"model": "gpt-5-codex",
"instructions": "You are Codex.",
"input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]}]
}`,
want: `{
"model": "up-model",
"messages": [
{"role": "system", "content": "You are Codex."},
{"role": "user", "content": "hi"}
]
}`,
},
{
name: "input as a plain string",
req: `{"model": "m", "input": "hello"}`,
want: `{"model": "up-model", "messages": [{"role": "user", "content": "hello"}]}`,
},
{
name: "system role input merges into instructions",
req: `{
"instructions": "base",
"input": [{"type": "message", "role": "system", "content": [{"type": "input_text", "text": "extra"}]}]
}`,
want: `{"model": "up-model", "messages": [{"role": "system", "content": "base\n\nextra"}]}`,
},
{
name: "image part becomes an image_url part",
req: `{
"input": [{"type": "message", "role": "user", "content": [
{"type": "input_image", "image_url": "https://example.com/a.png"},
{"type": "input_text", "text": "look"}
]}]
}`,
want: `{
"model": "up-model",
"messages": [{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
{"type": "text", "text": "look"}
]}]
}`,
},
{
name: "function_call and function_call_output round trip",
req: `{
"input": [
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "read a.txt"}]},
{"type": "function_call", "call_id": "call_1", "name": "read_file", "arguments": "{\"path\":\"a.txt\"}"},
{"type": "function_call_output", "call_id": "call_1", "output": "file body"},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done"}]}
]
}`,
want: `{
"model": "up-model",
"messages": [
{"role": "user", "content": "read a.txt"},
{"role": "assistant", "content": null, "tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"}}
]},
{"role": "tool", "tool_call_id": "call_1", "content": "file body"},
{"role": "assistant", "content": "done"}
]
}`,
},
{
name: "consecutive assistant items merge into one message",
req: `{
"input": [
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "part1"}]},
{"type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}"},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "part2"}]},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "next"}]},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "after"}]}
]
}`,
want: `{
"model": "up-model",
"messages": [
{"role": "assistant", "content": "part1\n\npart2", "tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
]},
{"role": "user", "content": "next"},
{"role": "assistant", "content": "after"}
]
}`,
},
{
name: "reasoning items are dropped",
req: `{
"input": [
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "thinking"}]},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]}
]
}`,
want: `{"model": "up-model", "messages": [{"role": "user", "content": "hi"}]}`,
},
{
name: "function_call_output with array output",
req: `{
"input": [
{"type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}"},
{"type": "function_call_output", "call_id": "c1", "output": [{"type": "output_text", "text": "line1"}, {"type": "output_text", "text": "line2"}]}
]
}`,
want: `{
"model": "up-model",
"messages": [
{"role": "assistant", "content": null, "tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "c1", "content": "line1line2"}
]
}`,
},
{
name: "custom tools degrade to permissive function tools",
req: `{
"input": "go",
"tools": [
{"type": "custom", "name": "shell", "description": "run a command"},
{"type": "local_shell", "name": "local"}
]
}`,
want: `{
"model": "up-model",
"messages": [{"role": "user", "content": "go"}],
"tools": [
{"type": "function", "function": {"name": "shell", "description": "run a command", "parameters": {"type": "object", "additionalProperties": true}}},
{"type": "function", "function": {"name": "local", "parameters": {"type": "object", "additionalProperties": true}}}
]
}`,
},
{
name: "function tools pass through with their schema",
req: `{
"input": "go",
"tools": [{"type": "function", "name": "f", "parameters": {"type": "object", "properties": {"a": {"type": "string"}}}}]
}`,
want: `{
"model": "up-model",
"messages": [{"role": "user", "content": "go"}],
"tools": [{"type": "function", "function": {"name": "f", "parameters": {"type": "object", "properties": {"a": {"type": "string"}}}}}]
}`,
},
{
name: "streaming asks the upstream for a usage chunk",
req: `{
"model": "m", "input": "hi", "stream": true,
"max_output_tokens": 512, "temperature": 0.5, "top_p": 0.9,
"store": false, "include": ["reasoning.encrypted_content"],
"prompt_cache_key": "k", "parallel_tool_calls": true,
"reasoning": {"effort": "high"}, "metadata": {"a": "b"}, "text": {"format": "json"}
}`,
want: `{
"model": "up-model",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 512, "temperature": 0.5, "top_p": 0.9,
"stream": true, "stream_options": {"include_usage": true}
}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var req Request
if err := json.Unmarshal([]byte(tc.req), &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
got, err := ConvertRequest(&req, "up-model")
if err != nil {
t.Fatalf("ConvertRequest: %v", err)
}
gotJSON, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
if want, gotNorm := normalizeJSON(t, tc.want), normalizeJSON(t, string(gotJSON)); gotNorm != want {
t.Errorf("converted request mismatch\n got: %s\nwant: %s", gotJSON, want)
}
})
}
}
func TestConvertToolChoice(t *testing.T) {
cases := []struct {
raw string
want string
}{
{`"auto"`, `"auto"`},
{`"none"`, `"none"`},
{`"required"`, `"required"`},
{`{"type": "function", "name": "f"}`, `{"type": "function", "function": {"name": "f"}}`},
{`{"type": "allowed_tools", "tools": []}`, `null`},
{`"weird"`, `null`},
{``, `null`},
}
for _, tc := range cases {
var req Request
if tc.raw != "" {
if err := json.Unmarshal([]byte(`{"tool_choice": `+tc.raw+`}`), &req); err != nil {
t.Fatalf("unmarshal %s: %v", tc.raw, err)
}
}
got, err := convertToolChoice(req.ToolChoice)
if err != nil {
t.Fatalf("convertToolChoice(%s): %v", tc.raw, err)
}
gotJSON, _ := json.Marshal(got)
if want := normalizeJSON(t, tc.want); string(gotJSON) != want {
t.Errorf("tool_choice %s -> %s, want %s", tc.raw, gotJSON, want)
}
}
}
func TestMapStatus(t *testing.T) {
cases := []struct {
finish string
wantStatus string
wantReason string
}{
{"stop", "completed", ""},
{"tool_calls", "completed", ""},
{"length", "incomplete", "max_output_tokens"},
{"content_filter", "incomplete", "content_filter"},
{"", "completed", ""},
}
for _, tc := range cases {
status, incomplete := MapStatus(tc.finish)
if status != tc.wantStatus {
t.Errorf("MapStatus(%q) status = %q, want %q", tc.finish, status, tc.wantStatus)
}
if tc.wantReason == "" {
if incomplete != nil {
t.Errorf("MapStatus(%q) incomplete = %v, want nil", tc.finish, incomplete)
}
} else if incomplete == nil || incomplete.Reason != tc.wantReason {
t.Errorf("MapStatus(%q) incomplete = %v, want reason %q", tc.finish, incomplete, tc.wantReason)
}
}
}
func TestConvertResponseText(t *testing.T) {
resp := chatResponse("stop", "hello", nil)
out := ConvertResponse(resp, "gpt-5-codex")
if out.ID != "resp_chatcmpl-1" || out.Object != "response" {
t.Errorf("envelope = %+v", out)
}
if out.Status != "completed" || out.IncompleteDetails != nil {
t.Errorf("status = %q incomplete = %v", out.Status, out.IncompleteDetails)
}
if out.Model != "gpt-5-codex" {
t.Errorf("model = %q, want the requested model echoed", out.Model)
}
if len(out.Output) != 1 || out.Output[0].Type != "message" {
t.Fatalf("output = %+v", out.Output)
}
msg := out.Output[0]
if msg.Role != "assistant" || len(msg.Content) != 1 || msg.Content[0].Text != "hello" {
t.Errorf("message item = %+v", msg)
}
if out.OutputText != "hello" {
t.Errorf("output_text = %q", out.OutputText)
}
if out.Usage == nil || out.Usage.InputTokens != 9 || out.Usage.OutputTokens != 2 || out.Usage.TotalTokens != 11 {
t.Errorf("usage = %+v", out.Usage)
}
}
func TestConvertResponseToolCalls(t *testing.T) {
resp := chatResponse("tool_calls", "", []chatcmpl.ToolCall{
{ID: "call_1", Type: "function", Function: chatcmpl.ToolCallFunction{Name: "shell", Arguments: `{"cmd":"ls"}`}},
})
out := ConvertResponse(resp, "m")
if len(out.Output) != 1 {
t.Fatalf("output = %+v", out.Output)
}
call := out.Output[0]
if call.Type != "function_call" || call.CallID != "call_1" || call.Name != "shell" || call.Arguments != `{"cmd":"ls"}` {
t.Errorf("function_call item = %+v", call)
}
if out.Status != "completed" {
t.Errorf("status = %q", out.Status)
}
}
func TestConvertResponseIncomplete(t *testing.T) {
out := ConvertResponse(chatResponse("length", "cut off", nil), "m")
if out.Status != "incomplete" {
t.Errorf("status = %q, want incomplete", out.Status)
}
if out.IncompleteDetails == nil || out.IncompleteDetails.Reason != "max_output_tokens" {
t.Errorf("incomplete_details = %+v", out.IncompleteDetails)
}
}
func TestEstimateTokens(t *testing.T) {
var req Request
if err := json.Unmarshal([]byte(`{
"instructions": "be helpful",
"input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello there"}]}],
"tools": [{"type": "function", "name": "f", "parameters": {"type": "object"}}]
}`), &req); err != nil {
t.Fatalf("unmarshal: %v", err)
}
chatReq, err := ConvertRequest(&req, "m")
if err != nil {
t.Fatalf("ConvertRequest: %v", err)
}
if got := EstimateTokens(chatReq); got <= 0 {
t.Errorf("EstimateTokens = %d, want positive", got)
}
}
func chatResponse(finish, text string, calls []chatcmpl.ToolCall) *chatcmpl.Response {
return &chatcmpl.Response{
ID: "chatcmpl-1",
Model: "up-model",
Choices: []chatcmpl.Choice{{
Index: 0,
Message: chatcmpl.Message{Role: "assistant", Content: text, ToolCalls: calls},
FinishReason: finish,
}},
Usage: &chatcmpl.Usage{PromptTokens: 9, CompletionTokens: 2, TotalTokens: 11},
}
}
+386
View File
@@ -0,0 +1,386 @@
package responses
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
// StreamConverter synthesizes Responses SSE events from Chat Completions
// stream chunks.
//
// Output items are tracked by their upstream origin: one message item
// (opened on the first text delta) and one function_call item per
// upstream tool-call index, in order of first appearance. Finish closes
// every open item and emits response.completed with usage.
type StreamConverter struct {
w http.ResponseWriter
flusher http.Flusher
model string
respID string
started bool
seq int
nextOut int
msgOpen bool
msgIndex int
msgID string
msgText strings.Builder
tools map[int]*toolItem
toolOrder []int
inputEstimate int
outputChars int
usage *chatcmpl.Usage
finishReason string
}
type toolItem struct {
outputIndex int
id string
callID string
name string
started bool
pending string
args strings.Builder
}
// NewStreamConverter builds a converter writing to w. model is echoed
// in every event's response object; inputTokens seeds the usage shown
// when the upstream stream carries none.
func NewStreamConverter(w http.ResponseWriter, model string, inputTokens int) *StreamConverter {
return &StreamConverter{
w: w,
flusher: flusherOf(w),
model: model,
respID: "resp_" + randHex(16),
tools: map[int]*toolItem{},
inputEstimate: inputTokens,
}
}
// WriteStart sends the SSE headers and the opening response events.
func (s *StreamConverter) WriteStart() error {
h := s.w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
s.w.WriteHeader(http.StatusOK)
s.started = true
initial := map[string]any{
"id": s.respID,
"object": "response",
"created_at": time.Now().Unix(),
"status": "in_progress",
"model": s.model,
"output": []any{},
}
if err := s.emit("response.created", map[string]any{"response": initial}); err != nil {
return err
}
return s.emit("response.in_progress", map[string]any{"response": initial})
}
// OnChunk folds one upstream chunk into the Responses event stream.
func (s *StreamConverter) OnChunk(chunk *chatcmpl.StreamChunk) error {
if chunk == nil {
return nil
}
if chunk.Usage != nil {
u := *chunk.Usage
s.usage = &u
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != nil && *choice.Delta.Content != "" {
if err := s.writeText(*choice.Delta.Content); err != nil {
return err
}
}
for _, tc := range choice.Delta.ToolCalls {
if err := s.toolDelta(tc); err != nil {
return err
}
}
if choice.FinishReason != nil && *choice.FinishReason != "" {
s.finishReason = *choice.FinishReason
}
}
return nil
}
func (s *StreamConverter) writeText(text string) error {
if !s.msgOpen {
s.msgIndex = s.nextOut
s.nextOut++
s.msgOpen = true
s.msgID = "msg_" + randHex(12)
if err := s.emit("response.output_item.added", map[string]any{
"output_index": s.msgIndex,
"item": map[string]any{
"id": s.msgID,
"type": "message",
"role": "assistant",
"status": "in_progress",
"content": []any{},
},
}); err != nil {
return err
}
if err := s.emit("response.content_part.added", map[string]any{
"item_id": s.msgID,
"output_index": s.msgIndex,
"content_index": 0,
"part": map[string]any{"type": "output_text", "text": ""},
}); err != nil {
return err
}
}
s.msgText.WriteString(text)
s.outputChars += len(text)
return s.emit("response.output_text.delta", map[string]any{
"item_id": s.msgID,
"output_index": s.msgIndex,
"content_index": 0,
"delta": text,
})
}
func (s *StreamConverter) toolDelta(tc chatcmpl.ToolCall) error {
st := s.tools[tc.Index]
if st == nil {
st = &toolItem{}
s.tools[tc.Index] = st
s.toolOrder = append(s.toolOrder, tc.Index)
}
if tc.ID != "" && st.callID == "" {
st.callID = tc.ID
}
if tc.Function.Name != "" && st.name == "" {
st.name = tc.Function.Name
}
if tc.Function.Arguments != "" {
st.pending += tc.Function.Arguments
}
// Open the item once we know a name or have arguments to flush, so
// the client never sees an unnamed function_call.
if !st.started && (st.name != "" || st.pending != "") {
st.outputIndex = s.nextOut
s.nextOut++
st.started = true
st.id = "fc_" + randHex(12)
if st.callID == "" {
st.callID = fmt.Sprintf("call_%s_%d", s.respID[len("resp_"):], tc.Index)
}
if err := s.emit("response.output_item.added", map[string]any{
"output_index": st.outputIndex,
"item": map[string]any{
"id": st.id,
"type": "function_call",
"status": "in_progress",
"call_id": st.callID,
"name": st.name,
"arguments": "",
},
}); err != nil {
return err
}
s.outputChars += len(st.name)
}
if st.started && st.pending != "" {
s.outputChars += len(st.pending)
st.args.WriteString(st.pending)
pending := st.pending
st.pending = ""
return s.emit("response.function_call_arguments.delta", map[string]any{
"item_id": st.id,
"output_index": st.outputIndex,
"delta": pending,
})
}
return nil
}
// Finish closes every open item and ends the response.
func (s *StreamConverter) Finish() error {
if s.msgOpen {
text := s.msgText.String()
if err := s.emit("response.output_text.done", map[string]any{
"item_id": s.msgID,
"output_index": s.msgIndex,
"content_index": 0,
"text": text,
}); err != nil {
return err
}
if err := s.emit("response.content_part.done", map[string]any{
"item_id": s.msgID,
"output_index": s.msgIndex,
"content_index": 0,
"part": map[string]any{"type": "output_text", "text": text},
}); err != nil {
return err
}
if err := s.emit("response.output_item.done", map[string]any{
"output_index": s.msgIndex,
"item": map[string]any{
"id": s.msgID,
"type": "message",
"role": "assistant",
"status": "completed",
"content": []any{map[string]any{"type": "output_text", "text": text}},
},
}); err != nil {
return err
}
s.msgOpen = false
}
for _, idx := range s.toolOrder {
st := s.tools[idx]
if st == nil || !st.started {
continue
}
if err := s.emit("response.function_call_arguments.done", map[string]any{
"item_id": st.id,
"output_index": st.outputIndex,
"arguments": st.args.String(),
}); err != nil {
return err
}
if err := s.emit("response.output_item.done", map[string]any{
"output_index": st.outputIndex,
"item": map[string]any{
"id": st.id,
"type": "function_call",
"status": "completed",
"call_id": st.callID,
"name": st.name,
"arguments": st.args.String(),
},
}); err != nil {
return err
}
}
return s.emit("response.completed", map[string]any{"response": s.finalResponse()})
}
// WriteError terminates the stream with a response.failed event.
func (s *StreamConverter) WriteError(code, message string) {
_ = s.emit("response.failed", map[string]any{
"response": map[string]any{
"id": s.respID,
"object": "response",
"created_at": time.Now().Unix(),
"status": "failed",
"model": s.model,
"output": []any{},
"error": map[string]any{"code": code, "message": message},
},
})
}
func (s *StreamConverter) finalResponse() map[string]any {
status, incomplete := MapStatus(s.finishReason)
output := []any{}
text := s.msgText.String()
if text != "" {
output = append(output, map[string]any{
"id": s.msgID,
"type": "message",
"role": "assistant",
"status": "completed",
"content": []any{map[string]any{"type": "output_text", "text": text}},
})
}
for _, idx := range s.toolOrder {
st := s.tools[idx]
if st == nil || !st.started {
continue
}
output = append(output, map[string]any{
"id": st.id,
"type": "function_call",
"status": "completed",
"call_id": st.callID,
"name": st.name,
"arguments": st.args.String(),
})
}
resp := map[string]any{
"id": s.respID,
"object": "response",
"created_at": time.Now().Unix(),
"status": status,
"model": s.model,
"output": output,
}
if text != "" {
resp["output_text"] = text
}
if incomplete != nil {
resp["incomplete_details"] = incomplete
}
resp["usage"] = s.usageMap()
return resp
}
func (s *StreamConverter) usageMap() map[string]any {
if s.usage != nil {
total := s.usage.TotalTokens
if total == 0 {
total = s.usage.PromptTokens + s.usage.CompletionTokens
}
return map[string]any{
"input_tokens": s.usage.PromptTokens,
"output_tokens": s.usage.CompletionTokens,
"total_tokens": total,
}
}
out := (s.outputChars + 3) / 4
return map[string]any{
"input_tokens": s.inputEstimate,
"output_tokens": out,
"total_tokens": s.inputEstimate + out,
}
}
func (s *StreamConverter) emit(event string, payload map[string]any) error {
s.seq++
payload["type"] = event
payload["sequence_number"] = s.seq
return writeEvent(s.w, s.flusher, event, payload)
}
func writeEvent(w http.ResponseWriter, flusher http.Flusher, event string, payload any) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
var sb strings.Builder
sb.WriteString("event: ")
sb.WriteString(event)
sb.WriteString("\ndata: ")
sb.Write(data)
sb.WriteString("\n\n")
if _, err := io.WriteString(w, sb.String()); err != nil {
return err
}
if flusher != nil {
flusher.Flush()
}
return nil
}
func flusherOf(w http.ResponseWriter) http.Flusher {
if f, ok := w.(http.Flusher); ok {
return f
}
return nil
}
+344
View File
@@ -0,0 +1,344 @@
package responses
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"testing"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
// captureWriter records everything the converter writes.
type captureWriter struct {
header http.Header
body bytes.Buffer
status int
flushes int
}
func newCaptureWriter() *captureWriter {
return &captureWriter{header: http.Header{}}
}
func (c *captureWriter) Header() http.Header { return c.header }
func (c *captureWriter) Write(b []byte) (int, error) { return c.body.Write(b) }
func (c *captureWriter) WriteHeader(status int) { c.status = status }
func (c *captureWriter) Flush() { c.flushes++ }
type sseEvent struct {
name string
data map[string]any
}
func parseSSE(t *testing.T, data string) []sseEvent {
t.Helper()
var events []sseEvent
for _, block := range strings.Split(strings.TrimSpace(data), "\n\n") {
if block == "" {
continue
}
var name string
var payload string
for _, line := range strings.Split(block, "\n") {
switch {
case strings.HasPrefix(line, "event: "):
name = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
payload = strings.TrimPrefix(line, "data: ")
}
}
var parsed map[string]any
if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
t.Fatalf("event %s payload is not JSON: %v (%s)", name, err, payload)
}
events = append(events, sseEvent{name: name, data: parsed})
}
return events
}
func eventNames(events []sseEvent) []string {
names := make([]string, len(events))
for i, e := range events {
names[i] = e.name
}
return names
}
func textChunk(text string) *chatcmpl.StreamChunk {
return &chatcmpl.StreamChunk{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{Content: &text}}}}
}
func finishChunk(reason string, usage *chatcmpl.Usage) *chatcmpl.StreamChunk {
return &chatcmpl.StreamChunk{
Choices: []chatcmpl.ChunkChoice{{FinishReason: &reason}},
Usage: usage,
}
}
func runConverter(t *testing.T, chunks []*chatcmpl.StreamChunk) []sseEvent {
t.Helper()
w := newCaptureWriter()
conv := NewStreamConverter(w, "gpt-5-codex", 42)
if err := conv.WriteStart(); err != nil {
t.Fatalf("WriteStart: %v", err)
}
for _, c := range chunks {
if err := conv.OnChunk(c); err != nil {
t.Fatalf("OnChunk: %v", err)
}
}
if err := conv.Finish(); err != nil {
t.Fatalf("Finish: %v", err)
}
if w.status != http.StatusOK {
t.Errorf("status = %d", w.status)
}
if ct := w.header.Get("Content-Type"); ct != "text/event-stream; charset=utf-8" {
t.Errorf("content type = %q", ct)
}
return parseSSE(t, w.body.String())
}
func TestStreamTextOnly(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
textChunk("Hello"),
textChunk(", world"),
finishChunk("stop", &chatcmpl.Usage{PromptTokens: 10, CompletionTokens: 3}),
})
wantNames := []string{
"response.created",
"response.in_progress",
"response.output_item.added",
"response.content_part.added",
"response.output_text.delta",
"response.output_text.delta",
"response.output_text.done",
"response.content_part.done",
"response.output_item.done",
"response.completed",
}
if got := eventNames(events); !equalStrings(got, wantNames) {
t.Fatalf("events = %v, want %v", got, wantNames)
}
for i, e := range events {
if seq, ok := e.data["sequence_number"].(float64); !ok || int(seq) != i+1 {
t.Errorf("event %d (%s) sequence_number = %v, want %d", i, e.name, e.data["sequence_number"], i+1)
}
}
created := events[0].data["response"].(map[string]any)
if created["status"] != "in_progress" {
t.Errorf("created status = %v", created["status"])
}
if created["model"] != "gpt-5-codex" {
t.Errorf("created model = %v", created["model"])
}
added := events[2].data["item"].(map[string]any)
if added["type"] != "message" || added["role"] != "assistant" {
t.Errorf("added item = %v", added)
}
if events[4].data["delta"] != "Hello" || events[5].data["delta"] != ", world" {
t.Errorf("deltas = %v, %v", events[4].data["delta"], events[5].data["delta"])
}
completed := events[9].data["response"].(map[string]any)
if completed["status"] != "completed" {
t.Errorf("completed status = %v", completed["status"])
}
if completed["output_text"] != "Hello, world" {
t.Errorf("completed output_text = %v", completed["output_text"])
}
usage := completed["usage"].(map[string]any)
if usage["input_tokens"].(float64) != 10 || usage["output_tokens"].(float64) != 3 {
t.Errorf("completed usage = %v", usage)
}
output := completed["output"].([]any)
if len(output) != 1 {
t.Fatalf("completed output = %v", output)
}
msg := output[0].(map[string]any)
content := msg["content"].([]any)
part := content[0].(map[string]any)
if part["type"] != "output_text" || part["text"] != "Hello, world" {
t.Errorf("completed message content = %v", part)
}
}
func TestStreamToolCall(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0, ID: "call_1", Type: "function",
Function: chatcmpl.ToolCallFunction{Name: "read_file"},
}}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0, Function: chatcmpl.ToolCallFunction{Arguments: `{"path":`},
}}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0, Function: chatcmpl.ToolCallFunction{Arguments: `"a.txt"}`},
}}}}}},
finishChunk("tool_calls", &chatcmpl.Usage{PromptTokens: 7, CompletionTokens: 5}),
})
wantNames := []string{
"response.created",
"response.in_progress",
"response.output_item.added",
"response.function_call_arguments.delta",
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
"response.output_item.done",
"response.completed",
}
if got := eventNames(events); !equalStrings(got, wantNames) {
t.Fatalf("events = %v, want %v", got, wantNames)
}
added := events[2].data["item"].(map[string]any)
if added["type"] != "function_call" || added["call_id"] != "call_1" || added["name"] != "read_file" {
t.Errorf("added item = %v", added)
}
if events[3].data["delta"] != `{"path":` || events[4].data["delta"] != `"a.txt"}` {
t.Errorf("argument deltas = %v, %v", events[3].data["delta"], events[4].data["delta"])
}
if events[5].data["arguments"] != `{"path":"a.txt"}` {
t.Errorf("arguments done = %v", events[5].data["arguments"])
}
for _, e := range events[2:7] {
if e.data["output_index"].(float64) != 0 {
t.Errorf("%s output_index = %v, want 0", e.name, e.data["output_index"])
}
}
completed := events[7].data["response"].(map[string]any)
output := completed["output"].([]any)
call := output[0].(map[string]any)
if call["call_id"] != "call_1" || call["arguments"] != `{"path":"a.txt"}` {
t.Errorf("completed function_call = %v", call)
}
}
func TestStreamParallelToolCalls(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0, ID: "call_a", Type: "function",
Function: chatcmpl.ToolCallFunction{Name: "tool_a"},
}}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 1, ID: "call_b", Type: "function",
Function: chatcmpl.ToolCallFunction{Name: "tool_b", Arguments: `{}`},
}}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0, Function: chatcmpl.ToolCallFunction{Arguments: `{"x":1}`},
}}}}}},
finishChunk("tool_calls", nil),
})
names := map[float64]string{}
args := map[float64]string{}
for _, e := range events {
if e.name == "response.output_item.added" {
idx := e.data["output_index"].(float64)
names[idx] = e.data["item"].(map[string]any)["name"].(string)
}
if e.name == "response.function_call_arguments.delta" {
idx := e.data["output_index"].(float64)
args[idx] += e.data["delta"].(string)
}
}
if len(names) != 2 {
t.Fatalf("output_item.added count = %d, want 2 (%v)", len(names), names)
}
if names[0] != "tool_a" || names[1] != "tool_b" {
t.Errorf("tool names by output index = %v", names)
}
if args[0] != `{"x":1}` || args[1] != `{}` {
t.Errorf("interleaved args = %v", args)
}
}
func TestStreamSynthesizesCallID(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0, Type: "function",
Function: chatcmpl.ToolCallFunction{Name: "shell"},
}}}}}},
finishChunk("tool_calls", nil),
})
added := events[2].data["item"].(map[string]any)
callID, _ := added["call_id"].(string)
if !strings.HasPrefix(callID, "call_") {
t.Errorf("synthesized call_id = %q, want a call_ prefix", callID)
}
}
func TestStreamLengthFinishIsIncomplete(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
textChunk("cut"),
finishChunk("length", nil),
})
completed := events[len(events)-1]
if completed.name != "response.completed" {
t.Fatalf("last event = %s", completed.name)
}
resp := completed.data["response"].(map[string]any)
if resp["status"] != "incomplete" {
t.Errorf("status = %v, want incomplete", resp["status"])
}
details, ok := resp["incomplete_details"].(map[string]any)
if !ok || details["reason"] != "max_output_tokens" {
t.Errorf("incomplete_details = %v", resp["incomplete_details"])
}
}
func TestStreamEstimatedUsageWithoutUpstreamUsage(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
textChunk("some text"),
finishChunk("stop", nil),
})
completed := events[len(events)-1].data["response"].(map[string]any)
usage := completed["usage"].(map[string]any)
if usage["input_tokens"].(float64) != 42 {
t.Errorf("input tokens = %v, want the 42 estimate", usage["input_tokens"])
}
if usage["output_tokens"].(float64) <= 0 {
t.Errorf("output tokens = %v, want a positive estimate", usage["output_tokens"])
}
}
func TestStreamEmptyUpstream(t *testing.T) {
events := runConverter(t, nil)
want := []string{"response.created", "response.in_progress", "response.completed"}
if got := eventNames(events); !equalStrings(got, want) {
t.Fatalf("events = %v, want %v", got, want)
}
}
func TestStreamWriteError(t *testing.T) {
w := newCaptureWriter()
conv := NewStreamConverter(w, "gpt-5-codex", 1)
if err := conv.WriteStart(); err != nil {
t.Fatalf("WriteStart: %v", err)
}
conv.WriteError("api_error", "upstream exploded")
events := parseSSE(t, w.body.String())
last := events[len(events)-1]
if last.name != "response.failed" {
t.Fatalf("last event = %s, want response.failed", last.name)
}
resp := last.data["response"].(map[string]any)
if resp["status"] != "failed" {
t.Errorf("status = %v", resp["status"])
}
errObj := resp["error"].(map[string]any)
if errObj["code"] != "api_error" || errObj["message"] != "upstream exploded" {
t.Errorf("error = %v", errObj)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+177
View File
@@ -0,0 +1,177 @@
// Package responses describes the OpenAI Responses API that Codex
// speaks and converts it to and from Chat Completions.
package responses
import (
"bytes"
"encoding/json"
"strings"
)
// Request is the Responses API request body. Only the fields CrossRelay
// understands are modelled; everything else is ignored.
type Request struct {
Model string `json:"model"`
Instructions string `json:"instructions,omitempty"`
Input json.RawMessage `json:"input"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
}
// Tool is one tool definition. Type is "function" for regular tools;
// "custom" and "local_shell" are freeform forms Codex may send.
type Tool struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
// Item is one entry of the input array. Only the fields belonging to
// Type are populated.
type Item struct {
Type string `json:"type"` // message | function_call | function_call_output | reasoning
Role string `json:"role,omitempty"`
// message
Content json.RawMessage `json:"content,omitempty"` // string or []ContentPart
// function_call
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
// function_call_output
Output json.RawMessage `json:"output,omitempty"`
// reasoning
Summary json.RawMessage `json:"summary,omitempty"`
}
// ContentPart is one part of a message item's content.
type ContentPart struct {
Type string `json:"type"` // input_text | output_text | input_image
Text string `json:"text,omitempty"`
// input_image
ImageURL string `json:"image_url,omitempty"`
}
// Response is the Responses API response body.
type Response struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int64 `json:"created_at"`
Status string `json:"status"`
Model string `json:"model"`
Output []OutItem `json:"output"`
OutputText string `json:"output_text,omitempty"`
Usage *Usage `json:"usage,omitempty"`
IncompleteDetails *Incomplete `json:"incomplete_details,omitempty"`
Error *RespError `json:"error,omitempty"`
}
// OutItem is one output entry: an assistant message or a function call.
type OutItem struct {
ID string `json:"id,omitempty"`
Type string `json:"type"` // message | function_call
Role string `json:"role,omitempty"`
Status string `json:"status,omitempty"`
Content []ContentPart `json:"content,omitempty"`
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
// Usage reports token counts.
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
}
// IncompleteDetails explains a non-completed response.
type Incomplete struct {
Reason string `json:"reason"`
}
// RespError is the error payload of a failed response.
type RespError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// ErrorResponse is the Responses API error envelope.
type ErrorResponse struct {
Error Detail `json:"error"`
}
// Detail is the error payload.
type Detail struct {
Type string `json:"type"`
Message string `json:"message"`
}
// ParseInput decodes the input field, which is either a plain string
// or an array of items.
func ParseInput(raw json.RawMessage) ([]Item, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || string(trimmed) == "null" {
return nil, nil
}
if trimmed[0] == '"' {
var s string
if err := json.Unmarshal(trimmed, &s); err != nil {
return nil, err
}
if s == "" {
return nil, nil
}
return []Item{{Type: "message", Role: "user", Content: trimmed}}, nil
}
var items []Item
if err := json.Unmarshal(trimmed, &items); err != nil {
return nil, err
}
return items, nil
}
// ParseContent decodes a message item's content field into parts. A
// plain string becomes a single text part.
func ParseContent(raw json.RawMessage) ([]ContentPart, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || string(trimmed) == "null" {
return nil, nil
}
if trimmed[0] == '"' {
var s string
if err := json.Unmarshal(trimmed, &s); err != nil {
return nil, err
}
if s == "" {
return nil, nil
}
return []ContentPart{{Type: "input_text", Text: s}}, nil
}
var parts []ContentPart
if err := json.Unmarshal(trimmed, &parts); err != nil {
return nil, err
}
return parts, nil
}
// OutputText flattens a function_call_output output field, which is
// either a plain string or an array of text parts.
func OutputText(raw json.RawMessage) string {
parts, err := ParseContent(raw)
if err != nil {
return ""
}
var sb strings.Builder
for _, p := range parts {
switch p.Type {
case "output_text", "input_text", "text":
sb.WriteString(p.Text)
}
}
return sb.String()
}