diff --git a/docs/EDITORS.md b/docs/EDITORS.md index 65b29586..eea8d31e 100644 --- a/docs/EDITORS.md +++ b/docs/EDITORS.md @@ -49,9 +49,33 @@ they talk to the endpoint directly. The effort you pick in the tool reaches the model as `reasoning_effort`: Claude Code's and Claude Desktop's effort setting, Claude Code's thinking switch -(off is `none`), and Codex's reasoning level, whose picker lists `none` to -`max`. GLM grades its reasoning by it, DeepSeek reasons less with any level -set, and MiMo only honours `none`. +(off is `none`), Codex's reasoning level, whose picker lists `none` to +`max`, OpenCode's variants (`ctrl+t`, or `-m runanywhere/#`), and +DeepSeek Harness's Effort control. Which levels a model offers, and which one +it runs at when you pick none, come from the console's `/v1/models` `reasoning` +block, never from a list in wally: GLM 5.3 Flash low / high / max, DeepSeek +V4.1 Flash low / high / xhigh / max, Muse Spark minimal to xhigh, and MiMo only +on or off. OpenCode gets every level as a variant, a `none` variant where the +model can stop thinking, and the model's default as its starting level. +DeepSeek Harness shows the levels with "Provider default" first: its pi-ai +adapter takes one default for every model on a route and refuses a request +whose model lacks it, so wally leaves the default to the endpoint, which runs +the model's own. It offers no Off, because pi-ai sends the same value for Off +and for no choice at all. OpenClaw's `/think` (and `--thinking`) offers the +model's levels, `off` sends `none` where the model can stop thinking, and the +model's default is set for it unless your own config sets one +(`agents.defaults.models["runanywhere/"].params.thinking`). Claude Code +starts each model at its default level (`--effort`, unless you pass your own), +and `/effort` changes it; Codex and the ChatGPT app list each model's levels +and start at its default. + +Three tools keep their own default. Hermes takes any level with `--reasoning` +(`wally hermes -m -- --reasoning high`) or `/reasoning`, and otherwise +runs at `agent.reasoning_effort` in `~/.hermes/config.yaml` (per model under +`agent.reasoning_overrides`), which wally does not write. Claude Desktop shows +each model's levels up to its highest and starts at the app's own default. +Prime Agent is given no reasoning levels yet: its models run at the +endpoint's default, and a level has to be named in Prime Agent itself. Codex is told the model's context window through the catalog file, so its context meter and auto-compaction work against the real limit. Its hosted web @@ -189,3 +213,83 @@ different hosts; see [AGENTS.md](../AGENTS.md). browser, is not registered in this release; the browser flow above is the only way in. +### Reasoning effort without wally + +A tool pointed at `https://inference.runanywhere.ai/v1` with an API key, no +wally in between, sets a model's reasoning with the request field +`reasoning_effort`. `GET /v1/models` says what each model takes, in its +`reasoning` block: + +```json +"reasoning": { + "levels": ["low", "high", "xhigh", "max"], + "default_level": "high", + "thinking_off": true +} +``` + +`levels` are the values `reasoning_effort` accepts, `default_level` is what a +request that sends none runs at, and `thinking_off: true` means `none` turns +thinking off (a model with `false` always thinks, and `none` is refused). + +OpenCode needs each level declared as a variant, or it offers no choice. In +`opencode.json`: + +```json +{ + "provider": { + "runanywhere": { + "npm": "@ai-sdk/openai-compatible", + "name": "RunAnywhere", + "options": { + "baseURL": "https://inference.runanywhere.ai/v1", + "apiKey": "{env:RUNANYWHERE_API_KEY}" + }, + "models": { + "deepseek-v4.1-flash": { + "reasoning": true, + "options": { "reasoningEffort": "high" }, + "variants": { + "low": { "reasoningEffort": "low" }, + "high": { "reasoningEffort": "high" }, + "xhigh": { "reasoningEffort": "xhigh" }, + "max": { "reasoningEffort": "max" }, + "none": { "reasoningEffort": "none" } + } + } + } + } + } +} +``` + +`options.reasoningEffort` is the starting level and a variant overrides it: +cycle with `ctrl+t`, or name one with `opencode run -m +runanywhere/deepseek-v4.1-flash#max`. Repeat the block for each model with that +model's `levels`, and leave `none` out where `thinking_off` is false. (A +`#variant` in the config's own `model` key is ignored, which is why the +default goes in `options`.) + +DeepSeek Harness takes the levels as `reasoningEfforts` on each model of its +`llm-pi-ai` provider; each key is a level it shows, and its value is what it +sends: + +```yaml +- name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + runanywhere: + api: openai-completions + baseURL: https://inference.runanywhere.ai/v1 + apiKeyEnv: RUNANYWHERE_API_KEY + compat: + supportsDeveloperRole: false + models: + - id: deepseek-v4.1-flash + reasoningEfforts: { low: low, high: high, xhigh: xhigh, max: max } +``` + +Any other OpenAI-compatible client sends the field itself, for example +`"reasoning_effort": "max"` beside `model` and `messages`, or +`extra_body={"reasoning_effort": "max"}` in the OpenAI Python SDK. + diff --git a/src/commands/cmd_editors.rs b/src/commands/cmd_editors.rs index be89fcb6..60dd531c 100644 --- a/src/commands/cmd_editors.rs +++ b/src/commands/cmd_editors.rs @@ -1032,6 +1032,21 @@ fn run( launch_args.push("--model".to_string()); launch_args.push(model.to_string()); } + // The model's own default effort, as --model above: theirs wins, and + // /effort still changes it in the session. + if !args + .iter() + .any(|a| a == "--effort" || a.starts_with("--effort=")) + { + if let Some(level) = catalog + .iter() + .find(|entry| entry.id == model) + .and_then(claude_effort::default_effort) + { + launch_args.push("--effort".to_string()); + launch_args.push(level.to_string()); + } + } launch_args.extend(settings_args(args, settings_file.0.as_deref())); launch_args.extend(args.iter().cloned()); status = harness::launch(editor.command, "", &launch_args, options); diff --git a/src/harness/agents.rs b/src/harness/agents.rs index ddde4315..612c47a3 100644 --- a/src/harness/agents.rs +++ b/src/harness/agents.rs @@ -1104,8 +1104,21 @@ pub fn build_open_claw_config( "cacheWrite": 0, }); } + if let Some(efforts) = open_claw_reasoning_efforts(model) { + entry["reasoning"] = json!(true); + entry["compat"]["supportsReasoningEffort"] = json!(true); + entry["compat"]["supportedReasoningEfforts"] = efforts; + } entries.push(entry); } + for model in models { + if let Some(default_level) = open_claw_reasoning_efforts(model) + .and(model.reasoning.as_ref()) + .and_then(|reasoning| reasoning.default_level) + { + set_open_claw_default_thinking(&mut config, &model.id, default_level); + } + } config["models"]["providers"][PROVIDER_ID] = json!({ "baseUrl": base_url, "apiKey": key_or_placeholder(api_key), @@ -1183,6 +1196,9 @@ pub fn build_deep_seek_llm_config( }; entry["maxTokens"] = json!(max_tokens); } + if let Some(efforts) = deep_seek_reasoning_efforts(model) { + entry["reasoningEfforts"] = efforts; + } entries.push(entry); } let provider = json!({ @@ -1190,6 +1206,12 @@ pub fn build_deep_seek_llm_config( "api": "openai-completions", "baseURL": base_url, "models": entries, + // Once a model reasons, pi-ai sends the system prompt as `developer` + // to any endpoint it does not recognise (pi-ai 0.85 getCompat); keep + // it `system`, as for a model that does not. `reasoning_effort` is + // how the gateway takes a level, stated rather than left to pi-ai's + // baseURL detection. + "compat": { "supportsDeveloperRole": false, "supportsReasoningEffort": true }, // Always referenced, local server included. This used to be omitted // for a loopback endpoint on the theory that no reference meant a // keyless route; dsh 0.1.5 instead refuses the turn with "No API key @@ -1204,6 +1226,86 @@ pub fn build_deep_seek_llm_config( dump(&json!({ "providers": { PROVIDER_ID: provider } })) } +/// The `/think` levels OpenClaw offers for `model`, as the efforts it may send: +/// the model list's levels, and `none` first where that turns thinking off, +/// so `/think off` sends `none`. Without them OpenClaw takes a model on a +/// custom OpenAI-compatible provider not to reason (`--thinking high` is +/// refused, "Use one of: off, ultra") and sends no `reasoning_effort` at all. +/// Measured against OpenClaw 2026.10.4 with a capture server: low, xhigh and +/// max are sent as themselves, `off` as `none`, and a level OpenClaw has that +/// the model does not (medium on DeepSeek) as its nearest one. None for a +/// model with no levels: with `reasoning` on and nothing to choose, OpenClaw +/// would default it to thinking off, where today the endpoint thinks. +fn open_claw_reasoning_efforts(model: &CatalogModel) -> Option { + let reasoning = model.reasoning.as_ref()?; + if reasoning.levels.is_empty() { + return None; + } + let mut efforts: Vec<&str> = Vec::new(); + if reasoning.thinking_off { + efforts.push("none"); + } + efforts.extend(reasoning.levels.iter().copied()); + Some(json!(efforts)) +} + +/// `level` as OpenClaw's default `/think` for our `model` +/// (`agents.defaults.models["runanywhere/"].params.thinking`), unless the +/// person's own config already sets one there. Without it OpenClaw picks its +/// own default rather than the model list's. That table does not restrict +/// the model picker (OpenClaw docs, tools/thinking.md). A config whose path +/// there is not an object is left alone. +fn set_open_claw_default_thinking(config: &mut Value, model: &str, level: &str) { + let key = format!("{PROVIDER_ID}/{model}"); + let mut node = &mut *config; + for segment in ["agents", "defaults", "models", key.as_str(), "params"] { + if node.get(segment).is_none() { + node[segment] = json!({}); + } + node = &mut node[segment]; + if !node.is_object() { + return; + } + } + if node.get("thinking").is_none() { + node["thinking"] = json!(level); + } +} + +/// pi-ai's reasoning levels (`ModelThinkingLevel` without `off`), the names +/// dsh's llm-pi-ai adapter takes as `reasoningEfforts` keys. +const PI_AI_LEVELS: &[&str] = &["minimal", "low", "medium", "high", "xhigh", "max"]; + +/// The Effort control dsh offers for `model`: its levels from the model list, +/// each sent as itself in `reasoning_effort`. dsh's llm-pi-ai adapter shows an +/// Effort row only for a model whose entry declares `reasoningEfforts`; a +/// hand-declared model without it reasons at the server's default with no +/// control (DeepSeek's own adapter offers off / low / high / max). None for a +/// model the list does not describe and for one that only switches thinking +/// on or off: it gets no control, as before. +/// +/// Two things are left out on purpose. A default: the adapter takes one only +/// from the route's `reasoning`, the same for every model, and refuses a +/// request on a model that lacks it, so with none dsh shows "Provider +/// default", sends no level, and the gateway applies the model's own +/// `default_level`. And `off`: pi-ai sends its wire value both for `off` and +/// when no level is chosen, so declaring `off` as `none` would turn thinking +/// off whenever Provider default is selected. +fn deep_seek_reasoning_efforts(model: &CatalogModel) -> Option { + let reasoning = model.reasoning.as_ref()?; + let mut efforts = serde_json::Map::new(); + for level in &reasoning.levels { + if PI_AI_LEVELS.contains(level) { + efforts.insert((*level).to_string(), json!(level)); + } + } + if efforts.is_empty() { + None + } else { + Some(Value::Object(efforts)) + } +} + /// `models.json` for Prime Agent 0.10. The Rust build dropped `-e`, and a /// custom provider is this document: `baseUrl`, `api`, `apiKey`, and the /// model list. Other providers already in `existing` stay. `apiKey` is the @@ -2171,6 +2273,139 @@ mod tests { assert_eq!(deepseek_models[1]["input"], json!(["text"])); } + fn reasoning_model( + id: &str, + levels: &[&'static str], + default_level: &'static str, + ) -> CatalogModel { + CatalogModel { + id: id.to_string(), + reasoning: Some(crate::account::ReasoningSupport { + levels: levels.to_vec(), + default_level: Some(default_level), + thinking_off: true, + }), + ..Default::default() + } + } + + #[test] + fn dsh_offers_each_models_own_levels_from_the_model_list() { + let models = [ + reasoning_model( + "deepseek-v4.1-flash", + &["low", "high", "xhigh", "max"], + "high", + ), + reasoning_model( + "muse-spark", + &["minimal", "low", "medium", "high", "xhigh"], + "medium", + ), + // Thinking on or off only, and a model the list does not describe. + CatalogModel { + id: "mimo-v2.6-pro".to_string(), + reasoning: Some(crate::account::ReasoningSupport::default()), + ..Default::default() + }, + CatalogModel { + id: "local-model".to_string(), + ..Default::default() + }, + ]; + let doc: Value = serde_json::from_str(&build_deep_seek_llm_config( + "https://example.test/v1", + KEY_VARIABLE, + &models, + )) + .unwrap(); + let provider = &doc["providers"]["runanywhere"]; + let entries = &provider["models"]; + assert_eq!( + entries[0]["reasoningEfforts"], + json!({"low": "low", "high": "high", "xhigh": "xhigh", "max": "max"}) + ); + assert_eq!( + entries[1]["reasoningEfforts"], + json!({"minimal": "minimal", "low": "low", "medium": "medium", "high": "high", "xhigh": "xhigh"}) + ); + assert!(entries[2].get("reasoningEfforts").is_none()); + assert!(entries[3].get("reasoningEfforts").is_none()); + // No `off`: pi-ai would send it whenever no level is chosen. + assert!(entries[0]["reasoningEfforts"].get("off").is_none()); + // No route default: it would refuse every model without that level. + assert!(provider.get("reasoning").is_none()); + assert_eq!( + provider["compat"], + json!({"supportsDeveloperRole": false, "supportsReasoningEffort": true}) + ); + } + + #[test] + fn openclaw_offers_each_models_levels_and_defaults_to_the_lists_level() { + let models = [ + reasoning_model( + "deepseek-v4.1-flash", + &["low", "high", "xhigh", "max"], + "high", + ), + CatalogModel { + reasoning: Some(crate::account::ReasoningSupport { + levels: vec!["minimal", "low", "medium", "high", "xhigh"], + default_level: Some("medium"), + thinking_off: false, + }), + id: "muse-spark".to_string(), + ..Default::default() + }, + CatalogModel { + id: "mimo-v2.6-pro".to_string(), + reasoning: Some(crate::account::ReasoningSupport { + thinking_off: true, + ..Default::default() + }), + ..Default::default() + }, + ]; + // Their own default for muse stays theirs. + let existing = r#"{"agents":{"defaults":{"models":{"runanywhere/muse-spark":{"params":{"thinking":"low"}}}}}}"#; + let doc: Value = serde_json::from_str(&build_open_claw_config( + existing, + "deepseek-v4.1-flash", + "https://example.test/v1", + "k", + &models, + )) + .unwrap(); + let entries = &doc["models"]["providers"]["runanywhere"]["models"]; + assert_eq!(entries[0]["reasoning"], json!(true)); + assert_eq!(entries[0]["compat"]["supportsReasoningEffort"], json!(true)); + assert_eq!( + entries[0]["compat"]["supportedReasoningEfforts"], + json!(["none", "low", "high", "xhigh", "max"]) + ); + // Thinking cannot be turned off on muse-spark: no `none`. + assert_eq!( + entries[1]["compat"]["supportedReasoningEfforts"], + json!(["minimal", "low", "medium", "high", "xhigh"]) + ); + // No levels: left as before, so it is not defaulted to thinking off. + assert!(entries[2].get("reasoning").is_none()); + assert!(entries[2]["compat"] + .get("supportedReasoningEfforts") + .is_none()); + let defaults = &doc["agents"]["defaults"]["models"]; + assert_eq!( + defaults["runanywhere/deepseek-v4.1-flash"]["params"]["thinking"], + "high" + ); + assert_eq!( + defaults["runanywhere/muse-spark"]["params"]["thinking"], + "low" + ); + assert!(defaults.get("runanywhere/mimo-v2.6-pro").is_none()); + } + #[test] fn prime_agent_argv_names_the_model_before_theirs() { let argv = prime_agent_argv("glm-5.3-flash", &["-c".to_string()]); diff --git a/src/harness/claude_effort.rs b/src/harness/claude_effort.rs index 11e70dee..8e892e82 100644 --- a/src/harness/claude_effort.rs +++ b/src/harness/claude_effort.rs @@ -51,6 +51,25 @@ pub fn max_effort(model: &CatalogModel) -> Option<&'static str> { }) } +/// The effort Claude Code starts `model` at (`--effort`): the model list's +/// default level, as the nearest level `model`'s menu has. Without it Claude +/// Code starts at the default of the Claude model it takes the entry to be, +/// not the model's own. None for a model with no menu or no default. +pub fn default_effort(model: &CatalogModel) -> Option<&'static str> { + let menu = effort_menu(model)?; + let level = model.reasoning.as_ref()?.default_level?; + Some(match (level, menu) { + (_, EffortMenu::None) => return None, + ("minimal" | "low", _) => "low", + ("medium", _) => "medium", + ("high", _) => "high", + ("xhigh", EffortMenu::Full) => "xhigh", + ("xhigh", EffortMenu::WithoutXhigh) => "high", + ("max", _) => "max", + _ => return None, + }) +} + /// The Claude model Claude Code takes `model` to be (`modelPicker`'s /// `behavesAs`), for its effort menu. None for a model that keeps its own id. pub fn behaves_as(model: &CatalogModel) -> Option<&'static str> { @@ -128,6 +147,33 @@ mod tests { ] } + fn with_default(mut model: CatalogModel, default_level: &'static str) -> CatalogModel { + if let Some(reasoning) = model.reasoning.as_mut() { + reasoning.default_level = Some(default_level); + } + model + } + + #[test] + fn claude_code_starts_each_model_at_the_lists_default() { + let models = today(); + let defaults: Vec<_> = [ + with_default(models[0].clone(), "max"), + with_default(models[1].clone(), "high"), + models[2].clone(), + with_default(models[3].clone(), "medium"), + ] + .iter() + .map(default_effort) + .collect(); + assert_eq!(defaults, [Some("max"), Some("high"), None, Some("medium")]); + // A level the model's Claude menu lacks takes the nearest it has. + let glm_xhigh = with_default(models[0].clone(), "xhigh"); + assert_eq!(default_effort(&glm_xhigh), Some("high")); + // No model list entry, no default. + assert_eq!(default_effort(&CatalogModel::default()), None); + } + #[test] fn each_model_takes_the_menu_its_levels_call_for() { let menus: Vec<_> = today().iter().map(effort_menu).collect(); diff --git a/src/harness/opencode.rs b/src/harness/opencode.rs index b1bae708..626c3166 100644 --- a/src/harness/opencode.rs +++ b/src/harness/opencode.rs @@ -304,6 +304,7 @@ pub fn build_open_code_config( entry["attachment"] = json!(true); entry["modalities"] = json!({ "input": ["text", "image"], "output": ["text"] }); } + open_code_reasoning(&mut entry, model); // The real price, so opencode shows spend instead of $0.00. // opencode's cost is USD per million tokens; the catalog is // micro-dollars per million, so a million micros is one dollar. @@ -346,6 +347,38 @@ pub fn build_open_code_config( dump(&config) } +/// OpenCode's reasoning controls for `model`, from the model list: one +/// variant per level, each sending itself as `reasoning_effort` (the +/// `@ai-sdk/openai-compatible` provider option `reasoningEffort`), plus +/// `none` where the model can turn thinking off, and the model's default +/// level as its model-level option, which a chosen variant overrides. +/// Measured against OpenCode 2.0.28 with a capture server: `-m +/// runanywhere/#high` sends `reasoning_effort: "high"`, no variant sends +/// the default, and `#none` sends `"none"`. Without variants OpenCode offers +/// no effort choice for a custom provider's model at all, and the config's +/// `model` key ignores a `#variant`, so the default lives in `options`. +/// Nothing for a model the list does not describe. +fn open_code_reasoning(entry: &mut serde_json::Value, model: &CatalogModel) { + let Some(reasoning) = model.reasoning.as_ref() else { + return; + }; + let mut variants = serde_json::Map::new(); + for level in &reasoning.levels { + variants.insert((*level).to_string(), json!({ "reasoningEffort": level })); + } + if reasoning.thinking_off { + variants.insert("none".to_string(), json!({ "reasoningEffort": "none" })); + } + if variants.is_empty() { + return; + } + entry["reasoning"] = json!(true); + entry["variants"] = serde_json::Value::Object(variants); + if let Some(default_level) = reasoning.default_level { + entry["options"] = json!({ "reasoningEffort": default_level }); + } +} + /// Compatibility name for callers configuring a hosted catalog. pub fn build_open_code_cloud_config( primary: &str, @@ -479,6 +512,87 @@ mod tests { use super::*; use crate::util::env_lock::lock as env_lock; + fn described( + id: &str, + levels: &[&'static str], + default_level: Option<&'static str>, + thinking_off: bool, + ) -> CatalogModel { + CatalogModel { + id: id.to_string(), + reasoning: Some(crate::account::ReasoningSupport { + levels: levels.to_vec(), + default_level, + thinking_off, + }), + ..Default::default() + } + } + + #[test] + fn each_model_gets_its_own_levels_as_variants_and_its_default_as_an_option() { + let models = [ + described( + "deepseek-v4.1-flash", + &["low", "high", "xhigh", "max"], + Some("high"), + true, + ), + described( + "muse-spark", + &["minimal", "low", "medium", "high", "xhigh"], + Some("medium"), + false, + ), + described("mimo-v2.6-pro", &[], None, true), + CatalogModel { + id: "local-model".to_string(), + ..Default::default() + }, + ]; + let doc: serde_json::Value = serde_json::from_str(&build_open_code_config( + "deepseek-v4.1-flash", + "https://example.test/v1", + "k", + &models, + )) + .unwrap(); + let entries = &doc["provider"]["runanywhere"]["models"]; + let deepseek = &entries["deepseek-v4.1-flash"]; + assert_eq!(deepseek["reasoning"], json!(true)); + assert_eq!( + deepseek["variants"], + json!({ + "low": {"reasoningEffort": "low"}, + "high": {"reasoningEffort": "high"}, + "xhigh": {"reasoningEffort": "xhigh"}, + "max": {"reasoningEffort": "max"}, + "none": {"reasoningEffort": "none"}, + }) + ); + assert_eq!(deepseek["options"], json!({"reasoningEffort": "high"})); + // Thinking cannot be turned off on muse-spark: no `none`. + let muse = &entries["muse-spark"]; + assert!(muse["variants"].get("none").is_none()); + assert_eq!( + muse["variants"]["minimal"], + json!({"reasoningEffort": "minimal"}) + ); + assert_eq!(muse["options"], json!({"reasoningEffort": "medium"})); + // On or off only: thinking off is the one choice, and no default is sent. + let mimo = &entries["mimo-v2.6-pro"]; + assert_eq!( + mimo["variants"], + json!({"none": {"reasoningEffort": "none"}}) + ); + assert!(mimo.get("options").is_none()); + // A model the list does not describe is left as it was. + let local = &entries["local-model"]; + assert!(local.get("variants").is_none()); + assert!(local.get("reasoning").is_none()); + assert!(local.get("options").is_none()); + } + #[test] fn scoped_config_restores_non_utf8_previous_value_on_drop() { let _lock = env_lock();