Files
2026-10-19 13:26:52 +08:00

414 lines
12 KiB
Go

package gateway
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
"gitea.rutua.cn/kate522/CrossRelay/internal/codexcfg"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
)
// providerInput is the provider payload accepted by the management API.
type providerInput struct {
Name string `json:"name"`
Preset string `json:"preset"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Models config.Models `json:"models"`
Target string `json:"target"`
Protocol string `json:"protocol"`
APIFormat string `json:"api_format"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
cfg := s.store.Config()
var active any
if p := s.store.ActiveClaude(); p != nil {
active = map[string]any{
"id": p.ID,
"name": p.Name,
"base_url": p.BaseURL,
"models": p.Models,
"protocol": p.Protocol,
}
}
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,
"protocol": p.Protocol,
}
}
writeJSON(w, http.StatusOK, map[string]any{
"running": true,
"version": Version,
"port": s.port,
"uptime_sec": int(time.Since(s.started).Seconds()),
"active_claude_provider": active,
"active_codex_provider": activeCodex,
"stats": s.snapshotStats(),
"language": cfg.Language,
"port_mode": cfg.Gateway.PortMode,
"fixed_port": cfg.Gateway.FixedPort,
})
}
func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("target")
switch target {
case "", config.TargetClaude, config.TargetCodex:
default:
writeAPIError(w, http.StatusBadRequest, `target must be "claude" or "codex"`)
return
}
items := []config.Provider{}
for _, p := range s.store.Config().Providers {
if target == "" || p.Target == target {
items = append(items, p)
}
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
type fetchModelsInput struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
}
// handleFetchModels proxies the upstream model catalog so the UI can
// offer a picker without talking to the provider directly.
func (s *Server) handleFetchModels(w http.ResponseWriter, r *http.Request) {
var in fetchModelsInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
in.BaseURL = strings.TrimSpace(in.BaseURL)
in.APIKey = strings.TrimSpace(in.APIKey)
if in.BaseURL == "" || in.APIKey == "" {
writeAPIError(w, http.StatusBadRequest, "base URL and API key are required")
return
}
if u, err := url.Parse(in.BaseURL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
writeAPIError(w, http.StatusBadRequest, "base URL must be an http(s) URL")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
list, err := chatcmpl.NewClient(in.BaseURL, in.APIKey).ListModels(ctx)
if err != nil {
var ue *chatcmpl.UpstreamError
if errors.As(err, &ue) {
writeAPIError(w, http.StatusBadGateway, fmt.Sprintf("upstream %d: %s", ue.StatusCode, ue.Message))
return
}
writeAPIError(w, http.StatusBadGateway, fmt.Sprintf("upstream request failed: %v", err))
return
}
items := []chatcmpl.ModelInfo{}
if list != nil {
items = list.Data
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
var in providerInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
proto := strings.TrimSpace(in.Protocol)
if proto == "" {
proto = strings.TrimSpace(in.APIFormat)
}
p, err := s.store.AddProvider(in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models, in.Target, proto)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var in providerInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
proto := strings.TrimSpace(in.Protocol)
if proto == "" {
proto = strings.TrimSpace(in.APIFormat)
}
p, err := s.store.UpdateProvider(id, in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models, in.Target, proto)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.store.DeleteProvider(id); err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// applyInput selects which client configuration receives the provider.
type applyInput struct {
Target string `json:"target"`
}
// handleApply activates a provider and writes it into the target
// client's configuration. The body is optional; an absent or empty
// target means Claude Code, keeping older clients working.
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var in applyInput
if r.Body != nil {
_ = decodeJSON(r, &in, 1<<20) // an empty body leaves the zero value
}
switch in.Target {
case "", "claude":
s.applyClaude(w, id)
case "codex":
s.applyCodex(w, id)
default:
writeAPIError(w, http.StatusBadRequest, `target must be "claude" or "codex"`)
}
}
// applyClaude activates a provider and writes it into Claude Code's
// settings.json.
func (s *Server) applyClaude(w http.ResponseWriter, id string) {
p, err := s.store.ActivateClaude(id)
if err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
mgr, err := s.claudeManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
settings, err := mgr.Apply(claudecfg.ApplyOptions{
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", s.port),
Token: s.store.Config().RelayToken,
Models: p.Models,
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"provider": p,
"target": "claude",
"env": settings.Env,
})
}
// 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/v1", s.port),
Token: s.store.Config().RelayToken,
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
// Remember the provider the user had before the first apply so
// restore can put it back instead of dropping the key.
if injection.PriorProvider != "" && s.store.Config().CodexPriorProvider == "" {
if err := s.store.SetCodexPriorProvider(injection.PriorProvider); err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]any{
"provider": p,
"target": "codex",
"injection": injection,
})
}
// restoreInput selects the client and, optionally, one specific backup
// file to roll back to.
type restoreInput struct {
Target string `json:"target"`
Backup string `json:"backup,omitempty"`
}
// handleRestore puts a client's configuration back. Claude restores
// the newest backup (or the named one); Codex removes the managed
// keys, or rolls back to the named backup.
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
var in restoreInput
if r.Body != nil {
_ = decodeJSON(r, &in, 1<<20)
}
switch in.Target {
case "", "claude":
s.restoreClaude(w, in.Backup)
case "codex":
s.restoreCodex(w, in.Backup)
default:
writeAPIError(w, http.StatusBadRequest, `target must be "claude" or "codex"`)
}
}
func (s *Server) restoreClaude(w http.ResponseWriter, backup string) {
mgr, err := s.claudeManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
var name string
if backup != "" {
name, err = mgr.RestoreFrom(backup)
} else {
name, err = mgr.Restore()
}
if err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
_ = s.store.ClearActiveClaude()
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) {
switch target := r.URL.Query().Get("target"); target {
case "", "claude":
mgr, err := s.claudeManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
items, err := mgr.Backups()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []claudecfg.BackupInfo{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
case "codex":
mgr, err := s.codexManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
items, err := mgr.Backups()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []codexcfg.BackupInfo{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
default:
writeAPIError(w, http.StatusBadRequest, `target must be "claude" or "codex"`)
}
}
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
cfg := s.store.Config()
writeJSON(w, http.StatusOK, map[string]any{
"language": cfg.Language,
"port_mode": cfg.Gateway.PortMode,
"fixed_port": cfg.Gateway.FixedPort,
})
}
type settingsInput struct {
Language string `json:"language"`
PortMode string `json:"port_mode"`
FixedPort int `json:"fixed_port"`
}
func (s *Server) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var in settingsInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if in.Language != "" {
if err := s.store.SetLanguage(in.Language); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
if in.PortMode != "" {
if err := s.store.SetGateway(config.Gateway{
PortMode: in.PortMode,
FixedPort: in.FixedPort,
}); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
cfg := s.store.Config()
writeJSON(w, http.StatusOK, map[string]any{
"language": cfg.Language,
"port_mode": cfg.Gateway.PortMode,
"fixed_port": cfg.Gateway.FixedPort,
})
}
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": s.logs.list()})
}