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:
Luis Pater
2026-08-20 02:55:49 +08:00
parent 85d2faddd1
commit 9dc51b1f87
14 changed files with 685 additions and 3 deletions

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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()

View File

@@ -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.

View File

@@ -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
}

View 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)
}
}

View File

@@ -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

View File

@@ -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 {

View 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,
}
}

View 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")
}

View 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)
}
}

View File

@@ -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 {