Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3374292
feat(quota): implement quota management and fetching for multiple pro…
dacsang97 Dec 29, 2025
38c721a
feat(quota): enhance quota data structure with rate limit windows for…
dacsang97 Dec 29, 2025
8f9c99c
feat(quota): add error handling for unknown providers in quota fetching
dacsang97 Dec 29, 2025
623274f
feat(quota): update comments and improve handling for unsupported pro…
dacsang97 Dec 29, 2025
a66c580
feat(quota): implement background quota refresh mechanism and related…
dacsang97 Dec 29, 2025
11b08ec
feat(quota): implement access token management with refresh functiona…
dacsang97 Dec 29, 2025
369f52d
feat(quota): enhance refresh interval handling and update LastUpdated…
dacsang97 Dec 29, 2025
173a01f
feat(quota): improve quota refresh handling and update LastUpdated lo…
dacsang97 Dec 29, 2025
d3fe406
fix(logging): improve request/response capture
hkfires Dec 28, 2025
cc2725c
fix(handlers): match raw error text before JSON body for duplicate de…
hkfires Dec 28, 2025
5bca7af
fix(handlers): preserve upstream response logs before duplicate detec…
hkfires Dec 28, 2025
9a2e319
chore: add codex, agents, and opencode dirs to ignore files
hkfires Dec 29, 2025
8efb911
fix(translators): correct key path for `system_instruction.parts` in …
luispater Dec 29, 2025
74bf4a6
feat(amp): add per-client upstream API key mapping support
hkfires Dec 29, 2025
6b2550d
feat(api): add id token claims extraction for codex auth entries
hkfires Dec 29, 2025
42f5731
refactor(api): simplify codex id token claims extraction
hkfires Dec 29, 2025
6c9b019
fix(antigravity): inject required placeholder when properties exist w…
LTbinglingfeng Dec 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ config.yaml

