use super::{MigrateError, MigrationResult, section_header_present, value_to_toml_string};
#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
fn migrate_ollama_provider(
llm: &toml_edit::Table,
model: &Option<String>,
base_url: &Option<String>,
embedding_model: &Option<String>,
) -> Vec<String> {
let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
if let Some(m) = model {
block.push_str(&format!("model = \"{m}\"\n"));
}
if let Some(em) = embedding_model {
block.push_str(&format!("embedding_model = \"{em}\"\n"));
}
if let Some(u) = base_url {
block.push_str(&format!("base_url = \"{u}\"\n"));
}
let _ = llm; vec![block]
}
#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
fn migrate_claude_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
block.push_str(&format!("model = \"{m}\"\n"));
}
if let Some(t) = cloud
.get("max_tokens")
.and_then(toml_edit::Item::as_integer)
{
block.push_str(&format!("max_tokens = {t}\n"));
}
if cloud
.get("server_compaction")
.and_then(toml_edit::Item::as_bool)
== Some(true)
{
block.push_str("server_compaction = true\n");
}
if cloud
.get("enable_extended_context")
.and_then(toml_edit::Item::as_bool)
== Some(true)
{
block.push_str("enable_extended_context = true\n");
}
if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
let pairs: Vec<String> = thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
}
if let Some(v) = cloud
.get("prompt_cache_ttl")
.and_then(toml_edit::Item::as_str)
{
if v != "ephemeral" {
block.push_str(&format!("prompt_cache_ttl = \"{v}\"\n"));
}
}
} else if let Some(m) = model {
block.push_str(&format!("model = \"{m}\"\n"));
}
vec![block]
}
#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
fn migrate_openai_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
copy_str_field(openai, "model", &mut block);
copy_str_field(openai, "base_url", &mut block);
copy_int_field(openai, "max_tokens", &mut block);
copy_str_field(openai, "embedding_model", &mut block);
copy_str_field(openai, "reasoning_effort", &mut block);
} else if let Some(m) = model {
block.push_str(&format!("model = \"{m}\"\n"));
}
vec![block]
}
#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
fn migrate_gemini_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
copy_str_field(gemini, "model", &mut block);
copy_int_field(gemini, "max_tokens", &mut block);
copy_str_field(gemini, "base_url", &mut block);
copy_str_field(gemini, "embedding_model", &mut block);
copy_str_field(gemini, "thinking_level", &mut block);
copy_int_field(gemini, "thinking_budget", &mut block);
if let Some(v) = gemini
.get("include_thoughts")
.and_then(toml_edit::Item::as_bool)
{
block.push_str(&format!("include_thoughts = {v}\n"));
}
} else if let Some(m) = model {
block.push_str(&format!("model = \"{m}\"\n"));
}
vec![block]
}
#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
fn migrate_compatible_provider(llm: &toml_edit::Table) -> Vec<String> {
let mut blocks = Vec::new();
if let Some(compat_arr) = llm
.get("compatible")
.and_then(toml_edit::Item::as_array_of_tables)
{
for entry in compat_arr {
let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
copy_str_field(entry, "name", &mut block);
copy_str_field(entry, "base_url", &mut block);
copy_str_field(entry, "model", &mut block);
copy_int_field(entry, "max_tokens", &mut block);
copy_str_field(entry, "embedding_model", &mut block);
blocks.push(block);
}
}
blocks
}
#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
fn migrate_orchestrator_provider(
llm: &toml_edit::Table,
model: &Option<String>,
base_url: &Option<String>,
embedding_model: &Option<String>,
) -> (Vec<String>, Option<String>) {
let mut blocks = Vec::new();
let routing = None;
if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
let default_name = orch
.get("default")
.and_then(toml_edit::Item::as_str)
.unwrap_or("")
.to_owned();
let embed_name = orch
.get("embed")
.and_then(toml_edit::Item::as_str)
.unwrap_or("")
.to_owned();
if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
for (name, pcfg_item) in providers {
let Some(pcfg) = pcfg_item.as_table() else {
continue;
};
let ptype = pcfg
.get("type")
.and_then(toml_edit::Item::as_str)
.unwrap_or("ollama");
let mut block =
format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
if name == default_name {
block.push_str("default = true\n");
}
if name == embed_name {
block.push_str("embed = true\n");
}
copy_str_field(pcfg, "model", &mut block);
copy_str_field(pcfg, "base_url", &mut block);
copy_str_field(pcfg, "embedding_model", &mut block);
if ptype == "claude" && !pcfg.contains_key("model") {
if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
copy_str_field(cloud, "model", &mut block);
copy_int_field(cloud, "max_tokens", &mut block);
}
}
if ptype == "openai" && !pcfg.contains_key("model") {
if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
copy_str_field(openai, "model", &mut block);
copy_str_field(openai, "base_url", &mut block);
copy_int_field(openai, "max_tokens", &mut block);
copy_str_field(openai, "embedding_model", &mut block);
}
}
if ptype == "ollama" && !pcfg.contains_key("base_url") {
if let Some(u) = base_url {
block.push_str(&format!("base_url = \"{u}\"\n"));
}
}
if ptype == "ollama" && !pcfg.contains_key("model") {
if let Some(m) = model {
block.push_str(&format!("model = \"{m}\"\n"));
}
}
if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
if let Some(em) = embedding_model {
block.push_str(&format!("embedding_model = \"{em}\"\n"));
}
}
blocks.push(block);
}
}
}
(blocks, routing)
}
#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
fn migrate_router_provider(
llm: &toml_edit::Table,
model: &Option<String>,
base_url: &Option<String>,
embedding_model: &Option<String>,
) -> (Vec<String>, Option<String>) {
let mut blocks = Vec::new();
let mut routing = None;
if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
let strategy = router
.get("strategy")
.and_then(toml_edit::Item::as_str)
.unwrap_or("ema");
routing = Some(strategy.to_owned());
if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
for item in chain {
let name = item.as_str().unwrap_or_default();
let ptype = infer_provider_type(name, llm);
let mut block =
format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
match ptype {
"claude" => {
if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
copy_str_field(cloud, "model", &mut block);
copy_int_field(cloud, "max_tokens", &mut block);
}
}
"openai" => {
if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table)
{
copy_str_field(openai, "model", &mut block);
copy_str_field(openai, "base_url", &mut block);
copy_int_field(openai, "max_tokens", &mut block);
copy_str_field(openai, "embedding_model", &mut block);
} else {
if let Some(m) = model {
block.push_str(&format!("model = \"{m}\"\n"));
}
if let Some(u) = base_url {
block.push_str(&format!("base_url = \"{u}\"\n"));
}
}
}
"ollama" => {
if let Some(m) = model {
block.push_str(&format!("model = \"{m}\"\n"));
}
if let Some(em) = embedding_model {
block.push_str(&format!("embedding_model = \"{em}\"\n"));
}
if let Some(u) = base_url {
block.push_str(&format!("base_url = \"{u}\"\n"));
}
}
_ => {
if let Some(m) = model {
block.push_str(&format!("model = \"{m}\"\n"));
}
}
}
blocks.push(block);
}
}
}
(blocks, routing)
}
fn strip_task_routing_keys(toml_src: &str) -> String {
let mut in_routes_block = false;
let mut out = Vec::new();
for line in toml_src.lines() {
let trimmed = line.trim();
if trimmed == "[llm.routes]" {
in_routes_block = true;
continue;
}
if in_routes_block {
if trimmed.starts_with('[') {
in_routes_block = false;
} else {
continue;
}
}
if trimmed.starts_with("routing") && trimmed.contains("\"task\"") {
continue;
}
out.push(line);
}
out.join("\n")
}
#[allow(
clippy::too_many_lines,
clippy::format_push_string,
clippy::manual_let_else,
clippy::op_ref,
clippy::collapsible_if
)]
pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
Some(t) => t,
None => {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
};
if llm.get("routing").and_then(toml_edit::Item::as_str) == Some("task") {
let routes_count = llm
.get("routes")
.and_then(toml_edit::Item::as_table)
.map_or(0, toml_edit::Table::len);
let msg = format!(
"routing = \"task\" is no longer supported and has been removed (#3248). \
{routes_count} route(s) in [llm.routes] will be dropped. \
Falling back to default single-provider routing."
);
tracing::warn!("{msg}");
eprintln!("WARNING: {msg}");
let cleaned = strip_task_routing_keys(toml_src);
return migrate_llm_to_providers(&cleaned);
}
let has_provider_field = llm.contains_key("provider");
let has_cloud = llm.contains_key("cloud");
let has_openai = llm.contains_key("openai");
let has_gemini = llm.contains_key("gemini");
let has_orchestrator = llm.contains_key("orchestrator");
let has_router = llm.contains_key("router");
let has_providers = llm.contains_key("providers");
if !has_provider_field
&& !has_cloud
&& !has_openai
&& !has_orchestrator
&& !has_router
&& !has_gemini
{
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
if has_providers {
return Err(MigrateError::Parse(
"cannot migrate: [[llm.providers]] already exists alongside legacy keys"
.parse::<toml_edit::DocumentMut>()
.unwrap_err(),
));
}
let provider_str = llm
.get("provider")
.and_then(toml_edit::Item::as_str)
.unwrap_or("ollama");
let base_url = llm
.get("base_url")
.and_then(toml_edit::Item::as_str)
.map(str::to_owned);
let model = llm
.get("model")
.and_then(toml_edit::Item::as_str)
.map(str::to_owned);
let embedding_model = llm
.get("embedding_model")
.and_then(toml_edit::Item::as_str)
.map(str::to_owned);
let mut provider_blocks: Vec<String> = Vec::new();
let mut routing: Option<String> = None;
match provider_str {
"ollama" => {
provider_blocks.extend(migrate_ollama_provider(
llm,
&model,
&base_url,
&embedding_model,
));
}
"claude" => {
provider_blocks.extend(migrate_claude_provider(llm, &model));
}
"openai" => {
provider_blocks.extend(migrate_openai_provider(llm, &model));
}
"gemini" => {
provider_blocks.extend(migrate_gemini_provider(llm, &model));
}
"compatible" => {
provider_blocks.extend(migrate_compatible_provider(llm));
}
"orchestrator" => {
let (blocks, r) =
migrate_orchestrator_provider(llm, &model, &base_url, &embedding_model);
provider_blocks.extend(blocks);
routing = r;
}
"router" => {
let (blocks, r) = migrate_router_provider(llm, &model, &base_url, &embedding_model);
provider_blocks.extend(blocks);
routing = r;
}
other => {
let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
if let Some(ref m) = model {
block.push_str(&format!("model = \"{m}\"\n"));
}
provider_blocks.push(block);
}
}
if provider_blocks.is_empty() {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let mut new_llm = "[llm]\n".to_owned();
if let Some(ref r) = routing {
new_llm.push_str(&format!("routing = \"{r}\"\n"));
}
for key in &[
"response_cache_enabled",
"response_cache_ttl_secs",
"semantic_cache_enabled",
"semantic_cache_threshold",
"semantic_cache_max_candidates",
"summary_model",
"instruction_file",
] {
if let Some(val) = llm.get(key) {
if let Some(v) = val.as_value() {
let raw = value_to_toml_string(v);
if !raw.is_empty() {
new_llm.push_str(&format!("{key} = {raw}\n"));
}
}
}
}
new_llm.push('\n');
for block in &provider_blocks {
new_llm.push_str(block);
new_llm.push('\n');
}
let output = replace_llm_section(toml_src, &new_llm);
Ok(MigrationResult {
output,
changed_count: provider_blocks.len(),
sections_changed: vec!["llm.providers".to_owned()],
})
}
fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
match name {
"claude" => "claude",
"openai" => "openai",
"gemini" => "gemini",
"ollama" => "ollama",
"candle" => "candle",
_ => {
if llm.contains_key("compatible") {
"compatible"
} else if llm.contains_key("openai") {
"openai"
} else {
"ollama"
}
}
}
}
fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
use std::fmt::Write as _;
if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
let _ = writeln!(out, "{key} = \"{v}\"");
}
}
fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
use std::fmt::Write as _;
if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
let _ = writeln!(out, "{key} = {v}");
}
}
fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
let mut out = String::new();
let mut in_llm = false;
let mut skip_until_next_top = false;
for line in toml_str.lines() {
let trimmed = line.trim();
let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
&& trimmed.ends_with(']')
&& !trimmed[1..trimmed.len() - 1].contains('.');
let is_top_aot = trimmed.starts_with("[[")
&& trimmed.ends_with("]]")
&& !trimmed[2..trimmed.len() - 2].contains('.');
let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
&& (trimmed.contains(']'));
if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
in_llm = true;
skip_until_next_top = true;
continue;
}
if is_top_section || is_top_aot {
if skip_until_next_top {
out.push_str(new_llm_section);
skip_until_next_top = false;
}
in_llm = false;
}
if !skip_until_next_top {
out.push_str(line);
out.push('\n');
}
}
if skip_until_next_top {
out.push_str(new_llm_section);
}
out
}
struct SttFields {
model: Option<String>,
base_url: Option<String>,
provider_hint: String,
}
fn extract_stt_fields(doc: &toml_edit::DocumentMut) -> SttFields {
let stt_table = doc
.get("llm")
.and_then(toml_edit::Item::as_table)
.and_then(|llm| llm.get("stt"))
.and_then(toml_edit::Item::as_table);
let model = stt_table
.and_then(|stt| stt.get("model"))
.and_then(toml_edit::Item::as_str)
.map(ToOwned::to_owned);
let base_url = stt_table
.and_then(|stt| stt.get("base_url"))
.and_then(toml_edit::Item::as_str)
.map(ToOwned::to_owned);
let provider_hint = stt_table
.and_then(|stt| stt.get("provider"))
.and_then(toml_edit::Item::as_str)
.map(ToOwned::to_owned)
.unwrap_or_default();
SttFields {
model,
base_url,
provider_hint,
}
}
fn find_matching_provider_index(
doc: &toml_edit::DocumentMut,
target_type: &str,
provider_hint: &str,
) -> Option<usize> {
let providers = doc
.get("llm")
.and_then(toml_edit::Item::as_table)
.and_then(|llm| llm.get("providers"))
.and_then(toml_edit::Item::as_array_of_tables)?;
providers.iter().enumerate().find_map(|(i, t)| {
let name = t
.get("name")
.and_then(toml_edit::Item::as_str)
.unwrap_or("");
let ptype = t
.get("type")
.and_then(toml_edit::Item::as_str)
.unwrap_or("");
let name_match =
!provider_hint.is_empty() && (name == provider_hint || ptype == provider_hint);
let type_match = ptype == target_type;
if name_match || type_match {
Some(i)
} else {
None
}
})
}
fn attach_stt_to_existing_provider(
doc: &mut toml_edit::DocumentMut,
idx: usize,
stt_model: &str,
stt_base_url: Option<&str>,
) -> Result<String, MigrateError> {
let llm_mut = doc
.get_mut("llm")
.and_then(toml_edit::Item::as_table_mut)
.ok_or(MigrateError::InvalidStructure(
"[llm] table not accessible for mutation",
))?;
let providers_mut = llm_mut
.get_mut("providers")
.and_then(toml_edit::Item::as_array_of_tables_mut)
.ok_or(MigrateError::InvalidStructure(
"[[llm.providers]] array not accessible for mutation",
))?;
let entry = providers_mut
.iter_mut()
.nth(idx)
.ok_or(MigrateError::InvalidStructure(
"[[llm.providers]] entry index out of range during mutation",
))?;
let existing_name = entry
.get("name")
.and_then(toml_edit::Item::as_str)
.map(ToOwned::to_owned);
let entry_name = existing_name.unwrap_or_else(|| {
let t = entry
.get("type")
.and_then(toml_edit::Item::as_str)
.unwrap_or("openai");
format!("{t}-stt")
});
entry.insert("name", toml_edit::value(entry_name.clone()));
entry.insert("stt_model", toml_edit::value(stt_model));
if let Some(url) = stt_base_url
&& entry.get("base_url").is_none()
{
entry.insert("base_url", toml_edit::value(url));
}
Ok(entry_name)
}
fn append_new_stt_provider(
doc: &mut toml_edit::DocumentMut,
target_type: &str,
stt_model: &str,
stt_base_url: Option<&str>,
) -> Result<String, MigrateError> {
let new_name = if target_type == "candle" {
"local-whisper".to_owned()
} else {
"openai-stt".to_owned()
};
let mut new_entry = toml_edit::Table::new();
new_entry.insert("name", toml_edit::value(new_name.clone()));
new_entry.insert("type", toml_edit::value(target_type));
new_entry.insert("stt_model", toml_edit::value(stt_model));
if let Some(url) = stt_base_url {
new_entry.insert("base_url", toml_edit::value(url));
}
let llm_mut = doc
.get_mut("llm")
.and_then(toml_edit::Item::as_table_mut)
.ok_or(MigrateError::InvalidStructure(
"[llm] table not accessible for mutation",
))?;
if let Some(item) = llm_mut.get_mut("providers") {
if let Some(arr) = item.as_array_of_tables_mut() {
arr.push(new_entry);
}
} else {
let mut arr = toml_edit::ArrayOfTables::new();
arr.push(new_entry);
llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
}
Ok(new_name)
}
fn rewrite_stt_section(doc: &mut toml_edit::DocumentMut, resolved_provider_name: &str) {
if let Some(stt_table) = doc
.get_mut("llm")
.and_then(toml_edit::Item::as_table_mut)
.and_then(|llm| llm.get_mut("stt"))
.and_then(toml_edit::Item::as_table_mut)
{
stt_table.insert("provider", toml_edit::value(resolved_provider_name));
stt_table.remove("model");
stt_table.remove("base_url");
}
}
pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
let stt = extract_stt_fields(&doc);
if stt.model.is_none() && stt.base_url.is_none() {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let stt_model = stt.model.unwrap_or_else(|| "whisper-1".to_owned());
let target_type = match stt.provider_hint.as_str() {
"candle-whisper" | "candle" => "candle",
_ => "openai",
};
let resolved_name = match find_matching_provider_index(&doc, target_type, &stt.provider_hint) {
Some(idx) => {
attach_stt_to_existing_provider(&mut doc, idx, &stt_model, stt.base_url.as_deref())?
}
None => {
append_new_stt_provider(&mut doc, target_type, &stt_model, stt.base_url.as_deref())?
}
};
rewrite_stt_section(&mut doc, &resolved_name);
Ok(MigrationResult {
output: doc.to_string(),
changed_count: 1,
sections_changed: vec!["llm.providers.stt_model".to_owned()],
})
}
pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
let old_value = doc
.get("orchestration")
.and_then(toml_edit::Item::as_table)
.and_then(|t| t.get("planner_model"))
.and_then(toml_edit::Item::as_value)
.and_then(toml_edit::Value::as_str)
.map(ToOwned::to_owned);
let Some(old_model) = old_value else {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
};
let commented_out = format!(
"# planner_provider = \"{old_model}\" \
# MIGRATED: was planner_model; update to a [[llm.providers]] name"
);
let orch_table = doc
.get_mut("orchestration")
.and_then(toml_edit::Item::as_table_mut)
.ok_or(MigrateError::InvalidStructure(
"[orchestration] is not a table",
))?;
orch_table.remove("planner_model");
let decor = orch_table.decor_mut();
let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
let new_suffix = if existing_suffix.trim().is_empty() {
format!("\n{commented_out}\n")
} else {
format!("{existing_suffix}\n{commented_out}\n")
};
decor.set_suffix(new_suffix);
eprintln!(
"Migration warning: [orchestration].planner_model has been renamed to planner_provider \
and its value commented out. `planner_provider` must reference a [[llm.providers]] \
`name` field, not a raw model name. Update or remove the commented line."
);
Ok(MigrationResult {
output: doc.to_string(),
changed_count: 1,
sections_changed: vec!["orchestration.planner_provider".to_owned()],
})
}
pub fn migrate_orchestration_orchestrator_provider(
toml_src: &str,
) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("orchestrator_provider") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n# Provider for scheduling-tier LLM calls (aggregation, predicate, verify fallback).\n\
# Set to a cheap/fast model to reduce orchestration cost. Empty = primary provider.\n\
# Add under the orchestration section in your config:\n\
# orchestrator_provider = \"\"\n";
Ok(MigrationResult {
output: format!("{toml_src}{comment}"),
changed_count: 1,
sections_changed: vec!["orchestration".to_owned()],
})
}
pub fn migrate_provider_max_concurrent(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("max_concurrent") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
if !toml_src.contains("[[llm.providers]]") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n# Optional: maximum concurrent sub-agent calls to this provider (admission control).\n\
# Remove the comment to enable; omit or set to 0 for unlimited.\n\
# max_concurrent = 4\n";
Ok(MigrationResult {
output: format!("{toml_src}{comment}"),
changed_count: 1,
sections_changed: vec!["llm.providers".to_owned()],
})
}
pub(crate) fn migrate_gonkagate_to_gonka(toml_src: &str) -> MigrationResult {
const MARKER: &str = "# [migration] GonkaGate detected: consider migrating to type = \"gonka\"";
if !toml_src.contains("gonkagate") {
return MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: vec![],
};
}
let mut changed_count = 0;
let mut lines: Vec<String> = toml_src.lines().map(str::to_owned).collect();
let indices: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.contains("gonkagate"))
.map(|(i, _)| i)
.rev()
.collect();
for gonka_idx in indices {
let header_idx = (0..=gonka_idx)
.rev()
.find(|&i| lines[i].starts_with("[["))
.unwrap_or(gonka_idx);
let already_marked = header_idx > 0 && lines[header_idx - 1].contains(MARKER);
if already_marked {
continue;
}
lines.insert(
header_idx,
format!("{MARKER} (see docs/guides/gonka-native.md)"),
);
changed_count += 1;
}
let output = lines.join("\n");
let output = if toml_src.ends_with('\n') {
format!("{output}\n")
} else {
output
};
MigrationResult {
output,
changed_count,
sections_changed: if changed_count > 0 {
vec!["llm".into()]
} else {
vec![]
},
}
}
pub fn migrate_cocoon_provider_notice(toml_src: &str) -> Result<MigrationResult, MigrateError> {
Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: vec![],
})
}
pub fn migrate_embed_provider_rename(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let has_old =
toml_src.contains("embed_provider") || toml_src.contains("trace_extraction_embed_provider");
if !has_old {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let mut changed_count = 0usize;
let mut sections_changed = Vec::new();
let output = toml_src
.lines()
.map(|line| {
let trimmed = line.trim_start();
if trimmed.starts_with("trace_extraction_embed_provider") {
let replaced = line.replacen(
"trace_extraction_embed_provider",
"trace_extraction_embedding_provider",
1,
);
changed_count += 1;
if !sections_changed.contains(&"learning".to_owned()) {
sections_changed.push("learning".to_owned());
}
return replaced;
}
if trimmed.starts_with("embed_provider") {
let replaced = line.replacen("embed_provider", "embedding_provider", 1);
changed_count += 1;
return replaced;
}
line.to_owned()
})
.collect::<Vec<_>>()
.join("\n");
let output = if toml_src.ends_with('\n') && !output.ends_with('\n') {
format!("{output}\n")
} else {
output
};
Ok(MigrationResult {
output,
changed_count,
sections_changed,
})
}
pub fn migrate_cocoon_show_balance(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("show_balance") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let section = "\n[cocoon]\n\
# show_balance = true \
# set to false to redact TON balance in TUI status bar (spec §15.2) (#4649)\n";
let output = format!("{toml_src}{section}");
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["cocoon".to_owned()],
})
}
pub fn migrate_llm_stream_limits(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if section_header_present(toml_src, "llm.stream_limits") || !toml_src.contains("[llm]") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n# SSE streaming buffer caps (#4750). Defaults match pre-existing behavior.\n\
# [llm.stream_limits]\n\
# max_tool_json_bytes = 4194304 # 4 MiB\n\
# max_thinking_bytes = 1048576 # 1 MiB\n\
# max_compaction_bytes = 32768 # 32 KiB\n";
let output = format!("{toml_src}{comment}");
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["llm.stream_limits".to_owned()],
})
}
pub fn migrate_eval_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
let old_value = doc
.get("experiments")
.and_then(toml_edit::Item::as_table)
.and_then(|t| t.get("eval_model"))
.and_then(toml_edit::Item::as_value)
.and_then(toml_edit::Value::as_str)
.map(ToOwned::to_owned);
let Some(old_model) = old_value else {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
};
let commented_out = format!(
"# eval_provider = \"{old_model}\" \
# MIGRATED: was eval_model; update to a [[llm.providers]] name"
);
let exp_table = doc
.get_mut("experiments")
.and_then(toml_edit::Item::as_table_mut)
.ok_or(MigrateError::InvalidStructure(
"[experiments] is not a table",
))?;
exp_table.remove("eval_model");
let decor = exp_table.decor_mut();
let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
let new_suffix = if existing_suffix.trim().is_empty() {
format!("\n{commented_out}\n")
} else {
format!("{existing_suffix}\n{commented_out}\n")
};
decor.set_suffix(new_suffix);
eprintln!(
"Migration warning: [experiments].eval_model has been renamed to eval_provider \
and its value commented out. `eval_provider` must reference a [[llm.providers]] \
`name` field, not a raw model name. Update or remove the commented line."
);
Ok(MigrationResult {
output: doc.to_string(),
changed_count: 1,
sections_changed: vec!["experiments.eval_provider".to_owned()],
})
}