Skip to main content

wcode_harness/
limits.rs

1//! Per-model context/output limits for recognized providers, so the agent can
2//! size context against the real window instead of guessing.
3//!
4//! The provider's own API does not expose a context window: the OpenCode Go
5//! (zen) `/models` endpoint returns only `id/object/created/owned_by`, and rig
6//! leaves `Model::context_length` empty for every OpenAI-compatible listing
7//! (it is only populated for providers whose listing reports a window, e.g.
8//! Groq/Moonshot/OpenRouter). The authoritative catalog is models.dev, whose
9//! `opencode-go` entry (`api = https://opencode.ai/zen/go/v1`) carries
10//! `limit.context` / `limit.output` per model. [`OPENCODE_GO`] is a snapshot of
11//! that table.
12//!
13//! The *live* measure of how full the context is comes from the provider's own
14//! response usage (`prompt_tokens` → `crate::message::Usage::input_tokens`),
15//! not from here; these limits only say *how much* fits.
16
17use crate::streamfn::LlmEndpoint;
18
19/// Context window assumed when neither the model catalog nor config supplies
20/// one: a conservative baseline so an unknown model still compacts in time.
21pub const DEFAULT_CONTEXT_WINDOW: u64 = 128_000;
22
23/// Context window and output ceiling advertised for a model.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct ModelLimit {
26    /// Total context window (tokens) the model accepts.
27    pub context: u64,
28    /// Maximum output tokens the provider advertises for one reply.
29    pub output: u64,
30}
31
32/// Cap on how many tokens are held back for the model's reply. The advertised
33/// output ceiling can be huge (OpenCode's `deepseek-v4.1-flash` is 384k), so it
34/// is capped to keep most of the window usable for input.
35pub const DEFAULT_RESERVE_CAP: u64 = 32_768;
36
37impl ModelLimit {
38    /// Tokens reserved for the reply: `min(output, DEFAULT_RESERVE_CAP)`.
39    pub fn reserve(&self) -> u64 {
40        self.output.min(DEFAULT_RESERVE_CAP)
41    }
42
43    /// Input-token count at which the window is considered full: the context
44    /// window minus the reply reserve.
45    pub fn trigger(&self) -> u64 {
46        self.context.saturating_sub(self.reserve())
47    }
48
49    /// True when a provider-reported `input_tokens` has reached the trigger —
50    /// the next request risks running past the window.
51    pub fn is_full(&self, input_tokens: u64) -> bool {
52        input_tokens >= self.trigger()
53    }
54}
55
56/// True for endpoints served by OpenCode's Go (zen) API.
57pub fn is_opencode_go(base_url: Option<&str>) -> bool {
58    base_url.is_some_and(|u| u.to_ascii_lowercase().contains("opencode.ai/zen/go"))
59}
60
61/// Limits for `model` served from `base_url`, when we recognize the provider.
62/// `None` = unknown provider/model (the caller falls back to a default budget).
63pub fn model_limit(base_url: Option<&str>, model: &str) -> Option<ModelLimit> {
64    if is_opencode_go(base_url) {
65        return opencode_go(model);
66    }
67    None
68}
69
70/// The wire protocol `model` speaks on OpenCode Go, when we recognize it.
71/// `None` = unknown / both-work / not served by `chat`-or-`responses`, or a
72/// non-zen provider — the caller (`EndpointPref::resolve`) falls back to `Chat`.
73///
74/// Gated by `is_opencode_go` EXACTLY like `model_limit`: a known id behind a
75/// non-OpenCode base is NOT assumed (mirrors `unknown_model_or_provider_is_none`).
76/// Only the responses-only ids are enumerated; every other id is `None`
77/// (both-works, or speaks a protocol wcode does not implement).
78///
79/// # Returns
80/// `Some(LlmEndpoint::Responses)` for an id in `OPENCODE_GO_RESPONSES_ONLY` under a
81/// zen base; `None` otherwise (never `Some(Chat)` — absence means "let the caller
82/// default to chat").
83///
84/// Does NOT promise: it does not verify the id exists in the live catalog, and it is
85/// a snapshot of the OpenCode Go listing (`OPENCODE_GO_RESPONSES_ONLY`).
86pub fn model_protocol(base_url: Option<&str>, model: &str) -> Option<LlmEndpoint> {
87    if is_opencode_go(base_url) && OPENCODE_GO_RESPONSES_ONLY.contains(&model) {
88        return Some(LlmEndpoint::Responses);
89    }
90    None
91}
92
93fn opencode_go(model: &str) -> Option<ModelLimit> {
94    OPENCODE_GO
95        .iter()
96        .find(|(id, ..)| *id == model)
97        .map(|(_, context, output)| ModelLimit {
98            context: *context,
99            output: *output,
100        })
101}
102
103/// The snapshot ids served by OpenCode Go ONLY on the Responses wire
104/// (`/responses`); every other id in `OPENCODE_GO` answers on `chat`. Snapshot from
105/// the orchestrator's live probe. Served by NEITHER wcode wire: `claude-haiku-5-5`,
106/// `minimax-m2.7`, `gemini-4-argon-contributor` — deliberately absent, so they fall
107/// through to chat = today's behavior.
108static OPENCODE_GO_RESPONSES_ONLY: &[&str] = &[
109    "grok-4.6",
110    "grok-4.7",
111    "gpt-5.6-luna",
112    "gpt-6-luna",
113    "muse-spark-1.2-contributor",
114    "muse-spark-1.3-contributor",
115];
116
117/// OpenCode Go (zen) model table, snapshotted from the models.dev `opencode-go`
118/// catalog: `(model id, context window, max output tokens)`.
119static OPENCODE_GO: &[(&str, u64, u64)] = &[
120    ("claude-haiku-5-5", 1_000_000, 128_000),
121    ("deepseek-v4-flash", 1_000_000, 384_000),
122    ("deepseek-v4-flash-vision-exp", 1_000_000, 384_000),
123    ("deepseek-v4-pro", 1_000_000, 384_000),
124    ("deepseek-v4.1-flash", 1_000_000, 384_000),
125    ("glm-5", 202_752, 32_768),
126    ("glm-5.1", 202_752, 32_768),
127    ("glm-5.2", 1_000_000, 131_072),
128    ("glm-5.3", 1_000_000, 131_072),
129    ("glm-5.3-flash", 1_000_000, 131_072),
130    ("gpt-5.6-luna", 1_050_000, 128_000),
131    ("gpt-6-luna", 1_050_000, 128_000),
132    ("grok-4.5", 500_000, 500_000),
133    ("grok-4.6", 500_000, 500_000),
134    ("grok-4.7", 500_000, 500_000),
135    ("hy3", 256_000, 128_000),
136    ("hy4-preview", 1_024_000, 64_000),
137    ("kimi-k2.5", 262_144, 65_536),
138    ("kimi-k2.6", 262_144, 65_536),
139    ("kimi-k2.7-code", 262_144, 262_144),
140    ("kimi-k3", 1_048_576, 131_072),
141    ("longcat-2.0", 1_000_000, 131_072),
142    ("longcat-2.5-preview-free", 1_000_000, 131_072),
143    ("mimo-v2-omni", 262_144, 128_000),
144    ("mimo-v2-pro", 1_048_576, 128_000),
145    ("mimo-v2.5", 1_000_000, 128_000),
146    ("mimo-v2.5-pro", 1_048_576, 128_000),
147    ("mimo-v2.6-flash", 1_048_576, 131_072),
148    ("mimo-v2.6-pro", 1_048_576, 131_072),
149    ("minimax-m2.5", 204_800, 65_536),
150    ("minimax-m2.7", 204_800, 131_072),
151    ("minimax-m3", 1_000_000, 131_072),
152    ("muse-spark-1.2-contributor", 1_048_576, 131_072),
153    ("muse-spark-1.3-contributor", 1_048_576, 131_072),
154    ("omen-alpha", 500_000, 128_000),
155    ("ox-alpha-free", 1_000_000, 131_072),
156    ("qwen3.5-plus", 262_144, 65_536),
157    ("qwen3.6-plus", 1_000_000, 65_536),
158    ("qwen3.7-max", 1_000_000, 65_536),
159    ("qwen3.7-plus", 1_000_000, 65_536),
160    ("qwen3.8-flash", 1_000_000, 131_072),
161    ("qwen3.8-max", 1_000_000, 131_072),
162    ("space-bunny", 1_048_576, 524_288),
163    ("space-bunny-free", 1_048_576, 524_288),
164    ("step-5-preview-free", 1_000_000, 65_536),
165];
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::compaction::CompactionPolicy;
171
172    const ZEN: Option<&str> = Some("https://opencode.ai/zen/go/v1");
173
174    #[test]
175    fn recognizes_opencode_go_endpoint() {
176        assert!(is_opencode_go(ZEN));
177        assert!(is_opencode_go(Some("https://opencode.ai/zen/go/v1/")));
178        assert!(is_opencode_go(Some("HTTPS://OPENCODE.AI/ZEN/GO/V1")));
179        assert!(!is_opencode_go(Some("https://api.openai.com/v1")));
180        assert!(!is_opencode_go(Some("https://opencode.ai/zen/v1")));
181        assert!(!is_opencode_go(None));
182    }
183
184    #[test]
185    fn looks_up_configured_model() {
186        let l = model_limit(ZEN, "deepseek-v4.1-flash").expect("known opencode model");
187        assert_eq!(l.context, 1_000_000);
188        assert_eq!(l.output, 384_000);
189    }
190
191    #[test]
192    fn unknown_model_or_provider_is_none() {
193        assert_eq!(model_limit(ZEN, "no-such-model"), None);
194        // A known OpenCode id behind a non-opencode endpoint is not assumed.
195        assert_eq!(model_limit(Some("https://api.openai.com/v1"), "kimi-k3"), None);
196        assert_eq!(model_limit(None, "kimi-k3"), None);
197    }
198
199    #[test]
200    fn step_5_preview_free_is_a_1m_window() {
201        // Verified against the models.dev `opencode-go` catalog
202        // (api = https://opencode.ai/zen/go/v1). Missing here, the model fell
203        // back to DEFAULT_CONTEXT_WINDOW and compacted at ~11% of its window.
204        assert_eq!(
205            model_limit(ZEN, "step-5-preview-free"),
206            Some(ModelLimit {
207                context: 1_000_000,
208                output: 65_536,
209            })
210        );
211    }
212
213    #[test]
214    fn catalog_window_beats_the_default_in_compaction() {
215        // The trigger is the window less the reply reserve, so the catalog row
216        // is what moves it from the 128k default's 111,616 to 983,616.
217        let p = CompactionPolicy::default();
218        let window = p.context_window(ZEN, "step-5-preview-free");
219        assert_eq!(window, 1_000_000);
220        assert!(
221            !p.should_compact(window, 111_616),
222            "the old 128k trigger must not fire on a 1M model"
223        );
224        assert!(
225            p.should_compact(window, 983_616),
226            "1M window - 16_384 reserved = 983,616"
227        );
228    }
229
230    #[test]
231    fn configured_window_still_wins_only_when_the_catalog_misses() {
232        let p = CompactionPolicy {
233            window: Some(200_000),
234            ..Default::default()
235        };
236        // Catalog miss: the configured override is the window.
237        assert_eq!(p.context_window(ZEN, "not-a-real-model"), 200_000);
238        // Catalog hit: it wins, and the override is ignored.
239        assert_eq!(p.context_window(ZEN, "step-5-preview-free"), 1_000_000);
240    }
241
242    #[test]
243    fn reserve_is_capped_and_trigger_and_is_full_agree() {
244        // Huge advertised output (384k) is capped to the reserve cap.
245        let big = ModelLimit { context: 1_000_000, output: 384_000 };
246        assert_eq!(big.reserve(), DEFAULT_RESERVE_CAP);
247        assert_eq!(big.trigger(), 1_000_000 - DEFAULT_RESERVE_CAP);
248        assert!(!big.is_full(big.trigger() - 1));
249        assert!(big.is_full(big.trigger()));
250
251        // Small advertised output is used verbatim, not the cap.
252        let small = ModelLimit { context: 8_192, output: 4_096 };
253        assert_eq!(small.reserve(), 4_096);
254        assert_eq!(small.trigger(), 4_096);
255        assert!(small.is_full(4_096));
256    }
257
258    #[test]
259    fn model_protocol_matrix() {
260        // Every responses-only id under a zen base ⇒ Responses.
261        for id in [
262            "grok-4.6",
263            "grok-4.7",
264            "gpt-5.6-luna",
265            "gpt-6-luna",
266            "muse-spark-1.2-contributor",
267            "muse-spark-1.3-contributor",
268        ] {
269            assert_eq!(model_protocol(ZEN, id), Some(LlmEndpoint::Responses), "{id}");
270        }
271    }
272
273    #[test]
274    fn model_protocol_both_works_and_unknown_are_none() {
275        // A both-works id (chat), an unknown id: no protocol fact.
276        assert_eq!(model_protocol(ZEN, "deepseek-v4-flash"), None);
277        assert_eq!(model_protocol(ZEN, "no-such-model"), None);
278    }
279
280    #[test]
281    fn model_protocol_needs_a_zen_base() {
282        // A known responses-only id behind a NON-zen base is NOT assumed
283        // (mirrors `unknown_model_or_provider_is_none`).
284        assert_eq!(
285            model_protocol(Some("https://api.openai.com/v1"), "grok-4.7"),
286            None
287        );
288        assert_eq!(model_protocol(None, "grok-4.7"), None);
289    }
290}