mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-23 00:41:13 +00:00
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:
parent
c300d32a82
commit
68ab756040
27 changed files with 633 additions and 116 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", &[]),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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#"
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
||||
|
|
|
|||
|
|
@ -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: &[],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
Loading…
Add table
Reference in a new issue