Use the lithos control enum API instead of local spellings

lithos-llm now exposes `ReasoningEffort::ALL`, `Speed::ALL`, `as_str`,
`Display`, and `FromStr` on its request-control enums. Fabro's
`controls` module kept parallel name tables and parsers for them; only
the nearest-supported-effort rule is Fabro's own, so that is what stays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-09 23:23:49 -06:00
parent 2a2fc41807
commit b82b3dd48e
No known key found for this signature in database
8 changed files with 47 additions and 119 deletions

View file

@ -7,9 +7,9 @@ use fabro_agent::cli::AgentArgs;
use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer};
use fabro_server::serve::DEFAULT_TCP_PORT;
use fabro_static::EnvVars;
use fabro_types::ReasoningEffort;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_types::settings::run::MergeStrategy;
use fabro_types::{ReasoningEffort, controls};
use fabro_util::printer::Printer;
pub(crate) const LONG_VERSION: &str = concat!(
@ -1857,13 +1857,12 @@ pub(crate) struct CompletionArgs {
}
fn parse_reasoning_effort_arg(value: &str) -> Result<ReasoningEffort, String> {
controls::parse_reasoning_effort(value).ok_or_else(|| {
value.parse().map_err(|_| {
format!(
"unknown reasoning effort '{value}'; expected one of: {}",
controls::REASONING_EFFORTS
.iter()
.copied()
.map(controls::reasoning_effort_name)
ReasoningEffort::ALL
.into_iter()
.map(ReasoningEffort::as_str)
.collect::<Vec<_>>()
.join(", ")
)

View file

@ -6,7 +6,7 @@ use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus};
use fabro_llm::{ModelSelectionError, api, catalog, selection};
use fabro_redact::redact_string;
use fabro_types::controls;
use fabro_types::ReasoningEffort;
use super::super::{
ApiError, AppState, FromStr, IntoResponse, Json, MAX_PAGE_OFFSET, ModelTestMode, Path,
@ -204,9 +204,9 @@ async fn test_model(
Err(error) => return error.into_response(),
};
let reasoning_effort = match params.reasoning_effort.as_deref() {
Some(value) => match controls::parse_reasoning_effort(value) {
Some(effort) => Some(effort),
None => {
Some(value) => match value.parse::<ReasoningEffort>() {
Ok(effort) => Some(effort),
Err(_) => {
return ApiError::new(
StatusCode::BAD_REQUEST,
format!("invalid reasoning effort: {value}"),
@ -264,16 +264,14 @@ async fn test_model(
if let Some(effort) = reasoning_effort {
let capabilities = info.model.capabilities();
if !capabilities.reasoning_effort(effort).is_supported() {
let allowed = controls::REASONING_EFFORTS
.iter()
.copied()
let allowed = ReasoningEffort::ALL
.into_iter()
.filter(|candidate| capabilities.reasoning_effort(*candidate).is_supported())
.map(controls::reasoning_effort_name)
.map(ReasoningEffort::as_str)
.collect::<Vec<_>>()
.join(", ");
return ApiError::bad_request(format!(
"model '{model_id}' does not support reasoning_effort '{}'; allowed values: {allowed}",
controls::reasoning_effort_name(effort)
"model '{model_id}' does not support reasoning_effort '{effort}'; allowed values: {allowed}"
))
.into_response();
}

View file

@ -4,8 +4,8 @@ use chrono::{DateTime, Utc};
use fabro_llm::LlmError;
use fabro_types::{
CommandTermination, ContentPart, Cost, ExecOutputTail, LlmOutputKind, LlmRetryPhase,
Message as LlmMessage, ModelRef, ReasoningOutput, Role, SessionMessage,
StageContextWindowProjection, TokenCounts, ToolCall, ToolResult, controls,
Message as LlmMessage, ModelRef, ReasoningOutput, Role, SessionMessage, Speed,
StageContextWindowProjection, TokenCounts, ToolCall, ToolResult,
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@ -511,7 +511,7 @@ impl AgentEvent {
session_id,
provider = %requested_model.provider,
model = %requested_model.model_id,
speed = requested_model.speed.map_or("", controls::speed_name),
speed = requested_model.speed.map_or("", Speed::as_str),
"LLM request started"
);
}

View file

@ -5,9 +5,9 @@
use std::collections::HashSet;
use fabro_types::controls::REASONING_EFFORTS;
use fabro_types::{
Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, Provider, ProviderId,
ReasoningEffort,
};
use lithos_llm::catalog::{Catalog, CatalogProvider};
@ -62,9 +62,8 @@ fn model_view(entry: &ModelEntry<'_>, configured: bool) -> Model {
sampling: capabilities.sampling().is_supported(),
},
controls: ModelControls {
reasoning_effort: REASONING_EFFORTS
.iter()
.copied()
reasoning_effort: ReasoningEffort::ALL
.into_iter()
.filter(|effort| capabilities.reasoning_effort(*effort).is_supported())
.collect(),
},

View file

@ -392,29 +392,25 @@ fn control_attr<'a>(node: &'a Node, key: &str) -> Option<&'a str> {
}
fn parse_reasoning_effort(node: &Node, value: &str) -> Result<ReasoningEffort, Error> {
controls::parse_reasoning_effort(value).ok_or_else(|| {
value.parse().map_err(|_| {
Error::handler(format!(
"Invalid reasoning_effort \"{value}\" for node \"{}\"; expected one of: {}",
node.id,
expected_values(
controls::REASONING_EFFORTS
.iter()
.map(|effort| controls::reasoning_effort_name(*effort))
ReasoningEffort::ALL
.into_iter()
.map(ReasoningEffort::as_str)
),
))
})
}
fn parse_speed(node: &Node, value: &str) -> Result<Speed, Error> {
controls::parse_speed(value).ok_or_else(|| {
value.parse().map_err(|_| {
Error::handler(format!(
"Invalid speed \"{value}\" for node \"{}\"; expected one of: {}",
node.id,
expected_values(
controls::SPEEDS
.iter()
.map(|speed| controls::speed_name(*speed))
),
expected_values(Speed::ALL.into_iter().map(Speed::as_str)),
))
})
}

View file

@ -4,7 +4,7 @@ use fabro_llm::catalog::ModelEntry;
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{FallbackTarget, ModelSelectionError, catalog, selection};
use fabro_types::settings::{ModelRef, ResolvedModelRef};
use fabro_types::{ProviderId, ReasoningEffort, RunNoticeCode, RunNoticeLevel, controls};
use fabro_types::{ProviderId, ReasoningEffort, RunNoticeCode, RunNoticeLevel};
use crate::Error;
@ -180,7 +180,6 @@ impl ModelFallbackNotice {
target,
requested_effort,
} => {
let requested_effort = controls::reasoning_effort_name(*requested_effort);
format!(
"Model fallback `{target}` for requested model `{requested_model}` was skipped because it has no reasoning level near `{requested_effort}`."
)

View file

@ -10,8 +10,6 @@ use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId};
pub use lithos_llm::types::{Cost, CostSource, Speed, TokenCounts};
use serde::{Deserialize, Serialize};
use crate::controls;
const USD_MICROS_PER_USD_F64: f64 = 1_000_000.0;
#[allow(
@ -156,7 +154,7 @@ impl ModelRef {
(
self.provider.as_str(),
self.model_id.as_str(),
self.speed.map_or("", controls::speed_name),
self.speed.map_or("", Speed::as_str),
)
}
}
@ -165,7 +163,7 @@ impl std::hash::Hash for ModelRef {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.provider.hash(state);
self.model_id.hash(state);
self.speed.map(controls::speed_name).hash(state);
self.speed.map(Speed::as_str).hash(state);
}
}
@ -173,7 +171,7 @@ impl std::fmt::Display for ModelRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.provider, self.model_id)?;
if let Some(speed) = self.speed {
write!(f, " ({})", controls::speed_name(speed))?;
write!(f, " ({speed})")?;
}
Ok(())
}

View file

@ -1,74 +1,17 @@
//! Helpers over the lithos request-control enums.
//!
//! lithos owns [`ReasoningEffort`] and [`Speed`] and marks both
//! `#[non_exhaustive]`. Fabro needs to list, name, and parse them for
//! settings, graph attributes, and CLI flags, so the spellings live here in
//! one place. The names match the lithos serde form.
//! lithos owns [`ReasoningEffort`] and [`Speed`], their spellings, and their
//! parsing (`ALL`, `as_str`, `Display`, `FromStr`). What stays here is
//! Fabro's own rule for substituting a reasoning level a model lacks.
pub use lithos_llm::types::{ReasoningEffort, Speed};
/// Every reasoning effort, least to most.
pub const REASONING_EFFORTS: &[ReasoningEffort] = &[
ReasoningEffort::Minimal,
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Xhigh,
ReasoningEffort::Max,
];
/// Every speed tier.
pub const SPEEDS: &[Speed] = &[Speed::Fast, Speed::Balanced, Speed::Economical];
/// The wire spelling of a reasoning effort.
#[must_use]
pub fn reasoning_effort_name(effort: ReasoningEffort) -> &'static str {
match effort {
ReasoningEffort::Minimal => "minimal",
ReasoningEffort::Low => "low",
ReasoningEffort::Medium => "medium",
ReasoningEffort::High => "high",
ReasoningEffort::Xhigh => "xhigh",
ReasoningEffort::Max => "max",
_ => "unknown",
}
}
/// The wire spelling of a speed tier.
#[must_use]
pub fn speed_name(speed: Speed) -> &'static str {
match speed {
Speed::Fast => "fast",
Speed::Balanced => "balanced",
Speed::Economical => "economical",
_ => "unknown",
}
}
/// Parses a reasoning effort from its wire spelling.
#[must_use]
pub fn parse_reasoning_effort(value: &str) -> Option<ReasoningEffort> {
REASONING_EFFORTS
.iter()
.copied()
.find(|effort| reasoning_effort_name(*effort) == value)
}
/// Parses a speed tier from its wire spelling.
#[must_use]
pub fn parse_speed(value: &str) -> Option<Speed> {
SPEEDS
.iter()
.copied()
.find(|speed| speed_name(*speed) == value)
}
/// Position of an effort in the least-to-most ordering.
fn effort_rank(effort: ReasoningEffort) -> usize {
REASONING_EFFORTS
ReasoningEffort::ALL
.iter()
.position(|candidate| *candidate == effort)
.unwrap_or(REASONING_EFFORTS.len())
.unwrap_or(ReasoningEffort::ALL.len())
}
/// Selects the supported effort nearest to `requested`.
@ -81,9 +24,8 @@ pub fn closest_supported_effort(
supported: impl Fn(ReasoningEffort) -> bool,
) -> Option<ReasoningEffort> {
let target = effort_rank(requested);
REASONING_EFFORTS
.iter()
.copied()
ReasoningEffort::ALL
.into_iter()
.filter(|effort| supported(*effort))
.min_by_key(|effort| {
let rank = effort_rank(*effort);
@ -96,22 +38,19 @@ mod tests {
use super::*;
#[test]
fn names_round_trip_through_serde() {
for effort in REASONING_EFFORTS {
let json = serde_json::to_string(effort).unwrap();
assert_eq!(json, format!("\"{}\"", reasoning_effort_name(*effort)));
assert_eq!(
parse_reasoning_effort(reasoning_effort_name(*effort)),
Some(*effort)
);
fn lithos_spellings_match_serde() {
for effort in ReasoningEffort::ALL {
let json = serde_json::to_string(&effort).unwrap();
assert_eq!(json, format!("\"{effort}\""));
assert_eq!(effort.as_str().parse::<ReasoningEffort>().unwrap(), effort);
}
for speed in SPEEDS {
let json = serde_json::to_string(speed).unwrap();
assert_eq!(json, format!("\"{}\"", speed_name(*speed)));
assert_eq!(parse_speed(speed_name(*speed)), Some(*speed));
for speed in Speed::ALL {
let json = serde_json::to_string(&speed).unwrap();
assert_eq!(json, format!("\"{speed}\""));
assert_eq!(speed.as_str().parse::<Speed>().unwrap(), speed);
}
assert_eq!(parse_reasoning_effort("standard"), None);
assert_eq!(parse_speed("standard"), None);
assert!("standard".parse::<ReasoningEffort>().is_err());
assert!("standard".parse::<Speed>().is_err());
}
#[test]