diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 4e3df61b1..af00cf4ca 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -8289,6 +8289,20 @@ components: description: Cost per million cached input tokens in USD. example: 1.50 + ModelControls: + description: Request-control values accepted by a provider/model offering. + type: object + required: + - reasoning_effort + properties: + reasoning_effort: + type: array + description: >- + Exact reasoning-effort values accepted by this offering. An empty + array means the request control is unsupported. + items: + $ref: "#/components/schemas/ReasoningEffort" + Model: description: | One provider's offering of an LLM model. The `id` is unique within @@ -8303,6 +8317,7 @@ components: - training - knowledge_cutoff - features + - controls - costs - estimated_output_tps - aliases @@ -8336,6 +8351,8 @@ components: example: "May 2025" features: $ref: "#/components/schemas/ModelFeatures" + controls: + $ref: "#/components/schemas/ModelControls" costs: $ref: "#/components/schemas/ModelCosts" estimated_output_tps: diff --git a/lib/apps/fabro-cli/src/commands/model.rs b/lib/apps/fabro-cli/src/commands/model.rs index f84bd44e2..774feae42 100644 --- a/lib/apps/fabro-cli/src/commands/model.rs +++ b/lib/apps/fabro-cli/src/commands/model.rs @@ -517,7 +517,9 @@ impl Default for ModelsCommand { #[cfg(test)] mod tests { - use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature}; + use fabro_model::{ + ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature, + }; use super::*; @@ -546,6 +548,7 @@ mod tests { cache_control_breakpoints: false, sampling_params: true, }, + controls: ModelControls::default(), costs: ModelCosts { input_cost_per_mtok: Some(1.0), output_cost_per_mtok: Some(2.0), @@ -581,6 +584,7 @@ mod tests { cache_control_breakpoints: false, sampling_params: true, }, + controls: ModelControls::default(), costs: ModelCosts { input_cost_per_mtok: Some(1.0), output_cost_per_mtok: Some(2.0), diff --git a/lib/apps/fabro-cli/tests/it/cmd/model.rs b/lib/apps/fabro-cli/tests/it/cmd/model.rs index 18d3bce01..28fe1fa01 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/model.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/model.rs @@ -108,6 +108,9 @@ fn list_with_filters_renders_server_models_table() { "vision": false, "reasoning": false }, + "controls": { + "reasoning_effort": [] + }, "costs": { "input_cost_per_mtok": 1.2, "output_cost_per_mtok": 3.4, @@ -134,6 +137,9 @@ fn list_with_filters_renders_server_models_table() { "vision": true, "reasoning": true }, + "controls": { + "reasoning_effort": [] + }, "costs": { "input_cost_per_mtok": null, "output_cost_per_mtok": null, @@ -206,6 +212,9 @@ fn list_uses_configured_server_target_without_server_flag() { "vision": false, "reasoning": false }, + "controls": { + "reasoning_effort": [] + }, "costs": { "input_cost_per_mtok": 1.0, "output_cost_per_mtok": 2.0, @@ -260,6 +269,9 @@ fn list_uses_fabro_config_for_machine_settings() { "vision": false, "reasoning": false }, + "controls": { + "reasoning_effort": [] + }, "costs": { "input_cost_per_mtok": 1.0, "output_cost_per_mtok": 2.0, diff --git a/lib/apps/fabro-cli/tests/it/cmd/model_test.rs b/lib/apps/fabro-cli/tests/it/cmd/model_test.rs index 70e64ac77..acaae9851 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/model_test.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/model_test.rs @@ -42,6 +42,9 @@ fn model_json(id: &str, provider: &str, configured: bool) -> serde_json::Value { "vision": false, "reasoning": false }, + "controls": { + "reasoning_effort": [] + }, "costs": { "input_cost_per_mtok": 1.0, "output_cost_per_mtok": 2.0, diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 2375dd54e..0ca6f15cb 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -6768,6 +6768,35 @@ async fn list_models_filters_by_provider() { ); } +#[tokio::test] +async fn list_models_exposes_reasoning_effort_controls() { + let app = test_app_with(); + + let req = Request::builder() + .method("GET") + .uri(api("/models?provider=kimi")) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + let body = response_json!(response, StatusCode::OK).await; + let models = body["data"].as_array().unwrap(); + let kimi_k3 = models + .iter() + .find(|model| model["id"] == "kimi-k3") + .expect("Kimi K3 should be listed"); + let kimi_k2_5 = models + .iter() + .find(|model| model["id"] == "kimi-k2.5") + .expect("Kimi K2.5 should be listed"); + + assert_eq!( + kimi_k3["controls"]["reasoning_effort"], + json!(["low", "high", "max"]) + ); + assert_eq!(kimi_k2_5["controls"]["reasoning_effort"], json!([])); +} + #[tokio::test] async fn list_models_marks_configured_true_when_provider_has_credential_material() { let state = test_app_state_with_env_lookup( diff --git a/lib/components/fabro-llm/src/model_test.rs b/lib/components/fabro-llm/src/model_test.rs index aec0a83a7..6a985fb1b 100644 --- a/lib/components/fabro-llm/src/model_test.rs +++ b/lib/components/fabro-llm/src/model_test.rs @@ -169,7 +169,9 @@ fn validate_deep_result(result: &GenerateResult) -> Result<(), String> { mod tests { use std::collections::HashMap; - use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature}; + use fabro_model::{ + ModelControls, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature, + }; use super::*; use crate::types::{FinishReason, Message, Response, StepResult, TokenCounts, ToolResult}; @@ -187,6 +189,7 @@ mod tests { training: None, knowledge_cutoff: None, features, + controls: ModelControls::default(), costs: ModelCosts { input_cost_per_mtok: None, output_cost_per_mtok: None, diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index ea005803f..96665e176 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -484,6 +484,7 @@ fn main() { ), ("ReasoningEffort", "fabro_model::ReasoningEffort", &[]), ("ModelFeatures", "fabro_model::ModelFeatures", &[]), + ("ModelControls", "fabro_model::ModelControls", &[]), ("ModelCosts", "fabro_model::ModelCosts", &[]), ("ModelTestMode", "fabro_model::ModelTestMode", &[]), ("RunProjection", "fabro_types::RunProjection", &[]), diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index a826b48ea..6bd8ea14e 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -20,9 +20,9 @@ pub mod types { }; pub use fabro_environment::Environment; pub use fabro_model::{ - CostSource, Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, - ModelTestMode, Provider, ReasoningEffort, ReasoningEffortFeature, Speed as BillingSpeed, - TokenCounts as CompletionUsage, + CostSource, Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, + ModelRef as BillingModelRef, ModelTestMode, Provider, ReasoningEffort, + ReasoningEffortFeature, Speed as BillingSpeed, TokenCounts as CompletionUsage, }; pub use fabro_types::run_event::AgentSessionActivatedProps; pub use fabro_types::settings::run::McpHttpProtocol; diff --git a/lib/foundation/fabro-api/tests/model_round_trip.rs b/lib/foundation/fabro-api/tests/model_round_trip.rs index 57d3db557..0decc177f 100644 --- a/lib/foundation/fabro-api/tests/model_round_trip.rs +++ b/lib/foundation/fabro-api/tests/model_round_trip.rs @@ -1,13 +1,15 @@ use std::any::{TypeId, type_name}; -use fabro_api::types::Model as ApiModel; +use fabro_api::types::{Model as ApiModel, ModelControls as ApiModelControls}; use fabro_model::{ - Model, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature, + Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffort, + ReasoningEffortFeature, }; #[test] fn model_reuses_canonical_type() { assert_same_type::(); + assert_same_type::(); } #[test] @@ -32,6 +34,13 @@ fn model_json_matches_openapi_shape() { cache_control_breakpoints: false, sampling_params: true, }, + controls: ModelControls { + reasoning_effort: vec![ + ReasoningEffort::Low, + ReasoningEffort::High, + ReasoningEffort::Max, + ], + }, costs: ModelCosts { input_cost_per_mtok: Some(5.0), output_cost_per_mtok: Some(25.0), @@ -50,6 +59,10 @@ fn model_json_matches_openapi_shape() { assert_eq!(json["knowledge_cutoff"], "May 2025"); assert_eq!(json["features"]["reasoning_effort"], "levels"); assert_eq!(json["features"]["prompt_cache"], true); + assert_eq!( + json["controls"]["reasoning_effort"], + serde_json::json!(["low", "high", "max"]) + ); assert_eq!(json["estimated_output_tps"], 25.0); assert_eq!(json["small_default"], true); assert_eq!(json["configured"], true); diff --git a/lib/foundation/fabro-api/tests/provider_id_round_trip.rs b/lib/foundation/fabro-api/tests/provider_id_round_trip.rs index 970c7158f..2c834ac3f 100644 --- a/lib/foundation/fabro-api/tests/provider_id_round_trip.rs +++ b/lib/foundation/fabro-api/tests/provider_id_round_trip.rs @@ -2,7 +2,8 @@ use std::any::{TypeId, type_name}; use fabro_api::types::Model as ApiModel; use fabro_model::{ - Model, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature, + Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ProviderId, + ReasoningEffortFeature, }; use serde_json::json; @@ -42,6 +43,7 @@ fn provider_id_json_matches_openapi_shape_through_model() { cache_control_breakpoints: false, sampling_params: true, }, + controls: ModelControls::default(), costs: ModelCosts { input_cost_per_mtok: None, output_cost_per_mtok: None, diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index c3f4de596..ddded4535 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -15,7 +15,9 @@ use crate::codec::CodecKind; use crate::ids::{ModelId, ProviderId}; use crate::provider::Provider; use crate::reasoning::ReasoningEffort; -use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature}; +use crate::types::{ + Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature, +}; #[derive(RustEmbed)] #[folder = "src/catalog/providers"] @@ -2228,6 +2230,9 @@ fn build_model( training: settings.training.clone(), knowledge_cutoff: settings.knowledge_cutoff.clone(), features: model_features, + controls: ModelControls { + reasoning_effort: controls.reasoning_effort.clone(), + }, costs, estimated_output_tps: settings.estimated_output_tps, aliases: settings.aliases.clone().unwrap_or_default(), @@ -3324,6 +3329,12 @@ enabled = true cache_control_breakpoints: false, sampling_params: true, }, + controls: ModelControls { + reasoning_effort: [ + High, + XHigh, + ], + }, costs: ModelCosts { input_cost_per_mtok: Some( 0.784, @@ -3394,6 +3405,13 @@ enabled = true cache_control_breakpoints: false, sampling_params: false, }, + controls: ModelControls { + reasoning_effort: [ + Low, + High, + Max, + ], + }, costs: ModelCosts { input_cost_per_mtok: Some( 3.0, @@ -6018,6 +6036,15 @@ sampling_params = false cache_control_breakpoints: false, sampling_params: true, }, + controls: ModelControls { + reasoning_effort: [ + Low, + Medium, + High, + XHigh, + Max, + ], + }, costs: ModelCosts { input_cost_per_mtok: Some( 5.0, @@ -6075,6 +6102,9 @@ sampling_params = false cache_control_breakpoints: false, sampling_params: false, }, + controls: ModelControls { + reasoning_effort: [], + }, costs: ModelCosts { input_cost_per_mtok: Some( 0.6, @@ -6124,6 +6154,13 @@ sampling_params = false cache_control_breakpoints: false, sampling_params: false, }, + controls: ModelControls { + reasoning_effort: [ + Low, + High, + Max, + ], + }, costs: ModelCosts { input_cost_per_mtok: Some( 3.0, @@ -6197,6 +6234,12 @@ sampling_params = false cache_control_breakpoints: false, sampling_params: true, }, + controls: ModelControls { + reasoning_effort: [ + High, + Max, + ], + }, costs: ModelCosts { input_cost_per_mtok: Some( 1.4, @@ -6267,6 +6310,15 @@ sampling_params = false cache_control_breakpoints: false, sampling_params: true, }, + controls: ModelControls { + reasoning_effort: [ + Low, + Medium, + High, + XHigh, + Max, + ], + }, costs: ModelCosts { input_cost_per_mtok: Some( 0.25, @@ -6324,6 +6376,15 @@ sampling_params = false cache_control_breakpoints: false, sampling_params: true, }, + controls: ModelControls { + reasoning_effort: [ + Low, + Medium, + High, + XHigh, + Max, + ], + }, costs: ModelCosts { input_cost_per_mtok: Some( 30.0, diff --git a/lib/foundation/fabro-model/src/lib.rs b/lib/foundation/fabro-model/src/lib.rs index 5f3511096..fc43c95a7 100644 --- a/lib/foundation/fabro-model/src/lib.rs +++ b/lib/foundation/fabro-model/src/lib.rs @@ -27,4 +27,6 @@ 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, ReasoningEffortFeature}; +pub use types::{ + Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature, +}; diff --git a/lib/foundation/fabro-model/src/types.rs b/lib/foundation/fabro-model/src/types.rs index ae3804784..50ae020dc 100644 --- a/lib/foundation/fabro-model/src/types.rs +++ b/lib/foundation/fabro-model/src/types.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{ModelId, ProviderId}; +use crate::reasoning::ReasoningEffort; // --- 2.9 Model --- @@ -83,6 +84,14 @@ pub struct ModelCosts { pub cache_input_cost_per_mtok: Option, } +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ModelControls { + /// Exact reasoning-effort values accepted by this provider/model offering. + /// An empty list means the request control is unsupported. + #[serde(default)] + pub reasoning_effort: Vec, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Model { pub id: ModelId, @@ -93,6 +102,10 @@ pub struct Model { pub training: Option, pub knowledge_cutoff: Option, pub features: ModelFeatures, + /// Required in API responses; defaulted on deserialization so newer + /// clients tolerate older servers that predate this field. + #[serde(default)] + pub controls: ModelControls, pub costs: ModelCosts, pub estimated_output_tps: Option, pub aliases: Vec, @@ -236,6 +249,7 @@ mod tests { cache_control_breakpoints: false, sampling_params: true, }, + controls: ModelControls::default(), costs: ModelCosts { input_cost_per_mtok: Some(1.0), output_cost_per_mtok: Some(2.0), diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 82eee9346..9ea572eb3 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -226,6 +226,7 @@ models/mcp-transport.ts models/merge-method.ts models/merge-run-pull-request-request.ts models/merge-run-pull-request-response.ts +models/model-controls.ts models/model-costs.ts models/model-features.ts models/model-limits.ts diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index ff970ecb5..d80dd8a4c 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 './merge-method'; export * from './merge-run-pull-request-request'; export * from './merge-run-pull-request-response'; export * from './model'; +export * from './model-controls'; export * from './model-costs'; export * from './model-features'; export * from './model-limits'; diff --git a/lib/packages/fabro-api-client/src/models/model-controls.ts b/lib/packages/fabro-api-client/src/models/model-controls.ts new file mode 100644 index 000000000..59f22ce2a --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/model-controls.ts @@ -0,0 +1,28 @@ +/* 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. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { ReasoningEffort } from './reasoning-effort'; + +/** + * Request-control values accepted by a provider/model offering. + */ +export interface ModelControls { + /** + * Exact reasoning-effort values accepted by this offering. An empty array means the request control is unsupported. + */ + 'reasoning_effort': Array; +} diff --git a/lib/packages/fabro-api-client/src/models/model.ts b/lib/packages/fabro-api-client/src/models/model.ts index d3326e2db..0bf86bee5 100644 --- a/lib/packages/fabro-api-client/src/models/model.ts +++ b/lib/packages/fabro-api-client/src/models/model.ts @@ -13,6 +13,9 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { ModelControls } from './model-controls'; // May contain unused imports in some cases // @ts-ignore import type { ModelCosts } from './model-costs'; @@ -53,6 +56,7 @@ export interface Model { */ 'knowledge_cutoff': string | null; 'features': ModelFeatures; + 'controls': ModelControls; 'costs': ModelCosts; /** * Estimated output tokens per second.