feat(model): describe catalog model capabilities

Add explicit model feature metadata for reasoning effort levels and prompt caching, while preserving the legacy effort flag as a compatibility alias. Gate Anthropic prompt-cache request encoding on the catalog feature and keep request serialization details in the adapter.
This commit is contained in:
Bryan Helmkamp 2026-05-13 08:25:57 -04:00
parent c300d32a82
commit 68ab756040
No known key found for this signature in database
27 changed files with 633 additions and 116 deletions

View file

@ -4400,6 +4400,13 @@ components:
description: Maximum output tokens, if known.
example: 128000
ReasoningEffortFeature:
description: Whether Fabro may expose reasoning effort levels for a model.
type: string
enum:
- levels
- none
ModelFeatures:
description: Capability flags for a model.
type: object
@ -4407,6 +4414,8 @@ components:
- tools
- vision
- reasoning
- reasoning_effort
- prompt_cache
- effort
properties:
tools:
@ -4418,9 +4427,15 @@ components:
reasoning:
type: boolean
description: Whether the model supports extended reasoning.
reasoning_effort:
$ref: "#/components/schemas/ReasoningEffortFeature"
prompt_cache:
type: boolean
description: Whether the model endpoint supports prompt caching.
effort:
type: boolean
description: Whether the model supports direct reasoning effort controls.
deprecated: true
description: Deprecated compatibility flag equivalent to reasoning_effort = levels.
ModelCosts:
description: Pricing per million tokens in USD.

View file

