feat(management): expose routing.session-affinity and its TTL like routing/strategy - #5447
feat(management): expose routing.session-affinity and its TTL like routing/strategy#5447deathemperor wants to merge 3 commits into
Conversation
…uting/strategy
`routing/strategy` can be read and switched at runtime, but the setting
that makes the round-robin modes usable with Claude Code's prompt cache,
`routing.session-affinity` (+ `session-affinity-ttl`), was config-file
only. A management client that flips the strategy to round-robin could
not also turn affinity on, so every request landed on a different
credential and every prompt-cache read missed until the user edited
config.yaml by hand.
This adds GET/PUT/PATCH /v0/management/routing/session-affinity.
GET returns {"enabled": bool, "ttl": "1h"} (the TTL falls back to the
runtime default when unset). PUT/PATCH accepts either or both of
{"enabled": bool, "ttl": string}; the TTL is validated with
time.ParseDuration (empty clears it back to the default) and the change
persists with the same comment-preserving save and hot-reload as
routing/strategy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 798136de60
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| parsed, errParse := time.ParseDuration(ttl) | ||
| if errParse != nil || parsed <= 0 { |
There was a problem hiding this comment.
Enforce the runtime's one-second TTL floor
When a management client sends a positive sub-second value such as {"ttl":"500ms"}, this validation accepts and persists it, but normalizedRoutingRuntimeState in sdk/cliproxy/service_config.go:49-54 clamps the selector TTL to one second while this endpoint's GET returns the raw 500ms. The successful update and readback therefore misrepresent the effective routing behavior; reject values below one second or normalize them consistently with the runtime.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in ead55b5: the PUT now rejects any TTL below one second (minSessionAffinityTTL = time.Second, mirroring the clamp in normalizedRoutingRuntimeState), so the persisted value and the readback never diverge from what the selector actually uses. {"ttl":"500ms"} is covered in the bad-input test.
…nd floor normalizedRoutingRuntimeState clamps the selector TTL to 1s, so a persisted 500ms would read back as something the router never uses. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ead55b5742
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| h.cfg.Routing.SessionAffinityTTL = ttl | ||
| } | ||
| if body.Enabled != nil { | ||
| h.cfg.Routing.SessionAffinity = *body.Enabled |
There was a problem hiding this comment.
Persist disabled and cleared affinity values
After these keys have been set to nonzero values, sending {"enabled":false} or {"ttl":""} only clears the in-memory fields. Both RoutingConfig fields use yaml:",omitempty", while SaveConfigPreserveComments merges generated keys without pruning omitted routing keys, so the YAML retains session-affinity: true and/or the old TTL. A restart or any reload from disk therefore restores settings that this endpoint returned 200 for clearing; explicitly remove or overwrite the corresponding YAML scalars when applying these values.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 882e60b: SaveConfigPreserveComments now prunes the routing mapping to the keys the struct renders (same pruneMappingToGeneratedKeys used for the oauth maps and plugin configs), so {"enabled":false} / {"ttl":""} remove session-affinity / session-affinity-ttl from the YAML instead of only the in-memory fields; strategy survives. Covered by TestRoutingSessionAffinityClearedValuesLeaveTheFile (enable → clear → re-read the file).
routing.session-affinity and session-affinity-ttl are omitempty, and SaveConfigPreserveComments merged generated keys without removing the ones the struct no longer renders — so disabling affinity or clearing the TTL through the management API left the old values in the YAML and a reload restored them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 882e60ba2a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // routing.* keys are omitempty: a cleared session-affinity / TTL must | ||
| // leave the file too, or the next reload restores it. | ||
| pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "routing") |
There was a problem hiding this comment.
Limit pruning to the affinity keys
When a config's routing mapping contains a YAML merge key or an extension key not represented by RoutingConfig, any management API save now passes the whole mapping to pruneMissingMapKeys, which permanently deletes those entries even when the request is unrelated to routing. Previously the comment-preserving merge left unmodeled nested entries intact; remove only session-affinity and session-affinity-ttl when those values are explicitly cleared rather than pruning the entire mapping.
Useful? React with 👍 / 👎.
What
GET / PUT / PATCH /v0/management/routing/session-affinity, the companion ofrouting/strategy.ttlis validated withtime.ParseDuration(must be positive); a bad body or TTL is a 400 and leaves the config untouched.persistpath asrouting/strategy: comment-preserving save, then the async hot-reload that rebuilds the selector (sdk/cliproxy/service_config.goalready readsRouting.SessionAffinityand the TTL on reload, so no runtime change is needed).GETreports the effective TTL: the configured string, or1hwhen unset, matching the runtime default.Why
routing/strategycan be switched at runtime, butrouting.session-affinity(+session-affinity-ttl) is config-file only. A management client that flips the strategy toround-robincannot also turn affinity on, so every request lands on a different credential and every prompt-cache read misses until the user editsconfig.yamlby hand. Infinitus (a macOS menu-bar account manager that drives CLIProxyAPI purely over the Management API and never touches the config file) currently has to tell the user to go edit YAML for this one knob; with this route the toggle sits next to the strategy picker.Implementation
internal/api/handlers/management/config_basic.go:GetRoutingSessionAffinity,PutRoutingSessionAffinity,defaultSessionAffinityTTLnext to the strategy handlers.internal/api/server_management.go: three route lines under/routing/strategy.internal/api/handlers/management/config_basic_session_affinity_test.go: round trip through a temp config file (values in memory and on disk), partial updates, empty TTL, rejected bodies leave the config unchanged.Verified with
gofmt -l,go vet ./internal/api/...,go build -o test-output ./cmd/server && rm test-output,go test ./internal/api/..., andgo test -race -count=2on the new tests.🤖 Generated with Claude Code