mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
refactor: simplify model-keyed fallback internals
Consolidation pass over the fallback feature, no intended behavior changes beyond noted validation and event-shape cleanups: - Unify the two parallel notice types: FallbackPlanNotice is gone; ModelFallbackNotice now owns the runtime NoNearbyReasoningLevel case and the shared ChainEmpty wording. Notices emit through a new Emitter::notice_scoped with their own level, and each distinct notice is emitted once per run instead of on every LLM call. - Move canonical_model_id onto Catalog so chain keys are written and read through one function; reject provider-qualified fallback keys, which could never match at dispatch and were silently dead config. - Type FallbackTarget as ProviderId/ModelId, removing repeated ProviderId::new re-wrapping at every use site. - Derive FallbackPlan's current route from a position index instead of storing current/requested_controls copies; advance() no longer has unreachable None branches. - Bundle the agent invocation's live state (session, bridge, lease, forwarder, accounting) into LiveAgentInvocation; failover_agent_session drops from 21 parameters to 7 and the six copies of the abort/discard/classify teardown collapse into two methods. - Share one route_request builder between one_shot and its failover loop; complete_one_shot_request takes the request by value instead of deep-cloning the message payload per call. - Event::Failover carries FailoverProps directly; the props' original route and attempt fields are now required, and reasoning efforts are typed ReasoningEffort instead of strings. - Reuse RunModelSettings/RunModelControls in fabro-api via with_replacement, add the missing controls property to the OpenAPI schema, regenerate the TS client, and add the type-identity/JSON parity test. - Smaller cleanups: ReasoningEffort::closest_supported uses enum discriminants; ModelFallbackPolicy gains len(); resolve_model_fallbacks takes a provider slice; duplicate-target filtering lives only in the resolver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ba82656656
commit
e6eb36f852
20 changed files with 715 additions and 667 deletions
|
|
@ -14324,6 +14324,20 @@ components:
|
|||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ModelRef"
|
||||
controls:
|
||||
$ref: "#/components/schemas/RunModelControls"
|
||||
|
||||
RunModelControls:
|
||||
type: object
|
||||
description: >
|
||||
Run-level default values for typed model controls. Node and style
|
||||
attributes still win over these defaults.
|
||||
required: [reasoning_effort, speed]
|
||||
properties:
|
||||
reasoning_effort:
|
||||
type: ["string", "null"]
|
||||
speed:
|
||||
type: ["string", "null"]
|
||||
|
||||
RunGitSettings:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
|
@ -626,8 +626,7 @@ fn run_model_fallback_check(
|
|||
return true;
|
||||
}
|
||||
|
||||
let eligible = ready_providers.iter().cloned().collect::<HashSet<_>>();
|
||||
let resolved = match resolve_model_fallbacks(catalog, &eligible, configured) {
|
||||
let resolved = match resolve_model_fallbacks(catalog, ready_providers, configured) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => {
|
||||
checks.push(CheckResult {
|
||||
|
|
@ -676,10 +675,7 @@ fn run_model_fallback_check(
|
|||
} else {
|
||||
CheckStatus::Pass
|
||||
},
|
||||
summary: format!(
|
||||
"{} requested model chain(s)",
|
||||
resolved.policy.iter().count()
|
||||
),
|
||||
summary: format!("{} requested model chain(s)", resolved.policy.len()),
|
||||
details,
|
||||
remediation: None,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1146,30 +1146,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
ssh_command: ssh_command.clone(),
|
||||
})
|
||||
}
|
||||
Event::Failover {
|
||||
original_provider,
|
||||
original_model,
|
||||
attempt,
|
||||
from_provider,
|
||||
from_model,
|
||||
to_provider,
|
||||
to_model,
|
||||
requested_reasoning_effort,
|
||||
effective_reasoning_effort,
|
||||
error,
|
||||
..
|
||||
} => EventBody::Failover(fabro_types::FailoverProps {
|
||||
original_provider: Some(original_provider.clone()),
|
||||
original_model: Some(original_model.clone()),
|
||||
attempt: Some(*attempt),
|
||||
from_provider: from_provider.clone(),
|
||||
from_model: from_model.clone(),
|
||||
to_provider: to_provider.clone(),
|
||||
to_model: to_model.clone(),
|
||||
requested_reasoning_effort: requested_reasoning_effort.clone(),
|
||||
effective_reasoning_effort: effective_reasoning_effort.clone(),
|
||||
error: error.clone(),
|
||||
}),
|
||||
Event::Failover { props, .. } => EventBody::Failover(props.clone()),
|
||||
Event::CommandStarted {
|
||||
script,
|
||||
command,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,24 @@ impl Emitter {
|
|||
});
|
||||
}
|
||||
|
||||
pub fn notice_scoped(
|
||||
&self,
|
||||
level: RunNoticeLevel,
|
||||
code: RunNoticeCode,
|
||||
message: impl Into<String>,
|
||||
scope: &StageScope,
|
||||
) {
|
||||
self.emit_scoped(
|
||||
&Event::RunNotice {
|
||||
level,
|
||||
code: code.to_string(),
|
||||
message: message.into(),
|
||||
exec_output_tail: None,
|
||||
},
|
||||
scope,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn notice_with_tail(
|
||||
&self,
|
||||
level: RunNoticeLevel,
|
||||
|
|
|
|||
|
|
@ -584,16 +584,7 @@ pub enum Event {
|
|||
},
|
||||
Failover {
|
||||
stage: String,
|
||||
original_provider: String,
|
||||
original_model: String,
|
||||
attempt: u32,
|
||||
from_provider: String,
|
||||
from_model: String,
|
||||
to_provider: String,
|
||||
to_model: String,
|
||||
requested_reasoning_effort: Option<String>,
|
||||
effective_reasoning_effort: Option<String>,
|
||||
error: String,
|
||||
props: fabro_types::FailoverProps,
|
||||
},
|
||||
CommandStarted {
|
||||
node_id: String,
|
||||
|
|
@ -1398,31 +1389,19 @@ impl Event {
|
|||
Self::SshAccessReady { ssh_command } => {
|
||||
info!(ssh_command, "SSH access ready");
|
||||
}
|
||||
Self::Failover {
|
||||
stage,
|
||||
original_provider,
|
||||
original_model,
|
||||
attempt,
|
||||
from_provider,
|
||||
from_model,
|
||||
to_provider,
|
||||
to_model,
|
||||
requested_reasoning_effort,
|
||||
effective_reasoning_effort,
|
||||
error,
|
||||
} => {
|
||||
Self::Failover { stage, props } => {
|
||||
warn!(
|
||||
stage,
|
||||
original_provider,
|
||||
original_model,
|
||||
attempt,
|
||||
from_provider,
|
||||
from_model,
|
||||
to_provider,
|
||||
to_model,
|
||||
requested_reasoning_effort,
|
||||
effective_reasoning_effort,
|
||||
error,
|
||||
original_provider = %props.original_provider,
|
||||
original_model = %props.original_model,
|
||||
attempt = props.attempt,
|
||||
from_provider = %props.from_provider,
|
||||
from_model = %props.from_model,
|
||||
to_provider = %props.to_provider,
|
||||
to_model = %props.to_model,
|
||||
requested_reasoning_effort = ?props.requested_reasoning_effort,
|
||||
effective_reasoning_effort = ?props.effective_reasoning_effort,
|
||||
error = %props.error,
|
||||
"LLM provider failover"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,8 @@
|
|||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use fabro_model::{Catalog, FallbackTarget, Model, ModelSelectionError, ProviderId};
|
||||
use fabro_model::{
|
||||
Catalog, FallbackTarget, Model, ModelSelectionError, ProviderId, ReasoningEffort,
|
||||
};
|
||||
use fabro_types::settings::{ModelRef, ResolvedModelRef};
|
||||
use fabro_types::{RunNoticeCode, RunNoticeLevel};
|
||||
|
||||
|
|
@ -16,6 +18,7 @@ pub struct ModelFallbackPolicy {
|
|||
}
|
||||
|
||||
impl ModelFallbackPolicy {
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn new(chains: BTreeMap<String, Vec<FallbackTarget>>) -> Self {
|
||||
Self { chains }
|
||||
|
|
@ -28,8 +31,13 @@ impl ModelFallbackPolicy {
|
|||
provider: &ProviderId,
|
||||
model: &str,
|
||||
) -> Option<&'a [FallbackTarget]> {
|
||||
let canonical = canonical_model_id(catalog, provider, model);
|
||||
self.chains.get(&canonical).map(Vec::as_slice)
|
||||
self.chain_for_canonical(&catalog.canonical_model_id(provider, model))
|
||||
}
|
||||
|
||||
/// Look up a chain by an already-canonicalized requested model ID.
|
||||
#[must_use]
|
||||
pub fn chain_for_canonical(&self, canonical_model: &str) -> Option<&[FallbackTarget]> {
|
||||
self.chains.get(canonical_model).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &[FallbackTarget])> {
|
||||
|
|
@ -38,23 +46,17 @@ impl ModelFallbackPolicy {
|
|||
.map(|(model, chain)| (model.as_str(), chain.as_slice()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.chains.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.chains.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_model_id(catalog: &Catalog, provider: &ProviderId, model: &str) -> String {
|
||||
catalog.get_on_provider(provider, model).map_or_else(
|
||||
|| {
|
||||
catalog
|
||||
.select(model, None, &catalog.all_provider_ids())
|
||||
.map_or_else(|_| model.to_string(), |offering| offering.id.to_string())
|
||||
},
|
||||
|offering| offering.id.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Server-side result of canonicalizing and filtering configured fallback
|
||||
/// chains.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
|
|
@ -91,6 +93,11 @@ pub enum ModelFallbackNotice {
|
|||
reference: ModelRef,
|
||||
target: FallbackTarget,
|
||||
},
|
||||
NoNearbyReasoningLevel {
|
||||
requested_model: String,
|
||||
target: FallbackTarget,
|
||||
requested_effort: ReasoningEffort,
|
||||
},
|
||||
ChainEmpty {
|
||||
requested_model: String,
|
||||
},
|
||||
|
|
@ -105,7 +112,8 @@ impl ModelFallbackNotice {
|
|||
| Self::NoConfiguredOffering { .. }
|
||||
| Self::PrimaryNotInCatalog { .. }
|
||||
| Self::NoCompatibleModel { .. }
|
||||
| Self::Duplicate { .. } => RunNoticeCode::ModelFallbackSkipped,
|
||||
| Self::Duplicate { .. }
|
||||
| Self::NoNearbyReasoningLevel { .. } => RunNoticeCode::ModelFallbackSkipped,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +125,7 @@ impl ModelFallbackNotice {
|
|||
| Self::NoConfiguredOffering { .. }
|
||||
| Self::PrimaryNotInCatalog { .. }
|
||||
| Self::NoCompatibleModel { .. }
|
||||
| Self::NoNearbyReasoningLevel { .. }
|
||||
| Self::ChainEmpty { .. } => RunNoticeLevel::Warn,
|
||||
}
|
||||
}
|
||||
|
|
@ -166,6 +175,13 @@ impl ModelFallbackNotice {
|
|||
} => format!(
|
||||
"Model fallback `{reference}` for requested model `{requested_model}` was skipped because target `{target}` already appears in that chain."
|
||||
),
|
||||
Self::NoNearbyReasoningLevel {
|
||||
requested_model,
|
||||
target,
|
||||
requested_effort,
|
||||
} => format!(
|
||||
"Model fallback `{target}` for requested model `{requested_model}` was skipped because it has no reasoning level near `{requested_effort}`."
|
||||
),
|
||||
Self::ChainEmpty { requested_model } => format!(
|
||||
"No usable model fallbacks remain for requested model `{requested_model}` after filtering its configured candidates."
|
||||
),
|
||||
|
|
@ -180,15 +196,17 @@ impl ModelFallbackNotice {
|
|||
/// parses the raw table and cannot canonicalize model aliases.
|
||||
pub fn resolve_model_fallbacks(
|
||||
catalog: &Catalog,
|
||||
eligible: &HashSet<ProviderId>,
|
||||
configured_providers: &[ProviderId],
|
||||
configured: &BTreeMap<String, Vec<ModelRef>>,
|
||||
) -> Result<ResolvedModelFallbacks, Error> {
|
||||
let eligible = configured_providers.iter().cloned().collect::<HashSet<_>>();
|
||||
let mut resolved = ResolvedModelFallbacks::default();
|
||||
let mut raw_key_by_canonical = HashMap::<String, String>::new();
|
||||
|
||||
for (raw_key, references) in configured {
|
||||
require_bare_model_key(catalog, raw_key)?;
|
||||
let selected =
|
||||
catalog.resolve_selection_with_catalog_fallback(Some(raw_key), None, eligible)?;
|
||||
catalog.resolve_selection_with_catalog_fallback(Some(raw_key), None, &eligible)?;
|
||||
let requested_model = selected.model;
|
||||
|
||||
if let Some(previous) =
|
||||
|
|
@ -209,7 +227,7 @@ pub fn resolve_model_fallbacks(
|
|||
&requested_model,
|
||||
&primary,
|
||||
primary_model,
|
||||
eligible,
|
||||
&eligible,
|
||||
model_ref,
|
||||
)? {
|
||||
FallbackCandidate::Skipped(notice) => {
|
||||
|
|
@ -241,6 +259,30 @@ pub fn resolve_model_fallbacks(
|
|||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Reject chain keys that name a provider. Keys are requested-model selectors;
|
||||
/// a provider-qualified key can never match a dispatch-time canonical model
|
||||
/// ID, so it would be silently dead configuration.
|
||||
fn require_bare_model_key(catalog: &Catalog, raw_key: &str) -> Result<(), Error> {
|
||||
let reference: ModelRef = raw_key
|
||||
.parse()
|
||||
.map_err(|error| Error::Precondition(format!("`run.model.fallbacks` key: {error}")))?;
|
||||
match reference.resolve(catalog) {
|
||||
Ok(ResolvedModelRef::Model { provider: None, .. }) => Ok(()),
|
||||
Ok(ResolvedModelRef::Model {
|
||||
provider: Some(_),
|
||||
selector,
|
||||
}) => Err(Error::Precondition(format!(
|
||||
"`run.model.fallbacks` keys name a requested model; use `{selector}` instead of `{raw_key}`"
|
||||
))),
|
||||
Ok(ResolvedModelRef::Provider(provider)) => Err(Error::Precondition(format!(
|
||||
"`run.model.fallbacks` key `{raw_key}` names provider `{provider}`; keys must name a requested model"
|
||||
))),
|
||||
Err(ambiguous) => Err(Error::Precondition(format!(
|
||||
"`run.model.fallbacks` key: {ambiguous}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
enum FallbackCandidate {
|
||||
Target(FallbackTarget),
|
||||
Skipped(ModelFallbackNotice),
|
||||
|
|
@ -332,7 +374,7 @@ fn resolve_fallback_candidate(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fabro_model::{Catalog, FallbackTarget, ProviderId};
|
||||
|
||||
|
|
@ -359,7 +401,7 @@ enabled = true
|
|||
#[test]
|
||||
fn canonicalizes_keys_and_keeps_each_chain_independent() {
|
||||
let catalog = openrouter_catalog();
|
||||
let eligible = HashSet::from([ProviderId::new("openrouter")]);
|
||||
let eligible = [ProviderId::new("openrouter")];
|
||||
let configured = BTreeMap::from([
|
||||
("gpt-sol".to_string(), references(&["claude-opus"])),
|
||||
(
|
||||
|
|
@ -393,7 +435,7 @@ enabled = true
|
|||
#[test]
|
||||
fn rejects_aliases_that_define_the_same_requested_model_twice() {
|
||||
let catalog = openrouter_catalog();
|
||||
let eligible = HashSet::from([ProviderId::new("openrouter")]);
|
||||
let eligible = [ProviderId::new("openrouter")];
|
||||
let configured = BTreeMap::from([
|
||||
("gpt-sol".to_string(), references(&["claude-opus"])),
|
||||
("gpt-5.6-sol".to_string(), references(&["claude-fable"])),
|
||||
|
|
@ -409,10 +451,27 @@ enabled = true
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_provider_qualified_keys() {
|
||||
let catalog = openrouter_catalog();
|
||||
let eligible = [ProviderId::new("openrouter")];
|
||||
let configured = BTreeMap::from([(
|
||||
"openrouter:gpt-sol".to_string(),
|
||||
references(&["claude-opus"]),
|
||||
)]);
|
||||
|
||||
let error = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap_err();
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("keys name a requested model"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unconfigured_candidates_per_requested_model() {
|
||||
let catalog = openrouter_catalog();
|
||||
let eligible = HashSet::from([ProviderId::new("openrouter")]);
|
||||
let eligible = [ProviderId::new("openrouter")];
|
||||
let configured = BTreeMap::from([(
|
||||
"kimi-k3".to_string(),
|
||||
references(&["kimi:kimi-k3", "openrouter:kimi-k3"]),
|
||||
|
|
@ -451,11 +510,11 @@ enabled = true
|
|||
.expect("catalog override should parse");
|
||||
Catalog::from_builtin_with_overrides(&overrides).expect("catalog should build")
|
||||
};
|
||||
let eligible = HashSet::from([
|
||||
let eligible = [
|
||||
ProviderId::new("modal"),
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("openrouter"),
|
||||
]);
|
||||
];
|
||||
let configured = BTreeMap::from([
|
||||
(
|
||||
"kimi-k3".to_string(),
|
||||
|
|
|
|||
|
|
@ -617,7 +617,7 @@ fn resolve_start_llm(
|
|||
settings.model.provider.as_deref(),
|
||||
false,
|
||||
)?;
|
||||
let fallbacks = resolve_model_fallbacks(catalog, &eligible, &settings.model.fallbacks)?;
|
||||
let fallbacks = resolve_model_fallbacks(catalog, configured, &settings.model.fallbacks)?;
|
||||
|
||||
Ok(ResolvedStartLlm {
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -244,6 +244,16 @@ fn main() {
|
|||
"fabro_types::settings::server::ServerAuthGithubSettings",
|
||||
&[],
|
||||
),
|
||||
(
|
||||
"RunModelSettings",
|
||||
"fabro_types::settings::run::RunModelSettings",
|
||||
&[],
|
||||
),
|
||||
(
|
||||
"RunModelControls",
|
||||
"fabro_types::settings::run::RunModelControls",
|
||||
&[],
|
||||
),
|
||||
(
|
||||
"ServerSandboxSettings",
|
||||
"fabro_types::settings::server::ServerSandboxSettings",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ pub mod types {
|
|||
ReasoningEffortFeature, Speed as BillingSpeed, TokenCounts as CompletionUsage,
|
||||
};
|
||||
pub use fabro_types::run_event::AgentSessionActivatedProps;
|
||||
pub use fabro_types::settings::run::McpHttpProtocol;
|
||||
pub use fabro_types::settings::run::{McpHttpProtocol, RunModelControls, RunModelSettings};
|
||||
pub use fabro_types::settings::server::{
|
||||
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
|
||||
LogDestination, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fabro_api::types::{
|
||||
RunModelControls as ApiRunModelControls, RunModelSettings as ApiRunModelSettings,
|
||||
};
|
||||
use fabro_types::settings::run::{RunModelControls, RunModelSettings};
|
||||
|
||||
#[test]
|
||||
fn run_model_settings_reuses_domain_types() {
|
||||
assert_same_type::<ApiRunModelSettings, RunModelSettings>();
|
||||
assert_same_type::<ApiRunModelControls, RunModelControls>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_model_settings_json_matches_openapi_shape() {
|
||||
let settings = RunModelSettings {
|
||||
provider: Some("openrouter".to_string()),
|
||||
name: Some("claude-fable".to_string()),
|
||||
fallbacks: BTreeMap::from([("claude-fable".to_string(), vec![
|
||||
"gpt-sol".parse().expect("fixture reference should parse"),
|
||||
"openrouter:claude-opus"
|
||||
.parse()
|
||||
.expect("fixture reference should parse"),
|
||||
])]),
|
||||
controls: RunModelControls {
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
speed: None,
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&settings).expect("run model settings should serialize");
|
||||
assert_eq!(json["provider"], "openrouter");
|
||||
assert_eq!(json["name"], "claude-fable");
|
||||
assert_eq!(json["fallbacks"]["claude-fable"][0], "gpt-sol");
|
||||
assert_eq!(
|
||||
json["fallbacks"]["claude-fable"][1],
|
||||
"openrouter:claude-opus"
|
||||
);
|
||||
assert_eq!(json["controls"]["reasoning_effort"], "high");
|
||||
assert_eq!(json["controls"]["speed"], serde_json::Value::Null);
|
||||
|
||||
let round_trip: ApiRunModelSettings =
|
||||
serde_json::from_value(json).expect("run model settings should deserialize");
|
||||
assert_eq!(round_trip, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_model_settings_tolerates_absent_controls() {
|
||||
let parsed: RunModelSettings = serde_json::from_value(serde_json::json!({
|
||||
"provider": null,
|
||||
"name": null,
|
||||
"fallbacks": {}
|
||||
}))
|
||||
.expect("settings without controls should deserialize");
|
||||
assert_eq!(parsed.controls, RunModelControls::default());
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} should be the same type as {}",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
|
|
@ -401,8 +401,8 @@ static GLOBAL_CATALOG: LazyLock<Catalog> = LazyLock::new(|| {
|
|||
/// A resolved fallback target: provider name + model ID.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FallbackTarget {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub provider: ProviderId,
|
||||
pub model: ModelId,
|
||||
}
|
||||
|
||||
impl FallbackTarget {
|
||||
|
|
@ -411,8 +411,8 @@ impl FallbackTarget {
|
|||
/// selectors all use one constructor.
|
||||
pub fn new(provider: impl std::fmt::Display, model: impl std::fmt::Display) -> Self {
|
||||
Self {
|
||||
provider: provider.to_string(),
|
||||
model: model.to_string(),
|
||||
provider: ProviderId::new(provider.to_string()),
|
||||
model: ModelId::new(model.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1051,6 +1051,23 @@ impl Catalog {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Canonicalize a model selector to its catalog model ID, preferring the
|
||||
/// given provider's offering. Unknown selectors pass through verbatim.
|
||||
///
|
||||
/// Model-keyed fallback chains are written and read through this one
|
||||
/// function so a configured chain key and a dispatch-time lookup cannot
|
||||
/// silently disagree.
|
||||
#[must_use]
|
||||
pub fn canonical_model_id(&self, provider: &ProviderId, selector: &str) -> String {
|
||||
self.get_on_provider(provider, selector).map_or_else(
|
||||
|| {
|
||||
self.select(selector, None, &self.all_provider_ids())
|
||||
.map_or_else(|_| selector.to_string(), |offering| offering.id.to_string())
|
||||
},
|
||||
|offering| offering.id.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Select the highest-priority default model on an eligible provider.
|
||||
pub fn select_default(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -46,16 +46,10 @@ impl ReasoningEffort {
|
|||
/// are equally distant, the higher effort wins.
|
||||
#[must_use]
|
||||
pub fn closest_supported(self, supported: &[Self]) -> Option<Self> {
|
||||
let variants = Self::variants();
|
||||
let requested_rank = variants.iter().position(|effort| *effort == self)?;
|
||||
|
||||
supported.iter().copied().min_by_key(|effort| {
|
||||
let rank = variants
|
||||
.iter()
|
||||
.position(|candidate| candidate == effort)
|
||||
.expect("supported reasoning effort must be an enum variant");
|
||||
(requested_rank.abs_diff(rank), Reverse(rank))
|
||||
})
|
||||
supported
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by_key(|effort| ((self as u8).abs_diff(*effort as u8), Reverse(*effort)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -114,10 +114,10 @@ pub use run_blob_id::RunBlobId;
|
|||
pub use run_event::{
|
||||
AgentMcpToolSummary, AgentMemoryFileProps, AgentSkillActivationSource, AgentSkillSummary,
|
||||
AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps, EventBody,
|
||||
ExecOutputTail, InterviewOption, LlmOutputKind, LlmRetryPhase, MetadataSnapshotFailureKind,
|
||||
MetadataSnapshotPhase, RunEvent, RunNoticeCode, RunNoticeLevel, RunPairEndedReason,
|
||||
RunPairFailedReason, RunRunnableSource, SessionCapability, TodoCreatedProps, TodoDeletedProps,
|
||||
TodoUpdatedProps,
|
||||
ExecOutputTail, FailoverProps, InterviewOption, LlmOutputKind, LlmRetryPhase,
|
||||
MetadataSnapshotFailureKind, MetadataSnapshotPhase, RunEvent, RunNoticeCode, RunNoticeLevel,
|
||||
RunPairEndedReason, RunPairFailedReason, RunRunnableSource, SessionCapability,
|
||||
TodoCreatedProps, TodoDeletedProps, TodoUpdatedProps,
|
||||
};
|
||||
pub use run_failure::RunFailure;
|
||||
pub use run_id::{RunId, fixtures};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use fabro_model::ReasoningEffort;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ExecOutputTail;
|
||||
|
|
@ -187,20 +188,17 @@ pub struct SshAccessReadyProps {
|
|||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FailoverProps {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original_provider: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original_model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attempt: Option<u32>,
|
||||
pub original_provider: String,
|
||||
pub original_model: String,
|
||||
pub attempt: u32,
|
||||
pub from_provider: String,
|
||||
pub from_model: String,
|
||||
pub to_provider: String,
|
||||
pub to_model: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub requested_reasoning_effort: Option<String>,
|
||||
pub requested_reasoning_effort: Option<ReasoningEffort>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effective_reasoning_effort: Option<String>,
|
||||
pub effective_reasoning_effort: Option<ReasoningEffort>,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1013,33 +1013,6 @@ mod tests {
|
|||
assert!(matches!(parsed.body, EventBody::RunCreated(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_failover_event_defaults_new_route_context() {
|
||||
let line = json!({
|
||||
"id": "evt_failover",
|
||||
"ts": "2026-04-04T12:00:00.000Z",
|
||||
"run_id": fixtures::RUN_1,
|
||||
"event": "agent.failover",
|
||||
"properties": {
|
||||
"from_provider": "anthropic",
|
||||
"from_model": "claude-fable-5",
|
||||
"to_provider": "openai",
|
||||
"to_model": "gpt-5.6-sol",
|
||||
"error": "provider unavailable"
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = RunEvent::from_value(line).unwrap();
|
||||
let EventBody::Failover(props) = parsed.body else {
|
||||
panic!("expected agent.failover");
|
||||
};
|
||||
assert_eq!(props.original_provider, None);
|
||||
assert_eq!(props.original_model, None);
|
||||
assert_eq!(props.attempt, None);
|
||||
assert_eq!(props.requested_reasoning_effort, None);
|
||||
assert_eq!(props.effective_reasoning_effort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_created_round_trip_preserves_manifest_blob() {
|
||||
let line = json!({
|
||||
|
|
|
|||
|
|
@ -382,6 +382,7 @@ models/run-links.ts
|
|||
models/run-manifest.ts
|
||||
models/run-meta-branch-settings.ts
|
||||
models/run-mode.ts
|
||||
models/run-model-controls.ts
|
||||
models/run-model-settings.ts
|
||||
models/run-model.ts
|
||||
models/run-namespace.ts
|
||||
|
|
|
|||
|
|
@ -354,6 +354,7 @@ export * from './run-manifest';
|
|||
export * from './run-meta-branch-settings';
|
||||
export * from './run-mode';
|
||||
export * from './run-model';
|
||||
export * from './run-model-controls';
|
||||
export * from './run-model-settings';
|
||||
export * from './run-namespace';
|
||||
export * from './run-origin';
|
||||
|
|
|
|||
23
lib/packages/fabro-api-client/src/models/run-model-controls.ts
generated
Normal file
23
lib/packages/fabro-api-client/src/models/run-model-controls.ts
generated
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/* 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.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Run-level default values for typed model controls. Node and style attributes still win over these defaults.
|
||||
*/
|
||||
export interface RunModelControls {
|
||||
'reasoning_effort': string | null;
|
||||
'speed': string | null;
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { RunModelControls } from './run-model-controls';
|
||||
|
||||
export interface RunModelSettings {
|
||||
'provider': string | null;
|
||||
|
|
@ -21,4 +24,5 @@ export interface RunModelSettings {
|
|||
* Ordered fallback targets keyed by the originally requested model. Each chain is independent; selecting a fallback target does not activate that target model\'s own chain.
|
||||
*/
|
||||
'fallbacks': { [key: string]: Array<string>; };
|
||||
'controls'?: RunModelControls;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue