diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index ba165d183..0879a39b8 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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. diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index e142b11fc..e36ff6fba 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -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", &[]), diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 738917a80..a69d6a4f7 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -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, diff --git a/lib/crates/fabro-api/tests/model_features_round_trip.rs b/lib/crates/fabro-api/tests/model_features_round_trip.rs index 4619e76b1..1cffcfe0a 100644 --- a/lib/crates/fabro-api/tests/model_features_round_trip.rs +++ b/lib/crates/fabro-api/tests/model_features_round_trip.rs @@ -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); diff --git a/lib/crates/fabro-api/tests/model_round_trip.rs b/lib/crates/fabro-api/tests/model_round_trip.rs index 4173131ed..8baeb6447 100644 --- a/lib/crates/fabro-api/tests/model_round_trip.rs +++ b/lib/crates/fabro-api/tests/model_round_trip.rs @@ -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); diff --git a/lib/crates/fabro-api/tests/provider_round_trip.rs b/lib/crates/fabro-api/tests/provider_round_trip.rs index a439b9e37..bcfbd5cc0 100644 --- a/lib/crates/fabro-api/tests/provider_round_trip.rs +++ b/lib/crates/fabro-api/tests/provider_round_trip.rs @@ -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, diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index 909fc949f..0525038da 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -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), diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index f32bdb9b3..d9896ef09 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -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, } } diff --git a/lib/crates/fabro-config/src/layers/combine.rs b/lib/crates/fabro-config/src/layers/combine.rs index dd58fe934..c7d50c38b 100644 --- a/lib/crates/fabro-config/src/layers/combine.rs +++ b/lib/crates/fabro-config/src/layers/combine.rs @@ -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> { diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs index 396f1e650..50535276b 100644 --- a/lib/crates/fabro-config/src/layers/llm.rs +++ b/lib/crates/fabro-config/src/layers/llm.rs @@ -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, + pub tools: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub vision: Option, + pub vision: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning: Option, + pub reasoning: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub effort: Option, + pub reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_cache: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, } /// 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#" diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs index 97783b531..fac687db8 100644 --- a/lib/crates/fabro-config/src/layers/mod.rs +++ b/lib/crates/fabro-config/src/layers/mod.rs @@ -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}; diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 4bd767eff..9fd4d33d1 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -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, diff --git a/lib/crates/fabro-llm/src/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs index c67ffba5f..87255e0d7 100644 --- a/lib/crates/fabro-llm/src/model_test.rs +++ b/lib/crates/fabro-llm/src/model_test.rs @@ -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; diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index 6cb8b5a0c..f8b36a92b 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -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 { + 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"); diff --git a/lib/crates/fabro-llm/src/types.rs b/lib/crates/fabro-llm/src/types.rs index a5a14b359..b33c7d670 100644 --- a/lib/crates/fabro-llm/src/types.rs +++ b/lib/crates/fabro-llm/src/types.rs @@ -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 --- diff --git a/lib/crates/fabro-model/src/adapter.rs b/lib/crates/fabro-model/src/adapter.rs index 68db1f792..3e37a4b4e 100644 --- a/lib/crates/fabro-model/src/adapter.rs +++ b/lib/crates/fabro-model/src/adapter.rs @@ -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: &[], }, diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index b0658e89f..f26e78551 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -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, + pub tools: Option, #[serde(default)] - pub vision: Option, + pub vision: Option, #[serde(default)] - pub reasoning: Option, + pub reasoning: Option, #[serde(default)] - pub effort: Option, + pub reasoning_effort: Option, + #[serde(default)] + pub prompt_cache: Option, + #[serde(default)] + pub effort: Option, } #[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 { + 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 { + 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::, _>>()?, - 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 { diff --git a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml index 5cb101f0c..1c51fa643 100644 --- a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml +++ b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml @@ -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 diff --git a/lib/crates/fabro-model/src/catalog/providers/gemini.toml b/lib/crates/fabro-model/src/catalog/providers/gemini.toml index 441e3c46d..d7e4c8b0d 100644 --- a/lib/crates/fabro-model/src/catalog/providers/gemini.toml +++ b/lib/crates/fabro-model/src/catalog/providers/gemini.toml @@ -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 diff --git a/lib/crates/fabro-model/src/catalog/providers/inception.toml b/lib/crates/fabro-model/src/catalog/providers/inception.toml index 591e76237..34d01cf9d 100644 --- a/lib/crates/fabro-model/src/catalog/providers/inception.toml +++ b/lib/crates/fabro-model/src/catalog/providers/inception.toml @@ -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 diff --git a/lib/crates/fabro-model/src/catalog/providers/openai.toml b/lib/crates/fabro-model/src/catalog/providers/openai.toml index 954c8a6bc..f93ead090 100644 --- a/lib/crates/fabro-model/src/catalog/providers/openai.toml +++ b/lib/crates/fabro-model/src/catalog/providers/openai.toml @@ -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 diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/crates/fabro-model/src/lib.rs index 516d49df8..a9adada4a 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -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}; diff --git a/lib/crates/fabro-model/src/types.rs b/lib/crates/fabro-model/src/types.rs index a7ab95d0b..bbd653e89 100644 --- a/lib/crates/fabro-model/src/types.rs +++ b/lib/crates/fabro-model/src/types.rs @@ -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> { diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 714dd5cb1..cdceffd1b 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index fddfb31bc..bc83aad0d 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/model-features.ts b/lib/packages/fabro-api-client/src/models/model-features.ts index 5c522e435..b495aa069 100644 --- a/lib/packages/fabro-api-client/src/models/model-features.ts +++ b/lib/packages/fabro-api-client/src/models/model-features.ts @@ -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; } - diff --git a/lib/packages/fabro-api-client/src/models/reasoning-effort-feature.ts b/lib/packages/fabro-api-client/src/models/reasoning-effort-feature.ts new file mode 100644 index 000000000..774b00841 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/reasoning-effort-feature.ts @@ -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];