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

+16 -3
View File
@@ -136,15 +136,28 @@ func (m *Manager) Restore() (string, error) {
if len(backups) == 0 {
return "", fmt.Errorf("no backup found in %s", m.backupDir)
}
newest := backups[0]
data, err := os.ReadFile(filepath.Join(m.backupDir, newest.Name))
return m.restoreFromLocked(backups[0].Name)
}
// RestoreFrom copies the named backup over settings.json.
func (m *Manager) RestoreFrom(name string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if !strings.HasSuffix(name, ".json") || strings.ContainsAny(name, `/\`) {
return "", fmt.Errorf("invalid backup name %q", name)
}
return m.restoreFromLocked(name)
}
func (m *Manager) restoreFromLocked(name string) (string, error) {
data, err := os.ReadFile(filepath.Join(m.backupDir, name))
if err != nil {
return "", err
}
if err := m.writeFile(data); err != nil {
return "", err
}
return newest.Name, nil
return name, nil
}
// BackupInfo describes one backup file.
+442
View File
@@ -0,0 +1,442 @@
// Package codexcfg reads and writes Codex's config.toml. CrossRelay
// only touches the keys it manages; every other line (tables,
// comments, spacing, line endings) round-trips untouched.
package codexcfg
import (
"bytes"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// ProviderID is the model_providers entry CrossRelay manages.
const ProviderID = "crossrelay"
// TableName is the full TOML table path of the managed provider.
const TableName = "model_providers." + ProviderID
// DefaultPath returns the location of Codex's config.toml. CODEX_HOME
// overrides the default location (~/.codex).
func DefaultPath() (string, error) {
if v := strings.TrimSpace(os.Getenv("CODEX_HOME")); v != "" {
return filepath.Join(v, "config.toml"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("locate home directory: %w", err)
}
return filepath.Join(home, ".codex", "config.toml"), nil
}
// Manager performs guarded edits on one config.toml file.
type Manager struct {
path string
backupDir string
mu sync.Mutex
}
// New builds a manager for configPath, storing backups in backupDir.
func New(configPath, backupDir string) *Manager {
return &Manager{path: configPath, backupDir: backupDir}
}
// ApplyOptions describes what gets injected into config.toml.
type ApplyOptions struct {
BaseURL string
Token string
}
// Injection is the managed state written into config.toml. It doubles
// as the preview returned to the UI after an apply.
type Injection struct {
ModelProvider string `json:"model_provider"`
Table map[string]string `json:"table"`
// PriorProvider is the model_provider value found before the
// apply, empty when the file carried none.
PriorProvider string `json:"prior_provider,omitempty"`
}
// Apply injects the relay configuration, backing up the current file
// first when the content changes. It returns the resulting injection.
func (m *Manager) Apply(opts ApplyOptions) (*Injection, error) {
m.mu.Lock()
defer m.mu.Unlock()
original, err := m.readRaw()
if err != nil {
return nil, err
}
lines := splitLines(original)
prior := readTopLevelKey(lines, "model_provider")
lines = setTopLevelKey(lines, "model_provider", ProviderID)
lines = setTable(lines, TableName, managedTableLines(opts))
updated := joinLines(lines)
if original == nil || !bytes.Equal(original, updated) {
if len(original) > 0 {
if err := m.backupLocked(original); err != nil {
return nil, err
}
}
if err := m.writeFile(updated); err != nil {
return nil, err
}
}
return &Injection{
ModelProvider: ProviderID,
Table: map[string]string{
"name": ProviderID,
"wire_api": "responses",
"base_url": opts.BaseURL,
"experimental_bearer_token": opts.Token,
"requires_openai_auth": "false",
},
PriorProvider: prior,
}, nil
}
// RemoveManaged deletes the keys CrossRelay manages. priorProvider is
// written back into model_provider when non-empty; otherwise the key
// is dropped, and only while it still carries the managed value.
func (m *Manager) RemoveManaged(priorProvider string) error {
m.mu.Lock()
defer m.mu.Unlock()
original, err := m.readRaw()
if err != nil {
return err
}
if len(original) == 0 {
return nil
}
lines := splitLines(original)
lines = removeTable(lines, TableName)
if prior := strings.TrimSpace(priorProvider); prior != "" {
lines = setTopLevelKey(lines, "model_provider", prior)
} else {
lines = removeTopLevelKey(lines, "model_provider", ProviderID)
}
updated := joinLines(lines)
if bytes.Equal(original, updated) {
return nil
}
return m.writeFile(updated)
}
// RestoreBackup copies a backup file over config.toml and returns the
// backup name.
func (m *Manager) RestoreBackup(name string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if !strings.HasPrefix(name, "config-") || !strings.HasSuffix(name, ".toml") ||
strings.ContainsAny(name, `/\`) {
return "", fmt.Errorf("invalid backup name %q", name)
}
data, err := os.ReadFile(filepath.Join(m.backupDir, name))
if err != nil {
return "", err
}
if err := m.writeFile(data); err != nil {
return "", err
}
return name, nil
}
// BackupInfo describes one backup file.
type BackupInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// Backups lists backup files, newest first.
func (m *Manager) Backups() ([]BackupInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.listBackupsLocked()
}
func managedTableLines(opts ApplyOptions) []string {
return []string{
fmt.Sprintf("name = %q", ProviderID),
`wire_api = "responses"`,
fmt.Sprintf("base_url = %q", opts.BaseURL),
fmt.Sprintf("experimental_bearer_token = %q", opts.Token),
"requires_openai_auth = false",
}
}
func (m *Manager) readRaw() ([]byte, error) {
data, err := os.ReadFile(m.path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return data, nil
}
func (m *Manager) writeFile(data []byte) error {
if err := os.MkdirAll(filepath.Dir(m.path), 0o700); err != nil {
return err
}
tmp := m.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, m.path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func (m *Manager) backupLocked(data []byte) error {
if err := os.MkdirAll(m.backupDir, 0o700); err != nil {
return err
}
name := fmt.Sprintf("config-%s.toml", time.Now().Format("20060102-150405"))
if err := os.WriteFile(filepath.Join(m.backupDir, name), data, 0o600); err != nil {
return err
}
m.pruneLocked(20)
return nil
}
func (m *Manager) listBackupsLocked() ([]BackupInfo, error) {
entries, err := os.ReadDir(m.backupDir)
if err != nil {
if os.IsNotExist(err) {
return []BackupInfo{}, nil
}
return nil, err
}
out := []BackupInfo{}
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "config-") || !strings.HasSuffix(e.Name(), ".toml") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, BackupInfo{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Modified.After(out[j].Modified) })
return out, nil
}
func (m *Manager) pruneLocked(keep int) {
backups, err := m.listBackupsLocked()
if err != nil || len(backups) <= keep {
return
}
for _, b := range backups[keep:] {
_ = os.Remove(filepath.Join(m.backupDir, b.Name))
}
}
// splitLines splits file content on "\n". A trailing newline shows up
// as a final empty element, so joining reproduces the file exactly,
// CRLF endings included.
func splitLines(data []byte) []string {
if len(data) == 0 {
return nil
}
return strings.Split(string(data), "\n")
}
func joinLines(lines []string) []byte {
out := strings.Join(lines, "\n")
if len(lines) > 0 && lines[len(lines)-1] != "" {
out += "\n"
}
return []byte(out)
}
func isHeaderLine(line string) bool {
return strings.HasPrefix(strings.TrimSpace(line), "[")
}
func parseKeyValue(line string) (key, value string, ok bool) {
s := strings.TrimSpace(line)
if s == "" || strings.HasPrefix(s, "#") || strings.HasPrefix(s, "[") {
return "", "", false
}
eq := strings.Index(s, "=")
if eq < 0 {
return "", "", false
}
key = strings.Trim(strings.TrimSpace(s[:eq]), `"'`)
if key == "" {
return "", "", false
}
return key, unquoteTOML(strings.TrimSpace(s[eq+1:])), true
}
func unquoteTOML(v string) string {
if len(v) >= 2 && v[0] == '"' {
if end := strings.Index(v[1:], `"`); end >= 0 {
if uq, err := strconv.Unquote(v[:end+2]); err == nil {
return uq
}
return v[1 : end+1]
}
}
if len(v) >= 2 && v[0] == '\'' {
if end := strings.Index(v[1:], "'"); end >= 0 {
return v[1 : end+1]
}
}
if i := strings.Index(v, "#"); i >= 0 {
v = strings.TrimSpace(v[:i])
}
return v
}
// findTopLevelKey returns the index of a key assignment in the
// top-level section (before the first table header).
func findTopLevelKey(lines []string, key string) int {
for i, line := range lines {
if isHeaderLine(line) {
return -1
}
if k, _, ok := parseKeyValue(line); ok && k == key {
return i
}
}
return -1
}
func readTopLevelKey(lines []string, key string) string {
if i := findTopLevelKey(lines, key); i >= 0 {
if _, v, ok := parseKeyValue(lines[i]); ok {
return v
}
}
return ""
}
// setTopLevelKey replaces an existing top-level key or inserts one at
// the end of the top-level section.
func setTopLevelKey(lines []string, key, value string) []string {
line := fmt.Sprintf("%s = %q", key, value)
if i := findTopLevelKey(lines, key); i >= 0 {
out := append([]string{}, lines...)
out[i] = line
return out
}
return insertTopLevelLine(lines, line)
}
func insertTopLevelLine(lines []string, line string) []string {
idx := len(lines)
for i, l := range lines {
if isHeaderLine(l) {
idx = i
break
}
}
// A trailing empty element represents the file's final newline; new
// lines go before it.
if idx == len(lines) && idx > 0 && lines[idx-1] == "" {
idx--
}
out := make([]string, 0, len(lines)+1)
out = append(out, lines[:idx]...)
out = append(out, line)
out = append(out, lines[idx:]...)
return out
}
// removeTopLevelKey drops a top-level key line, but only while it
// still carries the given value.
func removeTopLevelKey(lines []string, key, value string) []string {
i := findTopLevelKey(lines, key)
if i < 0 {
return lines
}
_, v, ok := parseKeyValue(lines[i])
if !ok || v != value {
return lines
}
out := make([]string, 0, len(lines))
out = append(out, lines[:i]...)
out = append(out, lines[i+1:]...)
return out
}
// setTable replaces the body of an existing table or appends a new
// table at the end of the file.
func setTable(lines []string, name string, body []string) []string {
header := "[" + name + "]"
if i := findTable(lines, name); i >= 0 {
end := tableEnd(lines, i)
var sep []string
if end < len(lines) && isHeaderLine(lines[end]) && end > i+1 && strings.TrimSpace(lines[end-1]) == "" {
sep = []string{""}
}
out := make([]string, 0, len(lines)+len(body)+1)
out = append(out, lines[:i+1]...)
out = append(out, body...)
out = append(out, sep...)
out = append(out, lines[end:]...)
return out
}
block := append([]string{header}, body...)
end := len(lines)
if end > 0 && lines[end-1] == "" {
end--
}
if end > 0 && strings.TrimSpace(lines[end-1]) != "" {
block = append([]string{""}, block...)
}
out := make([]string, 0, len(lines)+len(block)+1)
out = append(out, lines[:end]...)
out = append(out, block...)
out = append(out, lines[end:]...)
return out
}
// removeTable drops the named table plus one preceding blank
// separator so no double blank lines are left behind.
func removeTable(lines []string, name string) []string {
i := findTable(lines, name)
if i < 0 {
return lines
}
end := tableEnd(lines, i)
start := i
if start > 0 && strings.TrimSpace(lines[start-1]) == "" {
after := end >= len(lines) || strings.TrimSpace(lines[end]) == "" || isHeaderLine(lines[end])
if after {
start--
}
}
out := make([]string, 0, len(lines))
out = append(out, lines[:start]...)
out = append(out, lines[end:]...)
return out
}
func findTable(lines []string, name string) int {
want := "[" + name + "]"
for i, line := range lines {
if strings.TrimSpace(line) == want {
return i
}
}
return -1
}
func tableEnd(lines []string, headerIdx int) int {
end := headerIdx + 1
for end < len(lines) && !isHeaderLine(lines[end]) {
end++
}
return end
}
+258
View File
@@ -0,0 +1,258 @@
package codexcfg
import (
"os"
"path/filepath"
"strings"
"testing"
)
// richConfig mimics a real Codex config.toml: a custom provider, MCP
// servers and project trust entries that must survive untouched.
const richConfig = `# Codex settings
model_provider = "custom"
model_reasoning_effort = "high"
[model_providers.custom]
name = "custom"
wire_api = "responses"
base_url = "https://cpa.rutua.cn/v1"
[mcp_servers.docker]
command = "docker.exe"
args = ["mcp", "gateway", "run"]
[projects.'C:\Users\me\proj']
trust_level = "trusted"
`
func newMgr(t *testing.T) (*Manager, string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
return New(path, filepath.Join(dir, "backups")), path
}
func writeConfig(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
}
func readConfig(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
return string(data)
}
func TestApplyCreatesFileWhenMissing(t *testing.T) {
mgr, path := newMgr(t)
injection, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if injection.ModelProvider != ProviderID {
t.Errorf("model_provider = %q", injection.ModelProvider)
}
if injection.PriorProvider != "" {
t.Errorf("prior provider = %q, want empty", injection.PriorProvider)
}
got := readConfig(t, path)
for _, want := range []string{
`model_provider = "crossrelay"`,
"[model_providers.crossrelay]",
`name = "crossrelay"`,
`wire_api = "responses"`,
`base_url = "http://127.0.0.1:9000"`,
`experimental_bearer_token = "crl_x"`,
"requires_openai_auth = false",
} {
if !strings.Contains(got, want) {
t.Errorf("created config missing %q:\n%s", want, got)
}
}
}
func TestApplyPreservesEverythingElse(t *testing.T) {
mgr, path := newMgr(t)
writeConfig(t, path, richConfig)
injection, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if injection.PriorProvider != "custom" {
t.Errorf("prior provider = %q, want custom", injection.PriorProvider)
}
got := readConfig(t, path)
for _, want := range []string{
"# Codex settings",
`model_reasoning_effort = "high"`,
"[model_providers.custom]",
`base_url = "https://cpa.rutua.cn/v1"`,
"[mcp_servers.docker]",
`command = "docker.exe"`,
`args = ["mcp", "gateway", "run"]`,
`[projects.'C:\Users\me\proj']`,
`trust_level = "trusted"`,
} {
if !strings.Contains(got, want) {
t.Errorf("config lost %q after apply:\n%s", want, got)
}
}
if strings.Count(got, "model_reasoning_effort") != 1 {
t.Errorf("unrelated key duplicated:\n%s", got)
}
if strings.Count(got, "[model_providers.") != 2 {
t.Errorf("provider tables = %d, want 2:\n%s", strings.Count(got, "[model_providers."), got)
}
}
func TestApplyIsIdempotent(t *testing.T) {
mgr, path := newMgr(t)
writeConfig(t, path, richConfig)
opts := ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"}
if _, err := mgr.Apply(opts); err != nil {
t.Fatalf("first Apply: %v", err)
}
first := readConfig(t, path)
if _, err := mgr.Apply(opts); err != nil {
t.Fatalf("second Apply: %v", err)
}
if second := readConfig(t, path); second != first {
t.Errorf("second apply changed the file:\nfirst:\n%s\nsecond:\n%s", first, second)
}
backups, err := mgr.Backups()
if err != nil {
t.Fatalf("Backups: %v", err)
}
if len(backups) != 1 {
t.Errorf("backups = %d, want 1 (only the changing apply backs up)", len(backups))
}
}
func TestRemoveManagedRestoresPriorProvider(t *testing.T) {
mgr, path := newMgr(t)
writeConfig(t, path, richConfig)
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"}); err != nil {
t.Fatalf("Apply: %v", err)
}
if err := mgr.RemoveManaged("custom"); err != nil {
t.Fatalf("RemoveManaged: %v", err)
}
got := readConfig(t, path)
if !strings.Contains(got, `model_provider = "custom"`) {
t.Errorf("prior provider not restored:\n%s", got)
}
if strings.Contains(got, "crossrelay") {
t.Errorf("managed keys survived removal:\n%s", got)
}
for _, want := range []string{
`model_reasoning_effort = "high"`,
"[mcp_servers.docker]",
`trust_level = "trusted"`,
} {
if !strings.Contains(got, want) {
t.Errorf("config lost %q after removal:\n%s", want, got)
}
}
}
func TestRemoveManagedWithoutPriorDropsKey(t *testing.T) {
mgr, path := newMgr(t)
writeConfig(t, path, "model_provider = \"crossrelay\"\n\n[model_providers.crossrelay]\nname = \"crossrelay\"\n\n[other]\nx = 1\n")
if err := mgr.RemoveManaged(""); err != nil {
t.Fatalf("RemoveManaged: %v", err)
}
got := readConfig(t, path)
if strings.Contains(got, "model_provider") {
t.Errorf("managed key survived:\n%s", got)
}
if !strings.Contains(got, "[other]") || !strings.Contains(got, "x = 1") {
t.Errorf("unrelated table damaged:\n%s", got)
}
}
func TestRemoveManagedKeepsUserChangedProvider(t *testing.T) {
mgr, path := newMgr(t)
writeConfig(t, path, richConfig)
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"}); err != nil {
t.Fatalf("Apply: %v", err)
}
// The user switches provider by hand after the apply.
writeConfig(t, path, strings.Replace(readConfig(t, path), `model_provider = "crossrelay"`, `model_provider = "custom"`, 1))
if err := mgr.RemoveManaged(""); err != nil {
t.Fatalf("RemoveManaged: %v", err)
}
got := readConfig(t, path)
if !strings.Contains(got, `model_provider = "custom"`) {
t.Errorf("user's provider choice was deleted:\n%s", got)
}
if strings.Contains(got, "model_providers.crossrelay") {
t.Errorf("managed table survived:\n%s", got)
}
}
func TestBackupAndRestoreBackup(t *testing.T) {
mgr, path := newMgr(t)
writeConfig(t, path, richConfig)
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"}); err != nil {
t.Fatalf("Apply: %v", err)
}
backups, err := mgr.Backups()
if err != nil {
t.Fatalf("Backups: %v", err)
}
if len(backups) != 1 {
t.Fatalf("backups = %d, want 1", len(backups))
}
name, err := mgr.RestoreBackup(backups[0].Name)
if err != nil {
t.Fatalf("RestoreBackup: %v", err)
}
if name != backups[0].Name {
t.Errorf("restored name = %q", name)
}
if got := readConfig(t, path); got != richConfig {
t.Errorf("rollback content mismatch:\n%s", got)
}
}
func TestRestoreBackupRejectsForeignNames(t *testing.T) {
mgr, _ := newMgr(t)
if _, err := mgr.RestoreBackup("settings-20260101-000000.json"); err == nil {
t.Error("RestoreBackup accepted a non-config backup name")
}
if _, err := mgr.RestoreBackup("../config.toml"); err == nil {
t.Error("RestoreBackup accepted a path traversal name")
}
}
func TestCRLFPreserved(t *testing.T) {
mgr, path := newMgr(t)
crlf := strings.ReplaceAll(richConfig, "\n", "\r\n")
writeConfig(t, path, crlf)
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"}); err != nil {
t.Fatalf("Apply: %v", err)
}
got := readConfig(t, path)
for _, line := range []string{`command = "docker.exe"`, `trust_level = "trusted"`, `model_reasoning_effort = "high"`} {
if !strings.Contains(got, line+"\r\n") {
t.Errorf("line %q lost its CRLF ending:\n%q", line, got)
}
}
}
func TestDefaultPathHonoursCodexHome(t *testing.T) {
t.Setenv("CODEX_HOME", `C:\codex-home`)
path, err := DefaultPath()
if err != nil {
t.Fatalf("DefaultPath: %v", err)
}
if path != filepath.Join(`C:\codex-home`, "config.toml") {
t.Errorf("path = %q", path)
}
}
+8 -2
View File
@@ -48,6 +48,7 @@ type Provider struct {
APIKey string `json:"api_key"`
Models Models `json:"models"`
EnabledForClaude bool `json:"enabled_for_claude"`
EnabledForCodex bool `json:"enabled_for_codex"`
CreatedAt time.Time `json:"created_at"`
}
@@ -63,8 +64,13 @@ type Config struct {
RelayToken string `json:"relay_token"`
Providers []Provider `json:"providers"`
ActiveClaudeID string `json:"active_claude_id,omitempty"`
Gateway Gateway `json:"gateway"`
Language string `json:"language"`
ActiveCodexID string `json:"active_codex_id,omitempty"`
// CodexPriorProvider remembers the model_provider value that was in
// config.toml before CrossRelay first applied, so restore can put it
// back instead of dropping the key.
CodexPriorProvider string `json:"codex_prior_provider,omitempty"`
Gateway Gateway `json:"gateway"`
Language string `json:"language"`
}
// ResolveModel maps the model name Claude Code asked for onto the model
+96
View File
@@ -128,6 +128,102 @@ func TestDeleteProviderClearsActive(t *testing.T) {
}
}
func TestActivateCodexRoundTrip(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
Models{Default: "deepseek-chat"})
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if _, err := store.ActivateCodex(p.ID); err != nil {
t.Fatalf("ActivateCodex: %v", err)
}
if err := store.SetCodexPriorProvider("custom"); err != nil {
t.Fatalf("SetCodexPriorProvider: %v", err)
}
reloaded, err := Load()
if err != nil {
t.Fatalf("reload: %v", err)
}
active := reloaded.ActiveCodex()
if active == nil || active.Name != "DeepSeek" {
t.Fatalf("active codex provider after reload = %+v", active)
}
if !active.EnabledForCodex {
t.Error("enabled_for_codex not persisted")
}
if reloaded.Config().CodexPriorProvider != "custom" {
t.Errorf("prior provider = %q, want custom", reloaded.Config().CodexPriorProvider)
}
if err := reloaded.ClearActiveCodex(); err != nil {
t.Fatalf("ClearActiveCodex: %v", err)
}
if reloaded.ActiveCodex() != nil {
t.Error("active codex provider survived clear")
}
if _, err := reloaded.ActivateCodex("p_missing"); err == nil {
t.Error("ActivateCodex accepted an unknown id")
}
}
func TestUpdateProviderKeepsEnabledFlags(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
Models{Default: "deepseek-chat"})
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if _, err := store.ActivateClaude(p.ID); err != nil {
t.Fatalf("ActivateClaude: %v", err)
}
if _, err := store.ActivateCodex(p.ID); err != nil {
t.Fatalf("ActivateCodex: %v", err)
}
updated, err := store.UpdateProvider(p.ID, "DeepSeek 2", "deepseek", "https://api.deepseek.com/v1", "",
Models{Default: "deepseek-reasoner"})
if err != nil {
t.Fatalf("UpdateProvider: %v", err)
}
if !updated.EnabledForClaude || !updated.EnabledForCodex {
t.Errorf("enabled flags lost: claude=%v codex=%v", updated.EnabledForClaude, updated.EnabledForCodex)
}
if updated.APIKey != "sk-test" {
t.Errorf("api key = %q, want the stored key kept", updated.APIKey)
}
}
func TestDeleteProviderClearsCodexActive(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
Models{Default: "deepseek-chat"})
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if _, err := store.ActivateCodex(p.ID); err != nil {
t.Fatalf("ActivateCodex: %v", err)
}
if err := store.DeleteProvider(p.ID); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
if store.ActiveCodex() != nil {
t.Error("deleted provider stayed active for codex")
}
}
func TestSetGatewayValidation(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
+63
View File
@@ -88,6 +88,21 @@ func (s *Store) ActiveClaude() *Provider {
return nil
}
// ActiveCodex returns the provider currently applied to Codex.
func (s *Store) ActiveCodex() *Provider {
s.mu.Lock()
defer s.mu.Unlock()
if s.cfg.ActiveCodexID == "" {
return nil
}
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == s.cfg.ActiveCodexID {
return s.cfg.Providers[i].clone()
}
}
return nil
}
// AddProvider validates and appends a provider.
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
s.mu.Lock()
@@ -128,6 +143,7 @@ func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models
}
updated.ID = existing.ID
updated.EnabledForClaude = existing.EnabledForClaude
updated.EnabledForCodex = existing.EnabledForCodex
updated.CreatedAt = existing.CreatedAt
s.cfg.Providers[idx] = *updated
if err := s.saveLocked(); err != nil {
@@ -154,6 +170,9 @@ func (s *Store) DeleteProvider(id string) error {
if s.cfg.ActiveClaudeID == id {
s.cfg.ActiveClaudeID = ""
}
if s.cfg.ActiveCodexID == id {
s.cfg.ActiveCodexID = ""
}
return s.saveLocked()
}
@@ -192,6 +211,50 @@ func (s *Store) ClearActiveClaude() error {
return s.saveLocked()
}
// ActivateCodex marks a provider as the one written into Codex's
// config.toml and records it as active.
func (s *Store) ActivateCodex(id string) (*Provider, error) {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return nil, fmt.Errorf("provider %q not found", id)
}
s.cfg.Providers[idx].EnabledForCodex = true
s.cfg.ActiveCodexID = id
if err := s.saveLocked(); err != nil {
return nil, err
}
return s.cfg.Providers[idx].clone(), nil
}
// ClearActiveCodex forgets the active Codex provider without touching
// the provider list (used after restoring the official config).
func (s *Store) ClearActiveCodex() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cfg.ActiveCodexID == "" {
return nil
}
s.cfg.ActiveCodexID = ""
return s.saveLocked()
}
// SetCodexPriorProvider records (or clears) the model_provider value
// that config.toml carried before CrossRelay took it over.
func (s *Store) SetCodexPriorProvider(prior string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.cfg.CodexPriorProvider = strings.TrimSpace(prior)
return s.saveLocked()
}
// SetGateway updates listener settings.
func (s *Store) SetGateway(g Gateway) error {
g.PortMode = strings.TrimSpace(g.PortMode)
+257
View File
@@ -62,6 +62,7 @@ type testEnv struct {
upstream *fakeUpstream
store *config.Store
settings string
codex string
}
func newTestEnv(t *testing.T) *testEnv {
@@ -133,6 +134,262 @@ func messagesBody(model string, stream bool) map[string]any {
}
}
// newCodexTestEnv builds the standard environment with a realistic
// config.toml in place and the shared provider activated for Codex.
func newCodexTestEnv(t *testing.T) *testEnv {
t.Helper()
env := newTestEnv(t)
codexHome := t.TempDir()
t.Setenv("CODEX_HOME", codexHome)
env.codex = filepath.Join(codexHome, "config.toml")
if err := os.WriteFile(env.codex, []byte("model_provider = \"custom\"\n\n[model_providers.custom]\nname = \"custom\"\nwire_api = \"responses\"\nbase_url = \"https://old.example.com/v1\"\n"), 0o600); err != nil {
t.Fatalf("write config.toml: %v", err)
}
if _, err := env.store.ActivateCodex(env.store.Config().Providers[0].ID); err != nil {
t.Fatalf("ActivateCodex: %v", err)
}
return env
}
func (e *testEnv) get(t *testing.T, path string) map[string]any {
t.Helper()
resp, err := http.Get(e.gateway.URL + path)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return out
}
func responsesBody(model string, stream bool) map[string]any {
return map[string]any{
"model": model,
"instructions": "be brief",
"input": []any{map[string]any{
"type": "message", "role": "user",
"content": []any{map[string]any{"type": "input_text", "text": "hello"}},
}},
"stream": stream,
}
}
func TestResponsesNonStream(t *testing.T) {
env := newCodexTestEnv(t)
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if out["object"] != "response" || out["status"] != "completed" {
t.Errorf("envelope = %v", out)
}
if out["model"] != "gpt-5-codex" {
t.Errorf("model = %v, want the requested model echoed", out["model"])
}
if out["output_text"] != "Hi there" {
t.Errorf("output_text = %v", out["output_text"])
}
usage := out["usage"].(map[string]any)
if usage["input_tokens"].(float64) != 9 || usage["output_tokens"].(float64) != 2 {
t.Errorf("usage = %v", usage)
}
if env.upstream.lastModel != "ds-chat" {
t.Errorf("upstream model = %q, want the resolved model", env.upstream.lastModel)
}
if env.upstream.lastAuth != "Bearer up-key" {
t.Errorf("upstream auth = %q", env.upstream.lastAuth)
}
msgs := env.upstream.lastRequest["messages"].([]any)
system := msgs[0].(map[string]any)
if system["role"] != "system" || system["content"] != "be brief" {
t.Errorf("system message = %v", system)
}
user := msgs[1].(map[string]any)
if user["role"] != "user" || user["content"] != "hello" {
t.Errorf("user message = %v", user)
}
}
func TestResponsesStream(t *testing.T) {
env := newCodexTestEnv(t)
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", true), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Errorf("content type = %q", ct)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read stream: %v", err)
}
var names []string
var text strings.Builder
for _, block := range strings.Split(strings.TrimSpace(string(body)), "\n\n") {
if block == "" {
continue
}
for _, line := range strings.Split(block, "\n") {
switch {
case strings.HasPrefix(line, "event: "):
names = append(names, strings.TrimPrefix(line, "event: "))
case strings.HasPrefix(line, "data: "):
var payload map[string]any
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload); err == nil {
if payload["type"] == "response.output_text.delta" {
text.WriteString(payload["delta"].(string))
}
}
}
}
}
want := []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 len(names) != len(want) {
t.Fatalf("events = %v, want %v", names, want)
}
for i := range want {
if names[i] != want[i] {
t.Fatalf("events = %v, want %v", names, want)
}
}
if text.String() != "Hi there" {
t.Errorf("streamed text = %q", text.String())
}
}
func TestResponsesRejectsBadToken(t *testing.T) {
env := newCodexTestEnv(t)
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), "crl_wrong")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", resp.StatusCode)
}
}
func TestResponsesWithoutActiveProvider(t *testing.T) {
env := newTestEnv(t)
t.Setenv("CODEX_HOME", t.TempDir())
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
detail, ok := out["error"].(map[string]any)
if !ok || !strings.Contains(detail["message"].(string), "no active provider") {
t.Errorf("error = %v", out["error"])
}
}
func TestApplyAndRestoreCodex(t *testing.T) {
env := newCodexTestEnv(t)
id := env.store.Config().Providers[0].ID
resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("apply status = %d", resp.StatusCode)
}
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
injection, ok := out["injection"].(map[string]any)
if !ok || injection["model_provider"] != "crossrelay" {
t.Fatalf("injection = %v", out["injection"])
}
table := injection["table"].(map[string]any)
if table["wire_api"] != "responses" || table["base_url"] != "http://127.0.0.1:18787" {
t.Errorf("injected table = %v", table)
}
if table["experimental_bearer_token"] != env.store.Config().RelayToken {
t.Errorf("injected token = %v", table["experimental_bearer_token"])
}
data, err := os.ReadFile(env.codex)
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
got := string(data)
for _, want := range []string{
`model_provider = "crossrelay"`,
"[model_providers.crossrelay]",
"requires_openai_auth = false",
"[model_providers.custom]",
`base_url = "https://old.example.com/v1"`,
} {
if !strings.Contains(got, want) {
t.Errorf("config.toml missing %q:\n%s", want, got)
}
}
if env.store.Config().CodexPriorProvider != "custom" {
t.Errorf("prior provider = %q, want custom", env.store.Config().CodexPriorProvider)
}
if status := env.get(t, "/api/status"); status["active_codex_provider"] == nil {
t.Errorf("status lacks active_codex_provider: %v", status)
}
resp = env.post(t, "/api/restore", map[string]any{"target": "codex"}, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("restore status = %d", resp.StatusCode)
}
data, _ = os.ReadFile(env.codex)
got = string(data)
if !strings.Contains(got, `model_provider = "custom"`) {
t.Errorf("prior provider not restored:\n%s", got)
}
if strings.Contains(got, "crossrelay") {
t.Errorf("managed keys survived restore:\n%s", got)
}
if !strings.Contains(got, `base_url = "https://old.example.com/v1"`) {
t.Errorf("user's provider table damaged:\n%s", got)
}
if env.store.ActiveCodex() != nil {
t.Error("active codex provider survived restore")
}
if env.store.Config().CodexPriorProvider != "" {
t.Error("prior provider record survived restore")
}
}
func TestBackupsTargetDimension(t *testing.T) {
env := newCodexTestEnv(t)
id := env.store.Config().Providers[0].ID
resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "claude"}, "")
resp.Body.Close()
resp = env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "")
resp.Body.Close()
codexBackups := env.get(t, "/api/backups?target=codex")["items"].([]any)
if len(codexBackups) != 1 {
t.Fatalf("codex backups = %d, want 1", len(codexBackups))
}
if name := codexBackups[0].(map[string]any)["name"].(string); !strings.HasPrefix(name, "config-") {
t.Errorf("codex backup name = %q", name)
}
claudeBackups := env.get(t, "/api/backups")["items"].([]any)
if len(claudeBackups) != 1 {
t.Fatalf("claude backups = %d, want 1", len(claudeBackups))
}
if name := claudeBackups[0].(map[string]any)["name"].(string); !strings.HasPrefix(name, "settings-") {
t.Errorf("claude backup name = %q", name)
}
}
func TestMessagesNonStream(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
+162 -17
View File
@@ -6,6 +6,7 @@ import (
"time"
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
"gitea.rutua.cn/kate522/CrossRelay/internal/codexcfg"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
)
@@ -29,12 +30,22 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
"models": p.Models,
}
}
var activeCodex any
if p := s.store.ActiveCodex(); p != nil {
activeCodex = map[string]any{
"id": p.ID,
"name": p.Name,
"base_url": p.BaseURL,
"models": p.Models,
}
}
writeJSON(w, http.StatusOK, map[string]any{
"running": true,
"version": Version,
"port": s.port,
"uptime_sec": int(time.Since(s.started).Seconds()),
"active_claude_provider": active,
"active_codex_provider": activeCodex,
"stats": s.snapshotStats(),
"language": cfg.Language,
"port_mode": cfg.Gateway.PortMode,
@@ -84,10 +95,33 @@ func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// handleApply activates a provider and writes it into Claude Code's
// settings.json.
// applyInput selects which client configuration receives the provider.
type applyInput struct {
Target string `json:"target"`
}
// handleApply activates a provider and writes it into the target
// client's configuration. The body is optional; an absent or empty
// target means Claude Code, keeping older clients working.
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var in applyInput
if r.Body != nil {
_ = decodeJSON(r, &in, 1<<20) // an empty body leaves the zero value
}
switch in.Target {
case "", "claude":
s.applyClaude(w, id)
case "codex":
s.applyCodex(w, id)
default:
writeAPIError(w, http.StatusBadRequest, `target must be "claude" or "codex"`)
}
}
// applyClaude activates a provider and writes it into Claude Code's
// settings.json.
func (s *Server) applyClaude(w http.ResponseWriter, id string) {
p, err := s.store.ActivateClaude(id)
if err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
@@ -109,18 +143,84 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"provider": p,
"target": "claude",
"env": settings.Env,
})
}
// handleRestore puts back the most recent backup of settings.json.
// applyCodex activates a provider and writes it into Codex's
// config.toml.
func (s *Server) applyCodex(w http.ResponseWriter, id string) {
p, err := s.store.ActivateCodex(id)
if err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
mgr, err := s.codexManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
injection, err := mgr.Apply(codexcfg.ApplyOptions{
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", s.port),
Token: s.store.Config().RelayToken,
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
// Remember the provider the user had before the first apply so
// restore can put it back instead of dropping the key.
if injection.PriorProvider != "" && s.store.Config().CodexPriorProvider == "" {
if err := s.store.SetCodexPriorProvider(injection.PriorProvider); err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]any{
"provider": p,
"target": "codex",
"injection": injection,
})
}
// restoreInput selects the client and, optionally, one specific backup
// file to roll back to.
type restoreInput struct {
Target string `json:"target"`
Backup string `json:"backup,omitempty"`
}
// handleRestore puts a client's configuration back. Claude restores
// the newest backup (or the named one); Codex removes the managed
// keys, or rolls back to the named backup.
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
var in restoreInput
if r.Body != nil {
_ = decodeJSON(r, &in, 1<<20)
}
switch in.Target {
case "", "claude":
s.restoreClaude(w, in.Backup)
case "codex":
s.restoreCodex(w, in.Backup)
default:
writeAPIError(w, http.StatusBadRequest, `target must be "claude" or "codex"`)
}
}
func (s *Server) restoreClaude(w http.ResponseWriter, backup string) {
mgr, err := s.claudeManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
name, err := mgr.Restore()
var name string
if backup != "" {
name, err = mgr.RestoreFrom(backup)
} else {
name, err = mgr.Restore()
}
if err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
@@ -129,21 +229,66 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"restored_from": name})
}
func (s *Server) restoreCodex(w http.ResponseWriter, backup string) {
mgr, err := s.codexManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if backup != "" {
name, err := mgr.RestoreBackup(backup)
if err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
_ = s.store.ClearActiveCodex()
writeJSON(w, http.StatusOK, map[string]any{"restored_from": name})
return
}
if err := mgr.RemoveManaged(s.store.Config().CodexPriorProvider); err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
_ = s.store.ClearActiveCodex()
_ = s.store.SetCodexPriorProvider("")
writeJSON(w, http.StatusOK, map[string]any{"restored_from": ""})
}
func (s *Server) handleBackups(w http.ResponseWriter, r *http.Request) {
mgr, err := s.claudeManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
switch target := r.URL.Query().Get("target"); target {
case "", "claude":
mgr, err := s.claudeManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
items, err := mgr.Backups()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []claudecfg.BackupInfo{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
case "codex":
mgr, err := s.codexManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
items, err := mgr.Backups()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []codexcfg.BackupInfo{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
default:
writeAPIError(w, http.StatusBadRequest, `target must be "claude" or "codex"`)
}
items, err := mgr.Backups()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []claudecfg.BackupInfo{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
+118
View File
@@ -0,0 +1,118 @@
package gateway
import (
"errors"
"fmt"
"io"
"net/http"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
"gitea.rutua.cn/kate522/CrossRelay/internal/responses"
)
// handleResponses serves POST /v1/responses in both streaming and
// non-streaming modes.
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
var req responses.Request
if err := decodeJSON(r, &req, 64<<20); err != nil {
writeResponsesError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body: "+err.Error())
return
}
provider := s.store.ActiveCodex()
if provider == nil {
writeResponsesError(w, http.StatusBadRequest, "invalid_request_error",
"no active provider: activate one in the CrossRelay app")
return
}
model := provider.ResolveModel(req.Model)
chatReq, err := responses.ConvertRequest(&req, model)
if err != nil {
writeResponsesError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
rec := recordFrom(r)
rec.update(func(e *LogEntry) {
e.Model = model
e.InputTokens = responses.EstimateTokens(chatReq)
})
client := chatcmpl.NewClient(provider.BaseURL, provider.APIKey)
if req.Stream {
s.handleResponsesStream(w, r, client, chatReq, &req, model, rec)
return
}
resp, err := client.Create(r.Context(), chatReq)
if err != nil {
s.failUpstreamResponses(w, rec, err)
return
}
out := responses.ConvertResponse(resp, req.Model)
rec.update(func(e *LogEntry) {
if out.Usage != nil {
e.OutputTokens = out.Usage.OutputTokens
}
})
writeJSON(w, http.StatusOK, out)
}
// handleResponsesStream relays a Chat Completions SSE stream as
// Responses events.
func (s *Server) handleResponsesStream(w http.ResponseWriter, r *http.Request, client *chatcmpl.Client,
chatReq *chatcmpl.Request, req *responses.Request, model string, rec *record) {
resp, err := client.CreateStream(r.Context(), chatReq)
if err != nil {
s.failUpstreamResponses(w, rec, err)
return
}
defer resp.Body.Close()
conv := responses.NewStreamConverter(w, req.Model, responses.EstimateTokens(chatReq))
if err := conv.WriteStart(); err != nil {
return
}
stream := chatcmpl.NewStream(resp.Body)
for {
chunk, err := stream.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
conv.WriteError("api_error", err.Error())
return
}
if err := conv.OnChunk(chunk); err != nil {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
conv.WriteError("api_error", err.Error())
return
}
}
if err := conv.Finish(); err != nil {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
}
}
// failUpstreamResponses maps an upstream failure onto a Responses
// error envelope.
func (s *Server) failUpstreamResponses(w http.ResponseWriter, rec *record, err error) {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
var ue *chatcmpl.UpstreamError
if errors.As(err, &ue) {
switch {
case ue.StatusCode == http.StatusUnauthorized || ue.StatusCode == http.StatusForbidden:
writeResponsesError(w, http.StatusUnauthorized, "authentication_error", ue.Message)
case ue.StatusCode == http.StatusTooManyRequests:
writeResponsesError(w, http.StatusTooManyRequests, "rate_limit_error", ue.Message)
case ue.StatusCode == http.StatusBadRequest:
writeResponsesError(w, http.StatusBadRequest, "invalid_request_error", ue.Message)
default:
writeResponsesError(w, http.StatusBadGateway, "api_error", ue.Message)
}
return
}
writeResponsesError(w, http.StatusBadGateway, "api_error", fmt.Sprintf("upstream request failed: %v", err))
}
+19 -1
View File
@@ -1,5 +1,6 @@
// Package gateway serves the Anthropic Messages API to Claude Code and
// the local management API to the CrossRelay UI.
// the Responses API to Codex, plus the local management API to the
// CrossRelay UI.
package gateway
import (
@@ -13,7 +14,9 @@ import (
"gitea.rutua.cn/kate522/CrossRelay/internal/anthropic"
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
"gitea.rutua.cn/kate522/CrossRelay/internal/codexcfg"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
"gitea.rutua.cn/kate522/CrossRelay/internal/responses"
)
// Version is reported by /api/status.
@@ -70,6 +73,7 @@ func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/messages", s.withAuth(s.handleMessages))
mux.HandleFunc("POST /v1/messages/count_tokens", s.withAuth(s.handleCountTokens))
mux.HandleFunc("POST /v1/responses", s.withAuth(s.handleResponses))
mux.HandleFunc("GET /api/status", s.handleStatus)
mux.HandleFunc("GET /api/providers", s.handleListProviders)
mux.HandleFunc("POST /api/providers", s.handleCreateProvider)
@@ -199,6 +203,14 @@ func (s *Server) claudeManager() (*claudecfg.Manager, error) {
return claudecfg.New(path, filepath.Join(config.Dir(), "backups")), nil
}
func (s *Server) codexManager() (*codexcfg.Manager, error) {
path, err := codexcfg.DefaultPath()
if err != nil {
return nil, err
}
return codexcfg.New(path, filepath.Join(config.Dir(), "backups")), nil
}
func (s *Server) snapshotStats() Stats {
s.statsMu.Lock()
defer s.statsMu.Unlock()
@@ -222,6 +234,12 @@ func writeAPIError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{"error": message})
}
func writeResponsesError(w http.ResponseWriter, status int, errType, message string) {
writeJSON(w, status, responses.ErrorResponse{
Error: responses.Detail{Type: errType, Message: message},
})
}
func decodeJSON(r *http.Request, v any, limit int64) error {
dec := json.NewDecoder(io.LimitReader(r.Body, limit))
return dec.Decode(v)
+14
View File
@@ -21,6 +21,7 @@ var All = []Preset{
Sonnet: "deepseek-chat",
Haiku: "deepseek-chat",
Opus: "deepseek-chat",
Extra: codexModelExtras("deepseek-chat"),
},
},
{
@@ -31,6 +32,7 @@ var All = []Preset{
Sonnet: "kimi-k2-0905-preview",
Haiku: "kimi-k2-0905-preview",
Opus: "kimi-k2-0905-preview",
Extra: codexModelExtras("kimi-k2-0905-preview"),
},
},
{
@@ -41,10 +43,22 @@ var All = []Preset{
Sonnet: "glm-4.6",
Haiku: "glm-4.6",
Opus: "glm-4.6",
Extra: codexModelExtras("glm-4.6"),
},
},
}
// codexModelExtras maps the model names Codex CLI asks for onto one
// upstream model, so activating a preset for Codex works without manual
// mapping.
func codexModelExtras(upstream string) map[string]string {
return map[string]string{
"gpt-5-codex": upstream,
"gpt-5": upstream,
"codex-mini-latest": upstream,
}
}
// ByKey looks up a preset by its key.
func ByKey(key string) (Preset, bool) {
for _, p := range All {
+27
View File
@@ -0,0 +1,27 @@
package presets
import "testing"
func TestPresetsCarryCodexModelExtras(t *testing.T) {
for _, p := range All {
for _, name := range []string{"gpt-5-codex", "gpt-5", "codex-mini-latest"} {
got, ok := p.Models.Extra[name]
if !ok {
t.Errorf("preset %q has no extra mapping for %q", p.Key, name)
continue
}
if got != p.Models.Default {
t.Errorf("preset %q maps %q to %q, want %q", p.Key, name, got, p.Models.Default)
}
}
}
}
func TestByKey(t *testing.T) {
if _, ok := ByKey("deepseek"); !ok {
t.Error("deepseek preset missing")
}
if _, ok := ByKey("nope"); ok {
t.Error("unknown preset resolved")
}
}
+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()
}