Skip to content

Commit d5f359e

Browse files
opensecret: health-aware Auto model selection for Router V2 (#968)
* opensecret: health-aware Auto model selection for Router V2 Router V2 resolved an Auto alias to one public model before any health check, so a single-route model with no eligible provider left Auto Quick with nothing to do. Add a small compiled Auto model policy that runs after alias resolution and before any model-dependent work: paid Quick may move from DeepSeek V4.1 Flash to GLM 5.3 Flash and paid Powerful from GLM 5.3 to Kimi K3 only when every route of the preferred model is unavailable under that model's own failover policy. Free tiers keep their single target, explicit selections never change public model, and Router V1 is untouched. The decision consumes one health snapshot across all candidate routes, claims nothing, and feeds the chosen model into the existing same-model planner, first-send claim, tool-loop pinning, persistence, and usage. An alternate is skipped when the request needs more context than its window (Chat uses a text-only estimate with a byte bound before tokenizing, plus tool schemas and the caller's output limit), when it lacks vision the request needs, or, for Kimi on Chat, when tool-call ids are not in Kimi form. Before any provider send, a chosen Auto model that cannot hold the request's context or loses its routes earns exactly one further decision with that model excluded; nothing is ever replayed after a send starts. Router V2 also remembers the route a provider accepted per account, surface, and selector for one idle hour so warmed prompt caches are reused: the planner prefers the remembered same-model provider while eligible, and Auto retries the remembered alternate before the preferred model while it stays healthy. Responses now binds the user key, conversation ownership, the usage gate, and the caller's message against the tier's own model before the decision; its input handling is split so the budget check and both context builds use the final model. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * opensecret: keep the capacity result when Auto reselection has no alternate A bounded second Auto decision excluded the lost model as a context overflow even when the cause was a route that became unavailable after the first snapshot. With no other candidate (free Quick, or a paid tier whose alternate is incompatible) the selector reported no health rejection and the request ended as a 500 instead of the typed capacity result, losing the retry hint although nothing had been sent. Carry the exclusion cause: a lost route is excluded as unavailable with the capacity result's own retry hint, so the second decision reports it and keeps the 503 contract and Retry-After; a context overflow stays distinct. The Responses replay-safety downgrade after billed image helpers still applies. Tests cover free single-candidate Auto and paid Auto with an incompatible alternate on both surfaces, the hint fallback, the compatible alternate still being taken once, and the helper downgrade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 77b92f5 commit d5f359e

13 files changed

Lines changed: 4180 additions & 201 deletions

File tree

‎.agents/skills/change-opensecret-provider/SKILL.md‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,22 @@ preferences. Keep image and title helpers on the request's appropriate routing
6767
context. A helper candidate identifies a public model; resolve its provider
6868
through shared routing rather than pinning an independently guessed provider.
6969

70-
Trace alias resolution separately from provider selection. Current V2 failover
71-
stays within the resolved public model; an Auto alias alone does not implement
72-
cross-model failover. Do not infer inactive behavior from historical `Shadow`
73-
type names: active V2 selection consumes health snapshots.
70+
Trace alias resolution separately from model selection and provider selection.
71+
Router V2 resolves an Auto alias to its tier's preferred model, then
72+
`src/inference/auto_model.rs` may choose another compiled candidate of that
73+
tier before context assembly, persistence, and route pinning when every route
74+
of the preferred model is unavailable under that model's own failover policy.
75+
Explicit selections never change public model, and the chosen model still goes
76+
through the same-model provider planner and first-send claim. Before any
77+
provider send, a chosen Auto model that cannot hold the request or loses its
78+
routes earns exactly one further decision with that model excluded; after the
79+
first send the logical response stays pinned. Router V2 also
80+
prefers the account's remembered route from `src/inference/sticky_routes.rs`
81+
(model and provider, per surface and selector, recorded once the provider
82+
accepts a request) while it stays eligible and the account has not been idle
83+
for the sticky window; Router V1 never consults either. Do not
84+
infer inactive behavior from historical `Shadow` type names: active V2
85+
selection consumes health snapshots.
7486