@ -361,6 +361,11 @@ fn main() {
("ProviderId", "fabro_model::ProviderId", &[]),
("Model", "fabro_model::Model", &[]),
("ModelLimits", "fabro_model::ModelLimits", &[]),
(
"ReasoningEffortFeature",
"fabro_model::ReasoningEffortFeature",
&[],
),
("ModelFeatures", "fabro_model::ModelFeatures", &[]),
("ModelCosts", "fabro_model::ModelCosts", &[]),
("ModelTestMode", "fabro_model::ModelTestMode", &[]),

View file

@ -16,7 +16,7 @@ mod generated {
pub mod types {
pub use fabro_model::{
Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode,
Provider, Speed as BillingSpeed,
Provider, ReasoningEffortFeature, Speed as BillingSpeed,
};
pub use fabro_types::settings::server::{
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,

View file

@ -1,7 +1,7 @@
use std::any::{TypeId, type_name};
use fabro_api::types::ModelFeatures as ApiModelFeatures;
use fabro_model::ModelFeatures;
use fabro_model::{ModelFeatures, ReasoningEffortFeature};
#[test]
fn model_features_reuses_canonical_type() {
@ -11,17 +11,21 @@ fn model_features_reuses_canonical_type() {
#[test]
fn model_features_json_matches_openapi_shape() {
let features = ModelFeatures {
tools: true,
vision: true,
reasoning: true,
effort: false,
tools: true,
vision: true,
reasoning: true,
reasoning_effort: ReasoningEffortFeature::Levels,
prompt_cache: false,
effort: true,
};
let json = serde_json::to_value(&features).unwrap();
assert_eq!(json["tools"], true);
assert_eq!(json["vision"], true);
assert_eq!(json["reasoning"], true);
assert_eq!(json["effort"], false);
assert_eq!(json["reasoning_effort"], "levels");
assert_eq!(json["prompt_cache"], false);
assert_eq!(json["effort"], true);
let round_trip: ApiModelFeatures = serde_json::from_value(json).unwrap();
assert_eq!(round_trip, features);

View file

@ -1,7 +1,9 @@
use std::any::{TypeId, type_name};
use fabro_api::types::Model as ApiModel;
use fabro_model::{Model, ModelCosts, ModelFeatures, ModelLimits, Provider};
use fabro_model::{
Model, ModelCosts, ModelFeatures, ModelLimits, Provider, ReasoningEffortFeature,
};
#[test]
fn model_reuses_canonical_type() {
@ -22,10 +24,12 @@ fn model_json_matches_openapi_shape() {
training: Some("2025-08-01".to_string()),
knowledge_cutoff: Some("May 2025".to_string()),
features: ModelFeatures {
tools: true,
vision: true,
reasoning: true,
effort: true,
tools: true,
vision: true,
reasoning: true,
reasoning_effort: ReasoningEffortFeature::Levels,
prompt_cache: true,
effort: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(5.0),
@ -42,6 +46,8 @@ fn model_json_matches_openapi_shape() {
assert_eq!(json["id"], "claude-opus-4-7");
assert_eq!(json["provider"], "anthropic");
assert_eq!(json["knowledge_cutoff"], "May 2025");
assert_eq!(json["features"]["reasoning_effort"], "levels");
assert_eq!(json["features"]["prompt_cache"], true);
assert_eq!(json["features"]["effort"], true);
assert_eq!(json["estimated_output_tps"], 25.0);
assert_eq!(json["configured"], true);

View file

@ -1,7 +1,9 @@
use std::any::{TypeId, type_name};
use fabro_api::types::Model as ApiModel;
use fabro_model::{Model, ModelCosts, ModelFeatures, ModelLimits, Provider, ProviderId};
use fabro_model::{
Model, ModelCosts, ModelFeatures, ModelLimits, Provider, ProviderId, ReasoningEffortFeature,
};
use serde_json::json;
#[test]
@ -36,10 +38,12 @@ fn provider_id_json_matches_openapi_shape_through_model() {
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: false,
vision: false,
reasoning: false,
effort: false,
tools: false,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
effort: false,
},
costs: ModelCosts {
input_cost_per_mtok: None,

View file

@ -465,7 +465,7 @@ impl Default for ModelsCommand {
#[cfg(test)]
mod tests {
use fabro_model::{ModelCosts, ModelFeatures, ModelLimits};
use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature};
use super::*;
@ -486,10 +486,12 @@ mod tests {
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: true,
vision: false,
reasoning: false,
effort: false,
tools: true,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
effort: false,
},
costs: ModelCosts {
input_cost_per_mtok: Some(1.0),

View file

@ -352,10 +352,12 @@ fn model_limits_to_catalog(limits: &LlmModelLimits) -> model_catalog::SettingsMo
fn model_features_to_catalog(features: &LlmModelFeatures) -> model_catalog::SettingsModelFeatures {
model_catalog::SettingsModelFeatures {
tools: features.tools,
vision: features.vision,
reasoning: features.reasoning,
effort: features.effort,
tools: features.tools,
vision: features.vision,
reasoning: features.reasoning,
reasoning_effort: features.reasoning_effort,
prompt_cache: features.prompt_cache,
effort: features.effort,
}
}

View file

@ -13,7 +13,7 @@ use fabro_types::settings::{Duration, InterpString, Size};
use super::LogFilter;
use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer};
use super::features::FeaturesLayer;
use super::llm::{CostRates, CredentialRef, HeaderValueRef};
use super::llm::{CostRates, CredentialRef, HeaderValueRef, ReasoningEffortFeature};
use super::run::{
DaytonaSnapshotLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer,
ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer,
@ -83,6 +83,7 @@ impl_combine_or_option!(
ServerAuthMethod,
WebhookStrategy,
LogFilter,
ReasoningEffortFeature,
);
impl Combine for Option<Vec<String>> {

View file

@ -29,7 +29,9 @@
use std::collections::{BTreeMap, HashMap};
use fabro_model::catalog::deserialize_knowledge_cutoff;
pub use fabro_model::{CredentialRef, CredentialRefParseError, HeaderValueRef};
pub use fabro_model::{
CredentialRef, CredentialRefParseError, HeaderValueRef, ReasoningEffortFeature,
};
use serde::{Deserialize, Serialize};
use super::maps::MergeMap;
@ -134,13 +136,17 @@ pub struct ModelLimits {
#[serde(deny_unknown_fields)]
pub struct ModelFeatures {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<bool>,
pub tools: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vision: Option<bool>,
pub vision: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<bool>,
pub reasoning: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<bool>,
pub reasoning_effort: Option<ReasoningEffortFeature>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<bool>,
}
/// User-facing allow-list for native control values Fabro accepts on this
@ -550,6 +556,35 @@ cache_input_cost_per_mtok = 0.15
assert!(costs.speed.is_none());
}
#[test]
fn parses_model_reasoning_effort_and_prompt_cache_features() {
let toml = r#"
[models."claude-bedrock"]
provider = "bedrock"
[models."claude-bedrock".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = false
"#;
let layer: LlmLayer = toml::from_str(toml).unwrap();
let features = layer
.models
.get("claude-bedrock")
.unwrap()
.features
.as_ref()
.unwrap();
assert_eq!(
features.reasoning_effort,
Some(fabro_model::ReasoningEffortFeature::Levels)
);
assert_eq!(features.prompt_cache, Some(false));
}
#[test]
fn parses_knowledge_cutoff_display_label() {
let toml = r#"

View file

@ -20,7 +20,7 @@ pub use features::FeaturesLayer;
pub use llm::{
CostRates, CredentialRef, CredentialRefParseError, HeaderValueRef, LlmLayer, ModelControls,
ModelCostTable, ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits,
ModelSettings, ProviderSettings,
ModelSettings, ProviderSettings, ReasoningEffortFeature,
};
pub use log_filter::LogFilter;
pub use maps::{MergeMap, ReplaceMap, StickyMap};

View file

@ -46,9 +46,9 @@ pub use layers::{
InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, McpEntryLayer,
MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,
NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
PrepareStep, ProjectLayer, ProviderSettings, ReplaceMap, RunAgentLayer, RunArtifactsLayer,
RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer,
RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer,
PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer,
RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer,
RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer,
RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer,
RunSandboxLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,

View file

@ -169,7 +169,7 @@ fn validate_deep_result(result: &GenerateResult) -> Result<(), String> {
mod tests {
use std::collections::HashMap;
use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, Provider};
use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, Provider, ReasoningEffortFeature};
use super::*;
use crate::types::{FinishReason, Message, Response, StepResult, TokenCounts, ToolResult};
@ -220,10 +220,12 @@ mod tests {
#[tokio::test]
async fn run_model_test_deep_errors_when_model_lacks_tools() {
let info = test_model_with(ModelFeatures {
tools: false,
vision: false,
reasoning: true,
effort: true,
tools: false,
vision: false,
reasoning: true,
reasoning_effort: ReasoningEffortFeature::Levels,
prompt_cache: false,
effort: true,
});
let outcome = run_model_test(&info, ModelTestMode::Deep, empty_test_client()).await;

View file

@ -2,7 +2,7 @@ use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_model::Catalog;
use fabro_model::{Catalog, ReasoningEffortFeature};
use futures::stream;
use crate::error::{Error, error_from_status_code};
@ -1128,7 +1128,11 @@ async fn build_api_request(
request.tools.as_ref().map(|t| translate_tools(t))
};
let auto_cache = is_auto_cache_enabled(request.provider_options.as_ref());
let model_info = common::catalog_model(adapter.catalog.as_deref(), &request.model)
.or_else(|| Catalog::builtin().get(&request.model));
let supports_prompt_cache = model_info.is_some_and(|m| m.features.prompt_cache);
let auto_cache =
supports_prompt_cache && is_auto_cache_enabled(request.provider_options.as_ref());
let mut system_value = system.and_then(|s| {
if s.trim().is_empty() {
@ -1161,9 +1165,8 @@ async fn build_api_request(
// Check whether this model supports the `output_config.effort` parameter.
// Older reasoning models (e.g. claude-sonnet-4-5) need `thinking` with
// `budget_tokens` instead.
let model_info = common::catalog_model(adapter.catalog.as_deref(), &request.model)
.or_else(|| Catalog::builtin().get(&request.model));
let supports_effort = model_info.is_none_or(|m| m.features.effort);
let supports_effort =
model_info.is_none_or(|m| m.features.reasoning_effort == ReasoningEffortFeature::Levels);
let mut resolved_max_tokens = request
.max_tokens
@ -1195,7 +1198,9 @@ async fn build_api_request(
// Auto-set adaptive thinking for known effort-capable models when no
// explicit thinking config or reasoning_effort is provided.
let thinking = explicit_thinking.or_else(|| {
if model_info.is_some_and(|m| m.features.effort) {
if model_info
.is_some_and(|m| m.features.reasoning_effort == ReasoningEffortFeature::Levels)
{
Some(serde_json::json!({"type": "adaptive"}))
} else {
None
@ -1218,7 +1223,7 @@ async fn build_api_request(
system: system_value,
temperature: request.temperature,
top_p: request.top_p,
stop_sequences: request.stop_sequences.clone(),
stop_sequences: Some(request.stop_sequences.clone().unwrap_or_default()),
tools: api_tools,
tool_choice: tool_choice_json,
thinking,
@ -1419,6 +1424,8 @@ impl ProviderAdapter for Adapter {
#[cfg(test)]
mod tests {
use fabro_model::catalog::LlmCatalogSettings;
use super::*;
use crate::types::{AudioData, DocumentData, ReasoningEffort, ResponseFormat};
@ -1830,6 +1837,34 @@ mod tests {
}
}
fn catalog_with_anthropic_model(features: &str) -> Arc<Catalog> {
let settings: LlmCatalogSettings = toml::from_str(&format!(
r#"
[providers.anthropic]
display_name = "Anthropic"
adapter = "anthropic"
[models."test-claude"]
provider = "anthropic"
display_name = "Test Claude"
family = "claude"
default = true
[models."test-claude".limits]
context_window = 200000
max_output = 4096
[models."test-claude".features]
tools = true
vision = true
reasoning = true
{features}
"#
))
.unwrap();
Arc::new(Catalog::from_settings(&settings).unwrap())
}
fn make_request_with_format(format: ResponseFormat) -> Request {
Request {
provider: None,
@ -2294,6 +2329,73 @@ mod tests {
);
}
#[tokio::test]
async fn build_api_request_disables_prompt_cache_when_model_feature_is_false() {
let adapter = Adapter::new("test-key").with_catalog(catalog_with_anthropic_model(
r#"
reasoning_effort = "levels"
prompt_cache = false
"#,
));
let request = Request {
model: "test-claude".to_string(),
messages: vec![
Message::system("Use the cache if supported."),
Message::user("Hello"),
],
provider_options: Some(serde_json::json!({
"anthropic": {"auto_cache": true}
})),
..make_base_request()
};
let (api_request, req_builder) = build_api_request(&adapter, &request, false).await;
assert_eq!(
api_request.system,
Some(serde_json::Value::String(
"Use the cache if supported.".to_string()
))
);
let built = req_builder.build().expect("should build request");
let beta = built.headers().get("anthropic-beta");
assert!(
beta.is_none_or(|value| !value.to_str().unwrap().contains(CACHE_BETA_HEADER)),
"cache beta header must not be sent when the model disables prompt cache"
);
}
#[tokio::test]
async fn build_api_request_enables_prompt_cache_when_model_feature_is_true() {
let adapter = Adapter::new("test-key").with_catalog(catalog_with_anthropic_model(
r#"
reasoning_effort = "levels"
prompt_cache = true
"#,
));
let request = Request {
model: "test-claude".to_string(),
messages: vec![
Message::system("Use the cache if supported."),
Message::user("Hello"),
],
..make_base_request()
};
let (api_request, req_builder) = build_api_request(&adapter, &request, false).await;
assert_eq!(
api_request.system.unwrap()[0]["cache_control"]["type"],
"ephemeral"
);
let built = req_builder.build().expect("should build request");
let beta = built
.headers()
.get("anthropic-beta")
.expect("cache beta header should be present")
.to_str()
.unwrap();
assert!(beta.contains(CACHE_BETA_HEADER));
}
#[tokio::test]
async fn build_api_request_uses_adaptive_thinking_for_opus_4_7_without_forced_tools() {
let adapter = Adapter::new("test-key");
@ -2424,6 +2526,16 @@ mod tests {
assert_eq!(api_request.speed, Some("fast".to_string()));
}
#[tokio::test]
async fn build_api_request_serializes_absent_stop_sequences_as_empty_array() {
let adapter = Adapter::new("test-key");
let request = make_base_request();
let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await;
assert_eq!(api_request.stop_sequences, Some(Vec::new()));
}
#[tokio::test]
async fn build_api_request_injects_fast_mode_beta_header() {
let adapter = Adapter::new("test-key");

View file

@ -617,7 +617,7 @@ impl StreamEvent {
// --- 2.9 Model (re-exported from fabro-model) ---
pub use fabro_model::{Model, ModelCosts, ModelFeatures, ModelLimits};
pub use fabro_model::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature};
// --- 4.7 Timeouts ---

View file

@ -42,9 +42,9 @@ pub enum ApiKeyHeaderPolicy {
/// API.
#[derive(Debug, Clone, Copy)]
pub struct AdapterControlCapabilities {
/// Reasoning-effort values that can be sent through the provider's native
/// effort field. Models declaring `features.effort = true` may declare
/// `controls.reasoning_effort` only as a non-empty subset of this list.
/// Reasoning-effort values this adapter can accept for models declaring
/// `features.reasoning_effort = "levels"`. The adapter owns how those
/// levels are encoded on the provider wire API.
pub native_reasoning_effort: &'static [ReasoningEffort],
/// Additional speeds (beyond `Speed::Standard`, which is implicit) the
/// adapter supports. Models may declare `controls.speed` only as a
@ -116,8 +116,8 @@ pub const OPENAI_COMPATIBLE: AdapterMetadata = AdapterMetadata {
api_key_header: ApiKeyHeaderPolicy::Bearer,
controls: AdapterControlCapabilities {
// `openai_compatible` providers vary widely; the catalog requires
// models declaring `features.effort = true` to enumerate exactly
// which effort values their endpoint accepts.
// models declaring `features.reasoning_effort = "levels"` to
// enumerate exactly which effort values their endpoint accepts.
native_reasoning_effort: FULL_REASONING_EFFORTS,
additional_speeds: &[],
},

View file

@ -13,7 +13,7 @@ use crate::adapter::{self, AdapterMetadata};
use crate::ids::ProviderId;
use crate::provider::Provider;
use crate::reasoning::ReasoningEffort;
use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits};
use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature};
#[derive(RustEmbed)]
#[folder = "src/catalog/providers"]
@ -101,13 +101,17 @@ pub struct SettingsModelLimits {
#[serde(deny_unknown_fields)]
pub struct SettingsModelFeatures {
#[serde(default)]
pub tools: Option<bool>,
pub tools: Option<bool>,
#[serde(default)]
pub vision: Option<bool>,
pub vision: Option<bool>,
#[serde(default)]
pub reasoning: Option<bool>,
pub reasoning: Option<bool>,
#[serde(default)]
pub effort: Option<bool>,
pub reasoning_effort: Option<ReasoningEffortFeature>,
#[serde(default)]
pub prompt_cache: Option<bool>,
#[serde(default)]
pub effort: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
@ -465,10 +469,14 @@ pub enum CatalogBuildError {
adapter: String,
value: ReasoningEffort,
},
#[error("model '{model}' declares reasoning_effort controls but features.effort is false")]
ReasoningEffortWithoutFeature { model: String },
#[error(
"model '{model}' must declare at least one reasoning_effort when features.effort is true"
"model '{model}' declares reasoning_effort controls but features.reasoning_effort is none"
)]
ReasoningEffortWithoutFeature { model: String },
#[error("model '{model}' declares reasoning_effort feature but features.reasoning is false")]
ReasoningEffortWithoutReasoning { model: String },
#[error(
"model '{model}' must declare at least one reasoning_effort when features.reasoning_effort is levels"
)]
EmptyReasoningEffortControls { model: String },
#[error("model '{model}' has invalid speed '{value}'")]
@ -1009,10 +1017,12 @@ fn merge_model_features_settings(
fallback: &SettingsModelFeatures,
) -> SettingsModelFeatures {
SettingsModelFeatures {
tools: higher.tools.or(fallback.tools),
vision: higher.vision.or(fallback.vision),
reasoning: higher.reasoning.or(fallback.reasoning),
effort: higher.effort.or(fallback.effort),
tools: higher.tools.or(fallback.tools),
vision: higher.vision.or(fallback.vision),
reasoning: higher.reasoning.or(fallback.reasoning),
reasoning_effort: higher.reasoning_effort.or(fallback.reasoning_effort),
prompt_cache: higher.prompt_cache.or(fallback.prompt_cache),
effort: higher.effort.or(fallback.effort),
}
}
@ -1180,26 +1190,42 @@ fn build_model_features(
model_id: &str,
features: &SettingsModelFeatures,
) -> Result<ModelFeatures, CatalogBuildError> {
let reasoning = features
.reasoning
.ok_or_else(|| CatalogBuildError::MissingModelField {
model: model_id.to_string(),
field: "features.reasoning",
})?;
let reasoning_effort = features.reasoning_effort.unwrap_or_else(|| {
if features.effort.unwrap_or_default() {
ReasoningEffortFeature::Levels
} else {
ReasoningEffortFeature::None
}
});
if !reasoning && reasoning_effort == ReasoningEffortFeature::Levels {
return Err(CatalogBuildError::ReasoningEffortWithoutReasoning {
model: model_id.to_string(),
});
}
Ok(ModelFeatures {
tools: features
tools: features
.tools
.ok_or_else(|| CatalogBuildError::MissingModelField {
model: model_id.to_string(),
field: "features.tools",
})?,
vision: features
vision: features
.vision
.ok_or_else(|| CatalogBuildError::MissingModelField {
model: model_id.to_string(),
field: "features.vision",
})?,
reasoning: features
.reasoning
.ok_or_else(|| CatalogBuildError::MissingModelField {
model: model_id.to_string(),
field: "features.reasoning",
})?,
effort: features.effort.unwrap_or_default(),
reasoning,
reasoning_effort,
prompt_cache: features.prompt_cache.unwrap_or_default(),
effort: reasoning_effort == ReasoningEffortFeature::Levels,
})
}
@ -1248,17 +1274,18 @@ fn build_model_controls(
settings: &ModelCatalogSettings,
adapter: &'static AdapterMetadata,
) -> Result<CatalogModelControls, CatalogBuildError> {
let supports_reasoning_effort = features.reasoning_effort == ReasoningEffortFeature::Levels;
let reasoning_effort = match settings
.controls
.as_ref()
.and_then(|controls| controls.reasoning_effort.as_ref())
{
Some(values) if !features.effort && !values.is_empty() => {
Some(values) if !supports_reasoning_effort && !values.is_empty() => {
return Err(CatalogBuildError::ReasoningEffortWithoutFeature {
model: model_id.to_string(),
});
}
Some(values) if values.is_empty() && features.effort => {
Some(values) if values.is_empty() && supports_reasoning_effort => {
return Err(CatalogBuildError::EmptyReasoningEffortControls {
model: model_id.to_string(),
});
@ -1267,7 +1294,7 @@ fn build_model_controls(
.iter()
.map(|value| parse_reasoning_effort(model_id, value))
.collect::<Result<Vec<_>, _>>()?,
None if features.effort => adapter.controls.native_reasoning_effort.to_vec(),
None if supports_reasoning_effort => adapter.controls.native_reasoning_effort.to_vec(),
None => Vec::new(),
};
for value in &reasoning_effort {
@ -1467,7 +1494,7 @@ fn default_adapter_for_provider(provider: &ProviderId) -> &'static str {
fn default_controls_for_model(model: &Model) -> CatalogModelControls {
CatalogModelControls {
reasoning_effort: if model.features.effort {
reasoning_effort: if model.features.reasoning_effort == ReasoningEffortFeature::Levels {
ReasoningEffort::VARIANTS.to_vec()
} else {
Vec::new()
@ -1777,10 +1804,12 @@ effort = false
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: true,
vision: false,
reasoning: false,
effort: false,
tools: true,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
effort: false,
},
costs: ModelCosts {
input_cost_per_mtok: Some(1.0),
@ -2156,6 +2185,7 @@ adapter = "openai"
provider = "test"
display_name = "Model"
family = "test"
default = true
[models.model.limits]
context_window = 1000
@ -2186,6 +2216,7 @@ adapter = "anthropic"
provider = "test"
display_name = "Model"
family = "test"
default = true
[models.model.limits]
context_window = 1000
@ -2206,6 +2237,216 @@ input_cost_per_mtok = 1.0
));
}
#[test]
fn catalog_from_settings_accepts_reasoning_effort_feature_levels() {
let settings = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
[models.model]
provider = "test"
display_name = "Model"
family = "test"
default = true
[models.model.limits]
context_window = 1000
[models.model.features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
[models.model.controls]
reasoning_effort = ["low", "medium"]
"#,
);
let catalog = Catalog::from_settings(&settings).unwrap();
let model = catalog.get("model").unwrap();
assert_eq!(
model.features.reasoning_effort,
crate::ReasoningEffortFeature::Levels
);
assert!(model.features.prompt_cache);
assert!(model.features.effort);
assert_eq!(
catalog
.model_settings("model")
.unwrap()
.controls
.reasoning_effort,
vec![ReasoningEffort::Low, ReasoningEffort::Medium]
);
}
#[test]
fn catalog_from_settings_maps_legacy_effort_to_reasoning_effort_feature() {
let settings = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
[models.with_effort]
provider = "test"
display_name = "With Effort"
family = "test"
default = true
[models.with_effort.limits]
context_window = 1000
[models.with_effort.features]
tools = true
vision = false
reasoning = true
effort = true
[models.no_effort]
provider = "test"
display_name = "No Effort"
family = "test"
[models.no_effort.limits]
context_window = 1000
[models.no_effort.features]
tools = true
vision = false
reasoning = true
effort = false
"#,
);
let catalog = Catalog::from_settings(&settings).unwrap();
let with_effort = catalog.get("with_effort").unwrap();
assert_eq!(
with_effort.features.reasoning_effort,
crate::ReasoningEffortFeature::Levels
);
assert!(with_effort.features.effort);
let no_effort = catalog.get("no_effort").unwrap();
assert_eq!(
no_effort.features.reasoning_effort,
crate::ReasoningEffortFeature::None
);
assert!(!no_effort.features.effort);
}
#[test]
fn catalog_merge_prefers_explicit_reasoning_effort_over_legacy_effort() {
let fallback = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
[models.model]
provider = "test"
display_name = "Model"
family = "test"
default = true
[models.model.limits]
context_window = 1000
[models.model.features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
"#,
);
let higher = minimal_settings(
r"
[models.model.features]
effort = false
",
);
let merged = merge_catalog_settings(higher, fallback);
let catalog = Catalog::from_settings(&merged).unwrap();
let model = catalog.get("model").unwrap();
assert_eq!(
model.features.reasoning_effort,
crate::ReasoningEffortFeature::Levels
);
assert!(model.features.effort);
}
#[test]
fn catalog_from_settings_rejects_reasoning_effort_controls_when_feature_is_none() {
let settings = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
[models.model]
provider = "test"
display_name = "Model"
family = "test"
[models.model.limits]
context_window = 1000
[models.model.features]
tools = true
vision = false
reasoning = true
reasoning_effort = "none"
[models.model.controls]
reasoning_effort = ["low"]
"#,
);
assert!(matches!(
Catalog::from_settings(&settings).unwrap_err(),
CatalogBuildError::ReasoningEffortWithoutFeature { model }
if model == "model"
));
}
#[test]
fn catalog_from_settings_rejects_reasoning_effort_feature_without_reasoning() {
let settings = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
[models.model]
provider = "test"
display_name = "Model"
family = "test"
[models.model.limits]
context_window = 1000
[models.model.features]
tools = true
vision = false
reasoning = false
reasoning_effort = "levels"
"#,
);
assert!(matches!(
Catalog::from_settings(&settings).unwrap_err(),
CatalogBuildError::ReasoningEffortWithoutReasoning { model }
if model == "model"
));
}
// ---- Provider / catalog data integrity tests ----
#[test]
@ -2295,6 +2536,8 @@ input_cost_per_mtok = 1.0
tools: true,
vision: true,
reasoning: true,
reasoning_effort: Levels,
prompt_cache: true,
effort: true,
},
costs: ModelCosts {
@ -2363,6 +2606,8 @@ input_cost_per_mtok = 1.0
tools: true,
vision: true,
reasoning: true,
reasoning_effort: Levels,
prompt_cache: false,
effort: true,
},
costs: ModelCosts {
@ -2421,6 +2666,8 @@ input_cost_per_mtok = 1.0
tools: true,
vision: true,
reasoning: false,
reasoning_effort: None,
prompt_cache: false,
effort: false,
},
costs: ModelCosts {
@ -2482,6 +2729,8 @@ input_cost_per_mtok = 1.0
tools: true,
vision: false,
reasoning: true,
reasoning_effort: Levels,
prompt_cache: false,
effort: true,
},
costs: ModelCosts {
@ -2535,6 +2784,8 @@ input_cost_per_mtok = 1.0
tools: true,
vision: true,
reasoning: true,
reasoning_effort: Levels,
prompt_cache: false,
effort: true,
},
costs: ModelCosts {
@ -2586,6 +2837,8 @@ input_cost_per_mtok = 1.0
tools: true,
vision: true,
reasoning: true,
reasoning_effort: Levels,
prompt_cache: false,
effort: true,
},
costs: ModelCosts {
@ -2663,6 +2916,8 @@ input_cost_per_mtok = 1.0
tools: true,
vision: false,
reasoning: true,
reasoning_effort: Levels,
prompt_cache: false,
effort: true,
},
costs: ModelCosts {

View file

@ -22,7 +22,8 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
prompt_cache = true
[models."claude-opus-4-7".controls]
speed = ["fast"]
@ -54,7 +55,8 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
prompt_cache = true
[models."claude-opus-4-6".controls]
speed = ["fast"]
@ -86,6 +88,7 @@ max_output = 64000
tools = true
vision = true
reasoning = true
prompt_cache = true
[models."claude-sonnet-4-5".costs]
input_cost_per_mtok = 3.0
@ -111,7 +114,8 @@ max_output = 64000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
prompt_cache = true
[models."claude-sonnet-4-6".costs]
input_cost_per_mtok = 3.0
@ -136,6 +140,7 @@ max_output = 8192
tools = true
vision = true
reasoning = false
prompt_cache = true
[models."claude-haiku-4-5".costs]
input_cost_per_mtok = 0.8

View file

@ -23,7 +23,7 @@ max_output = 65536
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gemini-3.1-pro-preview".costs]
input_cost_per_mtok = 2.0
@ -48,7 +48,7 @@ max_output = 65536
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gemini-3.1-pro-preview-customtools".costs]
input_cost_per_mtok = 2.0
@ -73,7 +73,7 @@ max_output = 65536
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gemini-3-flash-preview".costs]
input_cost_per_mtok = 0.5
@ -98,7 +98,7 @@ max_output = 65536
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gemini-3.1-flash-lite-preview".costs]
input_cost_per_mtok = 0.25

View file

@ -23,7 +23,7 @@ max_output = 50000
tools = true
vision = false
reasoning = true
effort = true
reasoning_effort = "levels"
[models."mercury-2".costs]
input_cost_per_mtok = 0.25

View file

@ -23,7 +23,7 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gpt-5.2".costs]
input_cost_per_mtok = 1.75
@ -48,7 +48,7 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gpt-5-mini".costs]
input_cost_per_mtok = 0.25
@ -72,7 +72,7 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gpt-5.2-codex".costs]
input_cost_per_mtok = 1.75
@ -97,7 +97,7 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gpt-5.3-codex".costs]
input_cost_per_mtok = 1.75
@ -122,7 +122,7 @@ max_output = 128000
tools = true
vision = false
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gpt-5.4"]
provider = "openai"
@ -143,7 +143,7 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gpt-5.4".costs]
input_cost_per_mtok = 2.5
@ -168,7 +168,7 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gpt-5.5".costs]
input_cost_per_mtok = 5.0
@ -193,7 +193,7 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gpt-5.5-pro".costs]
input_cost_per_mtok = 30.0
@ -218,7 +218,7 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gpt-5.4-pro".costs]
input_cost_per_mtok = 30.0
@ -243,7 +243,7 @@ max_output = 128000
tools = true
vision = true
reasoning = true
effort = true
reasoning_effort = "levels"
[models."gpt-5.4-mini".costs]
input_cost_per_mtok = 0.75

View file

@ -26,4 +26,4 @@ pub use model_ref::ModelHandle;
pub use model_test::ModelTestMode;
pub use provider::Provider;
pub use reasoning::ReasoningEffort;
pub use types::{Model, ModelCosts, ModelFeatures, ModelLimits};
pub use types::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature};

View file

@ -5,6 +5,27 @@ use crate::provider::Provider;
// --- 2.9 Model ---
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasoningEffortFeature {
Levels,
#[default]
None,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelLimits {
pub context_window: i64,
@ -13,16 +34,20 @@ pub struct ModelLimits {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelFeatures {
pub tools: bool,
pub vision: bool,
pub reasoning: bool,
/// Whether the model supports the `reasoning_effort` / `effort` parameter
/// directly (e.g. Anthropic `output_config.effort`, OpenAI
/// `reasoning.effort`). Models with `reasoning=true` but `effort=false`
/// (e.g. claude-sonnet-4-5) need the older `thinking` API with
/// `budget_tokens` instead.
pub tools: bool,
pub vision: bool,
pub reasoning: bool,
/// Whether Fabro may expose abstract reasoning effort levels for this
/// model endpoint.
#[serde(default)]
pub effort: bool,
pub reasoning_effort: ReasoningEffortFeature,
/// Whether this model endpoint supports prompt caching annotations.
#[serde(default)]
pub prompt_cache: bool,
/// Deprecated compatibility bool equivalent to
/// `reasoning_effort == "levels"`.
#[serde(default)]
pub effort: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -95,8 +120,16 @@ impl Model {
self.features.reasoning
}
pub fn supports_reasoning_effort(&self) -> bool {
self.features.reasoning_effort == ReasoningEffortFeature::Levels
}
pub fn supports_effort(&self) -> bool {
self.features.effort
self.supports_reasoning_effort()
}
pub fn supports_prompt_cache(&self) -> bool {
self.features.prompt_cache
}
pub fn training(&self) -> Option<&str> {

View file

@ -220,6 +220,7 @@ models/pull-request-settings.ts
models/pull-request-user.ts
models/pull-request.ts
models/question-type.ts
models/reasoning-effort-feature.ts
models/render-workflow-graph-direction.ts
models/render-workflow-graph-format.ts
models/render-workflow-graph-request.ts

View file

@ -197,6 +197,7 @@ export * from './pull-request-ref';
export * from './pull-request-settings';
export * from './pull-request-user';
export * from './question-type';
export * from './reasoning-effort-feature';
export * from './render-workflow-graph-direction';
export * from './render-workflow-graph-format';
export * from './render-workflow-graph-request';

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ReasoningEffortFeature } from './reasoning-effort-feature';
/**
* Capability flags for a model.
@ -30,9 +33,14 @@ export interface ModelFeatures {
* Whether the model supports extended reasoning.
*/
'reasoning': boolean;
'reasoning_effort': ReasoningEffortFeature;
/**
* Whether the model supports direct reasoning effort controls.
* Whether the model endpoint supports prompt caching.
*/
'prompt_cache': boolean;
/**
* Deprecated compatibility flag equivalent to reasoning_effort = levels.
* @deprecated
*/
'effort': boolean;
}

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Whether Fabro may expose reasoning effort levels for a model.
*/
export const ReasoningEffortFeature = {
LEVELS: 'levels',
NONE: 'none'
} as const;
export type ReasoningEffortFeature = typeof ReasoningEffortFeature[keyof typeof ReasoningEffortFeature];