# Development/editor
bin/*
.claude/*
.vscode/*
.claude/*
.codex/*
.gemini/*
.serena/*
.agent/*
.agents/*
.opencode/*
.bmad/*
_bmad/*
_bmad-output/*
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,14 @@ GEMINI.md

# Tooling metadata
.vscode/*
.codex/*
.claude/*
.gemini/*
.serena/*
.agent/*
.agents/*
.agents/*
.opencode/*
.bmad/*
_bmad/*
_bmad-output/*
Expand Down
20 changes: 19 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ auth-dir: "~/.cli-proxy-api"
api-keys:
- "your-api-key-1"
- "your-api-key-2"
- "your-api-key-3"

# Enable debug logging
debug: false
Expand All @@ -52,6 +53,12 @@ logs-max-total-size-mb: 0
# When false, disable in-memory usage statistics aggregation
usage-statistics-enabled: false

# Quota refresh interval in seconds. When set to a positive value, the server
# will periodically fetch quota data for all configured providers in the background
# and cache it in memory. This eliminates the need to manually refresh quota data.
# Set to 0 to disable background refresh (fetch on-demand only). Default: 0
# quota-refresh-interval: 300 # 5 minutes

# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:[email protected]:1080/
proxy-url: ""

Expand All @@ -75,7 +82,6 @@ routing:

# When true, enable authentication for the WebSocket API (/v1/ws).
ws-auth: false

# Streaming behavior (SSE keep-alives + safe bootstrap retries).
# streaming:
# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
Expand Down Expand Up @@ -166,6 +172,18 @@ ws-auth: false
# upstream-url: "https://ampcode.com"
# # Optional: Override API key for Amp upstream (otherwise uses env or file)
# upstream-api-key: ""
# # Per-client upstream API key mapping
# # Maps client API keys (from top-level api-keys) to different Amp upstream API keys.
# # Useful when different clients need to use different Amp accounts/quotas.
# # If a client key isn't mapped, falls back to upstream-api-key (default behavior).
# upstream-api-keys:
# - upstream-api-key: "amp_key_for_team_a" # Upstream key to use for these clients
# api-keys: # Client keys that use this upstream key
# - "your-api-key-1"
# - "your-api-key-2"
# - upstream-api-key: "amp_key_for_team_b"
# api-keys:
# - "your-api-key-3"
# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (default: false)
# restrict-management-to-localhost: false
# # Force model mappings to run before checking local API keys (default: false)
Expand Down
37 changes: 37 additions & 0 deletions internal/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,46 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
log.WithError(err).Warnf("failed to stat auth file %s", path)
}
}
if claims := extractCodexIDTokenClaims(auth); claims != nil {
entry["id_token"] = claims
}
return entry
}

func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
if auth == nil || auth.Metadata == nil {
return nil
}
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
return nil
}
idTokenRaw, ok := auth.Metadata["id_token"].(string)
if !ok {
return nil
}
idToken := strings.TrimSpace(idTokenRaw)
if idToken == "" {
return nil
}
claims, err := codex.ParseJWTToken(idToken)
if err != nil || claims == nil {
return nil
}

result := gin.H{}
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != "" {
result["chatgpt_account_id"] = v
}
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" {
result["plan_type"] = v
}

if len(result) == 0 {
return nil
}
return result
}

func authEmail(auth *coreauth.Auth) string {
if auth == nil {
return ""
Expand Down
148 changes: 148 additions & 0 deletions internal/api/handlers/management/config_lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -940,3 +940,151 @@ func (h *Handler) GetAmpForceModelMappings(c *gin.Context) {
func (h *Handler) PutAmpForceModelMappings(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.ForceModelMappings = v })
}

// GetAmpUpstreamAPIKeys returns the ampcode upstream API keys mapping.
func (h *Handler) GetAmpUpstreamAPIKeys(c *gin.Context) {
if h == nil || h.cfg == nil {
c.JSON(200, gin.H{"upstream-api-keys": []config.AmpUpstreamAPIKeyEntry{}})
return
}
c.JSON(200, gin.H{"upstream-api-keys": h.cfg.AmpCode.UpstreamAPIKeys})
}

// PutAmpUpstreamAPIKeys replaces all ampcode upstream API keys mappings.
func (h *Handler) PutAmpUpstreamAPIKeys(c *gin.Context) {
var body struct {
Value []config.AmpUpstreamAPIKeyEntry `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
// Normalize entries: trim whitespace, filter empty
normalized := normalizeAmpUpstreamAPIKeyEntries(body.Value)
h.cfg.AmpCode.UpstreamAPIKeys = normalized
h.persist(c)
}

// PatchAmpUpstreamAPIKeys adds or updates upstream API keys entries.
// Matching is done by upstream-api-key value.
func (h *Handler) PatchAmpUpstreamAPIKeys(c *gin.Context) {
var body struct {
Value []config.AmpUpstreamAPIKeyEntry `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid body"})
return
}

existing := make(map[string]int)
for i, entry := range h.cfg.AmpCode.UpstreamAPIKeys {
existing[strings.TrimSpace(entry.UpstreamAPIKey)] = i
}

for _, newEntry := range body.Value {
upstreamKey := strings.TrimSpace(newEntry.UpstreamAPIKey)
if upstreamKey == "" {
continue
}
normalizedEntry := config.AmpUpstreamAPIKeyEntry{
UpstreamAPIKey: upstreamKey,
APIKeys: normalizeAPIKeysList(newEntry.APIKeys),
}
if idx, ok := existing[upstreamKey]; ok {
h.cfg.AmpCode.UpstreamAPIKeys[idx] = normalizedEntry
} else {
h.cfg.AmpCode.UpstreamAPIKeys = append(h.cfg.AmpCode.UpstreamAPIKeys, normalizedEntry)
existing[upstreamKey] = len(h.cfg.AmpCode.UpstreamAPIKeys) - 1
}
}
h.persist(c)
}

// DeleteAmpUpstreamAPIKeys removes specified upstream API keys entries.
// Body must be JSON: {"value": ["<upstream-api-key>", ...]}.
// If "value" is an empty array, clears all entries.
// If JSON is invalid or "value" is missing/null, returns 400 and does not persist any change.
func (h *Handler) DeleteAmpUpstreamAPIKeys(c *gin.Context) {
var body struct {
Value []string `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid body"})
return
}

if body.Value == nil {
c.JSON(400, gin.H{"error": "missing value"})
return
}

// Empty array means clear all
if len(body.Value) == 0 {
h.cfg.AmpCode.UpstreamAPIKeys = nil
h.persist(c)
return
}

toRemove := make(map[string]bool)
for _, key := range body.Value {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
toRemove[trimmed] = true
}
if len(toRemove) == 0 {
c.JSON(400, gin.H{"error": "empty value"})
return
}

newEntries := make([]config.AmpUpstreamAPIKeyEntry, 0, len(h.cfg.AmpCode.UpstreamAPIKeys))
for _, entry := range h.cfg.AmpCode.UpstreamAPIKeys {
if !toRemove[strings.TrimSpace(entry.UpstreamAPIKey)] {
newEntries = append(newEntries, entry)
}
}
h.cfg.AmpCode.UpstreamAPIKeys = newEntries
h.persist(c)
}

// normalizeAmpUpstreamAPIKeyEntries normalizes a list of upstream API key entries.
func normalizeAmpUpstreamAPIKeyEntries(entries []config.AmpUpstreamAPIKeyEntry) []config.AmpUpstreamAPIKeyEntry {
if len(entries) == 0 {
return nil
}
out := make([]config.AmpUpstreamAPIKeyEntry, 0, len(entries))
for _, entry := range entries {
upstreamKey := strings.TrimSpace(entry.UpstreamAPIKey)
if upstreamKey == "" {
continue
}
apiKeys := normalizeAPIKeysList(entry.APIKeys)
out = append(out, config.AmpUpstreamAPIKeyEntry{
UpstreamAPIKey: upstreamKey,
APIKeys: apiKeys,
})
}
if len(out) == 0 {
return nil
}
return out
}

// normalizeAPIKeysList trims and filters empty strings from a list of API keys.
func normalizeAPIKeysList(keys []string) []string {
if len(keys) == 0 {
return nil
}
out := make([]string, 0, len(keys))
for _, k := range keys {
trimmed := strings.TrimSpace(k)
if trimmed != "" {
out = append(out, trimmed)
}
}
if len(out) == 0 {
return nil
}
return out
}
31 changes: 30 additions & 1 deletion internal/api/handlers/management/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package management

import (
"context"
"crypto/subtle"
"fmt"
"net/http"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/quota"
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
Expand All @@ -36,6 +38,7 @@ type Handler struct {
authManager *coreauth.Manager
usageStats *usage.RequestStatistics
tokenStore coreauth.Store
quotaManager *quota.Manager
localPassword string
allowRemoteOverride bool
envSecret string
Expand All @@ -47,13 +50,23 @@ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Man
envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD")
envSecret = strings.TrimSpace(envSecret)

tokenStore := sdkAuth.GetTokenStore()
quotaManager := quota.NewManager(tokenStore, nil)

// Configure quota refresh interval if set in config
if cfg != nil && cfg.QuotaRefreshInterval > 0 {
interval := time.Duration(cfg.QuotaRefreshInterval) * time.Second
quotaManager.SetRefreshInterval(interval)
}

return &Handler{
cfg: cfg,
configFilePath: configFilePath,
failedAttempts: make(map[string]*attemptInfo),
authManager: manager,
usageStats: usage.GetRequestStatistics(),
tokenStore: sdkAuth.GetTokenStore(),
tokenStore: tokenStore,
quotaManager: quotaManager,
allowRemoteOverride: envSecret != "",
envSecret: envSecret,
}
Expand Down Expand Up @@ -89,6 +102,22 @@ func (h *Handler) SetLogDirectory(dir string) {
h.logDir = dir
}

// StartBackgroundWorkers starts background workers (quota refresh, etc.)
// This should be called when the server is ready to handle requests.
func (h *Handler) StartBackgroundWorkers(ctx context.Context) {
if h.quotaManager != nil {
h.quotaManager.StartWorker(ctx)
}
}

// StopBackgroundWorkers stops all background workers.
// This should be called during server shutdown.
func (h *Handler) StopBackgroundWorkers() {
if h.quotaManager != nil {
h.quotaManager.StopWorker()
}
}

// Middleware enforces access control for management endpoints.
// All requests (local and remote) require a valid management key.
// Additionally, remote access requires allow-remote-management=true.
Expand Down
Loading
Loading