Files
tkmind_go/crates/goose/src/model_config.rs
T
2026-08-25 02:22:06 +00:00

328 lines
11 KiB
Rust

use crate::config::{Config, ConfigError};
use crate::conversation::message::Message;
use crate::providers::base::Provider;
use anyhow::{anyhow, Result};
use goose_providers::conversation::token_usage::ProviderUsage;
use goose_providers::errors::ProviderError;
use goose_providers::model::ModelConfig;
use goose_providers::thinking::ThinkingEffort;
use rmcp::model::Tool;
use serde_json::Value;
use std::collections::HashMap;
pub fn model_config_from_user_config(
provider_name: &str,
model_name: impl AsRef<str>,
) -> Result<ModelConfig> {
let model = base_model_config_from_user_config(provider_name, model_name.as_ref())?;
materialize_model_config(provider_name, model)
}
pub fn model_config_from_user_config_with_session_settings(
provider_name: &str,
model_name: impl AsRef<str>,
previous: Option<&ModelConfig>,
request_params: Option<HashMap<String, Value>>,
context_limit: Option<usize>,
) -> Result<ModelConfig> {
let config = Config::global();
let model = base_model_config_from_user_config(provider_name, model_name.as_ref())?;
let model = materialize_model_config_inner(model, provider_name, false)?
.with_context_limit(context_limit)
.with_inherited_session_settings_from(previous, request_params)
.with_default_thinking_effort(config.get_goose_thinking_effort());
Ok(apply_canonical_limits(provider_name, model))
}
pub fn materialize_model_config(provider_name: &str, model: ModelConfig) -> Result<ModelConfig> {
let model = materialize_model_config_inner(model, provider_name, true)?;
Ok(apply_canonical_limits(provider_name, model))
}
fn apply_canonical_limits(provider_name: &str, model: ModelConfig) -> ModelConfig {
if provider_name == goose_providers::azure_foundry::AZURE_FOUNDRY_PROVIDER_NAME {
model
} else {
model.with_canonical_limits(provider_name)
}
}
fn materialize_model_config_inner(
mut model: ModelConfig,
provider_name: &str,
include_default_thinking_effort: bool,
) -> Result<ModelConfig> {
let config = Config::global();
if model.temperature.is_none() {
model = model.with_temperature(get_goose_temperature(config)?);
}
if model.toolshim && model.toolshim_model.is_none() {
model = model.with_toolshim_model(get_goose_toolshim_model(config)?);
}
model = model
.with_default_context_limit(config.get_goose_context_limit()?)
.with_default_max_tokens(config.get_goose_max_tokens()?);
if include_default_thinking_effort {
model = model.with_default_thinking_effort(config.get_goose_thinking_effort());
}
if provider_name == goose_providers::openai::OPEN_AI_PROVIDER_NAME {
model = apply_openai_request_params(model);
}
Ok(model)
}
fn configured_fast_model_name() -> Option<String> {
Config::global()
.get_param::<String>("GOOSE_FAST_MODEL")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
/// Resolve the model config to use for lightweight "fast" tasks (session
/// naming, tool-call labels, orchestrator routing). Resolution order:
/// 1. `GOOSE_FAST_MODEL` (user override)
/// 2. the provider's declared default fast model
/// 3. the supplied `model_config` (i.e. the main model)
///
/// The resulting config is materialized against the same provider so it picks
/// up context limits, temperature, and other provider defaults.
pub async fn get_fast_model(
provider_name: &str,
model_config: &ModelConfig,
) -> Result<ModelConfig> {
let fast_model_name = match configured_fast_model_name() {
Some(name) => Some(name),
None => provider_default_fast_model(provider_name).await,
};
match fast_model_name {
Some(name) if name != model_config.model_name => {
model_config_from_user_config(provider_name, name)
.map(|config| config.with_request_headers(model_config.request_headers.clone()))
}
_ => Ok(model_config.clone()),
}
}
/// A one-shot task summarizes a transcript or tool result that never recurs, so
/// a prompt cache entry written for it can never be read back and only costs the
/// cache-write premium.
fn one_shot_model_config(model_config: ModelConfig) -> ModelConfig {
model_config
.with_thinking_effort(ThinkingEffort::Off)
.with_prompt_cache_disabled()
}
/// Run a completion for a lightweight "fast" task (session naming, tool-call
/// labels, orchestrator routing) using the provider's fast model.
pub async fn complete_fast(
provider: &dyn Provider,
model_config: &ModelConfig,
session_id: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
fallback_to_main_model: bool,
) -> Result<(Message, ProviderUsage), ProviderError> {
let fast_model_config = one_shot_model_config(
get_fast_model(provider.get_name(), model_config)
.await
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?,
);
match crate::session_context::with_session_id(
Some(session_id.to_string()),
provider.complete(&fast_model_config, system, messages, tools),
)
.await
{
Ok(response) => Ok(response),
Err(e)
if fallback_to_main_model
&& fast_model_config.model_name != model_config.model_name =>
{
tracing::warn!(
"Fast model {} failed with error: {}. Falling back to main model {}",
fast_model_config.model_name,
e,
model_config.model_name
);
let fallback_config = one_shot_model_config(model_config.clone());
crate::session_context::with_session_id(
Some(session_id.to_string()),
provider.complete(&fallback_config, system, messages, tools),
)
.await
}
Err(e) => Err(e),
}
}
/// Run a completion for compaction or tool-result summarization on the main
/// session model with one-shot semantics (thinking off, no prompt-cache writes).
pub async fn complete_compaction(
provider: &dyn Provider,
model_config: &ModelConfig,
session_id: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<(Message, ProviderUsage), ProviderError> {
let compaction_model_config = one_shot_model_config(model_config.clone());
crate::session_context::with_session_id(
Some(session_id.to_string()),
provider.complete(&compaction_model_config, system, messages, tools),
)
.await
}
async fn provider_default_fast_model(provider_name: &str) -> Option<String> {
if provider_name == goose_providers::openai::OPEN_AI_PROVIDER_NAME {
return crate::providers::openai_def::live_fast_model();
}
crate::providers::get_from_registry(provider_name)
.await
.ok()
.and_then(|entry| entry.metadata().fast_model.clone())
}
fn apply_openai_request_params(mut model: ModelConfig) -> ModelConfig {
let config = Config::global();
if let Some(store) = config.get_openai_store() {
model = model.with_merged_request_params(HashMap::from([(
"store".to_string(),
serde_json::json!(store),
)]));
}
model
}
fn base_model_config_from_user_config(
provider_name: &str,
model_name: &str,
) -> Result<ModelConfig> {
let config = Config::global();
let mut model = ModelConfig {
model_name: model_name.to_string(),
context_limit: None,
temperature: get_goose_temperature(config)?,
max_tokens: None,
toolshim: get_goose_toolshim(config)?.unwrap_or(false),
toolshim_model: get_goose_toolshim_model(config)?,
request_params: None,
reasoning: None,
supports_vision: None,
request_headers: None,
};
if provider_name != goose_providers::azure_foundry::AZURE_FOUNDRY_PROVIDER_NAME {
model.normalize_effort_suffix();
}
Ok(model)
}
fn get_goose_temperature(config: &Config) -> Result<Option<f32>> {
match config.get_param::<f32>("GOOSE_TEMPERATURE") {
Ok(temp) if temp < 0.0 => Err(anyhow!(
"Value for 'GOOSE_TEMPERATURE' is out of valid range: {temp}"
)),
Ok(temp) => Ok(Some(temp)),
Err(ConfigError::NotFound(_)) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn get_goose_toolshim(config: &Config) -> Result<Option<bool>> {
match config.get_param::<serde_yaml::Value>("GOOSE_TOOLSHIM") {
Ok(value) => parse_yaml_bool_config("GOOSE_TOOLSHIM", value).map(Some),
Err(ConfigError::NotFound(_)) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Resolve the global toolshim setting, defaulting to false when unset.
pub fn global_toolshim() -> bool {
get_goose_toolshim(Config::global())
.ok()
.flatten()
.unwrap_or(false)
}
fn get_goose_toolshim_model(config: &Config) -> Result<Option<String>> {
match config.get_param::<String>("GOOSE_TOOLSHIM_OLLAMA_MODEL") {
Ok(value) if value.trim().is_empty() => Err(anyhow!(
"Invalid value for 'GOOSE_TOOLSHIM_OLLAMA_MODEL': '{value}' - cannot be empty if set"
)),
Ok(value) => Ok(Some(value)),
Err(ConfigError::NotFound(_)) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn parse_bool_config(key: &str, value: &str) -> Result<bool> {
match value.to_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(anyhow!(
"Invalid value for '{key}': '{value}' - must be one of: 1, true, yes, on, 0, false, no, off"
)),
}
}
fn parse_yaml_bool_config(key: &str, value: serde_yaml::Value) -> Result<bool> {
match value {
serde_yaml::Value::Bool(value) => Ok(value),
serde_yaml::Value::Number(value) => parse_bool_config(key, &value.to_string()),
serde_yaml::Value::String(value) => parse_bool_config(key, &value),
other => {
Err(anyhow!(
"Invalid value for '{key}': '{}' - must be one of: 1, true, yes, on, 0, false, no, off",
serde_yaml::to_string(&other).unwrap_or_else(|_| "<unprintable>".to_string()).trim()
))
}
}
}
#[cfg(test)]
mod one_shot_tests {
use super::*;
#[test]
fn prompt_cache_is_disabled() {
assert!(one_shot_model_config(ModelConfig::new("claude-haiku-4-5")).prompt_cache_disabled());
}
}
#[cfg(test)]
mod azure_foundry_tests {
use super::*;
#[test]
fn deployment_name_survives_thinking_effort_changes() {
let config = base_model_config_from_user_config("azure_foundry", "gpt-5-high")
.unwrap()
.with_thinking_effort(ThinkingEffort::Off);
assert_eq!(config.model_name, "gpt-5-high");
assert_eq!(config.context_limit, None);
assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Off));
}
#[test]
fn none_suffixed_deployment_name_is_preserved() {
let config = base_model_config_from_user_config("azure_foundry", "gpt-5-none").unwrap();
assert_eq!(config.model_name, "gpt-5-none");
assert_eq!(config.thinking_effort(), None);
}
}