Files
Kigi-CLI/crates/codegen/kigi-shell/src/agent/handlers/model_switch.rs
T
ZacharyZhang-NY 48d89c7830 refactor(auth): centralize inference-credential routing in CredentialAuthority
One authority answers 'which credential may ride this request':
credential_class / manager_for / credential_for / bearer_resolver_for,
keyed by (platform, base_url). SessionCredential is an opaque type with
no production constructor, so a new call site cannot re-introduce the
session-bearer leak. Platform-scoped tests extended across all bearer
channels (session, aux, summary, subagent override).

Verified: cargo check --workspace --all-targets clean; kigi-shell and
kigi-tui suites green (6611+ tests).
2026-07-22 15:12:00 -04:00

266 lines
11 KiB
Rust

//! Applies a model switch to a session — the ungated path. `set_session_model`
//! enforces the `allowed_models` gate before delegating here; internal callers
//! (`new_session`, `load_session`) call `apply` directly.
use crate::agent::config;
use crate::agent::mvp_agent::{
MvpAgent, agent_name_after_model_switch, harnesses_are_compatible, resolve_required_agent_type,
};
use crate::session::SessionCommand;
use agent_client_protocol::{self as acp};
use kigi_sampling_types::parse_reasoning_effort_meta;
use tokio::sync::oneshot;
/// Apply a model switch to a session (no gate — `set_session_model` gates first).
pub(crate) async fn apply(
agent: &MvpAgent,
args: acp::SetSessionModelRequest,
) -> Result<acp::SetSessionModelResponse, acp::Error> {
tracing::info!("Received set session model request {args:?}");
kigi_log::unified_log::info(
"model changed",
Some(args.session_id.0.as_ref()),
Some(serde_json::json!({ "model" : args.model_id.0.as_ref() })),
);
tracing::debug!("session_session_model::mvp_agent: {:?}", &args);
let effort_override = parse_reasoning_effort_meta(args.meta.as_ref());
let acp::SetSessionModelRequest {
session_id,
model_id,
..
} = args;
let handle = agent
.session_handle_waiting_for_load(&session_id)
.await
.ok_or_else(|| acp::Error::invalid_params().data("unknown session id"))?;
let model = agent.resolve_model_id(&model_id)?;
let use_concise = model.info().use_concise;
let session_default = handle
.session_default_agent_profile
.as_deref()
.unwrap_or(&handle.agent_name);
let required_agent_type =
resolve_required_agent_type(Some(model.info().agent_type.as_str()), session_default);
let previous_model_id = handle.model_id.0.clone();
let mut pending_rebuild_definition: Option<kigi_agent::AgentDefinition> = None;
{
let required = &required_agent_type;
let turn_count = handle
.signals_handle
.snapshot()
.await
.map(|s| s.turn_count)
.unwrap_or(0);
let (agent_tx, agent_rx) = oneshot::channel();
let _ = handle.cmd_tx.send(SessionCommand::GetActiveAgent {
responds_to: agent_tx,
});
let active_agent_type = agent_rx.await.ok().flatten();
let is_mismatch = active_agent_type
.as_ref()
.is_some_and(|active| !harnesses_are_compatible(active, required));
tracing::info!(
session_id = % session_id.0, model_id = % model_id.0, ? required_agent_type,
? active_agent_type, turn_count, is_mismatch,
"set_session_model: agent type compatibility check"
);
if is_mismatch && turn_count > 0 {
tracing::warn!(
session_id = % session_id.0, model_id = % model_id.0, active_agent = ?
active_agent_type, required_agent = % required, turn_count,
"set_session_model: agent type mismatch rejected"
);
let err_payload = config::ModelSwitchIncompatibleAgentError {
code: config::MODEL_SWITCH_INCOMPATIBLE_AGENT.to_string(),
active_agent_type: active_agent_type.unwrap_or_else(|| "unknown".to_owned()),
required_agent_type: required.clone(),
model_id: model_id.0.to_string(),
suggestion: "start_new_session".to_string(),
};
return Err(err_payload.into_acp_error());
}
if is_mismatch && turn_count == 0 {
let cwd = handle.tool_context.cwd.as_path();
let resolved = kigi_agent::discovery::by_name_in_cwd_with_plugins(
required,
cwd,
agent.plugin_registry_handle.snapshot().as_deref(),
);
match resolved {
Some(def) => {
tracing::info!(
session_id = % session_id.0, model_id = % model_id.0,
required_agent_type = % required, agent_def_name = % def.name,
"set_session_model: zero-turn harness switch — queued agent rebuild"
);
pending_rebuild_definition = Some(def);
}
None => {
tracing::warn!(
session_id = % session_id.0, model_id = % model_id.0,
required_agent_type = % required,
"set_session_model: zero-turn harness switch — could not resolve agent definition; proceeding with stale harness"
);
}
}
}
}
let mut model_sampling =
agent.prepare_sampling_config_for_model(&model, handle.origin_client.clone());
if let Some(eff) = effort_override {
if agent
.models_manager
.model_supports_reasoning_effort(model_id.0.as_ref())
{
// Legacy migration: pre-split sessions persisted canonical
// `xhigh` for models whose live menu now spells the top tier
// `max` (K3). The wire is identical either way (kimi_compat
// renames xhigh→max), but the menu has no xhigh-valued row, so
// display/active-row would drift from the model vocabulary and
// the stale token would be re-persisted forever. Migrate once.
let eff = if eff == kigi_sampling_types::ReasoningEffort::Xhigh
&& !agent
.models_manager
.model_offers_effort(model_id.0.as_ref(), eff)
&& agent.models_manager.model_offers_effort(
model_id.0.as_ref(),
kigi_sampling_types::ReasoningEffort::Max,
) {
tracing::info!(
session_id = % session_id.0,
"set_session_model: migrating legacy xhigh override to max \
(model menu offers max, not xhigh)"
);
kigi_sampling_types::ReasoningEffort::Max
} else {
eff
};
tracing::info!(
session_id = % session_id.0, effort = % eff,
"set_session_model: applying reasoning_effort override from meta"
);
model_sampling.reasoning_effort = Some(eff);
} else {
tracing::warn!(
session_id = % session_id.0, model_id = % model_id.0, effort = % eff,
"set_session_model: ignoring reasoning_effort override — model does not support it"
);
}
}
let applied_effort = model_sampling.reasoning_effort;
let gate_closed = !handle
.gateway_enabled
.load(std::sync::atomic::Ordering::Relaxed);
let apply_prompt_override = !gate_closed;
if gate_closed {
tracing::info!(
session_id = % session_id.0, model_id = % model_id.0,
"set_session_model: gateway gate closed, prompt override suppressed"
);
pending_rebuild_definition = None;
}
let did_rebuild = if let Some(def) = pending_rebuild_definition {
let (rebuild_tx, rebuild_rx) = oneshot::channel();
let _ = handle
.cmd_tx
.send(SessionCommand::RebuildAgentForDefinition {
definition: def,
responds_to: rebuild_tx,
});
let rebuild_result = rebuild_rx
.await
.map_err(|_| acp::Error::internal_error().data("rebuild_agent: actor closed"))?;
match rebuild_result {
Ok(()) => true,
Err(e) => {
tracing::error!(
session_id = % session_id.0, model_id = % model_id.0, error = ? e,
"set_session_model: zero-turn harness rebuild failed; aborting model switch"
);
return Err(e);
}
}
} else {
false
};
let model_unchanged = previous_model_id == model_id.0;
let new_threshold = {
let cfg = agent.cfg.borrow();
let models = agent.models_manager.models();
let model = config::find_model_by_id(&models, model_sampling.model.as_str());
crate::util::config::resolve_auto_compact_threshold_percent(
&cfg,
model_sampling.model.as_str(),
model.map(|e| &e.info),
)
};
// H4: hand the session the catalog KEY the picker actually resolved. The
// slug in `model_sampling.model` cannot distinguish `xai/grok-*` from
// `xai-grok/grok-*` (duplicate ids across an API-key platform and its
// subscription-OAuth twin are by design), and the process-global
// `current_model_id()` below is not written at all in Leader mode.
let catalog_key =
crate::agent::models::resolve_catalog_key(&agent.models_manager.models(), &model_id)
.map(|k| k.0.to_string());
let (tx, rx) = oneshot::channel();
let _ = handle.cmd_tx.send(SessionCommand::SetSessionModel {
sampling_config: model_sampling,
catalog_key,
use_concise,
apply_prompt_override,
skip_prompt_rewrite: did_rebuild || model_unchanged,
auto_compact_threshold_percent: new_threshold,
responds_to: tx,
});
let updated_model = rx
.await
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))?;
if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) {
handle.model_id = model_id.clone();
handle.reasoning_effort = applied_effort;
handle.agent_name =
agent_name_after_model_switch(did_rebuild, &required_agent_type, &handle.agent_name);
}
broadcast_model_changed(
agent,
&session_id,
model_id.0.as_ref(),
applied_effort.map(|eff| eff.to_string()),
);
if agent.cfg.borrow().mode != config::AgentMode::Leader {
agent.models_manager.set_current_model_id(model_id);
agent
.models_manager
.set_current_reasoning_effort(applied_effort);
}
Ok(acp::SetSessionModelResponse::new().meta(
serde_json::json!({ "model" : updated_model, })
.as_object()
.cloned(),
))
}
/// Broadcast a `ModelChanged` to every client subscribed to this session so
/// followers mirror the new model. The originating client ignores its own echo
/// (gated by `model_switch_pending`). Broadcast-only — no eventId, not persisted.
fn broadcast_model_changed(
agent: &MvpAgent,
session_id: &acp::SessionId,
model_id: &str,
reasoning_effort: Option<String>,
) {
let notification = crate::extensions::notification::SessionNotification {
session_id: session_id.clone(),
update: crate::extensions::notification::SessionUpdate::ModelChanged {
model_id: model_id.to_owned(),
reasoning_effort,
},
meta: None,
};
if let Ok(params) = serde_json::value::to_raw_value(&notification) {
agent
.gateway
.forward_fire_and_forget(acp::ExtNotification::new(
"kigi/session_notification",
params.into(),
));
}
}