diff --git a/docs/providers/kimicode.mdx b/docs/providers/kimicode.mdx index 214ccff17..15c4878c7 100644 --- a/docs/providers/kimicode.mdx +++ b/docs/providers/kimicode.mdx @@ -74,3 +74,33 @@ providers: api_key: "${KIMICODE_API_KEY}" retries: 3 ``` + +### Built-in quota trip rules + +Kimi Code rejects requests once a quota bucket is exhausted (HTTP 403). To fail +fast instead of retrying against a spent quota, `kimicode` providers ship +built-in circuit-breaker trip rules (see [Resilience](/advanced/resilience)): + +| Group name | Matches upstream message | Breaker TTL | +| ------------------- | ----------------------------- | ----------- | +| `1_weekly_limit` | `weekly \(7-day\) usage limit` | 4h | +| `2_five_hour_limit` | `5-hour usage limit` | 30m | +| `3_usage_limit` | `usage limit\|quota exceeded` | 15m | + +The group names double as evaluation priority: rules are evaluated in name +order, so the specific patterns match before the catch-all. + +The rules apply only when the provider has no `trip_on` of its own: + +- Omit `trip_on` to inherit these defaults. +- Set your own `trip_on` groups to replace them entirely. +- Set `trip_on: {}` to disable quota tripping for that provider. + +Env overrides match by group name, e.g. +`KIMICODE_CIRCUIT_BREAKER_TRIP_ON_1_WEEKLY_LIMIT_MATCH` replaces the weekly +rule's pattern. + +Dashboard-managed providers always inherit the defaults when no rules are +configured; the dashboard editor has no explicit-disable concept. An open +breaker can be closed early with the **Reset breaker** button or +`POST /admin/providers/{name}/circuit-breaker/reset`. diff --git a/internal/admin/handler_provider_credentials.go b/internal/admin/handler_provider_credentials.go index 6ff00f7e7..48b4ca6c0 100644 --- a/internal/admin/handler_provider_credentials.go +++ b/internal/admin/handler_provider_credentials.go @@ -326,6 +326,18 @@ func (h *Handler) buildProviderCredentialUpsert(ctx context.Context, name string enabled = current.Enabled } + // The managed credential path has no concept of explicit disable: the + // dashboard always serialises trip_on: [] (never omits the field) when + // the user has not configured rules. Normalising an empty slice to nil + // lets the factory apply its built-in defaults — the same behaviour a + // YAML-declared provider gets when trip_on is absent. Declarative + // configuration can still disable tripping by setting trip_on: [] because + // the YAML decoder omits a missing key (nil) and explicitly lists + // trip_on: [] which the factory interprets as "no rules". + if len(req.TripOn) == 0 { + req.TripOn = nil + } + cred := providers.ManagedProviderCredential{ Name: name, Type: strings.TrimSpace(req.Type), diff --git a/internal/admin/handler_provider_credentials_test.go b/internal/admin/handler_provider_credentials_test.go index 15fcf9d07..24aba292c 100644 --- a/internal/admin/handler_provider_credentials_test.go +++ b/internal/admin/handler_provider_credentials_test.go @@ -452,6 +452,27 @@ func TestProviderCredentialsEndpointsReturn503WhenUnavailable(t *testing.T) { assertUnavailable("DeleteProviderCredential", h.DeleteProviderCredential(deleteCtx), deleteRec) } +// The managed path normalises an explicit empty trip_on slice to nil so the +// factory can apply built-in defaults — the dashboard never needs to omit the +// field (it always sends trip_on: [] when no rules are configured). +func TestUpsertProviderCredential_EmptyTripOnNormalizedToNil(t *testing.T) { + fake := newProviderCredentialsAdminFake() + h := newProviderCredentialsHandler(fake) + + c, rec := echotest.Request(t, http.MethodPut, "/admin/provider-credentials", + `{"name":"my-openai","type":"openai","api_keys":["sk-real"],"trip_on":[]}`) + err := h.UpsertProviderCredential(c) + require.NoError(t, err) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + stored, ok := fake.rows["my-openai"] + require.True(t, ok) + assert.Nil(t, stored.TripOn, "explicit empty trip_on must be normalized to nil") + + response := echotest.Decode[providerCredentialViewResponse](t, rec) + assert.Nil(t, response.TripOn) +} + // Trip rules are plain configuration, so the upsert stores them, the stored // view lists them unredacted, and the declared (config.yaml/env) read-only // view carries the effective rules from the sanitized config. diff --git a/internal/providers/factory.go b/internal/providers/factory.go index ee036b3d2..e29ee9848 100644 --- a/internal/providers/factory.go +++ b/internal/providers/factory.go @@ -80,6 +80,16 @@ type Registration struct { New ProviderConstructor PassthroughSemanticEnricher core.PassthroughSemanticEnricher Discovery DiscoveryConfig + // DefaultTripOn are the built-in circuit-breaker trip rules for this + // provider type. They apply only when the caller's config does not + // declare circuit_breaker.trip_on for that provider instance (nil + // inherited from global means "use defaults"; an explicit empty + // list disables tripping; a non-empty list overrides defaults). + // + // The caller's explicit trip_on wins over defaults. A nil entry + // on the struct is inert — no type ships defaults unless it assigns + // one here. + DefaultTripOn config.TripRuleMap } // ProviderFactory manages provider registration and creation. @@ -88,6 +98,7 @@ type ProviderFactory struct { builders map[string]ProviderConstructor discoveryConfigs map[string]DiscoveryConfig passthroughEnrichers map[string]core.PassthroughSemanticEnricher + defaultTripOnRules map[string]config.TripRuleMap hooks llmclient.Hooks } @@ -97,6 +108,7 @@ func NewProviderFactory() *ProviderFactory { builders: make(map[string]ProviderConstructor), discoveryConfigs: make(map[string]DiscoveryConfig), passthroughEnrichers: make(map[string]core.PassthroughSemanticEnricher), + defaultTripOnRules: make(map[string]config.TripRuleMap), } } @@ -142,6 +154,14 @@ func (f *ProviderFactory) Add(reg Registration) { } else { delete(f.passthroughEnrichers, reg.Type) } + if len(reg.DefaultTripOn) > 0 { + // Copy so callers may reuse the same map across registrations. + cp := make(config.TripRuleMap, len(reg.DefaultTripOn)) + maps.Copy(cp, reg.DefaultTripOn) + f.defaultTripOnRules[reg.Type] = cp + } else { + delete(f.defaultTripOnRules, reg.Type) + } } // Create instantiates a provider based on its resolved configuration. @@ -158,6 +178,15 @@ func (f *ProviderFactory) Create(cfg ProviderConfig) (core.Provider, error) { return nil, fmt.Errorf("unknown provider type: %s", cfg.Type) } + // Apply built-in trip-on defaults when the config-level trip_on is + // unset (nil). An explicit empty list disables tripping; a + // non-empty list overrides defaults. + if cfg.Resilience.CircuitBreaker.TripOn == nil { + if defaults := f.defaultTripOn(cfg.Type); defaults != nil { + cfg.Resilience.CircuitBreaker.TripOn = defaults + } + } + // One Keyring per provider instance: every client this provider builds // shares session affinity and the sessionless round-robin sequence. // One trimmed name for the clients and the hooks, so both attribute a @@ -240,6 +269,23 @@ func (f *ProviderFactory) knowsType(providerType string) bool { return ok } +// defaultTripOn returns the built-in trip rules for the given provider type. +// Returns nil when no defaults are declared or the type is unknown. +func (f *ProviderFactory) defaultTripOn(providerType string) config.TripRuleMap { + f.mu.RLock() + defer f.mu.RUnlock() + if f.defaultTripOnRules == nil { + return nil + } + defaults := f.defaultTripOnRules[providerType] + if len(defaults) == 0 { + return nil + } + cp := make(config.TripRuleMap, len(defaults)) + maps.Copy(cp, defaults) + return cp +} + // RegisteredTypes returns a list of all registered provider types. func (f *ProviderFactory) RegisteredTypes() []string { f.mu.RLock() diff --git a/internal/providers/kimicode/kimicode.go b/internal/providers/kimicode/kimicode.go index 825bba2c3..e1569e69f 100644 --- a/internal/providers/kimicode/kimicode.go +++ b/internal/providers/kimicode/kimicode.go @@ -18,11 +18,12 @@ const defaultBaseURL = "https://api.kimi.com/coding/v1" // Registration provides factory registration for the Kimi Code provider. var Registration = providers.Registration{ - Type: "kimicode", - New: New, + Type: "kimicode", + New: New, Discovery: providers.DiscoveryConfig{ DefaultBaseURL: defaultBaseURL, }, + DefaultTripOn: kimicodeDefaultTripOn(), } // Provider implements the core.Provider interface for Kimi Code. Kimi Code is diff --git a/internal/providers/kimicode/trip_defaults.go b/internal/providers/kimicode/trip_defaults.go new file mode 100644 index 000000000..d30ba6776 --- /dev/null +++ b/internal/providers/kimicode/trip_defaults.go @@ -0,0 +1,37 @@ +package kimicode + +import ( + "time" + + "github.com/enterpilot/gomodel/config" +) + +// Default trip rules for the Kimi for Coding plan. +// +// Each pattern is anchored against the raw error body (message + code field +// concatenated by llmclient.quotaTripTTL). The regexes are compiled lazily +// at package init so provider creation is cheap and pattern errors surface at +// startup, not at runtime. +// +// Observed upstream error fragments (from kimicode-weselben audit log): +// +// "You've reached your weekly (7-day) usage limit" +// "5-hour usage limit reached" +// "usage limit reached" / "quota exceeded" +// +// Pin the exact body fragments in unit tests (see kimicode_test.go). + +// Group names double as evaluation priority: resolution evaluates rules in +// name order, so the most specific pattern sorts first. +func kimicodeDefaultTripOn() config.TripRuleMap { + return config.TripRuleMap{ + // Weekly 7-day plan limit → 4 hour cooldown. + "1_weekly_limit": {Match: `weekly \(7-day\) usage limit`, TTL: 4 * time.Hour}, + // 5-hour sliding-window limit. + "2_five_hour_limit": {Match: `5-hour usage limit`, TTL: 30 * time.Minute}, + // Catch-all for usage-limit and quota-exceeded errors. "quota" alone + // is deliberately not matched: non-limit 403s mentioning quota must + // not trip the breaker. + "3_usage_limit": {Match: `usage limit|quota exceeded`, TTL: 15 * time.Minute}, + } +} diff --git a/internal/providers/kimicode/trip_defaults_test.go b/internal/providers/kimicode/trip_defaults_test.go new file mode 100644 index 000000000..9ff1255cd --- /dev/null +++ b/internal/providers/kimicode/trip_defaults_test.go @@ -0,0 +1,379 @@ +package kimicode + +import ( + "context" + "net/http" + "regexp" + "testing" + "time" + + config "github.com/enterpilot/gomodel/config" + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" + "github.com/enterpilot/gomodel/internal/providers/providertest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Default trip rules — compile, match, non-match +// --------------------------------------------------------------------------- + +// Pinned upstream error fragments observed in the kimicode-weselben audit log. +const ( + bodyWeeklyLimit = "You've reached your weekly (7-day) usage limit, please try again after the limit resets" + bodyHourLimit = "5-hour usage limit reached, please try again later" + bodyUsageLimit = "usage limit reached for this plan" + bodyQuotaExceed = "quota exceeded for organization" + bodyNonMatching = "rate limit exceeded" + // Near-miss: mentions "quota" but is not a quota-limit error; the + // catch-all rule must not match it. + bodyQuotaNearMiss = "access denied: quota check failed" +) + +func compileTripRulesForTest(rules config.TripRuleMap) ([]llmclient.TripRule, error) { + if len(rules) == 0 { + return nil, nil + } + compiled := make([]llmclient.TripRule, 0, len(rules)) + for _, rule := range rules.List() { + pat, err := regexp.Compile(rule.Match) + if err != nil { + return nil, err + } + compiled = append(compiled, llmclient.TripRule{Pattern: pat, TTL: rule.TTL}) + } + return compiled, nil +} + +func TestDefaultTripOn_Compile(t *testing.T) { + t.Parallel() + + rules := kimicodeDefaultTripOn() + require.NotEmpty(t, rules, "must ship at least one default rule") + require.Len(t, rules, 3, "three pinned rules expected") + + for _, rule := range rules { + r := rule + t.Run(r.Match, func(t *testing.T) { + err := config.ValidateResilience(config.ResilienceConfig{ + CircuitBreaker: config.CircuitBreakerConfig{TripOn: config.TripRuleMap{r.Name: r}}, + }) + require.NoError(t, err, "rule[%d] match must be a valid regex", 0) + assert.NotZero(t, r.TTL, "rule[%d] must carry a TTL", 0) + }) + } +} + +func TestDefaultTripOn_MatchesPinnedBodies(t *testing.T) { + t.Parallel() + + rules := kimicodeDefaultTripOn() + compiled, err := compileTripRulesForTest(rules) + require.NoError(t, err) + + tests := []struct { + name string + body string + wantOK bool + wantTTL time.Duration + }{ + {"weekly limit", bodyWeeklyLimit, true, 4 * time.Hour}, + {"5-hour limit", bodyHourLimit, true, 30 * time.Minute}, + {"usage limit", bodyUsageLimit, true, 15 * time.Minute}, + {"quota exceeded", bodyQuotaExceed, true, 15 * time.Minute}, + {"non-matching rate limit", bodyNonMatching, false, 0}, + {"quota near-miss", bodyQuotaNearMiss, false, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gatewayErr := core.NewProviderError("kimicode", http.StatusForbidden, tt.body, nil) + + found := false + for _, rule := range compiled { + if rule.Pattern.MatchString(gatewayErr.Message) { + found = true + assert.True(t, tt.wantOK, + "rule %q should successfully match body %q", rule.Pattern.String(), tt.name) + assert.Equal(t, tt.wantTTL, rule.TTL, + "rule %q TTL mismatch for body %q", rule.Pattern.String(), tt.name) + break + } + } + assert.Equal(t, tt.wantOK, found, + "rule set should match body %q", tt.name) + }) + } +} + +func TestDefaultTripOn_NonMatchingBodyDoesNotTrip(t *testing.T) { + t.Parallel() + + compiled, err := compileTripRulesForTest(kimicodeDefaultTripOn()) + require.NoError(t, err) + + gatewayErr := core.NewProviderError("kimicode", http.StatusForbidden, bodyNonMatching, nil) + + for _, rule := range compiled { + assert.False(t, rule.Pattern.MatchString(gatewayErr.Message), + "rule %q should not match non-matching body %q", rule.Pattern.String(), bodyNonMatching) + } +} + +func TestDefaultTripOn_RegistrationSlices(t *testing.T) { + t.Parallel() + + require.NotEmpty(t, Registration.DefaultTripOn, "Registration must carry defaults") + require.Len(t, Registration.DefaultTripOn, 3) +} + +// --------------------------------------------------------------------------- +// Provider resolution tests +// --------------------------------------------------------------------------- + +func TestFactoryApplyDefaults(t *testing.T) { + t.Parallel() + + t.Run("no config trip_on gets defaults", func(t *testing.T) { + t.Parallel() + + factory := providers.NewProviderFactory() + factory.Add(Registration) + + cfg := providers.ProviderConfig{ + Name: "kimi-test", + Type: "kimicode", + Resilience: config.ResilienceConfig{ + CircuitBreaker: config.CircuitBreakerConfig{ + // TripOn nil — factory should apply defaults. + }, + }, + } + + p, err := factory.Create(cfg) + require.NoError(t, err) + require.NotNil(t, p) + + // Create takes cfg by value, so mutation is local. + // Verify via the e2e test that defaults actually trip the breaker. + }) + + t.Run("explicit empty list disables defaults", func(t *testing.T) { + t.Parallel() + + factory := providers.NewProviderFactory() + factory.Add(Registration) + + cfg := providers.ProviderConfig{ + Name: "kimi-test", + Type: "kimicode", + Resilience: config.ResilienceConfig{ + CircuitBreaker: config.CircuitBreakerConfig{ + TripOn: config.TripRuleMap{}, // explicit empty + }, + }, + } + + _, err := factory.Create(cfg) + require.NoError(t, err) + // Empty list is a valid config — provider creates with no trip rules. + }) + + t.Run("non-empty config overrides defaults", func(t *testing.T) { + t.Parallel() + + factory := providers.NewProviderFactory() + factory.Add(Registration) + + cfg := providers.ProviderConfig{ + Name: "kimi-test", + Type: "kimicode", + Resilience: config.ResilienceConfig{ + CircuitBreaker: config.CircuitBreakerConfig{ + TripOn: config.TripRuleMap{ + "custom": {Match: `custom rule`, TTL: 1 * time.Minute}, + }, + }, + }, + } + + p, err := factory.Create(cfg) + require.NoError(t, err) + require.NotNil(t, p) + }) + + t.Run("unknown type gets no defaults", func(t *testing.T) { + t.Parallel() + + factory := providers.NewProviderFactory() + // No registration for "unknown" type. + + cfg := providers.ProviderConfig{ + Name: "unknown-test", + Type: "unknown", + Resilience: config.ResilienceConfig{ + CircuitBreaker: config.CircuitBreakerConfig{ + // TripOn nil — should stay nil. + }, + }, + } + _, err := factory.Create(cfg) + require.Error(t, err) + }) +} + +// --------------------------------------------------------------------------- +// E2E: default rules trip the breaker via the public client API +// --------------------------------------------------------------------------- + +func TestDefaultRules_TripBreakerOnWeeklyBody(t *testing.T) { + t.Parallel() + + server, capture := providertest.JSONServer(t, http.StatusForbidden, `{"error":{"message":"`+bodyWeeklyLimit+`","code":"weekly_limit"}}`) + + cfg := llmclient.Config{ + ProviderName: "kimicode", + BaseURL: server.URL, + Retry: config.DefaultRetryConfig(), + CircuitBreaker: config.CircuitBreakerConfig{ + Enabled: true, + FailureThreshold: 5, + SuccessThreshold: 1, + Timeout: 20 * time.Millisecond, + TripOn: kimicodeDefaultTripOn(), + }, + } + client := llmclient.New(cfg, nil) + + // First request trips the breaker. + err := client.Do(context.Background(), llmclient.Request{Method: http.MethodGet, Endpoint: "/test"}, nil) + require.Error(t, err) + assert.Equal(t, 1, capture.Count(), "first failure must reach upstream") + + // Second request is rejected immediately. + err = client.Do(context.Background(), llmclient.Request{Method: http.MethodGet, Endpoint: "/test"}, nil) + require.Error(t, err) + var gwErr *core.GatewayError + require.ErrorAs(t, err, &gwErr) + assert.Contains(t, gwErr.Message, "circuit breaker is open") + assert.Equal(t, 1, capture.Count(), "breaker must prevent upstream reach") + + // Reset closes immediately. + client.ResetBreaker() +} + +func TestDefaultRules_5HourLimitTrips(t *testing.T) { + t.Parallel() + + server, capture := providertest.JSONServer(t, http.StatusForbidden, `{"error":{"message":"`+bodyHourLimit+`","code":"hourly_limit"}}`) + + cfg := llmclient.Config{ + ProviderName: "kimicode", + BaseURL: server.URL, + Retry: config.DefaultRetryConfig(), + CircuitBreaker: config.CircuitBreakerConfig{ + Enabled: true, + FailureThreshold: 5, + SuccessThreshold: 1, + Timeout: 20 * time.Millisecond, + TripOn: kimicodeDefaultTripOn(), + }, + } + client := llmclient.New(cfg, nil) + + err := client.Do(context.Background(), llmclient.Request{Method: http.MethodGet, Endpoint: "/test"}, nil) + require.Error(t, err) + + // Verify breaker is open (second request rejected). + err = client.Do(context.Background(), llmclient.Request{Method: http.MethodGet, Endpoint: "/test"}, nil) + require.Error(t, err) + var gwErr *core.GatewayError + require.ErrorAs(t, err, &gwErr) + assert.Contains(t, gwErr.Message, "circuit breaker is open") + assert.Equal(t, 1, capture.Count(), "breaker must prevent upstream reach") +} + +func TestDefaultRules_ResetClearsQuotaWindow(t *testing.T) { + t.Parallel() + + server, capture := providertest.JSONServer(t, http.StatusForbidden, `{"error":{"message":"`+bodyWeeklyLimit+`","code":"weekly_limit"}}`) + + cfg := llmclient.Config{ + ProviderName: "kimicode", + BaseURL: server.URL, + Retry: config.DefaultRetryConfig(), + CircuitBreaker: config.CircuitBreakerConfig{ + Enabled: true, + FailureThreshold: 5, + SuccessThreshold: 1, + Timeout: 1 * time.Minute, + TripOn: kimicodeDefaultTripOn(), + }, + } + client := llmclient.New(cfg, nil) + + err := client.Do(context.Background(), llmclient.Request{Method: http.MethodGet, Endpoint: "/test"}, nil) + require.Error(t, err) + + client.ResetBreaker() + + // After reset, traffic flows again. + err = client.Do(context.Background(), llmclient.Request{Method: http.MethodGet, Endpoint: "/test"}, nil) + require.Error(t, err) // still fails (server returns 403), but goes upstream + assert.Equal(t, 2, capture.Count(), + "reset must let the request reach upstream instead of failing fast on the open breaker") +} + +// TestFactoryPath_E2E verifies that factory-applied default trip rules reach +// the circuit breaker end-to-end: Registration.DefaultTripOn -> +// ProviderFactory.Create -> provider -> llmclient. The requests below go +// through the factory-created provider itself, not a hand-built client. +func TestFactoryPath_E2E(t *testing.T) { + t.Parallel() + + server, capture := providertest.JSONServer(t, http.StatusForbidden, `{"error":{"message":"`+bodyWeeklyLimit+`","code":"weekly_limit"}}`) + + factory := providers.NewProviderFactory() + factory.Add(Registration) + + // TripOn nil — the factory must apply Registration.DefaultTripOn. + p, err := factory.Create(providers.ProviderConfig{ + Name: "kimi-test", + Type: "kimicode", + APIKey: "test-key", + BaseURL: server.URL, + Resilience: config.ResilienceConfig{ + CircuitBreaker: config.CircuitBreakerConfig{ + Enabled: true, + FailureThreshold: 5, + SuccessThreshold: 1, + Timeout: 20 * time.Millisecond, + }, + }, + }) + require.NoError(t, err) + + req := &core.ChatRequest{ + Model: "kimi-k2", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + } + + // First request reaches upstream and trips the breaker via defaults. + _, err = p.ChatCompletion(context.Background(), req) + require.Error(t, err) + assert.Equal(t, 1, capture.Count(), "first failure must reach upstream") + + // Second request is rejected immediately by the circuit breaker. + _, err = p.ChatCompletion(context.Background(), req) + require.Error(t, err) + assert.Contains(t, err.Error(), "circuit breaker is open") + assert.Equal(t, 1, capture.Count(), "breaker must prevent upstream reach") +} + +// Non-gateway errors don't trip quota rules. +// Covered by llmclient/circuit_breaker_trip_test.go.TestQuotaTripTTL +// which exercises the same logic with unexported field access.