mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
feat(cliproxy): add OAuth request-scoped error rules support
- Add `oauth-request-scoped-errors` configuration with normalization, sanitization, and YAML management persistence/hot-reload hooks. - Route request-scoped error classification to use per-provider rules only for OAuth auth entries. - Add config diff reporting and management CRUD endpoints for `oauth-request-scoped-errors` (get/put/patch/delete) with input sanitization. Closes: #5085
This commit is contained in:
@@ -732,6 +732,48 @@ nonstream-keepalive-interval: 0
|
||||
# xai:
|
||||
# - "grok-3-mini"
|
||||
|
||||
# OAuth provider request-scoped error rules (custom error classification for OAuth credentials)
|
||||
# oauth-request-scoped-errors:
|
||||
# vertex:
|
||||
# - status: 400
|
||||
# match:
|
||||
# - "maximum_context_length"
|
||||
# - "context_length_exceeded"
|
||||
# match-regexr:
|
||||
# - "maximum_context_length$"
|
||||
# - "^context_length_exceeded"
|
||||
# action: "stop" # options: "stop", "stop-and-cooldown", "continue", "continue-and-cooldown"
|
||||
# aistudio:
|
||||
# - status: 400
|
||||
# match:
|
||||
# - "invalid_argument"
|
||||
# action: "stop"
|
||||
# antigravity:
|
||||
# - status: 500
|
||||
# match:
|
||||
# - "internal_server_error"
|
||||
# action: "stop-and-cooldown"
|
||||
# claude:
|
||||
# - status: 400
|
||||
# match:
|
||||
# - "prompt is too long"
|
||||
# action: "stop"
|
||||
# codex:
|
||||
# - status: 400
|
||||
# match:
|
||||
# - "context_window_exceeded"
|
||||
# action: "stop"
|
||||
# kimi:
|
||||
# - status: 400
|
||||
# match:
|
||||
# - "length_limit"
|
||||
# action: "stop"
|
||||
# xai:
|
||||
# - status: 400
|
||||
# match:
|
||||
# - "max_tokens_exceeded"
|
||||
# action: "stop"
|
||||
|
||||
# Optional payload configuration
|
||||
# payload:
|
||||
# default: # Default rules only set parameters when they are missing in the payload.
|
||||
|
||||
@@ -1254,6 +1254,103 @@ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
|
||||
h.persist(c)
|
||||
}
|
||||
|
||||
// oauth-request-scoped-errors: map[string][]RequestScopedErrorRule
|
||||
func (h *Handler) GetOAuthRequestScopedErrors(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"oauth-request-scoped-errors": sanitizedOAuthRequestScopedErrors(h.cfg.OAuthRequestScopedErrors)})
|
||||
}
|
||||
|
||||
func (h *Handler) PutOAuthRequestScopedErrors(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
var entries map[string][]config.RequestScopedErrorRule
|
||||
if err = json.Unmarshal(data, &entries); err != nil {
|
||||
var wrapper struct {
|
||||
Items map[string][]config.RequestScopedErrorRule `json:"items"`
|
||||
}
|
||||
if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
entries = wrapper.Items
|
||||
}
|
||||
h.cfg.OAuthRequestScopedErrors = sanitizedOAuthRequestScopedErrors(entries)
|
||||
h.persist(c)
|
||||
}
|
||||
|
||||
func (h *Handler) PatchOAuthRequestScopedErrors(c *gin.Context) {
|
||||
var body struct {
|
||||
Provider *string `json:"provider"`
|
||||
Channel *string `json:"channel"`
|
||||
Rules []config.RequestScopedErrorRule `json:"rules"`
|
||||
}
|
||||
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
channelRaw := ""
|
||||
if body.Channel != nil {
|
||||
channelRaw = *body.Channel
|
||||
} else if body.Provider != nil {
|
||||
channelRaw = *body.Provider
|
||||
}
|
||||
channel := strings.ToLower(strings.TrimSpace(channelRaw))
|
||||
if channel == "" {
|
||||
c.JSON(400, gin.H{"error": "invalid channel"})
|
||||
return
|
||||
}
|
||||
|
||||
normalizedMap := sanitizedOAuthRequestScopedErrors(map[string][]config.RequestScopedErrorRule{channel: body.Rules})
|
||||
normalized := normalizedMap[channel]
|
||||
if len(normalized) == 0 {
|
||||
if h.cfg.OAuthRequestScopedErrors == nil {
|
||||
c.JSON(404, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
if _, ok := h.cfg.OAuthRequestScopedErrors[channel]; !ok {
|
||||
c.JSON(404, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
delete(h.cfg.OAuthRequestScopedErrors, channel)
|
||||
if len(h.cfg.OAuthRequestScopedErrors) == 0 {
|
||||
h.cfg.OAuthRequestScopedErrors = nil
|
||||
}
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if h.cfg.OAuthRequestScopedErrors == nil {
|
||||
h.cfg.OAuthRequestScopedErrors = make(map[string][]config.RequestScopedErrorRule)
|
||||
}
|
||||
h.cfg.OAuthRequestScopedErrors[channel] = normalized
|
||||
h.persist(c)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteOAuthRequestScopedErrors(c *gin.Context) {
|
||||
channel := strings.ToLower(strings.TrimSpace(c.Query("channel")))
|
||||
if channel == "" {
|
||||
channel = strings.ToLower(strings.TrimSpace(c.Query("provider")))
|
||||
}
|
||||
if channel == "" {
|
||||
c.JSON(400, gin.H{"error": "missing channel"})
|
||||
return
|
||||
}
|
||||
if h.cfg.OAuthRequestScopedErrors == nil {
|
||||
c.JSON(404, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
if _, ok := h.cfg.OAuthRequestScopedErrors[channel]; !ok {
|
||||
c.JSON(404, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
delete(h.cfg.OAuthRequestScopedErrors, channel)
|
||||
if len(h.cfg.OAuthRequestScopedErrors) == 0 {
|
||||
h.cfg.OAuthRequestScopedErrors = nil
|
||||
}
|
||||
h.persist(c)
|
||||
}
|
||||
|
||||
// codex-api-key: []CodexKey
|
||||
func (h *Handler) GetCodexKeys(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"codex-api-key": h.codexKeysWithAuthIndex()})
|
||||
@@ -1801,3 +1898,25 @@ func sanitizedOAuthModelAlias(entries map[string][]config.OAuthModelAlias) map[s
|
||||
}
|
||||
return cfg.OAuthModelAlias
|
||||
}
|
||||
|
||||
func sanitizedOAuthRequestScopedErrors(entries map[string][]config.RequestScopedErrorRule) map[string][]config.RequestScopedErrorRule {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string][]config.RequestScopedErrorRule, len(entries))
|
||||
for channel, rules := range entries {
|
||||
if len(rules) == 0 {
|
||||
continue
|
||||
}
|
||||
copied[channel] = append([]config.RequestScopedErrorRule(nil), rules...)
|
||||
}
|
||||
if len(copied) == 0 {
|
||||
return nil
|
||||
}
|
||||
cfg := config.Config{OAuthRequestScopedErrors: copied}
|
||||
cfg.SanitizeOAuthRequestScopedErrors()
|
||||
if len(cfg.OAuthRequestScopedErrors) == 0 {
|
||||
return nil
|
||||
}
|
||||
return cfg.OAuthRequestScopedErrors
|
||||
}
|
||||
|
||||
@@ -155,6 +155,11 @@ func (s *Server) registerManagementRoutes() {
|
||||
mgmt.PATCH("/oauth-model-alias", s.mgmt.PatchOAuthModelAlias)
|
||||
mgmt.DELETE("/oauth-model-alias", s.mgmt.DeleteOAuthModelAlias)
|
||||
|
||||
mgmt.GET("/oauth-request-scoped-errors", s.mgmt.GetOAuthRequestScopedErrors)
|
||||
mgmt.PUT("/oauth-request-scoped-errors", s.mgmt.PutOAuthRequestScopedErrors)
|
||||
mgmt.PATCH("/oauth-request-scoped-errors", s.mgmt.PatchOAuthRequestScopedErrors)
|
||||
mgmt.DELETE("/oauth-request-scoped-errors", s.mgmt.DeleteOAuthRequestScopedErrors)
|
||||
|
||||
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
|
||||
mgmt.GET("/auth-files/models", s.mgmt.GetAuthFileModels)
|
||||
mgmt.GET("/model-definitions/:channel", s.mgmt.GetStaticModelDefinitions)
|
||||
|
||||
@@ -158,6 +158,12 @@ type Config struct {
|
||||
// gemini-api-key, interactions-api-key, codex-api-key, xai-api-key, claude-api-key, openai-compatibility, and vertex-api-key.
|
||||
OAuthModelAlias map[string][]OAuthModelAlias `yaml:"oauth-model-alias,omitempty" json:"oauth-model-alias,omitempty"`
|
||||
|
||||
// OAuthRequestScopedErrors defines per-provider request-scoped error rules applied to OAuth/file-backed auth entries.
|
||||
// Supported channels include: vertex, aistudio, antigravity, claude, codex, kimi, xai, and OAuth plugin provider keys.
|
||||
//
|
||||
// NOTE: This applies only to OAuth credentials and does not affect per-credential request-scoped-errors under *-api-key.
|
||||
OAuthRequestScopedErrors map[string][]RequestScopedErrorRule `yaml:"oauth-request-scoped-errors,omitempty" json:"oauth-request-scoped-errors,omitempty"`
|
||||
|
||||
// Payload defines default and override rules for provider payload parameters.
|
||||
Payload PayloadConfig `yaml:"payload" json:"payload"`
|
||||
}
|
||||
|
||||
@@ -180,6 +180,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
||||
// Normalize global OAuth model name aliases.
|
||||
cfg.SanitizeOAuthModelAlias()
|
||||
|
||||
// Normalize global OAuth request-scoped error rules.
|
||||
cfg.SanitizeOAuthRequestScopedErrors()
|
||||
|
||||
// Validate raw payload rules and drop invalid entries.
|
||||
cfg.SanitizePayloadRules()
|
||||
|
||||
|
||||
@@ -101,6 +101,54 @@ func (cfg *Config) SanitizeOAuthModelAlias() {
|
||||
cfg.OAuthModelAlias = out
|
||||
}
|
||||
|
||||
// SanitizeOAuthRequestScopedErrors normalizes and validates global OAuth request-scoped error rules.
|
||||
// It trims whitespace, normalizes channel keys to lower-case, validates status/action, and drops invalid rules.
|
||||
func (cfg *Config) SanitizeOAuthRequestScopedErrors() {
|
||||
if cfg == nil || len(cfg.OAuthRequestScopedErrors) == 0 {
|
||||
return
|
||||
}
|
||||
out := make(map[string][]RequestScopedErrorRule, len(cfg.OAuthRequestScopedErrors))
|
||||
for rawChannel, rules := range cfg.OAuthRequestScopedErrors {
|
||||
channel := strings.ToLower(strings.TrimSpace(rawChannel))
|
||||
if channel == "" || len(rules) == 0 {
|
||||
continue
|
||||
}
|
||||
clean := make([]RequestScopedErrorRule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
action := strings.ToLower(strings.TrimSpace(r.Action))
|
||||
match := make([]string, 0, len(r.Match))
|
||||
for _, m := range r.Match {
|
||||
if tm := strings.TrimSpace(m); tm != "" {
|
||||
match = append(match, tm)
|
||||
}
|
||||
}
|
||||
matchRegexr := make([]string, 0, len(r.MatchRegexr))
|
||||
for _, re := range r.MatchRegexr {
|
||||
if tre := strings.TrimSpace(re); tre != "" {
|
||||
matchRegexr = append(matchRegexr, tre)
|
||||
}
|
||||
}
|
||||
if r.Status <= 0 || (len(match) == 0 && len(matchRegexr) == 0) || action == "" {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, RequestScopedErrorRule{
|
||||
Status: r.Status,
|
||||
Match: match,
|
||||
MatchRegexr: matchRegexr,
|
||||
Action: action,
|
||||
})
|
||||
}
|
||||
if len(clean) > 0 {
|
||||
out[channel] = clean
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
cfg.OAuthRequestScopedErrors = nil
|
||||
return
|
||||
}
|
||||
cfg.OAuthRequestScopedErrors = out
|
||||
}
|
||||
|
||||
// SanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are
|
||||
// not actionable, specifically those missing a BaseURL. It trims whitespace before
|
||||
// evaluation and preserves the relative order of remaining entries.
|
||||
|
||||
@@ -54,6 +54,7 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
||||
|
||||
pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models")
|
||||
pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-model-alias")
|
||||
pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-request-scoped-errors")
|
||||
pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "plugins", "configs")
|
||||
|
||||
// Merge generated into original in-place, preserving comments/order of existing nodes.
|
||||
@@ -687,10 +688,10 @@ func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, keyPath ...string)
|
||||
}
|
||||
srcIdx := findMapKeyIndex(srcRoot, key)
|
||||
if srcIdx < 0 {
|
||||
// Keep an explicit empty mapping for oauth-model-alias when it was previously present.
|
||||
// When users delete the last channel from oauth-model-alias via the management API,
|
||||
// Keep an explicit empty mapping for oauth-model-alias and oauth-request-scoped-errors when previously present.
|
||||
// When users delete the last channel via the management API,
|
||||
// we want that deletion to persist across hot reloads and restarts.
|
||||
if key == "oauth-model-alias" {
|
||||
if key == "oauth-model-alias" || key == "oauth-request-scoped-errors" {
|
||||
dstRoot.Content[dstIdx+1] = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
return
|
||||
}
|
||||
|
||||
115
internal/config/oauth_request_scoped_errors_test.go
Normal file
115
internal/config/oauth_request_scoped_errors_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseConfigOAuthRequestScopedErrors(t *testing.T) {
|
||||
const yamlConfig = `
|
||||
oauth-request-scoped-errors:
|
||||
vertex:
|
||||
- status: 400
|
||||
match:
|
||||
- "maximum_context_length"
|
||||
- "context_length_exceeded"
|
||||
match-regexr:
|
||||
- "maximum_context_length$"
|
||||
- "^context_length_exceeded"
|
||||
action: "stop"
|
||||
aistudio:
|
||||
- status: 400
|
||||
match:
|
||||
- "invalid_argument"
|
||||
action: "continue"
|
||||
antigravity:
|
||||
- status: 500
|
||||
match:
|
||||
- "internal_server_error"
|
||||
action: "stop-and-cooldown"
|
||||
claude:
|
||||
- status: 429
|
||||
match:
|
||||
- "rate_limit"
|
||||
action: "continue-and-cooldown"
|
||||
codex:
|
||||
- status: 400
|
||||
match:
|
||||
- "context_window_exceeded"
|
||||
action: "stop"
|
||||
kimi:
|
||||
- status: 400
|
||||
match:
|
||||
- "length_limit"
|
||||
action: "stop"
|
||||
xai:
|
||||
- status: 400
|
||||
match:
|
||||
- "max_tokens_exceeded"
|
||||
action: "stop"
|
||||
`
|
||||
|
||||
cfg, err := ParseConfigBytes([]byte(yamlConfig))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigFromBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.OAuthRequestScopedErrors) != 7 {
|
||||
t.Fatalf("cfg.OAuthRequestScopedErrors len = %d, want 7", len(cfg.OAuthRequestScopedErrors))
|
||||
}
|
||||
|
||||
vertexRules, ok := cfg.OAuthRequestScopedErrors["vertex"]
|
||||
if !ok || len(vertexRules) != 1 {
|
||||
t.Fatalf("vertex rules missing or len != 1: %#v", vertexRules)
|
||||
}
|
||||
rule := vertexRules[0]
|
||||
if rule.Status != 400 || rule.Action != "stop" {
|
||||
t.Errorf("unexpected vertex rule: %+v", rule)
|
||||
}
|
||||
if len(rule.Match) != 2 || len(rule.MatchRegexr) != 2 {
|
||||
t.Errorf("unexpected vertex match len: %+v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOAuthRequestScopedErrors(t *testing.T) {
|
||||
cfg := &Config{
|
||||
OAuthRequestScopedErrors: map[string][]RequestScopedErrorRule{
|
||||
" Vertex ": {
|
||||
{
|
||||
Status: 400,
|
||||
Match: []string{" context_length ", ""},
|
||||
MatchRegexr: []string{" ^error.* ", ""},
|
||||
Action: " STOP ",
|
||||
},
|
||||
{
|
||||
Status: 0, // invalid status
|
||||
Match: []string{"foo"},
|
||||
Action: "stop",
|
||||
},
|
||||
{
|
||||
Status: 400, // missing match / action
|
||||
},
|
||||
},
|
||||
" empty-channel ": {},
|
||||
},
|
||||
}
|
||||
|
||||
cfg.SanitizeOAuthRequestScopedErrors()
|
||||
|
||||
if len(cfg.OAuthRequestScopedErrors) != 1 {
|
||||
t.Fatalf("expected 1 sanitized channel, got %d", len(cfg.OAuthRequestScopedErrors))
|
||||
}
|
||||
|
||||
rules := cfg.OAuthRequestScopedErrors["vertex"]
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("expected 1 rule for vertex, got %d", len(rules))
|
||||
}
|
||||
if rules[0].Status != 400 || rules[0].Action != "stop" {
|
||||
t.Errorf("unexpected sanitized rule: %+v", rules[0])
|
||||
}
|
||||
if len(rules[0].Match) != 1 || rules[0].Match[0] != "context_length" {
|
||||
t.Errorf("unexpected sanitized match: %+v", rules[0].Match)
|
||||
}
|
||||
if len(rules[0].MatchRegexr) != 1 || rules[0].MatchRegexr[0] != "^error.*" {
|
||||
t.Errorf("unexpected sanitized regexr: %+v", rules[0].MatchRegexr)
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ func ParseConfigBytes(data []byte) (*Config, error) {
|
||||
cfg.SanitizeOpenAICompatibility()
|
||||
cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)
|
||||
cfg.SanitizeOAuthModelAlias()
|
||||
cfg.SanitizeOAuthRequestScopedErrors()
|
||||
cfg.SanitizePayloadRules()
|
||||
|
||||
return &cfg, nil
|
||||
|
||||
@@ -375,6 +375,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
||||
if entries, _ := DiffOAuthModelAliasChanges(oldCfg.OAuthModelAlias, newCfg.OAuthModelAlias); len(entries) > 0 {
|
||||
changes = append(changes, entries...)
|
||||
}
|
||||
if entries, _ := DiffOAuthRequestScopedErrorsChanges(oldCfg.OAuthRequestScopedErrors, newCfg.OAuthRequestScopedErrors); len(entries) > 0 {
|
||||
changes = append(changes, entries...)
|
||||
}
|
||||
|
||||
// Remote management (never print the key)
|
||||
if oldCfg.RemoteManagement.AllowRemote != newCfg.RemoteManagement.AllowRemote {
|
||||
|
||||
91
internal/watcher/diff/oauth_request_scoped_errors.go
Normal file
91
internal/watcher/diff/oauth_request_scoped_errors.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
type OAuthRequestScopedErrorsSummary struct {
|
||||
hash string
|
||||
count int
|
||||
}
|
||||
|
||||
// SummarizeOAuthRequestScopedErrors summarizes OAuth request-scoped errors per channel.
|
||||
func SummarizeOAuthRequestScopedErrors(entries map[string][]config.RequestScopedErrorRule) map[string]OAuthRequestScopedErrorsSummary {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]OAuthRequestScopedErrorsSummary, len(entries))
|
||||
for k, v := range entries {
|
||||
key := strings.ToLower(strings.TrimSpace(k))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = summarizeOAuthRequestScopedErrorsList(v)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DiffOAuthRequestScopedErrorsChanges compares OAuth request-scoped error maps.
|
||||
func DiffOAuthRequestScopedErrorsChanges(oldMap, newMap map[string][]config.RequestScopedErrorRule) ([]string, []string) {
|
||||
oldSummary := SummarizeOAuthRequestScopedErrors(oldMap)
|
||||
newSummary := SummarizeOAuthRequestScopedErrors(newMap)
|
||||
keys := make(map[string]struct{}, len(oldSummary)+len(newSummary))
|
||||
for k := range oldSummary {
|
||||
keys[k] = struct{}{}
|
||||
}
|
||||
for k := range newSummary {
|
||||
keys[k] = struct{}{}
|
||||
}
|
||||
changes := make([]string, 0, len(keys))
|
||||
affected := make([]string, 0, len(keys))
|
||||
for key := range keys {
|
||||
oldInfo, okOld := oldSummary[key]
|
||||
newInfo, okNew := newSummary[key]
|
||||
switch {
|
||||
case okOld && !okNew:
|
||||
changes = append(changes, fmt.Sprintf("oauth-request-scoped-errors[%s]: removed", key))
|
||||
affected = append(affected, key)
|
||||
case !okOld && okNew:
|
||||
changes = append(changes, fmt.Sprintf("oauth-request-scoped-errors[%s]: added (%d entries)", key, newInfo.count))
|
||||
affected = append(affected, key)
|
||||
case okOld && okNew && oldInfo.hash != newInfo.hash:
|
||||
changes = append(changes, fmt.Sprintf("oauth-request-scoped-errors[%s]: updated (%d -> %d entries)", key, oldInfo.count, newInfo.count))
|
||||
affected = append(affected, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(changes)
|
||||
sort.Strings(affected)
|
||||
return changes, affected
|
||||
}
|
||||
|
||||
func summarizeOAuthRequestScopedErrorsList(list []config.RequestScopedErrorRule) OAuthRequestScopedErrorsSummary {
|
||||
if len(list) == 0 {
|
||||
return OAuthRequestScopedErrorsSummary{}
|
||||
}
|
||||
var b strings.Builder
|
||||
valid := 0
|
||||
for _, entry := range list {
|
||||
if entry.Status <= 0 || (len(entry.Match) == 0 && len(entry.MatchRegexr) == 0) || entry.Action == "" {
|
||||
continue
|
||||
}
|
||||
valid++
|
||||
b.WriteString(fmt.Sprintf("%d|%s|%s|%s\n", entry.Status, strings.Join(entry.Match, ","), strings.Join(entry.MatchRegexr, ","), entry.Action))
|
||||
}
|
||||
if valid == 0 {
|
||||
return OAuthRequestScopedErrorsSummary{}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(b.String()))
|
||||
return OAuthRequestScopedErrorsSummary{
|
||||
hash: hex.EncodeToString(sum[:]),
|
||||
count: valid,
|
||||
}
|
||||
}
|
||||
57
internal/watcher/diff/oauth_request_scoped_errors_test.go
Normal file
57
internal/watcher/diff/oauth_request_scoped_errors_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func TestSummarizeOAuthRequestScopedErrors_NormalizesKeys(t *testing.T) {
|
||||
out := SummarizeOAuthRequestScopedErrors(map[string][]config.RequestScopedErrorRule{
|
||||
" Vertex ": {
|
||||
{Status: 400, Match: []string{"error"}, Action: "stop"},
|
||||
},
|
||||
"": {
|
||||
{Status: 500, Match: []string{"err"}, Action: "continue"},
|
||||
},
|
||||
})
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 normalized entry, got %d", len(out))
|
||||
}
|
||||
if summary, ok := out["vertex"]; !ok || summary.count != 1 {
|
||||
t.Fatalf("unexpected summary for vertex: %#v", summary)
|
||||
}
|
||||
|
||||
if outEmpty := SummarizeOAuthRequestScopedErrors(nil); outEmpty != nil {
|
||||
t.Fatalf("expected nil summary for nil map, got %#v", outEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffOAuthRequestScopedErrorsChanges(t *testing.T) {
|
||||
oldMap := map[string][]config.RequestScopedErrorRule{
|
||||
"vertex": {
|
||||
{Status: 400, Match: []string{"context_length"}, Action: "stop"},
|
||||
},
|
||||
"claude": {
|
||||
{Status: 429, Match: []string{"rate_limit"}, Action: "continue"},
|
||||
},
|
||||
}
|
||||
newMap := map[string][]config.RequestScopedErrorRule{
|
||||
"vertex": {
|
||||
{Status: 400, Match: []string{"context_length_updated"}, Action: "stop"},
|
||||
},
|
||||
"codex": {
|
||||
{Status: 400, Match: []string{"window_exceeded"}, Action: "stop"},
|
||||
},
|
||||
}
|
||||
|
||||
changes, affected := DiffOAuthRequestScopedErrorsChanges(oldMap, newMap)
|
||||
|
||||
expectContains(t, changes, "oauth-request-scoped-errors[claude]: removed")
|
||||
expectContains(t, changes, "oauth-request-scoped-errors[codex]: added (1 entries)")
|
||||
expectContains(t, changes, "oauth-request-scoped-errors[vertex]: updated (1 -> 1 entries)")
|
||||
|
||||
expectContains(t, affected, "claude")
|
||||
expectContains(t, affected, "codex")
|
||||
expectContains(t, affected, "vertex")
|
||||
}
|
||||
181
sdk/cliproxy/auth/conductor_oauth_request_scoped_errors_test.go
Normal file
181
sdk/cliproxy/auth/conductor_oauth_request_scoped_errors_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestOAuthRequestScopedErrors_AppliesToOAuthAuth(t *testing.T) {
|
||||
previous := quotaCooldownDisabled.Load()
|
||||
quotaCooldownDisabled.Store(false)
|
||||
t.Cleanup(func() { quotaCooldownDisabled.Store(previous) })
|
||||
|
||||
cfg := &internalconfig.Config{
|
||||
OAuthRequestScopedErrors: map[string][]internalconfig.RequestScopedErrorRule{
|
||||
"vertex": {
|
||||
{
|
||||
Status: 400,
|
||||
Match: []string{
|
||||
"maximum_context_length",
|
||||
"context_length_exceeded",
|
||||
},
|
||||
MatchRegexr: []string{
|
||||
"maximum_context_length$",
|
||||
"^context_length_exceeded",
|
||||
},
|
||||
Action: "stop",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m := NewManager(nil, nil, nil)
|
||||
m.SetConfig(cfg)
|
||||
|
||||
auth1 := &Auth{
|
||||
ID: "auth-vertex-oauth",
|
||||
Provider: "vertex",
|
||||
Status: StatusActive,
|
||||
Attributes: map[string]string{"auth_kind": "oauth", "priority": "10"},
|
||||
}
|
||||
auth2 := &Auth{
|
||||
ID: "auth-vertex-oauth-2",
|
||||
Provider: "vertex",
|
||||
Status: StatusActive,
|
||||
Attributes: map[string]string{"auth_kind": "oauth", "priority": "5"},
|
||||
}
|
||||
|
||||
reg := registry.GetGlobalRegistry()
|
||||
reg.RegisterClient(auth1.ID, "vertex", []*registry.ModelInfo{{ID: "claude-3-5-sonnet"}})
|
||||
reg.RegisterClient(auth2.ID, "vertex", []*registry.ModelInfo{{ID: "claude-3-5-sonnet"}})
|
||||
t.Cleanup(func() {
|
||||
reg.UnregisterClient(auth1.ID)
|
||||
reg.UnregisterClient(auth2.ID)
|
||||
})
|
||||
|
||||
if _, err := m.Register(context.Background(), auth1); err != nil {
|
||||
t.Fatalf("register auth1: %v", err)
|
||||
}
|
||||
if _, err := m.Register(context.Background(), auth2); err != nil {
|
||||
t.Fatalf("register auth2: %v", err)
|
||||
}
|
||||
|
||||
execCount := 0
|
||||
exec := &mockCustomErrorExecutor{
|
||||
identifier: "vertex",
|
||||
executeFn: func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
execCount++
|
||||
return cliproxyexecutor.Response{}, customStatusError{
|
||||
code: http.StatusBadRequest,
|
||||
msg: `{"error": "maximum_context_length"}`,
|
||||
}
|
||||
},
|
||||
}
|
||||
m.RegisterExecutor(exec)
|
||||
|
||||
req := cliproxyexecutor.Request{Model: "claude-3-5-sonnet"}
|
||||
opts := cliproxyexecutor.Options{}
|
||||
|
||||
_, errExec := m.Execute(context.Background(), []string{"vertex"}, req, opts)
|
||||
if errExec == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
// Action: stop should terminate immediately and not try auth2
|
||||
if execCount != 1 {
|
||||
t.Fatalf("expected execCount = 1 (stopped), got %d", execCount)
|
||||
}
|
||||
|
||||
// Action: stop without cooldown should leave auth1 active
|
||||
auth1State, ok := m.GetByID("auth-vertex-oauth")
|
||||
if !ok || auth1State.Status != StatusActive || auth1State.Unavailable {
|
||||
t.Fatalf("expected auth1 to remain active, got status=%v unavailable=%v", auth1State.Status, auth1State.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthRequestScopedErrors_DoesNotApplyToAPIKey(t *testing.T) {
|
||||
previous := quotaCooldownDisabled.Load()
|
||||
quotaCooldownDisabled.Store(false)
|
||||
t.Cleanup(func() { quotaCooldownDisabled.Store(previous) })
|
||||
|
||||
cfg := &internalconfig.Config{
|
||||
OAuthRequestScopedErrors: map[string][]internalconfig.RequestScopedErrorRule{
|
||||
"vertex": {
|
||||
{
|
||||
Status: 500,
|
||||
Match: []string{"internal_server_error"},
|
||||
Action: "stop",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m := NewManager(nil, nil, nil)
|
||||
m.SetConfig(cfg)
|
||||
|
||||
// API key auth must not use oauth-request-scoped-errors
|
||||
auth1 := &Auth{
|
||||
ID: "auth-vertex-apikey",
|
||||
Provider: "vertex",
|
||||
Status: StatusActive,
|
||||
Attributes: map[string]string{"auth_kind": "apikey", "api_key": "test-key", "priority": "10"},
|
||||
}
|
||||
auth2 := &Auth{
|
||||
ID: "auth-vertex-apikey-2",
|
||||
Provider: "vertex",
|
||||
Status: StatusActive,
|
||||
Attributes: map[string]string{"auth_kind": "apikey", "api_key": "test-key-2", "priority": "5"},
|
||||
}
|
||||
|
||||
reg := registry.GetGlobalRegistry()
|
||||
reg.RegisterClient(auth1.ID, "vertex", []*registry.ModelInfo{{ID: "claude-3-5-sonnet"}})
|
||||
reg.RegisterClient(auth2.ID, "vertex", []*registry.ModelInfo{{ID: "claude-3-5-sonnet"}})
|
||||
t.Cleanup(func() {
|
||||
reg.UnregisterClient(auth1.ID)
|
||||
reg.UnregisterClient(auth2.ID)
|
||||
})
|
||||
|
||||
if _, err := m.Register(context.Background(), auth1); err != nil {
|
||||
t.Fatalf("register auth1: %v", err)
|
||||
}
|
||||
if _, err := m.Register(context.Background(), auth2); err != nil {
|
||||
t.Fatalf("register auth2: %v", err)
|
||||
}
|
||||
|
||||
execCount := 0
|
||||
exec := &mockCustomErrorExecutor{
|
||||
identifier: "vertex",
|
||||
executeFn: func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
execCount++
|
||||
if execCount == 1 {
|
||||
return cliproxyexecutor.Response{}, customStatusError{
|
||||
code: http.StatusInternalServerError,
|
||||
msg: `{"error": "internal_server_error"}`,
|
||||
}
|
||||
}
|
||||
return cliproxyexecutor.Response{Payload: []byte(`{"success": true}`)}, nil
|
||||
},
|
||||
}
|
||||
m.RegisterExecutor(exec)
|
||||
|
||||
req := cliproxyexecutor.Request{Model: "claude-3-5-sonnet"}
|
||||
opts := cliproxyexecutor.Options{}
|
||||
|
||||
resp, errExec := m.Execute(context.Background(), []string{"vertex"}, req, opts)
|
||||
if errExec != nil {
|
||||
t.Fatalf("unexpected Execute error: %v", errExec)
|
||||
}
|
||||
if string(resp.Payload) != `{"success": true}` {
|
||||
t.Fatalf("unexpected payload: %s", string(resp.Payload))
|
||||
}
|
||||
|
||||
// Should not have stopped at auth1; fell back to auth2 because OAuth rule was skipped for API key
|
||||
if execCount != 2 {
|
||||
t.Fatalf("expected execCount = 2 (rotated because OAuth rule skipped for API key), got %d", execCount)
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,16 @@ func extractRequestScopedErrorRules(auth *Auth, cfg *internalconfig.Config) []in
|
||||
return nil
|
||||
}
|
||||
|
||||
if auth.AuthKind() == AuthKindOAuth {
|
||||
if len(cfg.OAuthRequestScopedErrors) > 0 {
|
||||
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||
if rules, ok := cfg.OAuthRequestScopedErrors[provider]; ok && len(rules) > 0 {
|
||||
return rules
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||
index := -1
|
||||
if auth.Attributes != nil {
|
||||
|
||||
Reference in New Issue
Block a user