Separate model reasoning selections and reduce agent notification noise

This commit is contained in:
2026-10-25 01:31:39 -05:00
parent 55eb87a983
commit 596b7266a4
19 changed files with 829 additions and 138 deletions
+121 -24
View File
@@ -2,15 +2,15 @@ use std::collections::HashSet;
use std::sync::{Arc, OnceLock};
use async_trait::async_trait;
use futures::StreamExt;
use futures::future::BoxFuture;
use futures::StreamExt;
use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities,
RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest,
};
use rig_core::client::{CompletionClient, ModelListingClient};
use rig_core::providers::gemini;
use rig_core::http_client::{self, HttpClientExt};
use rig_core::providers::gemini;
use crate::anthropic_auth::{anthropic_client, prepare_subscription_request, restore_tool_names};
use crate::request::build_completion_request;
@@ -22,6 +22,7 @@ pub struct RigModelInfo {
pub display_name: String,
pub context_size: Option<u32>,
pub max_output_tokens: Option<u32>,
pub reasoning_efforts: Vec<String>,
}
pub async fn discover_anthropic_models(api_key: &str) -> Result<Vec<RigModelInfo>, String> {
@@ -30,30 +31,48 @@ pub async fn discover_anthropic_models(api_key: &str) -> Result<Vec<RigModelInfo
let mut cursors = HashSet::new();
let mut path = "/v1/models?limit=1000".to_string();
loop {
let request = client.get(&path).and_then(|request| Ok(request.body(http_client::NoBody)?))
let request = client
.get(&path)
.and_then(|request| Ok(request.body(http_client::NoBody)?))
.map_err(|_| "Could not build the Anthropic models request.".to_string())?;
let response = client.send::<_, Vec<u8>>(request).await
let response = client
.send::<_, Vec<u8>>(request)
.await
.map_err(|_| "Could not retrieve Anthropic models.".to_string())?;
if !response.status().is_success() {
return Err(format!("Anthropic model discovery returned HTTP {}.", response.status()));
return Err(format!(
"Anthropic model discovery returned HTTP {}.",
response.status()
));
}
let body = response.into_body().await
let body = response
.into_body()
.await
.map_err(|_| "Could not read the Anthropic models response.".to_string())?;
let page: serde_json::Value = serde_json::from_slice(&body)
.map_err(|_| "Invalid Anthropic models response.".to_string())?;
let entries = page["data"].as_array()
let entries = page["data"]
.as_array()
.ok_or_else(|| "Anthropic models response is missing its model list.".to_string())?;
for entry in entries {
models.push(anthropic_model_info(entry)?);
}
match page["has_more"].as_bool() {
Some(false) => return Ok(models),
Some(true) => {},
None => return Err("Anthropic models response is missing pagination state.".to_string()),
Some(true) => {}
None => {
return Err("Anthropic models response is missing pagination state.".to_string())
}
}
let cursor = page["last_id"].as_str().filter(|cursor| {
!cursor.is_empty() && cursor.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}).ok_or_else(|| "Invalid Anthropic model pagination cursor.".to_string())?;
let cursor = page["last_id"]
.as_str()
.filter(|cursor| {
!cursor.is_empty()
&& cursor.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
})
})
.ok_or_else(|| "Invalid Anthropic model pagination cursor.".to_string())?;
if !cursors.insert(cursor.to_string()) {
return Err("Anthropic model pagination did not advance.".to_string());
}
@@ -62,17 +81,24 @@ pub async fn discover_anthropic_models(api_key: &str) -> Result<Vec<RigModelInfo
}
fn positive_token_limit(value: &serde_json::Value) -> Option<u32> {
value.as_u64().and_then(|tokens| u32::try_from(tokens).ok()).filter(|tokens| *tokens > 0)
value
.as_u64()
.and_then(|tokens| u32::try_from(tokens).ok())
.filter(|tokens| *tokens > 0)
}
fn anthropic_model_info(entry: &serde_json::Value) -> Result<RigModelInfo, String> {
let id = entry["id"].as_str().filter(|id| !id.is_empty())
let id = entry["id"]
.as_str()
.filter(|id| !id.is_empty())
.ok_or_else(|| "Anthropic returned a model without an ID.".to_string())?;
// Read live limits first. Rig's generic model lister drops these fields.
// The models.dev snapshot is metadata only, never an availability allowlist.
static LIMITS: OnceLock<serde_json::Value> = OnceLock::new();
let limits = LIMITS.get_or_init(|| serde_json::from_str(include_str!("../data/anthropic_limits.json"))
.expect("bundled Anthropic limits must be valid JSON"));
let limits = LIMITS.get_or_init(|| {
serde_json::from_str(include_str!("../data/anthropic_limits.json"))
.expect("bundled Anthropic limits must be valid JSON")
});
let fallback = &limits["models"][id];
Ok(RigModelInfo {
id: id.to_string(),
@@ -81,9 +107,22 @@ fn anthropic_model_info(entry: &serde_json::Value) -> Result<RigModelInfo, Strin
.or_else(|| positive_token_limit(&fallback["context"])),
max_output_tokens: positive_token_limit(&entry["max_tokens"])
.or_else(|| positive_token_limit(&fallback["output"])),
reasoning_efforts: anthropic_reasoning_efforts(entry),
})
}
fn anthropic_reasoning_efforts(entry: &serde_json::Value) -> Vec<String> {
let effort = &entry["capabilities"]["effort"];
if effort["supported"].as_bool() != Some(true) {
return Vec::new();
}
["low", "medium", "high", "xhigh", "max"]
.into_iter()
.filter(|level| effort[*level]["supported"].as_bool() == Some(true))
.map(str::to_string)
.collect()
}
pub async fn discover_gemini_models(api_key: &str) -> Result<Vec<RigModelInfo>, String> {
let client = gemini::Client::new(api_key).map_err(|error| error.to_string())?;
let models = client
@@ -98,6 +137,7 @@ pub async fn discover_gemini_models(api_key: &str) -> Result<Vec<RigModelInfo>,
id: model.id,
context_size: model.context_length,
max_output_tokens: None,
reasoning_efforts: Vec::new(),
})
.collect())
}
@@ -125,6 +165,7 @@ pub fn vertex_ai_model_catalog() -> Vec<RigModelInfo> {
display_name: display_name.to_string(),
context_size: None,
max_output_tokens: None,
reasoning_efforts: Vec::new(),
})
.collect()
}
@@ -168,6 +209,7 @@ pub struct AnthropicRuntime {
config: AnthropicRuntimeConfig,
descriptor: RuntimeDescriptor,
credential_resolver: Option<AnthropicCredentialResolver>,
reasoning_effort: Option<String>,
}
impl std::fmt::Debug for AnthropicRuntime {
@@ -181,12 +223,18 @@ impl std::fmt::Debug for AnthropicRuntime {
}
impl AnthropicRuntime {
pub fn with_reasoning_effort(mut self, effort: Option<String>) -> Self {
self.reasoning_effort = effort;
self
}
pub fn new(config: AnthropicRuntimeConfig) -> Self {
let descriptor = native_descriptor("anthropic", &config.model);
Self {
config,
descriptor,
credential_resolver: None,
reasoning_effort: None,
}
}
@@ -224,8 +272,15 @@ impl AgentRuntime for AnthropicRuntime {
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let mut model = client.completion_model(&self.config.model);
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
let mut completion_request =
build_completion_request(request, max_output_tokens, true, true, None)?;
let mut completion_request = build_completion_request(
request,
max_output_tokens,
true,
true,
self.reasoning_effort
.as_deref()
.map(anthropic_reasoning_params),
)?;
let aliases = if crate::is_claude_subscription_token(&credential) {
model = model.with_prompt_caching();
Some(prepare_subscription_request(&mut completion_request)?)
@@ -256,14 +311,24 @@ pub struct GeminiRuntimeConfig {
#[derive(Clone, Debug)]
pub struct GeminiRuntime {
reasoning_effort: Option<String>,
config: GeminiRuntimeConfig,
descriptor: RuntimeDescriptor,
}
impl GeminiRuntime {
pub fn with_reasoning_effort(mut self, effort: Option<String>) -> Self {
self.reasoning_effort = effort;
self
}
pub fn new(config: GeminiRuntimeConfig) -> Self {
let descriptor = native_descriptor("gemini", &config.model);
Self { config, descriptor }
Self {
config,
descriptor,
reasoning_effort: None,
}
}
}
@@ -288,8 +353,15 @@ impl AgentRuntime for GeminiRuntime {
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model);
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
let completion_request =
build_completion_request(request, max_output_tokens, true, true, None)?;
let completion_request = build_completion_request(
request,
max_output_tokens,
true,
true,
self.reasoning_effort
.as_deref()
.map(gemini_reasoning_params),
)?;
start_model_turn(model, completion_request, control, max_output_tokens).await
}
}
@@ -304,14 +376,24 @@ pub struct VertexAiRuntimeConfig {
#[derive(Clone, Debug)]
pub struct VertexAiRuntime {
reasoning_effort: Option<String>,
config: VertexAiRuntimeConfig,
descriptor: RuntimeDescriptor,
}
impl VertexAiRuntime {
pub fn with_reasoning_effort(mut self, effort: Option<String>) -> Self {
self.reasoning_effort = effort;
self
}
pub fn new(config: VertexAiRuntimeConfig) -> Self {
let descriptor = native_descriptor("vertex-ai", &config.model);
Self { config, descriptor }
Self {
config,
descriptor,
reasoning_effort: None,
}
}
}
@@ -343,12 +425,27 @@ impl AgentRuntime for VertexAiRuntime {
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model);
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
let completion_request =
build_completion_request(request, max_output_tokens, true, true, None)?;
let completion_request = build_completion_request(
request,
max_output_tokens,
true,
true,
self.reasoning_effort
.as_deref()
.map(gemini_reasoning_params),
)?;
start_model_completion(model, completion_request, control, max_output_tokens).await
}
}
fn anthropic_reasoning_params(effort: &str) -> serde_json::Value {
serde_json::json!({ "output_config": { "effort": effort } })
}
fn gemini_reasoning_params(effort: &str) -> serde_json::Value {
serde_json::json!({ "generationConfig": { "thinkingConfig": { "thinkingLevel": effort } } })
}
fn native_descriptor(provider: &str, model: &str) -> RuntimeDescriptor {
RuntimeDescriptor {
id: format!("rig-{provider}:{model}"),