From b5635408c6d5333d5c8abcb4e7d7e47f7115114b Mon Sep 17 00:00:00 2001 From: Shubham Malhotra Date: Thu, 29 Oct 2026 11:18:06 -0700 Subject: [PATCH 1/4] test(harness): hold every config's output limit to the model's own bounds The API is moving to the industry rule for output limits: max_tokens is a hard total of everything the model generates, thinking included (OpenAI max_completion_tokens, Anthropic's thinking budget inside max_tokens, OpenRouter). The control plane's reasoning allowance on top of a named max_tokens is being removed, so a harness's limit is the whole budget for thinking plus answer, and making it large enough is the client's job. No behaviour change. The four config writers that declare an output limit (OpenCode limit.output, OpenClaw / Prime Agent / dsh maxTokens) each had their own copy of "max_output, else min(window, fallback)". They now share catalog_models::output_limit, which documents the rule, and a new test pins it for all four: - a model with max_output: min(16,384, max_output) <= limit <= max_output - a window but no max_output: min(window, 65,536) for OpenCode, OpenClaw, Prime Agent; min(window, 32,768) for dsh - no window (a private id the catalogue lists without limits): no limit, so the server applies the model's default The writers rebuild our provider block from the live catalog on every launch (OpenCode's config is ephemeral; OpenClaw, Prime Agent and dsh replace providers.runanywhere), so an old config asking 262,144 on deepseek is not carried forward by wally. Failing test first: the test, written before the helper, failed to compile error[E0425]: cannot find function `output_limit` in module `harness` Break test (cargo test --test test_wally_harness every_harness_config): BREAK A, output_limit caps max_output at 8,192 (the 2026-09-22 shape): opencode: deepseek-v4.1-flash limit 8192 is outside [min(16384, 65536), 65536] test result: FAILED. 0 passed; 1 failed BREAK B, opencode writes the whole context window as its output limit: opencode: deepseek-v4.1-flash limit 1048576 is outside [min(16384, 65536), 65536] test result: FAILED. 0 passed; 1 failed BREAK C, dsh fallback 32,768 -> 65,536: assertion `left == right` failed: dsh fallback left: Some(65536) right: Some(32768) test result: FAILED. 0 passed; 1 failed RESTORED: test result: ok. 1 passed; 0 failed Co-Authored-By: Claude Opus 5.5 --- src/harness/agents.rs | 32 +++---- src/harness/catalog_models.rs | 29 +++++++ src/harness/opencode.rs | 12 +-- tests/test_wally_harness.rs | 152 ++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 28 deletions(-) diff --git a/src/harness/agents.rs b/src/harness/agents.rs index b76c974b..6f81350b 100644 --- a/src/harness/agents.rs +++ b/src/harness/agents.rs @@ -11,7 +11,7 @@ use crate::bootstrap::GlobalOptions; use crate::io::json::{dump, dump_pretty}; use crate::io::output as out; -use super::catalog_models::{catalog_models_for, CatalogModel}; +use super::catalog_models::{catalog_models_for, output_limit, CatalogModel}; use super::declared_harness::{harness_header_value, DeclaredHarness, HARNESS_HEADER}; use super::harness::{launch, release, resolve, Endpoint}; @@ -1097,13 +1097,11 @@ pub fn build_open_claw_config( if model.context_window > 0 { entry["contextWindow"] = json!(model.context_window); // OpenClaw wants a maxTokens beside the window; without one it - // budgets the session against a cap it invented. - let max_tokens = if model.max_output > 0 { - model.max_output - } else { - model.context_window.min(65536) - }; - entry["maxTokens"] = json!(max_tokens); + // budgets the session against a cap it invented. It is the whole + // budget for thinking and answer (`output_limit`). + if let Some(max_tokens) = output_limit(model, 65536) { + entry["maxTokens"] = json!(max_tokens); + } } if model.input_per_mtok > 0 || model.output_per_mtok > 0 { // Per-million-token rates, in whole currency units. The catalog @@ -1202,12 +1200,9 @@ pub fn build_deep_seek_llm_config( }); if model.context_window > 0 { entry["contextWindow"] = json!(model.context_window); - let max_tokens = if model.max_output > 0 { - model.max_output - } else { - model.context_window.min(32768) - }; - entry["maxTokens"] = json!(max_tokens); + if let Some(max_tokens) = output_limit(model, 32768) { + entry["maxTokens"] = json!(max_tokens); + } } if let Some(efforts) = deep_seek_reasoning_efforts(model) { entry["reasoningEfforts"] = efforts; @@ -1345,12 +1340,9 @@ pub fn build_prime_agent_models_config( }); if model.context_window > 0 { entry["contextWindow"] = json!(model.context_window); - let max_tokens = if model.max_output > 0 { - model.max_output - } else { - model.context_window.min(65536) - }; - entry["maxTokens"] = json!(max_tokens); + if let Some(max_tokens) = output_limit(model, 65536) { + entry["maxTokens"] = json!(max_tokens); + } } if model.input_per_mtok > 0 || model.output_per_mtok > 0 { const PER_MICRO: f64 = 1.0 / 1_000_000.0; diff --git a/src/harness/catalog_models.rs b/src/harness/catalog_models.rs index 2d610ab0..fe01ee8e 100644 --- a/src/harness/catalog_models.rs +++ b/src/harness/catalog_models.rs @@ -35,6 +35,35 @@ pub struct CatalogModel { pub reasoning: Option, } +/// The output limit a harness config declares for `model`, which the harness +/// then sends as its `max_tokens`. The model list's `max_output` when it has +/// one; `min(context_window, fallback)` when it gives a window but no output +/// cap; None when it gives no window either (a private id the catalogue lists +/// without limits), so the config writes no limit and the server applies the +/// model's documented default. +/// +/// `max_tokens` is a hard total: it caps everything the model generates, +/// thinking included, which is how OpenAI (`max_completion_tokens`), +/// Anthropic (the thinking budget fits inside `max_tokens`) and OpenRouter +/// define it. The API adds no reasoning allowance on top for any caller, so +/// this limit is the whole budget for a turn's thinking and its answer. It is +/// the client's job to make it large enough, never the server's: a reasoning +/// model given 8,192 can spend all of it thinking and answer nothing (the +/// 2026-09-22 OpenCode incident). The writers therefore declare the model's +/// full `max_output`, which is never below min(16,384, max_output) and never +/// above max_output, and the server clamps anything above the model's ceiling +/// anyway, reporting it in `X-RA-Max-Tokens-Granted`. +pub fn output_limit(model: &CatalogModel, fallback: i64) -> Option { + if model.context_window <= 0 { + return None; + } + Some(if model.max_output > 0 { + model.max_output + } else { + model.context_window.min(fallback) + }) +} + /// Moves the entry whose id is `primary` to the front, or inserts a bare one /// when the catalog did not carry it — the launched model is always selectable. /// What a model on `catalog` is sent for a requested `reasoning_effort`, diff --git a/src/harness/opencode.rs b/src/harness/opencode.rs index 626c3166..7ecdfdb4 100644 --- a/src/harness/opencode.rs +++ b/src/harness/opencode.rs @@ -6,7 +6,7 @@ use crate::account::{self, ConsoleClient}; use crate::io::json::dump; use crate::io::output as out; -use super::catalog_models::{catalog_models_with, CatalogModel}; +use super::catalog_models::{catalog_models_with, output_limit, CatalogModel}; use super::declared_harness::{harness_header_value, DeclaredHarness, HARNESS_HEADER}; use super::harness::{ model_id_is_safe, refresh_and_recheck_model, report_cloud_session_invalid, @@ -286,13 +286,9 @@ pub fn build_open_code_config( // fire at the model's actual window instead of a wrong default // (which makes it nag to compact and never stop). Output is a sane // cap, never the whole context — opencode's own docs warn against - // that. - if model.context_window > 0 { - let output = if model.max_output > 0 { - model.max_output - } else { - model.context_window.min(65536) - }; + // that. It becomes opencode's `max_tokens`, the whole budget for + // thinking and answer (`output_limit`). + if let Some(output) = output_limit(model, 65536) { entry["limit"] = json!({ "context": model.context_window, "output": output }); } // Image input exactly where the catalog lists it. opencode 1.18 reads diff --git a/tests/test_wally_harness.rs b/tests/test_wally_harness.rs index 489b8453..717f1dcd 100644 --- a/tests/test_wally_harness.rs +++ b/tests/test_wally_harness.rs @@ -1357,3 +1357,155 @@ fn deepseek_web_ui_takes_a_free_port_unless_one_was_chosen() { ); } } + +// The limit a harness config writes becomes that harness's `max_tokens`, and +// the API reads `max_tokens` as a hard total of everything the model +// generates, thinking included (OpenAI, Anthropic and OpenRouter define it the +// same way; the server adds no reasoning allowance on top). So the config, not +// the server, has to make the limit big enough: a reasoning model given 8,192 +// can spend all of it thinking and answer nothing (the 2026-09-22 OpenCode +// incident). Every writer declares the model list's `max_output`: never below +// min(16,384, max_output), never above max_output. A model the list gives a +// window but no output cap gets the writer's own fallback, min(window, 65,536) +// for OpenCode, OpenClaw and Prime Agent and min(window, 32,768) for dsh; one +// with no window at all (a private id the catalogue lists without limits) gets +// no limit, so the harness sends its own and the server applies the model's +// default. +#[test] +fn every_harness_config_writes_an_output_limit_within_the_models_own_bounds() { + const FLOOR: i64 = 16_384; + let model = |id: &str, context_window: i64, max_output: i64| CatalogModel { + id: id.to_string(), + context_window, + max_output, + ..Default::default() + }; + // Hosted models with a max_output: the bound applies. + let capped = [ + model("deepseek-v4.1-flash", 1_048_576, 65_536), + model("glm-5.3-flash", 202_752, 131_072), + model("short-output", 32_768, 8_192), + ]; + // A window but no output cap: the writer's fallback. No window: nothing. + let uncapped = [ + model("big-window", 1_048_576, 0), + model("small-window", 20_000, 0), + model("deepseek-v4.1-flash-dedicated", 0, 0), + ]; + let catalog: Vec = capped.iter().chain(uncapped.iter()).cloned().collect(); + let base = "https://api.example.test/v1"; + let primary = catalog[0].id.as_str(); + + let by_id = |entries: &Value, field: &str| -> Vec<(String, Option)> { + entries + .as_array() + .expect("models array") + .iter() + .map(|entry| { + ( + entry["id"].as_str().expect("model id").to_string(), + entry + .get(field) + .map(|v| v.as_i64().expect("an integer limit")), + ) + }) + .collect() + }; + let open_code: Value = serde_json::from_str(&harness::build_open_code_config( + primary, base, "tok", &catalog, + )) + .unwrap(); + let open_code: Vec<(String, Option)> = open_code["provider"]["runanywhere"]["models"] + .as_object() + .expect("models object") + .iter() + .map(|(id, entry)| { + ( + id.clone(), + entry + .get("limit") + .map(|limit| limit["output"].as_i64().expect("an integer limit")), + ) + }) + .collect(); + let open_claw: Value = serde_json::from_str(&harness::build_open_claw_config( + "", primary, base, "key", &catalog, + )) + .unwrap(); + let prime: Value = serde_json::from_str( + &harness::build_prime_agent_models_config("", base, "key", &catalog).unwrap(), + ) + .unwrap(); + let dsh: Value = serde_json::from_str(&harness::build_deep_seek_llm_config( + base, + "RUNANYWHERE_API_KEY", + &catalog, + )) + .unwrap(); + let writers = [ + ("opencode", 65_536, open_code), + ( + "openclaw", + 65_536, + by_id( + &open_claw["models"]["providers"]["runanywhere"]["models"], + "maxTokens", + ), + ), + ( + "prime-agent", + 65_536, + by_id(&prime["providers"]["runanywhere"]["models"], "maxTokens"), + ), + ( + "dsh", + 32_768, + by_id(&dsh["providers"]["runanywhere"]["models"], "maxTokens"), + ), + ]; + + for (writer, fallback, written) in &writers { + assert_eq!( + written.len(), + catalog.len(), + "{writer}: every catalog model is declared" + ); + let limit_of = |id: &str| -> Option { + written + .iter() + .find(|(written_id, _)| written_id == id) + .unwrap_or_else(|| panic!("{writer} did not declare {id}")) + .1 + }; + for model in &capped { + let limit = limit_of(&model.id) + .unwrap_or_else(|| panic!("{writer} wrote no limit for {}", model.id)); + assert!( + FLOOR.min(model.max_output) <= limit && limit <= model.max_output, + "{writer}: {} limit {limit} is outside [min({FLOOR}, {max}), {max}]", + model.id, + max = model.max_output, + ); + } + assert_eq!(limit_of("big-window"), Some(*fallback), "{writer} fallback"); + assert_eq!( + limit_of("small-window"), + Some(20_000), + "{writer}: the fallback never exceeds the window" + ); + assert_eq!( + limit_of("deepseek-v4.1-flash-dedicated"), + None, + "{writer}: no window, no limit" + ); + // The one rule all four writers share. + for model in &catalog { + assert_eq!( + limit_of(&model.id), + harness::output_limit(model, *fallback), + "{writer}: {} must follow output_limit", + model.id + ); + } + } +} From cc2596cd3a22501fec8ad6e4d871ee6fc5a27f8f Mon Sep 17 00:00:00 2001 From: Shubham Malhotra Date: Thu, 29 Oct 2026 11:19:20 -0700 Subject: [PATCH 2/4] docs(harness): X-RA-Harness is attribution only; max_tokens is the whole budget No behaviour change. Brings the comments in line with the API's output-limit rule (max_tokens is a hard total, thinking included, for every caller; the control plane's reasoning allowance is being removed) and pins the Anthropic bridge's part of it with a test. - declared_harness.rs: the header is bookkeeping (usage by tool), never a reason a request is served differently. The old text said it earned the reasoning allowance under RA_REASONING_ALLOWANCE_HARNESS_ONLY; wally does not rely on that. Room to think comes from the limit wally writes into the harness config (catalog_models::output_limit). - anthropic/translate.rs (Claude Code and Claude Desktop bridge): Anthropic's max_tokens already includes thinking, so it crosses 1:1 as the whole budget. thinking.budget_tokens is dropped (no gateway field), and only thinking.type=disabled crosses, as reasoning_effort none. Documented that a default-thinking model can spend all of max_tokens thinking (deepseek seen at 24,426 of 32,000); mapping budget_tokens to an effort level is later. - agents.rs Hermes handoff: no header and no limit written; it follows the plain-API rule, its own max_tokens as the total, the model default when none. Writing max_tokens into config.yaml only once Hermes reads it there. New test: anthropic::translate::tests:: max_tokens_crosses_as_the_whole_budget_and_budget_tokens_is_not_sent It pins behaviour that already holds, so it was green when written; the break test is what shows it bites (cargo test --lib max_tokens_crosses): BREAK A, the bridge halves max_tokens: panicked at src/anthropic/translate.rs:1770:9: assertion `left == right` failed left: Number(16000) test result: FAILED. 0 passed; 1 failed BREAK B, the bridge adds budget_tokens on top as max_completion_tokens: panicked at src/anthropic/translate.rs:1777:9: {"max_completion_tokens":56000,"max_tokens":32000,...} test result: FAILED. 0 passed; 1 failed RESTORED: test result: ok. 1 passed; 0 failed Co-Authored-By: Claude Opus 5.5 --- src/anthropic/translate.rs | 34 +++++++++++++++++++++++++++++++++ src/harness/agents.rs | 7 +++++++ src/harness/declared_harness.rs | 16 +++++++++++----- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/anthropic/translate.rs b/src/anthropic/translate.rs index a150026c..3ee9f417 100644 --- a/src/anthropic/translate.rs +++ b/src/anthropic/translate.rs @@ -761,6 +761,14 @@ pub fn request_to_openai(anthropic: &Value, model: &str) -> Value { } openai.insert("messages".into(), Value::Array(messages)); + // One limit, copied as is. Anthropic's `max_tokens` counts thinking (its + // thinking budget must fit inside it) and so does the gateway's: a hard + // total of everything the model generates, with no server allowance on + // top. `thinking.budget_tokens` has no gateway field and is dropped; only + // `thinking.type: disabled` crosses, as `reasoning_effort: none`. So a + // model that thinks by default may spend all of `max_tokens` thinking + // (deepseek-v4.1-flash was seen at 24,426 of 32,000). Mapping + // `budget_tokens` onto a `reasoning_effort` level is possible later. if let Some(max_tokens) = anthropic.get("max_tokens") { openai.insert("max_tokens".into(), max_tokens.clone()); } @@ -1746,6 +1754,32 @@ mod tests { .is_none()); } + /// Anthropic's `max_tokens` already includes thinking (the thinking budget + /// must fit inside it), which is what the gateway's `max_tokens` means + /// too, so it crosses 1:1 and is the whole budget. `budget_tokens` has no + /// gateway field and is not sent, and nothing is added to make room for + /// thinking: no `max_completion_tokens`, no second limit. + #[test] + fn max_tokens_crosses_as_the_whole_budget_and_budget_tokens_is_not_sent() { + let request = json!({ + "max_tokens": 32_000, + "thinking": {"type": "enabled", "budget_tokens": 24_000}, + "messages": [{"role": "user", "content": "hi"}], + }); + let body = request_to_openai(&request, "deepseek-v4.1-flash"); + assert_eq!(body["max_tokens"], 32_000); + assert!(body.get("max_completion_tokens").is_none(), "{body}"); + assert!(!body.to_string().contains("budget_tokens"), "{body}"); + assert!(body.get("reasoning_effort").is_none(), "{body}"); + + // No limit asked, none sent: the gateway applies the model default. + let unbounded = request_to_openai( + &json!({"messages": [{"role": "user", "content": "hi"}]}), + "deepseek-v4.1-flash", + ); + assert!(unbounded.get("max_tokens").is_none(), "{unbounded}"); + } + #[test] fn upstream_failure_no_status_is_api_error() { match upstream_failure(0, "") { diff --git a/src/harness/agents.rs b/src/harness/agents.rs index 6f81350b..c542d6f6 100644 --- a/src/harness/agents.rs +++ b/src/harness/agents.rs @@ -21,6 +21,13 @@ pub enum Handoff { /// extra request headers only from `config.yaml`, never the environment. /// Wally does not write those headers. It does set `model.supports_vision` /// in that file, because Hermes reads vision from there and from nowhere else. + /// + /// Nor does wally write an output limit for Hermes. Its requests follow the + /// plain API rule, which is the same rule every harness gets: whatever + /// `max_tokens` Hermes sends is the hard total, thinking included, and a + /// request naming none gets the model's default. Writing a `max_tokens` + /// into `config.yaml` is an option only once Hermes is shown to read it + /// from there. CustomEndpointEnvironment, ConfigFile, PatchOverlay, diff --git a/src/harness/declared_harness.rs b/src/harness/declared_harness.rs index 1c3adf87..14b5917a 100644 --- a/src/harness/declared_harness.rs +++ b/src/harness/declared_harness.rs @@ -3,11 +3,17 @@ //! //! InferenceInfra attributes every inference request to a harness //! (api/app/services/harness.py `detect()`): a declared `X-RA-Harness` header -//! first, then a substring match over the User-Agent. The attribution shapes a -//! request -- with `RA_REASONING_ALLOWANCE_HARNESS_ONLY` on, only a recognised -//! harness gets the reasoning allowance on top of its `max_tokens` -- so a -//! harness that reaches the endpoint looking like a plain API client gets its -//! reasoning trace cut short. +//! first, then a substring match over the User-Agent. +//! +//! The attribution is bookkeeping only (usage by tool, the ledger's harness +//! column). It never changes what a request is served: `max_tokens` is a hard +//! total of everything the model generates, thinking included, for every +//! caller, harness or plain API alike. InferenceInfra once added a reasoning +//! allowance on top of a named `max_tokens` (with +//! `RA_REASONING_ALLOWANCE_HARNESS_ONLY` limiting it to recognised harnesses); +//! that allowance is being removed, and wally does not rely on it. A harness +//! gets room to think from the limit wally writes into its config +//! (`catalog_models::output_limit`), never from the header. //! //! wally launches these tools and knows which one it started, so it says so. //! The names are the contract's own `Harness` enum, never a retyped list, so a From 13339523e9c9619e58019350f32fc77603a6f14a Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 1 Nov 2026 15:43:30 +0530 Subject: [PATCH 3/4] harness: keep a known output cap without a window; describe the max_tokens change as pending --- src/anthropic/translate.rs | 6 +++--- src/harness/agents.rs | 25 ++++++++++++------------ src/harness/catalog_models.rs | 34 +++++++++++++++------------------ src/harness/declared_harness.rs | 12 ++++++------ src/harness/opencode.rs | 4 +++- src/harness/shannon.rs | 3 +++ tests/test_wally_harness.rs | 24 ++++++++++++++++------- 7 files changed, 60 insertions(+), 48 deletions(-) diff --git a/src/anthropic/translate.rs b/src/anthropic/translate.rs index 3ee9f417..ef5f2d4b 100644 --- a/src/anthropic/translate.rs +++ b/src/anthropic/translate.rs @@ -762,9 +762,9 @@ pub fn request_to_openai(anthropic: &Value, model: &str) -> Value { openai.insert("messages".into(), Value::Array(messages)); // One limit, copied as is. Anthropic's `max_tokens` counts thinking (its - // thinking budget must fit inside it) and so does the gateway's: a hard - // total of everything the model generates, with no server allowance on - // top. `thinking.budget_tokens` has no gateway field and is dropped; only + // thinking budget must fit inside it), and once InferenceInfra#5737 ships + // the gateway's does too: a hard total of everything the model generates, + // with no server allowance on top. `thinking.budget_tokens` has no gateway field and is dropped; only // `thinking.type: disabled` crosses, as `reasoning_effort: none`. So a // model that thinks by default may spend all of `max_tokens` thinking // (deepseek-v4.1-flash was seen at 24,426 of 32,000). Mapping diff --git a/src/harness/agents.rs b/src/harness/agents.rs index c542d6f6..acf1b436 100644 --- a/src/harness/agents.rs +++ b/src/harness/agents.rs @@ -1103,12 +1103,13 @@ pub fn build_open_claw_config( }); if model.context_window > 0 { entry["contextWindow"] = json!(model.context_window); - // OpenClaw wants a maxTokens beside the window; without one it - // budgets the session against a cap it invented. It is the whole - // budget for thinking and answer (`output_limit`). - if let Some(max_tokens) = output_limit(model, 65536) { - entry["maxTokens"] = json!(max_tokens); - } + } + // OpenClaw wants a maxTokens beside the window; without one it + // budgets the session against a cap it invented. It is the whole + // budget for thinking and answer (`output_limit`), and a known cap is + // written even when the list reports no window. + if let Some(max_tokens) = output_limit(model, 65536) { + entry["maxTokens"] = json!(max_tokens); } if model.input_per_mtok > 0 || model.output_per_mtok > 0 { // Per-million-token rates, in whole currency units. The catalog @@ -1207,9 +1208,9 @@ pub fn build_deep_seek_llm_config( }); if model.context_window > 0 { entry["contextWindow"] = json!(model.context_window); - if let Some(max_tokens) = output_limit(model, 32768) { - entry["maxTokens"] = json!(max_tokens); - } + } + if let Some(max_tokens) = output_limit(model, 32768) { + entry["maxTokens"] = json!(max_tokens); } if let Some(efforts) = deep_seek_reasoning_efforts(model) { entry["reasoningEfforts"] = efforts; @@ -1347,9 +1348,9 @@ pub fn build_prime_agent_models_config( }); if model.context_window > 0 { entry["contextWindow"] = json!(model.context_window); - if let Some(max_tokens) = output_limit(model, 65536) { - entry["maxTokens"] = json!(max_tokens); - } + } + if let Some(max_tokens) = output_limit(model, 65536) { + entry["maxTokens"] = json!(max_tokens); } if model.input_per_mtok > 0 || model.output_per_mtok > 0 { const PER_MICRO: f64 = 1.0 / 1_000_000.0; diff --git a/src/harness/catalog_models.rs b/src/harness/catalog_models.rs index fe01ee8e..0c5a094d 100644 --- a/src/harness/catalog_models.rs +++ b/src/harness/catalog_models.rs @@ -37,31 +37,27 @@ pub struct CatalogModel { /// The output limit a harness config declares for `model`, which the harness /// then sends as its `max_tokens`. The model list's `max_output` when it has -/// one; `min(context_window, fallback)` when it gives a window but no output -/// cap; None when it gives no window either (a private id the catalogue lists +/// one, with or without a context window (the list reports the two +/// separately); `min(context_window, fallback)` when it gives a window but no +/// output cap; None when it gives neither (a private id the catalogue lists /// without limits), so the config writes no limit and the server applies the /// model's documented default. /// -/// `max_tokens` is a hard total: it caps everything the model generates, -/// thinking included, which is how OpenAI (`max_completion_tokens`), +/// `max_tokens` is meant as a hard total: it caps everything the model +/// generates, thinking included, which is how OpenAI (`max_completion_tokens`), /// Anthropic (the thinking budget fits inside `max_tokens`) and OpenRouter -/// define it. The API adds no reasoning allowance on top for any caller, so -/// this limit is the whole budget for a turn's thinking and its answer. It is -/// the client's job to make it large enough, never the server's: a reasoning -/// model given 8,192 can spend all of it thinking and answer nothing (the -/// 2026-09-22 OpenCode incident). The writers therefore declare the model's -/// full `max_output`, which is never below min(16,384, max_output) and never -/// above max_output, and the server clamps anything above the model's ceiling -/// anyway, reporting it in `X-RA-Max-Tokens-Granted`. +/// define it. Once InferenceInfra#5737 ships, the API adds no reasoning +/// allowance on top for any caller, so this limit is the whole budget for a +/// turn's thinking and its answer. Making it large enough is the client's job: +/// a reasoning model given 8,192 can spend all of it thinking and answer +/// nothing (the 2026-09-22 OpenCode incident). The writers therefore declare +/// the model's full `max_output`, which is never below min(16,384, max_output) +/// and never above max_output. pub fn output_limit(model: &CatalogModel, fallback: i64) -> Option { - if model.context_window <= 0 { - return None; + if model.max_output > 0 { + return Some(model.max_output); } - Some(if model.max_output > 0 { - model.max_output - } else { - model.context_window.min(fallback) - }) + (model.context_window > 0).then(|| model.context_window.min(fallback)) } /// Moves the entry whose id is `primary` to the front, or inserts a bare one diff --git a/src/harness/declared_harness.rs b/src/harness/declared_harness.rs index 14b5917a..98b0afa3 100644 --- a/src/harness/declared_harness.rs +++ b/src/harness/declared_harness.rs @@ -6,12 +6,12 @@ //! first, then a substring match over the User-Agent. //! //! The attribution is bookkeeping only (usage by tool, the ledger's harness -//! column). It never changes what a request is served: `max_tokens` is a hard -//! total of everything the model generates, thinking included, for every -//! caller, harness or plain API alike. InferenceInfra once added a reasoning -//! allowance on top of a named `max_tokens` (with -//! `RA_REASONING_ALLOWANCE_HARNESS_ONLY` limiting it to recognised harnesses); -//! that allowance is being removed, and wally does not rely on it. A harness +//! column). InferenceInfra currently adds a reasoning allowance on top of a +//! named `max_tokens` (with `RA_REASONING_ALLOWANCE_HARNESS_ONLY` limiting it +//! to recognised harnesses). Once InferenceInfra#5737 ships, that allowance is +//! gone and `max_tokens` is a hard total of everything the model generates, +//! thinking included, for every caller alike. wally does not rely on the +//! allowance either way. A harness //! gets room to think from the limit wally writes into its config //! (`catalog_models::output_limit`), never from the header. //! diff --git a/src/harness/opencode.rs b/src/harness/opencode.rs index 7ecdfdb4..bebc2d44 100644 --- a/src/harness/opencode.rs +++ b/src/harness/opencode.rs @@ -288,7 +288,9 @@ pub fn build_open_code_config( // cap, never the whole context — opencode's own docs warn against // that. It becomes opencode's `max_tokens`, the whole budget for // thinking and answer (`output_limit`). - if let Some(output) = output_limit(model, 65536) { + // opencode's `limit` needs the context size beside the output, so a + // model the list reports without a window gets no limit here. + if let Some(output) = output_limit(model, 65536).filter(|_| model.context_window > 0) { entry["limit"] = json!({ "context": model.context_window, "output": output }); } // Image input exactly where the catalog lists it. opencode 1.18 reads diff --git a/src/harness/shannon.rs b/src/harness/shannon.rs index c877514f..7dd0a96a 100644 --- a/src/harness/shannon.rs +++ b/src/harness/shannon.rs @@ -230,6 +230,9 @@ pub fn models_config(base_url: &str, models: &[CatalogModel]) -> Value { if model.context_window > 0 { entry["contextWindow"] = json!(model.context_window); } + // Only a known cap: Shannon applies its own default when none is + // written, so a window-only model is not given a guessed limit. + // For a model with a cap this matches `output_limit`. if model.max_output > 0 { entry["maxTokens"] = json!(model.max_output); } diff --git a/tests/test_wally_harness.rs b/tests/test_wally_harness.rs index 717f1dcd..1b6734fa 100644 --- a/tests/test_wally_harness.rs +++ b/tests/test_wally_harness.rs @@ -1359,17 +1359,17 @@ fn deepseek_web_ui_takes_a_free_port_unless_one_was_chosen() { } // The limit a harness config writes becomes that harness's `max_tokens`, and -// the API reads `max_tokens` as a hard total of everything the model -// generates, thinking included (OpenAI, Anthropic and OpenRouter define it the -// same way; the server adds no reasoning allowance on top). So the config, not +// once InferenceInfra#5737 ships the API reads `max_tokens` as a hard total of +// everything the model generates, thinking included (OpenAI, Anthropic and +// OpenRouter define it the same way), with no reasoning allowance on top. So the config, not // the server, has to make the limit big enough: a reasoning model given 8,192 // can spend all of it thinking and answer nothing (the 2026-09-22 OpenCode // incident). Every writer declares the model list's `max_output`: never below // min(16,384, max_output), never above max_output. A model the list gives a // window but no output cap gets the writer's own fallback, min(window, 65,536) // for OpenCode, OpenClaw and Prime Agent and min(window, 32,768) for dsh; one -// with no window at all (a private id the catalogue lists without limits) gets -// no limit, so the harness sends its own and the server applies the model's +// with neither a window nor an output cap (a private id the catalogue lists +// without limits) gets no limit, so the harness sends its own and the server applies the model's // default. #[test] fn every_harness_config_writes_an_output_limit_within_the_models_own_bounds() { @@ -1385,6 +1385,8 @@ fn every_harness_config_writes_an_output_limit_within_the_models_own_bounds() { model("deepseek-v4.1-flash", 1_048_576, 65_536), model("glm-5.3-flash", 202_752, 131_072), model("short-output", 32_768, 8_192), + // An output cap with no window reported: the cap still applies. + model("output-only", 0, 32_768), ]; // A window but no output cap: the writer's fallback. No window: nothing. let uncapped = [ @@ -1477,7 +1479,10 @@ fn every_harness_config_writes_an_output_limit_within_the_models_own_bounds() { .unwrap_or_else(|| panic!("{writer} did not declare {id}")) .1 }; - for model in &capped { + // opencode's `limit` needs a context size, so it writes none for a + // model reported without a window; the other writers keep the cap. + let windowless = |model: &CatalogModel| *writer == "opencode" && model.context_window <= 0; + for model in capped.iter().filter(|model| !windowless(model)) { let limit = limit_of(&model.id) .unwrap_or_else(|| panic!("{writer} wrote no limit for {}", model.id)); assert!( @@ -1500,9 +1505,14 @@ fn every_harness_config_writes_an_output_limit_within_the_models_own_bounds() { ); // The one rule all four writers share. for model in &catalog { + let expected = if windowless(model) { + None + } else { + harness::output_limit(model, *fallback) + }; assert_eq!( limit_of(&model.id), - harness::output_limit(model, *fallback), + expected, "{writer}: {} must follow output_limit", model.id ); From 8e564b4b88f41413522678363d8f4c47b17c5c7f Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 1 Nov 2026 16:08:09 +0530 Subject: [PATCH 4/4] harness: name OpenCode's no-window exception in output_limit's doc and the test --- src/harness/catalog_models.rs | 4 +++- tests/test_wally_harness.rs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/harness/catalog_models.rs b/src/harness/catalog_models.rs index 0c5a094d..a5432039 100644 --- a/src/harness/catalog_models.rs +++ b/src/harness/catalog_models.rs @@ -41,7 +41,9 @@ pub struct CatalogModel { /// separately); `min(context_window, fallback)` when it gives a window but no /// output cap; None when it gives neither (a private id the catalogue lists /// without limits), so the config writes no limit and the server applies the -/// model's documented default. +/// model's documented default. OpenCode is the one caller that also needs a +/// window: its `limit` pairs the output with a context size, so it writes no +/// limit for a model reported without one. /// /// `max_tokens` is meant as a hard total: it caps everything the model /// generates, thinking included, which is how OpenAI (`max_completion_tokens`), diff --git a/tests/test_wally_harness.rs b/tests/test_wally_harness.rs index 1b6734fa..2f2ade7c 100644 --- a/tests/test_wally_harness.rs +++ b/tests/test_wally_harness.rs @@ -1385,7 +1385,8 @@ fn every_harness_config_writes_an_output_limit_within_the_models_own_bounds() { model("deepseek-v4.1-flash", 1_048_576, 65_536), model("glm-5.3-flash", 202_752, 131_072), model("short-output", 32_768, 8_192), - // An output cap with no window reported: the cap still applies. + // An output cap with no window reported: OpenClaw, Prime Agent and dsh + // apply it; OpenCode writes no limit without a window. model("output-only", 0, 32_768), ]; // A window but no output cap: the writer's fallback. No window: nothing.