Merge branch 'v7' into dev

This commit is contained in:
Luis Pater
2026-05-10 02:33:42 +08:00
324 changed files with 3634 additions and 1148 deletions
+4 -4
View File
@@ -16,10 +16,10 @@ import (
"net/http"
"github.com/gin-gonic/gin"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
@@ -15,10 +15,10 @@ import (
"time"
"github.com/gin-gonic/gin"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
+4 -4
View File
@@ -13,10 +13,10 @@ import (
"time"
"github.com/gin-gonic/gin"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
)
// GeminiAPIHandler contains the handlers for Gemini API endpoints.
+25 -13
View File
@@ -14,14 +14,14 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
coreauth "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/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"golang.org/x/net/context"
)
@@ -850,14 +850,22 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string
resolvedModelName := modelName
initialSuffix := thinking.ParseSuffix(modelName)
if initialSuffix.ModelName == "auto" {
resolvedBase := util.ResolveAutoModel(initialSuffix.ModelName)
if initialSuffix.HasSuffix {
resolvedModelName = fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix)
if h != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() {
resolvedModelName = modelName
} else {
resolvedModelName = resolvedBase
resolvedBase := util.ResolveAutoModel(initialSuffix.ModelName)
if initialSuffix.HasSuffix {
resolvedModelName = fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix)
} else {
resolvedModelName = resolvedBase
}
}
} else {
resolvedModelName = util.ResolveAutoModel(modelName)
if h != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() {
resolvedModelName = modelName
} else {
resolvedModelName = util.ResolveAutoModel(modelName)
}
}
parsed := thinking.ParseSuffix(resolvedModelName)
@@ -870,6 +878,10 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string
}
}
if h != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() {
return []string{"home"}, resolvedModelName, nil
}
providers = util.GetProviderName(baseModel)
// Fallback: if baseModel has no provider but differs from resolvedModelName,
// try using the full model name. This handles edge cases where custom models
@@ -9,9 +9,9 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestWriteErrorResponse_AddonHeadersDisabledByDefault(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package handlers
import (
"testing"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
"golang.org/x/net/context"
)
@@ -7,9 +7,9 @@ import (
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestGetRequestDetails_PreservesSuffix(t *testing.T) {
@@ -8,11 +8,11 @@ import (
"sync"
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
type failOnceStreamExecutor struct {
+5 -5
View File
@@ -14,11 +14,11 @@ import (
"sync"
"github.com/gin-gonic/gin"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
responsesconverter "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/openai/responses"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
responsesconverter "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/responses"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -14,9 +14,9 @@ import (
"time"
"github.com/gin-gonic/gin"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -10,9 +10,9 @@ import (
"testing"
"github.com/gin-gonic/gin"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/tidwall/gjson"
)
@@ -9,11 +9,11 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
type compactCaptureExecutor struct {
@@ -16,10 +16,10 @@ import (
"sort"
"github.com/gin-gonic/gin"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -8,9 +8,9 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestForwardResponsesStreamTerminalErrorUsesResponsesErrorChunk(t *testing.T) {
@@ -7,9 +7,9 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/tidwall/gjson"
)
@@ -13,13 +13,13 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -14,12 +14,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/tidwall/gjson"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
)
type StreamForwardOptions struct {
+82 -7
View File
@@ -1,16 +1,21 @@
// Package api exposes helpers for embedding CLIProxyAPI.
//
// It wraps internal management handler types so external projects can integrate
// management endpoints without importing internal packages.
// It wraps internal management handler types and helpers so external projects
// can integrate management endpoints without importing internal packages.
package api
import (
"context"
"github.com/gin-gonic/gin"
internalmanagement "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
internalmanagement "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
// Handler re-exports the management handler used by the internal HTTP API.
type Handler = internalmanagement.Handler
// ManagementTokenRequester exposes a limited subset of management endpoints for requesting tokens.
type ManagementTokenRequester interface {
RequestAnthropicToken(*gin.Context)
@@ -23,13 +28,23 @@ type ManagementTokenRequester interface {
}
type managementTokenRequester struct {
handler *internalmanagement.Handler
handler *Handler
}
// NewHandler creates a management handler for SDK consumers.
func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
return internalmanagement.NewHandler(cfg, configFilePath, manager)
}
// NewHandlerWithoutConfigFilePath creates a management handler that skips config file persistence.
func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manager) *Handler {
return internalmanagement.NewHandlerWithoutConfigFilePath(cfg, manager)
}
// NewManagementTokenRequester creates a limited management handler exposing only token request endpoints.
func NewManagementTokenRequester(cfg *config.Config, manager *coreauth.Manager) ManagementTokenRequester {
return &managementTokenRequester{
handler: internalmanagement.NewHandlerWithoutConfigFilePath(cfg, manager),
handler: NewHandlerWithoutConfigFilePath(cfg, manager),
}
}
@@ -60,3 +75,63 @@ func (m *managementTokenRequester) GetAuthStatus(c *gin.Context) {
func (m *managementTokenRequester) PostOAuthCallback(c *gin.Context) {
m.handler.PostOAuthCallback(c)
}
// WriteConfig persists management configuration to disk.
func WriteConfig(path string, data []byte) error {
return internalmanagement.WriteConfig(path, data)
}
// RegisterOAuthSession records a pending OAuth callback state.
func RegisterOAuthSession(state, provider string) {
internalmanagement.RegisterOAuthSession(state, provider)
}
// SetOAuthSessionError stores an OAuth session error message.
func SetOAuthSessionError(state, message string) {
internalmanagement.SetOAuthSessionError(state, message)
}
// CompleteOAuthSession marks a single OAuth session as completed.
func CompleteOAuthSession(state string) {
internalmanagement.CompleteOAuthSession(state)
}
// CompleteOAuthSessionsByProvider removes all pending OAuth sessions for a provider.
func CompleteOAuthSessionsByProvider(provider string) int {
return internalmanagement.CompleteOAuthSessionsByProvider(provider)
}
// GetOAuthSession returns the current OAuth session state.
func GetOAuthSession(state string) (provider string, status string, ok bool) {
return internalmanagement.GetOAuthSession(state)
}
// IsOAuthSessionPending reports whether a provider/state pair is still pending.
func IsOAuthSessionPending(state, provider string) bool {
return internalmanagement.IsOAuthSessionPending(state, provider)
}
// ValidateOAuthState validates an OAuth state token.
func ValidateOAuthState(state string) error {
return internalmanagement.ValidateOAuthState(state)
}
// NormalizeOAuthProvider normalizes a provider name to its canonical form.
func NormalizeOAuthProvider(provider string) (string, error) {
return internalmanagement.NormalizeOAuthProvider(provider)
}
// WriteOAuthCallbackFile writes an OAuth callback payload to disk.
func WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string) (string, error) {
return internalmanagement.WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage)
}
// WriteOAuthCallbackFileForPendingSession writes an OAuth callback payload for a pending session.
func WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage string) (string, error) {
return internalmanagement.WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage)
}
// PopulateAuthContext copies auth metadata from a Gin context into a request context.
func PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context {
return internalmanagement.PopulateAuthContext(ctx, c)
}
+4 -4
View File
@@ -8,10 +8,10 @@ import (
"time"
"github.com/gin-gonic/gin"
internalapi "github.com/router-for-me/CLIProxyAPI/v6/internal/api"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
internalapi "github.com/router-for-me/CLIProxyAPI/v7/internal/api"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/logging"
)
// ServerOption customises HTTP server construction.
+6 -6
View File
@@ -8,12 +8,12 @@ import (
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/antigravity"
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
+6 -6
View File
@@ -7,13 +7,13 @@ import (
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
// legacy client removed
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
+6 -6
View File
@@ -7,13 +7,13 @@ import (
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
// legacy client removed
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
+5 -5
View File
@@ -13,11 +13,11 @@ import (
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package auth
import (
"fmt"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
)
// ProjectSelectionError indicates that the user must choose a specific project ID.
+5 -1
View File
@@ -15,7 +15,7 @@ import (
"sync"
"time"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// FileTokenStore persists token records and auth metadata using the filesystem as backing storage.
@@ -72,6 +72,10 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str
switch {
case auth.Storage != nil:
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
auth.Metadata["disabled"] = auth.Disabled
if setter, ok := auth.Storage.(metadataSetter); ok {
setter.SetMetadata(auth.Metadata)
}
+64
View File
@@ -0,0 +1,64 @@
package auth
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
type testTokenStorage struct {
meta map[string]any
}
func (s *testTokenStorage) SetMetadata(meta map[string]any) { s.meta = meta }
func (s *testTokenStorage) SaveTokenToFile(authFilePath string) error {
raw, err := json.Marshal(s.meta)
if err != nil {
return err
}
return os.WriteFile(authFilePath, raw, 0o600)
}
func TestFileTokenStore_Save_DisabledPersistsFlagForTokenStorage(t *testing.T) {
ctx := context.Background()
baseDir := t.TempDir()
path := filepath.Join(baseDir, "disabled.json")
if err := os.WriteFile(path, []byte(`{"type":"test","disabled":true}`), 0o600); err != nil {
t.Fatalf("seed auth file: %v", err)
}
store := NewFileTokenStore()
store.SetBaseDir(baseDir)
storage := &testTokenStorage{}
auth := &cliproxyauth.Auth{
ID: "disabled.json",
Provider: "test",
FileName: "disabled.json",
Disabled: true,
Storage: storage,
Metadata: map[string]any{"type": "test"},
}
if _, err := store.Save(ctx, auth); err != nil {
t.Fatalf("Save() error: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read auth file: %v", err)
}
var meta map[string]any
if err := json.Unmarshal(raw, &meta); err != nil {
t.Fatalf("unmarshal auth file: %v", err)
}
if disabled, _ := meta["disabled"].(bool); !disabled {
t.Fatalf("disabled=%v, want true (raw=%s)", meta["disabled"], string(raw))
}
}
+3 -3
View File
@@ -5,10 +5,10 @@ import (
"fmt"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/gemini"
// legacy client removed
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// GeminiAuthenticator implements the login flow for Google Gemini CLI accounts.
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"errors"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
var ErrRefreshNotSupported = errors.New("cliproxy auth: refresh not supported")
+4 -4
View File
@@ -6,10 +6,10 @@ import (
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi"
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"fmt"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// Manager aggregates authenticators and coordinates persistence via a token store.
+1 -1
View File
@@ -3,7 +3,7 @@ package auth
import (
"time"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func init() {
+1 -1
View File
@@ -3,7 +3,7 @@ package auth
import (
"sync"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
var (
@@ -7,9 +7,9 @@ import (
"testing"
"time"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type antigravityCreditsFallbackExecutor struct {
@@ -4,7 +4,7 @@ import (
"context"
"testing"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestLookupAPIKeyUpstreamModel(t *testing.T) {
+1 -1
View File
@@ -336,7 +336,7 @@ func (l *authAutoRefreshLoop) remove(authID string) {
}
func nextRefreshCheckAt(now time.Time, auth *Auth, interval time.Duration) (time.Time, bool) {
if auth == nil || auth.Disabled {
if auth == nil {
return time.Time{}, false
}
+25 -3
View File
@@ -34,9 +34,31 @@ func setRefreshLeadFactory(t *testing.T, provider string, factory func() *time.D
func TestNextRefreshCheckAt_DisabledUnschedule(t *testing.T) {
now := time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC)
auth := &Auth{ID: "a1", Provider: "test", Disabled: true}
if _, ok := nextRefreshCheckAt(now, auth, 15*time.Minute); ok {
t.Fatalf("nextRefreshCheckAt() ok = true, want false")
expiry := now.Add(time.Hour)
lead := 10 * time.Minute
setRefreshLeadFactory(t, "disabled-schedule", func() *time.Duration {
d := lead
return &d
})
auth := &Auth{
ID: "a1",
Provider: "disabled-schedule",
Disabled: true,
Status: StatusDisabled,
Metadata: map[string]any{
"email": "x@example.com",
"expires_at": expiry.Format(time.RFC3339),
},
}
got, ok := nextRefreshCheckAt(now, auth, 15*time.Minute)
if !ok {
t.Fatalf("nextRefreshCheckAt() ok = false, want true")
}
want := expiry.Add(-lead)
if !got.Equal(want) {
t.Fatalf("nextRefreshCheckAt() = %s, want %s", got, want)
}
}
+285 -18
View File
@@ -16,13 +16,14 @@ import (
"time"
"github.com/google/uuid"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"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/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
log "github.com/sirupsen/logrus"
)
@@ -50,6 +51,7 @@ type ExecutionSessionCloser interface {
}
const (
homeAuthCountMetadataKey = "__cliproxy_home_auth_count"
// CloseAllExecutionSessionsID asks an executor to release all active execution sessions.
// Executors that do not support this marker may ignore it.
CloseAllExecutionSessionsID = "__all_execution_sessions__"
@@ -377,6 +379,15 @@ func (m *Manager) SetConfig(cfg *internalconfig.Config) {
m.rebuildAPIKeyModelAliasFromRuntimeConfig()
}
// HomeEnabled reports whether the home control plane integration is enabled in the runtime config.
func (m *Manager) HomeEnabled() bool {
if m == nil {
return false
}
cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)
return cfg != nil && cfg.Home.Enabled
}
func (m *Manager) lookupAPIKeyUpstreamModel(authID, requestedModel string) string {
if m == nil {
return ""
@@ -522,6 +533,11 @@ func preserveRequestedModelSuffix(requestedModel, resolved string) string {
}
func (m *Manager) executionModelCandidates(auth *Auth, routeModel string) []string {
if auth != nil && auth.Attributes != nil {
if homeModel := strings.TrimSpace(auth.Attributes[homeUpstreamModelAttributeKey]); homeModel != "" {
return []string{homeModel}
}
}
requestedModel := rewriteModelForAuth(routeModel, auth)
requestedModel = m.applyOAuthModelAlias(auth, requestedModel)
if pool := m.resolveOpenAICompatUpstreamModelPool(auth, requestedModel); len(pool) > 0 {
@@ -555,6 +571,14 @@ func (m *Manager) selectionModelKeyForAuth(auth *Auth, routeModel string) string
}
func (m *Manager) stateModelForExecution(auth *Auth, routeModel, upstreamModel string, pooled bool) string {
if auth != nil && auth.Attributes != nil {
if homeModel := strings.TrimSpace(auth.Attributes[homeUpstreamModelAttributeKey]); homeModel != "" {
if resolved := strings.TrimSpace(upstreamModel); resolved != "" {
return resolved
}
return homeModel
}
}
stateModel := executionResultModel(routeModel, upstreamModel, pooled)
selectionModel := m.selectionModelForAuth(auth, routeModel)
if canonicalModelKey(selectionModel) == canonicalModelKey(upstreamModel) && strings.TrimSpace(selectionModel) != "" {
@@ -1293,19 +1317,25 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
}
routeModel := req.Model
opts = ensureRequestedModelMetadata(opts, routeModel)
homeMode := m.HomeEnabled()
homeAuthCount := 1
tried := make(map[string]struct{})
attempted := make(map[string]struct{})
var lastErr error
for {
if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
if !homeMode && maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
if lastErr != nil {
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
}
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
pickOpts := opts
if homeMode {
pickOpts = withHomeAuthCount(opts, homeAuthCount)
}
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried)
if errPick != nil {
if lastErr != nil {
if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) {
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, errPick
@@ -1361,6 +1391,9 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
return cliproxyexecutor.Response{}, authErr
}
lastErr = authErr
if homeMode {
homeAuthCount++
}
continue
}
}
@@ -1372,19 +1405,25 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
}
routeModel := req.Model
opts = ensureRequestedModelMetadata(opts, routeModel)
homeMode := m.HomeEnabled()
homeAuthCount := 1
tried := make(map[string]struct{})
attempted := make(map[string]struct{})
var lastErr error
for {
if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
if !homeMode && maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
if lastErr != nil {
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
}
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
pickOpts := opts
if homeMode {
pickOpts = withHomeAuthCount(opts, homeAuthCount)
}
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried)
if errPick != nil {
if lastErr != nil {
if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) {
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, errPick
@@ -1440,6 +1479,9 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
return cliproxyexecutor.Response{}, authErr
}
lastErr = authErr
if homeMode {
homeAuthCount++
}
continue
}
}
@@ -1451,19 +1493,25 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
}
routeModel := req.Model
opts = ensureRequestedModelMetadata(opts, routeModel)
homeMode := m.HomeEnabled()
homeAuthCount := 1
tried := make(map[string]struct{})
attempted := make(map[string]struct{})
var lastErr error
for {
if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
if !homeMode && maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
if lastErr != nil {
return nil, lastErr
}
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
}
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
pickOpts := opts
if homeMode {
pickOpts = withHomeAuthCount(opts, homeAuthCount)
}
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried)
if errPick != nil {
if lastErr != nil {
if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) {
return nil, lastErr
}
return nil, errPick
@@ -1493,6 +1541,9 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
return nil, errStream
}
lastErr = errStream
if homeMode {
homeAuthCount++
}
continue
}
return streamResult, nil
@@ -1520,6 +1571,40 @@ func ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel
return opts
}
func withHomeAuthCount(opts cliproxyexecutor.Options, count int) cliproxyexecutor.Options {
if count <= 0 {
count = 1
}
meta := make(map[string]any, len(opts.Metadata)+1)
for k, v := range opts.Metadata {
meta[k] = v
}
meta[homeAuthCountMetadataKey] = count
opts.Metadata = meta
return opts
}
func homeAuthCountFromMetadata(meta map[string]any) int {
if len(meta) == 0 {
return 1
}
switch value := meta[homeAuthCountMetadataKey].(type) {
case int:
if value > 0 {
return value
}
case int64:
if value > 0 {
return int(value)
}
case float64:
if value > 0 {
return int(value)
}
}
return 1
}
func hasRequestedModelMetadata(meta map[string]any) bool {
if len(meta) == 0 {
return false
@@ -2710,6 +2795,11 @@ func (m *Manager) routeAwareSelectionRequired(auth *Auth, routeModel string) boo
}
func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) {
if m.HomeEnabled() {
auth, exec, _, err := m.pickNextViaHome(ctx, model, opts)
return auth, exec, err
}
pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)
disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata)
@@ -2779,6 +2869,11 @@ func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, op
}
func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) {
if m.HomeEnabled() {
auth, exec, _, err := m.pickNextViaHome(ctx, model, opts)
return auth, exec, err
}
if !m.useSchedulerFastPath() {
return m.pickNextLegacy(ctx, provider, model, opts, tried)
}
@@ -2836,6 +2931,10 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli
}
func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) {
if m.HomeEnabled() {
return m.pickNextViaHome(ctx, model, opts)
}
pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)
disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata)
@@ -2928,6 +3027,10 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m
}
func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) {
if m.HomeEnabled() {
return m.pickNextViaHome(ctx, model, opts)
}
if !m.useSchedulerFastPath() {
return m.pickNextMixedLegacy(ctx, providers, model, opts, tried)
}
@@ -3012,6 +3115,170 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s
}
}
type homeErrorEnvelope struct {
Error *homeErrorDetail `json:"error"`
}
type homeErrorDetail struct {
Type string `json:"type"`
Message string `json:"message"`
Code string `json:"code,omitempty"`
}
const (
homeUpstreamModelAttributeKey = "home_upstream_model"
homeRequestRetryExceededErrorCode = "request_retry_exceeded"
)
func isHomeRequestRetryExceededError(err error) bool {
var authErr *Error
if !errors.As(err, &authErr) || authErr == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(authErr.Code), homeRequestRetryExceededErrorCode)
}
func shouldReturnLastErrorOnPickFailure(homeMode bool, lastErr error, errPick error) bool {
if lastErr == nil {
return false
}
if !homeMode {
return true
}
return isHomeRequestRetryExceededError(errPick)
}
type homeAuthDispatchResponse struct {
Model string `json:"model"`
Provider string `json:"provider"`
AuthIndex string `json:"auth_index"`
UserAPIKey string `json:"user_api_key"`
Auth Auth `json:"auth"`
}
func setHomeUserAPIKeyOnGinContext(ctx context.Context, apiKey string) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" || ctx == nil {
return
}
ginCtx, ok := ctx.Value("gin").(interface{ Set(string, any) })
if !ok || ginCtx == nil {
return
}
ginCtx.Set("userApiKey", apiKey)
}
func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts cliproxyexecutor.Options) (*Auth, ProviderExecutor, string, error) {
if m == nil {
return nil, nil, "", &Error{Code: "auth_not_found", Message: "no auth available"}
}
if ctx == nil {
ctx = context.Background()
}
client := home.Current()
if client == nil || !client.HeartbeatOK() {
return nil, nil, "", &Error{Code: "home_unavailable", Message: "home control center unavailable", HTTPStatus: http.StatusServiceUnavailable}
}
requestedModel := requestedModelFromMetadata(opts.Metadata, model)
sessionID := ExtractSessionID(opts.Headers, opts.OriginalRequest, opts.Metadata)
count := homeAuthCountFromMetadata(opts.Metadata)
raw, err := client.RPopAuth(ctx, requestedModel, sessionID, opts.Headers, count)
if err != nil {
return nil, nil, "", &Error{Code: "auth_not_found", Message: err.Error(), HTTPStatus: http.StatusServiceUnavailable}
}
var env homeErrorEnvelope
if errUnmarshal := json.Unmarshal(raw, &env); errUnmarshal == nil && env.Error != nil {
code := strings.TrimSpace(env.Error.Type)
if code == "" {
code = strings.TrimSpace(env.Error.Code)
}
msg := strings.TrimSpace(env.Error.Message)
if msg == "" {
msg = "home returned error"
}
status := http.StatusBadGateway
switch strings.ToLower(code) {
case "model_not_found":
status = http.StatusNotFound
case "authentication_error", "unauthorized":
status = http.StatusUnauthorized
}
return nil, nil, "", &Error{Code: code, Message: msg, HTTPStatus: status}
}
var dispatch homeAuthDispatchResponse
if errUnmarshal := json.Unmarshal(raw, &dispatch); errUnmarshal != nil {
return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned invalid auth payload", HTTPStatus: http.StatusBadGateway}
}
setHomeUserAPIKeyOnGinContext(ctx, dispatch.UserAPIKey)
auth := dispatch.Auth
if strings.TrimSpace(auth.ID) == "" {
// Backward compatibility: older home instances returned the auth directly.
if errUnmarshal := json.Unmarshal(raw, &auth); errUnmarshal != nil {
return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned invalid auth payload", HTTPStatus: http.StatusBadGateway}
}
}
if upstreamModel := strings.TrimSpace(dispatch.Model); upstreamModel != "" {
if auth.Attributes == nil {
auth.Attributes = make(map[string]string, 1)
}
auth.Attributes[homeUpstreamModelAttributeKey] = upstreamModel
}
if strings.TrimSpace(auth.ID) == "" {
return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned auth without id", HTTPStatus: http.StatusBadGateway}
}
providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
if providerKey == "" {
return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned auth without provider", HTTPStatus: http.StatusBadGateway}
}
homeAuthIndex := strings.TrimSpace(dispatch.AuthIndex)
if homeAuthIndex != "" {
auth.Index = homeAuthIndex
auth.indexAssigned = true
} else {
auth.EnsureIndex()
}
executor, ok := m.Executor(providerKey)
if !ok && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["base_url"]) != "" {
executor, ok = m.Executor("openai-compatibility")
if ok {
providerKey = "openai-compatibility"
}
}
if !ok {
return nil, nil, "", &Error{Code: "executor_not_found", Message: "executor not registered", HTTPStatus: http.StatusBadGateway}
}
return auth.Clone(), executor, providerKey, nil
}
func requestedModelFromMetadata(metadata map[string]any, fallback string) string {
if metadata != nil {
if v, ok := metadata[cliproxyexecutor.RequestedModelMetadataKey]; ok {
switch typed := v.(type) {
case string:
if trimmed := strings.TrimSpace(typed); trimmed != "" {
return trimmed
}
case []byte:
if trimmed := strings.TrimSpace(string(typed)); trimmed != "" {
return trimmed
}
}
}
}
fallback = strings.TrimSpace(fallback)
if fallback == "" {
return "unknown"
}
return fallback
}
func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opts cliproxyexecutor.Options) []creditsCandidateEntry {
if m == nil {
return nil
@@ -3271,7 +3538,7 @@ func (m *Manager) queueRefreshReschedule(authID string) {
}
func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool {
if a == nil || a.Disabled {
if a == nil {
return false
}
if !a.NextRefreshAfter.IsZero() && now.Before(a.NextRefreshAfter) {
@@ -3478,7 +3745,7 @@ func lookupMetadataTime(meta map[string]any, keys ...string) (time.Time, bool) {
func (m *Manager) markRefreshPending(id string, now time.Time) bool {
m.mu.Lock()
auth, ok := m.auths[id]
if !ok || auth == nil || auth.Disabled {
if !ok || auth == nil {
m.mu.Unlock()
return false
}
@@ -4,7 +4,7 @@ import (
"testing"
"time"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
func TestFindAllAntigravityCreditsCandidateAuths_PrefersKnownCreditsThenUnknown(t *testing.T) {
@@ -6,7 +6,7 @@ import (
"sync"
"testing"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type replaceAwareExecutor struct {
@@ -7,10 +7,10 @@ import (
"testing"
"time"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
)
type aliasRoutingExecutor struct {
@@ -8,9 +8,9 @@ import (
"time"
"github.com/google/uuid"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
const requestScopedNotFoundMessage = "Item with id 'rs_0b5f3eb6f51f175c0169ca74e4a85881998539920821603a74' not found. Items are not persisted when `store` is set to false. Try again with `store` set to true, or remove this item from your input."
@@ -6,8 +6,8 @@ import (
"net/http"
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type schedulerProviderTestExecutor struct {
+2 -2
View File
@@ -3,8 +3,8 @@ package auth
import (
"strings"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
)
type modelAliasEntry interface {
+1 -1
View File
@@ -3,7 +3,7 @@ package auth
import (
"testing"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) {
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"sync"
"testing"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type openAICompatPoolExecutor struct {
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
// schedulerStrategy identifies which built-in routing semantics the scheduler should apply.
@@ -6,8 +6,8 @@ import (
"net/http"
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type schedulerBenchmarkExecutor struct {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type schedulerTestExecutor struct{}
+3 -3
View File
@@ -18,9 +18,9 @@ import (
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
// RoundRobinSelector provides a simple provider scoped round-robin selection strategy.
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"testing"
"time"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
func TestFillFirstSelectorPick_Deterministic(t *testing.T) {
+58 -28
View File
@@ -7,12 +7,13 @@ import (
"encoding/json"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth"
baseauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth"
)
// PostAuthHook defines a function that is called after an Auth record is created
@@ -256,45 +257,65 @@ func (a *Auth) indexSeed() string {
return ""
}
if fileName := strings.TrimSpace(a.FileName); fileName != "" {
return "file:" + fileName
}
providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
provider := strings.ToLower(strings.TrimSpace(a.Provider))
compatName := ""
baseURL := ""
apiKey := ""
source := ""
filePath := ""
if a.Attributes != nil {
if value := strings.TrimSpace(a.Attributes["provider_key"]); value != "" {
providerKey = strings.ToLower(value)
}
compatName = strings.ToLower(strings.TrimSpace(a.Attributes["compat_name"]))
compatName = strings.TrimSpace(a.Attributes["compat_name"])
baseURL = strings.TrimSpace(a.Attributes["base_url"])
apiKey = strings.TrimSpace(a.Attributes["api_key"])
source = strings.TrimSpace(a.Attributes["source"])
filePath = strings.TrimSpace(a.Attributes["path"])
if filePath == "" {
filePath = strings.TrimSpace(a.Attributes["source"])
}
}
proxyURL := strings.TrimSpace(a.ProxyURL)
hasCredentialIdentity := compatName != "" || baseURL != "" || proxyURL != "" || apiKey != "" || source != ""
if providerKey != "" && hasCredentialIdentity {
parts := []string{"provider=" + providerKey}
if compatName != "" {
parts = append(parts, "compat="+compatName)
if filePath == "" {
filePath = strings.TrimSpace(a.FileName)
}
if filePath == "" {
filePath = strings.TrimSpace(a.ID)
}
if filePath != "" && strings.HasSuffix(strings.ToLower(filePath), ".json") {
abs, errAbs := filepath.Abs(filePath)
if errAbs == nil && strings.TrimSpace(abs) != "" {
filePath = abs
}
if baseURL != "" {
parts = append(parts, "base="+baseURL)
filePath = filepath.Clean(filePath)
authType := ""
if a.Metadata != nil {
if rawType, ok := a.Metadata["type"].(string); ok {
authType = strings.TrimSpace(rawType)
}
}
if proxyURL != "" {
parts = append(parts, "proxy="+proxyURL)
if authType == "" {
authType = strings.TrimSpace(provider)
}
if apiKey != "" {
parts = append(parts, "api_key="+apiKey)
authType = strings.ToLower(strings.TrimSpace(authType))
if authType != "" {
return authType + ":" + filePath
}
if source != "" {
parts = append(parts, "source="+source)
}
apiPrefix := ""
if apiKey != "" {
switch {
case compatName != "" || strings.EqualFold(provider, "openai-compatibility"):
apiPrefix = "openai-compatibility"
case strings.EqualFold(provider, "gemini"):
apiPrefix = "gemini-api-key"
case strings.EqualFold(provider, "codex"):
apiPrefix = "codex-api-key"
case strings.EqualFold(provider, "claude"):
apiPrefix = "claude-api-key"
}
return "config:" + strings.Join(parts, "\x00")
}
if apiPrefix != "" {
return apiPrefix + ":" + strings.TrimSpace(baseURL) + "+" + strings.TrimSpace(apiKey)
}
if id := strings.TrimSpace(a.ID); id != "" {
@@ -355,19 +376,28 @@ func (a *Auth) ProxyInfo() string {
return "via proxy"
}
// DisableCoolingOverride returns the auth-file scoped disable_cooling override when present.
// DisableCoolingOverride returns the auth scoped disable_cooling override when present.
// The value is read from metadata key "disable_cooling" (or legacy "disable-cooling").
//
// NOTE: This override is intentionally "true-only". When the metadata value is false, it is treated
// as "not set" so the global disable-cooling flag can still take effect.
func (a *Auth) DisableCoolingOverride() (bool, bool) {
if a == nil || a.Metadata == nil {
return false, false
}
if val, ok := a.Metadata["disable_cooling"]; ok {
if parsed, okParse := parseBoolAny(val); okParse {
if !parsed {
return false, false
}
return parsed, true
}
}
if val, ok := a.Metadata["disable-cooling"]; ok {
if parsed, okParse := parseBoolAny(val); okParse {
if !parsed {
return false, false
}
return parsed, true
}
}
+36 -2
View File
@@ -1,6 +1,8 @@
package auth
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -96,8 +98,40 @@ func TestEnsureIndexUsesCredentialIdentity(t *testing.T) {
if geminiIndex == altBaseIndex {
t.Fatalf("same provider/key with different base_url produced duplicate auth_index %q", geminiIndex)
}
if geminiIndex == duplicateIndex {
t.Fatalf("duplicate config entries should be separated by source-derived seed, got %q", geminiIndex)
if geminiIndex != duplicateIndex {
t.Fatalf("same provider/key with different source should share auth_index, got %q vs %q", geminiIndex, duplicateIndex)
}
}
func TestEnsureIndexUsesOAuthTypeAndAbsolutePath(t *testing.T) {
t.Parallel()
wd, errWd := os.Getwd()
if errWd != nil {
t.Fatalf("os.Getwd returned error: %v", errWd)
}
relPath := "test-oauth.json"
absPath := filepath.Join(wd, relPath)
expectedSeed := "gemini:" + filepath.Clean(absPath)
expectedIndex := stableAuthIndex(expectedSeed)
a := &Auth{
Provider: "gemini-cli",
Attributes: map[string]string{
"path": relPath,
},
Metadata: map[string]any{
"type": "gemini",
},
}
got := a.EnsureIndex()
if got == "" {
t.Fatal("auth index should not be empty")
}
if got != expectedIndex {
t.Fatalf("auth index = %q, want %q", got, expectedIndex)
}
}
+7 -7
View File
@@ -8,12 +8,12 @@ import (
"strings"
"time"
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
// Builder constructs a Service instance with customizable providers.
@@ -214,7 +214,7 @@ func (b *Builder) Build() (*Service, error) {
if b.cfg != nil {
strategy = strings.ToLower(strings.TrimSpace(b.cfg.Routing.Strategy))
// Support both legacy ClaudeCodeSessionAffinity and new universal SessionAffinity
sessionAffinity = b.cfg.Routing.ClaudeCodeSessionAffinity || b.cfg.Routing.SessionAffinity
sessionAffinity = b.cfg.Routing.SessionAffinity
if ttlStr := strings.TrimSpace(b.cfg.Routing.SessionAffinityTTL); ttlStr != "" {
if parsed, err := time.ParseDuration(ttlStr); err == nil && parsed > 0 {
sessionAffinityTTL = parsed
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http"
"net/url"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
// RequestedModelMetadataKey stores the client-requested model name in Options.Metadata.
+1 -1
View File
@@ -1,6 +1,6 @@
package cliproxy
import "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
import "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
// ModelInfo re-exports the registry model info structure.
type ModelInfo = registry.ModelInfo
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"context"
"net/http"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
// Context encapsulates execution state shared across middleware, translators, and executors.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
log "github.com/sirupsen/logrus"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package cliproxy
import (
"context"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
// NewFileTokenClientProvider returns the default token-backed client loader.
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"sync"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http"
"testing"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestRoundTripperForDirectBypassesProxy(t *testing.T) {
+327 -124
View File
@@ -12,17 +12,18 @@ import (
"sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
"github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
"github.com/router-for-me/CLIProxyAPI/v7/internal/wsrelay"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
log "github.com/sirupsen/logrus"
)
@@ -36,6 +37,9 @@ type Service struct {
// cfgMu protects concurrent access to the configuration.
cfgMu sync.RWMutex
// configUpdateMu serializes config updates across watcher + home.
configUpdateMu sync.Mutex
// configPath is the path to the configuration file.
configPath string
@@ -89,6 +93,9 @@ type Service struct {
// wsGateway manages websocket Gemini providers.
wsGateway *wsrelay.Manager
homeClient *home.Client
homeCancel context.CancelFunc
}
// RegisterUsagePlugin registers a usage plugin on the global usage manager.
@@ -462,6 +469,249 @@ func (s *Service) rebindExecutors() {
}
}
func (s *Service) applyConfigUpdate(newCfg *config.Config) {
if s == nil {
return
}
s.configUpdateMu.Lock()
defer s.configUpdateMu.Unlock()
previousStrategy := ""
var previousSessionAffinity bool
var previousSessionAffinityTTL string
s.cfgMu.RLock()
if s.cfg != nil {
previousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy))
previousSessionAffinity = s.cfg.Routing.SessionAffinity
previousSessionAffinityTTL = s.cfg.Routing.SessionAffinityTTL
}
s.cfgMu.RUnlock()
if newCfg == nil {
s.cfgMu.RLock()
newCfg = s.cfg
s.cfgMu.RUnlock()
}
if newCfg == nil {
return
}
nextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy))
normalizeStrategy := func(strategy string) string {
switch strategy {
case "fill-first", "fillfirst", "ff":
return "fill-first"
default:
return "round-robin"
}
}
previousStrategy = normalizeStrategy(previousStrategy)
nextStrategy = normalizeStrategy(nextStrategy)
nextSessionAffinity := newCfg.Routing.SessionAffinity
nextSessionAffinityTTL := newCfg.Routing.SessionAffinityTTL
selectorChanged := previousStrategy != nextStrategy ||
previousSessionAffinity != nextSessionAffinity ||
previousSessionAffinityTTL != nextSessionAffinityTTL
if s.coreManager != nil && selectorChanged {
var selector coreauth.Selector
switch nextStrategy {
case "fill-first":
selector = &coreauth.FillFirstSelector{}
default:
selector = &coreauth.RoundRobinSelector{}
}
if nextSessionAffinity {
ttl := time.Hour
if ttlStr := strings.TrimSpace(nextSessionAffinityTTL); ttlStr != "" {
if parsed, err := time.ParseDuration(ttlStr); err == nil && parsed > 0 {
ttl = parsed
}
}
selector = coreauth.NewSessionAffinitySelectorWithConfig(coreauth.SessionAffinityConfig{
Fallback: selector,
TTL: ttl,
})
}
s.coreManager.SetSelector(selector)
}
s.applyRetryConfig(newCfg)
s.applyPprofConfig(newCfg)
if s.server != nil {
s.server.UpdateClients(newCfg)
}
s.cfgMu.Lock()
s.cfg = newCfg
s.cfgMu.Unlock()
if s.coreManager != nil {
s.coreManager.SetConfig(newCfg)
s.coreManager.SetOAuthModelAlias(newCfg.OAuthModelAlias)
}
s.rebindExecutors()
}
func forceHomeRuntimeConfig(cfg *config.Config) {
if cfg == nil {
return
}
cfg.APIKeys = nil
cfg.UsageStatisticsEnabled = true
cfg.DisableCooling = true
cfg.WebsocketAuth = false
cfg.EnableGeminiCLIEndpoint = false
cfg.RemoteManagement.AllowRemote = false
cfg.RemoteManagement.DisableControlPanel = true
}
func (s *Service) registerHomeExecutors() {
if s == nil || s.coreManager == nil || s.cfg == nil {
return
}
// Register baseline executors so home-dispatched auth entries can execute without
// requiring any local auth-dir credentials.
s.coreManager.RegisterExecutor(executor.NewCodexAutoExecutor(s.cfg))
s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
s.coreManager.RegisterExecutor(executor.NewGeminiExecutor(s.cfg))
s.coreManager.RegisterExecutor(executor.NewGeminiVertexExecutor(s.cfg))
s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg))
s.coreManager.RegisterExecutor(executor.NewAIStudioExecutor(s.cfg, "", s.wsGateway))
s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg))
s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg))
s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("openai-compatibility", s.cfg))
}
func (s *Service) applyHomeOverlay(remoteCfg *config.Config) {
if s == nil || remoteCfg == nil {
return
}
s.cfgMu.RLock()
baseCfg := s.cfg
s.cfgMu.RUnlock()
if baseCfg == nil {
return
}
merged := *remoteCfg
merged.Host = baseCfg.Host
merged.Port = baseCfg.Port
merged.TLS = baseCfg.TLS
merged.Home = baseCfg.Home
forceHomeRuntimeConfig(&merged)
s.applyConfigUpdate(&merged)
}
func (s *Service) startHomeUsageForwarder(ctx context.Context, client *home.Client) {
if s == nil || client == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
sleep := func(d time.Duration) bool {
if d <= 0 {
return true
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
go func() {
for {
select {
case <-ctx.Done():
return
default:
}
if !client.HeartbeatOK() {
if !sleep(time.Second) {
return
}
continue
}
items := redisqueue.PopOldest(64)
if len(items) == 0 {
if !sleep(500 * time.Millisecond) {
return
}
continue
}
for i := range items {
if errPush := client.LPushUsage(ctx, items[i]); errPush != nil {
for j := i; j < len(items); j++ {
redisqueue.Enqueue(items[j])
}
if !sleep(time.Second) {
return
}
break
}
}
}
}()
}
func (s *Service) startHomeSubscriber(ctx context.Context) {
if s == nil {
return
}
s.cfgMu.RLock()
cfg := s.cfg
s.cfgMu.RUnlock()
if cfg == nil || !cfg.Home.Enabled {
return
}
if s.homeCancel != nil {
s.homeCancel()
s.homeCancel = nil
}
if s.homeClient != nil {
s.homeClient.Close()
s.homeClient = nil
}
homeCtx := ctx
if homeCtx == nil {
homeCtx = context.Background()
}
homeCtx, cancel := context.WithCancel(homeCtx)
s.homeCancel = cancel
client := home.New(cfg.Home)
s.homeClient = client
home.SetCurrent(client)
go client.StartConfigSubscriber(homeCtx, func(raw []byte) error {
parsed, err := config.ParseConfigBytes(raw)
if err != nil {
log.Warnf("failed to parse home config payload: %v", err)
return err
}
s.applyHomeOverlay(parsed)
return nil
})
s.startHomeUsageForwarder(homeCtx, client)
}
// Run starts the service and blocks until the context is cancelled or the server stops.
// It initializes all components including authentication, file watching, HTTP server,
// and starts processing requests. The method blocks until the context is cancelled.
@@ -480,6 +730,11 @@ func (s *Service) Run(ctx context.Context) error {
}
usage.StartDefault(ctx)
homeEnabled := s.cfg != nil && s.cfg.Home.Enabled
if homeEnabled {
forceHomeRuntimeConfig(s.cfg)
redisqueue.SetUsageStatisticsEnabled(true)
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
@@ -489,32 +744,36 @@ func (s *Service) Run(ctx context.Context) error {
}
}()
if err := s.ensureAuthDir(); err != nil {
return err
if !homeEnabled {
if errEnsureAuthDir := s.ensureAuthDir(); errEnsureAuthDir != nil {
return errEnsureAuthDir
}
}
s.applyRetryConfig(s.cfg)
if s.coreManager != nil {
if s.coreManager != nil && !homeEnabled {
if errLoad := s.coreManager.Load(ctx); errLoad != nil {
log.Warnf("failed to load auth store: %v", errLoad)
}
}
tokenResult, err := s.tokenProvider.Load(ctx, s.cfg)
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
if tokenResult == nil {
tokenResult = &TokenClientResult{}
}
if !homeEnabled {
tokenResult, err := s.tokenProvider.Load(ctx, s.cfg)
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
if tokenResult == nil {
tokenResult = &TokenClientResult{}
}
apiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg)
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
if apiKeyResult == nil {
apiKeyResult = &APIKeyClientResult{}
apiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg)
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
if apiKeyResult == nil {
apiKeyResult = &APIKeyClientResult{}
}
}
// legacy clients removed; no caches to refresh
@@ -526,6 +785,10 @@ func (s *Service) Run(ctx context.Context) error {
s.authManager = newDefaultAuthManager()
}
if homeEnabled {
s.startHomeSubscriber(ctx)
}
s.ensureWebsocketGateway()
if s.server != nil && s.wsGateway != nil {
s.server.AttachWebsocketRoute(s.wsGateway.Path(), s.wsGateway.Handler())
@@ -547,6 +810,12 @@ func (s *Service) Run(ctx context.Context) error {
})
}
if homeEnabled {
s.registerHomeExecutors()
// Home mode does not expose in-process Redis RESP usage output; usage is forwarded to home instead.
redisqueue.SetEnabled(true)
}
if s.hooks.OnBeforeStart != nil {
s.hooks.OnBeforeStart(s.cfg)
}
@@ -607,107 +876,31 @@ func (s *Service) Run(ctx context.Context) error {
s.hooks.OnAfterStart(s)
}
var watcherWrapper *WatcherWrapper
reloadCallback := func(newCfg *config.Config) {
previousStrategy := ""
var previousSessionAffinity bool
var previousSessionAffinityTTL string
s.cfgMu.RLock()
if s.cfg != nil {
previousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy))
previousSessionAffinity = s.cfg.Routing.ClaudeCodeSessionAffinity || s.cfg.Routing.SessionAffinity
previousSessionAffinityTTL = s.cfg.Routing.SessionAffinityTTL
if !homeEnabled {
var watcherWrapper *WatcherWrapper
reloadCallback := func(newCfg *config.Config) { s.applyConfigUpdate(newCfg) }
watcherWrapper, errCreate := s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback)
if errCreate != nil {
return fmt.Errorf("cliproxy: failed to create watcher: %w", errCreate)
}
s.cfgMu.RUnlock()
if newCfg == nil {
s.cfgMu.RLock()
newCfg = s.cfg
s.cfgMu.RUnlock()
s.watcher = watcherWrapper
s.ensureAuthUpdateQueue(ctx)
if s.authUpdates != nil {
watcherWrapper.SetAuthUpdateQueue(s.authUpdates)
}
if newCfg == nil {
return
watcherWrapper.SetConfig(s.cfg)
watcherCtx, watcherCancel := context.WithCancel(context.Background())
s.watcherCancel = watcherCancel
if errStart := watcherWrapper.Start(watcherCtx); errStart != nil {
return fmt.Errorf("cliproxy: failed to start watcher: %w", errStart)
}
nextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy))
normalizeStrategy := func(strategy string) string {
switch strategy {
case "fill-first", "fillfirst", "ff":
return "fill-first"
default:
return "round-robin"
}
}
previousStrategy = normalizeStrategy(previousStrategy)
nextStrategy = normalizeStrategy(nextStrategy)
nextSessionAffinity := newCfg.Routing.ClaudeCodeSessionAffinity || newCfg.Routing.SessionAffinity
nextSessionAffinityTTL := newCfg.Routing.SessionAffinityTTL
selectorChanged := previousStrategy != nextStrategy ||
previousSessionAffinity != nextSessionAffinity ||
previousSessionAffinityTTL != nextSessionAffinityTTL
if s.coreManager != nil && selectorChanged {
var selector coreauth.Selector
switch nextStrategy {
case "fill-first":
selector = &coreauth.FillFirstSelector{}
default:
selector = &coreauth.RoundRobinSelector{}
}
if nextSessionAffinity {
ttl := time.Hour
if ttlStr := strings.TrimSpace(nextSessionAffinityTTL); ttlStr != "" {
if parsed, err := time.ParseDuration(ttlStr); err == nil && parsed > 0 {
ttl = parsed
}
}
selector = coreauth.NewSessionAffinitySelectorWithConfig(coreauth.SessionAffinityConfig{
Fallback: selector,
TTL: ttl,
})
}
s.coreManager.SetSelector(selector)
}
s.applyRetryConfig(newCfg)
s.applyPprofConfig(newCfg)
if s.server != nil {
s.server.UpdateClients(newCfg)
}
s.cfgMu.Lock()
s.cfg = newCfg
s.cfgMu.Unlock()
if s.coreManager != nil {
s.coreManager.SetConfig(newCfg)
s.coreManager.SetOAuthModelAlias(newCfg.OAuthModelAlias)
}
s.rebindExecutors()
log.Info("file watcher started for config and auth directory changes")
}
watcherWrapper, err = s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback)
if err != nil {
return fmt.Errorf("cliproxy: failed to create watcher: %w", err)
}
s.watcher = watcherWrapper
s.ensureAuthUpdateQueue(ctx)
if s.authUpdates != nil {
watcherWrapper.SetAuthUpdateQueue(s.authUpdates)
}
watcherWrapper.SetConfig(s.cfg)
watcherCtx, watcherCancel := context.WithCancel(context.Background())
s.watcherCancel = watcherCancel
if err = watcherWrapper.Start(watcherCtx); err != nil {
return fmt.Errorf("cliproxy: failed to start watcher: %w", err)
}
log.Info("file watcher started for config and auth directory changes")
// Prefer core auth manager auto refresh if available.
if s.coreManager != nil {
if s.coreManager != nil && !homeEnabled {
interval := 15 * time.Minute
s.coreManager.StartAutoRefresh(context.Background(), interval)
log.Infof("core auth auto-refresh started (interval=%s)", interval)
@@ -717,8 +910,8 @@ func (s *Service) Run(ctx context.Context) error {
case <-ctx.Done():
log.Debug("service context cancelled, shutting down...")
return ctx.Err()
case err = <-s.serverErr:
return err
case errServer := <-s.serverErr:
return errServer
}
}
@@ -741,6 +934,16 @@ func (s *Service) Shutdown(ctx context.Context) error {
ctx = context.Background()
}
if s.homeCancel != nil {
s.homeCancel()
s.homeCancel = nil
}
if s.homeClient != nil {
s.homeClient.Close()
s.homeClient = nil
}
home.ClearCurrent()
// legacy refresh loop removed; only stopping core auth manager below
if s.watcherCancel != nil {
@@ -3,8 +3,8 @@ package cliproxy
import (
"testing"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestEnsureExecutorsForAuth_CodexDoesNotReplaceInNormalMode(t *testing.T) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"strings"
"testing"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestRegisterModelsForAuth_UsesPreMergedExcludedModelsAttribute(t *testing.T) {
@@ -3,7 +3,7 @@ package cliproxy
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestApplyOAuthModelAlias_Rename(t *testing.T) {
+32 -3
View File
@@ -5,9 +5,9 @@ import (
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestServiceApplyCoreAuthAddOrUpdate_DeleteReAddDoesNotInheritStaleRuntimeState(t *testing.T) {
@@ -99,3 +99,32 @@ func TestServiceApplyCoreAuthAddOrUpdate_DeleteReAddDoesNotInheritStaleRuntimeSt
t.Fatalf("expected re-added auth to re-register models in global registry")
}
}
func TestForceHomeRuntimeConfigEnablesUsageStatistics(t *testing.T) {
cfg := &config.Config{
UsageStatisticsEnabled: false,
}
forceHomeRuntimeConfig(cfg)
if !cfg.UsageStatisticsEnabled {
t.Fatal("expected home runtime config to force usage statistics enabled")
}
}
func TestApplyHomeOverlayForcesUsageStatisticsEnabled(t *testing.T) {
baseCfg := &config.Config{}
baseCfg.Home.Enabled = true
service := &Service{cfg: baseCfg}
service.applyHomeOverlay(&config.Config{
UsageStatisticsEnabled: false,
})
if service.cfg == nil || !service.cfg.UsageStatisticsEnabled {
t.Fatal("expected home overlay to force usage statistics enabled")
}
if !service.cfg.Home.Enabled {
t.Fatal("expected home overlay to preserve local home settings")
}
}
+3 -3
View File
@@ -6,9 +6,9 @@ package cliproxy
import (
"context"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
// TokenClientProvider loads clients backed by stored authentication tokens.
+7
View File
@@ -22,9 +22,16 @@ type Record struct {
RequestedAt time.Time
Latency time.Duration
Failed bool
Fail Failure
Detail Detail
}
// Failure holds HTTP failure metadata for an upstream request attempt.
type Failure struct {
StatusCode int
Body string
}
// Detail holds the token usage breakdown.
type Detail struct {
InputTokens int64
+3 -3
View File
@@ -3,9 +3,9 @@ package cliproxy
import (
"context"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func defaultWatcherFactory(configPath, authDir string, reload func(*config.Config)) (*WatcherWrapper, error) {
+3 -1
View File
@@ -4,7 +4,7 @@
// embed CLIProxyAPI without importing internal packages.
package config
import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
import internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
type SDKConfig = internalconfig.SDKConfig
@@ -41,6 +41,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
return internalconfig.LoadConfigOptional(configFile, optional)
}
func ParseConfigBytes(data []byte) (*Config, error) { return internalconfig.ParseConfigBytes(data) }
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
return internalconfig.SaveConfigPreserveComments(configFile, cfg)
}
+1 -1
View File
@@ -1,7 +1,7 @@
// Package logging re-exports request logging primitives for SDK consumers.
package logging
import internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
import internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
const defaultErrorLogsMaxFiles = 10
+2 -2
View File
@@ -2,9 +2,9 @@
package builtin
import (
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
)
// Registry exposes the default registry populated with all built-in translators.