mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
Merge remote-tracking branch 'origin/main' into feat/expose-model-controls
# Conflicts: # lib/foundation/fabro-api/src/lib.rs
This commit is contained in:
commit
3beaddc224
29 changed files with 870 additions and 141 deletions
26
apps/fabro-web/public/images/providers/poolside.svg
Normal file
26
apps/fabro-web/public/images/providers/poolside.svg
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<svg width="24" height="24" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="poolside-fade" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="2" y="2" width="28" height="28">
|
||||
<rect x="2" y="2" width="28" height="28" fill="url(#poolside-fade-a)"/>
|
||||
<rect x="2" y="2" width="28" height="28" fill="url(#poolside-fade-b)"/>
|
||||
<rect x="2" y="2" width="28" height="28" fill="url(#poolside-fade-c)"/>
|
||||
</mask>
|
||||
<g mask="url(#poolside-fade)">
|
||||
<path d="M3.41712 9.86302C6.80654 2.91367 15.1874 0.0275226 22.1368 3.41673C29.0863 6.80621 31.9726 15.188 28.5831 22.1374C25.1936 29.0867 16.8118 31.9722 9.86243 28.5827C4.66204 26.0461 1.73968 20.7158 2.01477 15.2849L2.05188 14.7595L2.07239 14.6081C2.21562 13.8614 2.90513 13.3305 3.67884 13.3992C4.45222 13.468 5.03641 14.1117 5.04602 14.8718L5.04016 15.0251L5.01087 15.4382C4.81888 19.2309 6.61285 22.9542 9.87512 25.1355L13.9933 16.6902L4.10755 11.8689C3.40938 11.5284 3.09379 10.7172 3.35657 10.0036L3.41712 9.86302ZM12.5714 26.4499C17.2445 27.9897 22.4003 26.2141 25.1349 22.1238L16.6896 18.0056L12.5714 26.4499ZM23.7208 8.16575C23.7497 8.57599 23.771 9.00755 23.7794 9.45579C23.8234 11.8115 23.5514 14.7561 22.5206 17.5115L26.4493 19.4275C27.7704 15.4172 26.6506 11.0524 23.7208 8.16575ZM20.5333 6.70384C19.8869 7.05321 19.0593 7.56983 18.1691 8.239C16.5266 9.47362 14.7653 11.1474 13.504 13.114L19.8097 16.1892C20.5828 13.9845 20.8187 11.5667 20.7804 9.51243C20.7596 8.39857 20.656 7.42837 20.5333 6.70384ZM17.4181 5.09153C13.3401 4.56091 9.21077 6.36601 6.86438 9.87571L10.7941 11.7917C12.3304 9.28322 14.4831 7.25617 16.3663 5.84056C16.7246 5.57129 17.0771 5.32128 17.4181 5.09153Z" fill="currentColor"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="poolside-fade-a" x1="9.875" y1="25.975" x2="8.5625" y2="25.975" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="black"/>
|
||||
<stop offset="1" stop-color="black" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="poolside-fade-b" x1="9.9625" y1="15.825" x2="10.4" y2="14.9937" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="black" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="black"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="poolside-fade-c" x1="3.1375" y1="25.9312" x2="3.1375" y2="14.7312" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="black"/>
|
||||
<stop offset="0.105" stop-color="black" stop-opacity="0.9"/>
|
||||
<stop offset="0.903766" stop-color="black" stop-opacity="0.04"/>
|
||||
<stop offset="1" stop-color="black" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
|
@ -8515,7 +8515,7 @@ components:
|
|||
type: string
|
||||
description: Stop sequences.
|
||||
reasoning_effort:
|
||||
type: string
|
||||
$ref: "#/components/schemas/ReasoningEffort"
|
||||
description: Reasoning effort level.
|
||||
provider:
|
||||
type: string
|
||||
|
|
@ -8524,15 +8524,38 @@ components:
|
|||
description: Provider-specific options.
|
||||
|
||||
CompletionUsage:
|
||||
description: >
|
||||
Five disjoint token buckets for one completion. `input_tokens` excludes
|
||||
cache reads and writes, while `output_tokens` excludes reasoning tokens
|
||||
when the provider reports them separately.
|
||||
type: object
|
||||
required: [input_tokens, output_tokens]
|
||||
required:
|
||||
- input_tokens
|
||||
- output_tokens
|
||||
- reasoning_tokens
|
||||
- cache_read_tokens
|
||||
- cache_write_tokens
|
||||
properties:
|
||||
input_tokens:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of uncached input tokens consumed.
|
||||
output_tokens:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of non-reasoning output tokens generated.
|
||||
reasoning_tokens:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of separately reported reasoning tokens.
|
||||
cache_read_tokens:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of input tokens served from a provider cache.
|
||||
cache_write_tokens:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of input tokens written to a provider cache.
|
||||
|
||||
CompletionResponse:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ Fabro performs this selection once when creating a run and persists the chosen p
|
|||
| `kimi-k2.5` | kimi | `kimi` | 262K | $0.60 / $3.00 | 50 tok/s |
|
||||
| `laguna-s-2.1` | poolside | `laguna`, `laguna-s` | 1M | $0.10 / $0.20 | n/a |
|
||||
| `laguna-xs-2.1` | poolside | `laguna-xs` | 262K | $0.10 / $0.20 | n/a |
|
||||
| `glm-4.7` | zai | `glm`, `glm4` | 203K | $0.60 / $2.20 | 100 tok/s |
|
||||
| `glm-5.2` | zai | `glm`, `glm5`, `glm52`, `glm5.2` | 1M | $1.40 / $4.40 | n/a |
|
||||
| `minimax-m2.5` | minimax | `minimax` | 197K | $0.30 / $1.20 | 45 tok/s |
|
||||
| `mercury-2` | inception | `mercury` | 131K | $0.25 / $0.75 | 1000 tok/s |
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ When no model or provider is specified, Fabro chooses the default offering on th
|
|||
| `gemini` | `gemini-3.5-flash` |
|
||||
| `kimi` | `kimi-k2.5` |
|
||||
| `poolside` | `laguna-s-2.1` |
|
||||
| `zai` | `glm-4.7` |
|
||||
| `zai` | `glm-5.2` |
|
||||
| `minimax` | `minimax-m2.5` |
|
||||
| `inception` | `mercury-2` |
|
||||
|
||||
|
|
|
|||
|
|
@ -53,10 +53,11 @@ The built-in catalog gives OpenRouter offerings the same human-facing model slug
|
|||
| `claude-haiku-4-5` | `anthropic/claude-haiku-4.5`; provider small default |
|
||||
| `gpt-5.4`, `gpt-5.5` | `openai/gpt-5.4`, `openai/gpt-5.5` |
|
||||
| `gemini-3.1-pro-preview`, `gemini-3.5-flash` | `google/...` API IDs |
|
||||
| `deepseek-v4-pro`, `deepseek-v4-flash` | `deepseek/...` API IDs |
|
||||
| `deepseek-v4-pro` (`deepseek`, `deepseek-v4`), `deepseek-v4-flash` (`deepseek-flash`) | `deepseek/...` API IDs |
|
||||
| `kimi-k2.6`, `qwen3-coder`, `qwen3.6-flash` | Vendor-prefixed API IDs |
|
||||
| `laguna-s-2.1`, `laguna-xs-2.1` | `poolside/...`; native reasoning and tool use |
|
||||
| `glm-4.6`, `minimax-m2.7`, `mimo-v2.5-pro` | Vendor-prefixed API IDs |
|
||||
| `glm-5.2` (`glm`, `glm5`, `glm52`, `glm5.2`), `glm-4.6` | `z-ai/...` API IDs |
|
||||
| `minimax-m2.7`, `mimo-v2.5-pro` | Vendor-prefixed API IDs |
|
||||
| `nemotron-3-super-120b-a12b`, `devstral-2512` | Vendor-prefixed API IDs |
|
||||
|
||||
Any other OpenRouter model can be added under the provider. Choose a stable Fabro model slug as the table key and put OpenRouter's exact vendor/model string in `api_id`:
|
||||
|
|
|
|||
|
|
@ -34,14 +34,15 @@ use fabro_types::{
|
|||
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
|
||||
use fabro_validate::Severity;
|
||||
use fabro_workflow::Error as WorkflowError;
|
||||
use fabro_workflow::operations::{CreateRunInput, ValidateInput, WorkflowInput, validate};
|
||||
use fabro_workflow::operations::{
|
||||
CreateRunInput, ValidateInput, WorkflowInput, validate, validate_with_ready_providers,
|
||||
};
|
||||
use fabro_workflow::pipeline::Validated;
|
||||
use fabro_workflow::run_materialization::materialize_run;
|
||||
use fabro_workflow::run_materialization::materialize_run_with_ready_providers;
|
||||
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use tokio::process::Command;
|
||||
use tokio::time;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::interp::process_env_var;
|
||||
use crate::server::AppState;
|
||||
|
|
@ -196,14 +197,34 @@ pub(crate) fn validate_prepared_manifest_with_vars(
|
|||
catalog: Arc<Catalog>,
|
||||
vars: HashMap<String, String>,
|
||||
) -> Result<Validated, WorkflowError> {
|
||||
validate(ValidateInput {
|
||||
validate(manifest_validate_input(prepared, catalog, vars))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_prepared_manifest_for_preflight(
|
||||
prepared: &PreparedManifest,
|
||||
catalog: Arc<Catalog>,
|
||||
vars: HashMap<String, String>,
|
||||
ready_providers: &[ProviderId],
|
||||
) -> Result<Validated, WorkflowError> {
|
||||
validate_with_ready_providers(
|
||||
manifest_validate_input(prepared, catalog, vars),
|
||||
ready_providers,
|
||||
)
|
||||
}
|
||||
|
||||
fn manifest_validate_input(
|
||||
prepared: &PreparedManifest,
|
||||
catalog: Arc<Catalog>,
|
||||
vars: HashMap<String, String>,
|
||||
) -> ValidateInput {
|
||||
ValidateInput {
|
||||
workflow: WorkflowInput::Bundled(prepared.workflow_input.clone()),
|
||||
settings: prepared.settings.clone(),
|
||||
vars,
|
||||
cwd: prepared.cwd.clone(),
|
||||
custom_transforms: Vec::new(),
|
||||
catalog,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create_run_input(
|
||||
|
|
@ -238,8 +259,10 @@ pub(crate) async fn run_preflight(
|
|||
state: &AppState,
|
||||
prepared: &PreparedManifest,
|
||||
validated: &Validated,
|
||||
llm_result: Result<LlmClientResult>,
|
||||
) -> Result<(types::PreflightResponse, bool)> {
|
||||
let (report, checks_ok) = build_preflight_report(state, prepared, validated).await?;
|
||||
let (report, checks_ok) =
|
||||
build_preflight_report(state, prepared, validated, llm_result).await?;
|
||||
let preflight_ok = !validated.has_errors() && checks_ok;
|
||||
Ok((
|
||||
preflight_response(
|
||||
|
|
@ -458,6 +481,7 @@ async fn build_preflight_report(
|
|||
state: &AppState,
|
||||
prepared: &PreparedManifest,
|
||||
validated: &Validated,
|
||||
llm_result: Result<LlmClientResult>,
|
||||
) -> Result<(CheckReport, bool)> {
|
||||
let graph = validated.graph();
|
||||
let mut checks = base_preflight_checks(prepared, graph);
|
||||
|
|
@ -475,22 +499,23 @@ async fn build_preflight_report(
|
|||
}
|
||||
|
||||
let catalog = state.catalog();
|
||||
let llm_result = state.resolve_llm_client().await;
|
||||
if let Err(err) = &llm_result {
|
||||
warn!(error = ?err, "Failed to resolve LLM client while checking ready providers");
|
||||
}
|
||||
// Preflight is credential-independent static validation. Materialize
|
||||
// against every enabled catalog provider so aliases and defaults can be
|
||||
// inspected even when the corresponding adapter is not currently ready;
|
||||
// `run_llm_check` below reports actual credential/registration readiness.
|
||||
let enabled_providers = catalog.all_provider_ids().into_iter().collect::<Vec<_>>();
|
||||
let materialized = materialize_run(
|
||||
let ready_providers = llm_result
|
||||
.as_ref()
|
||||
.map(LlmClientResult::provider_ids)
|
||||
.unwrap_or_default();
|
||||
let materialized = materialize_run_with_ready_providers(
|
||||
prepared.settings.clone(),
|
||||
graph,
|
||||
catalog.as_ref(),
|
||||
&enabled_providers,
|
||||
&ready_providers,
|
||||
)?;
|
||||
let resolved_run = materialized.run;
|
||||
let (Some(run_model), Some(run_provider)) = (
|
||||
resolved_run.model.name.as_deref(),
|
||||
resolved_run.model.provider.as_deref(),
|
||||
) else {
|
||||
bail!("materialized run is missing a resolved model or provider");
|
||||
};
|
||||
let server_settings = state.server_settings();
|
||||
let github_integration = &server_settings.server.integrations.github;
|
||||
let sandbox_provider = effective_sandbox_provider(&resolved_run);
|
||||
|
|
@ -553,7 +578,8 @@ async fn build_preflight_report(
|
|||
let llm_ok = run_llm_check(
|
||||
&mut checks,
|
||||
graph,
|
||||
&resolved_run,
|
||||
run_model,
|
||||
run_provider,
|
||||
catalog.as_ref(),
|
||||
llm_result,
|
||||
)
|
||||
|
|
@ -1029,17 +1055,11 @@ struct PendingModelProbe {
|
|||
async fn run_llm_check(
|
||||
checks: &mut Vec<CheckResult>,
|
||||
graph: &Graph,
|
||||
settings: &RunNamespace,
|
||||
model: &str,
|
||||
default_provider: &str,
|
||||
catalog: &Catalog,
|
||||
llm_result: Result<LlmClientResult>,
|
||||
) -> bool {
|
||||
let model = settings
|
||||
.model
|
||||
.name
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| catalog.default_for_configured_ids(&[]).id.as_str());
|
||||
let provider = settings.model.provider.as_deref();
|
||||
let default_provider = provider.unwrap_or("anthropic");
|
||||
let mut model_providers = std::collections::BTreeSet::new();
|
||||
let mut has_llm_nodes = false;
|
||||
|
||||
|
|
@ -1050,24 +1070,7 @@ async fn run_llm_check(
|
|||
has_llm_nodes = true;
|
||||
let node_model = node.model().unwrap_or(model);
|
||||
let node_provider = node.provider().unwrap_or(default_provider);
|
||||
let resolved = if node.provider().is_some() {
|
||||
catalog.get_on_provider(&ProviderId::new(node_provider), node_model)
|
||||
} else {
|
||||
catalog
|
||||
.select(node_model, None, &catalog.all_provider_ids())
|
||||
.ok()
|
||||
};
|
||||
let (resolved_model, resolved_provider) = if let Some(info) = resolved {
|
||||
(info.id.to_string(), info.provider.to_string())
|
||||
} else {
|
||||
(node_model.to_string(), node_provider.to_string())
|
||||
};
|
||||
let final_provider = if node.provider().is_some() {
|
||||
node_provider.to_string()
|
||||
} else {
|
||||
resolved_provider
|
||||
};
|
||||
model_providers.insert((resolved_model, final_provider));
|
||||
model_providers.insert((node_model.to_string(), node_provider.to_string()));
|
||||
}
|
||||
|
||||
if !has_llm_nodes {
|
||||
|
|
@ -1375,6 +1378,7 @@ fn report_to_api(report: &CheckReport) -> types::PreflightCheckReport {
|
|||
mod tests {
|
||||
use fabro_model::ProviderId;
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_workflow::run_materialization::materialize_run;
|
||||
|
||||
use super::*;
|
||||
|
||||
|
|
@ -1470,6 +1474,100 @@ mod tests {
|
|||
Arc::new(Catalog::from_builtin().unwrap())
|
||||
}
|
||||
|
||||
fn openai_compatible_completion(model: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": "chatcmpl_preflight",
|
||||
"object": "chat.completion",
|
||||
"created": 1_700_000_000,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "OK"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
||||
})
|
||||
}
|
||||
|
||||
fn ready_kimi_and_openrouter_state(
|
||||
server: &httpmock::MockServer,
|
||||
) -> Arc<crate::server::AppState> {
|
||||
let kimi_url = server.url("/kimi/v1");
|
||||
let openrouter_url = server.url("/openrouter/v1");
|
||||
let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!(
|
||||
r#"
|
||||
[providers.kimi]
|
||||
base_url = "{kimi_url}"
|
||||
|
||||
[providers.openrouter]
|
||||
base_url = "{openrouter_url}"
|
||||
enabled = true
|
||||
"#
|
||||
))
|
||||
.expect("catalog overrides should parse");
|
||||
|
||||
crate::test_support::TestAppStateBuilder::new()
|
||||
.llm_catalog_settings(llm_catalog_settings)
|
||||
.vault_entries([
|
||||
(EnvVars::KIMI_API_KEY, "test-kimi-key"),
|
||||
(EnvVars::OPENROUTER_API_KEY, "test-openrouter-key"),
|
||||
])
|
||||
.build()
|
||||
}
|
||||
|
||||
async fn preflight_for_model(
|
||||
state: &Arc<crate::server::AppState>,
|
||||
model: &str,
|
||||
) -> (types::PreflightResponse, bool) {
|
||||
let llm_result = state.resolve_llm_client().await;
|
||||
let mut ready_providers = llm_result
|
||||
.as_ref()
|
||||
.map(LlmClientResult::provider_ids)
|
||||
.unwrap_or_default();
|
||||
ready_providers.sort();
|
||||
assert_eq!(ready_providers, vec![
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("openrouter")
|
||||
]);
|
||||
|
||||
let mut manifest = minimal_manifest();
|
||||
manifest.workflows.get_mut("workflow.fabro").unwrap().source = format!(
|
||||
r#"
|
||||
digraph Demo {{
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
work [prompt="Do work", model="{model}"]
|
||||
start -> work -> exit
|
||||
}}
|
||||
"#
|
||||
);
|
||||
let prepared = prepare_manifest(
|
||||
&manifest_run_defaults(Some(&default_settings_fixture())),
|
||||
&manifest,
|
||||
)
|
||||
.unwrap();
|
||||
let validated = validate_prepared_manifest_for_preflight(
|
||||
&prepared,
|
||||
state.catalog(),
|
||||
HashMap::new(),
|
||||
&ready_providers,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
run_preflight(state.as_ref(), &prepared, &validated, llm_result)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn resolve_and_run_preflight(
|
||||
state: &AppState,
|
||||
prepared: &PreparedManifest,
|
||||
validated: &Validated,
|
||||
) -> Result<(types::PreflightResponse, bool)> {
|
||||
let llm_result = state.resolve_llm_client().await;
|
||||
run_preflight(state, prepared, validated, llm_result).await
|
||||
}
|
||||
|
||||
fn manifest_workflow() -> types::ManifestWorkflow {
|
||||
types::ManifestWorkflow {
|
||||
config: None,
|
||||
|
|
@ -2099,7 +2197,7 @@ name = "Control Plane"
|
|||
|
||||
assert!(validated.has_errors());
|
||||
|
||||
let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated)
|
||||
let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -2143,7 +2241,7 @@ issues = "read"
|
|||
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
|
||||
assert!(!validated.has_errors());
|
||||
|
||||
let (response, _ok) = run_preflight(state.as_ref(), &prepared, &validated)
|
||||
let (response, _ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -2194,7 +2292,7 @@ id = "local"
|
|||
|
||||
assert!(!validated.has_errors());
|
||||
|
||||
let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated)
|
||||
let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -2301,7 +2399,7 @@ id = "daytona"
|
|||
.unwrap();
|
||||
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
|
||||
|
||||
let (response, _ok) = run_preflight(state.as_ref(), &prepared, &validated)
|
||||
let (response, _ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -2369,7 +2467,7 @@ digraph Demo {
|
|||
.unwrap();
|
||||
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
|
||||
|
||||
let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated)
|
||||
let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -2390,6 +2488,80 @@ digraph Demo {
|
|||
assert!(response_mock.calls_async().await >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preflight_uses_ready_providers_for_known_shared_alias() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let openrouter_probe = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/openrouter/v1/chat/completions")
|
||||
.header("authorization", "Bearer test-openrouter-key")
|
||||
.json_body_includes(r#"{"model":"anthropic/claude-fable-5"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(openai_compatible_completion("anthropic/claude-fable-5"));
|
||||
})
|
||||
.await;
|
||||
let state = ready_kimi_and_openrouter_state(&server);
|
||||
|
||||
let (response, _ok) = preflight_for_model(&state, "claude-fable").await;
|
||||
|
||||
let llm_check = response.checks.sections[0]
|
||||
.checks
|
||||
.iter()
|
||||
.find(|check| check.name == "LLM" && check.summary == "claude-fable-5")
|
||||
.expect("preflight should include Claude Fable");
|
||||
assert_eq!(
|
||||
llm_check
|
||||
.details
|
||||
.iter()
|
||||
.map(|detail| detail.text.as_str())
|
||||
.find(|detail| detail.starts_with("Provider: ")),
|
||||
Some("Provider: openrouter")
|
||||
);
|
||||
assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Pass);
|
||||
openrouter_probe.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preflight_uses_ready_providers_for_unknown_unqualified_model() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let kimi_probe = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/kimi/v1/chat/completions")
|
||||
.header("authorization", "Bearer test-kimi-key")
|
||||
.json_body_includes(r#"{"model":"provider-private-preview"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(openai_compatible_completion("provider-private-preview"));
|
||||
})
|
||||
.await;
|
||||
let state = ready_kimi_and_openrouter_state(&server);
|
||||
|
||||
let (response, _ok) = preflight_for_model(&state, "provider-private-preview").await;
|
||||
|
||||
assert!(response.workflow.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.rule == "node_model_known"
|
||||
&& diagnostic.message.contains("provider-private-preview")
|
||||
}));
|
||||
let llm_check = response.checks.sections[0]
|
||||
.checks
|
||||
.iter()
|
||||
.find(|check| check.name == "LLM" && check.summary == "provider-private-preview")
|
||||
.expect("preflight should include the unknown passthrough model");
|
||||
assert_eq!(
|
||||
llm_check
|
||||
.details
|
||||
.iter()
|
||||
.map(|detail| detail.text.as_str())
|
||||
.find(|detail| detail.starts_with("Provider: ")),
|
||||
Some("Provider: kimi")
|
||||
);
|
||||
assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Pass);
|
||||
kimi_probe.assert_async().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_validation_rejects_unknown_llm_provider() {
|
||||
let mut manifest = minimal_manifest();
|
||||
|
|
@ -2466,9 +2638,21 @@ digraph Demo {
|
|||
&manifest,
|
||||
)
|
||||
.unwrap();
|
||||
let validated = validate_prepared_manifest(&prepared, state.catalog()).unwrap();
|
||||
let llm_result = state.resolve_llm_client().await;
|
||||
let ready_providers = llm_result
|
||||
.as_ref()
|
||||
.map(LlmClientResult::provider_ids)
|
||||
.unwrap_or_default();
|
||||
assert!(ready_providers.is_empty());
|
||||
let validated = validate_prepared_manifest_for_preflight(
|
||||
&prepared,
|
||||
state.catalog(),
|
||||
HashMap::new(),
|
||||
&ready_providers,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated)
|
||||
let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated, llm_result)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -1445,14 +1445,26 @@ impl AppState {
|
|||
self.llm_source.configured_providers(catalog.as_ref()).await
|
||||
}
|
||||
|
||||
pub(crate) async fn ready_llm_provider_ids(&self) -> Vec<ProviderId> {
|
||||
match self.resolve_llm_client().await {
|
||||
Ok(result) => result.provider_ids(),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to resolve LLM client while checking ready providers");
|
||||
Vec::new()
|
||||
}
|
||||
/// Resolve the LLM client once and derive the ready provider IDs from it,
|
||||
/// logging a warning when resolution fails. Callers that need both values
|
||||
/// must use this instead of `ready_llm_provider_ids` so the client is not
|
||||
/// resolved twice.
|
||||
pub(crate) async fn resolve_llm_client_with_ready_ids(
|
||||
&self,
|
||||
) -> (anyhow::Result<LlmClientResult>, Vec<ProviderId>) {
|
||||
let llm_result = self.resolve_llm_client().await;
|
||||
if let Err(err) = &llm_result {
|
||||
warn!(error = ?err, "Failed to resolve LLM client while checking ready providers");
|
||||
}
|
||||
let ready_provider_ids = llm_result
|
||||
.as_ref()
|
||||
.map(LlmClientResult::provider_ids)
|
||||
.unwrap_or_default();
|
||||
(llm_result, ready_provider_ids)
|
||||
}
|
||||
|
||||
pub(crate) async fn ready_llm_provider_ids(&self) -> Vec<ProviderId> {
|
||||
self.resolve_llm_client_with_ready_ids().await.1
|
||||
}
|
||||
|
||||
pub(crate) async fn decorate_run_summary(&self, run: fabro_types::Run) -> fabro_types::Run {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ use std::sync::Arc;
|
|||
use fabro_model::{Catalog, ModelSelectionError};
|
||||
|
||||
use super::super::{
|
||||
ApiError, AppState, CompletionResponse, CompletionToolChoiceMode, CompletionUsage,
|
||||
CreateCompletionRequest, FinishReason, GenerateParams, IntoResponse, Json, LlmMessage,
|
||||
LlmRequest, ProviderId, RequiredUser, Response, Router, State, StatusCode, ToolChoice,
|
||||
ToolDefinition, Ulid, error, generate_object, info, post, warn,
|
||||
ApiError, AppState, CompletionResponse, CompletionToolChoiceMode, CreateCompletionRequest,
|
||||
FinishReason, GenerateParams, IntoResponse, Json, LlmMessage, LlmRequest, ProviderId,
|
||||
RequiredUser, Response, Router, State, StatusCode, ToolChoice, ToolDefinition, Ulid, error,
|
||||
generate_object, info, post, warn,
|
||||
};
|
||||
use super::llm_sse;
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ async fn create_completion(
|
|||
} else {
|
||||
Some(req.stop_sequences)
|
||||
},
|
||||
reasoning_effort: req.reasoning_effort.as_deref().and_then(|s| s.parse().ok()),
|
||||
reasoning_effort: req.reasoning_effort,
|
||||
speed: None,
|
||||
metadata: None,
|
||||
provider_options: req.provider_options,
|
||||
|
|
@ -139,22 +139,23 @@ async fn create_completion(
|
|||
let msg_id = Ulid::new().to_string();
|
||||
|
||||
if let Some(schema) = req.schema {
|
||||
// Structured output uses generate_object for JSON parsing logic
|
||||
let mut params =
|
||||
GenerateParams::new(&request.model, std::sync::Arc::new(client.clone()))
|
||||
.messages(request.messages);
|
||||
if let Some(ref p) = request.provider {
|
||||
params = params.provider(p);
|
||||
}
|
||||
if let Some(temp) = request.temperature {
|
||||
params = params.temperature(temp);
|
||||
}
|
||||
if let Some(max_tokens) = request.max_tokens {
|
||||
params = params.max_tokens(max_tokens);
|
||||
}
|
||||
if let Some(top_p) = request.top_p {
|
||||
params = params.top_p(top_p);
|
||||
}
|
||||
// Structured output uses generate_object for JSON parsing logic.
|
||||
// tools/tool_choice are not forwarded: GenerateParams carries
|
||||
// executable Arc<Tool>s, not wire ToolDefinitions, and
|
||||
// generate_object sets response_format from the schema itself.
|
||||
let params = GenerateParams {
|
||||
messages: Some(request.messages),
|
||||
provider: request.provider,
|
||||
temperature: request.temperature,
|
||||
top_p: request.top_p,
|
||||
max_tokens: request.max_tokens,
|
||||
stop_sequences: request.stop_sequences,
|
||||
reasoning_effort: request.reasoning_effort,
|
||||
speed: request.speed,
|
||||
metadata: request.metadata,
|
||||
provider_options: request.provider_options,
|
||||
..GenerateParams::new(request.model, std::sync::Arc::new(client.clone()))
|
||||
};
|
||||
match generate_object(params, schema).await {
|
||||
Ok(result) => {
|
||||
// `result.finish_reason` / `result.usage` resolve through
|
||||
|
|
@ -169,10 +170,7 @@ async fn create_completion(
|
|||
provider: selected_provider,
|
||||
message: response.message,
|
||||
stop_reason,
|
||||
usage: CompletionUsage {
|
||||
input_tokens: response.usage.input_tokens,
|
||||
output_tokens: response.usage.output_tokens,
|
||||
},
|
||||
usage: response.usage,
|
||||
output,
|
||||
cost_usd: response.cost_usd,
|
||||
cost_source: response.cost_source,
|
||||
|
|
@ -192,10 +190,7 @@ async fn create_completion(
|
|||
provider: ProviderId::new(response.provider),
|
||||
message: response.message,
|
||||
stop_reason,
|
||||
usage: CompletionUsage {
|
||||
input_tokens: response.usage.input_tokens,
|
||||
output_tokens: response.usage.output_tokens,
|
||||
},
|
||||
usage: response.usage,
|
||||
output: None,
|
||||
cost_usd: response.cost_usd,
|
||||
cost_source: response.cost_source,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ use crate::run_files::{list_run_commits, list_run_files};
|
|||
use crate::run_manifest;
|
||||
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
|
||||
use crate::run_title_generation::{self, GenerateTitleInput, TitlePromptInput, WorkflowSummary};
|
||||
use crate::server_secrets::LlmClientResult;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use crate::test_support as server_test_support;
|
||||
|
||||
|
|
@ -591,17 +590,8 @@ pub(crate) async fn create_run_from_manifest(
|
|||
// and ask-fabro-readiness) and the LLM client itself (for the spawned
|
||||
// title-generation task). `ready_llm_provider_ids` would otherwise call
|
||||
// `resolve_llm_client` a second time and discard the client.
|
||||
let llm_client_for_title = match state.resolve_llm_client().await {
|
||||
Ok(result) => Some(result),
|
||||
Err(err) => {
|
||||
tracing::warn!(error = ?err, "Failed to resolve LLM client while creating run");
|
||||
None
|
||||
}
|
||||
};
|
||||
let ready_provider_ids = llm_client_for_title
|
||||
.as_ref()
|
||||
.map(LlmClientResult::provider_ids)
|
||||
.unwrap_or_default();
|
||||
let (llm_result, ready_provider_ids) = state.resolve_llm_client_with_ready_ids().await;
|
||||
let llm_client_for_title = llm_result.ok();
|
||||
let run_materialization_provider_ids = {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
{
|
||||
|
|
@ -835,10 +825,12 @@ async fn run_preflight(
|
|||
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
|
||||
.into_response();
|
||||
}
|
||||
let mut validated = match run_manifest::validate_prepared_manifest_with_vars(
|
||||
let (llm_result, ready_providers) = state.resolve_llm_client_with_ready_ids().await;
|
||||
let mut validated = match run_manifest::validate_prepared_manifest_for_preflight(
|
||||
&prepared,
|
||||
state.catalog(),
|
||||
vars,
|
||||
&ready_providers,
|
||||
) {
|
||||
Ok(validated) => validated,
|
||||
Err(WorkflowError::Parse(_)) => {
|
||||
|
|
@ -847,13 +839,14 @@ async fn run_preflight(
|
|||
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
||||
};
|
||||
validated.promote_template_undefined_variables_to_errors();
|
||||
let response = match run_manifest::run_preflight(&state, &prepared, &validated).await {
|
||||
Ok((response, _ok)) => response,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let response =
|
||||
match run_manifest::run_preflight(&state, &prepared, &validated, llm_result).await {
|
||||
Ok((response, _ok)) => response,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15269,6 +15269,27 @@ async fn create_completion_missing_messages_returns_422() {
|
|||
assert_status!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_completion_invalid_reasoning_effort_returns_422() {
|
||||
let app = test_app_with();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/completions"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"messages": [],
|
||||
"reasoning_effort": "bogus"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_status!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_completion_unknown_provider_returns_clear_error() {
|
||||
let app = test_app_with();
|
||||
|
|
@ -15301,6 +15322,73 @@ async fn create_completion_unknown_provider_returns_clear_error() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_completion_returns_disjoint_usage_buckets() {
|
||||
let upstream = MockServer::start();
|
||||
let completion = upstream.mock(|when, then| {
|
||||
when.method(POST).path("/chat/completions");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(json!({
|
||||
"id": "chatcmpl-usage",
|
||||
"model": "kimi-k3",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": "OK"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 200,
|
||||
"completion_tokens": 30,
|
||||
"total_tokens": 230,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 50,
|
||||
"cache_write_tokens": 100
|
||||
},
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 20
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
let state = TestAppStateBuilder::new()
|
||||
.provider_base_url("kimi", upstream.base_url())
|
||||
.vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(state);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/completions"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"provider": "kimi",
|
||||
"model": "kimi-k3",
|
||||
"stream": false,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{"kind": "text", "data": "hi"}]
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
assert_eq!(
|
||||
body["usage"],
|
||||
json!({
|
||||
"input_tokens": 50,
|
||||
"output_tokens": 10,
|
||||
"reasoning_tokens": 20,
|
||||
"cache_read_tokens": 50,
|
||||
"cache_write_tokens": 100
|
||||
})
|
||||
);
|
||||
completion.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_completion_default_model_uses_app_state_catalog() {
|
||||
let upstream = MockServer::start();
|
||||
|
|
@ -15376,6 +15464,72 @@ reasoning = false
|
|||
completion.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_completion_structured_output_forwards_reasoning_effort() {
|
||||
let upstream = MockServer::start();
|
||||
let completion = upstream.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/chat/completions")
|
||||
.json_body_includes(r#"{"model":"kimi-k3","reasoning_effort":"high"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(json!({
|
||||
"id": "chatcmpl-kimi-structured",
|
||||
"model": "kimi-k3",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "{\"answer\":42}"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 4,
|
||||
"total_tokens": 14
|
||||
}
|
||||
}));
|
||||
});
|
||||
let state = TestAppStateBuilder::new()
|
||||
.provider_base_url("kimi", upstream.base_url())
|
||||
.vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(state);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/completions"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"provider": "kimi",
|
||||
"model": "kimi-k3",
|
||||
"reasoning_effort": "high",
|
||||
"stream": false,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {"type": "integer"}
|
||||
},
|
||||
"required": ["answer"]
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"kind": "text", "data": "Return the answer."}]
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
assert_eq!(body["output"], json!({"answer": 42}));
|
||||
completion.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demo_list_runs_returns_run_list_items() {
|
||||
let state = test_app_state();
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ fn create_from_source(
|
|||
.filter(|provider| !provider.is_empty())
|
||||
.map(ProviderId::new),
|
||||
&options.configured_providers,
|
||||
false,
|
||||
&options.catalog,
|
||||
)?;
|
||||
|
||||
|
|
@ -336,6 +337,7 @@ pub(super) fn preprocess_and_validate(
|
|||
render_mode: RenderMode,
|
||||
default_provider: Option<ProviderId>,
|
||||
eligible_providers: &[ProviderId],
|
||||
catalog_fallback: bool,
|
||||
catalog: &Arc<Catalog>,
|
||||
) -> Result<Validated, Error> {
|
||||
let mut parsed = pipeline::parse(dot_source)?;
|
||||
|
|
@ -351,6 +353,7 @@ pub(super) fn preprocess_and_validate(
|
|||
catalog: Arc::clone(catalog),
|
||||
default_provider,
|
||||
eligible_providers: eligible_providers.iter().cloned().collect(),
|
||||
catalog_fallback,
|
||||
})?;
|
||||
Ok(pipeline::validate(transformed, catalog.as_ref(), &[]))
|
||||
}
|
||||
|
|
@ -579,6 +582,7 @@ reasoning = false
|
|||
RenderMode::Structural,
|
||||
None,
|
||||
&test_provider_ids(),
|
||||
false,
|
||||
&test_catalog(),
|
||||
)
|
||||
.unwrap()
|
||||
|
|
@ -749,6 +753,7 @@ reasoning = false
|
|||
RenderMode::Strict,
|
||||
None,
|
||||
&test_provider_ids(),
|
||||
false,
|
||||
&test_catalog(),
|
||||
);
|
||||
let Err(err) = result else {
|
||||
|
|
@ -787,6 +792,7 @@ reasoning = false
|
|||
RenderMode::Strict,
|
||||
None,
|
||||
&test_provider_ids(),
|
||||
false,
|
||||
&test_catalog(),
|
||||
);
|
||||
let Err(err) = result else {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub use rewind::{RewindInput, RewindOutcome, rewind};
|
|||
pub use source::WorkflowInput;
|
||||
pub use start::{StartServices, Started, start};
|
||||
pub use timeline::{ForkTarget, RunTimeline, TimelineEntry, build_timeline, timeline};
|
||||
pub use validate::{ValidateInput, validate};
|
||||
pub use validate::{ValidateInput, validate, validate_with_ready_providers};
|
||||
|
||||
pub use crate::pipeline::{LlmSpec, SandboxEnvSpec};
|
||||
pub use crate::transforms::RenderMode;
|
||||
|
|
|
|||
|
|
@ -622,6 +622,7 @@ fn resolve_start_llm(
|
|||
&eligible,
|
||||
settings.model.name.as_deref(),
|
||||
settings.model.provider.as_deref(),
|
||||
false,
|
||||
)?;
|
||||
let fallback_chain =
|
||||
resolve_fallback_chain(catalog, &provider_id, &model, &settings.model, &eligible)?;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::collections::HashMap;
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_model::Catalog;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_types::WorkflowSettings;
|
||||
|
||||
use super::create::{preprocess_and_validate, template_context};
|
||||
|
|
@ -28,17 +28,35 @@ pub struct ValidateInput {
|
|||
/// Returns `Validated` even when validation produced errors. Call
|
||||
/// `validated.raise_on_errors()` if the caller wants to fail fast.
|
||||
pub fn validate(input: ValidateInput) -> Result<Validated, Error> {
|
||||
let eligible_providers = input
|
||||
.catalog
|
||||
.all_provider_ids()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
validate_with_eligible_providers(input, &eligible_providers, false)
|
||||
}
|
||||
|
||||
/// Parse, transform, and validate, resolving models against the ready
|
||||
/// providers first and falling back to the full catalog only for
|
||||
/// provider-readiness selection failures.
|
||||
pub fn validate_with_ready_providers(
|
||||
input: ValidateInput,
|
||||
ready_providers: &[ProviderId],
|
||||
) -> Result<Validated, Error> {
|
||||
validate_with_eligible_providers(input, ready_providers, true)
|
||||
}
|
||||
|
||||
fn validate_with_eligible_providers(
|
||||
input: ValidateInput,
|
||||
eligible_providers: &[ProviderId],
|
||||
catalog_fallback: bool,
|
||||
) -> Result<Validated, Error> {
|
||||
let resolved = resolve_workflow(ResolveWorkflowInput {
|
||||
workflow: input.workflow,
|
||||
settings: input.settings,
|
||||
cwd: input.cwd,
|
||||
})
|
||||
.map_err(|err| Error::Parse(err.to_string()))?;
|
||||
let eligible_providers = input
|
||||
.catalog
|
||||
.all_provider_ids()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
preprocess_and_validate(
|
||||
&resolved.raw_source,
|
||||
|
|
@ -60,7 +78,8 @@ pub fn validate(input: ValidateInput) -> Result<Validated, Error> {
|
|||
.as_deref()
|
||||
.filter(|provider| !provider.is_empty())
|
||||
.map(fabro_model::ProviderId::new),
|
||||
&eligible_providers,
|
||||
eligible_providers,
|
||||
catalog_fallback,
|
||||
&input.catalog,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Result<Transform
|
|||
options.eligible_providers.clone(),
|
||||
)
|
||||
.with_default_provider(options.default_provider.clone())
|
||||
.with_catalog_fallback(options.catalog_fallback)
|
||||
.apply(graph)?;
|
||||
|
||||
// Custom transforms
|
||||
|
|
@ -120,6 +121,7 @@ mod tests {
|
|||
catalog: test_catalog(),
|
||||
default_provider: None,
|
||||
eligible_providers: Catalog::builtin().all_provider_ids(),
|
||||
catalog_fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +186,7 @@ mod tests {
|
|||
catalog: test_catalog(),
|
||||
default_provider: None,
|
||||
eligible_providers: Catalog::builtin().all_provider_ids(),
|
||||
catalog_fallback: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -238,6 +241,7 @@ mod tests {
|
|||
catalog: test_catalog(),
|
||||
default_provider: None,
|
||||
eligible_providers: Catalog::builtin().all_provider_ids(),
|
||||
catalog_fallback: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -370,6 +374,7 @@ mod tests {
|
|||
catalog: test_catalog(),
|
||||
default_provider: None,
|
||||
eligible_providers: Catalog::builtin().all_provider_ids(),
|
||||
catalog_fallback: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -332,6 +332,9 @@ pub struct TransformOptions {
|
|||
pub catalog: Arc<fabro_model::Catalog>,
|
||||
pub default_provider: Option<ProviderId>,
|
||||
pub eligible_providers: HashSet<ProviderId>,
|
||||
/// Fall back to the full catalog when the eligible providers cannot
|
||||
/// supply a requested model, instead of erroring.
|
||||
pub catalog_fallback: bool,
|
||||
}
|
||||
|
||||
/// Options for the FINALIZE phase.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ mod tests {
|
|||
catalog: std::sync::Arc::clone(&catalog),
|
||||
default_provider: None,
|
||||
eligible_providers: catalog.all_provider_ids(),
|
||||
catalog_fallback: false,
|
||||
})
|
||||
.unwrap();
|
||||
validate(transformed, catalog.as_ref(), &[])
|
||||
|
|
|
|||
|
|
@ -9,10 +9,32 @@ use fabro_types::settings::run::RunGoal;
|
|||
use crate::error::Error;
|
||||
|
||||
pub fn materialize_run(
|
||||
mut settings: WorkflowSettings,
|
||||
settings: WorkflowSettings,
|
||||
graph: &Graph,
|
||||
catalog: &Catalog,
|
||||
configured_providers: &[ProviderId],
|
||||
) -> Result<WorkflowSettings, Error> {
|
||||
materialize_run_with_eligible_providers(settings, graph, catalog, configured_providers, false)
|
||||
}
|
||||
|
||||
/// Materialize while resolving the run model against the ready providers
|
||||
/// first, falling back to the full catalog only for provider-readiness
|
||||
/// selection failures.
|
||||
pub fn materialize_run_with_ready_providers(
|
||||
settings: WorkflowSettings,
|
||||
graph: &Graph,
|
||||
catalog: &Catalog,
|
||||
ready_providers: &[ProviderId],
|
||||
) -> Result<WorkflowSettings, Error> {
|
||||
materialize_run_with_eligible_providers(settings, graph, catalog, ready_providers, true)
|
||||
}
|
||||
|
||||
fn materialize_run_with_eligible_providers(
|
||||
mut settings: WorkflowSettings,
|
||||
graph: &Graph,
|
||||
catalog: &Catalog,
|
||||
eligible_providers: &[ProviderId],
|
||||
catalog_fallback: bool,
|
||||
) -> Result<WorkflowSettings, Error> {
|
||||
let configured_model = settings.run.model.name.take();
|
||||
let configured_provider = settings.run.model.provider.take();
|
||||
|
|
@ -29,9 +51,14 @@ pub fn materialize_run(
|
|||
|
||||
let provider = configured_provider.or(graph_provider);
|
||||
let model = configured_model.or(graph_model);
|
||||
let eligible = configured_providers.iter().cloned().collect::<HashSet<_>>();
|
||||
let (resolved_model, resolved_provider) =
|
||||
resolve_run_model(catalog, &eligible, model.as_deref(), provider.as_deref())?;
|
||||
let eligible = eligible_providers.iter().cloned().collect::<HashSet<_>>();
|
||||
let (resolved_model, resolved_provider) = resolve_run_model(
|
||||
catalog,
|
||||
&eligible,
|
||||
model.as_deref(),
|
||||
provider.as_deref(),
|
||||
catalog_fallback,
|
||||
)?;
|
||||
|
||||
settings.run.model.name = Some(resolved_model);
|
||||
settings.run.model.provider = Some(resolved_provider.into_inner());
|
||||
|
|
@ -60,10 +87,15 @@ pub(crate) fn resolve_run_model(
|
|||
eligible: &HashSet<ProviderId>,
|
||||
model: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
catalog_fallback: bool,
|
||||
) -> Result<(String, ProviderId), ModelSelectionError> {
|
||||
let provider = provider
|
||||
.filter(|provider| !provider.is_empty())
|
||||
.map(ProviderId::new);
|
||||
let selected = catalog.resolve_selection(model, provider.as_ref(), eligible)?;
|
||||
let selected = if catalog_fallback {
|
||||
catalog.resolve_selection_with_catalog_fallback(model, provider.as_ref(), eligible)?
|
||||
} else {
|
||||
catalog.resolve_selection(model, provider.as_ref(), eligible)?
|
||||
};
|
||||
Ok((selected.model, selected.provider))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub struct ModelResolutionTransform {
|
|||
catalog: Arc<Catalog>,
|
||||
default_provider: Option<ProviderId>,
|
||||
eligible_providers: HashSet<ProviderId>,
|
||||
catalog_fallback: bool,
|
||||
}
|
||||
|
||||
impl ModelResolutionTransform {
|
||||
|
|
@ -23,6 +24,7 @@ impl ModelResolutionTransform {
|
|||
catalog,
|
||||
default_provider: None,
|
||||
eligible_providers,
|
||||
catalog_fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,6 +34,7 @@ impl ModelResolutionTransform {
|
|||
catalog,
|
||||
default_provider: None,
|
||||
eligible_providers,
|
||||
catalog_fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,16 +44,29 @@ impl ModelResolutionTransform {
|
|||
self
|
||||
}
|
||||
|
||||
/// When enabled, provider-readiness selection failures fall back to the
|
||||
/// full catalog instead of erroring.
|
||||
#[must_use]
|
||||
pub fn with_catalog_fallback(mut self, catalog_fallback: bool) -> Self {
|
||||
self.catalog_fallback = catalog_fallback;
|
||||
self
|
||||
}
|
||||
|
||||
fn resolve_model(
|
||||
&self,
|
||||
model: &str,
|
||||
explicit_provider: Option<&ProviderId>,
|
||||
) -> Result<(String, ProviderId), Error> {
|
||||
let selected = self.catalog.resolve_selection(
|
||||
Some(model),
|
||||
explicit_provider,
|
||||
&self.eligible_providers,
|
||||
)?;
|
||||
let selected = if self.catalog_fallback {
|
||||
self.catalog.resolve_selection_with_catalog_fallback(
|
||||
Some(model),
|
||||
explicit_provider,
|
||||
&self.eligible_providers,
|
||||
)
|
||||
} else {
|
||||
self.catalog
|
||||
.resolve_selection(Some(model), explicit_provider, &self.eligible_providers)
|
||||
}?;
|
||||
Ok((selected.model, selected.provider))
|
||||
}
|
||||
}
|
||||
|
|
@ -326,6 +342,50 @@ reasoning = false
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_resolution_keeps_ready_preference_for_unpinned_nodes() {
|
||||
let overrides: LlmCatalogSettings = toml::from_str(
|
||||
r"
|
||||
[providers.openrouter]
|
||||
enabled = true
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap());
|
||||
let mut graph = Graph::new("test");
|
||||
let mut portable = Node::new("portable");
|
||||
portable.attrs.insert(
|
||||
"model".to_string(),
|
||||
AttrValue::String("claude-fable".to_string()),
|
||||
);
|
||||
graph.nodes.insert("portable".to_string(), portable);
|
||||
let mut pinned = Node::new("pinned");
|
||||
pinned.attrs.insert(
|
||||
"model".to_string(),
|
||||
AttrValue::String("claude-fable".to_string()),
|
||||
);
|
||||
pinned.attrs.insert(
|
||||
"provider".to_string(),
|
||||
AttrValue::String("anthropic".to_string()),
|
||||
);
|
||||
graph.nodes.insert("pinned".to_string(), pinned);
|
||||
|
||||
let graph = ModelResolutionTransform::for_eligible(
|
||||
Arc::clone(&catalog),
|
||||
HashSet::from([ProviderId::new("openrouter")]),
|
||||
)
|
||||
.with_catalog_fallback(true)
|
||||
.apply(graph)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
graph.nodes["portable"].provider(),
|
||||
Some("openrouter"),
|
||||
"the unrelated unavailable pin must not force catalog-wide routing"
|
||||
);
|
||||
assert_eq!(graph.nodes["pinned"].provider(), Some("anthropic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_default_alias_materializes_to_canonical_offering() {
|
||||
let mut graph = Graph::new("test");
|
||||
|
|
|
|||
|
|
@ -4908,6 +4908,7 @@ async fn import_e2e_through_engine() {
|
|||
catalog: std::sync::Arc::clone(&catalog),
|
||||
default_provider: None,
|
||||
eligible_providers: catalog.all_provider_ids(),
|
||||
catalog_fallback: false,
|
||||
})
|
||||
.unwrap();
|
||||
let validated = validate(transformed, catalog.as_ref(), &[]);
|
||||
|
|
|
|||
|
|
@ -461,6 +461,7 @@ fn main() {
|
|||
"fabro_types::PendingInterviewRecord",
|
||||
&[],
|
||||
),
|
||||
("CompletionUsage", "fabro_model::TokenCounts", &[]),
|
||||
("BilledTokenCounts", "fabro_types::BilledTokenCounts", &[]),
|
||||
("BillingModelRef", "fabro_model::ModelRef", &[]),
|
||||
("BillingSpeed", "fabro_model::Speed", &[]),
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub mod types {
|
|||
pub use fabro_model::{
|
||||
CostSource, Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits,
|
||||
ModelRef as BillingModelRef, ModelTestMode, Provider, ReasoningEffort,
|
||||
ReasoningEffortFeature, Speed as BillingSpeed,
|
||||
ReasoningEffortFeature, Speed as BillingSpeed, TokenCounts as CompletionUsage,
|
||||
};
|
||||
pub use fabro_types::run_event::AgentSessionActivatedProps;
|
||||
pub use fabro_types::settings::run::McpHttpProtocol;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::CompletionUsage as ApiCompletionUsage;
|
||||
use fabro_model::TokenCounts;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn completion_usage_reuses_canonical_type() {
|
||||
assert_same_type::<ApiCompletionUsage, TokenCounts>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_usage_json_matches_openapi_shape() {
|
||||
let usage = TokenCounts {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
reasoning_tokens: 3,
|
||||
cache_read_tokens: 4,
|
||||
cache_write_tokens: 5,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&usage).unwrap();
|
||||
assert_eq!(json["input_tokens"], 10);
|
||||
assert_eq!(json["output_tokens"], 20);
|
||||
assert_eq!(json["reasoning_tokens"], 3);
|
||||
assert_eq!(json["cache_read_tokens"], 4);
|
||||
assert_eq!(json["cache_write_tokens"], 5);
|
||||
|
||||
let round_trip: ApiCompletionUsage = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(round_trip, usage);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_usage_keeps_zero_counts_present() {
|
||||
let json = serde_json::to_value(TokenCounts::default()).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0
|
||||
})
|
||||
);
|
||||
|
||||
let round_trip: ApiCompletionUsage = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(round_trip, TokenCounts::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>()
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
use fabro_api::types::CreateCompletionRequest;
|
||||
use fabro_model::ReasoningEffort;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn create_completion_request_reuses_canonical_reasoning_effort() {
|
||||
let request: CreateCompletionRequest = serde_json::from_value(json!({
|
||||
"messages": [],
|
||||
"reasoning_effort": "high"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High));
|
||||
}
|
||||
|
|
@ -1103,6 +1103,31 @@ impl Catalog {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve a selection against a preferred provider snapshot, falling back
|
||||
/// to every provider in the catalog only when the preferred set cannot
|
||||
/// supply the requested provider or model.
|
||||
///
|
||||
/// This is useful for readiness checks: ready providers remain preferred,
|
||||
/// while a catalog-only offering can still be selected so the caller can
|
||||
/// report why its provider is unavailable. Semantic failures such as an
|
||||
/// unknown provider do not fall back.
|
||||
pub fn resolve_selection_with_catalog_fallback(
|
||||
&self,
|
||||
selector: Option<&str>,
|
||||
explicit_provider: Option<&ProviderId>,
|
||||
preferred_providers: &HashSet<ProviderId>,
|
||||
) -> Result<SelectedModel, ModelSelectionError> {
|
||||
match self.resolve_selection(selector, explicit_provider, preferred_providers) {
|
||||
Ok(selected) => Ok(selected),
|
||||
Err(
|
||||
ModelSelectionError::ProviderUnavailable { .. }
|
||||
| ModelSelectionError::NoEligibleOffering { .. }
|
||||
| ModelSelectionError::NoDefaultModel { .. },
|
||||
) => self.resolve_selection(selector, explicit_provider, &self.all_provider_ids()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_model_selector(&self, selector: &str) -> bool {
|
||||
self.candidate_indices(selector).is_some()
|
||||
|
|
@ -3123,8 +3148,11 @@ enabled = true
|
|||
for provider in [ProviderId::openai(), ProviderId::new("openrouter")] {
|
||||
for (alias, canonical_id) in [
|
||||
("sol", "gpt-5.6-sol"),
|
||||
("gpt-sol", "gpt-5.6-sol"),
|
||||
("terra", "gpt-5.6-terra"),
|
||||
("gpt-terra", "gpt-5.6-terra"),
|
||||
("luna", "gpt-5.6-luna"),
|
||||
("gpt-luna", "gpt-5.6-luna"),
|
||||
] {
|
||||
let model = catalog
|
||||
.resolve_on_provider(&provider, alias)
|
||||
|
|
@ -3137,6 +3165,57 @@ enabled = true
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_glm_5_2_aliases_are_portable() {
|
||||
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
|
||||
r"
|
||||
[providers.openrouter]
|
||||
enabled = true
|
||||
",
|
||||
))
|
||||
.expect("enabled OpenRouter override should build from the built-in provider settings");
|
||||
|
||||
for provider in [ProviderId::new("zai"), ProviderId::new("openrouter")] {
|
||||
for alias in ["glm", "glm5", "glm52", "glm5.2"] {
|
||||
let model = catalog
|
||||
.resolve_on_provider(&provider, alias)
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("{alias} should resolve on {provider}: {error}")
|
||||
});
|
||||
assert_eq!(model.provider, provider, "{alias}");
|
||||
assert_eq!(model.id, "glm-5.2", "{alias}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_deepseek_v4_selectors_resolve_on_openrouter() {
|
||||
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
|
||||
r"
|
||||
[providers.openrouter]
|
||||
enabled = true
|
||||
",
|
||||
))
|
||||
.expect("enabled OpenRouter override should build from the built-in provider settings");
|
||||
let openrouter = ProviderId::new("openrouter");
|
||||
|
||||
for (selector, canonical_id) in [
|
||||
("deepseek-v4-pro", "deepseek-v4-pro"),
|
||||
("deepseek-v4", "deepseek-v4-pro"),
|
||||
("deepseek", "deepseek-v4-pro"),
|
||||
("deepseek-v4-flash", "deepseek-v4-flash"),
|
||||
("deepseek-flash", "deepseek-v4-flash"),
|
||||
] {
|
||||
let model = catalog
|
||||
.resolve_on_provider(&openrouter, selector)
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("{selector} should resolve on {openrouter}: {error}")
|
||||
});
|
||||
assert_eq!(model.provider, openrouter, "{selector}");
|
||||
assert_eq!(model.id, canonical_id, "{selector}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_legacy_vendor_ids_normalize_for_pinned_and_unpinned_selection() {
|
||||
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
|
||||
|
|
@ -3268,7 +3347,12 @@ enabled = true
|
|||
),
|
||||
},
|
||||
estimated_output_tps: None,
|
||||
aliases: [],
|
||||
aliases: [
|
||||
"glm",
|
||||
"glm5",
|
||||
"glm52",
|
||||
"glm5.2",
|
||||
],
|
||||
default: false,
|
||||
small_default: false,
|
||||
configured: false,
|
||||
|
|
@ -4007,6 +4091,30 @@ adapter = "openai_compatible"
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_fallback_preserves_ready_preference_per_request() {
|
||||
let catalog = portable_model_catalog();
|
||||
let openai = ProviderId::openai();
|
||||
let openrouter = ProviderId::new("openrouter");
|
||||
let ready = HashSet::from([openrouter.clone()]);
|
||||
|
||||
let shared = catalog
|
||||
.resolve_selection_with_catalog_fallback(Some("portable"), None, &ready)
|
||||
.unwrap();
|
||||
assert_eq!(shared.provider, openrouter);
|
||||
|
||||
let pinned = catalog
|
||||
.resolve_selection_with_catalog_fallback(Some("portable"), Some(&openai), &ready)
|
||||
.unwrap();
|
||||
assert_eq!(pinned.provider, openai);
|
||||
|
||||
let unknown = catalog
|
||||
.resolve_selection_with_catalog_fallback(Some("provider-private-preview"), None, &ready)
|
||||
.unwrap();
|
||||
assert_eq!(unknown.provider, ProviderId::new("openrouter"));
|
||||
assert_eq!(unknown.model, "provider-private-preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_builtin_selector_uses_readiness_priority_and_explicit_pins() {
|
||||
let catalog = portable_model_catalog();
|
||||
|
|
@ -6147,6 +6255,8 @@ sampling_params = false
|
|||
aliases: [
|
||||
"glm",
|
||||
"glm5",
|
||||
"glm52",
|
||||
"glm5.2",
|
||||
],
|
||||
default: true,
|
||||
small_default: false,
|
||||
|
|
@ -6164,6 +6274,8 @@ sampling_params = false
|
|||
]);
|
||||
assert_eq!(catalog.get("glm").unwrap().id, "glm-5.2");
|
||||
assert_eq!(catalog.get("glm5").unwrap().id, "glm-5.2");
|
||||
assert_eq!(catalog.get("glm52").unwrap().id, "glm-5.2");
|
||||
assert_eq!(catalog.get("glm5.2").unwrap().id, "glm-5.2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ family = "gpt-5"
|
|||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
default = true
|
||||
aliases = ["sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"]
|
||||
aliases = ["sol", "gpt-sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"]
|
||||
|
||||
[providers.openai.models."gpt-5.6-sol".limits]
|
||||
context_window = 272000
|
||||
|
|
@ -37,7 +37,7 @@ display_name = "GPT-5.6 Terra"
|
|||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
aliases = ["terra", "gpt56-terra", "gpt-56-terra"]
|
||||
aliases = ["terra", "gpt-terra", "gpt56-terra", "gpt-56-terra"]
|
||||
|
||||
[providers.openai.models."gpt-5.6-terra".limits]
|
||||
context_window = 272000
|
||||
|
|
@ -60,7 +60,7 @@ display_name = "GPT-5.6 Luna"
|
|||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
aliases = ["luna", "gpt56-luna", "gpt-56-luna"]
|
||||
aliases = ["luna", "gpt-luna", "gpt56-luna", "gpt-56-luna"]
|
||||
|
||||
[providers.openai.models."gpt-5.6-luna".limits]
|
||||
context_window = 272000
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ display_name = "GPT-5.6 Sol (via OpenRouter)"
|
|||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
aliases = ["sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"]
|
||||
aliases = ["sol", "gpt-sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"]
|
||||
|
||||
[providers.openrouter.models."gpt-5.6-sol".limits]
|
||||
context_window = 1050000
|
||||
|
|
@ -192,7 +192,7 @@ display_name = "GPT-5.6 Terra (via OpenRouter)"
|
|||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
aliases = ["terra", "gpt56-terra", "gpt-56-terra"]
|
||||
aliases = ["terra", "gpt-terra", "gpt56-terra", "gpt-56-terra"]
|
||||
|
||||
[providers.openrouter.models."gpt-5.6-terra".limits]
|
||||
context_window = 1050000
|
||||
|
|
@ -217,7 +217,7 @@ display_name = "GPT-5.6 Luna (via OpenRouter)"
|
|||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
aliases = ["luna", "gpt56-luna", "gpt-56-luna"]
|
||||
aliases = ["luna", "gpt-luna", "gpt56-luna", "gpt-56-luna"]
|
||||
|
||||
[providers.openrouter.models."gpt-5.6-luna".limits]
|
||||
context_window = 1050000
|
||||
|
|
@ -354,6 +354,7 @@ output_cost_per_mtok = 1.20
|
|||
api_id = "deepseek/deepseek-v4-pro"
|
||||
display_name = "DeepSeek V4 Pro"
|
||||
family = "deepseek-v4"
|
||||
aliases = ["deepseek-v4", "deepseek"]
|
||||
|
||||
[providers.openrouter.models."deepseek-v4-pro".limits]
|
||||
context_window = 1050000
|
||||
|
|
@ -372,6 +373,7 @@ output_cost_per_mtok = 0.87
|
|||
api_id = "deepseek/deepseek-v4-flash"
|
||||
display_name = "DeepSeek V4 Flash"
|
||||
family = "deepseek-v4"
|
||||
aliases = ["deepseek-flash"]
|
||||
|
||||
[providers.openrouter.models."deepseek-v4-flash".limits]
|
||||
context_window = 1050000
|
||||
|
|
@ -513,6 +515,7 @@ output_cost_per_mtok = 1.125
|
|||
api_id = "z-ai/glm-5.2"
|
||||
display_name = "GLM 5.2 (via OpenRouter)"
|
||||
family = "glm-5"
|
||||
aliases = ["glm", "glm5", "glm52", "glm5.2"]
|
||||
|
||||
[providers.openrouter.models."glm-5.2".limits]
|
||||
context_window = 1048576
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ credentials = ["env:ZAI_API_KEY", "vault:ZAI_API_KEY"]
|
|||
display_name = "GLM 5.2"
|
||||
family = "glm-5"
|
||||
default = true
|
||||
aliases = ["glm", "glm5"]
|
||||
aliases = ["glm", "glm5", "glm52", "glm5.2"]
|
||||
|
||||
[providers.zai.models."glm-5.2".limits]
|
||||
context_window = 1048576
|
||||
|
|
|
|||
|
|
@ -14,7 +14,28 @@
|
|||
|
||||
|
||||
|
||||
/**
|
||||
* Five disjoint token buckets for one completion. `input_tokens` excludes cache reads and writes, while `output_tokens` excludes reasoning tokens when the provider reports them separately.
|
||||
*/
|
||||
export interface CompletionUsage {
|
||||
/**
|
||||
* Number of uncached input tokens consumed.
|
||||
*/
|
||||
'input_tokens': number;
|
||||
/**
|
||||
* Number of non-reasoning output tokens generated.
|
||||
*/
|
||||
'output_tokens': number;
|
||||
/**
|
||||
* Number of separately reported reasoning tokens.
|
||||
*/
|
||||
'reasoning_tokens': number;
|
||||
/**
|
||||
* Number of input tokens served from a provider cache.
|
||||
*/
|
||||
'cache_read_tokens': number;
|
||||
/**
|
||||
* Number of input tokens written to a provider cache.
|
||||
*/
|
||||
'cache_write_tokens': number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import type { CompletionToolChoice } from './completion-tool-choice';
|
|||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { CompletionToolDefinition } from './completion-tool-definition';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { ReasoningEffort } from './reasoning-effort';
|
||||
|
||||
export interface CreateCompletionRequest {
|
||||
/**
|
||||
|
|
@ -56,7 +59,7 @@ export interface CreateCompletionRequest {
|
|||
/**
|
||||
* Reasoning effort level.
|
||||
*/
|
||||
'reasoning_effort'?: string;
|
||||
'reasoning_effort'?: ReasoningEffort;
|
||||
/**
|
||||
* Optional provider pin.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue