From ea227798d40a618f779b8bd63577badec9fe0444 Mon Sep 17 00:00:00 2001
From: panviktor <35385675+panviktor@users.noreply.github.com>
Date: Sun, 12 Apr 2026 19:21:50 +0200
Subject: [PATCH 01/30] Tighten Phase 4.10 provider policy cleanup
---
crates/adapters/core/Cargo.toml | 2 +-
crates/adapters/core/src/channels/mod.rs | 63 +----
crates/adapters/core/src/lib.rs | 1 -
.../core/src/runtime_history_hygiene.rs | 16 --
.../core/src/runtime_tool_artifacts.rs | 221 ------------------
crates/adapters/core/src/tools/mod.rs | 2 +-
crates/adapters/onboard/src/wizard.rs | 114 +++++----
crates/adapters/providers/src/compatible.rs | 37 ++-
crates/adapters/providers/src/openai.rs | 154 +-----------
crates/adapters/providers/src/openai_codex.rs | 65 +-----
crates/adapters/providers/src/openrouter.rs | 35 ++-
crates/domain/data/model_catalog.json | 197 ++++++++++++++++
crates/domain/src/config/model_catalog.rs | 126 ++++++++++
docs/fork/ipc-phase4_10-audit.md | 69 ++++++
docs/fork/ipc-phase4_10-plan.md | 37 ++-
15 files changed, 574 insertions(+), 565 deletions(-)
delete mode 100644 crates/adapters/core/src/runtime_tool_artifacts.rs
diff --git a/crates/adapters/core/Cargo.toml b/crates/adapters/core/Cargo.toml
index e1b0f8b7..1d8591c8 100644
--- a/crates/adapters/core/Cargo.toml
+++ b/crates/adapters/core/Cargo.toml
@@ -16,6 +16,7 @@ whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-pr
rag-pdf = ["dep:pdf-extract", "synapse_tools/rag-pdf"]
browser-native = ["dep:fantoccini", "synapse_tools/browser-native"]
sandbox-landlock = ["synapse_security/sandbox-landlock"]
+_ipc_tests_todo = []
[dependencies]
synapse_domain = { path = "../../domain" }
@@ -138,4 +139,3 @@ libc = "0.2"
[dev-dependencies]
tempfile = "3.27"
tokio = { version = "1.50", features = ["rt-multi-thread", "macros", "test-util"] }
-
diff --git a/crates/adapters/core/src/channels/mod.rs b/crates/adapters/core/src/channels/mod.rs
index d9e9f761..35f4761d 100644
--- a/crates/adapters/core/src/channels/mod.rs
+++ b/crates/adapters/core/src/channels/mod.rs
@@ -3030,12 +3030,9 @@ pub async fn start_channels(
mod tests {
use super::*;
use crate::channel_runtime_support::spawn_supervised_listener_with_health_interval;
- use crate::runtime_history_hygiene::{
- normalize_cached_channel_turns, proactive_trim_turns, strip_tool_result_content,
- };
+ use crate::runtime_history_hygiene::{normalize_cached_channel_turns, proactive_trim_turns};
use crate::runtime_system_prompt::BOOTSTRAP_MAX_CHARS;
- use crate::runtime_tool_artifacts::strip_isolated_tool_json_artifacts;
- use std::collections::{HashMap, HashSet};
+ use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Instant;
@@ -3156,26 +3153,6 @@ mod tests {
assert_eq!(effective_channel_message_timeout_secs(300), 300);
}
- #[test]
- fn strip_tool_result_content_removes_blocks_and_header() {
- let input = r#"[Tool results]
-Mon Feb 20
-{"status":200}"#;
- assert_eq!(strip_tool_result_content(input), "");
-
- let mixed = "Some context\nok\nMore text";
- let cleaned = strip_tool_result_content(mixed);
- assert!(cleaned.contains("Some context"));
- assert!(cleaned.contains("More text"));
- assert!(!cleaned.contains("tool_result"));
-
- assert_eq!(
- strip_tool_result_content("no tool results here"),
- "no tool results here"
- );
- assert_eq!(strip_tool_result_content(""), "");
- }
-
#[test]
fn normalize_cached_channel_turns_merges_consecutive_user_turns() {
let turns = vec![
@@ -4693,42 +4670,6 @@ mod tests {
assert!(emitted.is_char_boundary(emitted.len()));
}
- #[test]
- fn strip_isolated_tool_json_artifacts_removes_tool_calls_and_results() {
- let mut known_tools = HashSet::new();
- known_tools.insert("schedule".to_string());
-
- let input = r#"{"name":"schedule","arguments":{"action":"create","message":"test"}}
-{"name":"schedule","arguments":{"action":"cancel","task_id":"test"}}
-Let me create the reminder properly:
-{"name":"schedule","arguments":{"action":"create","message":"Go to sleep"}}
-{"result":{"task_id":"abc","status":"scheduled"}}
-Done reminder set for 1:38 AM."#;
-
- let result = strip_isolated_tool_json_artifacts(input, &known_tools);
- let normalized = result
- .lines()
- .filter(|line| !line.trim().is_empty())
- .collect::>()
- .join("\n");
- assert_eq!(
- normalized,
- "Let me create the reminder properly:\nDone reminder set for 1:38 AM."
- );
- }
-
- #[test]
- fn strip_isolated_tool_json_artifacts_preserves_non_tool_json() {
- let mut known_tools = HashSet::new();
- known_tools.insert("shell".to_string());
-
- let input = r#"{"name":"profile","arguments":{"display_mode":"compact"}}
-This is an example JSON object for profile settings."#;
-
- let result = strip_isolated_tool_json_artifacts(input, &known_tools);
- assert_eq!(result, input);
- }
-
// ── AIEOS Identity Tests (Issue #168) ─────────────────────────
#[test]
diff --git a/crates/adapters/core/src/lib.rs b/crates/adapters/core/src/lib.rs
index dac75ab5..50787aed 100644
--- a/crates/adapters/core/src/lib.rs
+++ b/crates/adapters/core/src/lib.rs
@@ -36,7 +36,6 @@ pub(crate) mod runtime_adapter_contract;
pub(crate) mod runtime_history_hygiene;
pub mod runtime_routes;
pub mod runtime_system_prompt;
-pub(crate) mod runtime_tool_artifacts;
pub(crate) mod runtime_tool_notifications;
pub(crate) mod runtime_tool_observer;
pub mod scoped_instruction_context;
diff --git a/crates/adapters/core/src/runtime_history_hygiene.rs b/crates/adapters/core/src/runtime_history_hygiene.rs
index e55d468c..7b70b0ef 100644
--- a/crates/adapters/core/src/runtime_history_hygiene.rs
+++ b/crates/adapters/core/src/runtime_history_hygiene.rs
@@ -35,22 +35,6 @@ pub(crate) fn normalize_cached_channel_turns(turns: Vec) -> Vec…` blocks from a history entry.
-pub(crate) fn strip_tool_result_content(text: &str) -> String {
- static TOOL_RESULT_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| {
- regex::Regex::new(r"(?s)]*>.*?").unwrap()
- });
-
- let cleaned = TOOL_RESULT_RE.replace_all(text, "");
- let cleaned = cleaned.trim();
-
- if cleaned == "[Tool results]" || cleaned.is_empty() {
- return String::new();
- }
-
- cleaned.to_string()
-}
-
/// Proactively trim conversation turns to the given estimated character budget.
///
/// Drops oldest turns first, but always preserves the current/latest turn.
diff --git a/crates/adapters/core/src/runtime_tool_artifacts.rs b/crates/adapters/core/src/runtime_tool_artifacts.rs
deleted file mode 100644
index 29c0ed7a..00000000
--- a/crates/adapters/core/src/runtime_tool_artifacts.rs
+++ /dev/null
@@ -1,221 +0,0 @@
-//! Transport-neutral cleanup for tool artifacts leaked into assistant text.
-
-use std::collections::HashSet;
-
-/// Remove leading lines that narrate tool usage.
-///
-/// Only strips lines from the very beginning of the message so genuine answer
-/// content is preserved.
-pub(crate) fn strip_tool_narration(message: &str) -> String {
- let narration_prefixes: &[&str] = &[
- "let me ",
- "i'll ",
- "i will ",
- "i am going to ",
- "i'm going to ",
- "searching ",
- "looking up ",
- "fetching ",
- "checking ",
- "using the ",
- "using my ",
- "one moment",
- "hold on",
- "just a moment",
- "give me a moment",
- "allow me to ",
- ];
-
- let mut result_lines: Vec<&str> = Vec::new();
- let mut past_narration = false;
-
- for line in message.lines() {
- if past_narration {
- result_lines.push(line);
- continue;
- }
- let trimmed = line.trim();
- if trimmed.is_empty() {
- continue;
- }
- let lower = trimmed.to_lowercase();
- if narration_prefixes
- .iter()
- .any(|prefix| lower.starts_with(prefix))
- {
- continue;
- }
- past_narration = true;
- result_lines.push(line);
- }
-
- let joined = result_lines.join("\n");
- let trimmed = joined.trim();
- if trimmed.is_empty() && !message.trim().is_empty() {
- message.to_string()
- } else {
- trimmed.to_string()
- }
-}
-
-fn is_tool_call_payload(value: &serde_json::Value, known_tool_names: &HashSet) -> bool {
- let Some(object) = value.as_object() else {
- return false;
- };
-
- let name = object.get("name").and_then(|value| value.as_str());
- let has_args = object.contains_key("arguments");
-
- let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
- return false;
- };
-
- has_args && known_tool_names.contains(&name.to_ascii_lowercase())
-}
-
-fn is_tool_result_payload(
- object: &serde_json::Map,
- saw_tool_call_payload: bool,
-) -> bool {
- if !saw_tool_call_payload || !object.contains_key("result") {
- return false;
- }
-
- object.keys().all(|key| {
- matches!(
- key.as_str(),
- "result" | "id" | "tool_call_id" | "name" | "tool"
- )
- })
-}
-
-fn sanitize_tool_json_value(
- value: &serde_json::Value,
- known_tool_names: &HashSet,
- saw_tool_call_payload: bool,
-) -> Option<(String, bool)> {
- if is_tool_call_payload(value, known_tool_names) {
- return Some((String::new(), true));
- }
-
- if let Some(array) = value.as_array() {
- if !array.is_empty()
- && array
- .iter()
- .all(|item| is_tool_call_payload(item, known_tool_names))
- {
- return Some((String::new(), true));
- }
- return None;
- }
-
- let object = value.as_object()?;
-
- if let Some(tool_calls) = object.get("tool_calls").and_then(|value| value.as_array()) {
- if !tool_calls.is_empty()
- && tool_calls
- .iter()
- .all(|call| is_tool_call_payload(call, known_tool_names))
- {
- let content = object
- .get("content")
- .and_then(|value| value.as_str())
- .unwrap_or("")
- .trim()
- .to_string();
- return Some((content, true));
- }
- }
-
- if is_tool_result_payload(object, saw_tool_call_payload) {
- return Some((String::new(), false));
- }
-
- None
-}
-
-fn is_line_isolated_json_segment(message: &str, start: usize, end: usize) -> bool {
- let line_start = message[..start].rfind('\n').map_or(0, |idx| idx + 1);
- let line_end = message[end..]
- .find('\n')
- .map_or(message.len(), |idx| end + idx);
-
- message[line_start..start].trim().is_empty() && message[end..line_end].trim().is_empty()
-}
-
-pub(crate) fn strip_isolated_tool_json_artifacts(
- message: &str,
- known_tool_names: &HashSet,
-) -> String {
- let mut cleaned = String::with_capacity(message.len());
- let mut cursor = 0usize;
- let mut saw_tool_call_payload = false;
-
- while cursor < message.len() {
- let Some(rel_start) = message[cursor..].find(['{', '[']) else {
- cleaned.push_str(&message[cursor..]);
- break;
- };
-
- let start = cursor + rel_start;
- cleaned.push_str(&message[cursor..start]);
-
- let candidate = &message[start..];
- let mut stream =
- serde_json::Deserializer::from_str(candidate).into_iter::();
-
- if let Some(Ok(value)) = stream.next() {
- let consumed = stream.byte_offset();
- if consumed > 0 {
- let end = start + consumed;
- if is_line_isolated_json_segment(message, start, end) {
- if let Some((replacement, marks_tool_call)) =
- sanitize_tool_json_value(&value, known_tool_names, saw_tool_call_payload)
- {
- if marks_tool_call {
- saw_tool_call_payload = true;
- }
- if !replacement.trim().is_empty() {
- cleaned.push_str(replacement.trim());
- }
- cursor = end;
- continue;
- }
- }
- }
- }
-
- let Some(ch) = message[start..].chars().next() else {
- break;
- };
- cleaned.push(ch);
- cursor = start + ch.len_utf8();
- }
-
- let mut result = cleaned.replace("\r\n", "\n");
- while result.contains("\n\n\n") {
- result = result.replace("\n\n\n", "\n\n");
- }
- result.trim().to_string()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn strip_tool_narration_removes_only_leading_narration() {
- let input = "Let me check that.\n\nActual answer\nI will not be stripped here.";
-
- let result = strip_tool_narration(input);
-
- assert_eq!(result, "Actual answer\nI will not be stripped here.");
- }
-
- #[test]
- fn strip_tool_narration_returns_original_when_everything_is_narration() {
- let input = "Let me check that.";
-
- assert_eq!(strip_tool_narration(input), input);
- }
-}
diff --git a/crates/adapters/core/src/tools/mod.rs b/crates/adapters/core/src/tools/mod.rs
index 3b9cf104..380205a2 100644
--- a/crates/adapters/core/src/tools/mod.rs
+++ b/crates/adapters/core/src/tools/mod.rs
@@ -906,7 +906,7 @@ mod tests {
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"browser_open"));
- assert!(names.contains(&"schedule"));
+ assert!(!names.contains(&"schedule"));
assert!(names.contains(&"model_routing_config"));
assert!(names.contains(&"pushover"));
assert!(names.contains(&"proxy_config"));
diff --git a/crates/adapters/onboard/src/wizard.rs b/crates/adapters/onboard/src/wizard.rs
index dd2edfe6..77ef1d39 100644
--- a/crates/adapters/onboard/src/wizard.rs
+++ b/crates/adapters/onboard/src/wizard.rs
@@ -404,12 +404,8 @@ fn apply_provider_update(
// ── Quick setup (zero prompts) ───────────────────────────────────
/// Non-interactive setup: generates a sensible default config instantly.
-/// Use `synapseclaw onboard` or `synapseclaw onboard --api-key sk-... --provider openrouter --memory sqlite|lucid`.
+/// Use `synapseclaw onboard` or `synapseclaw onboard --api-key sk-... --provider openrouter`.
// Phase 4.3: SurrealDB is the only backend — no selection needed.
-fn backend_key_from_choice(_choice: usize) -> &'static str {
- "surrealdb"
-}
-
fn memory_config_defaults_for_backend(_backend: &str) -> MemoryConfig {
MemoryConfig::default()
}
@@ -1199,6 +1195,57 @@ fn ollama_uses_remote_endpoint(provider_api_url: Option<&str>) -> bool {
!ollama_endpoint_is_local(&normalized)
}
+fn provider_prefers_configured_models_endpoint(provider_name: &str) -> bool {
+ matches!(
+ canonical_provider_name(provider_name),
+ "openai"
+ | "openai-codex"
+ | "openrouter"
+ | "groq"
+ | "mistral"
+ | "deepseek"
+ | "xai"
+ | "together-ai"
+ | "fireworks"
+ | "novita"
+ | "cohere"
+ | "moonshot"
+ | "glm"
+ | "zai"
+ | "qwen"
+ | "venice"
+ | "astrai"
+ | "nvidia"
+ | "opencode-go"
+ | "llamacpp"
+ | "sglang"
+ | "vllm"
+ | "osaurus"
+ )
+}
+
+fn models_endpoint_from_api_base_url(raw_url: &str) -> Option {
+ let normalized = raw_url.trim().trim_end_matches('/');
+ if normalized.is_empty() {
+ return None;
+ }
+ if normalized.ends_with("/models") {
+ return Some(normalized.to_string());
+ }
+ if let Some(prefix) = normalized.strip_suffix("/chat/completions") {
+ return Some(format!("{prefix}/models"));
+ }
+
+ let has_api_path = reqwest::Url::parse(normalized)
+ .ok()
+ .is_some_and(|url| !url.path().trim_matches('/').is_empty());
+ if has_api_path {
+ Some(format!("{normalized}/models"))
+ } else {
+ Some(format!("{normalized}/v1/models"))
+ }
+}
+
fn resolve_live_models_endpoint(
provider_name: &str,
provider_api_url: Option<&str>,
@@ -1214,32 +1261,9 @@ fn resolve_live_models_endpoint(
return Some(format!("{normalized}/models"));
}
- if matches!(
- canonical_provider_name(provider_name),
- "llamacpp" | "sglang" | "vllm" | "osaurus"
- ) {
- if let Some(url) = provider_api_url
- .map(str::trim)
- .filter(|url| !url.is_empty())
- {
- let normalized = url.trim_end_matches('/');
- if normalized.ends_with("/models") {
- return Some(normalized.to_string());
- }
- return Some(format!("{normalized}/models"));
- }
- }
-
- if canonical_provider_name(provider_name) == "openai-codex" {
- if let Some(url) = provider_api_url
- .map(str::trim)
- .filter(|url| !url.is_empty())
- {
- let normalized = url.trim_end_matches('/');
- if normalized.ends_with("/models") {
- return Some(normalized.to_string());
- }
- return Some(format!("{normalized}/models"));
+ if provider_prefers_configured_models_endpoint(provider_name) {
+ if let Some(endpoint) = provider_api_url.and_then(models_endpoint_from_api_base_url) {
+ return Some(endpoint);
}
}
@@ -6548,11 +6572,30 @@ mod tests {
);
assert_eq!(
resolve_live_models_endpoint("venice", Some("http://localhost:9999/v1")),
- Some("https://api.venice.ai/api/v1/models".to_string())
+ Some("http://localhost:9999/v1/models".to_string())
);
assert_eq!(resolve_live_models_endpoint("unknown-provider", None), None);
}
+ #[test]
+ fn resolve_live_models_endpoint_honors_compatible_provider_api_url() {
+ assert_eq!(
+ resolve_live_models_endpoint("deepseek", Some("https://api.deepseek.com/v1")),
+ Some("https://api.deepseek.com/v1/models".to_string())
+ );
+ assert_eq!(
+ resolve_live_models_endpoint(
+ "openai",
+ Some("https://router.example.com/openai/chat/completions")
+ ),
+ Some("https://router.example.com/openai/models".to_string())
+ );
+ assert_eq!(
+ resolve_live_models_endpoint("openrouter", Some("https://router.example.com")),
+ Some("https://router.example.com/v1/models".to_string())
+ );
+ }
+
#[test]
fn resolve_live_models_endpoint_supports_custom_provider_urls() {
assert_eq!(
@@ -7002,13 +7045,6 @@ mod tests {
assert_eq!(provider_env_var("some-new-provider"), "API_KEY");
}
- #[test]
- fn backend_key_from_choice_always_returns_surrealdb() {
- // Phase 4.3: SurrealDB is the only backend
- assert_eq!(backend_key_from_choice(0), "surrealdb");
- assert_eq!(backend_key_from_choice(999), "surrealdb");
- }
-
// Phase 4.3: old memory backend tests removed (sqlite/lucid/markdown/none gone)
#[test]
diff --git a/crates/adapters/providers/src/compatible.rs b/crates/adapters/providers/src/compatible.rs
index 5c88b9db..3f4d3a06 100644
--- a/crates/adapters/providers/src/compatible.rs
+++ b/crates/adapters/providers/src/compatible.rs
@@ -15,6 +15,7 @@ use reqwest::{
Client,
};
use serde::{Deserialize, Serialize};
+use synapse_domain::config::model_catalog;
/// A provider that speaks the OpenAI-compatible chat completions API.
/// Used by provider adapters that expose the standard chat-completions shape.
@@ -39,7 +40,8 @@ pub struct OpenAiCompatibleProvider {
timeout_secs: u64,
/// Extra HTTP headers to include in all API requests.
extra_headers: std::collections::HashMap,
- /// Optional reasoning effort for GPT-5/Codex-compatible backends.
+ /// Optional reasoning effort, sent only when catalog metadata says the
+ /// concrete provider:model accepts this OpenAI-compatible control.
reasoning_effort: Option,
/// Custom API path suffix (e.g. "/v2/generate").
/// When set, overrides the default `/chat/completions` path detection.
@@ -372,11 +374,17 @@ impl OpenAiCompatibleProvider {
}
fn reasoning_effort_for_model(&self, model: &str) -> Option {
- let id = model.rsplit('/').next().unwrap_or(model);
- let supports_reasoning_effort = id.starts_with("gpt-5") || id.contains("codex");
- supports_reasoning_effort
- .then(|| self.reasoning_effort.clone())
- .flatten()
+ let requested = self.reasoning_effort.as_deref()?;
+ let provider = self.catalog_provider_id()?;
+ model_catalog::model_request_policy(&provider, model)
+ .and_then(|policy| policy.resolve_reasoning_effort(requested))
+ }
+
+ fn catalog_provider_id(&self) -> Option {
+ let normalized = self.name.trim().to_ascii_lowercase().replace(' ', "-");
+ model_catalog::provider_default_model(&normalized)
+ .map(|_| normalized)
+ .or_else(|| model_catalog::provider_for_api_base_url(&self.base_url).map(String::from))
}
}
@@ -2370,19 +2378,26 @@ mod tests {
}
#[test]
- fn reasoning_effort_only_applies_to_gpt5_and_codex_models() {
- let provider = make_provider("test", "https://example.com", None)
+ fn reasoning_effort_uses_catalog_policy_not_model_name_guessing() {
+ let provider = make_provider("OpenAI", "https://api.openai.com/v1", None)
.with_reasoning_effort(Some("high".to_string()));
assert_eq!(
- provider.reasoning_effort_for_model("gpt-5.3-codex"),
+ provider.reasoning_effort_for_model("openai/gpt-5.4"),
Some("high".to_string())
);
+
+ let unknown = make_provider("test", "https://example.com", None)
+ .with_reasoning_effort(Some("high".to_string()));
+ assert_eq!(unknown.reasoning_effort_for_model("gpt-5.4"), None);
+
+ let codex_named_compatible =
+ make_provider("openai-codex", "https://api.openai.com/v1", None)
+ .with_reasoning_effort(Some("xhigh".to_string()));
assert_eq!(
- provider.reasoning_effort_for_model("openai/gpt-5"),
+ codex_named_compatible.reasoning_effort_for_model("gpt-5.4"),
Some("high".to_string())
);
- assert_eq!(provider.reasoning_effort_for_model("llama-3.3-70b"), None);
}
#[tokio::test]
diff --git a/crates/adapters/providers/src/openai.rs b/crates/adapters/providers/src/openai.rs
index abeb1f47..0cb3a7d4 100644
--- a/crates/adapters/providers/src/openai.rs
+++ b/crates/adapters/providers/src/openai.rs
@@ -6,6 +6,7 @@ use crate::traits::{
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
+use synapse_domain::config::model_catalog;
pub struct OpenAiProvider {
base_url: String,
@@ -188,36 +189,10 @@ impl OpenAiProvider {
}
}
- /// Adjust temperature for models that have specific requirements.
- /// Some OpenAI models (like gpt-5-mini, o1, o3, etc) only accept temperature=1.0.
- fn adjust_temperature_for_model(model: &str, requested_temperature: f64) -> f64 {
- // Models that require temperature=1.0
- let requires_1_0 = matches!(
- model,
- "gpt-5"
- | "gpt-5-2025-08-07"
- | "gpt-5-mini"
- | "gpt-5-mini-2025-08-07"
- | "gpt-5-nano"
- | "gpt-5-nano-2025-08-07"
- | "gpt-5.1-chat-latest"
- | "gpt-5.2-chat-latest"
- | "gpt-5.3-chat-latest"
- | "o1"
- | "o1-2024-12-17"
- | "o3"
- | "o3-2025-04-16"
- | "o3-mini"
- | "o3-mini-2025-01-31"
- | "o4-mini"
- | "o4-mini-2025-04-16"
- );
-
- if requires_1_0 {
- 1.0
- } else {
- requested_temperature
- }
+ fn request_temperature_for_model(model: &str, requested_temperature: f64) -> f64 {
+ model_catalog::model_request_policy("openai", model)
+ .and_then(|policy| policy.fixed_temperature)
+ .unwrap_or(requested_temperature)
}
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option> {
@@ -358,7 +333,7 @@ impl Provider for OpenAiProvider {
anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
})?;
- let adjusted_temperature = Self::adjust_temperature_for_model(model, temperature);
+ let adjusted_temperature = Self::request_temperature_for_model(model, temperature);
let mut messages = Vec::new();
@@ -414,7 +389,7 @@ impl Provider for OpenAiProvider {
anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
})?;
- let adjusted_temperature = Self::adjust_temperature_for_model(model, temperature);
+ let adjusted_temperature = Self::request_temperature_for_model(model, temperature);
let tools = Self::convert_tools(request.tools);
let native_request = NativeChatRequest {
@@ -469,7 +444,7 @@ impl Provider for OpenAiProvider {
anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
})?;
- let adjusted_temperature = Self::adjust_temperature_for_model(model, temperature);
+ let adjusted_temperature = Self::request_temperature_for_model(model, temperature);
let native_tools: Option> = if tools.is_empty() {
None
@@ -898,124 +873,19 @@ mod tests {
assert!(json.contains("thinking..."));
}
- // ═══════════════════════════════════════════════════════════════════════
- // Temperature adjustment tests
- // ═══════════════════════════════════════════════════════════════════════
-
- #[test]
- fn adjust_temperature_for_o1_models() {
- assert_eq!(OpenAiProvider::adjust_temperature_for_model("o1", 0.7), 1.0);
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("o1-2024-12-17", 0.5),
- 1.0
- );
- }
-
#[test]
- fn adjust_temperature_for_o3_models() {
- assert_eq!(OpenAiProvider::adjust_temperature_for_model("o3", 0.7), 1.0);
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("o3-2025-04-16", 0.5),
- 1.0
- );
+ fn request_temperature_uses_catalog_policy_only() {
assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("o3-mini", 0.3),
+ OpenAiProvider::request_temperature_for_model("o3", 0.7),
1.0
);
assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("o3-mini-2025-01-31", 0.8),
+ OpenAiProvider::request_temperature_for_model("openai/gpt-5", 0.2),
1.0
);
- }
-
- #[test]
- fn adjust_temperature_for_o4_models() {
assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("o4-mini", 0.7),
- 1.0
- );
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("o4-mini-2025-04-16", 0.5),
- 1.0
- );
- }
-
- #[test]
- fn adjust_temperature_for_gpt5_models() {
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-5", 0.7),
- 1.0
- );
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-5-2025-08-07", 0.5),
- 1.0
- );
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-5-mini", 0.3),
- 1.0
- );
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-5-mini-2025-08-07", 0.8),
- 1.0
- );
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-5-nano", 0.6),
- 1.0
- );
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-5-nano-2025-08-07", 0.4),
- 1.0
- );
- }
-
- #[test]
- fn adjust_temperature_for_gpt5_chat_latest_models() {
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-5.1-chat-latest", 0.7),
- 1.0
- );
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-5.2-chat-latest", 0.5),
- 1.0
- );
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-5.3-chat-latest", 0.3),
- 1.0
- );
- }
-
- #[test]
- fn adjust_temperature_preserves_for_standard_models() {
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-4o", 0.7),
- 0.7
- );
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-4-turbo", 0.5),
- 0.5
- );
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-3.5-turbo", 0.3),
+ OpenAiProvider::request_temperature_for_model("catalog-unknown-model", 0.3),
0.3
);
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-4", 1.0),
- 1.0
- );
- }
-
- #[test]
- fn adjust_temperature_handles_edge_cases() {
- // Temperature 0.0 should be preserved for standard models
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-4o", 0.0),
- 0.0
- );
- // Temperature 1.0 should be preserved for all models
- assert_eq!(OpenAiProvider::adjust_temperature_for_model("o1", 1.0), 1.0);
- assert_eq!(
- OpenAiProvider::adjust_temperature_for_model("gpt-4o", 1.0),
- 1.0
- );
}
}
diff --git a/crates/adapters/providers/src/openai_codex.rs b/crates/adapters/providers/src/openai_codex.rs
index 23a95d72..2bce1832 100644
--- a/crates/adapters/providers/src/openai_codex.rs
+++ b/crates/adapters/providers/src/openai_codex.rs
@@ -17,6 +17,7 @@ use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
+use synapse_domain::config::model_catalog;
const DEFAULT_CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
const OFFICIAL_OPENAI_RESPONSES_URL: &str = "https://api.openai.com/v1/responses";
@@ -1019,36 +1020,6 @@ fn parse_responses_chat_response(response: ResponsesResponse) -> ParsedResponses
}
}
-fn clamp_reasoning_effort(model: &str, effort: &str) -> String {
- let id = normalize_model_id(model);
- if matches!(id, "gpt-5.4" | "gpt-5.4-mini" | "gpt-5-codex") {
- return match effort {
- "low" | "medium" | "high" => effort.to_string(),
- "minimal" => "low".to_string(),
- _ => "high".to_string(),
- };
- }
- if (id.starts_with("gpt-5.2") || id.starts_with("gpt-5.3") || id.starts_with("gpt-5.4"))
- && effort == "minimal"
- {
- return "low".to_string();
- }
- if (id.starts_with("gpt-5.4") || id.starts_with("gpt-5-codex")) && effort == "xhigh" {
- return "high".to_string();
- }
- if id == "gpt-5.1" && effort == "xhigh" {
- return "high".to_string();
- }
- if id == "gpt-5.1-codex-mini" {
- return if effort == "high" || effort == "xhigh" {
- "high".to_string()
- } else {
- "medium".to_string()
- };
- }
- effort.to_string()
-}
-
fn resolve_reasoning_effort(model_id: &str, configured: Option<&str>) -> String {
let raw = configured
.map(ToString::to_string)
@@ -1056,7 +1027,9 @@ fn resolve_reasoning_effort(model_id: &str, configured: Option<&str>) -> String
.and_then(|value| first_nonempty(Some(&value)))
.unwrap_or_else(|| "xhigh".to_string())
.to_ascii_lowercase();
- clamp_reasoning_effort(model_id, &raw)
+ model_catalog::model_request_policy("openai-codex", model_id)
+ .and_then(|policy| policy.resolve_reasoning_effort(&raw))
+ .unwrap_or(raw)
}
fn nonempty_preserve(text: Option<&str>) -> Option {
@@ -2107,41 +2080,25 @@ mod tests {
}
#[test]
- fn clamp_reasoning_effort_adjusts_known_models() {
+ fn resolve_reasoning_effort_uses_catalog_policy() {
assert_eq!(
- clamp_reasoning_effort("gpt-5-codex", "xhigh"),
+ resolve_reasoning_effort("gpt-5-codex", Some("xhigh")),
"high".to_string()
);
assert_eq!(
- clamp_reasoning_effort("gpt-5-codex", "minimal"),
+ resolve_reasoning_effort("gpt-5-codex", Some("minimal")),
"low".to_string()
);
assert_eq!(
- clamp_reasoning_effort("gpt-5-codex", "medium"),
- "medium".to_string()
- );
- assert_eq!(
- clamp_reasoning_effort("gpt-5.3-codex", "minimal"),
- "low".to_string()
- );
- assert_eq!(
- clamp_reasoning_effort("gpt-5.1", "xhigh"),
- "high".to_string()
- );
- assert_eq!(
- clamp_reasoning_effort("gpt-5-codex", "xhigh"),
- "high".to_string()
+ resolve_reasoning_effort("gpt-5.3-codex", Some("xhigh")),
+ "xhigh".to_string()
);
assert_eq!(
- clamp_reasoning_effort("gpt-5.1-codex-mini", "low"),
+ resolve_reasoning_effort("gpt-5.1-codex-mini", Some("low")),
"medium".to_string()
);
assert_eq!(
- clamp_reasoning_effort("gpt-5.1-codex-mini", "xhigh"),
- "high".to_string()
- );
- assert_eq!(
- clamp_reasoning_effort("gpt-5.3-codex", "xhigh"),
+ resolve_reasoning_effort("catalog-unknown-model", Some("xhigh")),
"xhigh".to_string()
);
}
diff --git a/crates/adapters/providers/src/openrouter.rs b/crates/adapters/providers/src/openrouter.rs
index 9bb1b038..fa997630 100644
--- a/crates/adapters/providers/src/openrouter.rs
+++ b/crates/adapters/providers/src/openrouter.rs
@@ -8,6 +8,7 @@ use async_trait::async_trait;
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
+use synapse_domain::config::model_catalog;
pub struct OpenRouterProvider {
credential: Option,
@@ -181,15 +182,28 @@ impl OpenRouterProvider {
}
}
- fn reasoning_options(&self) -> Option {
- match (self.reasoning_enabled, self.reasoning_effort.as_ref()) {
+ fn reasoning_options(&self, model: &str) -> Option {
+ if self.reasoning_enabled == Some(false) {
+ return Some(OpenRouterReasoningOptions {
+ enabled: Some(false),
+ effort: None,
+ });
+ }
+
+ let policy = model_catalog::model_request_policy("openrouter", model)?;
+ let effort = self
+ .reasoning_effort
+ .as_deref()
+ .and_then(|requested| policy.resolve_reasoning_effort(requested));
+
+ match (self.reasoning_enabled, effort) {
(Some(false), _) => Some(OpenRouterReasoningOptions {
enabled: Some(false),
effort: None,
}),
(Some(true), Some(effort)) => Some(OpenRouterReasoningOptions {
enabled: Some(true),
- effort: Some(effort.clone()),
+ effort: Some(effort),
}),
(Some(true), None) => Some(OpenRouterReasoningOptions {
enabled: Some(true),
@@ -197,7 +211,7 @@ impl OpenRouterProvider {
}),
(None, Some(effort)) => Some(OpenRouterReasoningOptions {
enabled: None,
- effort: Some(effort.clone()),
+ effort: Some(effort),
}),
(None, None) => None,
}
@@ -492,7 +506,7 @@ impl Provider for OpenRouterProvider {
model: model.to_string(),
messages,
temperature,
- reasoning: self.reasoning_options(),
+ reasoning: self.reasoning_options(model),
};
let response = self
@@ -541,7 +555,7 @@ impl Provider for OpenRouterProvider {
model: model.to_string(),
messages: api_messages,
temperature,
- reasoning: self.reasoning_options(),
+ reasoning: self.reasoning_options(model),
};
let response = self
@@ -586,7 +600,7 @@ impl Provider for OpenRouterProvider {
model: model.to_string(),
messages: Self::convert_messages(request.messages),
temperature,
- reasoning: self.reasoning_options(),
+ reasoning: self.reasoning_options(model),
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
tools,
};
@@ -680,7 +694,7 @@ impl Provider for OpenRouterProvider {
model: model.to_string(),
messages: native_messages,
temperature,
- reasoning: self.reasoning_options(),
+ reasoning: self.reasoning_options(model),
tool_choice: native_tools.as_ref().map(|_| "auto".to_string()),
tools: native_tools,
};
@@ -752,12 +766,13 @@ mod tests {
OpenRouterProvider::new_with_reasoning(None, Some(true), Some("high".to_string()));
assert_eq!(
- provider.reasoning_options(),
+ provider.reasoning_options("x-ai/grok-4.20"),
Some(OpenRouterReasoningOptions {
enabled: Some(true),
effort: Some("high".to_string()),
})
);
+ assert_eq!(provider.reasoning_options("unknown/no-policy"), None);
}
#[test]
@@ -766,7 +781,7 @@ mod tests {
OpenRouterProvider::new_with_reasoning(None, Some(false), Some("high".to_string()));
assert_eq!(
- provider.reasoning_options(),
+ provider.reasoning_options("unknown/no-policy"),
Some(OpenRouterReasoningOptions {
enabled: Some(false),
effort: None,
diff --git a/crates/domain/data/model_catalog.json b/crates/domain/data/model_catalog.json
index ee50b497..01ade817 100644
--- a/crates/domain/data/model_catalog.json
+++ b/crates/domain/data/model_catalog.json
@@ -584,5 +584,202 @@
"provider": "openrouter",
"model": "x-ai/grok-4.20"
}
+ ],
+ "request_policies": [
+ {
+ "provider": "openai",
+ "model": "gpt-5",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5-2025-08-07",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5-mini",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5-mini-2025-08-07",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5-nano",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5-nano-2025-08-07",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5.1-chat-latest",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5.2-chat-latest",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5.3-chat-latest",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5.4",
+ "fixed_temperature": 1.0,
+ "reasoning_efforts": ["low", "medium", "high"],
+ "default_reasoning_effort": "high",
+ "reasoning_effort_aliases": {
+ "minimal": "low",
+ "xhigh": "high"
+ }
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5.4-mini",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "gpt-5.4-pro",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "o1",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "o1-2024-12-17",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "o3",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "o3-2025-04-16",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "o3-mini",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "o3-mini-2025-01-31",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "o4-mini",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai",
+ "model": "o4-mini-2025-04-16",
+ "fixed_temperature": 1.0
+ },
+ {
+ "provider": "openai-codex",
+ "model": "gpt-5.4",
+ "reasoning_efforts": ["low", "medium", "high"],
+ "default_reasoning_effort": "high",
+ "reasoning_effort_aliases": {
+ "minimal": "low",
+ "xhigh": "high"
+ }
+ },
+ {
+ "provider": "openai-codex",
+ "model": "gpt-5.4-mini",
+ "reasoning_efforts": ["low", "medium", "high"],
+ "default_reasoning_effort": "high",
+ "reasoning_effort_aliases": {
+ "minimal": "low",
+ "xhigh": "high"
+ }
+ },
+ {
+ "provider": "openai-codex",
+ "model": "gpt-5-codex",
+ "reasoning_efforts": ["low", "medium", "high"],
+ "default_reasoning_effort": "high",
+ "reasoning_effort_aliases": {
+ "minimal": "low",
+ "xhigh": "high"
+ }
+ },
+ {
+ "provider": "openai-codex",
+ "model": "gpt-5.3-codex",
+ "reasoning_efforts": ["low", "medium", "high", "xhigh"],
+ "default_reasoning_effort": "xhigh",
+ "reasoning_effort_aliases": {
+ "minimal": "low"
+ }
+ },
+ {
+ "provider": "openai-codex",
+ "model": "gpt-5.2-codex",
+ "reasoning_efforts": ["low", "medium", "high", "xhigh"],
+ "default_reasoning_effort": "xhigh",
+ "reasoning_effort_aliases": {
+ "minimal": "low"
+ }
+ },
+ {
+ "provider": "openai-codex",
+ "model": "gpt-5.1",
+ "reasoning_efforts": ["low", "medium", "high"],
+ "default_reasoning_effort": "high",
+ "reasoning_effort_aliases": {
+ "minimal": "low",
+ "xhigh": "high"
+ }
+ },
+ {
+ "provider": "openai-codex",
+ "model": "gpt-5.1-codex-mini",
+ "reasoning_efforts": ["medium", "high"],
+ "default_reasoning_effort": "medium",
+ "reasoning_effort_aliases": {
+ "minimal": "medium",
+ "low": "medium",
+ "xhigh": "high"
+ }
+ },
+ {
+ "provider": "openrouter",
+ "model": "x-ai/grok-4.20",
+ "reasoning_efforts": ["low", "medium", "high"],
+ "default_reasoning_effort": "high",
+ "reasoning_effort_aliases": {
+ "minimal": "low",
+ "xhigh": "high"
+ }
+ },
+ {
+ "provider": "openrouter",
+ "model": "openai/gpt-5.4",
+ "reasoning_efforts": ["low", "medium", "high"],
+ "default_reasoning_effort": "high",
+ "reasoning_effort_aliases": {
+ "minimal": "low",
+ "xhigh": "high"
+ }
+ }
]
}
diff --git a/crates/domain/src/config/model_catalog.rs b/crates/domain/src/config/model_catalog.rs
index 68841313..bcb9213d 100644
--- a/crates/domain/src/config/model_catalog.rs
+++ b/crates/domain/src/config/model_catalog.rs
@@ -37,6 +37,8 @@ struct ModelCatalogData {
embedding_profiles: Vec,
#[serde(default, alias = "model_routes")]
route_aliases: Vec,
+ #[serde(default)]
+ request_policies: Vec,
}
#[derive(Debug, Clone, Deserialize)]
@@ -81,6 +83,54 @@ struct ModelProfileCatalogEntry {
features: Vec,
}
+#[derive(Debug, Clone, Deserialize)]
+struct ModelRequestPolicyCatalogEntry {
+ provider: String,
+ model: String,
+ #[serde(default)]
+ fixed_temperature: Option,
+ #[serde(default)]
+ reasoning_efforts: Vec,
+ #[serde(default)]
+ default_reasoning_effort: Option,
+ #[serde(default)]
+ reasoning_effort_aliases: HashMap,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct ModelRequestPolicy {
+ pub fixed_temperature: Option,
+ pub reasoning_efforts: Vec,
+ pub default_reasoning_effort: Option,
+ pub reasoning_effort_aliases: HashMap,
+}
+
+impl ModelRequestPolicy {
+ pub fn resolve_reasoning_effort(&self, requested: &str) -> Option {
+ let requested = requested.trim().to_ascii_lowercase();
+ if requested.is_empty() {
+ return self.default_reasoning_effort.clone();
+ }
+
+ let resolved = self
+ .reasoning_effort_aliases
+ .get(&requested)
+ .cloned()
+ .unwrap_or(requested);
+
+ if self.reasoning_efforts.is_empty()
+ || self
+ .reasoning_efforts
+ .iter()
+ .any(|effort| effort.eq_ignore_ascii_case(&resolved))
+ {
+ return Some(resolved);
+ }
+
+ self.default_reasoning_effort.clone()
+ }
+}
+
#[derive(Debug, Clone, Deserialize)]
struct EmbeddingProfileCatalogEntry {
provider: String,
@@ -221,6 +271,17 @@ fn merge_catalog_data(
}
}
+ for policy in override_data.request_policies {
+ if let Some(existing) = merged.request_policies.iter_mut().find(|item| {
+ item.provider.eq_ignore_ascii_case(&policy.provider)
+ && item.model.eq_ignore_ascii_case(&policy.model)
+ }) {
+ *existing = policy;
+ } else {
+ merged.request_policies.push(policy);
+ }
+ }
+
merged
}
@@ -384,6 +445,32 @@ pub fn model_profile(provider: &str, model: &str) -> Option
})
}
+pub fn model_request_policy(provider: &str, model: &str) -> Option {
+ let provider = provider.trim();
+ let model = model.trim();
+ if provider.is_empty() || model.is_empty() {
+ return None;
+ }
+
+ let normalized_model = model.rsplit('/').next().unwrap_or(model);
+
+ active_model_catalog()
+ .data
+ .request_policies
+ .iter()
+ .find(|entry| {
+ entry.provider.eq_ignore_ascii_case(provider)
+ && (entry.model.eq_ignore_ascii_case(model)
+ || entry.model.eq_ignore_ascii_case(normalized_model))
+ })
+ .map(|entry| ModelRequestPolicy {
+ fixed_temperature: entry.fixed_temperature,
+ reasoning_efforts: entry.reasoning_efforts.clone(),
+ default_reasoning_effort: entry.default_reasoning_effort.clone(),
+ reasoning_effort_aliases: entry.reasoning_effort_aliases.clone(),
+ })
+}
+
pub fn embedding_profile(
provider: &str,
model: &str,
@@ -675,6 +762,15 @@ mod tests {
"provider": "openrouter",
"model": "google/gemma-4-31b-preview"
}
+ ],
+ "request_policies": [
+ {
+ "provider": "openai-codex",
+ "model": "gpt-5.4",
+ "default_reasoning_effort": "high",
+ "reasoning_efforts": ["low", "medium", "high"],
+ "reasoning_effort_aliases": { "minimal": "low", "xhigh": "high" }
+ }
]
}"#,
)
@@ -712,5 +808,35 @@ mod tests {
.find(|route| route.hint == "gemma31b")
.expect("route alias should merge by hint");
assert_eq!(alias.model, "google/gemma-4-31b-preview");
+ let policy = merged
+ .request_policies
+ .iter()
+ .find(|policy| policy.provider == "openai-codex" && policy.model == "gpt-5.4")
+ .expect("request policy should merge by provider/model key");
+ assert_eq!(policy.default_reasoning_effort.as_deref(), Some("high"));
+ }
+
+ #[test]
+ fn request_policy_resolves_temperature_and_reasoning_aliases() {
+ let policy =
+ model_request_policy("openai-codex", "gpt-5.4").expect("policy should resolve");
+ assert_eq!(
+ policy.resolve_reasoning_effort("xhigh"),
+ Some("high".into())
+ );
+ assert_eq!(
+ policy.resolve_reasoning_effort("minimal"),
+ Some("low".into())
+ );
+
+ let policy = model_request_policy("openai", "o3").expect("policy should resolve");
+ assert_eq!(policy.fixed_temperature, Some(1.0));
+
+ let policy =
+ model_request_policy("openrouter", "x-ai/grok-4.20").expect("policy should resolve");
+ assert_eq!(
+ policy.resolve_reasoning_effort("xhigh"),
+ Some("high".into())
+ );
}
}
diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md
index f619431f..bea39e6f 100644
--- a/docs/fork/ipc-phase4_10-audit.md
+++ b/docs/fork/ipc-phase4_10-audit.md
@@ -330,6 +330,75 @@ Reference:
## Re-Audit Of SynapseClaw
+### 2026-04-12 Phase 4.10 Code Audit Update
+
+Current status:
+
+1. **Prompt/context architecture is materially stronger than the 2026-04-08 baseline**
+ - provider-facing history is separated from audit history in the active
+ runtime paths
+ - compaction preserves protected head/tail and avoids splitting
+ tool-call/tool-result groups
+ - provider usage tokens can feed the next compaction pressure decision
+ - condensed artifact cache is a shared persistent runtime service/port,
+ visible to both web and channel diagnostics
+
+2. **Shared runtime tool protocol is now strict**
+ - shared text/XML tool-call fallback and dead presentation scrubbers were
+ removed
+ - raw `` output is treated as a defect unless a concrete provider
+ adapter owns a narrowly-scoped normalization path
+ - GLM/minimax/perl/XML-style dialects are not common-runtime features
+
+3. **Model/provider request knowledge moved further out of Rust hot paths**
+ - fixed OpenAI temperature rules and reasoning-effort aliases now resolve
+ through `model_catalog.json` request policies
+ - OpenAI, OpenAI Codex, OpenRouter, and generic OpenAI-compatible request
+ controls no longer depend on broad model-family match arms
+ - configured OpenAI-compatible `/models` refresh endpoints now derive from
+ the effective `api_url`, so native/aggregator/local endpoints do not share
+ stale default-provider discovery assumptions
+
+4. **Hermes parity is closer on context safety**
+ - model context is treated as input plus reserved output
+ - compression threshold and hard ceiling scale by trusted window ratio
+ - large-window to small-window switches preflight against the target route
+ - web/channel model-switch and command semantics share the same runtime
+ command/effect/preflight surfaces where lifecycle allows
+
+5. **Memory hygiene is substantially better but still not fully done**
+ - old `Children learn_from Parents`-style generic graph pollution is blocked
+ through governor/extractor gates rather than phrase lists
+ - raw conversation autosave and consolidation now share one governor path
+ - remaining risk is concept-heavy long-dialogue ranking after real
+ compaction, not the already-fixed string-pattern examples
+
+Still open after this audit:
+
+1. **No pluggable `ContextEngine` interface yet**
+ - this is intentionally deferred until the current domain pressure
+ service/port boundary is stable enough to avoid a parallel context system
+
+2. **Provider-native continuation remains live-unvalidated**
+ - `openai-codex` scaffolding exists, but the deployed Codex backend rejected
+ `previous_response_id`; an official/custom key-based Responses endpoint
+ still needs validation
+
+3. **Slice 18 remains partial**
+ - endpoint-aware cache and `/models` refresh are in place
+ - no external models.dev-style registry source yet
+ - no active probe-down request ladder for unknown/local endpoints yet
+
+4. **Reasoning controls are policy-backed but not fully autonomous**
+ - request controls now resolve from catalog request policies
+ - fuller lane/cost-aware reasoning selection and provider-native
+ `reasoning_details` preservation remain future work
+
+5. **Long-dialogue quality still needs the expensive closeout run**
+ - focused cheap-route semantic run passed without procedural skill growth
+ - it did not produce a real compaction row, so compaction-plus-ranking still
+ needs validation at a slice-close point
+
### What is already moving in the right direction
1. `MEMORY.md` is no longer part of runtime bootstrap or scaffolding.
diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md
index 75f2d7f0..8565d159 100644
--- a/docs/fork/ipc-phase4_10-plan.md
+++ b/docs/fork/ipc-phase4_10-plan.md
@@ -603,6 +603,10 @@ Expected outcome:
- follow-through: configured AIEOS identity now fails loud when the JSON
source is missing, invalid, or renders empty instead of silently mixing in
OpenClaw `IDENTITY.md`
+ - follow-through: dead shared presentation/history cleanup helpers that
+ stripped XML/tool JSON artifacts were removed; shared runtime still flags
+ raw `` output as a defect, with only concrete provider adapters
+ allowed to normalize provider-specific raw envelopes.
- Slice 10 groundwork:
- lane candidate schema and manual profile metadata
- preset expansion (`chatgpt`, `claude`, `openrouter`, `gemini`, `local`)
@@ -635,6 +639,9 @@ Expected outcome:
- `/model` help now resolves effective lanes through the same runtime
lane resolver, so implicit reasoning fallbacks are visible instead of
only explicit config lanes
+ - provider request quirks such as fixed temperature and accepted reasoning
+ effort aliases now live in editable model-catalog request policies, not
+ adapter-local Rust match arms
- status note: this is not a full Slice 10 close; remaining Slice 10 work
is now mostly registry/profile clean-up, adapter-heuristic shrinkage, and
keeping lane/candidate-first explanations consistent as Slice 14/18
@@ -991,9 +998,11 @@ Expected outcome:
domain pressure service/port boundary is stable enough to avoid creating
a parallel context subsystem
- reasoning-control follow-through:
- - promote provider reasoning controls from global runtime override to a
- capability/lane policy once the model-profile registry exposes support
- and provider cost tradeoffs consistently
+ - follow-up narrowed: provider request controls now resolve through
+ catalog-owned request policies for OpenAI-compatible, OpenAI Codex, and
+ OpenRouter paths instead of model-name prefix/match arms; remaining work
+ is a fuller lane/cost-aware decision model as provider metadata becomes
+ richer
- add full `reasoning_details` preservation when the shared provider
response/history model can carry provider-native reasoning blocks without
leaking adapter-specific shapes into the core runtime
@@ -1246,8 +1255,10 @@ Expected outcome:
first-class turn routing are now mostly owned by Slice 14 and the shared
marker/admission path, not by a separate Slice 10 routing fork
- provider capability metadata is still narrower than the eventual lane matrix
- - a few provider-local model-family heuristics still remain adapter-side
- (for example reasoning-effort clamps)
+ - known request-policy cases for fixed temperature and reasoning effort
+ aliases now resolve from `model_catalog.json`; keep auditing future
+ provider request quirks so they enter through catalog policy or
+ adapter-local wire normalization, not broad model-family match arms
- route state stores lane/candidate identity, and route-switch UX now renders lane
for lane-aware switches; downstream runtime surfaces should keep moving toward
lane/candidate-first explanations
@@ -1888,13 +1899,20 @@ Expected outcome:
unknown context windows, never raises them
- web `Agent` and channel `AgentRuntimePort` now record those observations
through the same `WorkspaceModelProfileCatalog` cache path
+ - `models refresh` now honors configured OpenAI-compatible `api_url`
+ endpoints when building `/models` requests, so native vs aggregator vs
+ local-compatible endpoints do not share stale default-provider discovery
+ assumptions
- still open after Hermes source audit:
- no models.dev-style provider-aware registry source yet
- - no explicit probe-down tier strategy for unknown/local endpoints yet
+ - no active probe-down request ladder for unknown/local endpoints yet; the
+ current explicit strategy is endpoint-aware `/models` refresh first, then
+ typed context-limit observations from failed turns, then operator catalog
+ override for proactive routing
- status note:
- treat Slice 18 as partial: catalog/cache/refresh/endpoint-aware lookup are
- landed, but external registry ingestion and explicit unknown-endpoint
- probe-down strategy are not complete.
+ landed, but external registry ingestion and active unknown-endpoint
+ probe-down requests are not complete.
- Hermes-derived model-window resolver follow-through:
- keep explicit user override first
- completed: persistent live model cache lookup is now endpoint-aware, so the
@@ -1909,6 +1927,9 @@ Expected outcome:
- completed: persist failed-turn context-limit observations as typed
endpoint-aware profile/cache updates when a trustworthy lower window can be
inferred
+ - completed: configured OpenAI-compatible refresh endpoints are derived from
+ the effective `api_url` instead of silently using the built-in provider
+ default endpoint
- keep broad hardcoded family fallbacks out of core Rust; if unavoidable,
they belong in local/bundled catalog data with provenance and freshness
- consider a probe-down tier strategy only as an explicit adapter fallback
From 20bf335c96a0cf3a8423ea3d33281d0e4f725d7b Mon Sep 17 00:00:00 2001
From: panviktor <35385675+panviktor@users.noreply.github.com>
Date: Sun, 12 Apr 2026 20:06:12 +0200
Subject: [PATCH 02/30] Remove legacy route fallbacks from lane selection
---
crates/adapters/core/src/channels/mod.rs | 59 ++--
crates/adapters/core/src/gateway/ws.rs | 12 +-
.../core/src/runtime_adapter_contract.rs | 10 +-
crates/adapters/core/src/runtime_routes.rs | 51 +--
.../services/inbound_message_service.rs | 321 ++++++++++++------
.../services/model_lane_resolution.rs | 161 +++------
.../services/runtime_command_presentation.rs | 1 +
.../services/summary_route_resolution.rs | 106 +++---
.../use_cases/handle_inbound_message.rs | 55 +--
crates/domain/src/config/schema.rs | 27 +-
docs/fork/ipc-phase4_10-audit.md | 18 +
docs/fork/ipc-phase4_10-plan.md | 20 +-
12 files changed, 476 insertions(+), 365 deletions(-)
diff --git a/crates/adapters/core/src/channels/mod.rs b/crates/adapters/core/src/channels/mod.rs
index 35f4761d..14844764 100644
--- a/crates/adapters/core/src/channels/mod.rs
+++ b/crates/adapters/core/src/channels/mod.rs
@@ -3433,16 +3433,19 @@ mod tests {
);
ctx.default_provider = Arc::new("openrouter".to_string());
ctx.model = Arc::new("large-model".to_string());
- ctx.model_routes = Arc::new(vec![synapse_domain::config::schema::ModelRouteConfig {
- hint: "tiny".to_string(),
- capability: None,
- provider: "openrouter".to_string(),
- model: "tiny-model".to_string(),
- api_key: None,
- profile: synapse_domain::config::schema::ModelCandidateProfileConfig {
- context_window_tokens: Some(1_000),
- ..Default::default()
- },
+ ctx.model_lanes = Arc::new(vec![synapse_domain::config::schema::ModelLaneConfig {
+ lane: synapse_domain::config::schema::CapabilityLane::Reasoning,
+ candidates: vec![synapse_domain::config::schema::ModelLaneCandidateConfig {
+ provider: "openrouter".to_string(),
+ model: "tiny-model".to_string(),
+ api_key: None,
+ api_key_env: None,
+ dimensions: None,
+ profile: synapse_domain::config::schema::ModelCandidateProfileConfig {
+ context_window_tokens: Some(1_000),
+ ..Default::default()
+ },
+ }],
}]);
ctx.conversation_histories.lock().unwrap().insert(
"sender".to_string(),
@@ -3453,7 +3456,8 @@ mod tests {
&synapse_domain::application::services::inbound_message_service::CommandEffect::SwitchModel {
model: "tiny-model".to_string(),
inferred_provider: Some("openrouter".to_string()),
- lane: None,
+ lane: Some(synapse_domain::config::schema::CapabilityLane::Reasoning),
+ candidate_index: Some(0),
compacted: false,
},
&ctx,
@@ -3473,16 +3477,19 @@ mod tests {
);
ctx.default_provider = Arc::new("openrouter".to_string());
ctx.model = Arc::new("large-model".to_string());
- ctx.model_routes = Arc::new(vec![synapse_domain::config::schema::ModelRouteConfig {
- hint: "compact".to_string(),
- capability: None,
- provider: "openrouter".to_string(),
- model: "compact-model".to_string(),
- api_key: None,
- profile: synapse_domain::config::schema::ModelCandidateProfileConfig {
- context_window_tokens: Some(8_000),
- ..Default::default()
- },
+ ctx.model_lanes = Arc::new(vec![synapse_domain::config::schema::ModelLaneConfig {
+ lane: synapse_domain::config::schema::CapabilityLane::Reasoning,
+ candidates: vec![synapse_domain::config::schema::ModelLaneCandidateConfig {
+ provider: "openrouter".to_string(),
+ model: "compact-model".to_string(),
+ api_key: None,
+ api_key_env: None,
+ dimensions: None,
+ profile: synapse_domain::config::schema::ModelCandidateProfileConfig {
+ context_window_tokens: Some(8_000),
+ ..Default::default()
+ },
+ }],
}]);
ctx.conversation_histories.lock().unwrap().insert(
"sender".to_string(),
@@ -3495,7 +3502,8 @@ mod tests {
&synapse_domain::application::services::inbound_message_service::CommandEffect::SwitchModel {
model: "compact-model".to_string(),
inferred_provider: Some("openrouter".to_string()),
- lane: None,
+ lane: Some(synapse_domain::config::schema::CapabilityLane::Reasoning),
+ candidate_index: Some(0),
compacted: false,
},
&ctx,
@@ -3508,7 +3516,7 @@ mod tests {
synapse_domain::application::services::runtime_command_presentation::format_switch_model_success(
"compact-model",
"openrouter",
- None,
+ Some(synapse_domain::config::schema::CapabilityLane::Reasoning),
true,
&synapse_domain::application::services::runtime_command_presentation::RuntimeCommandPresentationOptions::new("openrouter"),
)
@@ -3522,6 +3530,11 @@ mod tests {
.expect("route set");
assert_eq!(route.provider, "openrouter");
assert_eq!(route.model, "compact-model");
+ assert_eq!(
+ route.lane,
+ Some(synapse_domain::config::schema::CapabilityLane::Reasoning)
+ );
+ assert_eq!(route.candidate_index, Some(0));
assert_eq!(
ctx.conversation_histories
.lock()
diff --git a/crates/adapters/core/src/gateway/ws.rs b/crates/adapters/core/src/gateway/ws.rs
index 054dfc3b..50e620e5 100644
--- a/crates/adapters/core/src/gateway/ws.rs
+++ b/crates/adapters/core/src/gateway/ws.rs
@@ -1230,12 +1230,14 @@ async fn handle_runtime_command_if_needed(
let config_snapshot = state.config.lock().clone();
let effect = synapse_domain::application::services::inbound_message_service::command_effect(
&command,
- &config_snapshot.model_routes,
+ &config_snapshot,
);
- let default_provider = config_snapshot
- .default_provider
- .clone()
- .unwrap_or_else(|| "openrouter".to_string());
+ let default_provider = config_snapshot.default_provider.clone().unwrap_or_else(|| {
+ current_web_route_selection(state, session_key)
+ .ok()
+ .map(|route| route.provider)
+ .unwrap_or_default()
+ });
let adapter_contract = WebRuntimeAdapterContract;
let mut command_host = WebRuntimeCommandHost {
state,
diff --git a/crates/adapters/core/src/runtime_adapter_contract.rs b/crates/adapters/core/src/runtime_adapter_contract.rs
index b8539ee1..c8b1d8ad 100644
--- a/crates/adapters/core/src/runtime_adapter_contract.rs
+++ b/crates/adapters/core/src/runtime_adapter_contract.rs
@@ -109,6 +109,11 @@ impl RuntimeRouteMutationRequest {
..Self::default()
}
}
+
+ fn with_candidate_index(mut self, candidate_index: Option) -> Self {
+ self.candidate_index = candidate_index;
+ self
+ }
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -206,6 +211,7 @@ where
model,
inferred_provider,
lane,
+ candidate_index,
compacted,
} => {
let provider = inferred_provider
@@ -221,7 +227,8 @@ where
));
}
let request =
- RuntimeRouteMutationRequest::model(provider.clone(), model.clone(), *lane);
+ RuntimeRouteMutationRequest::model(provider.clone(), model.clone(), *lane)
+ .with_candidate_index(*candidate_index);
match host.switch_model(request, *compacted).await {
Ok(RuntimeModelSwitchOutcome::Applied {
provider,
@@ -415,6 +422,7 @@ mod tests {
model: "vision-model".into(),
inferred_provider: Some("openrouter".into()),
lane: Some(CapabilityLane::MultimodalUnderstanding),
+ candidate_index: None,
compacted: false,
},
CommandEffect::SwitchModelBlocked {
diff --git a/crates/adapters/core/src/runtime_routes.rs b/crates/adapters/core/src/runtime_routes.rs
index a5c5fd74..451a8d49 100644
--- a/crates/adapters/core/src/runtime_routes.rs
+++ b/crates/adapters/core/src/runtime_routes.rs
@@ -293,7 +293,6 @@ impl ModelProfileCatalogPort for WorkspaceModelProfileCatalog {
pub(crate) fn build_models_help_response(current: &RouteSelection, config: &Config) -> String {
let workspace_dir = config.workspace_dir.as_path();
- let model_routes = &config.model_routes;
let mut response = String::new();
let _ = writeln!(
response,
@@ -458,25 +457,7 @@ pub(crate) fn build_models_help_response(current: &RouteSelection, config: &Conf
}
}
- if !model_routes.is_empty() {
- response.push_str("\nConfigured model routes:\n");
- for route in model_routes {
- let _ = writeln!(
- response,
- " `{}` → {} ({})",
- route.hint, route.model, route.provider
- );
- }
- }
-
- let catalog_aliases = synapse_domain::config::model_catalog::model_route_aliases()
- .into_iter()
- .filter(|alias| {
- !model_routes
- .iter()
- .any(|route| route.hint.eq_ignore_ascii_case(alias.hint.as_str()))
- })
- .collect::>();
+ let catalog_aliases = synapse_domain::config::model_catalog::model_route_aliases();
if !catalog_aliases.is_empty() {
response.push_str("\nCatalog model aliases:\n");
for route in catalog_aliases.iter().take(12) {
@@ -1060,7 +1041,6 @@ mod tests {
};
use synapse_domain::config::schema::{
Config, ModelCandidateProfileConfig, ModelLaneCandidateConfig, ModelLaneConfig,
- ModelRouteConfig,
};
use synapse_domain::domain::tool_repair::{ToolFailureKind, ToolRepairAction, ToolRepairTrace};
use synapse_domain::domain::turn_admission::{
@@ -1137,24 +1117,27 @@ mod tests {
}
#[test]
- fn models_help_includes_configured_routes() {
+ fn models_help_includes_effective_lanes_and_catalog_aliases() {
let workspace = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.workspace_dir = workspace.path().to_path_buf();
- config.model_routes = vec![ModelRouteConfig {
- hint: "cheap".into(),
- provider: "openrouter".into(),
- model: "qwen/qwen3.6-plus".into(),
- api_key: None,
- capability: None,
- profile: Default::default(),
+ config.model_lanes = vec![ModelLaneConfig {
+ lane: CapabilityLane::CheapReasoning,
+ candidates: vec![ModelLaneCandidateConfig {
+ provider: "test-provider".into(),
+ model: "test-model".into(),
+ api_key: None,
+ api_key_env: None,
+ dimensions: None,
+ profile: Default::default(),
+ }],
}];
let response = build_models_help_response(
&RouteSelection {
- provider: "openrouter".into(),
- model: "qwen/qwen3.6-plus".into(),
- lane: None,
- candidate_index: None,
+ provider: "test-provider".into(),
+ model: "test-model".into(),
+ lane: Some(CapabilityLane::CheapReasoning),
+ candidate_index: Some(0),
last_admission: None,
recent_admissions: Vec::new(),
last_tool_repair: None,
@@ -1167,7 +1150,7 @@ mod tests {
},
&config,
);
- assert!(response.contains("`cheap` → qwen/qwen3.6-plus (openrouter)"));
+ assert!(response.contains("`cheap_reasoning` → test-model (test-provider)"));
assert!(response.contains("Profile sources:"));
assert!(response.contains("Current route limits:"));
assert!(response.contains("Catalog model aliases:"));
diff --git a/crates/domain/src/application/services/inbound_message_service.rs b/crates/domain/src/application/services/inbound_message_service.rs
index 5e47d0f8..12575ac7 100644
--- a/crates/domain/src/application/services/inbound_message_service.rs
+++ b/crates/domain/src/application/services/inbound_message_service.rs
@@ -10,8 +10,9 @@
//! - message classification (command vs regular message)
//! - route selection management (provider/model overrides per sender)
+use crate::application::services::model_preset_resolution::resolve_effective_model_lanes;
use crate::application::services::route_switch_preflight::RouteSwitchPreflight;
-use crate::config::schema::{CapabilityLane, ModelRouteConfig};
+use crate::config::schema::{CapabilityLane, Config};
use crate::domain::channel::{ChannelCapability, InboundEnvelope};
// ── Runtime commands ─────────────────────────────────────────────
@@ -291,10 +292,12 @@ pub enum CommandEffect {
/// Switch to a specific model, optionally with inferred provider from routes.
SwitchModel {
model: String,
- /// If a model_route matched, the coupled provider.
+ /// If a lane or catalog alias matched, the coupled provider.
inferred_provider: Option,
- /// If a model_route matched a capability lane, preserve it in route state.
+ /// If a lane or catalog alias matched a capability lane, preserve it in route state.
lane: Option,
+ /// Ordered lane candidate index when the selector matched a lane candidate.
+ candidate_index: Option,
/// Whether provider-facing context was compacted before the switch.
compacted: bool,
},
@@ -312,11 +315,25 @@ pub enum CommandEffect {
/// Determine the effect of a runtime command.
///
-/// Resolves model routes (model name → provider) but leaves
+/// Resolves lane/catalog routes (model selector → provider) but leaves
/// provider validation to the adapter (requires infrastructure).
-pub fn command_effect(
+pub fn command_effect(command: &RuntimeCommand, config: &Config) -> CommandEffect {
+ command_effect_with_alias_resolver(command, config, |value| {
+ crate::config::model_catalog::model_route_alias(value).map(|route| {
+ ResolvedModelCommandRoute {
+ provider: route.provider,
+ model: route.model,
+ lane: route.capability,
+ candidate_index: None,
+ }
+ })
+ })
+}
+
+fn command_effect_with_alias_resolver(
command: &RuntimeCommand,
- model_routes: &[ModelRouteConfig],
+ config: &Config,
+ alias_resolver: impl Fn(&str) -> Option,
) -> CommandEffect {
match command {
RuntimeCommand::ShowProviders => CommandEffect::ShowProviders,
@@ -325,27 +342,22 @@ pub fn command_effect(
},
RuntimeCommand::ShowModel => CommandEffect::ShowModel,
RuntimeCommand::SetModel(raw) => {
- let model = raw.trim().trim_matches('`').to_string();
- // Look up in model_routes by model name or hint
- let matched = model_routes
- .iter()
- .find(|route| {
- route.model.eq_ignore_ascii_case(&model)
- || route.hint.eq_ignore_ascii_case(&model)
- })
- .cloned()
- .or_else(|| crate::config::model_catalog::model_route_alias(&model));
+ let model = normalize_model_selector(raw);
+ let matched =
+ resolve_model_command_route_with_alias_resolver(&model, config, alias_resolver);
match matched {
Some(route) => CommandEffect::SwitchModel {
model: route.model,
inferred_provider: Some(route.provider),
- lane: route.capability,
+ lane: route.lane,
+ candidate_index: route.candidate_index,
compacted: false,
},
None => CommandEffect::SwitchModel {
model,
inferred_provider: None,
lane: None,
+ candidate_index: None,
compacted: false,
},
}
@@ -354,12 +366,112 @@ pub fn command_effect(
}
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ResolvedModelCommandRoute {
+ pub provider: String,
+ pub model: String,
+ pub lane: Option,
+ pub candidate_index: Option,
+}
+
+pub fn resolve_model_command_route(
+ selector: &str,
+ config: &Config,
+) -> Option {
+ resolve_model_command_route_with_alias_resolver(selector, config, |value| {
+ crate::config::model_catalog::model_route_alias(value).map(|route| {
+ ResolvedModelCommandRoute {
+ provider: route.provider,
+ model: route.model,
+ lane: route.capability,
+ candidate_index: None,
+ }
+ })
+ })
+}
+
+fn resolve_model_command_route_with_alias_resolver(
+ selector: &str,
+ config: &Config,
+ alias_resolver: impl Fn(&str) -> Option,
+) -> Option {
+ let selector = normalize_model_selector(selector);
+ if selector.is_empty() {
+ return None;
+ }
+
+ let effective_lanes = resolve_effective_model_lanes(config);
+ if let Ok(requested_lane) = selector.parse::() {
+ if let Some((lane, candidate)) = effective_lanes
+ .iter()
+ .find(|lane| lane.lane == requested_lane)
+ .and_then(|lane| {
+ lane.candidates
+ .first()
+ .map(|candidate| (lane.lane, candidate))
+ })
+ {
+ return Some(ResolvedModelCommandRoute {
+ provider: candidate.provider.clone(),
+ model: candidate.model.clone(),
+ lane: Some(lane),
+ candidate_index: Some(0),
+ });
+ }
+ }
+
+ for lane in &effective_lanes {
+ if let Some((index, candidate)) = lane
+ .candidates
+ .iter()
+ .enumerate()
+ .find(|(_, candidate)| model_selector_matches_candidate(&selector, candidate))
+ {
+ return Some(ResolvedModelCommandRoute {
+ provider: candidate.provider.clone(),
+ model: candidate.model.clone(),
+ lane: Some(lane.lane),
+ candidate_index: Some(index),
+ });
+ }
+ }
+
+ alias_resolver(&selector)
+}
+
+fn model_selector_matches_candidate(
+ selector: &str,
+ candidate: &crate::config::schema::ModelLaneCandidateConfig,
+) -> bool {
+ candidate.model.eq_ignore_ascii_case(selector)
+ || provider_model_selector(candidate.provider.as_str(), candidate.model.as_str())
+ .eq_ignore_ascii_case(selector)
+}
+
+fn provider_model_selector(provider: &str, model: &str) -> String {
+ format!("{}:{}", provider.trim(), model.trim())
+}
+
+fn normalize_model_selector(value: &str) -> String {
+ value.trim().trim_matches('`').to_string()
+}
+
#[cfg(test)]
mod tests {
use super::*;
- use crate::config::schema::ModelCandidateProfileConfig;
+ use crate::config::schema::{
+ ModelCandidateProfileConfig, ModelLaneCandidateConfig, ModelLaneConfig,
+ };
use crate::domain::channel::SourceKind;
+ const TEST_PROVIDER: &str = "test-provider";
+ const TEST_MAIN_MODEL: &str = "test-main-model";
+ const TEST_VISION_PROVIDER: &str = "test-vision-provider";
+ const TEST_VISION_MODEL: &str = "test-vision-model";
+ const TEST_ALIAS: &str = "test-alias";
+ const TEST_ALIAS_PROVIDER: &str = "test-alias-provider";
+ const TEST_ALIAS_MODEL: &str = "test-alias-model";
+
fn caps_with_runtime() -> Vec {
vec![
ChannelCapability::SendText,
@@ -381,8 +493,11 @@ mod tests {
#[test]
fn parse_models_set_provider() {
- let cmd = parse_runtime_command("/models anthropic", &caps_with_runtime());
- assert_eq!(cmd, Some(RuntimeCommand::SetProvider("anthropic".into())));
+ let cmd = parse_runtime_command("/models test-provider", &caps_with_runtime());
+ assert_eq!(
+ cmd,
+ Some(RuntimeCommand::SetProvider("test-provider".into()))
+ );
}
#[test]
@@ -393,11 +508,8 @@ mod tests {
#[test]
fn parse_model_set() {
- let cmd = parse_runtime_command("/model claude-sonnet-4-5", &caps_with_runtime());
- assert_eq!(
- cmd,
- Some(RuntimeCommand::SetModel("claude-sonnet-4-5".into()))
- );
+ let cmd = parse_runtime_command("/model test-model", &caps_with_runtime());
+ assert_eq!(cmd, Some(RuntimeCommand::SetModel("test-model".into())));
}
#[test]
@@ -658,43 +770,63 @@ mod tests {
// ── command effect tests ─────────────────────────────────────
- fn test_routes() -> Vec {
- vec![
- ModelRouteConfig {
- hint: "opus".into(),
- capability: None,
- provider: "anthropic".into(),
- model: "claude-sonnet-4-5".into(),
- api_key: None,
- profile: ModelCandidateProfileConfig::default(),
+ fn command_test_config() -> Config {
+ let mut config = Config::default();
+ config.model_lanes = vec![
+ ModelLaneConfig {
+ lane: CapabilityLane::Reasoning,
+ candidates: vec![ModelLaneCandidateConfig {
+ provider: TEST_PROVIDER.into(),
+ model: TEST_MAIN_MODEL.into(),
+ api_key: None,
+ api_key_env: None,
+ dimensions: None,
+ profile: ModelCandidateProfileConfig::default(),
+ }],
},
- ModelRouteConfig {
- hint: "gpt4".into(),
- capability: None,
- provider: "openai".into(),
- model: "gpt-5.4".into(),
- api_key: None,
- profile: ModelCandidateProfileConfig::default(),
+ ModelLaneConfig {
+ lane: CapabilityLane::MultimodalUnderstanding,
+ candidates: vec![ModelLaneCandidateConfig {
+ provider: TEST_VISION_PROVIDER.into(),
+ model: TEST_VISION_MODEL.into(),
+ api_key: None,
+ api_key_env: None,
+ dimensions: None,
+ profile: ModelCandidateProfileConfig::default(),
+ }],
},
- ]
+ ];
+ config
+ }
+
+ fn test_alias_resolver(value: &str) -> Option {
+ value
+ .eq_ignore_ascii_case(TEST_ALIAS)
+ .then(|| ResolvedModelCommandRoute {
+ provider: TEST_ALIAS_PROVIDER.into(),
+ model: TEST_ALIAS_MODEL.into(),
+ lane: Some(CapabilityLane::CheapReasoning),
+ candidate_index: None,
+ })
+ }
+
+ fn command_effect_for_test(command: &RuntimeCommand) -> CommandEffect {
+ command_effect_with_alias_resolver(command, &command_test_config(), test_alias_resolver)
}
#[test]
fn effect_show_providers() {
let cmd = RuntimeCommand::ShowProviders;
- assert_eq!(
- command_effect(&cmd, &test_routes()),
- CommandEffect::ShowProviders
- );
+ assert_eq!(command_effect_for_test(&cmd), CommandEffect::ShowProviders);
}
#[test]
fn effect_switch_provider() {
- let cmd = RuntimeCommand::SetProvider("anthropic".into());
+ let cmd = RuntimeCommand::SetProvider(TEST_PROVIDER.into());
assert_eq!(
- command_effect(&cmd, &test_routes()),
+ command_effect_for_test(&cmd),
CommandEffect::SwitchProvider {
- provider: "anthropic".into()
+ provider: TEST_PROVIDER.into()
}
);
}
@@ -702,21 +834,19 @@ mod tests {
#[test]
fn effect_show_model() {
let cmd = RuntimeCommand::ShowModel;
- assert_eq!(
- command_effect(&cmd, &test_routes()),
- CommandEffect::ShowModel
- );
+ assert_eq!(command_effect_for_test(&cmd), CommandEffect::ShowModel);
}
#[test]
- fn effect_switch_model_by_hint() {
- let cmd = RuntimeCommand::SetModel("opus".into());
+ fn effect_switch_model_by_lane_selector() {
+ let cmd = RuntimeCommand::SetModel("reasoning".into());
assert_eq!(
- command_effect(&cmd, &test_routes()),
+ command_effect_for_test(&cmd),
CommandEffect::SwitchModel {
- model: "claude-sonnet-4-5".into(),
- inferred_provider: Some("anthropic".into()),
- lane: None,
+ model: TEST_MAIN_MODEL.into(),
+ inferred_provider: Some(TEST_PROVIDER.into()),
+ lane: Some(CapabilityLane::Reasoning),
+ candidate_index: Some(0),
compacted: false,
}
);
@@ -724,13 +854,14 @@ mod tests {
#[test]
fn effect_switch_model_by_name() {
- let cmd = RuntimeCommand::SetModel("gpt-5.4".into());
+ let cmd = RuntimeCommand::SetModel(TEST_MAIN_MODEL.into());
assert_eq!(
- command_effect(&cmd, &test_routes()),
+ command_effect_for_test(&cmd),
CommandEffect::SwitchModel {
- model: "gpt-5.4".into(),
- inferred_provider: Some("openai".into()),
- lane: None,
+ model: TEST_MAIN_MODEL.into(),
+ inferred_provider: Some(TEST_PROVIDER.into()),
+ lane: Some(CapabilityLane::Reasoning),
+ candidate_index: Some(0),
compacted: false,
}
);
@@ -740,70 +871,57 @@ mod tests {
fn effect_switch_model_unknown() {
let cmd = RuntimeCommand::SetModel("custom-model".into());
assert_eq!(
- command_effect(&cmd, &test_routes()),
+ command_effect_for_test(&cmd),
CommandEffect::SwitchModel {
model: "custom-model".into(),
inferred_provider: None,
lane: None,
+ candidate_index: None,
compacted: false,
}
);
}
#[test]
- fn effect_switch_model_by_catalog_alias_when_config_route_missing() {
- let cmd = RuntimeCommand::SetModel("gemma31b".into());
+ fn effect_switch_model_by_catalog_alias_when_lane_missing() {
+ let cmd = RuntimeCommand::SetModel(TEST_ALIAS.into());
assert_eq!(
- command_effect(&cmd, &[]),
+ command_effect_for_test(&cmd),
CommandEffect::SwitchModel {
- model: "google/gemma-4-31b-it".into(),
- inferred_provider: Some("openrouter".into()),
- lane: None,
+ model: TEST_ALIAS_MODEL.into(),
+ inferred_provider: Some(TEST_ALIAS_PROVIDER.into()),
+ lane: Some(CapabilityLane::CheapReasoning),
+ candidate_index: None,
compacted: false,
}
);
}
#[test]
- fn effect_prefers_config_route_over_catalog_alias() {
- let cmd = RuntimeCommand::SetModel("gemma31b".into());
- let routes = vec![ModelRouteConfig {
- hint: "gemma31b".into(),
- capability: None,
- provider: "custom-provider".into(),
- model: "custom-gemma".into(),
- api_key: None,
- profile: ModelCandidateProfileConfig::default(),
- }];
+ fn effect_prefers_lane_candidate_over_catalog_alias() {
+ let cmd = RuntimeCommand::SetModel(TEST_MAIN_MODEL.into());
assert_eq!(
- command_effect(&cmd, &routes),
+ command_effect_for_test(&cmd),
CommandEffect::SwitchModel {
- model: "custom-gemma".into(),
- inferred_provider: Some("custom-provider".into()),
- lane: None,
+ model: TEST_MAIN_MODEL.into(),
+ inferred_provider: Some(TEST_PROVIDER.into()),
+ lane: Some(CapabilityLane::Reasoning),
+ candidate_index: Some(0),
compacted: false,
}
);
}
#[test]
- fn effect_switch_model_preserves_route_lane() {
- let cmd = RuntimeCommand::SetModel("vision".into());
- let routes = vec![ModelRouteConfig {
- hint: "vision".into(),
- capability: Some(CapabilityLane::MultimodalUnderstanding),
- provider: "openrouter".into(),
- model: "vision-model".into(),
- api_key: None,
- profile: ModelCandidateProfileConfig::default(),
- }];
-
+ fn effect_switch_model_preserves_lane_candidate() {
+ let cmd = RuntimeCommand::SetModel("multimodal_understanding".into());
assert_eq!(
- command_effect(&cmd, &routes),
+ command_effect_for_test(&cmd),
CommandEffect::SwitchModel {
- model: "vision-model".into(),
- inferred_provider: Some("openrouter".into()),
+ model: TEST_VISION_MODEL.into(),
+ inferred_provider: Some(TEST_VISION_PROVIDER.into()),
lane: Some(CapabilityLane::MultimodalUnderstanding),
+ candidate_index: Some(0),
compacted: false,
}
);
@@ -812,10 +930,7 @@ mod tests {
#[test]
fn effect_new_session() {
let cmd = RuntimeCommand::NewSession;
- assert_eq!(
- command_effect(&cmd, &test_routes()),
- CommandEffect::ClearSession
- );
+ assert_eq!(command_effect_for_test(&cmd), CommandEffect::ClearSession);
}
#[test]
diff --git a/crates/domain/src/application/services/model_lane_resolution.rs b/crates/domain/src/application/services/model_lane_resolution.rs
index ea586e63..23018152 100644
--- a/crates/domain/src/application/services/model_lane_resolution.rs
+++ b/crates/domain/src/application/services/model_lane_resolution.rs
@@ -1,9 +1,6 @@
use crate::application::services::model_capability_support::profile_supports_lane_confidently;
use crate::application::services::model_preset_resolution::resolve_effective_model_lanes;
-use crate::config::schema::{
- CapabilityLane, Config, EmbeddingRouteConfig, ModelCandidateProfileConfig, ModelFeature,
- ModelRouteConfig,
-};
+use crate::config::schema::{CapabilityLane, Config, ModelCandidateProfileConfig, ModelFeature};
use crate::ports::model_profile_catalog::{
CatalogModelProfile, CatalogModelProfileSource, ModelProfileCatalogPort,
};
@@ -14,8 +11,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub enum ModelLaneResolutionSource {
ExplicitLaneConfig,
ImplicitReasoningLane,
- LegacyModelRoute,
- LegacyEmbeddingRoute,
DefaultRoute,
}
@@ -23,8 +18,6 @@ pub fn model_lane_resolution_source_name(source: ModelLaneResolutionSource) -> &
match source {
ModelLaneResolutionSource::ExplicitLaneConfig => "explicit_lane_config",
ModelLaneResolutionSource::ImplicitReasoningLane => "implicit_reasoning_lane",
- ModelLaneResolutionSource::LegacyModelRoute => "legacy_model_route",
- ModelLaneResolutionSource::LegacyEmbeddingRoute => "legacy_embedding_route",
ModelLaneResolutionSource::DefaultRoute => "default_route",
}
}
@@ -189,45 +182,7 @@ pub fn resolve_lane_candidates(
}
}
- let legacy_model_routes = config
- .model_routes
- .iter()
- .filter(|route| route.capability == Some(lane) || legacy_route_matches_lane(route, lane))
- .map(|route| {
- resolve_candidate(
- ModelLaneResolutionSource::LegacyModelRoute,
- route.provider.as_str(),
- route.model.as_str(),
- route.api_key.clone(),
- None,
- None,
- &route.profile,
- catalog,
- )
- });
-
- let legacy_embedding_routes = config
- .embedding_routes
- .iter()
- .filter(|route| {
- route.capability == Some(lane) || legacy_embedding_route_matches_lane(route, lane)
- })
- .map(|route| {
- resolve_candidate(
- ModelLaneResolutionSource::LegacyEmbeddingRoute,
- route.provider.as_str(),
- route.model.as_str(),
- route.api_key.clone(),
- None,
- route.dimensions,
- &route.profile,
- catalog,
- )
- });
-
- let mut resolved = legacy_model_routes
- .chain(legacy_embedding_routes)
- .collect::>();
+ let mut resolved = Vec::new();
if resolved.is_empty() && lane_allows_implicit_reasoning_candidates(lane) {
resolved = resolve_lane_candidates(config, CapabilityLane::Reasoning, catalog)
@@ -241,11 +196,14 @@ pub fn resolve_lane_candidates(
}
if resolved.is_empty() && lane == CapabilityLane::Reasoning {
- if let Some(default_model) = config.default_model.clone() {
+ if let (Some(default_provider), Some(default_model)) = (
+ config.default_provider.as_deref(),
+ config.default_model.as_deref(),
+ ) {
resolved.push(resolve_candidate(
ModelLaneResolutionSource::DefaultRoute,
- config.default_provider.as_deref().unwrap_or("openrouter"),
- &default_model,
+ default_provider,
+ default_model,
config.api_key.clone(),
None,
None,
@@ -289,30 +247,18 @@ pub fn resolve_route_selection_profile(
}
}
- if let Some(route_match) = config
- .model_routes
- .iter()
- .find(|candidate| candidate.provider == route.provider && candidate.model == route.model)
- {
- return resolve_candidate_profile(
- route_match.provider.as_str(),
- route_match.model.as_str(),
- &route_match.profile,
- catalog,
- );
- }
-
- if let Some(route_match) = config
- .embedding_routes
- .iter()
- .find(|candidate| candidate.provider == route.provider && candidate.model == route.model)
- {
- return resolve_candidate_profile(
- route_match.provider.as_str(),
- route_match.model.as_str(),
- &route_match.profile,
- catalog,
- );
+ let effective_lanes = resolve_effective_model_lanes(config);
+ for lane in effective_lanes {
+ if let Some(candidate) = lane.candidates.iter().find(|candidate| {
+ candidate.provider == route.provider && candidate.model == route.model
+ }) {
+ return resolve_candidate_profile(
+ candidate.provider.as_str(),
+ candidate.model.as_str(),
+ &candidate.profile,
+ catalog,
+ );
+ }
}
resolve_candidate_profile(
@@ -487,22 +433,6 @@ fn current_unix_time() -> Option {
.map(|duration| duration.as_secs())
}
-fn legacy_route_matches_lane(route: &ModelRouteConfig, lane: CapabilityLane) -> bool {
- matches!(
- (lane, route.hint.as_str()),
- (CapabilityLane::CheapReasoning, "cheap")
- | (CapabilityLane::Reasoning, "reasoning")
- | (CapabilityLane::Reasoning, "main")
- )
-}
-
-fn legacy_embedding_route_matches_lane(route: &EmbeddingRouteConfig, lane: CapabilityLane) -> bool {
- matches!(
- (lane, route.hint.as_str()),
- (CapabilityLane::Embedding, "semantic")
- )
-}
-
fn lane_allows_implicit_reasoning_candidates(lane: CapabilityLane) -> bool {
matches!(
lane,
@@ -522,11 +452,16 @@ mod tests {
ModelLaneConfig, SummaryConfig,
};
+ const TEST_PROVIDER: &str = "test-provider";
+ const TEST_MODEL: &str = "test/reasoning-plus";
+ const TEST_DEFAULT_PROVIDER: &str = "test-default-provider";
+ const TEST_DEFAULT_MODEL: &str = "test-default-model";
+
struct StubCatalog;
impl ModelProfileCatalogPort for StubCatalog {
fn lookup_model_profile(&self, provider: &str, model: &str) -> Option {
- if provider == "openrouter" && model == "qwen/qwen3.6-plus" {
+ if provider == TEST_PROVIDER && model == TEST_MODEL {
return Some(CatalogModelProfile {
context_window_tokens: Some(200_000),
max_output_tokens: Some(32_000),
@@ -541,28 +476,20 @@ mod tests {
fn base_config() -> Config {
let mut config = Config::default();
- config.default_provider = Some("openai-codex".into());
- config.default_model = Some("gpt-5.4".into());
+ config.default_provider = Some(TEST_DEFAULT_PROVIDER.into());
+ config.default_model = Some(TEST_DEFAULT_MODEL.into());
config.summary = SummaryConfig::default();
config
}
#[test]
- fn explicit_lane_candidates_win_over_legacy_routes() {
+ fn explicit_lane_candidates_drive_capability_lanes() {
let mut config = base_config();
- config.model_routes.push(ModelRouteConfig {
- hint: "cheap".into(),
- capability: Some(CapabilityLane::CheapReasoning),
- provider: "openrouter".into(),
- model: "legacy-qwen".into(),
- api_key: None,
- profile: ModelCandidateProfileConfig::default(),
- });
config.model_lanes.push(ModelLaneConfig {
lane: CapabilityLane::CheapReasoning,
candidates: vec![ModelLaneCandidateConfig {
- provider: "openrouter".into(),
- model: "qwen/qwen3.6-plus".into(),
+ provider: TEST_PROVIDER.into(),
+ model: TEST_MODEL.into(),
api_key: None,
api_key_env: None,
dimensions: None,
@@ -589,12 +516,8 @@ mod tests {
features: vec![ModelFeature::Vision],
};
- let resolved = resolve_candidate_profile(
- "openrouter",
- "qwen/qwen3.6-plus",
- &manual,
- Some(&StubCatalog),
- );
+ let resolved =
+ resolve_candidate_profile(TEST_PROVIDER, TEST_MODEL, &manual, Some(&StubCatalog));
assert_eq!(resolved.context_window_tokens, Some(64_000));
assert_eq!(resolved.max_output_tokens, Some(32_000));
@@ -619,8 +542,8 @@ mod tests {
let resolved = resolve_lane_candidates(&config, CapabilityLane::Reasoning, None);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].source, ModelLaneResolutionSource::DefaultRoute);
- assert_eq!(resolved[0].provider, "openai-codex");
- assert_eq!(resolved[0].model, "gpt-5.4");
+ assert_eq!(resolved[0].provider, TEST_DEFAULT_PROVIDER);
+ assert_eq!(resolved[0].model, TEST_DEFAULT_MODEL);
}
#[test]
@@ -629,8 +552,8 @@ mod tests {
config.model_lanes.push(ModelLaneConfig {
lane: CapabilityLane::CheapReasoning,
candidates: vec![ModelLaneCandidateConfig {
- provider: "openrouter".into(),
- model: "qwen/qwen3.6-plus".into(),
+ provider: TEST_PROVIDER.into(),
+ model: TEST_MODEL.into(),
api_key: None,
api_key_env: None,
dimensions: None,
@@ -639,8 +562,8 @@ mod tests {
});
let route = RouteSelection {
- provider: "openrouter".into(),
- model: "qwen/qwen3.6-plus".into(),
+ provider: TEST_PROVIDER.into(),
+ model: TEST_MODEL.into(),
lane: Some(CapabilityLane::CheapReasoning),
candidate_index: Some(0),
last_admission: None,
@@ -711,7 +634,7 @@ mod tests {
config.model_lanes.push(ModelLaneConfig {
lane: CapabilityLane::Reasoning,
candidates: vec![ModelLaneCandidateConfig {
- provider: "openrouter".into(),
+ provider: TEST_PROVIDER.into(),
model: "universal-model".into(),
api_key: None,
api_key_env: None,
@@ -736,7 +659,7 @@ mod tests {
resolved[0].source,
ModelLaneResolutionSource::ImplicitReasoningLane
);
- assert_eq!(resolved[0].provider, "openrouter");
+ assert_eq!(resolved[0].provider, TEST_PROVIDER);
assert_eq!(resolved[0].model, "universal-model");
}
@@ -746,7 +669,7 @@ mod tests {
config.model_lanes.push(ModelLaneConfig {
lane: CapabilityLane::Reasoning,
candidates: vec![ModelLaneCandidateConfig {
- provider: "openrouter".into(),
+ provider: TEST_PROVIDER.into(),
model: "plain-model".into(),
api_key: None,
api_key_env: None,
diff --git a/crates/domain/src/application/services/runtime_command_presentation.rs b/crates/domain/src/application/services/runtime_command_presentation.rs
index 54a87e8e..be7e5484 100644
--- a/crates/domain/src/application/services/runtime_command_presentation.rs
+++ b/crates/domain/src/application/services/runtime_command_presentation.rs
@@ -56,6 +56,7 @@ pub fn format_common_command_effect(
model,
inferred_provider,
lane,
+ candidate_index: _,
compacted,
} => Some(format_switch_model_success(
model,
diff --git a/crates/domain/src/application/services/summary_route_resolution.rs b/crates/domain/src/application/services/summary_route_resolution.rs
index d8ef1750..ec9d85f0 100644
--- a/crates/domain/src/application/services/summary_route_resolution.rs
+++ b/crates/domain/src/application/services/summary_route_resolution.rs
@@ -33,7 +33,6 @@ pub struct SummaryRouteResolution {
pub fn resolve_summary_route(config: &Config, current_model: &str) -> SummaryRouteResolution {
let summary = &config.summary;
let summary_model = config.summary_model.as_deref();
- let model_routes = &config.model_routes;
let explicit_summary_config =
summary.provider.is_some() || summary.model.is_some() || summary.api_key_env.is_some();
@@ -75,17 +74,6 @@ pub fn resolve_summary_route(config: &Config, current_model: &str) -> SummaryRou
};
}
- if let Some(route) = model_routes.iter().find(|route| route.hint == "cheap") {
- return SummaryRouteResolution {
- source: SummaryRouteSource::CheapRoute,
- provider: Some(route.provider.clone()),
- model: route.model.clone(),
- temperature: summary.temperature,
- api_key: route.api_key.clone(),
- api_key_env: None,
- };
- }
-
SummaryRouteResolution {
source: SummaryRouteSource::CurrentRoute,
provider: None,
@@ -101,103 +89,117 @@ mod tests {
use super::*;
use crate::config::schema::{
Config, ModelCandidateProfileConfig, ModelLaneCandidateConfig, ModelLaneConfig,
- ModelRouteConfig, SummaryConfig,
+ SummaryConfig,
};
- fn cheap_route() -> ModelRouteConfig {
- ModelRouteConfig {
- hint: "cheap".into(),
- capability: Some(CapabilityLane::CheapReasoning),
- provider: "openrouter".into(),
- model: "qwen/qwen3.6-plus".into(),
- api_key: Some("test-key".into()),
- profile: ModelCandidateProfileConfig::default(),
+ const TEST_SUMMARY_PROVIDER: &str = "test-summary-provider";
+ const TEST_SUMMARY_MODEL: &str = "test-summary-model";
+ const TEST_CURRENT_MODEL: &str = "test-current-model";
+ const TEST_EXPLICIT_PROVIDER: &str = "test-explicit-provider";
+ const TEST_EXPLICIT_MODEL: &str = "test-explicit-model";
+
+ fn summary_lane_fixture() -> ModelLaneConfig {
+ ModelLaneConfig {
+ lane: CapabilityLane::CheapReasoning,
+ candidates: vec![ModelLaneCandidateConfig {
+ provider: TEST_SUMMARY_PROVIDER.into(),
+ model: TEST_SUMMARY_MODEL.into(),
+ api_key: Some("test-key".into()),
+ api_key_env: None,
+ dimensions: None,
+ profile: ModelCandidateProfileConfig::default(),
+ }],
}
}
fn base_config() -> Config {
let mut config = Config::default();
- config.default_model = Some("gpt-5.4".into());
+ config.default_model = Some(TEST_CURRENT_MODEL.into());
config
}
#[test]
- fn explicit_summary_config_wins_over_cheap_route() {
+ fn explicit_summary_config_wins_over_cheap_lane() {
let summary = SummaryConfig {
- provider: Some("anthropic".into()),
- model: Some("claude-haiku".into()),
+ provider: Some(TEST_EXPLICIT_PROVIDER.into()),
+ model: Some(TEST_EXPLICIT_MODEL.into()),
temperature: 0.2,
- api_key_env: Some("ANTHROPIC_API_KEY".into()),
+ api_key_env: Some("TEST_SUMMARY_API_KEY".into()),
};
let mut config = base_config();
config.summary = summary;
- config.model_routes = vec![cheap_route()];
+ config.model_lanes = vec![summary_lane_fixture()];
- let resolved = resolve_summary_route(&config, "gpt-5.4");
+ let resolved = resolve_summary_route(&config, TEST_CURRENT_MODEL);
assert_eq!(resolved.source, SummaryRouteSource::ExplicitSummaryConfig);
- assert_eq!(resolved.provider.as_deref(), Some("anthropic"));
- assert_eq!(resolved.model, "claude-haiku");
+ assert_eq!(resolved.provider.as_deref(), Some(TEST_EXPLICIT_PROVIDER));
+ assert_eq!(resolved.model, TEST_EXPLICIT_MODEL);
assert_eq!(resolved.temperature, 0.2);
- assert_eq!(resolved.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
+ assert_eq!(
+ resolved.api_key_env.as_deref(),
+ Some("TEST_SUMMARY_API_KEY")
+ );
}
#[test]
- fn summary_model_wins_over_cheap_route() {
+ fn summary_model_wins_over_cheap_lane() {
let mut config = base_config();
- config.summary_model = Some("gpt-5.4-mini".into());
- config.model_routes = vec![cheap_route()];
- let resolved = resolve_summary_route(&config, "gpt-5.4");
+ config.summary_model = Some("explicit-summary-model".into());
+ config.model_lanes = vec![summary_lane_fixture()];
+ let resolved = resolve_summary_route(&config, TEST_CURRENT_MODEL);
assert_eq!(resolved.source, SummaryRouteSource::ExplicitSummaryModel);
assert_eq!(resolved.provider, None);
- assert_eq!(resolved.model, "gpt-5.4-mini");
+ assert_eq!(resolved.model, "explicit-summary-model");
}
#[test]
- fn cheap_route_becomes_default_summary_lane() {
+ fn cheap_lane_becomes_default_summary_lane() {
let mut config = base_config();
- config.model_routes = vec![cheap_route()];
- let resolved = resolve_summary_route(&config, "gpt-5.4");
+ config.model_lanes = vec![summary_lane_fixture()];
+ let resolved = resolve_summary_route(&config, TEST_CURRENT_MODEL);
assert_eq!(resolved.source, SummaryRouteSource::CheapRoute);
- assert_eq!(resolved.provider.as_deref(), Some("openrouter"));
- assert_eq!(resolved.model, "qwen/qwen3.6-plus");
+ assert_eq!(resolved.provider.as_deref(), Some(TEST_SUMMARY_PROVIDER));
+ assert_eq!(resolved.model, TEST_SUMMARY_MODEL);
assert_eq!(resolved.api_key.as_deref(), Some("test-key"));
}
#[test]
fn current_route_is_final_fallback() {
let config = base_config();
- let resolved = resolve_summary_route(&config, "gpt-5.4");
+ let resolved = resolve_summary_route(&config, TEST_CURRENT_MODEL);
assert_eq!(resolved.source, SummaryRouteSource::CurrentRoute);
assert_eq!(resolved.provider, None);
- assert_eq!(resolved.model, "gpt-5.4");
+ assert_eq!(resolved.model, TEST_CURRENT_MODEL);
}
#[test]
- fn capability_lane_candidates_beat_legacy_cheap_route() {
+ fn capability_lane_candidate_provides_summary_route() {
let mut config = base_config();
- config.model_routes = vec![cheap_route()];
config.model_lanes = vec![ModelLaneConfig {
lane: CapabilityLane::CheapReasoning,
candidates: vec![ModelLaneCandidateConfig {
- provider: "anthropic".into(),
- model: "claude-haiku".into(),
+ provider: TEST_EXPLICIT_PROVIDER.into(),
+ model: TEST_EXPLICIT_MODEL.into(),
api_key: None,
- api_key_env: Some("ANTHROPIC_API_KEY".into()),
+ api_key_env: Some("TEST_SUMMARY_API_KEY".into()),
dimensions: None,
profile: ModelCandidateProfileConfig::default(),
}],
}];
- let resolved = resolve_summary_route(&config, "gpt-5.4");
+ let resolved = resolve_summary_route(&config, TEST_CURRENT_MODEL);
assert_eq!(resolved.source, SummaryRouteSource::CheapRoute);
- assert_eq!(resolved.provider.as_deref(), Some("anthropic"));
- assert_eq!(resolved.model, "claude-haiku");
- assert_eq!(resolved.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
+ assert_eq!(resolved.provider.as_deref(), Some(TEST_EXPLICIT_PROVIDER));
+ assert_eq!(resolved.model, TEST_EXPLICIT_MODEL);
+ assert_eq!(
+ resolved.api_key_env.as_deref(),
+ Some("TEST_SUMMARY_API_KEY")
+ );
}
}
diff --git a/crates/domain/src/application/use_cases/handle_inbound_message.rs b/crates/domain/src/application/use_cases/handle_inbound_message.rs
index 2b1818a7..17b0d1aa 100644
--- a/crates/domain/src/application/use_cases/handle_inbound_message.rs
+++ b/crates/domain/src/application/use_cases/handle_inbound_message.rs
@@ -188,7 +188,8 @@ pub async fn handle(
match classification {
MessageClassification::Command(cmd) => {
- let effect = inbound_message_service::command_effect(&cmd, &config.model_routes);
+ let routing_config = build_model_routing_config(config);
+ let effect = inbound_message_service::command_effect(&cmd, &routing_config);
// Apply state changes
match &effect {
@@ -251,14 +252,13 @@ async fn handle_regular_message(
// ── 3. Resolve route (with query classification override) ─────
let mut route = ports.routes.get_route(conversation_key);
+ let routing_config = build_model_routing_config(config);
// #4: Query classification override
if config.query_classifier.is_some() {
if let Some(hint) = config.query_classifier.as_ref().and_then(|f| f(content)) {
- if let Some(route_match) = config
- .model_routes
- .iter()
- .find(|route| route.hint.eq_ignore_ascii_case(&hint))
+ if let Some(route_match) =
+ inbound_message_service::resolve_model_command_route(&hint, &routing_config)
{
tracing::info!(
target: "query_classification",
@@ -269,14 +269,13 @@ async fn handle_regular_message(
);
route.provider = route_match.provider.clone();
route.model = route_match.model.clone();
- route.lane = route_match.capability;
- route.candidate_index = None;
+ route.lane = route_match.lane;
+ route.candidate_index = route_match.candidate_index;
route.clear_runtime_diagnostics();
}
}
}
- let routing_config = build_model_routing_config(config);
let current_route_profile =
crate::application::services::model_lane_resolution::resolve_route_selection_profile(
&routing_config,
@@ -1318,7 +1317,9 @@ fn channel_user_profile_key(envelope: &InboundEnvelope) -> String {
#[cfg(test)]
mod tests {
use super::*;
- use crate::config::schema::{ModelCandidateProfileConfig, ModelRouteConfig};
+ use crate::config::schema::{
+ ModelCandidateProfileConfig, ModelLaneCandidateConfig, ModelLaneConfig,
+ };
use crate::domain::channel::SourceKind;
use crate::ports::hooks::NoOpHooks;
use crate::ports::route_selection::RouteSelection;
@@ -1722,22 +1723,24 @@ mod tests {
}
fn config_with_route_window(
- hint: &str,
provider: &str,
model: &str,
context_window_tokens: usize,
) -> InboundMessageConfig {
let mut config = test_config();
- config.model_routes = vec![ModelRouteConfig {
- hint: hint.to_string(),
- capability: None,
- provider: provider.to_string(),
- model: model.to_string(),
- api_key: None,
- profile: ModelCandidateProfileConfig {
- context_window_tokens: Some(context_window_tokens),
- ..Default::default()
- },
+ config.model_lanes = vec![ModelLaneConfig {
+ lane: crate::config::schema::CapabilityLane::Reasoning,
+ candidates: vec![ModelLaneCandidateConfig {
+ provider: provider.to_string(),
+ model: model.to_string(),
+ api_key: None,
+ api_key_env: None,
+ dimensions: None,
+ profile: ModelCandidateProfileConfig {
+ context_window_tokens: Some(context_window_tokens),
+ ..Default::default()
+ },
+ }],
}];
config
}
@@ -1926,7 +1929,7 @@ mod tests {
#[tokio::test]
async fn channel_model_switch_waits_for_adapter_preflight() {
- let env = test_envelope("/model tiny");
+ let env = test_envelope("/model tiny-model");
let caps = vec![
ChannelCapability::SendText,
ChannelCapability::RuntimeCommands,
@@ -1935,7 +1938,7 @@ mod tests {
history.append_turn("telegram_user1", ChatMessage::user("x".repeat(20_000)));
let routes = Arc::new(RecordingRoutes::new("openrouter", "large-model"));
let ports = test_ports_with_state(history, routes.clone(), "");
- let config = config_with_route_window("tiny", "openrouter", "tiny-model", 1_000);
+ let config = config_with_route_window("openrouter", "tiny-model", 1_000);
let result = handle(&env, &caps, &config, &ports).await.unwrap();
@@ -1946,13 +1949,15 @@ mod tests {
model,
inferred_provider,
lane,
+ candidate_index,
compacted,
},
..
} => {
assert_eq!(model, "tiny-model");
assert_eq!(inferred_provider, Some("openrouter".to_string()));
- assert_eq!(lane, None);
+ assert_eq!(lane, Some(crate::config::schema::CapabilityLane::Reasoning));
+ assert_eq!(candidate_index, Some(0));
assert!(!compacted);
}
other => panic!("expected model switch command, got {other:?}"),
@@ -1963,7 +1968,7 @@ mod tests {
#[tokio::test]
async fn channel_model_switch_defers_safe_downshift_mutation_to_adapter() {
- let env = test_envelope("/model compact");
+ let env = test_envelope("/model compact-model");
let caps = vec![
ChannelCapability::SendText,
ChannelCapability::RuntimeCommands,
@@ -1977,7 +1982,7 @@ mod tests {
}
let routes = Arc::new(RecordingRoutes::new("openrouter", "large-model"));
let ports = test_ports_with_state(history.clone(), routes.clone(), "");
- let config = config_with_route_window("compact", "openrouter", "compact-model", 8_000);
+ let config = config_with_route_window("openrouter", "compact-model", 8_000);
let result = handle(&env, &caps, &config, &ports).await.unwrap();
diff --git a/crates/domain/src/config/schema.rs b/crates/domain/src/config/schema.rs
index 0c4d8a61..0abd7acd 100644
--- a/crates/domain/src/config/schema.rs
+++ b/crates/domain/src/config/schema.rs
@@ -185,8 +185,8 @@ pub struct Config {
/// Capability-aware model lanes — ordered candidates per runtime lane.
///
/// Candidate `0` is the default for the lane; later entries act as
- /// fallbacks or manual runtime alternatives. This complements the legacy
- /// `model_routes` table and is the preferred long-term routing surface.
+ /// fallbacks or manual runtime alternatives. This is the primary runtime
+ /// routing surface.
#[serde(default)]
pub model_lanes: Vec,
@@ -4188,6 +4188,17 @@ pub enum CapabilityLane {
}
impl CapabilityLane {
+ pub const ALL: [CapabilityLane; 8] = [
+ CapabilityLane::Reasoning,
+ CapabilityLane::CheapReasoning,
+ CapabilityLane::Embedding,
+ CapabilityLane::ImageGeneration,
+ CapabilityLane::AudioGeneration,
+ CapabilityLane::VideoGeneration,
+ CapabilityLane::MusicGeneration,
+ CapabilityLane::MultimodalUnderstanding,
+ ];
+
pub fn as_str(self) -> &'static str {
match self {
CapabilityLane::Reasoning => "reasoning",
@@ -4202,6 +4213,18 @@ impl CapabilityLane {
}
}
+impl std::str::FromStr for CapabilityLane {
+ type Err = ();
+
+ fn from_str(value: &str) -> std::result::Result {
+ let normalized = value.trim().trim_matches('`').replace('-', "_");
+ CapabilityLane::ALL
+ .into_iter()
+ .find(|lane| lane.as_str().eq_ignore_ascii_case(&normalized))
+ .ok_or(())
+ }
+}
+
/// Explicit model features used for lane routing and candidate selection.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md
index bea39e6f..fef23c96 100644
--- a/docs/fork/ipc-phase4_10-audit.md
+++ b/docs/fork/ipc-phase4_10-audit.md
@@ -48,6 +48,24 @@ Our best move is:
4. load project instructions progressively by scope
5. expose context size and contributors like a first-class runtime surface
+## 2026-04-12 Follow-Up
+
+- Runtime model switching now follows the same typed selector path in web and
+ channel: effective capability lanes first, catalog aliases second, then an
+ explicit unresolved model selector.
+- Legacy `model_routes` / `embedding_routes` are no longer lane-resolution
+ fallbacks for `resolve_lane_candidates`, summary routing, query-classifier
+ overrides, or `/model` command effects.
+- Matched lane candidates preserve `lane + candidate_index` through the shared
+ runtime-command adapter contract, which keeps route state aligned with the
+ candidate-profile registry instead of only storing provider/model strings.
+- `/model` help now renders effective capability lanes and catalog aliases
+ instead of promoting configured `model_routes` as a first-class runtime
+ surface.
+- Remaining audit target: old config/API/tooling surfaces still expose
+ `model_routes` as editable compatibility data; future cleanup should either
+ rename that data shape to catalog aliases or fully retire it.
+
---
## Product Patterns
diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md
index 8565d159..8f762aa2 100644
--- a/docs/fork/ipc-phase4_10-plan.md
+++ b/docs/fork/ipc-phase4_10-plan.md
@@ -1096,7 +1096,8 @@ Expected outcome:
- current status:
- agent history compaction, web session summarization, and channel summaries now resolve
their summarizer lane through one domain service
- - precedence is explicit `[summary]` config -> `summary_model` -> `cheap` route -> current route
+ - precedence is explicit `[summary]` config -> `summary_model` ->
+ `cheap_reasoning` capability lane -> current route
- live daemon validation confirmed compaction on a long cheap-route dialogue
(`history_len_before=54`, `history_len_after=25`)
- procedural skill count stayed flat during the pure semantic run (`list_skills = 61`)
@@ -1111,6 +1112,10 @@ Expected outcome:
entered through the automatic context path
- low-information detection now catches repeated semantic shingles, not only
exact token chants or contiguous repeated phrases
+ - summary route resolution no longer consumes legacy `model_routes` cheap
+ hints; the compactor lane must come from explicit/effective capability
+ lanes or explicit summary config, keeping cheap-model condensation on the
+ same routing language as the main runtime
- remaining quality tail after the hardening:
- recall ranking is better, but still needs live long-dialogue validation on
concept-heavy semantic sessions after the hybrid-rerank follow-through
@@ -1250,6 +1255,19 @@ Expected outcome:
- provider router now rejects unknown `hint:*` model selectors before
dispatch instead of executing them on the default provider
- `model_routing_config` preset operations now participate in typed routing facts
+ - `resolve_lane_candidates` no longer treats legacy `model_routes` or
+ `embedding_routes` as capability-lane fallbacks; fallback to the default
+ reasoning route now requires both configured provider and configured model,
+ with no provider string invented in code
+ - channel and web `/model` command effects now resolve selectors through
+ effective capability lanes first and catalog aliases second; matched lane
+ candidates preserve `lane + candidate_index` through the shared
+ runtime-command adapter contract
+ - `/model` help no longer renders configured `model_routes` as a first-class
+ routing surface; it shows effective capability lanes plus catalog aliases
+ so operator UX matches the runtime selector path
+ - query-classifier route overrides now reuse the same lane/catalog selector
+ resolver instead of scanning `model_routes` by hint
- remaining work:
- historical note: earlier remaining items about image/audio/video/music
first-class turn routing are now mostly owned by Slice 14 and the shared
From 1296c819789bd77d82ce796bf46ea1124c43ea8d Mon Sep 17 00:00:00 2001
From: panviktor <35385675+panviktor@users.noreply.github.com>
Date: Sun, 12 Apr 2026 20:17:07 +0200
Subject: [PATCH 03/30] Move routing config tool to lane candidates
---
.../tools/src/model_routing_config.rs | 315 +++++++++++-------
crates/domain/src/config/schema.rs | 15 +-
.../domain/src/domain/query_classification.rs | 5 +-
docs/fork/ipc-phase4_10-audit.md | 10 +-
docs/fork/ipc-phase4_10-plan.md | 6 +
5 files changed, 213 insertions(+), 138 deletions(-)
diff --git a/crates/adapters/tools/src/model_routing_config.rs b/crates/adapters/tools/src/model_routing_config.rs
index 108c219a..5b4298b5 100644
--- a/crates/adapters/tools/src/model_routing_config.rs
+++ b/crates/adapters/tools/src/model_routing_config.rs
@@ -9,7 +9,8 @@ use synapse_domain::application::services::model_preset_resolution::{
preset_title, resolve_effective_model_lanes,
};
use synapse_domain::config::schema::{
- CapabilityLane, ClassificationRule, Config, DelegateAgentConfig, ModelRouteConfig,
+ CapabilityLane, ClassificationRule, Config, DelegateAgentConfig, ModelCandidateProfileConfig,
+ ModelLaneCandidateConfig, ModelLaneConfig,
};
use synapse_domain::domain::security_policy::SecurityPolicy;
use synapse_domain::domain::tool_fact::{
@@ -211,26 +212,27 @@ impl ModelRoutingConfigTool {
Ok(Some(value))
}
- fn scenario_row(route: &ModelRouteConfig, rule: Option<&ClassificationRule>) -> Value {
- let classification = rule.map(|r| {
- json!({
- "keywords": r.keywords,
- "patterns": r.patterns,
- "min_length": r.min_length,
- "max_length": r.max_length,
- "priority": r.priority,
- })
- });
-
+ fn scenario_row(rule: &ClassificationRule, cfg: &Config) -> Value {
+ let resolved =
+ synapse_domain::application::services::inbound_message_service::resolve_model_command_route(
+ &rule.hint,
+ cfg,
+ );
json!({
- "hint": route.hint,
- "provider": route.provider,
- "model": route.model,
- "api_key_configured": route
- .api_key
- .as_ref()
- .is_some_and(|value| !value.trim().is_empty()),
- "classification": classification,
+ "selector": rule.hint,
+ "resolved_route": resolved.map(|route| json!({
+ "provider": route.provider,
+ "model": route.model,
+ "lane": route.lane.map(Self::lane_name),
+ "candidate_index": route.candidate_index,
+ })),
+ "classification": {
+ "keywords": rule.keywords,
+ "patterns": rule.patterns,
+ "min_length": rule.min_length,
+ "max_length": rule.max_length,
+ "priority": rule.priority,
+ },
})
}
@@ -272,9 +274,6 @@ impl ModelRoutingConfigTool {
}
fn snapshot(cfg: &Config) -> Value {
- let mut routes = cfg.model_routes.clone();
- routes.sort_by(|a, b| a.hint.cmp(&b.hint));
-
let mut rules = cfg.query_classification.rules.clone();
rules.sort_by(|a, b| {
b.priority
@@ -282,18 +281,23 @@ impl ModelRoutingConfigTool {
.then_with(|| a.hint.cmp(&b.hint))
});
- let mut scenarios = Vec::with_capacity(routes.len());
- for route in &routes {
- let rule = rules.iter().find(|r| r.hint == route.hint);
- scenarios.push(Self::scenario_row(route, rule));
- }
+ let scenarios = rules
+ .iter()
+ .map(|rule| Self::scenario_row(rule, cfg))
+ .collect::>();
- let classification_only_rules: Vec = rules
+ let unresolved_classification_rules: Vec = rules
.iter()
- .filter(|rule| !routes.iter().any(|route| route.hint == rule.hint))
+ .filter(|rule| {
+ synapse_domain::application::services::inbound_message_service::resolve_model_command_route(
+ &rule.hint,
+ cfg,
+ )
+ .is_none()
+ })
.map(|rule| {
json!({
- "hint": rule.hint,
+ "selector": rule.hint,
"keywords": rule.keywords,
"patterns": rule.patterns,
"min_length": rule.min_length,
@@ -341,16 +345,11 @@ impl ModelRoutingConfigTool {
"rules_count": cfg.query_classification.rules.len(),
},
"scenarios": scenarios,
- "classification_only_rules": classification_only_rules,
+ "unresolved_classification_rules": unresolved_classification_rules,
"agents": agents,
})
}
- fn normalize_and_sort_routes(routes: &mut Vec) {
- routes.retain(|route| !route.hint.trim().is_empty());
- routes.sort_by(|a, b| a.hint.cmp(&b.hint));
- }
-
fn normalize_and_sort_rules(rules: &mut Vec) {
rules.retain(|rule| !rule.hint.trim().is_empty());
rules.sort_by(|a, b| {
@@ -373,6 +372,75 @@ impl ModelRoutingConfigTool {
}
}
+ fn parse_lane_selector(args: &Value) -> anyhow::Result {
+ let raw = args
+ .get("lane")
+ .or_else(|| args.get("hint"))
+ .and_then(Value::as_str)
+ .ok_or_else(|| anyhow::anyhow!("Missing 'lane'"))?
+ .trim();
+ raw.parse::().map_err(|_| {
+ anyhow::anyhow!(
+ "'lane' must be one of: {}",
+ CapabilityLane::ALL
+ .into_iter()
+ .map(Self::lane_name)
+ .collect::>()
+ .join(", ")
+ )
+ })
+ }
+
+ fn upsert_lane_candidate(
+ cfg: &mut Config,
+ lane: CapabilityLane,
+ provider: String,
+ model: String,
+ api_key_update: MaybeSet,
+ ) {
+ let existing = cfg
+ .model_lanes
+ .iter()
+ .find(|entry| entry.lane == lane)
+ .and_then(|entry| {
+ entry
+ .candidates
+ .iter()
+ .find(|candidate| candidate.provider == provider && candidate.model == model)
+ .cloned()
+ });
+ let mut candidate = existing.unwrap_or_else(|| ModelLaneCandidateConfig {
+ provider: provider.clone(),
+ model: model.clone(),
+ api_key: None,
+ api_key_env: None,
+ dimensions: None,
+ profile: ModelCandidateProfileConfig::default(),
+ });
+
+ candidate.provider = provider;
+ candidate.model = model;
+ match api_key_update {
+ MaybeSet::Set(api_key) => candidate.api_key = Some(api_key),
+ MaybeSet::Null => candidate.api_key = None,
+ MaybeSet::Unset => {}
+ }
+
+ if let Some(entry) = cfg.model_lanes.iter_mut().find(|entry| entry.lane == lane) {
+ entry.candidates.retain(|existing| {
+ existing.provider != candidate.provider || existing.model != candidate.model
+ });
+ entry.candidates.insert(0, candidate);
+ } else {
+ cfg.model_lanes.push(ModelLaneConfig {
+ lane,
+ candidates: vec![candidate],
+ });
+ }
+ cfg.model_lanes
+ .sort_by(|left, right| left.lane.as_str().cmp(right.lane.as_str()));
+ }
+
fn handle_get(&self) -> anyhow::Result {
let cfg = self.load_config_without_env()?;
Ok(ToolResult {
@@ -384,10 +452,17 @@ impl ModelRoutingConfigTool {
fn handle_list_hints(&self) -> anyhow::Result {
let cfg = self.load_config_without_env()?;
- let mut route_hints: Vec =
- cfg.model_routes.iter().map(|r| r.hint.clone()).collect();
- route_hints.sort();
- route_hints.dedup();
+ let lane_selectors: Vec<&'static str> = CapabilityLane::ALL
+ .into_iter()
+ .map(Self::lane_name)
+ .collect();
+ let mut catalog_aliases: Vec =
+ synapse_domain::config::model_catalog::model_route_aliases()
+ .into_iter()
+ .map(|alias| alias.hint)
+ .collect();
+ catalog_aliases.sort();
+ catalog_aliases.dedup();
let mut classification_hints: Vec = cfg
.query_classification
@@ -401,24 +476,24 @@ impl ModelRoutingConfigTool {
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&json!({
- "model_route_hints": route_hints,
+ "lane_selectors": lane_selectors,
+ "catalog_aliases": catalog_aliases,
"classification_hints": classification_hints,
"example": {
- "conversation": {
+ "reasoning_lane": {
"action": "upsert_scenario",
- "hint": "conversation",
- "provider": "kimi",
- "model": "moonshot-v1-8k",
+ "hint": "reasoning",
+ "provider": "example-provider",
+ "model": "example-reasoning-model",
"classification_enabled": false
},
- "coding": {
+ "cheap_lane": {
"action": "upsert_scenario",
- "hint": "coding",
- "provider": "openai",
- "model": "gpt-5.3-codex",
+ "hint": "cheap_reasoning",
+ "provider": "test-provider",
+ "model": "test-fast-model",
"classification_enabled": true,
- "keywords": ["code", "bug", "refactor", "test"],
- "patterns": ["```"],
+ "keywords": ["summarize", "short"],
"priority": 50
}
}
@@ -608,7 +683,8 @@ impl ModelRoutingConfigTool {
}
async fn handle_upsert_scenario(&self, args: &Value) -> anyhow::Result {
- let hint = Self::parse_non_empty_string(args, "hint")?;
+ let lane = Self::parse_lane_selector(args)?;
+ let selector = Self::lane_name(lane).to_string();
let provider = Self::parse_non_empty_string(args, "provider")?;
let model = Self::parse_non_empty_string(args, "model")?;
let api_key_update = Self::parse_optional_string_update(args, "api_key")?;
@@ -636,53 +712,26 @@ impl ModelRoutingConfigTool {
|| !matches!(priority_update, MaybeSet::Unset);
let mut cfg = self.load_config_without_env()?;
-
- let existing_route = cfg
- .model_routes
- .iter()
- .find(|route| route.hint == hint)
- .cloned();
-
- let mut next_route = existing_route.unwrap_or(ModelRouteConfig {
- hint: hint.clone(),
- capability: None,
- provider: provider.clone(),
- model: model.clone(),
- api_key: None,
- profile: synapse_domain::config::schema::ModelCandidateProfileConfig::default(),
- });
-
- next_route.hint = hint.clone();
- next_route.provider = provider;
- next_route.model = model;
-
- match api_key_update {
- MaybeSet::Set(api_key) => next_route.api_key = Some(api_key),
- MaybeSet::Null => next_route.api_key = None,
- MaybeSet::Unset => {}
- }
-
- cfg.model_routes.retain(|route| route.hint != hint);
- cfg.model_routes.push(next_route);
- Self::normalize_and_sort_routes(&mut cfg.model_routes);
+ Self::upsert_lane_candidate(&mut cfg, lane, provider, model, api_key_update);
if should_touch_rule {
if matches!(classification_enabled, Some(false)) {
cfg.query_classification
.rules
- .retain(|rule| rule.hint != hint);
+ .retain(|rule| rule.hint != selector);
} else {
let existing_rule = cfg
.query_classification
.rules
.iter()
- .find(|rule| rule.hint == hint)
+ .find(|rule| rule.hint == selector)
.cloned();
let mut next_rule = existing_rule.unwrap_or_else(|| ClassificationRule {
- hint: hint.clone(),
+ hint: selector.clone(),
..ClassificationRule::default()
});
+ next_rule.hint = selector.clone();
if let Some(keywords) = keywords_update {
next_rule.keywords = keywords;
@@ -710,18 +759,18 @@ impl ModelRoutingConfigTool {
}
if matches!(classification_enabled, Some(true)) {
- Self::ensure_rule_defaults(&mut next_rule, &hint);
+ Self::ensure_rule_defaults(&mut next_rule, &selector);
}
if !Self::has_rule_matcher(&next_rule) {
anyhow::bail!(
- "Classification rule for hint '{hint}' has no matching criteria. Provide keywords/patterns or set min_length/max_length."
+ "Classification rule for selector '{selector}' has no matching criteria. Provide keywords/patterns or set min_length/max_length."
);
}
cfg.query_classification
.rules
- .retain(|rule| rule.hint != hint);
+ .retain(|rule| rule.hint != selector);
cfg.query_classification.rules.push(next_rule);
}
}
@@ -734,8 +783,9 @@ impl ModelRoutingConfigTool {
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&json!({
- "message": "Scenario route upserted",
- "hint": hint,
+ "message": "Scenario lane candidate upserted",
+ "selector": selector,
+ "lane": Self::lane_name(lane),
"config": Self::snapshot(&cfg),
}))?,
error: None,
@@ -743,7 +793,8 @@ impl ModelRoutingConfigTool {
}
async fn handle_remove_scenario(&self, args: &Value) -> anyhow::Result {
- let hint = Self::parse_non_empty_string(args, "hint")?;
+ let lane = Self::parse_lane_selector(args)?;
+ let selector = Self::lane_name(lane).to_string();
let remove_classification = args
.get("remove_classification")
.and_then(Value::as_bool)
@@ -751,24 +802,23 @@ impl ModelRoutingConfigTool {
let mut cfg = self.load_config_without_env()?;
- let before_routes = cfg.model_routes.len();
- cfg.model_routes.retain(|route| route.hint != hint);
- let routes_removed = before_routes.saturating_sub(cfg.model_routes.len());
+ let before_lanes = cfg.model_lanes.len();
+ cfg.model_lanes.retain(|entry| entry.lane != lane);
+ let lanes_removed = before_lanes.saturating_sub(cfg.model_lanes.len());
let mut rules_removed = 0usize;
if remove_classification {
let before_rules = cfg.query_classification.rules.len();
cfg.query_classification
.rules
- .retain(|rule| rule.hint != hint);
+ .retain(|rule| rule.hint != selector);
rules_removed = before_rules.saturating_sub(cfg.query_classification.rules.len());
}
- if routes_removed == 0 && rules_removed == 0 {
- anyhow::bail!("No scenario found for hint '{hint}'");
+ if lanes_removed == 0 && rules_removed == 0 {
+ anyhow::bail!("No scenario found for selector '{selector}'");
}
- Self::normalize_and_sort_routes(&mut cfg.model_routes);
Self::normalize_and_sort_rules(&mut cfg.query_classification.rules);
cfg.query_classification.enabled = !cfg.query_classification.rules.is_empty();
@@ -778,8 +828,9 @@ impl ModelRoutingConfigTool {
success: true,
output: serde_json::to_string_pretty(&json!({
"message": "Scenario removed",
- "hint": hint,
- "routes_removed": routes_removed,
+ "selector": selector,
+ "lane": Self::lane_name(lane),
+ "lanes_removed": lanes_removed,
"classification_rules_removed": rules_removed,
"config": Self::snapshot(&cfg),
}))?,
@@ -926,7 +977,7 @@ impl Tool for ModelRoutingConfigTool {
}
fn description(&self) -> &str {
- "Manage default model settings, scenario-based provider/model routes, classification rules, and delegate sub-agent profiles"
+ "Manage default model settings, capability-lane candidates, classification rules, and delegate sub-agent profiles"
}
fn parameters_schema(&self) -> Value {
@@ -950,7 +1001,11 @@ impl Tool for ModelRoutingConfigTool {
},
"hint": {
"type": "string",
- "description": "Scenario hint name (for example: conversation, coding, reasoning)"
+ "description": "Capability-lane selector for scenario routing (for example: reasoning, cheap_reasoning, image_generation). Prefer 'lane' for new calls."
+ },
+ "lane": {
+ "type": "string",
+ "description": "Capability lane for upsert_scenario/remove_scenario (reasoning, cheap_reasoning, embedding, multimodal_understanding, image_generation, audio_generation, video_generation, music_generation)"
},
"preset": {
"type": ["string", "null"],
@@ -974,7 +1029,7 @@ impl Tool for ModelRoutingConfigTool {
},
"api_key": {
"type": ["string", "null"],
- "description": "Optional API key override for scenario route or delegate agent"
+ "description": "Optional API key override for lane candidate or delegate agent"
},
"keywords": {
"description": "Classification keywords for upsert_scenario (string or string array)",
@@ -1006,7 +1061,7 @@ impl Tool for ModelRoutingConfigTool {
},
"classification_enabled": {
"type": "boolean",
- "description": "When true, upsert classification rule for this hint; false removes it"
+ "description": "When true, upsert classification rule for this lane selector; false removes it"
},
"remove_classification": {
"type": "boolean",
@@ -1082,7 +1137,8 @@ impl Tool for ModelRoutingConfigTool {
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
hint: args
- .get("hint")
+ .get("lane")
+ .or_else(|| args.get("hint"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
@@ -1203,8 +1259,8 @@ mod tests {
let result = tool
.execute(json!({
"action": "set_default",
- "provider": "kimi",
- "model": "moonshot-v1-8k",
+ "provider": "test-provider",
+ "model": "test-default-model",
"temperature": 0.2
}))
.await
@@ -1214,11 +1270,11 @@ mod tests {
let output: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(
output["config"]["default"]["provider"].as_str(),
- Some("kimi")
+ Some("test-provider")
);
assert_eq!(
output["config"]["default"]["model"].as_str(),
- Some("moonshot-v1-8k")
+ Some("test-default-model")
);
assert_eq!(
output["config"]["default"]["temperature"].as_f64(),
@@ -1227,16 +1283,16 @@ mod tests {
}
#[tokio::test]
- async fn upsert_scenario_creates_route_and_rule() {
+ async fn upsert_scenario_creates_lane_candidate_and_rule() {
let tmp = TempDir::new().unwrap();
let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
let result = tool
.execute(json!({
"action": "upsert_scenario",
- "hint": "coding",
- "provider": "openai",
- "model": "gpt-5.3-codex",
+ "lane": "reasoning",
+ "provider": "test-provider",
+ "model": "test-reasoning-model",
"classification_enabled": true,
"keywords": ["code", "bug", "refactor"],
"patterns": ["```"],
@@ -1255,9 +1311,10 @@ mod tests {
let scenarios = output["scenarios"].as_array().unwrap();
assert!(scenarios.iter().any(|item| {
- item["hint"] == json!("coding")
- && item["provider"] == json!("openai")
- && item["model"] == json!("gpt-5.3-codex")
+ item["selector"] == json!("reasoning")
+ && item["resolved_route"]["provider"] == json!("test-provider")
+ && item["resolved_route"]["model"] == json!("test-reasoning-model")
+ && item["resolved_route"]["lane"] == json!("reasoning")
}));
}
@@ -1269,9 +1326,9 @@ mod tests {
let _ = tool
.execute(json!({
"action": "upsert_scenario",
- "hint": "coding",
- "provider": "openai",
- "model": "gpt-5.3-codex",
+ "lane": "reasoning",
+ "provider": "test-provider",
+ "model": "test-reasoning-model",
"classification_enabled": true,
"keywords": ["code"]
}))
@@ -1281,7 +1338,7 @@ mod tests {
let removed = tool
.execute(json!({
"action": "remove_scenario",
- "hint": "coding"
+ "lane": "reasoning"
}))
.await
.unwrap();
@@ -1302,8 +1359,8 @@ mod tests {
.execute(json!({
"action": "upsert_agent",
"name": "coder",
- "provider": "openai",
- "model": "gpt-5.3-codex",
+ "provider": "test-provider",
+ "model": "test-agent-model",
"agentic": true,
"allowed_tools": ["file_read", "file_write", "shell"],
"max_iterations": 6
@@ -1314,8 +1371,14 @@ mod tests {
let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
let output: Value = serde_json::from_str(&get_result.output).unwrap();
- assert_eq!(output["agents"]["coder"]["provider"], json!("openai"));
- assert_eq!(output["agents"]["coder"]["model"], json!("gpt-5.3-codex"));
+ assert_eq!(
+ output["agents"]["coder"]["provider"],
+ json!("test-provider")
+ );
+ assert_eq!(
+ output["agents"]["coder"]["model"],
+ json!("test-agent-model")
+ );
assert_eq!(output["agents"]["coder"]["agentic"], json!(true));
let remove = tool
@@ -1341,7 +1404,7 @@ mod tests {
let result = tool
.execute(json!({
"action": "set_default",
- "provider": "openai"
+ "provider": "test-provider"
}))
.await
.unwrap();
@@ -1361,7 +1424,7 @@ mod tests {
let result = tool
.execute(json!({
"action": "set_default",
- "provider": "anthropic",
+ "provider": "test-provider",
"model": "totally-fake-model-12345"
}))
.await
diff --git a/crates/domain/src/config/schema.rs b/crates/domain/src/config/schema.rs
index 0abd7acd..7aea809c 100644
--- a/crates/domain/src/config/schema.rs
+++ b/crates/domain/src/config/schema.rs
@@ -4290,24 +4290,25 @@ pub struct ModelLaneConfig {
pub candidates: Vec,
}
-/// Route a task hint to a specific provider + model.
+/// Legacy catalog alias from a task hint to a specific provider + model.
///
/// ```toml
/// [[model_routes]]
/// hint = "reasoning"
-/// provider = "openrouter"
-/// model = "anthropic/claude-opus-4-20250514"
+/// provider = "example-provider"
+/// model = "example-reasoning-model"
///
/// [[model_routes]]
/// hint = "fast"
-/// provider = "groq"
-/// model = "llama-3.3-70b-versatile"
+/// provider = "example-provider"
+/// model = "example-fast-model"
/// ```
///
-/// Usage: pass `hint:reasoning` as the model parameter to route the request.
+/// Runtime routing should prefer `[[model_lanes]]`; this shape remains for
+/// catalog aliases and legacy config/API surfaces.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ModelRouteConfig {
- /// Task hint name (e.g. "reasoning", "fast", "code", "summarize")
+ /// Alias name, such as a capability selector or operator-defined shortcut.
pub hint: String,
/// Optional capability lane resolved by runtime/domain services.
#[serde(default)]
diff --git a/crates/domain/src/domain/query_classification.rs b/crates/domain/src/domain/query_classification.rs
index 7daf4ba4..3fde5a68 100644
--- a/crates/domain/src/domain/query_classification.rs
+++ b/crates/domain/src/domain/query_classification.rs
@@ -16,10 +16,11 @@ pub struct QueryClassificationConfig {
pub rules: Vec,
}
-/// A single classification rule mapping message patterns to a model hint.
+/// A single classification rule mapping message patterns to a capability-lane
+/// selector or catalog alias.
#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
pub struct ClassificationRule {
- /// Must match a `[[model_routes]]` hint value.
+ /// Selector resolved by the shared route resolver.
pub hint: String,
/// Case-insensitive substring matches.
#[serde(default)]
diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md
index fef23c96..9e65851a 100644
--- a/docs/fork/ipc-phase4_10-audit.md
+++ b/docs/fork/ipc-phase4_10-audit.md
@@ -62,9 +62,13 @@ Our best move is:
- `/model` help now renders effective capability lanes and catalog aliases
instead of promoting configured `model_routes` as a first-class runtime
surface.
-- Remaining audit target: old config/API/tooling surfaces still expose
- `model_routes` as editable compatibility data; future cleanup should either
- rename that data shape to catalog aliases or fully retire it.
+- `model_routing_config` no longer creates or removes editable
+ `model_routes`; scenario upserts now write the selected capability lane's
+ ordered candidate list and classification rules resolve through the shared
+ lane/catalog selector path.
+- Remaining audit target: old config/API surfaces still expose `model_routes`
+ as compatibility/catalog-alias data; future cleanup should either rename that
+ data shape to catalog aliases or fully retire it.
---
diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md
index 8f762aa2..aae8f7d2 100644
--- a/docs/fork/ipc-phase4_10-plan.md
+++ b/docs/fork/ipc-phase4_10-plan.md
@@ -1255,6 +1255,9 @@ Expected outcome:
- provider router now rejects unknown `hint:*` model selectors before
dispatch instead of executing them on the default provider
- `model_routing_config` preset operations now participate in typed routing facts
+ - `model_routing_config` scenario upsert/remove now manages explicit
+ capability-lane candidate lists plus classification rules instead of
+ writing legacy `model_routes`
- `resolve_lane_candidates` no longer treats legacy `model_routes` or
`embedding_routes` as capability-lane fallbacks; fallback to the default
reasoning route now requires both configured provider and configured model,
@@ -1280,6 +1283,9 @@ Expected outcome:
- route state stores lane/candidate identity, and route-switch UX now renders lane
for lane-aware switches; downstream runtime surfaces should keep moving toward
lane/candidate-first explanations
+ - old config/API surfaces still expose `model_routes` as compatibility or
+ catalog-alias data; remove or rename that surface in a dedicated cleanup
+ when the gateway/UI contract is ready to change
- keep auditing that new routing decisions enter through domain/profile
services rather than brand-string heuristics
From ed7e5a781a3cf2f1d2152910bccb8331abfb8b75 Mon Sep 17 00:00:00 2001
From: panviktor <35385675+panviktor@users.noreply.github.com>
Date: Sun, 12 Apr 2026 20:20:25 +0200
Subject: [PATCH 04/30] Move preset alias mapping into catalog
---
crates/domain/data/model_catalog.json | 10 ++++
.../services/model_preset_resolution.rs | 23 ++------
crates/domain/src/config/model_catalog.rs | 54 +++++++++++++++++++
docs/fork/ipc-phase4_10-plan.md | 2 +
4 files changed, 71 insertions(+), 18 deletions(-)
diff --git a/crates/domain/data/model_catalog.json b/crates/domain/data/model_catalog.json
index 01ade817..b50e1db2 100644
--- a/crates/domain/data/model_catalog.json
+++ b/crates/domain/data/model_catalog.json
@@ -2,29 +2,35 @@
"presets": [
{
"id": "chatgpt",
+ "aliases": ["openai", "openai-codex", "codex"],
"title": "ChatGPT / Codex",
"description": "OpenAI-style reasoning lane with minimal defaults.",
"default_provider": "openai-codex",
"default_model": "gpt-5.4",
"seed_multimodal_from_reasoning": true,
+ "provider_aliases": ["openai", "openai-codex"],
"extra_lanes": []
},
{
"id": "claude",
+ "aliases": ["anthropic"],
"title": "Claude",
"description": "Anthropic-style reasoning lane with minimal defaults.",
"default_provider": "anthropic",
"default_model": "claude-sonnet-4-6",
"seed_multimodal_from_reasoning": true,
+ "provider_aliases": ["anthropic"],
"extra_lanes": []
},
{
"id": "openrouter",
+ "aliases": [],
"title": "OpenRouter",
"description": "Reasoning lane plus cheap Qwen summary and embedding defaults.",
"default_provider": "openrouter",
"default_model": "anthropic/claude-sonnet-4-6",
"seed_multimodal_from_reasoning": false,
+ "provider_aliases": ["openrouter"],
"extra_lanes": [
{
"lane": "cheap_reasoning",
@@ -56,20 +62,24 @@
},
{
"id": "gemini",
+ "aliases": ["google"],
"title": "Gemini",
"description": "Gemini reasoning lane with room for multimodal expansion.",
"default_provider": "gemini",
"default_model": "gemini-3.1-pro",
"seed_multimodal_from_reasoning": true,
+ "provider_aliases": ["gemini"],
"extra_lanes": []
},
{
"id": "local",
+ "aliases": ["local-ai", "self-hosted", "ollama"],
"title": "Local / self-hosted",
"description": "Single local reasoning lane without cloud-only helper lanes.",
"default_provider": "ollama",
"default_model": "llama4-scout:latest",
"seed_multimodal_from_reasoning": false,
+ "provider_aliases": ["ollama", "llamacpp", "sglang", "vllm", "osaurus"],
"extra_lanes": []
}
],
diff --git a/crates/domain/src/application/services/model_preset_resolution.rs b/crates/domain/src/application/services/model_preset_resolution.rs
index 48fb4942..82b87e60 100644
--- a/crates/domain/src/application/services/model_preset_resolution.rs
+++ b/crates/domain/src/application/services/model_preset_resolution.rs
@@ -1,8 +1,10 @@
use crate::config::model_catalog::{
apply_default_api_key, known_model_presets as catalog_known_model_presets,
+ normalize_model_preset_id as catalog_normalize_model_preset_id,
preset_description as catalog_preset_description, preset_extra_lanes,
preset_reasoning_seed as catalog_preset_reasoning_seed, preset_seed_multimodal_from_reasoning,
- preset_title as catalog_preset_title, KnownModelPreset,
+ preset_title as catalog_preset_title,
+ recommended_preset_for_provider as catalog_recommended_preset_for_provider, KnownModelPreset,
};
use crate::config::schema::{
CapabilityLane, Config, ModelCandidateProfileConfig, ModelFeature, ModelLaneCandidateConfig,
@@ -14,26 +16,11 @@ pub fn known_model_presets() -> &'static [KnownModelPreset] {
}
pub fn recommended_model_preset_for_provider(provider: &str) -> Option<&'static str> {
- match provider {
- "openai" | "openai-codex" => Some("chatgpt"),
- "anthropic" => Some("claude"),
- "openrouter" => Some("openrouter"),
- "gemini" => Some("gemini"),
- "ollama" | "llamacpp" | "sglang" | "vllm" | "osaurus" => Some("local"),
- _ => None,
- }
+ catalog_recommended_preset_for_provider(provider)
}
pub fn normalize_model_preset_id(value: &str) -> Option<&'static str> {
- let normalized = value.trim().to_ascii_lowercase();
- match normalized.as_str() {
- "chatgpt" | "openai" | "openai-codex" | "codex" => Some("chatgpt"),
- "claude" | "anthropic" => Some("claude"),
- "openrouter" => Some("openrouter"),
- "gemini" | "google" => Some("gemini"),
- "local" | "local-ai" | "self-hosted" | "ollama" => Some("local"),
- _ => None,
- }
+ catalog_normalize_model_preset_id(value)
}
pub fn preset_title(preset_id: &str) -> Option<&'static str> {
diff --git a/crates/domain/src/config/model_catalog.rs b/crates/domain/src/config/model_catalog.rs
index bcb9213d..cea5d276 100644
--- a/crates/domain/src/config/model_catalog.rs
+++ b/crates/domain/src/config/model_catalog.rs
@@ -44,6 +44,8 @@ struct ModelCatalogData {
#[derive(Debug, Clone, Deserialize)]
struct ModelPresetCatalogEntry {
id: String,
+ #[serde(default)]
+ aliases: Vec,
title: String,
description: String,
default_provider: String,
@@ -51,6 +53,8 @@ struct ModelPresetCatalogEntry {
#[serde(default)]
seed_multimodal_from_reasoning: bool,
#[serde(default)]
+ provider_aliases: Vec,
+ #[serde(default)]
extra_lanes: Vec,
}
@@ -327,6 +331,46 @@ pub fn preset_description(preset_id: &str) -> Option<&'static str> {
.map(|preset| preset.description.as_str())
}
+pub fn normalize_model_preset_id(value: &str) -> Option<&'static str> {
+ let value = value.trim();
+ if value.is_empty() {
+ return None;
+ }
+
+ active_model_catalog()
+ .data
+ .presets
+ .iter()
+ .find(|preset| {
+ preset.id.eq_ignore_ascii_case(value)
+ || preset
+ .aliases
+ .iter()
+ .any(|alias| alias.eq_ignore_ascii_case(value))
+ })
+ .map(|preset| preset.id.as_str())
+}
+
+pub fn recommended_preset_for_provider(provider: &str) -> Option<&'static str> {
+ let provider = provider.trim();
+ if provider.is_empty() {
+ return None;
+ }
+
+ active_model_catalog()
+ .data
+ .presets
+ .iter()
+ .find(|preset| {
+ preset.default_provider.eq_ignore_ascii_case(provider)
+ || preset
+ .provider_aliases
+ .iter()
+ .any(|alias| alias.eq_ignore_ascii_case(provider))
+ })
+ .map(|preset| preset.id.as_str())
+}
+
pub fn preset_reasoning_seed(preset_id: &str) -> Option<(&'static str, &'static str)> {
active_model_catalog()
.data
@@ -592,6 +636,16 @@ mod tests {
assert!(presets.iter().any(|preset| preset.id == "openrouter"));
}
+ #[test]
+ fn preset_aliases_come_from_catalog_data() {
+ assert_eq!(normalize_model_preset_id("codex"), Some("chatgpt"));
+ assert_eq!(
+ recommended_preset_for_provider("openai-codex"),
+ Some("chatgpt")
+ );
+ assert_eq!(recommended_preset_for_provider("llamacpp"), Some("local"));
+ }
+
#[test]
fn openrouter_preset_has_auxiliary_lanes() {
let lanes = preset_extra_lanes("openrouter").expect("preset should exist");
diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md
index aae8f7d2..a673b062 100644
--- a/docs/fork/ipc-phase4_10-plan.md
+++ b/docs/fork/ipc-phase4_10-plan.md
@@ -1242,6 +1242,8 @@ Expected outcome:
- onboarding starts from simple presets and then populates the richer lane-aware config
- preset seeds, provider defaults, curated model lists, and default pricing now live in a
built-in external catalog instead of hardcoded Rust match arms
+ - preset aliases and provider-to-preset recommendations now also resolve from
+ the catalog instead of Rust provider-name match arms
- users can now materialize and edit a local override catalog next to `config.toml`
via `synapseclaw models catalog init`; runtime merges that file over the built-in catalog
- `/model` help and routing config inspection now surface preset/effective-lane information
From 94142ca5cea23355eb4f00a9f044eda519199e23 Mon Sep 17 00:00:00 2001
From: panviktor <35385675+panviktor@users.noreply.github.com>
Date: Sun, 12 Apr 2026 20:29:59 +0200
Subject: [PATCH 05/30] Unify live agent route classification
---
crates/adapters/core/src/agent/agent.rs | 255 +++++++++++++++---------
docs/fork/ipc-phase4_10-audit.md | 3 +
docs/fork/ipc-phase4_10-plan.md | 3 +
3 files changed, 168 insertions(+), 93 deletions(-)
diff --git a/crates/adapters/core/src/agent/agent.rs b/crates/adapters/core/src/agent/agent.rs
index 4ce7157a..1f70573a 100644
--- a/crates/adapters/core/src/agent/agent.rs
+++ b/crates/adapters/core/src/agent/agent.rs
@@ -27,6 +27,7 @@ use synapse_domain::application::services::model_capability_support::{
#[cfg(test)]
use synapse_domain::application::services::model_lane_resolution::ResolvedModelProfile;
use synapse_domain::application::services::model_lane_resolution::ResolvedModelProfileConfidence;
+use synapse_domain::application::services::model_preset_resolution::resolve_effective_model_lanes;
use synapse_domain::application::services::provider_context_budget::{
assess_provider_context_budget, provider_context_artifact_name,
provider_context_budget_tier_name, provider_context_condensation_mode_name,
@@ -76,7 +77,9 @@ use synapse_domain::application::services::turn_context::{
};
use synapse_domain::application::services::turn_interpretation;
use synapse_domain::application::services::turn_model_routing::resolve_turn_route_override;
-use synapse_domain::config::schema::{CapabilityLane, Config, ContextCompressionConfig};
+use synapse_domain::config::schema::{
+ CapabilityLane, Config, ContextCompressionConfig, ModelRouteConfig,
+};
use synapse_domain::domain::tool_fact::TypedToolFact;
use synapse_domain::domain::tool_repair::{tool_failure_kind_name, ToolRepairTrace};
use synapse_domain::domain::turn_admission::{
@@ -110,6 +113,16 @@ use synapse_security::security_policy_from_config;
const PROVIDER_CONTEXT_CHAT_MESSAGES: usize = 6;
const SESSION_HYGIENE_HARD_MESSAGE_LIMIT: usize = 400;
+#[derive(Debug, Clone)]
+struct AgentModelSelection {
+ provider: String,
+ actual_model: String,
+ provider_model_argument: String,
+ lane: Option,
+ candidate_index: Option,
+ hint: Option,
+}
+
#[derive(Clone, Default)]
pub struct AgentRuntimePorts {
pub conversation_store: Option>,
@@ -123,6 +136,46 @@ pub struct AgentRuntimePorts {
pub history_compaction_cache: Option>,
}
+fn build_provider_router_routes(config: &Config) -> Vec {
+ let mut routes = Vec::new();
+ let mut seen_hints = HashSet::new();
+
+ for lane in resolve_effective_model_lanes(config) {
+ if let Some(candidate) = lane.candidates.first() {
+ push_provider_router_route(
+ &mut routes,
+ &mut seen_hints,
+ ModelRouteConfig {
+ hint: lane.lane.as_str().to_string(),
+ capability: Some(lane.lane),
+ provider: candidate.provider.clone(),
+ model: candidate.model.clone(),
+ api_key: candidate.api_key.clone(),
+ profile: candidate.profile.clone(),
+ },
+ );
+ }
+ }
+
+ for alias in synapse_domain::config::model_catalog::model_route_aliases() {
+ push_provider_router_route(&mut routes, &mut seen_hints, alias);
+ }
+
+ routes
+}
+
+fn push_provider_router_route(
+ routes: &mut Vec,
+ seen_hints: &mut HashSet,
+ route: ModelRouteConfig,
+) {
+ let hint = route.hint.trim();
+ if hint.is_empty() || !seen_hints.insert(hint.to_ascii_lowercase()) {
+ return;
+ }
+ routes.push(route);
+}
+
fn canonicalize_tool_args(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
@@ -292,11 +345,8 @@ pub struct Agent {
memory_session_id: Option,
history: Vec,
classification_config: synapse_domain::config::schema::QueryClassificationConfig,
- available_hints: Vec,
- route_model_by_hint: HashMap,
route_model_preset: Option,
route_model_lanes: Vec,
- route_model_routes: Vec,
current_model_profile:
synapse_domain::application::services::model_lane_resolution::ResolvedModelProfile,
/// Cumulative token usage from the last turn (provider-reported).
@@ -358,11 +408,8 @@ pub struct AgentBuilder {
auto_save: Option,
memory_session_id: Option,
classification_config: Option,
- available_hints: Option>,
- route_model_by_hint: Option>,
route_model_preset: Option