1053 lines
34 KiB
Go
1053 lines
34 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management"
|
|
claudemodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/claude/models"
|
|
codexlive "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/live"
|
|
codexmodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/models"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/client/grokbuild"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/claude"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/gemini"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/openai"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const oauthCallbackSuccessHTML = `<html><head><meta charset="utf-8"><title>Authentication successful</title><script>setTimeout(function(){window.close();},5000);</script></head><body><h1>Authentication successful!</h1><p>You can close this window.</p><p>This window will close automatically in 5 seconds.</p></body></html>`
|
|
|
|
const codexAlphaSearchSourceFormat = "codex-alpha-search"
|
|
|
|
// setupRoutes configures the API routes for the server.
|
|
// It defines the endpoints and associates them with their respective handlers.
|
|
func (s *Server) setupRoutes() {
|
|
healthzHandler := func(c *gin.Context) {
|
|
if c.Request.Method == http.MethodHead {
|
|
c.Status(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
}
|
|
s.engine.GET("/healthz", healthzHandler)
|
|
s.engine.HEAD("/healthz", healthzHandler)
|
|
|
|
s.engine.GET("/management.html", s.serveManagementControlPanel)
|
|
s.engine.HEAD("/management.html", s.serveManagementControlPanel)
|
|
s.engine.GET("/management-assets/*filepath", s.serveManagementAsset)
|
|
s.engine.HEAD("/management-assets/*filepath", s.serveManagementAsset)
|
|
openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
|
|
geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers)
|
|
claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers)
|
|
openaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers)
|
|
s.codexLiveHandler = codexlive.NewHandler(s.handlers.AuthManager, s.cfg)
|
|
|
|
// OpenAI compatible API routes
|
|
v1 := s.engine.Group("/v1")
|
|
v1.Use(AuthMiddleware(s.accessManager))
|
|
{
|
|
v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))
|
|
v1.POST("/chat/completions", openaiHandlers.ChatCompletions)
|
|
v1.POST("/completions", openaiHandlers.Completions)
|
|
v1.POST("/images/generations", openaiHandlers.ImagesGenerations)
|
|
v1.POST("/images/edits", openaiHandlers.ImagesEdits)
|
|
v1.POST("/videos", openaiHandlers.XAIVideosGenerations)
|
|
v1.POST("/videos/generations", openaiHandlers.XAIVideosGenerations)
|
|
v1.POST("/videos/edits", openaiHandlers.XAIVideosEdits)
|
|
v1.POST("/videos/extensions", openaiHandlers.XAIVideosExtensions)
|
|
v1.GET("/videos/:request_id", openaiHandlers.XAIVideosRetrieve)
|
|
v1.POST("/messages", claudeCodeHandlers.ClaudeMessages)
|
|
v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens)
|
|
v1.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket)
|
|
v1.POST("/responses", openaiResponsesHandlers.Responses)
|
|
v1.POST("/responses/compact", openaiResponsesHandlers.Compact)
|
|
v1.POST("/alpha/search", s.codexAlphaSearch)
|
|
v1.POST("/live", s.codexLiveHandler.Handle)
|
|
v1.GET("/live/:call_id", s.codexLiveHandler.HandleSideband)
|
|
}
|
|
|
|
realtimeAuth := realtimeAuthMiddleware(s.accessManager, s.codexLiveHandler)
|
|
standardAuth := realtimeStandardAuthMiddleware(s.accessManager)
|
|
s.engine.GET("/v1/realtime", realtimeAuth, s.codexLiveHandler.HandleRealtimeWebsocket)
|
|
s.engine.POST("/v1/realtime", realtimeAuth, s.codexLiveHandler.Handle)
|
|
s.engine.POST("/v1/realtime/calls", realtimeAuth, s.codexLiveHandler.Handle)
|
|
s.engine.GET("/v1/realtime/calls/:call_id", realtimeAuth, s.codexLiveHandler.HandleSideband)
|
|
s.engine.POST("/v1/realtime/client_secrets", standardAuth, s.codexLiveHandler.CreateClientSecret)
|
|
s.engine.POST("/v1/realtime/sessions", standardAuth, s.codexLiveHandler.CreateLegacySession)
|
|
s.engine.POST("/v1/realtime/transcription_sessions", standardAuth, s.codexLiveHandler.HandleTranscriptionSession)
|
|
s.engine.GET("/v1/realtime/translations", realtimeAuth, s.codexLiveHandler.HandleTranslation)
|
|
s.engine.POST("/v1/realtime/translations", realtimeAuth, s.codexLiveHandler.HandleTranslation)
|
|
s.engine.POST("/v1/realtime/translations/client_secrets", standardAuth, s.codexLiveHandler.HandleTranslation)
|
|
s.engine.POST("/v1/realtime/calls/:call_id/hangup", standardAuth, s.codexLiveHandler.HandleHangup)
|
|
s.engine.POST("/v1/realtime/calls/:call_id/accept", standardAuth, s.codexLiveHandler.HandleSIPControl)
|
|
s.engine.POST("/v1/realtime/calls/:call_id/reject", standardAuth, s.codexLiveHandler.HandleSIPControl)
|
|
s.engine.POST("/v1/realtime/calls/:call_id/refer", standardAuth, s.codexLiveHandler.HandleSIPControl)
|
|
|
|
openaiV1 := s.engine.Group("/openai/v1")
|
|
openaiV1.Use(AuthMiddleware(s.accessManager))
|
|
{
|
|
openaiV1.POST("/videos", openaiHandlers.VideosCreate)
|
|
openaiV1.GET("/videos/:video_id/content", openaiHandlers.VideosContent)
|
|
openaiV1.GET("/videos/:video_id", openaiHandlers.VideosRetrieve)
|
|
}
|
|
|
|
// Codex CLI direct route aliases (chatgpt_base_url compatible)
|
|
codexDirect := s.engine.Group("/backend-api/codex")
|
|
codexDirect.Use(AuthMiddleware(s.accessManager))
|
|
{
|
|
codexDirect.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket)
|
|
codexDirect.POST("/responses", openaiResponsesHandlers.Responses)
|
|
codexDirect.POST("/responses/compact", openaiResponsesHandlers.Compact)
|
|
codexDirect.POST("/alpha/search", s.codexAlphaSearch)
|
|
}
|
|
|
|
// Gemini compatible API routes
|
|
v1beta := s.engine.Group("/v1beta")
|
|
v1beta.Use(AuthMiddleware(s.accessManager))
|
|
{
|
|
v1beta.GET("/models", s.geminiModelsHandler(geminiHandlers))
|
|
v1beta.POST("/interactions", geminiHandlers.Interactions)
|
|
v1beta.POST("/models/*action", geminiHandlers.GeminiHandler)
|
|
v1beta.GET("/models/*action", s.geminiGetHandler(geminiHandlers))
|
|
}
|
|
|
|
// Root endpoint
|
|
s.engine.GET("/", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "CLI Proxy API Server",
|
|
"endpoints": []string{
|
|
"POST /v1/chat/completions",
|
|
"POST /v1/completions",
|
|
"GET /v1/models",
|
|
},
|
|
})
|
|
})
|
|
|
|
// OAuth callback endpoints (reuse main server port)
|
|
// These endpoints receive provider redirects and persist
|
|
// the short-lived code/state for the waiting goroutine.
|
|
s.engine.GET("/anthropic/callback", func(c *gin.Context) {
|
|
code := c.Query("code")
|
|
state := c.Query("state")
|
|
errStr := c.Query("error")
|
|
if errStr == "" {
|
|
errStr = c.Query("error_description")
|
|
}
|
|
if state != "" {
|
|
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "anthropic", state, code, errStr)
|
|
}
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
|
})
|
|
|
|
s.engine.GET("/codex/callback", func(c *gin.Context) {
|
|
code := c.Query("code")
|
|
state := c.Query("state")
|
|
errStr := c.Query("error")
|
|
if errStr == "" {
|
|
errStr = c.Query("error_description")
|
|
}
|
|
if state != "" {
|
|
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "codex", state, code, errStr)
|
|
}
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
|
})
|
|
|
|
s.engine.GET("/antigravity/callback", func(c *gin.Context) {
|
|
code := c.Query("code")
|
|
state := c.Query("state")
|
|
errStr := c.Query("error")
|
|
if errStr == "" {
|
|
errStr = c.Query("error_description")
|
|
}
|
|
if state != "" {
|
|
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "antigravity", state, code, errStr)
|
|
}
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
|
})
|
|
|
|
// Management routes are registered lazily by registerManagementRoutes when a secret is configured.
|
|
}
|
|
|
|
func (s *Server) codexAlphaSearchModelRouterHost() handlers.PluginModelRouterHost {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
if s.pluginHost != nil {
|
|
return s.pluginHost
|
|
}
|
|
if s.handlers != nil && s.handlers.ModelRouterHost != nil {
|
|
return s.handlers.ModelRouterHost
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) codexAlphaSearchSelectionModel(ctx context.Context, c *gin.Context, body []byte, model string) (string, error) {
|
|
host := s.codexAlphaSearchModelRouterHost()
|
|
if host == nil {
|
|
return model, nil
|
|
}
|
|
|
|
var headers http.Header
|
|
queryValues := make(map[string][]string)
|
|
requestPath := ""
|
|
if c != nil && c.Request != nil {
|
|
headers = c.Request.Header.Clone()
|
|
if c.Request.URL != nil {
|
|
queryValues = c.Request.URL.Query()
|
|
requestPath = c.Request.URL.Path
|
|
}
|
|
}
|
|
metadata := map[string]any{
|
|
coreexecutor.RequestedModelMetadataKey: model,
|
|
}
|
|
if requestPath != "" {
|
|
metadata[coreexecutor.RequestPathMetadataKey] = requestPath
|
|
}
|
|
resp, handled := host.RouteModel(ctx, pluginapi.ModelRouteRequest{
|
|
SourceFormat: codexAlphaSearchSourceFormat,
|
|
RequestedModel: model,
|
|
Headers: headers,
|
|
Query: queryValues,
|
|
Body: body,
|
|
Metadata: metadata,
|
|
})
|
|
if !handled || !resp.Handled {
|
|
return model, nil
|
|
}
|
|
if resp.TargetKind != pluginapi.ModelRouteTargetProvider || !strings.EqualFold(strings.TrimSpace(resp.Target), "codex") {
|
|
return "", fmt.Errorf("unsupported Codex Alpha Search model route target %q (%q)", resp.TargetKind, resp.Target)
|
|
}
|
|
if targetModel := strings.TrimSpace(resp.TargetModel); targetModel != "" {
|
|
return targetModel, nil
|
|
}
|
|
return model, nil
|
|
}
|
|
|
|
func sanitizeCodexAlphaSearchBody(body []byte) []byte {
|
|
var payload map[string]json.RawMessage
|
|
if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil || payload == nil {
|
|
return body
|
|
}
|
|
|
|
removed := false
|
|
for _, field := range []string{"prompt_cache_key", "prompt_cache_retention"} {
|
|
if _, exists := payload[field]; exists {
|
|
delete(payload, field)
|
|
removed = true
|
|
}
|
|
}
|
|
if !removed {
|
|
return body
|
|
}
|
|
|
|
sanitizedBody, errMarshal := json.Marshal(payload)
|
|
if errMarshal != nil {
|
|
return body
|
|
}
|
|
return sanitizedBody
|
|
}
|
|
|
|
// rewriteCodexAlphaSearchModel replaces the top-level model field with the
|
|
// credential-resolved upstream model before the request is forwarded.
|
|
func rewriteCodexAlphaSearchModel(body []byte, upstreamModel string) []byte {
|
|
upstreamModel = strings.TrimSpace(upstreamModel)
|
|
if upstreamModel == "" {
|
|
return body
|
|
}
|
|
|
|
var payload map[string]json.RawMessage
|
|
if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil || payload == nil {
|
|
return body
|
|
}
|
|
if _, exists := payload["model"]; !exists {
|
|
return body
|
|
}
|
|
|
|
modelJSON, errMarshalModel := json.Marshal(upstreamModel)
|
|
if errMarshalModel != nil {
|
|
return body
|
|
}
|
|
if string(payload["model"]) == string(modelJSON) {
|
|
return body
|
|
}
|
|
|
|
payload["model"] = modelJSON
|
|
rewrittenBody, errMarshal := json.Marshal(payload)
|
|
if errMarshal != nil {
|
|
return body
|
|
}
|
|
return rewrittenBody
|
|
}
|
|
|
|
func homeSelectionAttemptContext(ctx context.Context, selection *auth.HomeDispatchSelection) (context.Context, func(), error) {
|
|
if selection == nil {
|
|
return nil, func() {}, errors.New("Home dispatch selection is nil")
|
|
}
|
|
return selection.AttemptContext(ctx)
|
|
}
|
|
|
|
// codexAlphaSearch forwards the standalone search endpoint used by current
|
|
// Codex clients. Unlike /responses, this payload is already in Codex search
|
|
// format and must not pass through a protocol translator.
|
|
func (s *Server) codexAlphaSearch(c *gin.Context) {
|
|
if s == nil || s.handlers == nil || s.handlers.AuthManager == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Codex auth manager unavailable"})
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 16<<20))
|
|
if err != nil {
|
|
c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadRequest), gin.H{"error": "Failed to read search request"})
|
|
return
|
|
}
|
|
|
|
var routing struct {
|
|
ID string `json:"id"`
|
|
Model string `json:"model"`
|
|
}
|
|
_ = json.Unmarshal(body, &routing)
|
|
upstreamRequestBody := sanitizeCodexAlphaSearchBody(body)
|
|
|
|
selectionHeaders := c.Request.Header.Clone()
|
|
if sessionID := strings.TrimSpace(routing.ID); sessionID != "" {
|
|
selectionHeaders.Set("X-Session-ID", sessionID)
|
|
}
|
|
ctx := context.WithValue(c.Request.Context(), "gin", c)
|
|
selectionModel, errRoute := s.codexAlphaSearchSelectionModel(ctx, c, body, strings.TrimSpace(routing.Model))
|
|
if errRoute != nil {
|
|
log.WithError(errRoute).Warn("codex alpha search: model router returned an unsupported target")
|
|
c.JSON(clienterror.HTTPStatusFromErrorOr(errRoute, http.StatusServiceUnavailable), gin.H{"error": errRoute.Error()})
|
|
return
|
|
}
|
|
selectionOpts := coreexecutor.Options{Headers: selectionHeaders, OriginalRequest: body}
|
|
var selection *auth.HomeDispatchSelection
|
|
var selected *auth.Auth
|
|
if s.handlers.AuthManager.HomeEnabled() {
|
|
selection, err = s.handlers.AuthManager.SelectHomeAuthWithCredentialPolicy(ctx, "codex", selectionModel, auth.CredentialPolicyCodexAlphaSearchV1, selectionOpts)
|
|
if selection != nil {
|
|
selected = selection.CloneAuth()
|
|
}
|
|
} else {
|
|
selected, err = s.handlers.AuthManager.SelectAuthWithCredentialPolicy(ctx, "codex", selectionModel, auth.CredentialPolicyCodexAlphaSearchV1, selectionOpts)
|
|
}
|
|
if err != nil {
|
|
status := clienterror.HTTPStatusFromErrorOr(err, http.StatusServiceUnavailable)
|
|
for _, value := range auth.SafeResponseHeaders(err).Values("Retry-After") {
|
|
c.Writer.Header().Add("Retry-After", value)
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if selected == nil {
|
|
if selection != nil {
|
|
selection.End("missing_auth")
|
|
}
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Codex auth unavailable"})
|
|
return
|
|
}
|
|
var releaseAttempt func()
|
|
if selection != nil {
|
|
attemptCtx, release, errBind := homeSelectionAttemptContext(ctx, selection)
|
|
if errBind != nil {
|
|
selection.End("attempt_bind_failed")
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": errBind.Error()})
|
|
return
|
|
}
|
|
ctx = attemptCtx
|
|
releaseAttempt = release
|
|
defer releaseAttempt()
|
|
}
|
|
logging.SetGinCPATraceID(c, selected.EnsureIndex())
|
|
|
|
baseHeaders := make(http.Header)
|
|
baseHeaders.Set("Content-Type", "application/json")
|
|
baseHeaders.Set("Accept", "application/json")
|
|
baseHeaders.Set("Originator", "codex_cli_rs")
|
|
for _, name := range []string{"Version", "User-Agent", "Session_id", "X-Client-Request-Id"} {
|
|
if value := strings.TrimSpace(c.GetHeader(name)); value != "" {
|
|
baseHeaders.Set(name, value)
|
|
}
|
|
}
|
|
|
|
errMissingBaseURL := errors.New("Codex Alpha Search API key base URL unavailable")
|
|
routeModel := strings.TrimSpace(selectionModel)
|
|
if routeModel == "" {
|
|
routeModel = strings.TrimSpace(routing.Model)
|
|
}
|
|
performRequest := func(current *auth.Auth) (*http.Response, error) {
|
|
headers := baseHeaders.Clone()
|
|
if accountID, ok := current.Metadata["account_id"].(string); ok && strings.TrimSpace(accountID) != "" {
|
|
headers.Set("Chatgpt-Account-Id", accountID)
|
|
}
|
|
upstreamURL := "https://chatgpt.com/backend-api/codex/alpha/search"
|
|
requestBody := upstreamRequestBody
|
|
// API-key Alpha Search reuses normal credential-aware model resolution so
|
|
// CPA routing prefixes and model aliases are not forwarded upstream.
|
|
if current.AuthKind() == auth.AuthKindAPIKey {
|
|
baseURL := ""
|
|
if current.Attributes != nil {
|
|
baseURL = strings.TrimSpace(current.Attributes["base_url"])
|
|
}
|
|
if baseURL == "" {
|
|
return nil, errMissingBaseURL
|
|
}
|
|
upstreamURL = strings.TrimRight(baseURL, "/") + "/alpha/search"
|
|
if upstreamModel := s.handlers.AuthManager.ResolveExecutionModel(current, routeModel); upstreamModel != "" {
|
|
requestBody = rewriteCodexAlphaSearchModel(upstreamRequestBody, upstreamModel)
|
|
}
|
|
}
|
|
req, errRequest := s.handlers.AuthManager.NewHttpRequest(ctx, current, http.MethodPost, upstreamURL, requestBody, headers)
|
|
if errRequest != nil {
|
|
return nil, errRequest
|
|
}
|
|
authType, authValue := current.AccountInfo()
|
|
helps.RecordAPIRequest(ctx, s.cfg, helps.UpstreamRequestLog{
|
|
URL: upstreamURL,
|
|
Method: http.MethodPost,
|
|
Headers: req.Header.Clone(),
|
|
Body: requestBody,
|
|
Provider: "codex",
|
|
AuthID: current.ID,
|
|
AuthLabel: current.Label,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
return s.handlers.AuthManager.HttpRequest(ctx, current, req)
|
|
}
|
|
|
|
if errCtx := ctx.Err(); errCtx != nil {
|
|
if selection != nil {
|
|
selection.End("attempt_canceled")
|
|
}
|
|
c.JSON(clienterror.HTTPStatusFromErrorOr(errCtx, http.StatusRequestTimeout), gin.H{"error": errCtx.Error()})
|
|
return
|
|
}
|
|
resp, err := performRequest(selected)
|
|
if err != nil {
|
|
if errors.Is(err, errMissingBaseURL) {
|
|
if selection != nil {
|
|
selection.End("missing_base_url")
|
|
}
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if selection != nil {
|
|
selection.End("request_failed")
|
|
}
|
|
helps.RecordAPIResponseError(ctx, s.cfg, err)
|
|
c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if selection != nil && resp.StatusCode == http.StatusUnauthorized {
|
|
s.handlers.AuthManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel)
|
|
helps.RecordAPIResponseMetadata(ctx, s.cfg, resp.StatusCode, resp.Header.Clone())
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
|
if errClose := resp.Body.Close(); errClose != nil {
|
|
log.Errorf("codex alpha search: close unauthorized response body error: %v", errClose)
|
|
}
|
|
refreshed, didRefresh, errRefresh := s.handlers.AuthManager.RefreshHomeSelectionAfterUnauthorized(ctx, selection, selected)
|
|
if errRefresh != nil {
|
|
selection.End("refresh_failed")
|
|
c.JSON(clienterror.HTTPStatusFromErrorOr(errRefresh, http.StatusServiceUnavailable), gin.H{"error": errRefresh.Error()})
|
|
return
|
|
}
|
|
if !didRefresh || refreshed == nil {
|
|
selection.End("refresh_unavailable")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Codex credential unauthorized"})
|
|
return
|
|
}
|
|
selected = refreshed
|
|
logging.SetGinCPATraceID(c, selected.EnsureIndex())
|
|
resp, err = performRequest(selected)
|
|
if err != nil {
|
|
if errors.Is(err, errMissingBaseURL) {
|
|
selection.End("missing_base_url")
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
selection.End("retry_failed")
|
|
helps.RecordAPIResponseError(ctx, s.cfg, err)
|
|
c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
s.handlers.AuthManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel)
|
|
}
|
|
}
|
|
closeResponseBody := func() error {
|
|
errClose := resp.Body.Close()
|
|
if errClose != nil {
|
|
log.Errorf("codex alpha search: close response body error: %v", errClose)
|
|
}
|
|
return errClose
|
|
}
|
|
if selection != nil {
|
|
if errBind := selection.Bind(closeResponseBody); errBind != nil {
|
|
selection.End("response_bind_failed")
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": errBind.Error()})
|
|
return
|
|
}
|
|
defer selection.End("response_closed")
|
|
} else {
|
|
defer func() { _ = closeResponseBody() }()
|
|
}
|
|
helps.RecordAPIResponseMetadata(ctx, s.cfg, resp.StatusCode, resp.Header.Clone())
|
|
upstreamBody, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, s.cfg, err)
|
|
c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": "Failed to read Codex search response"})
|
|
return
|
|
}
|
|
helps.AppendAPIResponseChunk(ctx, s.cfg, upstreamBody)
|
|
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
|
c.Header("Content-Type", contentType)
|
|
}
|
|
c.Status(resp.StatusCode)
|
|
_, _ = c.Writer.Write(upstreamBody)
|
|
}
|
|
|
|
// AttachWebsocketRoute registers a websocket upgrade handler on the primary Gin engine.
|
|
// The handler is served as-is without additional middleware beyond the standard stack already configured.
|
|
func (s *Server) AttachWebsocketRoute(path string, handler http.Handler) {
|
|
if s == nil || s.engine == nil || handler == nil {
|
|
return
|
|
}
|
|
trimmed := strings.TrimSpace(path)
|
|
if trimmed == "" {
|
|
trimmed = "/v1/ws"
|
|
}
|
|
if !strings.HasPrefix(trimmed, "/") {
|
|
trimmed = "/" + trimmed
|
|
}
|
|
s.wsRouteMu.Lock()
|
|
if _, exists := s.wsRoutes[trimmed]; exists {
|
|
s.wsRouteMu.Unlock()
|
|
return
|
|
}
|
|
s.wsRoutes[trimmed] = struct{}{}
|
|
s.wsRouteMu.Unlock()
|
|
|
|
authMiddleware := AuthMiddleware(s.accessManager)
|
|
conditionalAuth := func(c *gin.Context) {
|
|
if !s.wsAuthEnabled.Load() {
|
|
c.Next()
|
|
return
|
|
}
|
|
authMiddleware(c)
|
|
}
|
|
finalHandler := func(c *gin.Context) {
|
|
handler.ServeHTTP(c.Writer, c.Request)
|
|
c.Abort()
|
|
}
|
|
|
|
s.engine.GET(trimmed, conditionalAuth, finalHandler)
|
|
}
|
|
|
|
// isAnthropicModelsRequest reports whether a /v1/models request should be served in
|
|
// Anthropic format. Anthropic API clients send the Anthropic-Version header; Claude
|
|
// Code additionally uses a claude-cli User-Agent.
|
|
func isAnthropicModelsRequest(c *gin.Context) bool {
|
|
if c.GetHeader("Anthropic-Version") != "" {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(c.GetHeader("User-Agent"), "claude-cli")
|
|
}
|
|
|
|
// unifiedModelsHandler creates a unified handler for the /v1/models endpoint
|
|
// that routes to different handlers based on the request.
|
|
// Anthropic API requests (Anthropic-Version header, or a claude-cli User-Agent)
|
|
// route to the Claude handler, otherwise they route to the OpenAI handler.
|
|
func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if grokbuild.IsGrokShellUserAgent(c.GetHeader("User-Agent")) {
|
|
s.handleGrokModels(c)
|
|
return
|
|
}
|
|
|
|
if _, ok := c.Request.URL.Query()["client_version"]; ok {
|
|
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
|
|
s.handleHomeCodexClientModels(c)
|
|
return
|
|
}
|
|
openaiHandler.OpenAIModels(c)
|
|
return
|
|
}
|
|
|
|
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
|
|
s.handleHomeModels(c)
|
|
return
|
|
}
|
|
|
|
// Route to Claude handler for Anthropic API requests.
|
|
if isAnthropicModelsRequest(c) {
|
|
claudeHandler.ClaudeModels(c)
|
|
} else {
|
|
openaiHandler.OpenAIModels(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func grokModelsFromHomeEntries(entries []homeModelEntry) []grokbuild.ModelInfo {
|
|
models := make([]grokbuild.ModelInfo, 0, len(entries))
|
|
for _, entry := range entries {
|
|
models = append(models, grokbuild.ModelInfo{
|
|
ID: entry.id,
|
|
DisplayName: entry.displayName,
|
|
ContextLength: entry.contextLength,
|
|
})
|
|
}
|
|
return models
|
|
}
|
|
|
|
func grokModelsFromRegistryInfos(infos []*registry.ModelInfo) []grokbuild.ModelInfo {
|
|
models := make([]grokbuild.ModelInfo, 0, len(infos))
|
|
for _, info := range infos {
|
|
if info == nil {
|
|
continue
|
|
}
|
|
model := grokbuild.ModelInfo{
|
|
ID: info.ID,
|
|
DisplayName: info.DisplayName,
|
|
ContextLength: info.ContextLength,
|
|
}
|
|
if info.Thinking != nil {
|
|
model.ReasoningLevels = append([]string(nil), info.Thinking.Levels...)
|
|
}
|
|
models = append(models, model)
|
|
}
|
|
return models
|
|
}
|
|
|
|
func (s *Server) handleGrokModels(c *gin.Context) {
|
|
var models []grokbuild.ModelInfo
|
|
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
|
|
entries, ok := s.loadHomeModelEntries(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
models = grokModelsFromHomeEntries(entries)
|
|
} else {
|
|
models = grokModelsFromRegistryInfos(registry.GetGlobalRegistry().GetAvailableModelInfos())
|
|
}
|
|
c.JSON(http.StatusOK, grokbuild.BuildResponse(models))
|
|
}
|
|
|
|
// handleHomeCodexClientModels builds the Codex client catalog from Home model IDs.
|
|
// Template metadata still comes from the local/remote codex_client_models catalog.
|
|
func (s *Server) handleHomeCodexClientModels(c *gin.Context) {
|
|
entries, ok := s.loadHomeModelEntries(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
models := make([]map[string]any, 0, len(entries))
|
|
for _, entry := range entries {
|
|
model := map[string]any{
|
|
"id": entry.id,
|
|
"object": "model",
|
|
}
|
|
if entry.created > 0 {
|
|
model["created"] = entry.created
|
|
}
|
|
if entry.ownedBy != "" {
|
|
model["owned_by"] = entry.ownedBy
|
|
}
|
|
if entry.displayName != "" {
|
|
model["display_name"] = entry.displayName
|
|
model["description"] = entry.displayName
|
|
}
|
|
if entry.maxCompletionTokens > 0 {
|
|
model["max_completion_tokens"] = entry.maxCompletionTokens
|
|
}
|
|
models = append(models, model)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, codexmodels.BuildResponse(models, nil, s.cfg.Codex.OptimizeMultiAgentV2))
|
|
}
|
|
|
|
func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
|
|
s.handleHomeGeminiModels(c)
|
|
return
|
|
}
|
|
|
|
geminiHandler.GeminiModels(c)
|
|
}
|
|
}
|
|
|
|
func (s *Server) geminiGetHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
|
|
s.handleHomeGeminiModel(c)
|
|
return
|
|
}
|
|
|
|
geminiHandler.GeminiGetHandler(c)
|
|
}
|
|
}
|
|
|
|
type homeModelEntry struct {
|
|
id string
|
|
created int64
|
|
ownedBy string
|
|
displayName string
|
|
contextLength int
|
|
maxCompletionTokens int
|
|
}
|
|
|
|
func (s *Server) handleHomeModels(c *gin.Context) {
|
|
entries, ok := s.loadHomeModelEntries(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
isClaude := isAnthropicModelsRequest(c)
|
|
|
|
if isClaude {
|
|
disableCloaking := s.cfg != nil && s.cfg.ClaudeCode.DisableCloakingModelList
|
|
c.JSON(http.StatusOK, claudemodels.BuildResponse(formatHomeClaudeModels(entries), disableCloaking))
|
|
return
|
|
}
|
|
|
|
filtered := make([]map[string]any, 0, len(entries))
|
|
for _, entry := range entries {
|
|
model := map[string]any{
|
|
"id": entry.id,
|
|
"object": "model",
|
|
}
|
|
if entry.created > 0 {
|
|
model["created"] = entry.created
|
|
}
|
|
if entry.ownedBy != "" {
|
|
model["owned_by"] = entry.ownedBy
|
|
}
|
|
filtered = append(filtered, model)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"object": "list",
|
|
"data": filtered,
|
|
})
|
|
}
|
|
|
|
func formatHomeClaudeModels(entries []homeModelEntry) []map[string]any {
|
|
out := make([]map[string]any, 0, len(entries))
|
|
for _, entry := range entries {
|
|
out = append(out, formatHomeClaudeModel(entry))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func formatHomeClaudeModel(entry homeModelEntry) map[string]any {
|
|
displayName := entry.displayName
|
|
if displayName == "" {
|
|
displayName = entry.id
|
|
}
|
|
maxInput := entry.contextLength
|
|
if maxInput <= 0 {
|
|
maxInput = registry.DefaultClaudeMaxInputTokens
|
|
}
|
|
maxOutput := entry.maxCompletionTokens
|
|
if maxOutput <= 0 {
|
|
maxOutput = registry.DefaultClaudeMaxOutputTokens
|
|
}
|
|
model := map[string]any{
|
|
"id": entry.id,
|
|
"object": "model",
|
|
"owned_by": entry.ownedBy,
|
|
"type": "model",
|
|
"display_name": displayName,
|
|
"max_input_tokens": maxInput,
|
|
"max_tokens": maxOutput,
|
|
}
|
|
if entry.created > 0 {
|
|
model["created_at"] = time.Unix(entry.created, 0).UTC().Format(time.RFC3339)
|
|
}
|
|
return model
|
|
}
|
|
|
|
func (s *Server) handleHomeGeminiModels(c *gin.Context) {
|
|
entries, ok := s.loadHomeModelEntries(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"models": formatHomeGeminiModels(entries),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleHomeGeminiModel(c *gin.Context) {
|
|
entries, ok := s.loadHomeModelEntries(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
action := strings.TrimPrefix(c.Param("action"), "/")
|
|
action = strings.TrimSpace(action)
|
|
for _, entry := range entries {
|
|
if homeGeminiModelMatches(entry, action) {
|
|
c.JSON(http.StatusOK, formatHomeGeminiModel(entry))
|
|
return
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusNotFound, handlers.ErrorResponse{
|
|
Error: handlers.ErrorDetail{
|
|
Message: "Not Found",
|
|
Type: "not_found",
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) loadHomeModelEntries(c *gin.Context) ([]homeModelEntry, bool) {
|
|
if s == nil || c == nil || c.Request == nil {
|
|
return nil, false
|
|
}
|
|
client := home.Current()
|
|
if client == nil {
|
|
c.JSON(http.StatusServiceUnavailable, handlers.ErrorResponse{
|
|
Error: handlers.ErrorDetail{
|
|
Message: "home control center unavailable",
|
|
Type: "server_error",
|
|
},
|
|
})
|
|
return nil, false
|
|
}
|
|
|
|
raw, errGet := client.GetModels(c.Request.Context(), c.Request.Header, c.Request.URL.Query())
|
|
if errGet != nil {
|
|
c.JSON(http.StatusBadGateway, handlers.ErrorResponse{
|
|
Error: handlers.ErrorDetail{
|
|
Message: errGet.Error(),
|
|
Type: "server_error",
|
|
},
|
|
})
|
|
return nil, false
|
|
}
|
|
|
|
if statusCode, ok := homeModelsAuthStatus(raw); ok {
|
|
c.JSON(statusCode, handlers.ErrorResponse{
|
|
Error: handlers.ErrorDetail{
|
|
Message: homeModelsErrorMessage(raw),
|
|
Type: "authentication_error",
|
|
},
|
|
})
|
|
return nil, false
|
|
}
|
|
|
|
entries, errDecode := decodeHomeModels(raw)
|
|
if errDecode != nil {
|
|
c.JSON(http.StatusBadGateway, handlers.ErrorResponse{
|
|
Error: handlers.ErrorDetail{
|
|
Message: errDecode.Error(),
|
|
Type: "server_error",
|
|
},
|
|
})
|
|
return nil, false
|
|
}
|
|
|
|
return entries, true
|
|
}
|
|
|
|
func formatHomeGeminiModels(entries []homeModelEntry) []map[string]any {
|
|
out := make([]map[string]any, 0, len(entries))
|
|
for _, entry := range entries {
|
|
out = append(out, formatHomeGeminiModel(entry))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func formatHomeGeminiModel(entry homeModelEntry) map[string]any {
|
|
name := entry.id
|
|
if !strings.HasPrefix(name, "models/") {
|
|
name = "models/" + name
|
|
}
|
|
displayName := entry.displayName
|
|
if displayName == "" {
|
|
displayName = entry.id
|
|
}
|
|
return map[string]any{
|
|
"name": name,
|
|
"displayName": displayName,
|
|
"description": displayName,
|
|
"supportedGenerationMethods": []string{"generateContent"},
|
|
}
|
|
}
|
|
|
|
func homeGeminiModelMatches(entry homeModelEntry, action string) bool {
|
|
id := strings.TrimSpace(entry.id)
|
|
if id == "" || action == "" {
|
|
return false
|
|
}
|
|
normalizedAction := strings.TrimPrefix(action, "models/")
|
|
normalizedID := strings.TrimPrefix(id, "models/")
|
|
return action == id || action == "models/"+id || normalizedAction == normalizedID
|
|
}
|
|
|
|
// homeModelsAuthStatus inspects a home models response for an authentication/error envelope.
|
|
// It returns the HTTP status code to surface (401 for credential issues, 502 otherwise)
|
|
// and true when the payload is an error response rather than model data.
|
|
func homeModelsAuthStatus(raw []byte) (int, bool) {
|
|
errType := homeModelsErrorType(raw)
|
|
if errType == "" {
|
|
return 0, false
|
|
}
|
|
if errType == "no_credentials" || errType == "invalid_credential" {
|
|
return http.StatusUnauthorized, true
|
|
}
|
|
return http.StatusBadGateway, true
|
|
}
|
|
|
|
func homeModelsErrorType(raw []byte) string {
|
|
top, ok := unmarshalHomeModelsTopLevel(raw)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
rawErr, exists := top["error"]
|
|
if !exists {
|
|
return ""
|
|
}
|
|
var errObj struct {
|
|
Type string `json:"type"`
|
|
}
|
|
if errUnmarshal := json.Unmarshal(rawErr, &errObj); errUnmarshal != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(errObj.Type)
|
|
}
|
|
|
|
func homeModelsErrorMessage(raw []byte) string {
|
|
top, ok := unmarshalHomeModelsTopLevel(raw)
|
|
if !ok {
|
|
return "home models request failed"
|
|
}
|
|
rawErr, exists := top["error"]
|
|
if !exists {
|
|
return "home models request failed"
|
|
}
|
|
var errObj struct {
|
|
Message string `json:"message"`
|
|
}
|
|
if errUnmarshal := json.Unmarshal(rawErr, &errObj); errUnmarshal != nil {
|
|
return "home models request failed"
|
|
}
|
|
if msg := strings.TrimSpace(errObj.Message); msg != "" {
|
|
return msg
|
|
}
|
|
return "home models request failed"
|
|
}
|
|
|
|
func unmarshalHomeModelsTopLevel(raw []byte) (map[string]json.RawMessage, bool) {
|
|
if len(raw) == 0 {
|
|
return nil, false
|
|
}
|
|
var top map[string]json.RawMessage
|
|
if errUnmarshal := json.Unmarshal(raw, &top); errUnmarshal != nil {
|
|
return nil, false
|
|
}
|
|
return top, true
|
|
}
|
|
|
|
func decodeHomeModels(raw []byte) ([]homeModelEntry, error) {
|
|
if len(raw) == 0 {
|
|
return nil, fmt.Errorf("home models payload is empty")
|
|
}
|
|
|
|
var bySection map[string][]map[string]any
|
|
if err := json.Unmarshal(raw, &bySection); err != nil {
|
|
return nil, fmt.Errorf("parse home models payload: %w", err)
|
|
}
|
|
if len(bySection) == 0 {
|
|
return nil, fmt.Errorf("home models payload has no sections")
|
|
}
|
|
|
|
seen := make(map[string]struct{})
|
|
out := make([]homeModelEntry, 0, 256)
|
|
for _, models := range bySection {
|
|
for _, model := range models {
|
|
id, _ := model["id"].(string)
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
name, _ := model["name"].(string)
|
|
name = strings.TrimSpace(name)
|
|
id = strings.TrimPrefix(name, "models/")
|
|
}
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
|
|
ownedBy, _ := model["owned_by"].(string)
|
|
ownedBy = strings.TrimSpace(ownedBy)
|
|
displayName, _ := model["display_name"].(string)
|
|
displayName = strings.TrimSpace(displayName)
|
|
if displayName == "" {
|
|
displayName, _ = model["displayName"].(string)
|
|
displayName = strings.TrimSpace(displayName)
|
|
}
|
|
|
|
out = append(out, homeModelEntry{
|
|
id: id,
|
|
created: homeModelInt64Value(model, "created"),
|
|
ownedBy: ownedBy,
|
|
displayName: displayName,
|
|
contextLength: int(homeModelInt64Value(model, "context_length", "contextLength", "inputTokenLimit", "max_input_tokens")),
|
|
maxCompletionTokens: int(homeModelInt64Value(model, "max_completion_tokens", "maxCompletionTokens", "outputTokenLimit", "max_tokens")),
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("home models payload contains no models")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func homeModelInt64Value(model map[string]any, keys ...string) int64 {
|
|
for _, key := range keys {
|
|
switch value := model[key].(type) {
|
|
case float64:
|
|
return int64(value)
|
|
case int64:
|
|
return value
|
|
case int:
|
|
return int64(value)
|
|
case json.Number:
|
|
if n, errInt := value.Int64(); errInt == nil {
|
|
return n
|
|
}
|
|
case string:
|
|
if n, errParse := strconv.ParseInt(strings.TrimSpace(value), 10, 64); errParse == nil {
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|