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)
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user