Merge pull request #617 from fabro-sh/feat/expose-model-controls

Expose model reasoning effort controls
This commit is contained in:
Bryan Helmkamp 2026-07-24 09:34:55 -04:00 committed by GitHub
commit 415e9e5cd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 205 additions and 10 deletions

View file

@ -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:

View file

@ -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),

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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,

View file

@ -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", &[]),

View file

@ -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;

View file

@ -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::<ApiModel, Model>();
assert_same_type::<ApiModelControls, ModelControls>();
}
#[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);

View file

@ -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,

View file

@ -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,

View file

@ -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,
};

View file

@ -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<f64>,
}
#[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<ReasoningEffort>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Model {
pub id: ModelId,
@ -93,6 +102,10 @@ pub struct Model {
pub training: Option<String>,
pub knowledge_cutoff: Option<String>,
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<f64>,
pub aliases: Vec<String>,
@ -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),

View file

@ -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

View file

@ -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';

View file

@ -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<ReasoningEffort>;
}

View file

@ -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.