Skip to content
30 changes: 30 additions & 0 deletions docs/providers/kimicode.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
12 changes: 12 additions & 0 deletions internal/admin/handler_provider_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
21 changes: 21 additions & 0 deletions internal/admin/handler_provider_credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions internal/providers/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}

Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
cfg.Resilience.CircuitBreaker.TripOn = defaults
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// 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
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions internal/providers/kimicode/kimicode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions internal/providers/kimicode/trip_defaults.go
Original file line number Diff line number Diff line change
@@ -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},
}
}
Loading
Loading