From 5112f7ca0680b780ce42d0b8048cdde0a8e86c71 Mon Sep 17 00:00:00 2001 From: kate522 Date: Sun, 18 Oct 2026 18:35:01 +0800 Subject: [PATCH] CrossRelay stage 2: Codex /v1/responses relay, config.toml manager, Codex page --- README.md | 4 +- internal/claudecfg/claudecfg.go | 19 +- internal/codexcfg/codexcfg.go | 442 +++++++++++++++++++++++++++++ internal/codexcfg/codexcfg_test.go | 258 +++++++++++++++++ internal/config/config.go | 10 +- internal/config/config_test.go | 96 +++++++ internal/config/store.go | 63 ++++ internal/gateway/gateway_test.go | 257 +++++++++++++++++ internal/gateway/handlers.go | 179 ++++++++++-- internal/gateway/responses.go | 118 ++++++++ internal/gateway/server.go | 20 +- internal/presets/presets.go | 14 + internal/presets/presets_test.go | 27 ++ internal/responses/convert.go | 296 +++++++++++++++++++ internal/responses/convert_test.go | 354 +++++++++++++++++++++++ internal/responses/stream.go | 386 +++++++++++++++++++++++++ internal/responses/stream_test.go | 344 ++++++++++++++++++++++ internal/responses/types.go | 177 ++++++++++++ src/App.tsx | 2 + src/components/Layout.tsx | 3 +- src/i18n/resources.ts | 42 +++ src/lib/api.ts | 23 +- src/lib/queries.ts | 13 +- src/lib/types.ts | 49 +++- src/pages/Codex.test.tsx | 95 +++++++ src/pages/Codex.tsx | 137 +++++++++ src/pages/Dashboard.tsx | 7 +- src/pages/Providers.test.tsx | 22 +- src/pages/Providers.tsx | 105 ++++++- src/pages/SettingsPage.tsx | 49 +++- 30 files changed, 3539 insertions(+), 72 deletions(-) create mode 100644 internal/codexcfg/codexcfg.go create mode 100644 internal/codexcfg/codexcfg_test.go create mode 100644 internal/gateway/responses.go create mode 100644 internal/presets/presets_test.go create mode 100644 internal/responses/convert.go create mode 100644 internal/responses/convert_test.go create mode 100644 internal/responses/stream.go create mode 100644 internal/responses/stream_test.go create mode 100644 internal/responses/types.go create mode 100644 src/pages/Codex.test.tsx create mode 100644 src/pages/Codex.tsx diff --git a/README.md b/README.md index db8f1b9..665ba71 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 架构 - `src-tauri/`:Tauri 2 桌面壳(React 前端 + Rust 壳),负责拉起网关子进程、解析端口、退出时清理。 -- `cmd/gateway` + `internal/`:Go 网关,同时提供 `/v1/messages`(Anthropic Messages 协议)与管理 API `/api/*`,统一转换为上游 Chat Completions。 +- `cmd/gateway` + `internal/`:Go 网关,同时提供 `/v1/messages`(Anthropic Messages 协议)与 `/v1/responses`(OpenAI Responses 协议)及管理 API `/api/*`,统一转换为上游 Chat Completions。 - 用户数据位于 `~/.crossrelay/`:`config.json`(供应商与设置)、`state.json`(运行期端口)、`backups/`(配置备份)。 ## 开发 @@ -31,4 +31,4 @@ npm run typecheck ## 当前范围 -阶段一已完成:Claude Code 通路(`/v1/messages` 非流式 + SSE 流式、`~/.claude/settings.json` 注入与还原、供应商管理与中英双语界面)。Codex `/v1/responses`、MCP 双端同步、单二进制打包为后续阶段。 +阶段一、二已完成:Claude Code 通路(`/v1/messages` 非流式 + SSE 流式、`~/.claude/settings.json` 注入与还原)与 Codex 通路(`/v1/responses` 非流式 + SSE 流式、`~/.codex/config.toml` 定点注入与删键还原),两端共用供应商管理与中英双语界面。MCP 双端同步、单二进制打包为后续阶段。 diff --git a/internal/claudecfg/claudecfg.go b/internal/claudecfg/claudecfg.go index 69d55f5..c5c81fe 100644 --- a/internal/claudecfg/claudecfg.go +++ b/internal/claudecfg/claudecfg.go @@ -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. diff --git a/internal/codexcfg/codexcfg.go b/internal/codexcfg/codexcfg.go new file mode 100644 index 0000000..7014fc6 --- /dev/null +++ b/internal/codexcfg/codexcfg.go @@ -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 +} diff --git a/internal/codexcfg/codexcfg_test.go b/internal/codexcfg/codexcfg_test.go new file mode 100644 index 0000000..c096d96 --- /dev/null +++ b/internal/codexcfg/codexcfg_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 5cc704c..3fba003 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c75ec62..b739c1e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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() diff --git a/internal/config/store.go b/internal/config/store.go index 40e4031..6d6e8c9 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -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) diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 6b003b1..ec0ed80 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -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) diff --git a/internal/gateway/handlers.go b/internal/gateway/handlers.go index 16a37e3..8fab299 100644 --- a/internal/gateway/handlers.go +++ b/internal/gateway/handlers.go @@ -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) { diff --git a/internal/gateway/responses.go b/internal/gateway/responses.go new file mode 100644 index 0000000..9279f06 --- /dev/null +++ b/internal/gateway/responses.go @@ -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)) +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 44608c7..a01ca93 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -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) diff --git a/internal/presets/presets.go b/internal/presets/presets.go index d7e643e..b078b22 100644 --- a/internal/presets/presets.go +++ b/internal/presets/presets.go @@ -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 { diff --git a/internal/presets/presets_test.go b/internal/presets/presets_test.go new file mode 100644 index 0000000..12c3ab2 --- /dev/null +++ b/internal/presets/presets_test.go @@ -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") + } +} diff --git a/internal/responses/convert.go b/internal/responses/convert.go new file mode 100644 index 0000000..b5dc29c --- /dev/null +++ b/internal/responses/convert.go @@ -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) +} diff --git a/internal/responses/convert_test.go b/internal/responses/convert_test.go new file mode 100644 index 0000000..7efc48d --- /dev/null +++ b/internal/responses/convert_test.go @@ -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}, + } +} diff --git a/internal/responses/stream.go b/internal/responses/stream.go new file mode 100644 index 0000000..65916e4 --- /dev/null +++ b/internal/responses/stream.go @@ -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 +} diff --git a/internal/responses/stream_test.go b/internal/responses/stream_test.go new file mode 100644 index 0000000..83a3148 --- /dev/null +++ b/internal/responses/stream_test.go @@ -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 +} diff --git a/internal/responses/types.go b/internal/responses/types.go new file mode 100644 index 0000000..3bac77d --- /dev/null +++ b/internal/responses/types.go @@ -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() +} diff --git a/src/App.tsx b/src/App.tsx index 1317195..bfadc7e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { ToastProvider } from "./components/toast"; import { Layout } from "./components/Layout"; import Dashboard from "./pages/Dashboard"; import Providers from "./pages/Providers"; +import Codex from "./pages/Codex"; import SettingsPage from "./pages/SettingsPage"; const queryClient = new QueryClient({ @@ -31,6 +32,7 @@ export default function App({ initialLanguage }: { initialLanguage?: string }) { }> } /> } /> + } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 490136b..004a54a 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { NavLink, Outlet } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { Gauge, Server, Settings as SettingsIcon } from "lucide-react"; +import { Gauge, Server, Settings as SettingsIcon, Terminal } from "lucide-react"; import { cn } from "@/lib/utils"; import { useSettingsMutation, useSettings } from "@/lib/queries"; import i18n from "@/i18n"; @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; const navItems = [ { to: "/", key: "nav.dashboard", icon: Gauge }, { to: "/providers", key: "nav.providers", icon: Server }, + { to: "/codex", key: "nav.codex", icon: Terminal }, { to: "/settings", key: "nav.settings", icon: SettingsIcon }, ]; diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 9619a34..082b9a9 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -8,6 +8,7 @@ export const zhCN = { dashboard: "仪表盘", providers: "供应商", settings: "设置", + codex: "Codex", }, common: { save: "保存", @@ -73,6 +74,7 @@ export const zhCN = { extraKey: "请求的模型名", extraValue: "上游模型名", addMapping: "添加一行", + extraHint: "Codex 等客户端会把模型名原样发给网关,在此把入站模型名映射到上游模型名。", activated: "已激活", activate: "激活并写入", empty: "还没有供应商,先添加一个", @@ -87,6 +89,20 @@ export const zhCN = { keyHint: "留空表示保持原有 Key 不变", enabledClaude: "对 Claude Code 启用", }, + codex: { + title: "Codex", + status: "注入状态", + injected: "已注入", + notInjected: "未注入", + configPath: "配置文件", + portHint: "auto 端口模式下网关重启后端口会变,需重新激活", + preview: "已写入 config.toml 的托管配置", + activate: "激活并写入", + activated: "已激活", + restore: "还原官方配置", + restoreDone: "已还原 Codex 官方配置", + empty: "还没有供应商,请先到供应商页添加", + }, settings: { title: "设置", gateway: "网关", @@ -100,6 +116,11 @@ export const zhCN = { backupsEmpty: "暂无备份", restoreBackup: "还原", restored: "已还原", + backupTarget: "备份目标", + targetClaude: "Claude Code", + targetCodex: "Codex", + rollback: "回滚", + rolledBack: "已回滚", saveFailed: "保存失败", }, }, @@ -115,6 +136,7 @@ export const enUS = { dashboard: "Dashboard", providers: "Providers", settings: "Settings", + codex: "Codex", }, common: { save: "Save", @@ -180,6 +202,7 @@ export const enUS = { extraKey: "Requested model", extraValue: "Upstream model", addMapping: "Add row", + extraHint: "Clients like Codex send their model names as-is; map inbound names onto upstream models here.", activated: "Active", activate: "Activate & apply", empty: "No providers yet", @@ -194,6 +217,20 @@ export const enUS = { keyHint: "Leave empty to keep the current key", enabledClaude: "Enabled for Claude Code", }, + codex: { + title: "Codex", + status: "Injection status", + injected: "Injected", + notInjected: "Not injected", + configPath: "Config file", + portHint: "With an automatic port the gateway picks a new port on restart; activate again afterwards.", + preview: "Managed keys written to config.toml", + activate: "Activate & apply", + activated: "Active", + restore: "Restore official config", + restoreDone: "Codex official config restored", + empty: "No providers yet, add one on the Providers page", + }, settings: { title: "Settings", gateway: "Gateway", @@ -207,6 +244,11 @@ export const enUS = { backupsEmpty: "No backups yet", restoreBackup: "Restore", restored: "Restored", + backupTarget: "Backup target", + targetClaude: "Claude Code", + targetCodex: "Codex", + rollback: "Roll back", + rolledBack: "Rolled back", saveFailed: "Save failed", }, }, diff --git a/src/lib/api.ts b/src/lib/api.ts index 7e1303e..3e74c3e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -61,12 +61,27 @@ export const api = { request(`/api/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }), deleteProvider: (id: string) => request<{ deleted: string }>(`/api/providers/${id}`, { method: "DELETE" }), - applyProvider: (id: string) => - request(`/api/providers/${id}/apply`, { method: "POST", body: "{}" }), - restore: () => request<{ restored_from: string }>("/api/restore", { method: "POST", body: "{}" }), - listBackups: () => request<{ items: BackupInfo[] }>("/api/backups"), + applyProvider: (id: string, target: ApplyTarget = "claude") => + request(`/api/providers/${id}/apply`, { + method: "POST", + body: JSON.stringify({ target }), + }), + restore: (input: RestoreInput = {}) => + request<{ restored_from: string }>("/api/restore", { + method: "POST", + body: JSON.stringify(input), + }), + listBackups: (target: ApplyTarget = "claude") => + request<{ items: BackupInfo[] }>(`/api/backups?target=${target}`), getSettings: () => request("/api/settings"), updateSettings: (patch: Partial) => request("/api/settings", { method: "PUT", body: JSON.stringify(patch) }), listLogs: () => request<{ items: LogEntry[] }>("/api/logs"), }; + +export type ApplyTarget = "claude" | "codex"; + +export interface RestoreInput { + target?: ApplyTarget; + backup?: string; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index b03871f..712e5a3 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -5,7 +5,7 @@ export const queryKeys = { status: ["status"] as const, providers: ["providers"] as const, logs: ["logs"] as const, - backups: ["backups"] as const, + backups: (target: string) => ["backups", target] as const, settings: ["settings"] as const, }; @@ -21,8 +21,8 @@ export function useLogs() { return useQuery({ queryKey: queryKeys.logs, queryFn: api.listLogs, refetchInterval: 5000 }); } -export function useBackups() { - return useQuery({ queryKey: queryKeys.backups, queryFn: api.listBackups }); +export function useBackups(target: "claude" | "codex") { + return useQuery({ queryKey: queryKeys.backups(target), queryFn: () => api.listBackups(target) }); } export function useSettings() { @@ -33,7 +33,7 @@ function invalidateAll(queryClient: ReturnType) { return Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.providers }), queryClient.invalidateQueries({ queryKey: queryKeys.status }), - queryClient.invalidateQueries({ queryKey: queryKeys.backups }), + queryClient.invalidateQueries({ queryKey: ["backups"] }), queryClient.invalidateQueries({ queryKey: queryKeys.settings }), ]); } @@ -53,7 +53,8 @@ export function useProviderMutations() { onSuccess: () => invalidateAll(queryClient), }); const apply = useMutation({ - mutationFn: (id: string) => api.applyProvider(id), + mutationFn: ({ id, target }: { id: string; target: "claude" | "codex" }) => + api.applyProvider(id, target), onSuccess: () => invalidateAll(queryClient), }); return { create, update, remove, apply }; @@ -62,7 +63,7 @@ export function useProviderMutations() { export function useRestore() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => api.restore(), + mutationFn: (input: Parameters[0]) => api.restore(input), onSuccess: () => invalidateAll(queryClient), }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 8266547..271f738 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,6 +14,7 @@ export interface Provider { api_key: string; models: Models; enabled_for_claude: boolean; + enabled_for_codex: boolean; created_at: string; } @@ -37,6 +38,7 @@ export interface Status { port: number; uptime_sec: number; active_claude_provider: ActiveProvider | null; + active_codex_provider: ActiveProvider | null; stats: Stats; language: string; port_mode: string; @@ -69,7 +71,14 @@ export interface Settings { export interface ApplyResult { provider: Provider; - env: Record; + env?: Record; + injection?: Injection; +} + +export interface Injection { + model_provider: string; + table: Record; + prior_provider?: string; } export interface Preset { @@ -78,25 +87,47 @@ export interface Preset { models: Models; } +const CODEX_EXTRAS = ["gpt-5-codex", "gpt-5", "codex-mini-latest"]; + export const PRESETS: Preset[] = [ { key: "deepseek", base_url: "https://api.deepseek.com/v1", - models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" }, + models: withCodexExtras({ + default: "deepseek-chat", + sonnet: "deepseek-chat", + haiku: "deepseek-chat", + opus: "deepseek-chat", + }, "deepseek-chat"), }, { key: "moonshot", base_url: "https://api.moonshot.cn/v1", - models: { - default: "kimi-k2-0905-preview", - sonnet: "kimi-k2-0905-preview", - haiku: "kimi-k2-0905-preview", - opus: "kimi-k2-0905-preview", - }, + models: withCodexExtras( + { + default: "kimi-k2-0905-preview", + sonnet: "kimi-k2-0905-preview", + haiku: "kimi-k2-0905-preview", + opus: "kimi-k2-0905-preview", + }, + "kimi-k2-0905-preview", + ), }, { key: "zhipu", base_url: "https://open.bigmodel.cn/api/paas/v4", - models: { default: "glm-4.6", sonnet: "glm-4.6", haiku: "glm-4.6", opus: "glm-4.6" }, + models: withCodexExtras( + { + default: "glm-4.6", + sonnet: "glm-4.6", + haiku: "glm-4.6", + opus: "glm-4.6", + }, + "glm-4.6", + ), }, ]; + +function withCodexExtras(models: Models, upstream: string): Models { + return { ...models, extra: Object.fromEntries(CODEX_EXTRAS.map((m) => [m, upstream])) }; +} diff --git a/src/pages/Codex.test.tsx b/src/pages/Codex.test.tsx new file mode 100644 index 0000000..b07c69e --- /dev/null +++ b/src/pages/Codex.test.tsx @@ -0,0 +1,95 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders } from "@/test/utils"; +import userEvent from "@testing-library/user-event"; + +const listProviders = vi.fn(); +const applyProvider = vi.fn(); +const restore = vi.fn(); +const getStatus = vi.fn(); + +vi.mock("@/lib/api", () => ({ + api: { + listProviders: () => listProviders(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + deleteProvider: vi.fn(), + applyProvider: (id: string, target: string) => applyProvider(id, target), + restore: (input: unknown) => restore(input), + getStatus: () => getStatus(), + getSettings: vi.fn().mockResolvedValue({ language: "zh-CN", port_mode: "auto", fixed_port: 0 }), + updateSettings: vi.fn(), + listLogs: vi.fn().mockResolvedValue({ items: [] }), + listBackups: vi.fn().mockResolvedValue({ items: [] }), + }, +})); + +import Codex from "./Codex"; + +const provider = { + id: "p_1", + name: "DeepSeek", + preset: "deepseek", + base_url: "https://api.deepseek.com/v1", + api_key: "sk-x", + models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" }, + enabled_for_claude: false, + enabled_for_codex: false, + created_at: "2026-10-18T00:00:00Z", +}; + +beforeEach(() => { + vi.clearAllMocks(); + listProviders.mockResolvedValue({ items: [provider] }); + getStatus.mockResolvedValue({ + running: true, + version: "0.1.0", + port: 52917, + uptime_sec: 95, + active_claude_provider: null, + active_codex_provider: null, + stats: { requests_total: 0, requests_failed: 0, input_tokens: 0, output_tokens: 0 }, + language: "zh-CN", + port_mode: "auto", + fixed_port: 0, + }); + applyProvider.mockResolvedValue({ + provider, + injection: { + model_provider: "crossrelay", + table: { + name: "crossrelay", + wire_api: "responses", + base_url: "http://127.0.0.1:52917", + experimental_bearer_token: "crl_x", + requires_openai_auth: "false", + }, + }, + }); + restore.mockResolvedValue({ restored_from: "" }); +}); + +describe("Codex page", () => { + it("shows the injection status, config path and provider list", async () => { + renderWithProviders(); + expect(await screen.findByText("未注入")).toBeInTheDocument(); + expect(screen.getByText(/config\.toml/)).toBeInTheDocument(); + expect(screen.getByText("DeepSeek")).toBeInTheDocument(); + }); + + it("activates a provider for Codex and previews the injected keys", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole("button", { name: /激活并写入|Activate & apply/ })); + await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1", "codex")); + expect(await screen.findByText(/experimental_bearer_token/)).toBeInTheDocument(); + expect(screen.getByText(/wire_api/)).toBeInTheDocument(); + }); + + it("restores the official config", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole("button", { name: /还原官方配置|Restore official config/ })); + await waitFor(() => expect(restore).toHaveBeenCalledWith({ target: "codex" })); + }); +}); diff --git a/src/pages/Codex.tsx b/src/pages/Codex.tsx new file mode 100644 index 0000000..e8b3a5b --- /dev/null +++ b/src/pages/Codex.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RotateCcw, Zap } from "lucide-react"; +import type { Provider } from "@/lib/types"; +import { useProviders, useProviderMutations, useRestore, useStatus } from "@/lib/queries"; +import { useToast } from "@/components/toast"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function Codex() { + const { t } = useTranslation(); + const status = useStatus(); + const { data, isLoading, isError } = useProviders(); + const mutations = useProviderMutations(); + const restore = useRestore(); + const { show } = useToast(); + const [injection, setInjection] = useState | null>(null); + + const providers = data?.items ?? []; + const active = status.data?.active_codex_provider ?? null; + + const apply = (provider: Provider) => { + mutations.apply.mutate( + { id: provider.id, target: "codex" }, + { + onSuccess: (result) => { + setInjection(result.injection?.table ?? null); + show(`${t("codex.activated")}: ${provider.name}`); + }, + onError: (err: Error) => show(err.message, "error"), + }, + ); + }; + + const restoreConfig = () => { + restore.mutate( + { target: "codex" }, + { + onSuccess: () => { + setInjection(null); + show(t("codex.restoreDone")); + }, + onError: (err: Error) => show(err.message, "error"), + }, + ); + }; + + if (isLoading) return
{t("common.loading")}
; + if (isError) return
{t("common.error")}
; + + return ( +
+
+

{t("codex.title")}

+ +
+ + + + {t("codex.status")} + + +
+ + {active ? t("codex.injected") : t("codex.notInjected")} + + {active && {active.name}} +
+
+ {t("codex.configPath")}: ~/.codex/config.toml +
+
{t("codex.portHint")}
+
+
+ + {injection && ( + + + {t("codex.preview")} + + +
    +
  • + model_provider = crossrelay +
  • + {Object.entries(injection) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => ( +
  • + {key} = {value} +
  • + ))} +
+
+
+ )} + + {providers.length === 0 ? ( + + + {t("codex.empty")} + + + ) : ( +
+ {providers.map((provider) => ( + + +
+
+ {provider.name} + {provider.enabled_for_codex && {t("codex.activated")}} + {provider.preset && {provider.preset}} +
+
{provider.base_url}
+
+
+ +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 22bea32..30d19c5 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -118,10 +118,13 @@ export default function Dashboard() { size="sm" disabled={restore.isPending} onClick={() => - restore.mutate(undefined, { + restore.mutate( + { target: "claude" }, + { onSuccess: () => show(t("dashboard.restoreDone")), onError: (err) => show(err.message, "error"), - }) + }, + ) } > {restore.isPending ? ( diff --git a/src/pages/Providers.test.tsx b/src/pages/Providers.test.tsx index e63fbd6..a4f3949 100644 --- a/src/pages/Providers.test.tsx +++ b/src/pages/Providers.test.tsx @@ -21,8 +21,8 @@ vi.mock("@/lib/api", () => ({ createProvider: (input: unknown) => createProvider(input), updateProvider: (id: string, input: unknown) => updateProvider(id, input), deleteProvider: (id: string) => deleteProvider(id), - applyProvider: (id: string) => applyProvider(id), - restore: () => restore(), + applyProvider: (id: string, target: string) => applyProvider(id, target), + restore: (input: unknown) => restore(input), getStatus: () => getStatus(), getSettings: () => getSettings(), updateSettings: (patch: unknown) => updateSettings(patch), @@ -94,7 +94,23 @@ describe("Providers page", () => { const user = userEvent.setup(); renderPage(); await user.click(await screen.findByRole("button", { name: /激活并写入|Activate & apply/ })); - await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1")); + await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1", "claude")); expect(await screen.findByText(/ANTHROPIC_BASE_URL/)).toBeInTheDocument(); }); + + it("submits exact model mappings from the mapping editor", async () => { + const user = userEvent.setup(); + renderPage(); + await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ })); + await user.click(screen.getByRole("button", { name: /添加一行|Add row/ })); + await user.type(screen.getByPlaceholderText(/请求的模型名|Requested model/), "gpt-5-codex"); + await user.type(screen.getByPlaceholderText(/上游模型名|Upstream model/), "deepseek-chat"); + await user.type(screen.getByLabelText(/名称|Name/), "Kimi"); + await user.type(screen.getByLabelText(/Base URL/), "https://api.moonshot.cn/v1"); + await user.type(screen.getByLabelText(/API Key/), "sk-kimi"); + await user.click(screen.getByRole("button", { name: /保存|Save/ })); + await waitFor(() => expect(createProvider).toHaveBeenCalledTimes(1)); + const input = createProvider.mock.calls[0][0]; + expect(input.models.extra).toEqual({ "gpt-5-codex": "deepseek-chat" }); + }); }); diff --git a/src/pages/Providers.tsx b/src/pages/Providers.tsx index c9edaf5..c220912 100644 --- a/src/pages/Providers.tsx +++ b/src/pages/Providers.tsx @@ -17,7 +17,7 @@ interface FormState { preset: string; base_url: string; api_key: string; - models: Models; + models: Omit & { extra: { key: string; value: string }[] }; } const emptyForm: FormState = { @@ -25,9 +25,19 @@ const emptyForm: FormState = { preset: "", base_url: "", api_key: "", - models: { default: "", sonnet: "", haiku: "", opus: "", extra: {} }, + models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] }, }; +function toRows(extra: Record | undefined): { key: string; value: string }[] { + return Object.entries(extra ?? {}).map(([key, value]) => ({ key, value })); +} + +function toMap(rows: { key: string; value: string }[]): Record { + return Object.fromEntries( + rows.filter((r) => r.key.trim() && r.value.trim()).map((r) => [r.key.trim(), r.value.trim()]), + ); +} + export default function Providers() { const { t } = useTranslation(); const { data, isLoading, isError } = useProviders(); @@ -47,7 +57,9 @@ export default function Providers() { preset: presetKey, name: presetKey ? presetKey : "", base_url: preset?.base_url ?? "", - models: preset ? { ...preset.models } : { ...emptyForm.models }, + models: preset + ? { ...preset.models, extra: toRows(preset.models.extra) } + : { ...emptyForm.models }, }); }; @@ -64,7 +76,7 @@ export default function Providers() { sonnet: provider.models.sonnet ?? "", haiku: provider.models.haiku ?? "", opus: provider.models.opus ?? "", - extra: { ...(provider.models.extra ?? {}) }, + extra: toRows(provider.models.extra), }, }); }; @@ -91,9 +103,7 @@ export default function Providers() { sonnet: form.models.sonnet?.trim(), haiku: form.models.haiku?.trim(), opus: form.models.opus?.trim(), - extra: Object.fromEntries( - Object.entries(form.models.extra ?? {}).filter(([k, v]) => k.trim() && v.trim()), - ), + extra: toMap(form.models.extra), }, }; const onSuccess = () => { @@ -109,13 +119,16 @@ export default function Providers() { }; const apply = (provider: Provider) => { - mutations.apply.mutate(provider.id, { - onSuccess: (result) => { - setLastAppliedEnv(result.env); - show(`${t("providers.activated")}: ${provider.name}`); + mutations.apply.mutate( + { id: provider.id, target: "claude" }, + { + onSuccess: (result) => { + setLastAppliedEnv(result.env ?? null); + show(`${t("providers.activated")}: ${provider.name}`); + }, + onError: (err) => show(err.message, "error"), }, - onError: (err) => show(err.message, "error"), - }); + ); }; if (isLoading) return
{t("common.loading")}
; @@ -158,7 +171,9 @@ export default function Providers() { ...form, preset: key, base_url: preset?.base_url ?? form.base_url, - models: preset ? { ...preset.models } : form.models, + models: preset + ? { ...preset.models, extra: toRows(preset.models.extra) } + : form.models, }); }} > @@ -209,6 +224,68 @@ export default function Providers() { ))} +
+
+ {t("providers.extraMappings")} + +
+ {form.models.extra.map((row, index) => ( +
+ { + const extra = form.models.extra.map((r, i) => + i === index ? { ...r, key: e.target.value } : r, + ); + setForm({ ...form, models: { ...form.models, extra } }); + }} + /> + { + const extra = form.models.extra.map((r, i) => + i === index ? { ...r, value: e.target.value } : r, + ); + setForm({ ...form, models: { ...form.models, extra } }); + }} + /> + +
+ ))} +

{t("providers.extraHint")}

+
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 002a7c5..6b35a09 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -11,7 +11,8 @@ import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; export default function SettingsPage() { const { t, i18n } = useTranslation(); const { data: settings } = useSettings(); - const { data: backups } = useBackups(); + const [backupTarget, setBackupTarget] = useState<"claude" | "codex">("claude"); + const { data: backups } = useBackups(backupTarget); const mutation = useSettingsMutation(); const restore = useRestore(); const { show } = useToast(); @@ -93,16 +94,29 @@ export default function SettingsPage() { {t("settings.backups")} + @@ -115,6 +129,7 @@ export default function SettingsPage() { {t("settings.backups")} Size Modified + {t("common.actions")} @@ -123,6 +138,24 @@ export default function SettingsPage() { {item.name} {item.size} B {new Date(item.modified).toLocaleString()} + + + ))}