1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
// Model-group quota accounting for agents whose plan meters several families
// separately. Exports: model_group, group_from_refusal, groups_for_agent,
// has_grouped_quota.
// Deps: types::AgentKind.
use crate::types::AgentKind;
/// Cursor's metered premium pool, and the `auto` tier that outlives it.
const PREMIUM_GROUP: &str = "premium";
const AUTO_GROUP: &str = "auto";
/// Agents whose quota is metered per model family rather than per account.
///
/// agy is the case that forced this: `agy models` serves gemini-*, claude-* and
/// gpt-oss-* families, and each is metered on its own allowance. Captured
/// 2026-08-05 within one minute of each other:
///
/// ```text
/// $ agy -p "Reply OK" --model gemini-3.6-flash-low
/// Error: Individual quota reached. ... Resets in 59m21s.
/// $ agy -p "Reply OK" --model claude-sonnet-4-6
/// OK
/// ```
///
/// Marking the whole agent rate-limited on the first message would strand a
/// working claude allowance and hand the work to a weaker agent — the mirror of
/// the failures-reported-as-success class: a usable resource reported as dead.
pub(crate) fn has_grouped_quota(agent: AgentKind) -> bool {
// Two independent reasons an allowance is partitioned, and they are not the
// same fact. A provider that meters per model family says so in the
// provider table, and a second such provider needs no change here at all.
//
// A tier split *inside* one pool is different: cursor is a subscription —
// one account, one bill — but `auto` keeps serving after the premium pool is
// spent, so the two must be marked separately. That is not a metering shape,
// and rewriting cursor's shape to `PerModelFamily` to obtain grouping here
// is what broke cost classification and needed an agent-specific patch in
// the pricing layer to undo. The group table states the split directly.
if agent == AgentKind::OpenCode || !groups_for_agent(agent).is_empty() {
return true;
}
matches!(
crate::types::provider_for_cli(agent).1,
crate::types::MeteringShape::PerModelFamily
)
}
/// The quota group a model belongs to, by family prefix. Returns None when the
/// agent meters its whole account together, so callers fall back to per-agent
/// marking unchanged.
pub(crate) fn model_group<'a>(agent: AgentKind, model: Option<&'a str>) -> Option<&'a str> {
if agent == AgentKind::OpenCode {
return model.and_then(provider_from_model);
}
if !has_grouped_quota(agent) {
return None;
}
let model = model?.to_ascii_lowercase();
if agent == AgentKind::Cursor {
// Cursor meters one shared premium pool and `auto` is the only thing
// that keeps serving once it is spent, so `auto` is the exception and
// everything else draws on the metered pool.
//
// Enumerating premium models instead gets this backwards: the first
// version of this listed `composer-2.5` and `gpt-5.4-high` because those
// were the two models in the day's evidence, which left every other
// premium model reading as unmetered and dispatchable after the pool was
// already gone.
return Some(if model.starts_with("auto") { AUTO_GROUP } else { PREMIUM_GROUP });
}
Some(family_of(&model))
}
/// The group a refusal names, for providers whose own wording identifies the
/// exhausted tier.
///
/// Most marker writes happen where no model is in hand: a stderr line, a stream
/// error event, a failed task's captured output. Those paths marked the whole
/// agent, which for cursor meant one premium refusal took `auto` out with it —
/// `auto` being the one tier that keeps serving once the premium pool is spent.
/// The refusal itself says so, so it is read here rather than guessed.
pub(crate) fn group_from_refusal<'a>(agent: AgentKind, message: &'a str) -> Option<&'a str> {
if agent == AgentKind::OpenCode {
return named_opencode_provider(message);
}
if agent != AgentKind::Cursor {
return None;
}
// The needle is cursor's premium signature verbatim; its other quota
// refusal — "quota exceeded for this workspace" — names no tier and stays
// agent-wide, which is right: a workspace cap is not a tier cap.
message
.to_ascii_lowercase()
.contains("you're out of usage")
.then_some(PREMIUM_GROUP)
}
fn provider_from_model(model: &str) -> Option<&str> {
let (provider, _) = model.split_once('/')?;
(!provider.is_empty()).then_some(provider)
}
fn named_opencode_provider(message: &str) -> Option<&str> {
let lower = message.to_ascii_lowercase();
["providerid", "provider_id", "provider", "model"]
.iter()
.find_map(|key| value_after_key(message, &lower, key))
.and_then(|value| provider_from_model(value).or(Some(value)))
.filter(|provider| !provider.eq_ignore_ascii_case("unknown"))
}
fn value_after_key<'a>(message: &'a str, lower: &str, key: &str) -> Option<&'a str> {
let start = lower.find(key)? + key.len();
let value = message[start..].split_once(':')?.1.trim_start();
let value = value.strip_prefix('"').unwrap_or(value);
let end = value
.find(|ch: char| ch == '"' || ch == ',' || ch == '}' || ch.is_whitespace())
.unwrap_or(value.len());
(!value[..end].is_empty()).then_some(&value[..end])
}
/// Delegates to the types layer: how a provider partitions its allowance is a
/// fact about the provider, and keeping a second copy here had already produced
/// two different answers for `gpt-*`.
fn family_of(model: &str) -> &'static str {
crate::types::model_family(model)
}
/// Every group an agent can draw on, most capable first within each family.
/// Used to pick a replacement when the group in use is exhausted.
pub(crate) fn groups_for_agent(agent: AgentKind) -> &'static [(&'static str, &'static [&'static str])] {
match agent {
// Captured from `agy models`, 2026-08-05.
AgentKind::Antigravity => &[
("gemini", &["gemini-3.1-pro-high", "gemini-3.6-flash-high", "gemini-3.6-flash-low"]),
("claude", &["claude-opus-4-6-thinking", "claude-sonnet-4-6"]),
("gpt-oss", &["gpt-oss-120b-medium"]),
],
// Cursor's two tiers. The model lists are a preference order for
// picking a replacement, not the membership test — `model_group`
// decides membership, and it treats everything that is not `auto` as
// premium so an unlisted premium model is still held.
AgentKind::Cursor => &[
(PREMIUM_GROUP, &["composer-2.5", "gpt-5.4-high"]),
(AUTO_GROUP, &["auto"]),
],
_ => &[],
}
}
/// First model from a group that is not exhausted, preferring the caller's
/// current group so an unrelated quota outage never moves work off it.
pub(crate) fn healthy_model_for(
agent: AgentKind,
current: Option<&str>,
is_group_limited: impl Fn(&str) -> bool,
) -> Option<&'static str> {
let groups = groups_for_agent(agent);
if groups.is_empty() {
return None;
}
let current_group = model_group(agent, current);
if let Some(group) = current_group
&& !is_group_limited(group)
{
return None; // current model is fine; do not churn
}
groups
.iter()
.find(|(name, models)| !is_group_limited(name) && !models.is_empty())
.and_then(|(_, models)| models.first().copied())
}
#[cfg(test)]
#[path = "model_group_tests.rs"]
mod tests;