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 壳),负责拉起网关子进程、解析端口、退出时清理。
|
- `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/`(配置备份)。
|
- 用户数据位于 `~/.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 {
|
if len(backups) == 0 {
|
||||||
return "", fmt.Errorf("no backup found in %s", m.backupDir)
|
return "", fmt.Errorf("no backup found in %s", m.backupDir)
|
||||||
}
|
}
|
||||||
newest := backups[0]
|
return m.restoreFromLocked(backups[0].Name)
|
||||||
data, err := os.ReadFile(filepath.Join(m.backupDir, newest.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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := m.writeFile(data); err != nil {
|
if err := m.writeFile(data); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return newest.Name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupInfo describes one backup file.
|
// 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"`
|
APIKey string `json:"api_key"`
|
||||||
Models Models `json:"models"`
|
Models Models `json:"models"`
|
||||||
EnabledForClaude bool `json:"enabled_for_claude"`
|
EnabledForClaude bool `json:"enabled_for_claude"`
|
||||||
|
EnabledForCodex bool `json:"enabled_for_codex"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +64,13 @@ type Config struct {
|
|||||||
RelayToken string `json:"relay_token"`
|
RelayToken string `json:"relay_token"`
|
||||||
Providers []Provider `json:"providers"`
|
Providers []Provider `json:"providers"`
|
||||||
ActiveClaudeID string `json:"active_claude_id,omitempty"`
|
ActiveClaudeID string `json:"active_claude_id,omitempty"`
|
||||||
Gateway Gateway `json:"gateway"`
|
ActiveCodexID string `json:"active_codex_id,omitempty"`
|
||||||
Language string `json:"language"`
|
// 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
|
// 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) {
|
func TestSetGatewayValidation(t *testing.T) {
|
||||||
t.Setenv("CROSSRELAY_HOME", t.TempDir())
|
t.Setenv("CROSSRELAY_HOME", t.TempDir())
|
||||||
store, _ := Load()
|
store, _ := Load()
|
||||||
|
|||||||
@@ -88,6 +88,21 @@ func (s *Store) ActiveClaude() *Provider {
|
|||||||
return nil
|
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.
|
// AddProvider validates and appends a provider.
|
||||||
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
|
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -128,6 +143,7 @@ func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models
|
|||||||
}
|
}
|
||||||
updated.ID = existing.ID
|
updated.ID = existing.ID
|
||||||
updated.EnabledForClaude = existing.EnabledForClaude
|
updated.EnabledForClaude = existing.EnabledForClaude
|
||||||
|
updated.EnabledForCodex = existing.EnabledForCodex
|
||||||
updated.CreatedAt = existing.CreatedAt
|
updated.CreatedAt = existing.CreatedAt
|
||||||
s.cfg.Providers[idx] = *updated
|
s.cfg.Providers[idx] = *updated
|
||||||
if err := s.saveLocked(); err != nil {
|
if err := s.saveLocked(); err != nil {
|
||||||
@@ -154,6 +170,9 @@ func (s *Store) DeleteProvider(id string) error {
|
|||||||
if s.cfg.ActiveClaudeID == id {
|
if s.cfg.ActiveClaudeID == id {
|
||||||
s.cfg.ActiveClaudeID = ""
|
s.cfg.ActiveClaudeID = ""
|
||||||
}
|
}
|
||||||
|
if s.cfg.ActiveCodexID == id {
|
||||||
|
s.cfg.ActiveCodexID = ""
|
||||||
|
}
|
||||||
return s.saveLocked()
|
return s.saveLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +211,50 @@ func (s *Store) ClearActiveClaude() error {
|
|||||||
return s.saveLocked()
|
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.
|
// SetGateway updates listener settings.
|
||||||
func (s *Store) SetGateway(g Gateway) error {
|
func (s *Store) SetGateway(g Gateway) error {
|
||||||
g.PortMode = strings.TrimSpace(g.PortMode)
|
g.PortMode = strings.TrimSpace(g.PortMode)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ type testEnv struct {
|
|||||||
upstream *fakeUpstream
|
upstream *fakeUpstream
|
||||||
store *config.Store
|
store *config.Store
|
||||||
settings string
|
settings string
|
||||||
|
codex string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestEnv(t *testing.T) *testEnv {
|
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) {
|
func TestMessagesNonStream(t *testing.T) {
|
||||||
env := newTestEnv(t)
|
env := newTestEnv(t)
|
||||||
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
|
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
|
||||||
|
|||||||
+162
-17
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
|
"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/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,12 +30,22 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"models": p.Models,
|
"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{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"running": true,
|
"running": true,
|
||||||
"version": Version,
|
"version": Version,
|
||||||
"port": s.port,
|
"port": s.port,
|
||||||
"uptime_sec": int(time.Since(s.started).Seconds()),
|
"uptime_sec": int(time.Since(s.started).Seconds()),
|
||||||
"active_claude_provider": active,
|
"active_claude_provider": active,
|
||||||
|
"active_codex_provider": activeCodex,
|
||||||
"stats": s.snapshotStats(),
|
"stats": s.snapshotStats(),
|
||||||
"language": cfg.Language,
|
"language": cfg.Language,
|
||||||
"port_mode": cfg.Gateway.PortMode,
|
"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})
|
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleApply activates a provider and writes it into Claude Code's
|
// applyInput selects which client configuration receives the provider.
|
||||||
// settings.json.
|
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) {
|
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
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)
|
p, err := s.store.ActivateClaude(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusNotFound, err.Error())
|
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{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"provider": p,
|
"provider": p,
|
||||||
|
"target": "claude",
|
||||||
"env": settings.Env,
|
"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) {
|
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()
|
mgr, err := s.claudeManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name, err := mgr.Restore()
|
var name string
|
||||||
|
if backup != "" {
|
||||||
|
name, err = mgr.RestoreFrom(backup)
|
||||||
|
} else {
|
||||||
|
name, err = mgr.Restore()
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusNotFound, err.Error())
|
writeAPIError(w, http.StatusNotFound, err.Error())
|
||||||
return
|
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})
|
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) {
|
func (s *Server) handleBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
mgr, err := s.claudeManager()
|
switch target := r.URL.Query().Get("target"); target {
|
||||||
if err != nil {
|
case "", "claude":
|
||||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
mgr, err := s.claudeManager()
|
||||||
return
|
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) {
|
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
|
// 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
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,7 +14,9 @@ import (
|
|||||||
|
|
||||||
"gitea.rutua.cn/kate522/CrossRelay/internal/anthropic"
|
"gitea.rutua.cn/kate522/CrossRelay/internal/anthropic"
|
||||||
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
|
"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/config"
|
||||||
|
"gitea.rutua.cn/kate522/CrossRelay/internal/responses"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is reported by /api/status.
|
// Version is reported by /api/status.
|
||||||
@@ -70,6 +73,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /v1/messages", s.withAuth(s.handleMessages))
|
mux.HandleFunc("POST /v1/messages", s.withAuth(s.handleMessages))
|
||||||
mux.HandleFunc("POST /v1/messages/count_tokens", s.withAuth(s.handleCountTokens))
|
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/status", s.handleStatus)
|
||||||
mux.HandleFunc("GET /api/providers", s.handleListProviders)
|
mux.HandleFunc("GET /api/providers", s.handleListProviders)
|
||||||
mux.HandleFunc("POST /api/providers", s.handleCreateProvider)
|
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
|
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 {
|
func (s *Server) snapshotStats() Stats {
|
||||||
s.statsMu.Lock()
|
s.statsMu.Lock()
|
||||||
defer s.statsMu.Unlock()
|
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})
|
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 {
|
func decodeJSON(r *http.Request, v any, limit int64) error {
|
||||||
dec := json.NewDecoder(io.LimitReader(r.Body, limit))
|
dec := json.NewDecoder(io.LimitReader(r.Body, limit))
|
||||||
return dec.Decode(v)
|
return dec.Decode(v)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ var All = []Preset{
|
|||||||
Sonnet: "deepseek-chat",
|
Sonnet: "deepseek-chat",
|
||||||
Haiku: "deepseek-chat",
|
Haiku: "deepseek-chat",
|
||||||
Opus: "deepseek-chat",
|
Opus: "deepseek-chat",
|
||||||
|
Extra: codexModelExtras("deepseek-chat"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,6 +32,7 @@ var All = []Preset{
|
|||||||
Sonnet: "kimi-k2-0905-preview",
|
Sonnet: "kimi-k2-0905-preview",
|
||||||
Haiku: "kimi-k2-0905-preview",
|
Haiku: "kimi-k2-0905-preview",
|
||||||
Opus: "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",
|
Sonnet: "glm-4.6",
|
||||||
Haiku: "glm-4.6",
|
Haiku: "glm-4.6",
|
||||||
Opus: "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.
|
// ByKey looks up a preset by its key.
|
||||||
func ByKey(key string) (Preset, bool) {
|
func ByKey(key string) (Preset, bool) {
|
||||||
for _, p := range All {
|
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 { Layout } from "./components/Layout";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Providers from "./pages/Providers";
|
import Providers from "./pages/Providers";
|
||||||
|
import Codex from "./pages/Codex";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -31,6 +32,7 @@ export default function App({ initialLanguage }: { initialLanguage?: string }) {
|
|||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/providers" element={<Providers />} />
|
<Route path="/providers" element={<Providers />} />
|
||||||
|
<Route path="/codex" element={<Codex />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { cn } from "@/lib/utils";
|
||||||
import { useSettingsMutation, useSettings } from "@/lib/queries";
|
import { useSettingsMutation, useSettings } from "@/lib/queries";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/", key: "nav.dashboard", icon: Gauge },
|
{ to: "/", key: "nav.dashboard", icon: Gauge },
|
||||||
{ to: "/providers", key: "nav.providers", icon: Server },
|
{ to: "/providers", key: "nav.providers", icon: Server },
|
||||||
|
{ to: "/codex", key: "nav.codex", icon: Terminal },
|
||||||
{ to: "/settings", key: "nav.settings", icon: SettingsIcon },
|
{ to: "/settings", key: "nav.settings", icon: SettingsIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const zhCN = {
|
|||||||
dashboard: "仪表盘",
|
dashboard: "仪表盘",
|
||||||
providers: "供应商",
|
providers: "供应商",
|
||||||
settings: "设置",
|
settings: "设置",
|
||||||
|
codex: "Codex",
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
save: "保存",
|
save: "保存",
|
||||||
@@ -73,6 +74,7 @@ export const zhCN = {
|
|||||||
extraKey: "请求的模型名",
|
extraKey: "请求的模型名",
|
||||||
extraValue: "上游模型名",
|
extraValue: "上游模型名",
|
||||||
addMapping: "添加一行",
|
addMapping: "添加一行",
|
||||||
|
extraHint: "Codex 等客户端会把模型名原样发给网关,在此把入站模型名映射到上游模型名。",
|
||||||
activated: "已激活",
|
activated: "已激活",
|
||||||
activate: "激活并写入",
|
activate: "激活并写入",
|
||||||
empty: "还没有供应商,先添加一个",
|
empty: "还没有供应商,先添加一个",
|
||||||
@@ -87,6 +89,20 @@ export const zhCN = {
|
|||||||
keyHint: "留空表示保持原有 Key 不变",
|
keyHint: "留空表示保持原有 Key 不变",
|
||||||
enabledClaude: "对 Claude Code 启用",
|
enabledClaude: "对 Claude Code 启用",
|
||||||
},
|
},
|
||||||
|
codex: {
|
||||||
|
title: "Codex",
|
||||||
|
status: "注入状态",
|
||||||
|
injected: "已注入",
|
||||||
|
notInjected: "未注入",
|
||||||
|
configPath: "配置文件",
|
||||||
|
portHint: "auto 端口模式下网关重启后端口会变,需重新激活",
|
||||||
|
preview: "已写入 config.toml 的托管配置",
|
||||||
|
activate: "激活并写入",
|
||||||
|
activated: "已激活",
|
||||||
|
restore: "还原官方配置",
|
||||||
|
restoreDone: "已还原 Codex 官方配置",
|
||||||
|
empty: "还没有供应商,请先到供应商页添加",
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "设置",
|
title: "设置",
|
||||||
gateway: "网关",
|
gateway: "网关",
|
||||||
@@ -100,6 +116,11 @@ export const zhCN = {
|
|||||||
backupsEmpty: "暂无备份",
|
backupsEmpty: "暂无备份",
|
||||||
restoreBackup: "还原",
|
restoreBackup: "还原",
|
||||||
restored: "已还原",
|
restored: "已还原",
|
||||||
|
backupTarget: "备份目标",
|
||||||
|
targetClaude: "Claude Code",
|
||||||
|
targetCodex: "Codex",
|
||||||
|
rollback: "回滚",
|
||||||
|
rolledBack: "已回滚",
|
||||||
saveFailed: "保存失败",
|
saveFailed: "保存失败",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -115,6 +136,7 @@ export const enUS = {
|
|||||||
dashboard: "Dashboard",
|
dashboard: "Dashboard",
|
||||||
providers: "Providers",
|
providers: "Providers",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
|
codex: "Codex",
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
save: "Save",
|
save: "Save",
|
||||||
@@ -180,6 +202,7 @@ export const enUS = {
|
|||||||
extraKey: "Requested model",
|
extraKey: "Requested model",
|
||||||
extraValue: "Upstream model",
|
extraValue: "Upstream model",
|
||||||
addMapping: "Add row",
|
addMapping: "Add row",
|
||||||
|
extraHint: "Clients like Codex send their model names as-is; map inbound names onto upstream models here.",
|
||||||
activated: "Active",
|
activated: "Active",
|
||||||
activate: "Activate & apply",
|
activate: "Activate & apply",
|
||||||
empty: "No providers yet",
|
empty: "No providers yet",
|
||||||
@@ -194,6 +217,20 @@ export const enUS = {
|
|||||||
keyHint: "Leave empty to keep the current key",
|
keyHint: "Leave empty to keep the current key",
|
||||||
enabledClaude: "Enabled for Claude Code",
|
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: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
gateway: "Gateway",
|
gateway: "Gateway",
|
||||||
@@ -207,6 +244,11 @@ export const enUS = {
|
|||||||
backupsEmpty: "No backups yet",
|
backupsEmpty: "No backups yet",
|
||||||
restoreBackup: "Restore",
|
restoreBackup: "Restore",
|
||||||
restored: "Restored",
|
restored: "Restored",
|
||||||
|
backupTarget: "Backup target",
|
||||||
|
targetClaude: "Claude Code",
|
||||||
|
targetCodex: "Codex",
|
||||||
|
rollback: "Roll back",
|
||||||
|
rolledBack: "Rolled back",
|
||||||
saveFailed: "Save failed",
|
saveFailed: "Save failed",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+19
-4
@@ -61,12 +61,27 @@ export const api = {
|
|||||||
request<Provider>(`/api/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
request<Provider>(`/api/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
||||||
deleteProvider: (id: string) =>
|
deleteProvider: (id: string) =>
|
||||||
request<{ deleted: string }>(`/api/providers/${id}`, { method: "DELETE" }),
|
request<{ deleted: string }>(`/api/providers/${id}`, { method: "DELETE" }),
|
||||||
applyProvider: (id: string) =>
|
applyProvider: (id: string, target: ApplyTarget = "claude") =>
|
||||||
request<ApplyResult>(`/api/providers/${id}/apply`, { method: "POST", body: "{}" }),
|
request<ApplyResult>(`/api/providers/${id}/apply`, {
|
||||||
restore: () => request<{ restored_from: string }>("/api/restore", { method: "POST", body: "{}" }),
|
method: "POST",
|
||||||
listBackups: () => request<{ items: BackupInfo[] }>("/api/backups"),
|
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"),
|
getSettings: () => request<Settings>("/api/settings"),
|
||||||
updateSettings: (patch: Partial<Settings>) =>
|
updateSettings: (patch: Partial<Settings>) =>
|
||||||
request<Settings>("/api/settings", { method: "PUT", body: JSON.stringify(patch) }),
|
request<Settings>("/api/settings", { method: "PUT", body: JSON.stringify(patch) }),
|
||||||
listLogs: () => request<{ items: LogEntry[] }>("/api/logs"),
|
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,
|
status: ["status"] as const,
|
||||||
providers: ["providers"] as const,
|
providers: ["providers"] as const,
|
||||||
logs: ["logs"] as const,
|
logs: ["logs"] as const,
|
||||||
backups: ["backups"] as const,
|
backups: (target: string) => ["backups", target] as const,
|
||||||
settings: ["settings"] as const,
|
settings: ["settings"] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,8 +21,8 @@ export function useLogs() {
|
|||||||
return useQuery({ queryKey: queryKeys.logs, queryFn: api.listLogs, refetchInterval: 5000 });
|
return useQuery({ queryKey: queryKeys.logs, queryFn: api.listLogs, refetchInterval: 5000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackups() {
|
export function useBackups(target: "claude" | "codex") {
|
||||||
return useQuery({ queryKey: queryKeys.backups, queryFn: api.listBackups });
|
return useQuery({ queryKey: queryKeys.backups(target), queryFn: () => api.listBackups(target) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
@@ -33,7 +33,7 @@ function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
|
|||||||
return Promise.all([
|
return Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.providers }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.providers }),
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.status }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.status }),
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.backups }),
|
queryClient.invalidateQueries({ queryKey: ["backups"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.settings }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,8 @@ export function useProviderMutations() {
|
|||||||
onSuccess: () => invalidateAll(queryClient),
|
onSuccess: () => invalidateAll(queryClient),
|
||||||
});
|
});
|
||||||
const apply = useMutation({
|
const apply = useMutation({
|
||||||
mutationFn: (id: string) => api.applyProvider(id),
|
mutationFn: ({ id, target }: { id: string; target: "claude" | "codex" }) =>
|
||||||
|
api.applyProvider(id, target),
|
||||||
onSuccess: () => invalidateAll(queryClient),
|
onSuccess: () => invalidateAll(queryClient),
|
||||||
});
|
});
|
||||||
return { create, update, remove, apply };
|
return { create, update, remove, apply };
|
||||||
@@ -62,7 +63,7 @@ export function useProviderMutations() {
|
|||||||
export function useRestore() {
|
export function useRestore() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => api.restore(),
|
mutationFn: (input: Parameters<typeof api.restore>[0]) => api.restore(input),
|
||||||
onSuccess: () => invalidateAll(queryClient),
|
onSuccess: () => invalidateAll(queryClient),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-9
@@ -14,6 +14,7 @@ export interface Provider {
|
|||||||
api_key: string;
|
api_key: string;
|
||||||
models: Models;
|
models: Models;
|
||||||
enabled_for_claude: boolean;
|
enabled_for_claude: boolean;
|
||||||
|
enabled_for_codex: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ export interface Status {
|
|||||||
port: number;
|
port: number;
|
||||||
uptime_sec: number;
|
uptime_sec: number;
|
||||||
active_claude_provider: ActiveProvider | null;
|
active_claude_provider: ActiveProvider | null;
|
||||||
|
active_codex_provider: ActiveProvider | null;
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
language: string;
|
language: string;
|
||||||
port_mode: string;
|
port_mode: string;
|
||||||
@@ -69,7 +71,14 @@ export interface Settings {
|
|||||||
|
|
||||||
export interface ApplyResult {
|
export interface ApplyResult {
|
||||||
provider: Provider;
|
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 {
|
export interface Preset {
|
||||||
@@ -78,25 +87,47 @@ export interface Preset {
|
|||||||
models: Models;
|
models: Models;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CODEX_EXTRAS = ["gpt-5-codex", "gpt-5", "codex-mini-latest"];
|
||||||
|
|
||||||
export const PRESETS: Preset[] = [
|
export const PRESETS: Preset[] = [
|
||||||
{
|
{
|
||||||
key: "deepseek",
|
key: "deepseek",
|
||||||
base_url: "https://api.deepseek.com/v1",
|
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",
|
key: "moonshot",
|
||||||
base_url: "https://api.moonshot.cn/v1",
|
base_url: "https://api.moonshot.cn/v1",
|
||||||
models: {
|
models: withCodexExtras(
|
||||||
default: "kimi-k2-0905-preview",
|
{
|
||||||
sonnet: "kimi-k2-0905-preview",
|
default: "kimi-k2-0905-preview",
|
||||||
haiku: "kimi-k2-0905-preview",
|
sonnet: "kimi-k2-0905-preview",
|
||||||
opus: "kimi-k2-0905-preview",
|
haiku: "kimi-k2-0905-preview",
|
||||||
},
|
opus: "kimi-k2-0905-preview",
|
||||||
|
},
|
||||||
|
"kimi-k2-0905-preview",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "zhipu",
|
key: "zhipu",
|
||||||
base_url: "https://open.bigmodel.cn/api/paas/v4",
|
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"
|
size="sm"
|
||||||
disabled={restore.isPending}
|
disabled={restore.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
restore.mutate(undefined, {
|
restore.mutate(
|
||||||
|
{ target: "claude" },
|
||||||
|
{
|
||||||
onSuccess: () => show(t("dashboard.restoreDone")),
|
onSuccess: () => show(t("dashboard.restoreDone")),
|
||||||
onError: (err) => show(err.message, "error"),
|
onError: (err) => show(err.message, "error"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{restore.isPending ? (
|
{restore.isPending ? (
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ vi.mock("@/lib/api", () => ({
|
|||||||
createProvider: (input: unknown) => createProvider(input),
|
createProvider: (input: unknown) => createProvider(input),
|
||||||
updateProvider: (id: string, input: unknown) => updateProvider(id, input),
|
updateProvider: (id: string, input: unknown) => updateProvider(id, input),
|
||||||
deleteProvider: (id: string) => deleteProvider(id),
|
deleteProvider: (id: string) => deleteProvider(id),
|
||||||
applyProvider: (id: string) => applyProvider(id),
|
applyProvider: (id: string, target: string) => applyProvider(id, target),
|
||||||
restore: () => restore(),
|
restore: (input: unknown) => restore(input),
|
||||||
getStatus: () => getStatus(),
|
getStatus: () => getStatus(),
|
||||||
getSettings: () => getSettings(),
|
getSettings: () => getSettings(),
|
||||||
updateSettings: (patch: unknown) => updateSettings(patch),
|
updateSettings: (patch: unknown) => updateSettings(patch),
|
||||||
@@ -94,7 +94,23 @@ describe("Providers page", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderPage();
|
renderPage();
|
||||||
await user.click(await screen.findByRole("button", { name: /激活并写入|Activate & apply/ }));
|
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();
|
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;
|
preset: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
models: Models;
|
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: FormState = {
|
const emptyForm: FormState = {
|
||||||
@@ -25,9 +25,19 @@ const emptyForm: FormState = {
|
|||||||
preset: "",
|
preset: "",
|
||||||
base_url: "",
|
base_url: "",
|
||||||
api_key: "",
|
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() {
|
export default function Providers() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError } = useProviders();
|
const { data, isLoading, isError } = useProviders();
|
||||||
@@ -47,7 +57,9 @@ export default function Providers() {
|
|||||||
preset: presetKey,
|
preset: presetKey,
|
||||||
name: presetKey ? presetKey : "",
|
name: presetKey ? presetKey : "",
|
||||||
base_url: preset?.base_url ?? "",
|
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 ?? "",
|
sonnet: provider.models.sonnet ?? "",
|
||||||
haiku: provider.models.haiku ?? "",
|
haiku: provider.models.haiku ?? "",
|
||||||
opus: provider.models.opus ?? "",
|
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(),
|
sonnet: form.models.sonnet?.trim(),
|
||||||
haiku: form.models.haiku?.trim(),
|
haiku: form.models.haiku?.trim(),
|
||||||
opus: form.models.opus?.trim(),
|
opus: form.models.opus?.trim(),
|
||||||
extra: Object.fromEntries(
|
extra: toMap(form.models.extra),
|
||||||
Object.entries(form.models.extra ?? {}).filter(([k, v]) => k.trim() && v.trim()),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const onSuccess = () => {
|
const onSuccess = () => {
|
||||||
@@ -109,13 +119,16 @@ export default function Providers() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const apply = (provider: Provider) => {
|
const apply = (provider: Provider) => {
|
||||||
mutations.apply.mutate(provider.id, {
|
mutations.apply.mutate(
|
||||||
onSuccess: (result) => {
|
{ id: provider.id, target: "claude" },
|
||||||
setLastAppliedEnv(result.env);
|
{
|
||||||
show(`${t("providers.activated")}: ${provider.name}`);
|
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>;
|
if (isLoading) return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
|
||||||
@@ -158,7 +171,9 @@ export default function Providers() {
|
|||||||
...form,
|
...form,
|
||||||
preset: key,
|
preset: key,
|
||||||
base_url: preset?.base_url ?? form.base_url,
|
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>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</fieldset>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<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() {
|
export default function SettingsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { data: settings } = useSettings();
|
const { data: settings } = useSettings();
|
||||||
const { data: backups } = useBackups();
|
const [backupTarget, setBackupTarget] = useState<"claude" | "codex">("claude");
|
||||||
|
const { data: backups } = useBackups(backupTarget);
|
||||||
const mutation = useSettingsMutation();
|
const mutation = useSettingsMutation();
|
||||||
const restore = useRestore();
|
const restore = useRestore();
|
||||||
const { show } = useToast();
|
const { show } = useToast();
|
||||||
@@ -93,16 +94,29 @@ export default function SettingsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex-row items-center justify-between">
|
<CardHeader className="flex-row items-center justify-between">
|
||||||
<CardTitle>{t("settings.backups")}</CardTitle>
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={restore.isPending}
|
disabled={restore.isPending || !backups?.items?.length}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
restore.mutate(undefined, {
|
const newest = backups?.items?.[0]?.name;
|
||||||
onSuccess: (result) => show(`${t("settings.restored")}: ${result.restored_from}`),
|
if (!newest) return;
|
||||||
onError: (err) => show(err.message, "error"),
|
restore.mutate(
|
||||||
})
|
{ target: backupTarget, backup: newest },
|
||||||
}
|
{
|
||||||
|
onSuccess: () => show(`${t("settings.restored")}: ${newest}`),
|
||||||
|
onError: (err) => show(err.message, "error"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("settings.restoreBackup")}
|
{t("settings.restoreBackup")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -115,6 +129,7 @@ export default function SettingsPage() {
|
|||||||
<TH>{t("settings.backups")}</TH>
|
<TH>{t("settings.backups")}</TH>
|
||||||
<TH>Size</TH>
|
<TH>Size</TH>
|
||||||
<TH>Modified</TH>
|
<TH>Modified</TH>
|
||||||
|
<TH>{t("common.actions")}</TH>
|
||||||
</TR>
|
</TR>
|
||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
@@ -123,6 +138,24 @@ export default function SettingsPage() {
|
|||||||
<TD className="font-mono text-xs">{item.name}</TD>
|
<TD className="font-mono text-xs">{item.name}</TD>
|
||||||
<TD className="text-xs">{item.size} B</TD>
|
<TD className="text-xs">{item.size} B</TD>
|
||||||
<TD className="text-xs">{new Date(item.modified).toLocaleString()}</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>
|
</TR>
|
||||||
))}
|
))}
|
||||||
</TBody>
|
</TBody>
|
||||||
|
|||||||
Reference in new issue
Block a user