7587
Preserve first-send claim handling and later-turn pinning. A claim lost before
7688
the first send may select another same-model provider; later Responses tool

‎services/opensecret/src/bounded_ttl_cache.rs‎

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ where
6262
self.insert_evicting_at(key, value, Instant::now())
6363
}
6464

65-
fn insert_evicting_at(&mut self, key: K, value: V, now: Instant) -> InsertOutcome {
65+
pub(crate) fn insert_evicting_at(&mut self, key: K, value: V, now: Instant) -> InsertOutcome {
6666
let expires_at = now
6767
.checked_add(self.ttl)
6868
.expect("bounded cache TTL must fit in Instant");
@@ -96,6 +96,27 @@ where
9696
outcome
9797
}
9898

99+
/// Returns a live value without consuming it. Reads do not change the
100+
/// recency order, so LRU order keeps matching expiry order as
101+
/// [`Self::retire_expired_at`] requires. An expired entry is removed
102+
/// instead of returned.
103+
#[cfg(test)]
104+
pub(crate) fn get_live(&mut self, key: &K) -> Option<&V> {
105+
self.get_live_at(key, Instant::now())
106+
}
107+
108+
pub(crate) fn get_live_at(&mut self, key: &K, now: Instant) -> Option<&V> {
109+
if self
110+
.entries
111+
.peek(key)
112+
.is_some_and(|entry| entry.expires_at <= now)
113+
{
114+
drop(self.entries.pop(key));
115+
return None;
116+
}
117+
self.entries.peek(key).map(|entry| &entry.value)
118+
}
119+
99120
/// Removes a value exactly once and returns it only while its TTL is live.
100121
pub(crate) fn take_live(&mut self, key: &K) -> Option<V> {
101122
self.take_live_at(key, Instant::now())
@@ -148,7 +169,7 @@ where
148169
}
149170

150171
#[cfg(test)]
151-
fn len(&self) -> usize {
172+
pub(crate) fn len(&self) -> usize {
152173
self.entries.len()
153174
}
154175
}
@@ -464,4 +485,46 @@ mod tests {
464485
Some(128 * 12 - 1)
465486
);
466487
}
488+
489+
#[test]
490+
fn get_live_returns_without_consuming_and_drops_expired_entries() {
491+
let start = Instant::now();
492+
let mut cache = cache(2, Duration::from_secs(60));
493+
cache.insert_evicting_at(1, "one", start);
494+
495+
assert_eq!(
496+
cache.get_live_at(&1, start + Duration::from_secs(59)),
497+
Some(&"one")
498+
);
499+
assert_eq!(
500+
cache.get_live_at(&1, start + Duration::from_secs(59)),
501+
Some(&"one")
502+
);
503+
assert_eq!(cache.get_live_at(&1, start + Duration::from_secs(60)), None);
504+
assert_eq!(cache.len(), 0);
505+
assert_eq!(cache.get_live(&1), None);
506+
}
507+
508+
#[test]
509+
fn get_live_does_not_reorder_entries_so_oldest_expiry_is_still_retired_first() {
510+
let start = Instant::now();
511+
let mut cache = cache(3, Duration::from_secs(60));
512+
cache.insert_evicting_at(1, 1u8, start);
513+
cache.insert_evicting_at(2, 2u8, start + Duration::from_secs(10));
514+
cache.insert_evicting_at(3, 3u8, start + Duration::from_secs(20));
515+
516+
// Reading the oldest entry must not move it to the front.
517+
assert_eq!(
518+
cache.get_live_at(&1, start + Duration::from_secs(30)),
519+
Some(&1u8)
520+
);
521+
let retired = cache.retire_expired_at(start + Duration::from_secs(65));
522+
assert_eq!(retired.removed_count(), 1);
523+
assert_eq!(cache.get_live_at(&1, start + Duration::from_secs(65)), None);
524+
assert_eq!(
525+
cache.get_live_at(&2, start + Duration::from_secs(65)),
526+
Some(&2u8)
527+
);
528+
assert_eq!(cache.len(), 2);
529+
}
467530
}

‎services/opensecret/src/inference.rs‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
use crate::model_config::{ModelPlan, AUTO_POWERFUL_MODEL_ID, AUTO_QUICK_MODEL_ID};
22
use crate::provider_registry::{ProviderId, RouteSelectionSource};
3+
use auto_model::{AutoModelDecision, AutoModelReason};
34
use std::fmt;
45
use std::time::Duration;
56
use uuid::Uuid;
67

8+
pub(crate) mod auto_model;
79
pub(crate) mod health;
10+
pub(crate) mod sticky_routes;
811

912
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1013
pub(crate) enum ModelSelectionMode {
@@ -25,9 +28,19 @@ impl ModelSelectionMode {
2528
pub(crate) const fn is_auto(self) -> bool {
2629
matches!(self, Self::AutoQuick | Self::AutoPowerful)
2730
}
31+
32+
/// The public alias string for an Auto mode, the inverse of
33+
/// [`Self::from_requested_model`].
34+
pub(crate) const fn alias(self) -> Option<&'static str> {
35+
match self {
36+
Self::AutoQuick => Some(AUTO_QUICK_MODEL_ID),
37+
Self::AutoPowerful => Some(AUTO_POWERFUL_MODEL_ID),
38+
Self::Explicit => None,
39+
}
40+
}
2841
}
2942

30-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3144
pub(crate) enum InferenceSurface {
3245
ChatCompletions,
3346
Responses,
@@ -67,8 +80,14 @@ inference_id!(InferenceAttemptId);
6780
pub(crate) struct InferenceIntent {
6881
pub(crate) request_id: InferenceRequestId,
6982
pub(crate) account_uuid: Uuid,
83+
/// The caller's selector: an Auto alias or an explicit public model.
7084
pub(crate) requested_model_id: String,
85+
/// The model this logical request executes. Provider planning, context
86+
/// preparation, persistence, and usage all use this identity.
7187
pub(crate) public_model_id: String,
88+
/// The Router v2 Auto decision that produced `public_model_id`, when one
89+
/// ran. Explicit selections and Router v1 requests carry none.
90+
pub(crate) auto_model: Option<AutoModelDecision>,
7291
pub(crate) selection_mode: ModelSelectionMode,
7392
pub(crate) model_plan: ModelPlan,
7493
pub(crate) surface: InferenceSurface,
@@ -91,12 +110,37 @@ impl InferenceIntent {
91110
selection_mode: ModelSelectionMode::from_requested_model(&requested_model_id),
92111
requested_model_id,
93112
public_model_id: public_model_id.into(),
113+
auto_model: None,
94114
model_plan,
95115
surface,
96116
workload_class,
97117
}
98118
}
99119

120+
/// Applies a finished Router v2 Auto model decision. The chosen model
121+
/// becomes the executed identity while the decision stays visible as
122+
/// intent metadata.
123+
pub(crate) fn with_auto_model_decision(mut self, decision: AutoModelDecision) -> Self {
124+
debug_assert_eq!(decision.selector, self.requested_model_id);
125+
self.public_model_id = decision.chosen_model_id.to_string();
126+
self.auto_model = Some(decision);
127+
self
128+
}
129+
130+
/// The model the selector resolves to when healthy. Differs from
131+
/// `public_model_id` only after an Auto decision chose an alternate.
132+
pub(crate) fn preferred_model_id(&self) -> &str {
133+
self.auto_model
134+
.as_ref()
135+
.map_or(self.public_model_id.as_str(), |decision| {
136+
decision.preferred_model_id
137+
})
138+
}
139+
140+
pub(crate) fn auto_model_reason(&self) -> Option<AutoModelReason> {
141+
self.auto_model.as_ref().map(|decision| decision.reason)
142+
}
143+
100144
pub(crate) fn begin_execution(&self) -> InferenceExecution {
101145
InferenceExecution {
102146
request_id: self.request_id,

0 commit comments

Comments
 (0)