CrossRelay stage 2: Codex /v1/responses relay, config.toml manager, Codex page
This commit is contained in:
1 parent
404704171d
commit
5112f7ca06
30 files changed
+3539
-72
No files matched your search
@@ -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 双端同步、单二进制打包为后续阶段。
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -62,6 +62,7 @@ type testEnv struct {
|
||||
upstream *fakeUpstream
|
||||
store *config.Store
|
||||
settings string
|
||||
codex string
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
@@ -133,6 +134,262 @@ func messagesBody(model string, stream bool) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// newCodexTestEnv builds the standard environment with a realistic
|
||||
// config.toml in place and the shared provider activated for Codex.
|
||||
func newCodexTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
env := newTestEnv(t)
|
||||
codexHome := t.TempDir()
|
||||
t.Setenv("CODEX_HOME", codexHome)
|
||||
env.codex = filepath.Join(codexHome, "config.toml")
|
||||
if err := os.WriteFile(env.codex, []byte("model_provider = \"custom\"\n\n[model_providers.custom]\nname = \"custom\"\nwire_api = \"responses\"\nbase_url = \"https://old.example.com/v1\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config.toml: %v", err)
|
||||
}
|
||||
if _, err := env.store.ActivateCodex(env.store.Config().Providers[0].ID); err != nil {
|
||||
t.Fatalf("ActivateCodex: %v", err)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *testEnv) get(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
resp, err := http.Get(e.gateway.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return out
|
||||
}
|
||||
|
||||
func responsesBody(model string, stream bool) map[string]any {
|
||||
return map[string]any{
|
||||
"model": model,
|
||||
"instructions": "be brief",
|
||||
"input": []any{map[string]any{
|
||||
"type": "message", "role": "user",
|
||||
"content": []any{map[string]any{"type": "input_text", "text": "hello"}},
|
||||
}},
|
||||
"stream": stream,
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesNonStream(t *testing.T) {
|
||||
env := newCodexTestEnv(t)
|
||||
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), env.store.Config().RelayToken)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if out["object"] != "response" || out["status"] != "completed" {
|
||||
t.Errorf("envelope = %v", out)
|
||||
}
|
||||
if out["model"] != "gpt-5-codex" {
|
||||
t.Errorf("model = %v, want the requested model echoed", out["model"])
|
||||
}
|
||||
if out["output_text"] != "Hi there" {
|
||||
t.Errorf("output_text = %v", out["output_text"])
|
||||
}
|
||||
usage := out["usage"].(map[string]any)
|
||||
if usage["input_tokens"].(float64) != 9 || usage["output_tokens"].(float64) != 2 {
|
||||
t.Errorf("usage = %v", usage)
|
||||
}
|
||||
if env.upstream.lastModel != "ds-chat" {
|
||||
t.Errorf("upstream model = %q, want the resolved model", env.upstream.lastModel)
|
||||
}
|
||||
if env.upstream.lastAuth != "Bearer up-key" {
|
||||
t.Errorf("upstream auth = %q", env.upstream.lastAuth)
|
||||
}
|
||||
msgs := env.upstream.lastRequest["messages"].([]any)
|
||||
system := msgs[0].(map[string]any)
|
||||
if system["role"] != "system" || system["content"] != "be brief" {
|
||||
t.Errorf("system message = %v", system)
|
||||
}
|
||||
user := msgs[1].(map[string]any)
|
||||
if user["role"] != "user" || user["content"] != "hello" {
|
||||
t.Errorf("user message = %v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesStream(t *testing.T) {
|
||||
env := newCodexTestEnv(t)
|
||||
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", true), env.store.Config().RelayToken)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Errorf("content type = %q", ct)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read stream: %v", err)
|
||||
}
|
||||
var names []string
|
||||
var text strings.Builder
|
||||
for _, block := range strings.Split(strings.TrimSpace(string(body)), "\n\n") {
|
||||
if block == "" {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event: "):
|
||||
names = append(names, strings.TrimPrefix(line, "event: "))
|
||||
case strings.HasPrefix(line, "data: "):
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload); err == nil {
|
||||
if payload["type"] == "response.output_text.delta" {
|
||||
text.WriteString(payload["delta"].(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
"response.created", "response.in_progress",
|
||||
"response.output_item.added", "response.content_part.added",
|
||||
"response.output_text.delta", "response.output_text.delta",
|
||||
"response.output_text.done", "response.content_part.done",
|
||||
"response.output_item.done", "response.completed",
|
||||
}
|
||||
if len(names) != len(want) {
|
||||
t.Fatalf("events = %v, want %v", names, want)
|
||||
}
|
||||
for i := range want {
|
||||
if names[i] != want[i] {
|
||||
t.Fatalf("events = %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
if text.String() != "Hi there" {
|
||||
t.Errorf("streamed text = %q", text.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesRejectsBadToken(t *testing.T) {
|
||||
env := newCodexTestEnv(t)
|
||||
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), "crl_wrong")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesWithoutActiveProvider(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
t.Setenv("CODEX_HOME", t.TempDir())
|
||||
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), env.store.Config().RelayToken)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
detail, ok := out["error"].(map[string]any)
|
||||
if !ok || !strings.Contains(detail["message"].(string), "no active provider") {
|
||||
t.Errorf("error = %v", out["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAndRestoreCodex(t *testing.T) {
|
||||
env := newCodexTestEnv(t)
|
||||
id := env.store.Config().Providers[0].ID
|
||||
|
||||
resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("apply status = %d", resp.StatusCode)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
injection, ok := out["injection"].(map[string]any)
|
||||
if !ok || injection["model_provider"] != "crossrelay" {
|
||||
t.Fatalf("injection = %v", out["injection"])
|
||||
}
|
||||
table := injection["table"].(map[string]any)
|
||||
if table["wire_api"] != "responses" || table["base_url"] != "http://127.0.0.1:18787" {
|
||||
t.Errorf("injected table = %v", table)
|
||||
}
|
||||
if table["experimental_bearer_token"] != env.store.Config().RelayToken {
|
||||
t.Errorf("injected token = %v", table["experimental_bearer_token"])
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(env.codex)
|
||||
if err != nil {
|
||||
t.Fatalf("read config.toml: %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
for _, want := range []string{
|
||||
`model_provider = "crossrelay"`,
|
||||
"[model_providers.crossrelay]",
|
||||
"requires_openai_auth = false",
|
||||
"[model_providers.custom]",
|
||||
`base_url = "https://old.example.com/v1"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("config.toml missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if env.store.Config().CodexPriorProvider != "custom" {
|
||||
t.Errorf("prior provider = %q, want custom", env.store.Config().CodexPriorProvider)
|
||||
}
|
||||
if status := env.get(t, "/api/status"); status["active_codex_provider"] == nil {
|
||||
t.Errorf("status lacks active_codex_provider: %v", status)
|
||||
}
|
||||
|
||||
resp = env.post(t, "/api/restore", map[string]any{"target": "codex"}, "")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("restore status = %d", resp.StatusCode)
|
||||
}
|
||||
data, _ = os.ReadFile(env.codex)
|
||||
got = string(data)
|
||||
if !strings.Contains(got, `model_provider = "custom"`) {
|
||||
t.Errorf("prior provider not restored:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "crossrelay") {
|
||||
t.Errorf("managed keys survived restore:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `base_url = "https://old.example.com/v1"`) {
|
||||
t.Errorf("user's provider table damaged:\n%s", got)
|
||||
}
|
||||
if env.store.ActiveCodex() != nil {
|
||||
t.Error("active codex provider survived restore")
|
||||
}
|
||||
if env.store.Config().CodexPriorProvider != "" {
|
||||
t.Error("prior provider record survived restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsTargetDimension(t *testing.T) {
|
||||
env := newCodexTestEnv(t)
|
||||
id := env.store.Config().Providers[0].ID
|
||||
|
||||
resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "claude"}, "")
|
||||
resp.Body.Close()
|
||||
resp = env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "")
|
||||
resp.Body.Close()
|
||||
|
||||
codexBackups := env.get(t, "/api/backups?target=codex")["items"].([]any)
|
||||
if len(codexBackups) != 1 {
|
||||
t.Fatalf("codex backups = %d, want 1", len(codexBackups))
|
||||
}
|
||||
if name := codexBackups[0].(map[string]any)["name"].(string); !strings.HasPrefix(name, "config-") {
|
||||
t.Errorf("codex backup name = %q", name)
|
||||
}
|
||||
claudeBackups := env.get(t, "/api/backups")["items"].([]any)
|
||||
if len(claudeBackups) != 1 {
|
||||
t.Fatalf("claude backups = %d, want 1", len(claudeBackups))
|
||||
}
|
||||
if name := claudeBackups[0].(map[string]any)["name"].(string); !strings.HasPrefix(name, "settings-") {
|
||||
t.Errorf("claude backup name = %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesNonStream(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
|
||||
|
||||
+162
-17
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
|
||||
)
|
||||
|
||||
// ConvertRequest converts a Responses request into a Chat Completions
|
||||
// request addressed to the given upstream model.
|
||||
func ConvertRequest(req *Request, model string) (*chatcmpl.Request, error) {
|
||||
out := &chatcmpl.Request{
|
||||
Model: model,
|
||||
MaxTokens: req.MaxOutputTokens,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
Stream: req.Stream,
|
||||
}
|
||||
items, err := ParseInput(req.Input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse input: %w", err)
|
||||
}
|
||||
|
||||
var systemParts []string
|
||||
if s := strings.TrimSpace(req.Instructions); s != "" {
|
||||
systemParts = append(systemParts, s)
|
||||
}
|
||||
var assistantText []string
|
||||
var assistantCalls []chatcmpl.ToolCall
|
||||
flushAssistant := func() {
|
||||
if len(assistantText) == 0 && len(assistantCalls) == 0 {
|
||||
return
|
||||
}
|
||||
msg := chatcmpl.Message{Role: "assistant"}
|
||||
if len(assistantText) > 0 {
|
||||
msg.Content = strings.Join(assistantText, "\n\n")
|
||||
}
|
||||
msg.ToolCalls = assistantCalls
|
||||
out.Messages = append(out.Messages, msg)
|
||||
assistantText = nil
|
||||
assistantCalls = nil
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
switch it.Type {
|
||||
case "reasoning":
|
||||
// Upstream Chat Completions has no reasoning channel; the
|
||||
// item is dropped rather than replayed as spoken text.
|
||||
continue
|
||||
case "message":
|
||||
parts, err := ParseContent(it.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse message content: %w", err)
|
||||
}
|
||||
switch it.Role {
|
||||
case "system", "developer":
|
||||
flushAssistant()
|
||||
for _, p := range parts {
|
||||
if isTextPart(p) {
|
||||
systemParts = append(systemParts, p.Text)
|
||||
}
|
||||
}
|
||||
case "assistant":
|
||||
for _, p := range parts {
|
||||
if isTextPart(p) {
|
||||
assistantText = append(assistantText, p.Text)
|
||||
}
|
||||
}
|
||||
default:
|
||||
flushAssistant()
|
||||
out.Messages = append(out.Messages, userMessage(parts))
|
||||
}
|
||||
case "function_call":
|
||||
args := it.Arguments
|
||||
if strings.TrimSpace(args) == "" {
|
||||
args = "{}"
|
||||
}
|
||||
assistantCalls = append(assistantCalls, chatcmpl.ToolCall{
|
||||
ID: it.CallID,
|
||||
Type: "function",
|
||||
Function: chatcmpl.ToolCallFunction{Name: it.Name, Arguments: args},
|
||||
})
|
||||
case "function_call_output":
|
||||
flushAssistant()
|
||||
out.Messages = append(out.Messages, chatcmpl.Message{
|
||||
Role: "tool",
|
||||
ToolCallID: it.CallID,
|
||||
Content: OutputText(it.Output),
|
||||
})
|
||||
}
|
||||
}
|
||||
flushAssistant()
|
||||
|
||||
if len(systemParts) > 0 {
|
||||
system := chatcmpl.Message{Role: "system", Content: strings.Join(systemParts, "\n\n")}
|
||||
out.Messages = append([]chatcmpl.Message{system}, out.Messages...)
|
||||
}
|
||||
for _, t := range req.Tools {
|
||||
params := t.Parameters
|
||||
if t.Type != "function" {
|
||||
// Freeform tool forms (custom, local_shell) become function
|
||||
// tools with a permissive schema so upstreams that only
|
||||
// speak function calling still work.
|
||||
params = json.RawMessage(`{"type":"object","additionalProperties":true}`)
|
||||
}
|
||||
out.Tools = append(out.Tools, chatcmpl.Tool{
|
||||
Type: "function",
|
||||
Function: chatcmpl.ToolFunction{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: params,
|
||||
},
|
||||
})
|
||||
}
|
||||
choice, err := convertToolChoice(req.ToolChoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.ToolChoice = choice
|
||||
if req.Stream {
|
||||
out.StreamOptions = &chatcmpl.StreamOptions{IncludeUsage: true}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isTextPart(p ContentPart) bool {
|
||||
switch p.Type {
|
||||
case "input_text", "output_text", "text", "refusal":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func userMessage(parts []ContentPart) chatcmpl.Message {
|
||||
var cps []chatcmpl.ContentPart
|
||||
for _, p := range parts {
|
||||
switch {
|
||||
case p.Type == "input_image" && p.ImageURL != "":
|
||||
cps = append(cps, chatcmpl.ContentPart{Type: "image_url", ImageURL: &chatcmpl.ImageURL{URL: p.ImageURL}})
|
||||
case isTextPart(p) && p.Text != "":
|
||||
cps = append(cps, chatcmpl.ContentPart{Type: "text", Text: p.Text})
|
||||
}
|
||||
}
|
||||
if len(cps) == 0 {
|
||||
return chatcmpl.Message{Role: "user", Content: ""}
|
||||
}
|
||||
if len(cps) == 1 && cps[0].Type == "text" {
|
||||
return chatcmpl.Message{Role: "user", Content: cps[0].Text}
|
||||
}
|
||||
return chatcmpl.Message{Role: "user", Content: cps}
|
||||
}
|
||||
|
||||
func convertToolChoice(raw json.RawMessage) (any, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
switch s {
|
||||
case "auto", "none", "required":
|
||||
return s, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
var obj struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(raw, &obj) == nil && obj.Type == "function" && obj.Name != "" {
|
||||
return map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{"name": obj.Name},
|
||||
}, nil
|
||||
}
|
||||
// Unknown tool choice shape: let the upstream decide.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ConvertResponse converts a Chat Completions response into a Responses
|
||||
// response. requestedModel is echoed back to the client.
|
||||
func ConvertResponse(resp *chatcmpl.Response, requestedModel string) *Response {
|
||||
out := &Response{
|
||||
ID: "resp_" + resp.ID,
|
||||
Object: "response",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Status: "completed",
|
||||
Model: requestedModel,
|
||||
Output: []OutItem{},
|
||||
}
|
||||
var texts []string
|
||||
if len(resp.Choices) > 0 {
|
||||
c := resp.Choices[0]
|
||||
if txt := messageText(c.Message.Content); txt != "" {
|
||||
texts = append(texts, txt)
|
||||
out.Output = append(out.Output, OutItem{
|
||||
ID: "msg_" + randHex(12),
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Status: "completed",
|
||||
Content: []ContentPart{{Type: "output_text", Text: txt}},
|
||||
})
|
||||
}
|
||||
for _, tc := range c.Message.ToolCalls {
|
||||
args := tc.Function.Arguments
|
||||
if strings.TrimSpace(args) == "" {
|
||||
args = "{}"
|
||||
}
|
||||
out.Output = append(out.Output, OutItem{
|
||||
ID: "fc_" + randHex(12),
|
||||
Type: "function_call",
|
||||
Status: "completed",
|
||||
CallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: args,
|
||||
})
|
||||
}
|
||||
out.Status, out.IncompleteDetails = MapStatus(c.FinishReason)
|
||||
}
|
||||
out.OutputText = strings.Join(texts, "\n\n")
|
||||
if resp.Usage != nil {
|
||||
out.Usage = &Usage{
|
||||
InputTokens: resp.Usage.PromptTokens,
|
||||
OutputTokens: resp.Usage.CompletionTokens,
|
||||
TotalTokens: resp.Usage.TotalTokens,
|
||||
}
|
||||
if out.Usage.TotalTokens == 0 {
|
||||
out.Usage.TotalTokens = out.Usage.InputTokens + out.Usage.OutputTokens
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func messageText(content any) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []chatcmpl.ContentPart:
|
||||
var sb strings.Builder
|
||||
for _, p := range v {
|
||||
if p.Type == "text" {
|
||||
sb.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// MapStatus maps a Chat Completions finish_reason onto a Responses
|
||||
// status plus incomplete reason.
|
||||
func MapStatus(finish string) (string, *Incomplete) {
|
||||
switch finish {
|
||||
case "length":
|
||||
return "incomplete", &Incomplete{Reason: "max_output_tokens"}
|
||||
case "content_filter":
|
||||
return "incomplete", &Incomplete{Reason: "content_filter"}
|
||||
default:
|
||||
return "completed", nil
|
||||
}
|
||||
}
|
||||
|
||||
// EstimateTokens returns a rough input-token estimate for a converted
|
||||
// request. It fills in usage when the upstream stream carries none.
|
||||
func EstimateTokens(req *chatcmpl.Request) int {
|
||||
total := len(req.Model) / 4
|
||||
for _, m := range req.Messages {
|
||||
total += len(m.Role) + 4
|
||||
total += len(messageText(m.Content)) / 4
|
||||
for _, tc := range m.ToolCalls {
|
||||
total += len(tc.Function.Name)/4 + len(tc.Function.Arguments)/4
|
||||
}
|
||||
}
|
||||
for _, t := range req.Tools {
|
||||
total += len(t.Function.Name)/4 + len(t.Function.Description)/4 + len(t.Function.Parameters)/4
|
||||
}
|
||||
if total < 1 {
|
||||
total = 1
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%x", n)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
|
||||
)
|
||||
|
||||
func normalizeJSON(t *testing.T, s string) string {
|
||||
t.Helper()
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
t.Fatalf("want is not JSON: %v (%s)", err, s)
|
||||
}
|
||||
out, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("re-marshal want: %v", err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestConvertRequest(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
req string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "instructions become the system message",
|
||||
req: `{
|
||||
"model": "gpt-5-codex",
|
||||
"instructions": "You are Codex.",
|
||||
"input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]}]
|
||||
}`,
|
||||
want: `{
|
||||
"model": "up-model",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are Codex."},
|
||||
{"role": "user", "content": "hi"}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "input as a plain string",
|
||||
req: `{"model": "m", "input": "hello"}`,
|
||||
want: `{"model": "up-model", "messages": [{"role": "user", "content": "hello"}]}`,
|
||||
},
|
||||
{
|
||||
name: "system role input merges into instructions",
|
||||
req: `{
|
||||
"instructions": "base",
|
||||
"input": [{"type": "message", "role": "system", "content": [{"type": "input_text", "text": "extra"}]}]
|
||||
}`,
|
||||
want: `{"model": "up-model", "messages": [{"role": "system", "content": "base\n\nextra"}]}`,
|
||||
},
|
||||
{
|
||||
name: "image part becomes an image_url part",
|
||||
req: `{
|
||||
"input": [{"type": "message", "role": "user", "content": [
|
||||
{"type": "input_image", "image_url": "https://example.com/a.png"},
|
||||
{"type": "input_text", "text": "look"}
|
||||
]}]
|
||||
}`,
|
||||
want: `{
|
||||
"model": "up-model",
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
|
||||
{"type": "text", "text": "look"}
|
||||
]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "function_call and function_call_output round trip",
|
||||
req: `{
|
||||
"input": [
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "read a.txt"}]},
|
||||
{"type": "function_call", "call_id": "call_1", "name": "read_file", "arguments": "{\"path\":\"a.txt\"}"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": "file body"},
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done"}]}
|
||||
]
|
||||
}`,
|
||||
want: `{
|
||||
"model": "up-model",
|
||||
"messages": [
|
||||
{"role": "user", "content": "read a.txt"},
|
||||
{"role": "assistant", "content": null, "tool_calls": [
|
||||
{"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"}}
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "file body"},
|
||||
{"role": "assistant", "content": "done"}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "consecutive assistant items merge into one message",
|
||||
req: `{
|
||||
"input": [
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "part1"}]},
|
||||
{"type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}"},
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "part2"}]},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "next"}]},
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "after"}]}
|
||||
]
|
||||
}`,
|
||||
want: `{
|
||||
"model": "up-model",
|
||||
"messages": [
|
||||
{"role": "assistant", "content": "part1\n\npart2", "tool_calls": [
|
||||
{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
|
||||
]},
|
||||
{"role": "user", "content": "next"},
|
||||
{"role": "assistant", "content": "after"}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "reasoning items are dropped",
|
||||
req: `{
|
||||
"input": [
|
||||
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "thinking"}]},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]}
|
||||
]
|
||||
}`,
|
||||
want: `{"model": "up-model", "messages": [{"role": "user", "content": "hi"}]}`,
|
||||
},
|
||||
{
|
||||
name: "function_call_output with array output",
|
||||
req: `{
|
||||
"input": [
|
||||
{"type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "c1", "output": [{"type": "output_text", "text": "line1"}, {"type": "output_text", "text": "line2"}]}
|
||||
]
|
||||
}`,
|
||||
want: `{
|
||||
"model": "up-model",
|
||||
"messages": [
|
||||
{"role": "assistant", "content": null, "tool_calls": [
|
||||
{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "line1line2"}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "custom tools degrade to permissive function tools",
|
||||
req: `{
|
||||
"input": "go",
|
||||
"tools": [
|
||||
{"type": "custom", "name": "shell", "description": "run a command"},
|
||||
{"type": "local_shell", "name": "local"}
|
||||
]
|
||||
}`,
|
||||
want: `{
|
||||
"model": "up-model",
|
||||
"messages": [{"role": "user", "content": "go"}],
|
||||
"tools": [
|
||||
{"type": "function", "function": {"name": "shell", "description": "run a command", "parameters": {"type": "object", "additionalProperties": true}}},
|
||||
{"type": "function", "function": {"name": "local", "parameters": {"type": "object", "additionalProperties": true}}}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "function tools pass through with their schema",
|
||||
req: `{
|
||||
"input": "go",
|
||||
"tools": [{"type": "function", "name": "f", "parameters": {"type": "object", "properties": {"a": {"type": "string"}}}}]
|
||||
}`,
|
||||
want: `{
|
||||
"model": "up-model",
|
||||
"messages": [{"role": "user", "content": "go"}],
|
||||
"tools": [{"type": "function", "function": {"name": "f", "parameters": {"type": "object", "properties": {"a": {"type": "string"}}}}}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "streaming asks the upstream for a usage chunk",
|
||||
req: `{
|
||||
"model": "m", "input": "hi", "stream": true,
|
||||
"max_output_tokens": 512, "temperature": 0.5, "top_p": 0.9,
|
||||
"store": false, "include": ["reasoning.encrypted_content"],
|
||||
"prompt_cache_key": "k", "parallel_tool_calls": true,
|
||||
"reasoning": {"effort": "high"}, "metadata": {"a": "b"}, "text": {"format": "json"}
|
||||
}`,
|
||||
want: `{
|
||||
"model": "up-model",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"max_tokens": 512, "temperature": 0.5, "top_p": 0.9,
|
||||
"stream": true, "stream_options": {"include_usage": true}
|
||||
}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var req Request
|
||||
if err := json.Unmarshal([]byte(tc.req), &req); err != nil {
|
||||
t.Fatalf("unmarshal request: %v", err)
|
||||
}
|
||||
got, err := ConvertRequest(&req, "up-model")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertRequest: %v", err)
|
||||
}
|
||||
gotJSON, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal result: %v", err)
|
||||
}
|
||||
if want, gotNorm := normalizeJSON(t, tc.want), normalizeJSON(t, string(gotJSON)); gotNorm != want {
|
||||
t.Errorf("converted request mismatch\n got: %s\nwant: %s", gotJSON, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToolChoice(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{`"auto"`, `"auto"`},
|
||||
{`"none"`, `"none"`},
|
||||
{`"required"`, `"required"`},
|
||||
{`{"type": "function", "name": "f"}`, `{"type": "function", "function": {"name": "f"}}`},
|
||||
{`{"type": "allowed_tools", "tools": []}`, `null`},
|
||||
{`"weird"`, `null`},
|
||||
{``, `null`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
var req Request
|
||||
if tc.raw != "" {
|
||||
if err := json.Unmarshal([]byte(`{"tool_choice": `+tc.raw+`}`), &req); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", tc.raw, err)
|
||||
}
|
||||
}
|
||||
got, err := convertToolChoice(req.ToolChoice)
|
||||
if err != nil {
|
||||
t.Fatalf("convertToolChoice(%s): %v", tc.raw, err)
|
||||
}
|
||||
gotJSON, _ := json.Marshal(got)
|
||||
if want := normalizeJSON(t, tc.want); string(gotJSON) != want {
|
||||
t.Errorf("tool_choice %s -> %s, want %s", tc.raw, gotJSON, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
finish string
|
||||
wantStatus string
|
||||
wantReason string
|
||||
}{
|
||||
{"stop", "completed", ""},
|
||||
{"tool_calls", "completed", ""},
|
||||
{"length", "incomplete", "max_output_tokens"},
|
||||
{"content_filter", "incomplete", "content_filter"},
|
||||
{"", "completed", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
status, incomplete := MapStatus(tc.finish)
|
||||
if status != tc.wantStatus {
|
||||
t.Errorf("MapStatus(%q) status = %q, want %q", tc.finish, status, tc.wantStatus)
|
||||
}
|
||||
if tc.wantReason == "" {
|
||||
if incomplete != nil {
|
||||
t.Errorf("MapStatus(%q) incomplete = %v, want nil", tc.finish, incomplete)
|
||||
}
|
||||
} else if incomplete == nil || incomplete.Reason != tc.wantReason {
|
||||
t.Errorf("MapStatus(%q) incomplete = %v, want reason %q", tc.finish, incomplete, tc.wantReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertResponseText(t *testing.T) {
|
||||
resp := chatResponse("stop", "hello", nil)
|
||||
out := ConvertResponse(resp, "gpt-5-codex")
|
||||
if out.ID != "resp_chatcmpl-1" || out.Object != "response" {
|
||||
t.Errorf("envelope = %+v", out)
|
||||
}
|
||||
if out.Status != "completed" || out.IncompleteDetails != nil {
|
||||
t.Errorf("status = %q incomplete = %v", out.Status, out.IncompleteDetails)
|
||||
}
|
||||
if out.Model != "gpt-5-codex" {
|
||||
t.Errorf("model = %q, want the requested model echoed", out.Model)
|
||||
}
|
||||
if len(out.Output) != 1 || out.Output[0].Type != "message" {
|
||||
t.Fatalf("output = %+v", out.Output)
|
||||
}
|
||||
msg := out.Output[0]
|
||||
if msg.Role != "assistant" || len(msg.Content) != 1 || msg.Content[0].Text != "hello" {
|
||||
t.Errorf("message item = %+v", msg)
|
||||
}
|
||||
if out.OutputText != "hello" {
|
||||
t.Errorf("output_text = %q", out.OutputText)
|
||||
}
|
||||
if out.Usage == nil || out.Usage.InputTokens != 9 || out.Usage.OutputTokens != 2 || out.Usage.TotalTokens != 11 {
|
||||
t.Errorf("usage = %+v", out.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertResponseToolCalls(t *testing.T) {
|
||||
resp := chatResponse("tool_calls", "", []chatcmpl.ToolCall{
|
||||
{ID: "call_1", Type: "function", Function: chatcmpl.ToolCallFunction{Name: "shell", Arguments: `{"cmd":"ls"}`}},
|
||||
})
|
||||
out := ConvertResponse(resp, "m")
|
||||
if len(out.Output) != 1 {
|
||||
t.Fatalf("output = %+v", out.Output)
|
||||
}
|
||||
call := out.Output[0]
|
||||
if call.Type != "function_call" || call.CallID != "call_1" || call.Name != "shell" || call.Arguments != `{"cmd":"ls"}` {
|
||||
t.Errorf("function_call item = %+v", call)
|
||||
}
|
||||
if out.Status != "completed" {
|
||||
t.Errorf("status = %q", out.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertResponseIncomplete(t *testing.T) {
|
||||
out := ConvertResponse(chatResponse("length", "cut off", nil), "m")
|
||||
if out.Status != "incomplete" {
|
||||
t.Errorf("status = %q, want incomplete", out.Status)
|
||||
}
|
||||
if out.IncompleteDetails == nil || out.IncompleteDetails.Reason != "max_output_tokens" {
|
||||
t.Errorf("incomplete_details = %+v", out.IncompleteDetails)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateTokens(t *testing.T) {
|
||||
var req Request
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"instructions": "be helpful",
|
||||
"input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello there"}]}],
|
||||
"tools": [{"type": "function", "name": "f", "parameters": {"type": "object"}}]
|
||||
}`), &req); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
chatReq, err := ConvertRequest(&req, "m")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertRequest: %v", err)
|
||||
}
|
||||
if got := EstimateTokens(chatReq); got <= 0 {
|
||||
t.Errorf("EstimateTokens = %d, want positive", got)
|
||||
}
|
||||
}
|
||||
|
||||
func chatResponse(finish, text string, calls []chatcmpl.ToolCall) *chatcmpl.Response {
|
||||
return &chatcmpl.Response{
|
||||
ID: "chatcmpl-1",
|
||||
Model: "up-model",
|
||||
Choices: []chatcmpl.Choice{{
|
||||
Index: 0,
|
||||
Message: chatcmpl.Message{Role: "assistant", Content: text, ToolCalls: calls},
|
||||
FinishReason: finish,
|
||||
}},
|
||||
Usage: &chatcmpl.Usage{PromptTokens: 9, CompletionTokens: 2, TotalTokens: 11},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
|
||||
)
|
||||
|
||||
// StreamConverter synthesizes Responses SSE events from Chat Completions
|
||||
// stream chunks.
|
||||
//
|
||||
// Output items are tracked by their upstream origin: one message item
|
||||
// (opened on the first text delta) and one function_call item per
|
||||
// upstream tool-call index, in order of first appearance. Finish closes
|
||||
// every open item and emits response.completed with usage.
|
||||
type StreamConverter struct {
|
||||
w http.ResponseWriter
|
||||
flusher http.Flusher
|
||||
model string
|
||||
respID string
|
||||
|
||||
started bool
|
||||
seq int
|
||||
nextOut int
|
||||
|
||||
msgOpen bool
|
||||
msgIndex int
|
||||
msgID string
|
||||
msgText strings.Builder
|
||||
|
||||
tools map[int]*toolItem
|
||||
toolOrder []int
|
||||
|
||||
inputEstimate int
|
||||
outputChars int
|
||||
usage *chatcmpl.Usage
|
||||
finishReason string
|
||||
}
|
||||
|
||||
type toolItem struct {
|
||||
outputIndex int
|
||||
id string
|
||||
callID string
|
||||
name string
|
||||
started bool
|
||||
pending string
|
||||
args strings.Builder
|
||||
}
|
||||
|
||||
// NewStreamConverter builds a converter writing to w. model is echoed
|
||||
// in every event's response object; inputTokens seeds the usage shown
|
||||
// when the upstream stream carries none.
|
||||
func NewStreamConverter(w http.ResponseWriter, model string, inputTokens int) *StreamConverter {
|
||||
return &StreamConverter{
|
||||
w: w,
|
||||
flusher: flusherOf(w),
|
||||
model: model,
|
||||
respID: "resp_" + randHex(16),
|
||||
tools: map[int]*toolItem{},
|
||||
inputEstimate: inputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteStart sends the SSE headers and the opening response events.
|
||||
func (s *StreamConverter) WriteStart() error {
|
||||
h := s.w.Header()
|
||||
h.Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
s.w.WriteHeader(http.StatusOK)
|
||||
s.started = true
|
||||
initial := map[string]any{
|
||||
"id": s.respID,
|
||||
"object": "response",
|
||||
"created_at": time.Now().Unix(),
|
||||
"status": "in_progress",
|
||||
"model": s.model,
|
||||
"output": []any{},
|
||||
}
|
||||
if err := s.emit("response.created", map[string]any{"response": initial}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.emit("response.in_progress", map[string]any{"response": initial})
|
||||
}
|
||||
|
||||
// OnChunk folds one upstream chunk into the Responses event stream.
|
||||
func (s *StreamConverter) OnChunk(chunk *chatcmpl.StreamChunk) error {
|
||||
if chunk == nil {
|
||||
return nil
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
u := *chunk.Usage
|
||||
s.usage = &u
|
||||
}
|
||||
for _, choice := range chunk.Choices {
|
||||
if choice.Delta.Content != nil && *choice.Delta.Content != "" {
|
||||
if err := s.writeText(*choice.Delta.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, tc := range choice.Delta.ToolCalls {
|
||||
if err := s.toolDelta(tc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if choice.FinishReason != nil && *choice.FinishReason != "" {
|
||||
s.finishReason = *choice.FinishReason
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StreamConverter) writeText(text string) error {
|
||||
if !s.msgOpen {
|
||||
s.msgIndex = s.nextOut
|
||||
s.nextOut++
|
||||
s.msgOpen = true
|
||||
s.msgID = "msg_" + randHex(12)
|
||||
if err := s.emit("response.output_item.added", map[string]any{
|
||||
"output_index": s.msgIndex,
|
||||
"item": map[string]any{
|
||||
"id": s.msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "in_progress",
|
||||
"content": []any{},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.emit("response.content_part.added", map[string]any{
|
||||
"item_id": s.msgID,
|
||||
"output_index": s.msgIndex,
|
||||
"content_index": 0,
|
||||
"part": map[string]any{"type": "output_text", "text": ""},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.msgText.WriteString(text)
|
||||
s.outputChars += len(text)
|
||||
return s.emit("response.output_text.delta", map[string]any{
|
||||
"item_id": s.msgID,
|
||||
"output_index": s.msgIndex,
|
||||
"content_index": 0,
|
||||
"delta": text,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StreamConverter) toolDelta(tc chatcmpl.ToolCall) error {
|
||||
st := s.tools[tc.Index]
|
||||
if st == nil {
|
||||
st = &toolItem{}
|
||||
s.tools[tc.Index] = st
|
||||
s.toolOrder = append(s.toolOrder, tc.Index)
|
||||
}
|
||||
if tc.ID != "" && st.callID == "" {
|
||||
st.callID = tc.ID
|
||||
}
|
||||
if tc.Function.Name != "" && st.name == "" {
|
||||
st.name = tc.Function.Name
|
||||
}
|
||||
if tc.Function.Arguments != "" {
|
||||
st.pending += tc.Function.Arguments
|
||||
}
|
||||
// Open the item once we know a name or have arguments to flush, so
|
||||
// the client never sees an unnamed function_call.
|
||||
if !st.started && (st.name != "" || st.pending != "") {
|
||||
st.outputIndex = s.nextOut
|
||||
s.nextOut++
|
||||
st.started = true
|
||||
st.id = "fc_" + randHex(12)
|
||||
if st.callID == "" {
|
||||
st.callID = fmt.Sprintf("call_%s_%d", s.respID[len("resp_"):], tc.Index)
|
||||
}
|
||||
if err := s.emit("response.output_item.added", map[string]any{
|
||||
"output_index": st.outputIndex,
|
||||
"item": map[string]any{
|
||||
"id": st.id,
|
||||
"type": "function_call",
|
||||
"status": "in_progress",
|
||||
"call_id": st.callID,
|
||||
"name": st.name,
|
||||
"arguments": "",
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.outputChars += len(st.name)
|
||||
}
|
||||
if st.started && st.pending != "" {
|
||||
s.outputChars += len(st.pending)
|
||||
st.args.WriteString(st.pending)
|
||||
pending := st.pending
|
||||
st.pending = ""
|
||||
return s.emit("response.function_call_arguments.delta", map[string]any{
|
||||
"item_id": st.id,
|
||||
"output_index": st.outputIndex,
|
||||
"delta": pending,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finish closes every open item and ends the response.
|
||||
func (s *StreamConverter) Finish() error {
|
||||
if s.msgOpen {
|
||||
text := s.msgText.String()
|
||||
if err := s.emit("response.output_text.done", map[string]any{
|
||||
"item_id": s.msgID,
|
||||
"output_index": s.msgIndex,
|
||||
"content_index": 0,
|
||||
"text": text,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.emit("response.content_part.done", map[string]any{
|
||||
"item_id": s.msgID,
|
||||
"output_index": s.msgIndex,
|
||||
"content_index": 0,
|
||||
"part": map[string]any{"type": "output_text", "text": text},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.emit("response.output_item.done", map[string]any{
|
||||
"output_index": s.msgIndex,
|
||||
"item": map[string]any{
|
||||
"id": s.msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": []any{map[string]any{"type": "output_text", "text": text}},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.msgOpen = false
|
||||
}
|
||||
for _, idx := range s.toolOrder {
|
||||
st := s.tools[idx]
|
||||
if st == nil || !st.started {
|
||||
continue
|
||||
}
|
||||
if err := s.emit("response.function_call_arguments.done", map[string]any{
|
||||
"item_id": st.id,
|
||||
"output_index": st.outputIndex,
|
||||
"arguments": st.args.String(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.emit("response.output_item.done", map[string]any{
|
||||
"output_index": st.outputIndex,
|
||||
"item": map[string]any{
|
||||
"id": st.id,
|
||||
"type": "function_call",
|
||||
"status": "completed",
|
||||
"call_id": st.callID,
|
||||
"name": st.name,
|
||||
"arguments": st.args.String(),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.emit("response.completed", map[string]any{"response": s.finalResponse()})
|
||||
}
|
||||
|
||||
// WriteError terminates the stream with a response.failed event.
|
||||
func (s *StreamConverter) WriteError(code, message string) {
|
||||
_ = s.emit("response.failed", map[string]any{
|
||||
"response": map[string]any{
|
||||
"id": s.respID,
|
||||
"object": "response",
|
||||
"created_at": time.Now().Unix(),
|
||||
"status": "failed",
|
||||
"model": s.model,
|
||||
"output": []any{},
|
||||
"error": map[string]any{"code": code, "message": message},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StreamConverter) finalResponse() map[string]any {
|
||||
status, incomplete := MapStatus(s.finishReason)
|
||||
output := []any{}
|
||||
text := s.msgText.String()
|
||||
if text != "" {
|
||||
output = append(output, map[string]any{
|
||||
"id": s.msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": []any{map[string]any{"type": "output_text", "text": text}},
|
||||
})
|
||||
}
|
||||
for _, idx := range s.toolOrder {
|
||||
st := s.tools[idx]
|
||||
if st == nil || !st.started {
|
||||
continue
|
||||
}
|
||||
output = append(output, map[string]any{
|
||||
"id": st.id,
|
||||
"type": "function_call",
|
||||
"status": "completed",
|
||||
"call_id": st.callID,
|
||||
"name": st.name,
|
||||
"arguments": st.args.String(),
|
||||
})
|
||||
}
|
||||
resp := map[string]any{
|
||||
"id": s.respID,
|
||||
"object": "response",
|
||||
"created_at": time.Now().Unix(),
|
||||
"status": status,
|
||||
"model": s.model,
|
||||
"output": output,
|
||||
}
|
||||
if text != "" {
|
||||
resp["output_text"] = text
|
||||
}
|
||||
if incomplete != nil {
|
||||
resp["incomplete_details"] = incomplete
|
||||
}
|
||||
resp["usage"] = s.usageMap()
|
||||
return resp
|
||||
}
|
||||
|
||||
func (s *StreamConverter) usageMap() map[string]any {
|
||||
if s.usage != nil {
|
||||
total := s.usage.TotalTokens
|
||||
if total == 0 {
|
||||
total = s.usage.PromptTokens + s.usage.CompletionTokens
|
||||
}
|
||||
return map[string]any{
|
||||
"input_tokens": s.usage.PromptTokens,
|
||||
"output_tokens": s.usage.CompletionTokens,
|
||||
"total_tokens": total,
|
||||
}
|
||||
}
|
||||
out := (s.outputChars + 3) / 4
|
||||
return map[string]any{
|
||||
"input_tokens": s.inputEstimate,
|
||||
"output_tokens": out,
|
||||
"total_tokens": s.inputEstimate + out,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StreamConverter) emit(event string, payload map[string]any) error {
|
||||
s.seq++
|
||||
payload["type"] = event
|
||||
payload["sequence_number"] = s.seq
|
||||
return writeEvent(s.w, s.flusher, event, payload)
|
||||
}
|
||||
|
||||
func writeEvent(w http.ResponseWriter, flusher http.Flusher, event string, payload any) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("event: ")
|
||||
sb.WriteString(event)
|
||||
sb.WriteString("\ndata: ")
|
||||
sb.Write(data)
|
||||
sb.WriteString("\n\n")
|
||||
if _, err := io.WriteString(w, sb.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flusherOf(w http.ResponseWriter) http.Flusher {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
return f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
|
||||
)
|
||||
|
||||
// captureWriter records everything the converter writes.
|
||||
type captureWriter struct {
|
||||
header http.Header
|
||||
body bytes.Buffer
|
||||
status int
|
||||
flushes int
|
||||
}
|
||||
|
||||
func newCaptureWriter() *captureWriter {
|
||||
return &captureWriter{header: http.Header{}}
|
||||
}
|
||||
|
||||
func (c *captureWriter) Header() http.Header { return c.header }
|
||||
func (c *captureWriter) Write(b []byte) (int, error) { return c.body.Write(b) }
|
||||
func (c *captureWriter) WriteHeader(status int) { c.status = status }
|
||||
func (c *captureWriter) Flush() { c.flushes++ }
|
||||
|
||||
type sseEvent struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
func parseSSE(t *testing.T, data string) []sseEvent {
|
||||
t.Helper()
|
||||
var events []sseEvent
|
||||
for _, block := range strings.Split(strings.TrimSpace(data), "\n\n") {
|
||||
if block == "" {
|
||||
continue
|
||||
}
|
||||
var name string
|
||||
var payload string
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event: "):
|
||||
name = strings.TrimPrefix(line, "event: ")
|
||||
case strings.HasPrefix(line, "data: "):
|
||||
payload = strings.TrimPrefix(line, "data: ")
|
||||
}
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
|
||||
t.Fatalf("event %s payload is not JSON: %v (%s)", name, err, payload)
|
||||
}
|
||||
events = append(events, sseEvent{name: name, data: parsed})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func eventNames(events []sseEvent) []string {
|
||||
names := make([]string, len(events))
|
||||
for i, e := range events {
|
||||
names[i] = e.name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func textChunk(text string) *chatcmpl.StreamChunk {
|
||||
return &chatcmpl.StreamChunk{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{Content: &text}}}}
|
||||
}
|
||||
|
||||
func finishChunk(reason string, usage *chatcmpl.Usage) *chatcmpl.StreamChunk {
|
||||
return &chatcmpl.StreamChunk{
|
||||
Choices: []chatcmpl.ChunkChoice{{FinishReason: &reason}},
|
||||
Usage: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func runConverter(t *testing.T, chunks []*chatcmpl.StreamChunk) []sseEvent {
|
||||
t.Helper()
|
||||
w := newCaptureWriter()
|
||||
conv := NewStreamConverter(w, "gpt-5-codex", 42)
|
||||
if err := conv.WriteStart(); err != nil {
|
||||
t.Fatalf("WriteStart: %v", err)
|
||||
}
|
||||
for _, c := range chunks {
|
||||
if err := conv.OnChunk(c); err != nil {
|
||||
t.Fatalf("OnChunk: %v", err)
|
||||
}
|
||||
}
|
||||
if err := conv.Finish(); err != nil {
|
||||
t.Fatalf("Finish: %v", err)
|
||||
}
|
||||
if w.status != http.StatusOK {
|
||||
t.Errorf("status = %d", w.status)
|
||||
}
|
||||
if ct := w.header.Get("Content-Type"); ct != "text/event-stream; charset=utf-8" {
|
||||
t.Errorf("content type = %q", ct)
|
||||
}
|
||||
return parseSSE(t, w.body.String())
|
||||
}
|
||||
|
||||
func TestStreamTextOnly(t *testing.T) {
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{
|
||||
textChunk("Hello"),
|
||||
textChunk(", world"),
|
||||
finishChunk("stop", &chatcmpl.Usage{PromptTokens: 10, CompletionTokens: 3}),
|
||||
})
|
||||
wantNames := []string{
|
||||
"response.created",
|
||||
"response.in_progress",
|
||||
"response.output_item.added",
|
||||
"response.content_part.added",
|
||||
"response.output_text.delta",
|
||||
"response.output_text.delta",
|
||||
"response.output_text.done",
|
||||
"response.content_part.done",
|
||||
"response.output_item.done",
|
||||
"response.completed",
|
||||
}
|
||||
if got := eventNames(events); !equalStrings(got, wantNames) {
|
||||
t.Fatalf("events = %v, want %v", got, wantNames)
|
||||
}
|
||||
for i, e := range events {
|
||||
if seq, ok := e.data["sequence_number"].(float64); !ok || int(seq) != i+1 {
|
||||
t.Errorf("event %d (%s) sequence_number = %v, want %d", i, e.name, e.data["sequence_number"], i+1)
|
||||
}
|
||||
}
|
||||
created := events[0].data["response"].(map[string]any)
|
||||
if created["status"] != "in_progress" {
|
||||
t.Errorf("created status = %v", created["status"])
|
||||
}
|
||||
if created["model"] != "gpt-5-codex" {
|
||||
t.Errorf("created model = %v", created["model"])
|
||||
}
|
||||
added := events[2].data["item"].(map[string]any)
|
||||
if added["type"] != "message" || added["role"] != "assistant" {
|
||||
t.Errorf("added item = %v", added)
|
||||
}
|
||||
if events[4].data["delta"] != "Hello" || events[5].data["delta"] != ", world" {
|
||||
t.Errorf("deltas = %v, %v", events[4].data["delta"], events[5].data["delta"])
|
||||
}
|
||||
completed := events[9].data["response"].(map[string]any)
|
||||
if completed["status"] != "completed" {
|
||||
t.Errorf("completed status = %v", completed["status"])
|
||||
}
|
||||
if completed["output_text"] != "Hello, world" {
|
||||
t.Errorf("completed output_text = %v", completed["output_text"])
|
||||
}
|
||||
usage := completed["usage"].(map[string]any)
|
||||
if usage["input_tokens"].(float64) != 10 || usage["output_tokens"].(float64) != 3 {
|
||||
t.Errorf("completed usage = %v", usage)
|
||||
}
|
||||
output := completed["output"].([]any)
|
||||
if len(output) != 1 {
|
||||
t.Fatalf("completed output = %v", output)
|
||||
}
|
||||
msg := output[0].(map[string]any)
|
||||
content := msg["content"].([]any)
|
||||
part := content[0].(map[string]any)
|
||||
if part["type"] != "output_text" || part["text"] != "Hello, world" {
|
||||
t.Errorf("completed message content = %v", part)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToolCall(t *testing.T) {
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 0, ID: "call_1", Type: "function",
|
||||
Function: chatcmpl.ToolCallFunction{Name: "read_file"},
|
||||
}}}}}},
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 0, Function: chatcmpl.ToolCallFunction{Arguments: `{"path":`},
|
||||
}}}}}},
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 0, Function: chatcmpl.ToolCallFunction{Arguments: `"a.txt"}`},
|
||||
}}}}}},
|
||||
finishChunk("tool_calls", &chatcmpl.Usage{PromptTokens: 7, CompletionTokens: 5}),
|
||||
})
|
||||
wantNames := []string{
|
||||
"response.created",
|
||||
"response.in_progress",
|
||||
"response.output_item.added",
|
||||
"response.function_call_arguments.delta",
|
||||
"response.function_call_arguments.delta",
|
||||
"response.function_call_arguments.done",
|
||||
"response.output_item.done",
|
||||
"response.completed",
|
||||
}
|
||||
if got := eventNames(events); !equalStrings(got, wantNames) {
|
||||
t.Fatalf("events = %v, want %v", got, wantNames)
|
||||
}
|
||||
added := events[2].data["item"].(map[string]any)
|
||||
if added["type"] != "function_call" || added["call_id"] != "call_1" || added["name"] != "read_file" {
|
||||
t.Errorf("added item = %v", added)
|
||||
}
|
||||
if events[3].data["delta"] != `{"path":` || events[4].data["delta"] != `"a.txt"}` {
|
||||
t.Errorf("argument deltas = %v, %v", events[3].data["delta"], events[4].data["delta"])
|
||||
}
|
||||
if events[5].data["arguments"] != `{"path":"a.txt"}` {
|
||||
t.Errorf("arguments done = %v", events[5].data["arguments"])
|
||||
}
|
||||
for _, e := range events[2:7] {
|
||||
if e.data["output_index"].(float64) != 0 {
|
||||
t.Errorf("%s output_index = %v, want 0", e.name, e.data["output_index"])
|
||||
}
|
||||
}
|
||||
completed := events[7].data["response"].(map[string]any)
|
||||
output := completed["output"].([]any)
|
||||
call := output[0].(map[string]any)
|
||||
if call["call_id"] != "call_1" || call["arguments"] != `{"path":"a.txt"}` {
|
||||
t.Errorf("completed function_call = %v", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParallelToolCalls(t *testing.T) {
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 0, ID: "call_a", Type: "function",
|
||||
Function: chatcmpl.ToolCallFunction{Name: "tool_a"},
|
||||
}}}}}},
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 1, ID: "call_b", Type: "function",
|
||||
Function: chatcmpl.ToolCallFunction{Name: "tool_b", Arguments: `{}`},
|
||||
}}}}}},
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 0, Function: chatcmpl.ToolCallFunction{Arguments: `{"x":1}`},
|
||||
}}}}}},
|
||||
finishChunk("tool_calls", nil),
|
||||
})
|
||||
names := map[float64]string{}
|
||||
args := map[float64]string{}
|
||||
for _, e := range events {
|
||||
if e.name == "response.output_item.added" {
|
||||
idx := e.data["output_index"].(float64)
|
||||
names[idx] = e.data["item"].(map[string]any)["name"].(string)
|
||||
}
|
||||
if e.name == "response.function_call_arguments.delta" {
|
||||
idx := e.data["output_index"].(float64)
|
||||
args[idx] += e.data["delta"].(string)
|
||||
}
|
||||
}
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("output_item.added count = %d, want 2 (%v)", len(names), names)
|
||||
}
|
||||
if names[0] != "tool_a" || names[1] != "tool_b" {
|
||||
t.Errorf("tool names by output index = %v", names)
|
||||
}
|
||||
if args[0] != `{"x":1}` || args[1] != `{}` {
|
||||
t.Errorf("interleaved args = %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSynthesizesCallID(t *testing.T) {
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 0, Type: "function",
|
||||
Function: chatcmpl.ToolCallFunction{Name: "shell"},
|
||||
}}}}}},
|
||||
finishChunk("tool_calls", nil),
|
||||
})
|
||||
added := events[2].data["item"].(map[string]any)
|
||||
callID, _ := added["call_id"].(string)
|
||||
if !strings.HasPrefix(callID, "call_") {
|
||||
t.Errorf("synthesized call_id = %q, want a call_ prefix", callID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamLengthFinishIsIncomplete(t *testing.T) {
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{
|
||||
textChunk("cut"),
|
||||
finishChunk("length", nil),
|
||||
})
|
||||
completed := events[len(events)-1]
|
||||
if completed.name != "response.completed" {
|
||||
t.Fatalf("last event = %s", completed.name)
|
||||
}
|
||||
resp := completed.data["response"].(map[string]any)
|
||||
if resp["status"] != "incomplete" {
|
||||
t.Errorf("status = %v, want incomplete", resp["status"])
|
||||
}
|
||||
details, ok := resp["incomplete_details"].(map[string]any)
|
||||
if !ok || details["reason"] != "max_output_tokens" {
|
||||
t.Errorf("incomplete_details = %v", resp["incomplete_details"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamEstimatedUsageWithoutUpstreamUsage(t *testing.T) {
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{
|
||||
textChunk("some text"),
|
||||
finishChunk("stop", nil),
|
||||
})
|
||||
completed := events[len(events)-1].data["response"].(map[string]any)
|
||||
usage := completed["usage"].(map[string]any)
|
||||
if usage["input_tokens"].(float64) != 42 {
|
||||
t.Errorf("input tokens = %v, want the 42 estimate", usage["input_tokens"])
|
||||
}
|
||||
if usage["output_tokens"].(float64) <= 0 {
|
||||
t.Errorf("output tokens = %v, want a positive estimate", usage["output_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamEmptyUpstream(t *testing.T) {
|
||||
events := runConverter(t, nil)
|
||||
want := []string{"response.created", "response.in_progress", "response.completed"}
|
||||
if got := eventNames(events); !equalStrings(got, want) {
|
||||
t.Fatalf("events = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamWriteError(t *testing.T) {
|
||||
w := newCaptureWriter()
|
||||
conv := NewStreamConverter(w, "gpt-5-codex", 1)
|
||||
if err := conv.WriteStart(); err != nil {
|
||||
t.Fatalf("WriteStart: %v", err)
|
||||
}
|
||||
conv.WriteError("api_error", "upstream exploded")
|
||||
events := parseSSE(t, w.body.String())
|
||||
last := events[len(events)-1]
|
||||
if last.name != "response.failed" {
|
||||
t.Fatalf("last event = %s, want response.failed", last.name)
|
||||
}
|
||||
resp := last.data["response"].(map[string]any)
|
||||
if resp["status"] != "failed" {
|
||||
t.Errorf("status = %v", resp["status"])
|
||||
}
|
||||
errObj := resp["error"].(map[string]any)
|
||||
if errObj["code"] != "api_error" || errObj["message"] != "upstream exploded" {
|
||||
t.Errorf("error = %v", errObj)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Package responses describes the OpenAI Responses API that Codex
|
||||
// speaks and converts it to and from Chat Completions.
|
||||
package responses
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Request is the Responses API request body. Only the fields CrossRelay
|
||||
// understands are modelled; everything else is ignored.
|
||||
type Request struct {
|
||||
Model string `json:"model"`
|
||||
Instructions string `json:"instructions,omitempty"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
// Tool is one tool definition. Type is "function" for regular tools;
|
||||
// "custom" and "local_shell" are freeform forms Codex may send.
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// Item is one entry of the input array. Only the fields belonging to
|
||||
// Type are populated.
|
||||
type Item struct {
|
||||
Type string `json:"type"` // message | function_call | function_call_output | reasoning
|
||||
Role string `json:"role,omitempty"`
|
||||
// message
|
||||
Content json.RawMessage `json:"content,omitempty"` // string or []ContentPart
|
||||
// function_call
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
// function_call_output
|
||||
Output json.RawMessage `json:"output,omitempty"`
|
||||
// reasoning
|
||||
Summary json.RawMessage `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// ContentPart is one part of a message item's content.
|
||||
type ContentPart struct {
|
||||
Type string `json:"type"` // input_text | output_text | input_image
|
||||
Text string `json:"text,omitempty"`
|
||||
// input_image
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
// Response is the Responses API response body.
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Status string `json:"status"`
|
||||
Model string `json:"model"`
|
||||
Output []OutItem `json:"output"`
|
||||
OutputText string `json:"output_text,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
IncompleteDetails *Incomplete `json:"incomplete_details,omitempty"`
|
||||
Error *RespError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// OutItem is one output entry: an assistant message or a function call.
|
||||
type OutItem struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"` // message | function_call
|
||||
Role string `json:"role,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Content []ContentPart `json:"content,omitempty"`
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// Usage reports token counts.
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// IncompleteDetails explains a non-completed response.
|
||||
type Incomplete struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// RespError is the error payload of a failed response.
|
||||
type RespError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ErrorResponse is the Responses API error envelope.
|
||||
type ErrorResponse struct {
|
||||
Error Detail `json:"error"`
|
||||
}
|
||||
|
||||
// Detail is the error payload.
|
||||
type Detail struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ParseInput decodes the input field, which is either a plain string
|
||||
// or an array of items.
|
||||
func ParseInput(raw json.RawMessage) ([]Item, error) {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || string(trimmed) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if trimmed[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(trimmed, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []Item{{Type: "message", Role: "user", Content: trimmed}}, nil
|
||||
}
|
||||
var items []Item
|
||||
if err := json.Unmarshal(trimmed, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ParseContent decodes a message item's content field into parts. A
|
||||
// plain string becomes a single text part.
|
||||
func ParseContent(raw json.RawMessage) ([]ContentPart, error) {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || string(trimmed) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if trimmed[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(trimmed, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []ContentPart{{Type: "input_text", Text: s}}, nil
|
||||
}
|
||||
var parts []ContentPart
|
||||
if err := json.Unmarshal(trimmed, &parts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
// OutputText flattens a function_call_output output field, which is
|
||||
// either a plain string or an array of text parts.
|
||||
func OutputText(raw json.RawMessage) string {
|
||||
parts, err := ParseContent(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, p := range parts {
|
||||
switch p.Type {
|
||||
case "output_text", "input_text", "text":
|
||||
sb.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -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 }) {
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/providers" element={<Providers />} />
|
||||
<Route path="/codex" element={<Codex />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
+19
-4
@@ -61,12 +61,27 @@ export const api = {
|
||||
request<Provider>(`/api/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
||||
deleteProvider: (id: string) =>
|
||||
request<{ deleted: string }>(`/api/providers/${id}`, { method: "DELETE" }),
|
||||
applyProvider: (id: string) =>
|
||||
request<ApplyResult>(`/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<ApplyResult>(`/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<Settings>("/api/settings"),
|
||||
updateSettings: (patch: Partial<Settings>) =>
|
||||
request<Settings>("/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;
|
||||
}
|
||||
+7
-6
@@ -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<typeof useQueryClient>) {
|
||||
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<typeof api.restore>[0]) => api.restore(input),
|
||||
onSuccess: () => invalidateAll(queryClient),
|
||||
});
|
||||
}
|
||||
|
||||
+40
-9
@@ -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<string, string>;
|
||||
env?: Record<string, string>;
|
||||
injection?: Injection;
|
||||
}
|
||||
|
||||
export interface Injection {
|
||||
model_provider: string;
|
||||
table: Record<string, string>;
|
||||
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])) };
|
||||
}
|
||||
@@ -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(<Codex />);
|
||||
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(<Codex />);
|
||||
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(<Codex />);
|
||||
await user.click(await screen.findByRole("button", { name: /还原官方配置|Restore official config/ }));
|
||||
await waitFor(() => expect(restore).toHaveBeenCalledWith({ target: "codex" }));
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string> | 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 <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
|
||||
if (isError) return <div className="text-sm text-red-600">{t("common.error")}</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("codex.title")}</h2>
|
||||
<Button variant="outline" size="sm" disabled={restore.isPending} onClick={restoreConfig}>
|
||||
{restore.isPending ? (
|
||||
<RotateCcw size={14} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Zap size={14} aria-hidden />
|
||||
)}
|
||||
{restore.isPending ? t("common.restoring") : t("codex.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("codex.status")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={active ? "success" : "danger"}>
|
||||
{active ? t("codex.injected") : t("codex.notInjected")}
|
||||
</Badge>
|
||||
{active && <span className="text-sm font-medium">{active.name}</span>}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{t("codex.configPath")}: ~/.codex/config.toml
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{t("codex.portHint")}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{injection && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("codex.preview")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="grid grid-cols-2 gap-1 font-mono text-xs text-zinc-600">
|
||||
<li>
|
||||
<span className="text-zinc-400">model_provider</span> = crossrelay
|
||||
</li>
|
||||
{Object.entries(injection)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<span className="text-zinc-400">{key}</span> = {value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{providers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-zinc-500">
|
||||
{t("codex.empty")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{providers.map((provider) => (
|
||||
<Card key={provider.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{provider.name}</span>
|
||||
{provider.enabled_for_codex && <Badge tone="success">{t("codex.activated")}</Badge>}
|
||||
{provider.preset && <Badge>{provider.preset}</Badge>}
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => apply(provider)}>
|
||||
{t("codex.activate")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
+91
-14
@@ -17,7 +17,7 @@ interface FormState {
|
||||
preset: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Models;
|
||||
models: Omit<Models, "extra"> & { 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<string, string> | undefined): { key: string; value: string }[] {
|
||||
return Object.entries(extra ?? {}).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
|
||||
function toMap(rows: { key: string; value: string }[]): Record<string, string> {
|
||||
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 <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
|
||||
@@ -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() {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-zinc-600">{t("providers.extraMappings")}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
...form,
|
||||
models: {
|
||||
...form.models,
|
||||
extra: [...form.models.extra, { key: "", value: "" }],
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus size={12} aria-hidden />
|
||||
{t("providers.addMapping")}
|
||||
</Button>
|
||||
</div>
|
||||
{form.models.extra.map((row, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={row.key}
|
||||
placeholder={t("providers.extraKey")}
|
||||
onChange={(e) => {
|
||||
const extra = form.models.extra.map((r, i) =>
|
||||
i === index ? { ...r, key: e.target.value } : r,
|
||||
);
|
||||
setForm({ ...form, models: { ...form.models, extra } });
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder={t("providers.extraValue")}
|
||||
onChange={(e) => {
|
||||
const extra = form.models.extra.map((r, i) =>
|
||||
i === index ? { ...r, value: e.target.value } : r,
|
||||
);
|
||||
setForm({ ...form, models: { ...form.models, extra } });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() =>
|
||||
setForm({
|
||||
...form,
|
||||
models: {
|
||||
...form.models,
|
||||
extra: form.models.extra.filter((_, i) => i !== index),
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-zinc-500">{t("providers.extraHint")}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -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() {
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle>{t("settings.backups")}</CardTitle>
|
||||
<Select
|
||||
value={backupTarget}
|
||||
onChange={(e) => setBackupTarget(e.target.value as "claude" | "codex")}
|
||||
className="max-w-40"
|
||||
>
|
||||
<option value="claude">{t("settings.targetClaude")}</option>
|
||||
<option value="codex">{t("settings.targetCodex")}</option>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restore.isPending}
|
||||
onClick={() =>
|
||||
restore.mutate(undefined, {
|
||||
onSuccess: (result) => show(`${t("settings.restored")}: ${result.restored_from}`),
|
||||
onError: (err) => show(err.message, "error"),
|
||||
})
|
||||
}
|
||||
disabled={restore.isPending || !backups?.items?.length}
|
||||
onClick={() => {
|
||||
const newest = backups?.items?.[0]?.name;
|
||||
if (!newest) return;
|
||||
restore.mutate(
|
||||
{ target: backupTarget, backup: newest },
|
||||
{
|
||||
onSuccess: () => show(`${t("settings.restored")}: ${newest}`),
|
||||
onError: (err) => show(err.message, "error"),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("settings.restoreBackup")}
|
||||
</Button>
|
||||
@@ -115,6 +129,7 @@ export default function SettingsPage() {
|
||||
<TH>{t("settings.backups")}</TH>
|
||||
<TH>Size</TH>
|
||||
<TH>Modified</TH>
|
||||
<TH>{t("common.actions")}</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
@@ -123,6 +138,24 @@ export default function SettingsPage() {
|
||||
<TD className="font-mono text-xs">{item.name}</TD>
|
||||
<TD className="text-xs">{item.size} B</TD>
|
||||
<TD className="text-xs">{new Date(item.modified).toLocaleString()}</TD>
|
||||
<TD>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={restore.isPending}
|
||||
onClick={() =>
|
||||
restore.mutate(
|
||||
{ target: backupTarget, backup: item.name },
|
||||
{
|
||||
onSuccess: () => show(`${t("settings.rolledBack")}: ${item.name}`),
|
||||
onError: (err) => show(err.message, "error"),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("settings.rollback")}
|
||||
</Button>
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
|
||||
Reference in new issue
Block a user