diff --git a/Cargo.toml b/Cargo.toml index ef5156ddb..2b3e79d2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,4 +150,4 @@ opt-level = 2 [profile.dev.package.regex-automata] opt-level = 2 [profile.dev.package.regex-syntax] -opt-level = 2 +opt-level = 2 \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-02-settings-driven-llm-providers-models.md b/docs/superpowers/plans/2026-05-02-settings-driven-llm-providers-models.md index 88da6506e..3d766d6dc 100644 --- a/docs/superpowers/plans/2026-05-02-settings-driven-llm-providers-models.md +++ b/docs/superpowers/plans/2026-05-02-settings-driven-llm-providers-models.md @@ -10,6 +10,21 @@ --- +## Implementation status (2026-05-04 session) + +The foundation slice of this plan is implemented and shipped on the run branch: + +- **`fabro-model`**: new `adapter` module with the full vocabulary (`AdapterMetadata`, `AgentProfileKind`, `ApiKeyHeaderPolicy`, `AdapterControlCapabilities`) + four registered adapters (`anthropic`, `openai`, `gemini`, `openai_compatible`); `ProviderId` and `ModelId` newtypes; shared `ReasoningEffort` enum; `Speed` enum gains `strum::VariantArray`. +- **`fabro-llm`**: `adapter_registry` module mirroring `fabro_model::adapter` with infallible factories; tests enforce that every metadata key has a factory and vice-versa. +- **`fabro-config`**: new `[llm]` settings layer (`LlmLayer` + `ProviderSettings` + `ModelSettings` + `ModelControls` + `ModelCostTable` + `CostRates` + typed `CredentialRef`); legacy `[llm] provider = ...` migration error preserved while `[llm.providers]` and `[llm.models]` subtrees are accepted; whole-array replacement and field-merge semantics covered by tests. +- **`fabro-config` + `fabro-types`**: new `[run.model.controls]` block flowing through to `RunModelSettings.controls`. +- **`fabro-dev`**: workspace-policy test that scans every Rust source under `lib/` for non-comment `bootstrap_catalog` references and fails outside an explicit allowlist. +- All checked-in code passes `cargo build --workspace`, `cargo nextest run` for the affected crates (1,912+ tests), `cargo +nightly-2026-04-14 fmt --check --all`, and `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`. + +The remainder of the plan — replacing `fabro_model::Provider` with `ProviderId` across 80+ files, regenerating the OpenAPI clients, swapping the auth resolver to use `ProviderId`, replacing the 25 `Catalog::builtin()` production call sites with a settings-resolved `Arc` injected through server/workflow/CLI state, the `bootstrap_catalog` install hatch, the typed `Request.speed`/`GenerateParams.speed` swap, and the per-speed billing rows — is **deferred to follow-up sessions**. Each deferred step is marked individually below. + +--- + ## Summary This is a breaking cross-crate refactor. `fabro_model::Provider` stops being the product identity type; provider identity becomes a string-backed `ProviderId`. OpenAPI provider fields become strings, and the resolved settings catalog becomes the source of truth for model lookup, provider lookup, default selection, credential resolution, adapter registration, and `/models`. @@ -94,18 +109,18 @@ speed = "fast" ## Implementation Plan -- [ ] **Settings schema and merge behavior** - - Add `LlmSettings`, `ProviderSettings`, `ModelSettings`, `ModelControls`, `ModelCostTable`, `CostRates`, and `CredentialRef` to `fabro-config`. - - Store built-in providers and models in defaults settings data so production catalog construction starts from the same layered settings path as user/project/workflow overrides. - - Preserve sparse field-merge semantics for `[llm.providers.]` and `[llm.models.]`. Arrays such as `credentials`, `aliases`, `controls.reasoning_effort`, and `controls.speed` replace as whole arrays. To add one credential to a built-in provider, redeclare the full `credentials` list in the higher layer. - - Keep the targeted legacy `[llm]` migration error for old keys such as `provider` or `model`; accept only the new `[llm.providers]` and `[llm.models]` subtrees. Do not regress to a generic serde unknown-field error. - - Parse adapter keys as strings in `fabro-config`. Do not make `fabro-config` depend on `fabro-llm`; adapter-key validation happens when building the resolved catalog. +- [x] **Settings schema and merge behavior** — landed in commit `feat(config): add [llm] settings layer for provider/model catalog`. + - [x] Add `LlmSettings`, `ProviderSettings`, `ModelSettings`, `ModelControls`, `ModelCostTable`, `CostRates`, and `CredentialRef` to `fabro-config`. (Names: `LlmLayer`, `ProviderSettings`, `ModelSettings`, `ModelControls`, `ModelCostTable`, `CostRates`, `CredentialRef`.) + - [ ] Store built-in providers and models in defaults settings data so production catalog construction starts from the same layered settings path as user/project/workflow overrides. **Deferred** — depends on the catalog-resolution step below; the schema is in place to receive defaults. + - [x] Preserve sparse field-merge semantics for `[llm.providers.]` and `[llm.models.]`. Arrays such as `credentials`, `aliases`, `controls.reasoning_effort`, and `controls.speed` replace as whole arrays. (Backed by `MergeMap` per-key field-merge; arrays are `Option>` with `or` combine semantics.) + - [x] Keep the targeted legacy `[llm]` migration error for old keys such as `provider` or `model`; accept only the new `[llm.providers]` and `[llm.models]` subtrees. (`LEGACY_LLM_KEYS` matched in `parse_settings` before the strict deserialize.) + - [x] Parse adapter keys as strings in `fabro-config`. Do not make `fabro-config` depend on `fabro-llm`. (Adapter is `Option`; resolution happens against `fabro_model::adapter` metadata.) -- [ ] **Catalog model** - - Add `ProviderId` and `ModelId` string newtypes where they improve type clarity across crates. - - Replace product identity uses of `fabro_model::Provider` with `ProviderId`. Keep Rust enums for behavior that is still code-owned, including `ReasoningEffort` and `Speed`. - - Move `ReasoningEffort` from `fabro-llm` to `fabro-model` or another shared vocabulary crate so catalog data, request validation, OpenAPI replacement types, and LLM requests use one enum. - - Add code-owned adapter metadata beside the catalog, not in `fabro-config`. This metadata is still Rust code; only provider/model rows are data. +- [~] **Catalog model** — partially landed. Remaining items are **deferred** because they require breaking changes across 80+ files and the OpenAPI regeneration step. + - [x] Add `ProviderId` and `ModelId` string newtypes where they improve type clarity across crates. (`fabro_model::ids`.) + - [ ] Replace product identity uses of `fabro_model::Provider` with `ProviderId`. **Deferred** — closed `Provider` enum still backs `Model.provider`, vault `ApiCredential.provider`, OpenAPI types, and 80+ call sites; replacement requires the OpenAPI step plus a full sweep. + - [x] Move `ReasoningEffort` to `fabro-model`. (Added at `fabro_model::reasoning::ReasoningEffort`; the existing `fabro_llm::types::ReasoningEffort` stays in place until the LLM seam is fully cut over so the rest of the workspace keeps compiling.) + - [x] Add code-owned adapter metadata beside the catalog, not in `fabro-config`. (`fabro_model::adapter`.) - Add concrete metadata vocabulary types in the shared model/catalog layer so model validation and LLM factory registration share one contract: ```rust @@ -149,67 +164,53 @@ speed = "fast" - `AgentProfileKind` is an internal dispatch key that `fabro-agent` maps to concrete `AgentProfile` implementations; it is not a settings field. `ApiKeyHeaderPolicy` describes how an API key becomes an `ApiKeyHeader` without carrying secret values. - `native_reasoning_effort` is every reasoning-effort value that can be sent through the provider's native effort field. After omitted controls are filled from adapter defaults, resolved model `controls.reasoning_effort` must be a non-empty subset of `native_reasoning_effort` when `features.effort = true`; it must be omitted or empty when `features.effort = false`. V1 does not expose generic non-native effort fallback in catalog data. - Model `controls.speed` must be a subset of adapter `additional_speeds`. `Speed::Standard` is implicit and must not appear in either list. - - Build `Catalog` from resolved settings and return catalog-build errors for malformed provider/model data. - - Validate provider `adapter` strings against the adapter metadata while building the catalog. `fabro-llm` has the matching factory registry and tests must prove every metadata key has a factory. - - Build provider and model alias indexes after all layers merge and after disabled entries are filtered out of runtime lookup. Canonical IDs and aliases for enabled entries share one namespace within their kind. Any enabled-entry collision is fatal, including canonical ID versus another enabled entity's alias. Disabled entries do not reserve aliases; re-enabling a disabled entry can fail if its aliases collide with currently enabled entries. - - Surface alias/catalog failures at catalog construction: server startup fails, CLI run/validate fails, and workflow materialization fails before requests are issued. - - Replace hardcoded provider precedence with provider `priority`. Higher priority wins; missing priority is `0`; ties sort by canonical provider ID. `enabled = false` removes the provider/model from runtime selection but does not delete vault entries. - - Retire `Catalog::builtin()` from production lookup paths. Gate the old singleton behind `#[cfg(any(test, feature = "test-support"))]` for tests. Add a narrowly named bootstrap/defaults constructor for install and API-key validation flows that need built-in provider definitions before project settings are loaded. - - Put the bootstrap/defaults constructor behind an explicit module such as `fabro_model::bootstrap_catalog` and document it as install-only. - - Add a CI-enforced workspace test that scans for `bootstrap_catalog` references and allows only bootstrap/install/test-support paths. Request-serving crates and handlers must fail that test if they call the bootstrap constructor. + - [ ] Build `Catalog` from resolved settings and return catalog-build errors for malformed provider/model data. **Deferred** — this is the largest single piece and depends on the `Provider`→`ProviderId` swap above. + - [ ] Validate provider `adapter` strings against the adapter metadata while building the catalog. `fabro-llm` has the matching factory registry and tests must prove every metadata key has a factory. **Adapter registry parity test landed**; catalog-side validation deferred with the resolved `Catalog` builder. + - [ ] Build provider and model alias indexes after all layers merge and after disabled entries are filtered out of runtime lookup. **Deferred** with the resolved `Catalog` builder. + - [ ] Surface alias/catalog failures at catalog construction. **Deferred** with the resolved `Catalog` builder. + - [ ] Replace hardcoded provider precedence with provider `priority`. **Deferred** with the resolved `Catalog` builder. + - [ ] Retire `Catalog::builtin()` from production lookup paths. **Deferred** — the symbol still has 25 production call sites today; converting them requires the resolved `Catalog` to be reachable from server/workflow/CLI state. + - [ ] Put the bootstrap/defaults constructor behind an explicit module such as `fabro_model::bootstrap_catalog`. **Deferred** until the resolved catalog landing point exists. + - [x] Add a CI-enforced workspace test that scans for `bootstrap_catalog` references and allows only bootstrap/install/test-support paths. (`fabro-dev/tests/it/policy.rs::bootstrap_catalog_references_stay_in_allowlist`.) -- [ ] **OpenAPI and generated clients** - - Change provider fields in `docs/public/api-reference/fabro-api.yaml` from the closed `Provider` schema to strings or a shared `ProviderId` newtype. - - Remove `with_replacement("Provider", "fabro_model::Provider", &[])` from `lib/crates/fabro-api/build.rs`. - - Delete or replace `lib/crates/fabro-api/tests/provider_round_trip.rs`; add JSON parity coverage for `ProviderId` if that type is reused by `fabro-api`. - - Regenerate Rust API types with `cargo build -p fabro-api`. - - Regenerate the TypeScript API client after the OpenAPI change. +- [ ] **OpenAPI and generated clients** — **Deferred**, gated on the `Provider`→`ProviderId` swap. + - [ ] Change provider fields in `docs/public/api-reference/fabro-api.yaml` from the closed `Provider` schema to strings or a shared `ProviderId` newtype. + - [ ] Remove `with_replacement("Provider", "fabro_model::Provider", &[])` from `lib/crates/fabro-api/build.rs`. + - [ ] Delete or replace `lib/crates/fabro-api/tests/provider_round_trip.rs`; add JSON parity coverage for `ProviderId` if that type is reused by `fabro-api`. + - [ ] Regenerate Rust API types with `cargo build -p fabro-api`. + - [ ] Regenerate the TypeScript API client after the OpenAPI change. -- [ ] **Credentials and auth** - - Change `AuthCredential`, `ApiCredential`, resolver errors, and credential lookup helpers from closed `Provider` to `ProviderId`. - - Preserve existing vault JSON by deserializing old provider strings as provider IDs. - - Keep `credential_id_for` compatibility: API-key credentials use their canonical provider ID; Codex OAuth still maps only to `openai_codex`. - - Resolve provider `credentials` in list order. For `env:` refs, build an API credential for the current provider using the adapter registry's auth-header policy. For `credential:` refs, require structured credential/provider compatibility before attaching it. The first successfully resolved credential wins, so built-in ordering should put the preferred credential type first; for OpenAI, place `credential:openai_codex` before API-key refs only when Codex OAuth should be preferred over API-key traffic. - - Keep Codex OAuth outside configurable provider routing: the resolver produces the fixed ChatGPT Codex base URL and `codex_mode = true` only for canonical `openai` plus `openai_codex`. - - Define `fabro auth list` behavior for absent or disabled providers: list vault entries regardless, annotate catalog status as enabled, disabled, or unknown, and do not treat unknown entries as runtime-configured providers. - - Ensure new credential-ref Display/Debug/error paths redact secret values and never log resolved env values. Env names and credential IDs may appear only in non-secret diagnostic text. +- [ ] **Credentials and auth** — **Deferred**, gated on the `Provider`→`ProviderId` swap. The `CredentialRef` type and its redaction-safe `Display` impl are landed in `fabro-config`. + - [ ] Change `AuthCredential`, `ApiCredential`, resolver errors, and credential lookup helpers from closed `Provider` to `ProviderId`. + - [ ] Preserve existing vault JSON by deserializing old provider strings as provider IDs. + - [ ] Keep `credential_id_for` compatibility. + - [ ] Resolve provider `credentials` in list order with `env:` and `credential:` semantics from the plan. + - [ ] Keep Codex OAuth pinned to canonical `openai` + `openai_codex` + fixed ChatGPT Codex base URL. + - [ ] Define `fabro auth list` behavior for absent or disabled providers. + - [x] New credential-ref Display/Debug/error paths redact secret values. (`CredentialRef::Display` writes `credential:` / `env:` only; the parse-error message never echoes the input string.) -- [ ] **LLM client and adapter registry** - - Introduce an adapter factory registry in `fabro-llm` keyed by the same strings as catalog adapter metadata: `anthropic`, `openai`, `gemini`, and `openai_compatible`. - - Keep factory behavior in `fabro-llm`; keep static metadata needed by `fabro-model` and `fabro-auth` in the shared catalog/model layer to avoid dependency cycles. - - Change `Client::from_source` and `Client::from_credentials` call paths so provider settings and the resolved catalog are available before adapter registration. - - Register adapters by provider ID from the resolved catalog. `Client::resolve_provider` must use the injected catalog to map model IDs and aliases to provider IDs; it must not call `Catalog::builtin()`. - - Keep install/API-key validation working by using the bootstrap/defaults catalog for the provider currently being configured. - - Leave custom auth schemes and data-driven adapter implementations out of scope. +- [~] **LLM client and adapter registry** — adapter factory registry landed; client wiring deferred. + - [x] Introduce an adapter factory registry in `fabro-llm` keyed by the same strings as catalog adapter metadata. (`fabro_llm::adapter_registry`; tests enforce metadata↔factory parity.) + - [x] Keep factory behavior in `fabro-llm`; keep static metadata needed by `fabro-model` and `fabro-auth` in the shared catalog/model layer to avoid dependency cycles. (Metadata in `fabro_model::adapter`; factories in `fabro_llm::adapter_registry`.) + - [ ] Change `Client::from_source` and `Client::from_credentials` call paths so provider settings and the resolved catalog are available before adapter registration. **Deferred** — depends on resolved `Catalog`. + - [ ] Register adapters by provider ID from the resolved catalog. **Deferred**. + - [ ] Keep install/API-key validation working by using the bootstrap/defaults catalog. **Deferred**. -- [ ] **Validation** - - Do not change the public `LintRule` trait signature. - - Remove catalog-dependent model/provider-known checks from `rules::built_in_rules()`. - - Reintroduce those checks as catalog-bound rule instances, for example `model_support::rules_for_catalog(Arc)`, passed through the existing `extra_rules` argument after settings resolution. - - Thread the resolved catalog to CLI, server, workflow, and parser validation call sites that should report unknown models/providers. - - Keep pure graph-shape validation available without runtime settings. +- [ ] **Validation** — **Deferred**, depends on resolved `Catalog`. -- [ ] **Workflow, server, agent, and hooks plumbing** - - Store `Arc` in server app state and workflow service state. - - Replace production `Catalog::builtin()` call sites in server handlers, workflow operations, workflow transforms, hooks, diagnostics, completions, pull-request creation, and agent profile/session code. - - Ensure project and workflow/run TOML settings are merged before model resolution, validation, fallback-chain construction, and LLM client construction. - - Infer agent profile from the provider adapter registry entry. Do not make profiles data-driven in v1. - - Continue to expose existing node/workflow `cli_backend` behavior independently of provider settings. +- [~] **Workflow, server, agent, and hooks plumbing** — `[run.model.controls]` schema + resolution landed. + - [x] Add `[run.model.controls]` schema with `reasoning_effort` and `speed` fields. (`RunModelControlsLayer` in `fabro-config`; `RunModelControls` in `fabro-types`; resolved through `WorkflowSettingsBuilder`.) + - [ ] Store `Arc` in server/workflow state. **Deferred**. + - [ ] Replace 25 production `Catalog::builtin()` call sites with state-injected catalog. **Deferred**. + - [ ] Infer agent profile from adapter registry entry. **Deferred** — adapter metadata exposes `default_profile`; consumers still need wiring. -- [ ] **Controls and request validation** - - Add model control allow-lists to catalog data. Validate control values against existing Rust enums: `ReasoningEffort::{Low, Medium, High, XHigh, Max}` and `Speed::{Standard, Fast}`. - - Change `fabro_llm::types::Request.speed`, `fabro_llm::generate::GenerateParams.speed`, and agent/workflow speed config plumbing from `Option` to `Option`. Keep serde wire compatibility through the existing snake_case `Speed` representation and parse strings only at API/settings/graph boundaries. - - Validate model-declared controls against adapter capabilities at catalog build time. - - Define "explicit control" narrowly: a value from `[run.model.controls]`, a node attribute after stylesheet/import transforms, or a style-applied attribute. Define "legacy default" as the current hardcoded fallback returned only when no explicit value exists. - - Avoid a broad provenance refactor. Add helper methods that can distinguish "attribute present" from "fallback returned" at the control resolution sites. - - Explicit unsupported controls fail before building provider requests. Legacy defaults are omitted for models that do not declare the control. +- [~] **Controls and request validation** — schema + storage landed; runtime validation deferred. + - [x] Add model control allow-lists to catalog *settings* schema (`ModelControls.reasoning_effort`, `ModelControls.speed` as `Vec` allow-lists; concrete enum validation happens at catalog-build time). + - [ ] Change `Request.speed` and `GenerateParams.speed` from `Option` to `Option`. **Deferred** — the existing `Option` API stays in place until catalog wiring is ready. + - [ ] Validate model-declared controls against adapter capabilities at catalog build time. **Deferred** with `Catalog` builder. + - [ ] Reject explicit unsupported controls at request build time. **Deferred** with `Catalog` builder. -- [ ] **Billing** - - Do not collapse `ModelPricingPolicy` variants in this change. - - Change model costs to a base `CostRates` plus optional `speed: BTreeMap` overrides. - - Update `pricing_for(speed)` so selected rates are `costs.speed[speed]` when present, otherwise base rates. - - Preserve provider-shaped pricing policies. Anthropic cache-write 5m/1h rates continue to derive from the selected input rate, so Anthropic fast-mode cost rows produce the same cache-write rates as today's multiplier path. - - Remove the hardcoded `(Provider::Anthropic, Speed::Fast, claude-opus-4-7/4-6)` branch after the equivalent rows exist in defaults data. +- [ ] **Billing** — **Deferred**, depends on the resolved `Catalog` migration. The `ModelCostTable` settings schema (base `CostRates` + per-speed `BTreeMap`) is in place to receive the per-speed pricing rows. ## Test Plan @@ -233,4 +234,4 @@ speed = "fast" - Field-merge for provider/model tables is intentional. Whole-array replacement for controls can mask future built-in values; more granular array merge operations are deferred. - V1 does not support custom auth schemes, data-driven profile templates, provider-level CLI backend routing, data-driven adapter implementations, or new request control kinds. - Adding a new value to an existing Rust-owned control enum, such as a new speed value beyond `standard` and `fast`, remains a Rust change. -- Existing imprecise knowledge cutoff labels migrate to exact normalized dates, e.g. `May 2025` becomes `2025-05-01`; presentation can render lower precision. +- Existing imprecise knowledge cutoff labels migrate to exact normalized dates, e.g. `May 2025` becomes `2025-05-01`; presentation can render lower precision. \ No newline at end of file diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 071bd7c98..c1c568ab0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -950,6 +950,10 @@ fn attach_json_errors_without_prompting_for_human_input() { }, "metadata": {}, "model": { + "controls": { + "reasoning_effort": null, + "speed": null + }, "fallbacks": [], "name": "gpt-5.4", "provider": "openai" diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index cf1639391..141790bef 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -95,7 +95,7 @@ fn inspect_resolves_selector_via_server_endpoint() { "nightly-build", ]); - fabro_snapshot!(context.filters(), cmd, @r###" + fabro_snapshot!(context.filters(), cmd, @r#" success: true exit_code: 0 ----- stdout ----- @@ -128,7 +128,11 @@ fn inspect_resolves_selector_via_server_endpoint() { "model": { "provider": null, "name": null, - "fallbacks": [] + "fallbacks": [], + "controls": { + "reasoning_effort": null, + "speed": null + } }, "git": { "author": null @@ -196,7 +200,7 @@ fn inspect_resolves_selector_via_server_endpoint() { } ] ----- stderr ----- - "###); + "#); resolve_run.assert(); run_state.assert(); diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index ab704b1ee..c31577318 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -488,6 +488,7 @@ command = ["demo-mcp"] provider: Some(InterpString::parse("openai")), name: Some(InterpString::parse("gpt-5")), fallbacks: Vec::new(), + controls: None, }), execution: Some(RunExecutionLayer { mode: Some(RunMode::DryRun), diff --git a/lib/crates/fabro-config/src/layers/combine.rs b/lib/crates/fabro-config/src/layers/combine.rs index 072b37fda..f3dda42c7 100644 --- a/lib/crates/fabro-config/src/layers/combine.rs +++ b/lib/crates/fabro-config/src/layers/combine.rs @@ -1,5 +1,6 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; +use chrono::NaiveDate; use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity}; use fabro_types::settings::run::{ AgentPermissions, ApprovalMode, DaytonaNetworkLayer, MergeStrategy, RunMode, @@ -13,6 +14,7 @@ use fabro_types::settings::{Duration, InterpString, Size}; use super::LogFilter; use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer}; use super::features::FeaturesLayer; +use super::llm::{CostRates, CredentialRef}; use super::run::{ DaytonaSnapshotLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer, @@ -57,6 +59,7 @@ macro_rules! impl_combine_or_option { impl_combine_or_option!( String, bool, + f64, u16, u32, u64, @@ -77,6 +80,7 @@ impl_combine_or_option!( RunMode, GithubIntegrationStrategy, LogDestination, + NaiveDate, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy, @@ -89,12 +93,24 @@ impl Combine for Option> { } } +impl Combine for Option> { + fn combine(self, other: Self) -> Self { + self.or(other) + } +} + impl Combine for Option> { fn combine(self, other: Self) -> Self { self.or(other) } } +impl Combine for Option> { + fn combine(self, other: Self) -> Self { + self.or(other) + } +} + impl Combine for Option> { fn combine(self, other: Self) -> Self { self.or(other) diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs new file mode 100644 index 000000000..109b98143 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/llm.rs @@ -0,0 +1,650 @@ +//! `[llm]` settings layer. +//! +//! Holds the trusted, mergeable LLM provider/model catalog data: +//! +//! ```toml +//! [llm.providers.kimi] +//! display_name = "Kimi" +//! adapter = "openai_compatible" +//! base_url = "https://api.moonshot.ai/v1" +//! credentials = ["credential:kimi", "env:KIMI_API_KEY"] +//! priority = 60 +//! enabled = true +//! aliases = ["moonshot"] +//! +//! [llm.models."kimi-k2.5"] +//! provider = "kimi" +//! ... +//! ``` +//! +//! Per-provider and per-model entries field-merge across layers (default → +//! user → server → project → workflow/run). Inner arrays such as +//! `credentials`, `aliases`, `controls.reasoning_effort`, and +//! `controls.speed` replace as whole arrays. +//! +//! Adapter keys (`adapter = "..."`) are parsed as plain strings here. +//! Resolution against the static adapter registry happens in `fabro-model` +//! when the resolved [`Catalog`](fabro_model::Catalog) is built. + +use std::collections::BTreeMap; + +use chrono::NaiveDate; +use serde::{Deserialize, Deserializer, Serialize}; + +use super::maps::MergeMap; + +const CREDENTIAL_REF_PREFIX: &str = "credential:"; +const ENV_REF_PREFIX: &str = "env:"; + +/// Top-level `[llm]` settings layer. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct LlmLayer { + /// Provider definitions keyed by provider ID. + #[serde(default, skip_serializing_if = "MergeMap::is_empty")] + pub providers: MergeMap, + /// Model definitions keyed by canonical model ID. + #[serde(default, skip_serializing_if = "MergeMap::is_empty")] + pub models: MergeMap, +} + +/// One entry in `[llm.providers.]`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ProviderSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Adapter registry key (e.g. `"openai_compatible"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_url: Option, + /// Ordered list of credential references — first successful wins. Each + /// entry must be a typed `CredentialRef` (`credential:` or + /// `env:`); literal secret strings fail deserialization. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credentials: Option>, + /// Higher wins; missing → `0`; ties broken by canonical provider ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aliases: Option>, +} + +/// One entry in `[llm.models.]`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ModelSettings { + /// Provider ID this model belongs to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Identifier sent to the provider API. Defaults to the catalog model ID + /// when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family: Option, + /// Knowledge cutoff as an exact `YYYY-MM-DD` date. Lower-precision labels + /// (e.g. `May 2025`) migrate to the first of the month (`2025-05-01`); + /// presentation can render lower precision. + #[serde( + default, + deserialize_with = "deserialize_knowledge_cutoff", + skip_serializing_if = "Option::is_none" + )] + pub knowledge_cutoff: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aliases: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub estimated_output_tps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limits: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub controls: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub costs: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ModelLimits { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_window: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ModelFeatures { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, +} + +/// User-facing allow-list for native control values Fabro accepts on this +/// model. Whole-array replacement on merge. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ModelControls { + /// Allowed reasoning-effort values. Strings (e.g. `"low"`, `"high"`, + /// `"xhigh"`) — validated as `ReasoningEffort` at catalog build. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option>, + /// Additional speeds beyond `Speed::Standard`. Strings — validated as + /// `Speed` at catalog build. `Speed::Standard` is implicit and must not + /// appear here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option>, +} + +/// Pricing table. Base [`CostRates`] always apply; per-speed overrides +/// substitute when the request specifies a non-standard speed. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ModelCostTable { + #[serde(flatten)] + pub base: CostRates, + /// Per-speed cost overrides (e.g. `costs.speed.fast = { ... }`). Keys + /// must reference a speed declared in `controls.speed`. `standard` is + /// not a valid override key — base rates serve standard speed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct CostRates { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_cost_per_mtok: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_cost_per_mtok: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_input_cost_per_mtok: Option, +} + +/// Accept either a TOML local-date (`2025-01-01` → `Datetime`) or a +/// `YYYY-MM-DD` string for `knowledge_cutoff`. JSON has no native date +/// literal; settings authors use the bare TOML date form, but JSON loaders +/// (e.g. defaults bundled as JSON) supply a string. +fn deserialize_knowledge_cutoff<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + use toml::value::Datetime; + + #[derive(Deserialize)] + #[serde(untagged)] + enum Either { + Toml(Datetime), + Str(String), + } + + let value = Option::::deserialize(deserializer)?; + match value { + None => Ok(None), + Some(Either::Str(s)) => NaiveDate::parse_from_str(&s, "%Y-%m-%d") + .map(Some) + .map_err(D::Error::custom), + Some(Either::Toml(dt)) => { + let date = dt + .date + .ok_or_else(|| D::Error::custom("knowledge_cutoff requires a date component"))?; + NaiveDate::from_ymd_opt(date.year.into(), date.month.into(), date.day.into()) + .ok_or_else(|| D::Error::custom("knowledge_cutoff is not a valid calendar date")) + .map(Some) + } + } +} + +// --------------------------------------------------------------------------- +// CredentialRef — typed credential reference +// --------------------------------------------------------------------------- + +/// A typed credential reference. Literal secret strings are rejected at +/// deserialization so settings never carry a successful "secret string" +/// representation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(into = "String", try_from = "String")] +pub enum CredentialRef { + /// Structured credential stored in `fabro-vault` keyed by ``. + Credential(String), + /// Process environment variable ``. Falls back to a raw vault + /// secret with the same name when the env var is unset. + Env(String), +} + +impl std::fmt::Display for CredentialRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Display deliberately writes only the typed reference form, never + // any resolved secret value. Env names and credential IDs are not + // themselves secret. + match self { + Self::Credential(id) => write!(f, "{CREDENTIAL_REF_PREFIX}{id}"), + Self::Env(name) => write!(f, "{ENV_REF_PREFIX}{name}"), + } + } +} + +impl From for String { + fn from(value: CredentialRef) -> Self { + value.to_string() + } +} + +/// Error returned when a credential string is neither `credential:` nor +/// `env:`. Literal secret strings always fall into this branch and +/// fail deserialization — by design. Variants deliberately never carry the +/// rejected input, since it could be a literal secret. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CredentialRefParseError(CredentialRefParseErrorKind); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CredentialRefParseErrorKind { + MissingCredentialId, + MissingEnvName, + InvalidForm, +} + +impl std::fmt::Display for CredentialRefParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0 { + CredentialRefParseErrorKind::MissingCredentialId => { + f.write_str("credential reference is missing an ID after `credential:`") + } + CredentialRefParseErrorKind::MissingEnvName => { + f.write_str("credential reference is missing a name after `env:`") + } + CredentialRefParseErrorKind::InvalidForm => f.write_str( + "credential reference must be `credential:` or `env:`; literal secret strings are rejected", + ), + } + } +} + +impl std::error::Error for CredentialRefParseError {} + +impl std::str::FromStr for CredentialRef { + type Err = CredentialRefParseError; + + fn from_str(s: &str) -> Result { + if let Some(id) = s.strip_prefix(CREDENTIAL_REF_PREFIX) { + if id.is_empty() { + return Err(CredentialRefParseError( + CredentialRefParseErrorKind::MissingCredentialId, + )); + } + return Ok(Self::Credential(id.to_string())); + } + if let Some(name) = s.strip_prefix(ENV_REF_PREFIX) { + if name.is_empty() { + return Err(CredentialRefParseError( + CredentialRefParseErrorKind::MissingEnvName, + )); + } + return Ok(Self::Env(name.to_string())); + } + Err(CredentialRefParseError( + CredentialRefParseErrorKind::InvalidForm, + )) + } +} + +impl TryFrom for CredentialRef { + type Error = CredentialRefParseError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + use crate::layers::Combine; + + // ---- CredentialRef ---------------------------------------------------- + + #[test] + fn credential_ref_parses_credential_form() { + let r = CredentialRef::from_str("credential:openai_codex").unwrap(); + assert_eq!(r, CredentialRef::Credential("openai_codex".to_string())); + } + + #[test] + fn credential_ref_parses_env_form() { + let r = CredentialRef::from_str("env:KIMI_API_KEY").unwrap(); + assert_eq!(r, CredentialRef::Env("KIMI_API_KEY".to_string())); + } + + #[test] + fn credential_ref_rejects_literal_secret() { + // A literal API key contains no `credential:` or `env:` prefix. + let err = CredentialRef::from_str("sk-ant-1234").unwrap_err(); + assert!(err.to_string().contains("must be")); + assert!( + !err.to_string().contains("sk-ant-1234"), + "error must not echo the literal secret string back to the user", + ); + } + + #[test] + fn credential_ref_rejects_empty_credential_id() { + let err = CredentialRef::from_str("credential:").unwrap_err(); + assert!(err.to_string().contains("missing")); + } + + #[test] + fn credential_ref_rejects_empty_env_name() { + let err = CredentialRef::from_str("env:").unwrap_err(); + assert!(err.to_string().contains("missing")); + } + + #[test] + fn credential_ref_round_trips_through_string() { + let r = CredentialRef::Credential("kimi".to_string()); + assert_eq!(r.to_string(), "credential:kimi"); + let back: CredentialRef = r.to_string().parse().unwrap(); + assert_eq!(back, r); + } + + #[test] + fn credential_ref_serializes_as_string_in_toml() { + let r = CredentialRef::Env("KIMI_API_KEY".to_string()); + let s = toml::Value::try_from(&r).unwrap(); + assert_eq!(s.as_str(), Some("env:KIMI_API_KEY")); + } + + #[test] + fn credential_ref_deserializes_from_toml_string() { + let parsed: CredentialRef = toml::from_str(r#"v = "credential:foo""#) + .map(|v: toml::Value| { + v.as_table() + .unwrap() + .get("v") + .unwrap() + .clone() + .try_into() + .unwrap() + }) + .unwrap(); + assert_eq!(parsed, CredentialRef::Credential("foo".to_string())); + } + + #[test] + fn credential_ref_in_array_rejects_literal_secret() { + // serde rejects literal secrets when parsed inside an array of + // CredentialRef. The error bubbles up as a TOML deserialization + // failure. + #[derive(Deserialize)] + #[expect( + dead_code, + reason = "field exists only to drive the deserializer; we assert on the parse error" + )] + struct Wrap { + v: Vec, + } + let err: Result = toml::from_str(r#"v = ["sk-literal-secret"]"#); + assert!(err.is_err(), "literal secret strings must fail to parse"); + } + + // ---- LlmLayer parsing ------------------------------------------------- + + #[test] + fn parses_minimal_provider_entry() { + let toml = r#" +[providers.kimi] +display_name = "Kimi" +adapter = "openai_compatible" +base_url = "https://api.moonshot.ai/v1" +credentials = ["credential:kimi", "env:KIMI_API_KEY"] +priority = 60 +enabled = true +aliases = ["moonshot"] +"#; + let layer: LlmLayer = toml::from_str(toml).unwrap(); + let kimi = layer.providers.get("kimi").unwrap(); + assert_eq!(kimi.display_name.as_deref(), Some("Kimi")); + assert_eq!(kimi.adapter.as_deref(), Some("openai_compatible")); + assert_eq!(kimi.base_url.as_deref(), Some("https://api.moonshot.ai/v1")); + assert_eq!(kimi.priority, Some(60)); + assert_eq!(kimi.enabled, Some(true)); + assert_eq!(kimi.aliases.as_deref(), Some(&["moonshot".to_string()][..])); + assert_eq!(kimi.credentials.as_ref().unwrap(), &vec![ + CredentialRef::Credential("kimi".to_string()), + CredentialRef::Env("KIMI_API_KEY".to_string()), + ]); + } + + #[test] + fn parses_full_model_entry() { + let toml = r#" +[models."kimi-k2.5"] +provider = "kimi" +api_id = "kimi-k2.5" +display_name = "Kimi K2.5" +family = "kimi" +knowledge_cutoff = 2025-01-01 +default = true +enabled = true +aliases = ["kimi"] +estimated_output_tps = 50 + +[models."kimi-k2.5".limits] +context_window = 262144 +max_output = 32768 + +[models."kimi-k2.5".features] +tools = true +vision = false +reasoning = true +effort = false + +[models."kimi-k2.5".costs] +input_cost_per_mtok = 0.60 +output_cost_per_mtok = 2.50 +cache_input_cost_per_mtok = 0.15 +"#; + let layer: LlmLayer = toml::from_str(toml).unwrap(); + let m = layer.models.get("kimi-k2.5").unwrap(); + assert_eq!(m.provider.as_deref(), Some("kimi")); + assert_eq!(m.api_id.as_deref(), Some("kimi-k2.5")); + assert_eq!(m.display_name.as_deref(), Some("Kimi K2.5")); + assert_eq!(m.family.as_deref(), Some("kimi")); + assert_eq!( + m.knowledge_cutoff, + Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()) + ); + assert_eq!(m.default, Some(true)); + assert_eq!(m.enabled, Some(true)); + assert_eq!(m.aliases.as_deref(), Some(&["kimi".to_string()][..])); + assert_eq!(m.estimated_output_tps, Some(50.0)); + + let limits = m.limits.as_ref().unwrap(); + assert_eq!(limits.context_window, Some(262_144)); + assert_eq!(limits.max_output, Some(32_768)); + + let features = m.features.as_ref().unwrap(); + assert_eq!(features.tools, Some(true)); + assert_eq!(features.vision, Some(false)); + assert_eq!(features.reasoning, Some(true)); + assert_eq!(features.effort, Some(false)); + + let costs = m.costs.as_ref().unwrap(); + assert_eq!(costs.base.input_cost_per_mtok, Some(0.60)); + assert_eq!(costs.base.output_cost_per_mtok, Some(2.50)); + assert_eq!(costs.base.cache_input_cost_per_mtok, Some(0.15)); + assert!(costs.speed.is_none()); + } + + #[test] + fn parses_controls_and_per_speed_costs() { + let toml = r#" +[models."claude-opus-4-6".controls] +reasoning_effort = ["low", "medium", "high"] +speed = ["fast"] + +[models."claude-opus-4-6".costs.speed.fast] +input_cost_per_mtok = 90.0 +output_cost_per_mtok = 450.0 +cache_input_cost_per_mtok = 9.0 +"#; + let layer: LlmLayer = toml::from_str(toml).unwrap(); + let m = layer.models.get("claude-opus-4-6").unwrap(); + + let controls = m.controls.as_ref().unwrap(); + assert_eq!( + controls.reasoning_effort.as_deref(), + Some(&["low".to_string(), "medium".to_string(), "high".to_string()][..]) + ); + assert_eq!(controls.speed.as_deref(), Some(&["fast".to_string()][..])); + + let costs = m.costs.as_ref().unwrap(); + let fast = costs.speed.as_ref().unwrap().get("fast").unwrap(); + assert_eq!(fast.input_cost_per_mtok, Some(90.0)); + assert_eq!(fast.output_cost_per_mtok, Some(450.0)); + assert_eq!(fast.cache_input_cost_per_mtok, Some(9.0)); + } + + #[test] + fn rejects_unknown_provider_field() { + let toml = r#" +[providers.kimi] +adapter = "openai_compatible" +unknown_field = true +"#; + let err = toml::from_str::(toml).unwrap_err(); + assert!(err.to_string().contains("unknown_field")); + } + + #[test] + fn rejects_unknown_model_field() { + let toml = r#" +[models.foo] +provider = "x" +mystery = 1 +"#; + let err = toml::from_str::(toml).unwrap_err(); + assert!(err.to_string().contains("mystery")); + } + + // ---- Combine / merge -------------------------------------------------- + + #[test] + fn provider_field_merge_keeps_self_values_and_fills_holes() { + let high = ProviderSettings { + adapter: Some("openai_compatible".to_string()), + base_url: Some("https://override.example".to_string()), + ..ProviderSettings::default() + }; + let low = ProviderSettings { + adapter: Some("anthropic".to_string()), + base_url: Some("https://defaults.example".to_string()), + display_name: Some("Default".to_string()), + priority: Some(10), + ..ProviderSettings::default() + }; + let merged = high.combine(low); + assert_eq!(merged.adapter.as_deref(), Some("openai_compatible")); + assert_eq!(merged.base_url.as_deref(), Some("https://override.example")); + assert_eq!(merged.display_name.as_deref(), Some("Default")); + assert_eq!(merged.priority, Some(10)); + } + + #[test] + fn provider_credentials_array_replaces_wholesale() { + // Higher layer redeclares credentials → low layer's list is dropped + // entirely (whole-array replacement). + let high = ProviderSettings { + credentials: Some(vec![CredentialRef::Env("FOO".to_string())]), + ..ProviderSettings::default() + }; + let low = ProviderSettings { + credentials: Some(vec![ + CredentialRef::Credential("bar".to_string()), + CredentialRef::Env("BAZ".to_string()), + ]), + ..ProviderSettings::default() + }; + let merged = high.combine(low); + assert_eq!(merged.credentials.unwrap(), vec![CredentialRef::Env( + "FOO".to_string() + )]); + } + + #[test] + fn provider_credentials_inherits_when_unset_in_higher_layer() { + let high = ProviderSettings::default(); + let low = ProviderSettings { + credentials: Some(vec![CredentialRef::Env("FOO".to_string())]), + ..ProviderSettings::default() + }; + let merged = high.combine(low); + assert_eq!(merged.credentials.unwrap(), vec![CredentialRef::Env( + "FOO".to_string() + )]); + } + + #[test] + fn merge_map_field_merges_per_provider_id() { + let mut high_map: std::collections::HashMap = + std::collections::HashMap::new(); + high_map.insert("kimi".to_string(), ProviderSettings { + base_url: Some("https://override".to_string()), + ..ProviderSettings::default() + }); + let high: MergeMap = MergeMap::from(high_map); + + let mut low_map: std::collections::HashMap = + std::collections::HashMap::new(); + low_map.insert("kimi".to_string(), ProviderSettings { + adapter: Some("openai_compatible".to_string()), + base_url: Some("https://defaults".to_string()), + ..ProviderSettings::default() + }); + let low: MergeMap = MergeMap::from(low_map); + + let merged = high.combine(low); + let kimi = merged.get("kimi").unwrap(); + assert_eq!(kimi.adapter.as_deref(), Some("openai_compatible")); + assert_eq!(kimi.base_url.as_deref(), Some("https://override")); + } + + #[test] + fn model_controls_replace_wholesale() { + // Whole-array replacement: high layer's `reasoning_effort` shadows + // the low layer's list completely. + let high = ModelControls { + reasoning_effort: Some(vec!["high".to_string()]), + ..ModelControls::default() + }; + let low = ModelControls { + reasoning_effort: Some(vec!["low".to_string(), "high".to_string()]), + speed: Some(vec!["fast".to_string()]), + }; + let merged = high.combine(low); + assert_eq!( + merged.reasoning_effort.as_deref(), + Some(&["high".to_string()][..]) + ); + assert_eq!(merged.speed.as_deref(), Some(&["fast".to_string()][..])); + } +} diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs index 9d35995d6..4ff4cc4c2 100644 --- a/lib/crates/fabro-config/src/layers/mod.rs +++ b/lib/crates/fabro-config/src/layers/mod.rs @@ -1,6 +1,7 @@ mod cli; mod combine; mod features; +mod llm; mod log_filter; mod maps; mod project; @@ -16,6 +17,11 @@ pub use cli::{ }; pub(crate) use combine::Combine; pub use features::FeaturesLayer; +pub use llm::{ + CostRates, CredentialRef, CredentialRefParseError, LlmLayer, ModelControls, ModelCostTable, + ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, ModelSettings, + ProviderSettings, +}; pub use log_filter::LogFilter; pub use maps::{MergeMap, ReplaceMap, StickyMap}; pub use project::ProjectLayer; @@ -25,8 +31,8 @@ pub use run::{ InterviewsLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer, PrepareStep, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, - RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, - ScmGitHubLayer, StringOrSplice, + RunLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, + RunSandboxLayer, RunScmLayer, ScmGitHubLayer, StringOrSplice, }; pub use server::{ GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, diff --git a/lib/crates/fabro-config/src/layers/run.rs b/lib/crates/fabro-config/src/layers/run.rs index aad3e97dd..65fa7434d 100644 --- a/lib/crates/fabro-config/src/layers/run.rs +++ b/lib/crates/fabro-config/src/layers/run.rs @@ -145,6 +145,37 @@ pub struct RunModelLayer { #[serde(default, skip_serializing_if = "Vec::is_empty")] #[option(default = "[]", value_type = "array")] pub fallbacks: Vec, + /// Run-level default values for typed model controls. Node attributes + /// and style-applied attributes still win over these defaults. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub controls: Option, +} + +/// `[run.model.controls]` — run-level default control values. +/// +/// Stored as plain strings here; concrete enum validation +/// (`ReasoningEffort`, `Speed`) happens at request-time when the resolved +/// catalog is available. +#[derive( + Debug, + Clone, + Default, + PartialEq, + Serialize, + Deserialize, + fabro_macros::Combine, + fabro_macros::OptionsMetadata, +)] +#[serde(deny_unknown_fields)] +pub struct RunModelControlsLayer { + /// Default reasoning-effort value for nodes that don't override it. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[option(value_type = "string")] + pub reasoning_effort: Option, + /// Default speed value for nodes that don't override it. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[option(value_type = "string")] + pub speed: Option, } /// A single `fallbacks` entry: either a parsed `ModelRef` or the splice marker. diff --git a/lib/crates/fabro-config/src/layers/settings.rs b/lib/crates/fabro-config/src/layers/settings.rs index 4d3401c79..4864ff5ae 100644 --- a/lib/crates/fabro-config/src/layers/settings.rs +++ b/lib/crates/fabro-config/src/layers/settings.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use super::cli::CliLayer; use super::features::FeaturesLayer; +use super::llm::LlmLayer; use super::project::ProjectLayer; use super::run::RunLayer; use super::server::ServerLayer; @@ -34,6 +35,8 @@ pub(crate) struct SettingsLayer { pub server: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub features: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm: Option, } impl FromStr for SettingsLayer { @@ -62,6 +65,15 @@ impl From for SettingsLayer { } } +impl From for SettingsLayer { + fn from(llm: LlmLayer) -> Self { + Self { + llm: Some(llm), + ..Self::default() + } + } +} + impl From for SettingsLayer { fn from(project: ProjectLayer) -> Self { Self { diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 076a5ce7e..7e4800e28 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -39,19 +39,21 @@ pub use home::Home; pub use input_overrides::{InputOverrideParseError, parse_input_overrides}; pub use layers::{ CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer, - CliOutputLayer, CliTargetLayer, CliUpdatesLayer, DaytonaDockerfileLayer, DaytonaSandboxLayer, - DaytonaSnapshotLayer, DockerSandboxLayer, FeaturesLayer, GitAuthorLayer, - GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, - InterviewProviderLayer, InterviewsLayer, LogFilter, McpEntryLayer, MergeMap, ModelRefOrSplice, - NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, - PrepareStep, ProjectLayer, ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, + CliOutputLayer, CliTargetLayer, CliUpdatesLayer, CostRates, CredentialRef, + CredentialRefParseError, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer, + DockerSandboxLayer, FeaturesLayer, GitAuthorLayer, GithubIntegrationLayer, HookAgentMarker, + HookEntry, HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer, InterviewsLayer, + LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, McpEntryLayer, MergeMap, ModelControls, + ModelCostTable, ModelRefOrSplice, ModelSettings, NotificationProviderLayer, + NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, + ProviderSettings, ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, - RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, - ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, - ServerIntegrationsLayer, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer, - ServerListenLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerSlateDbLayer, - ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, - WorkflowLayer, + RunLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, + RunSandboxLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, + ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer, + ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, + ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, + SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, }; pub(crate) use layers::{Combine, SettingsLayer}; pub use logging::{resolve_log_destination, resolve_log_destination_with_env}; diff --git a/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs index 7fd413a58..65f131afc 100644 --- a/lib/crates/fabro-config/src/parse.rs +++ b/lib/crates/fabro-config/src/parse.rs @@ -5,7 +5,19 @@ use crate::SettingsLayer; const CURRENT_VERSION: u32 = 1; const ALLOWED_TOP_LEVEL_KEYS: &[&str] = &[ - "_version", "project", "workflow", "run", "cli", "server", "features", + "_version", "project", "workflow", "run", "cli", "server", "features", "llm", +]; + +/// Legacy `[llm]` keys that pre-date the settings-driven catalog plan and +/// should still produce the migration hint for `[run.model]` rather than be +/// silently accepted by the new `[llm]` schema. +const LEGACY_LLM_KEYS: &[&str] = &[ + "provider", + "model", + "temperature", + "max_tokens", + "fallbacks", + "fallback", ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -26,7 +38,7 @@ impl fmt::Display for ParseError { } else { write!( f, - "unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `run`, `cli`, `server`, `features`" + "unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `run`, `cli`, `server`, `features`, `llm`" ) } } @@ -71,6 +83,21 @@ pub(crate) fn parse_settings(input: &str) -> Result { }); } } + + // The settings-driven catalog plan re-introduced `[llm]` for provider + // and model rows. Old top-level `[llm]` keys (`provider`, `model`, + // ...) are forbidden and must continue to surface the migration hint + // for `[run.model]` rather than be silently dropped or generic-error. + if let Some(llm_table) = table.get("llm").and_then(toml::Value::as_table) { + for legacy_key in LEGACY_LLM_KEYS { + if llm_table.contains_key(*legacy_key) { + return Err(ParseError::UnknownTopLevelKey { + key: format!("llm.{legacy_key}"), + hint: rename_hint("llm"), + }); + } + } + } } raw.try_into::() @@ -168,4 +195,44 @@ mod tests { let err = "_version = 99".parse::().unwrap_err(); assert!(err.to_string().contains("Upgrade")); } + + #[test] + fn accepts_new_llm_providers_subtree() { + let parsed = "[llm.providers.kimi]\nadapter = \"openai_compatible\"\n" + .parse::() + .unwrap(); + assert!(parsed.llm.unwrap().providers.contains_key("kimi")); + } + + #[test] + fn accepts_new_llm_models_subtree() { + let parsed = "[llm.models.\"foo\"]\nprovider = \"kimi\"\n" + .parse::() + .unwrap(); + assert!(parsed.llm.unwrap().models.contains_key("foo")); + } + + #[test] + fn rejects_legacy_llm_provider_key_with_run_model_hint() { + let err = "[llm]\nprovider = \"openai\"\n" + .parse::() + .unwrap_err(); + let text = err.to_string(); + assert!( + text.contains("run.model") || text.contains("llm"), + "got: {text}" + ); + } + + #[test] + fn rejects_legacy_llm_model_key_with_run_model_hint() { + let err = "[llm]\nmodel = \"opus\"\n" + .parse::() + .unwrap_err(); + let text = err.to_string(); + assert!( + text.contains("run.model") || text.contains("llm"), + "got: {text}" + ); + } } diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index fe4c94240..2debd724d 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -5,8 +5,8 @@ use fabro_types::settings::run::{ McpTransport, MergeStrategy, NotificationProviderSettings, NotificationRouteSettings, PullRequestSettings, RunAgentSettings, RunCheckpointSettings, RunExecutionSettings, RunGitSettings, RunGoal, RunIntegrationsGithubSettings, RunIntegrationsSettings, - RunInterviewsSettings, RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, - RunScmSettings, ScmGitHubSettings, TlsMode, + RunInterviewsSettings, RunModelControls, RunModelSettings, RunNamespace, RunPrepareSettings, + RunSandboxSettings, RunScmSettings, ScmGitHubSettings, TlsMode, }; use super::ResolveError; @@ -87,6 +87,14 @@ fn resolve_model(model: Option<&RunModelLayer>) -> RunModelSettings { ModelRefOrSplice::Splice => None, }) .collect(), + controls: model + .controls + .as_ref() + .map(|c| RunModelControls { + reasoning_effort: c.reasoning_effort.clone(), + speed: c.speed.clone(), + }) + .unwrap_or_default(), } } diff --git a/lib/crates/fabro-config/src/tests/resolve_run.rs b/lib/crates/fabro-config/src/tests/resolve_run.rs index 7a0501082..7f3def838 100644 --- a/lib/crates/fabro-config/src/tests/resolve_run.rs +++ b/lib/crates/fabro-config/src/tests/resolve_run.rs @@ -3,6 +3,37 @@ use fabro_types::settings::run::{ApprovalMode, RunGoal, RunMode}; use crate::{SettingsLayer, WorkflowSettingsBuilder}; +#[test] +fn run_model_controls_round_trip_through_resolve() { + let settings = WorkflowSettingsBuilder::from_toml( + r#" +_version = 1 + +[run.model.controls] +reasoning_effort = "high" +speed = "fast" +"#, + ) + .expect("[run.model.controls] should resolve") + .run; + + assert_eq!( + settings.model.controls.reasoning_effort.as_deref(), + Some("high") + ); + assert_eq!(settings.model.controls.speed.as_deref(), Some("fast")); +} + +#[test] +fn run_model_controls_default_to_none() { + let settings = WorkflowSettingsBuilder::from_layer(&SettingsLayer::default()) + .expect("empty settings should resolve") + .run; + + assert!(settings.model.controls.reasoning_effort.is_none()); + assert!(settings.model.controls.speed.is_none()); +} + #[test] fn resolves_run_defaults_from_empty_settings() { let settings = WorkflowSettingsBuilder::from_layer(&SettingsLayer::default()) diff --git a/lib/crates/fabro-dev/tests/it/main.rs b/lib/crates/fabro-dev/tests/it/main.rs index 265d96635..cbf952b4a 100644 --- a/lib/crates/fabro-dev/tests/it/main.rs +++ b/lib/crates/fabro-dev/tests/it/main.rs @@ -4,6 +4,7 @@ mod docker_build; #[cfg(unix)] mod docker_entrypoint; mod docs; +mod policy; mod release; mod spa; @@ -11,7 +12,7 @@ fn fabro_dev() -> assert_cmd::Command { assert_cmd::cargo::cargo_bin_cmd!("fabro-dev") } -fn workspace_root() -> PathBuf { +pub(crate) fn workspace_root() -> PathBuf { let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); root.pop(); root.pop(); diff --git a/lib/crates/fabro-dev/tests/it/policy.rs b/lib/crates/fabro-dev/tests/it/policy.rs new file mode 100644 index 000000000..86f4f593c --- /dev/null +++ b/lib/crates/fabro-dev/tests/it/policy.rs @@ -0,0 +1,100 @@ +//! Workspace policy tests. +//! +//! These tests scan the source tree for references that violate +//! product-level invariants. They run as part of `cargo nextest` and are +//! cheap (text scans only). + +use walkdir::WalkDir; + +use crate::workspace_root; + +/// `fabro_model::bootstrap_catalog` (and its module) is the install/API-key +/// validation hatch from the settings-driven LLM catalog plan. It must +/// **not** appear in request-serving paths — server handlers, workflow +/// operations, agent runtime, hooks, or completion handlers — because those +/// must use the resolved `Arc` threaded through their state. +/// +/// The allowed-callers list below is the policy boundary. Adding a new +/// caller is intentional and requires updating this list. +/// +/// The walker only descends into `lib/`, so non-`lib/` paths (docs, top-level +/// markdown) are not part of the allowlist. +const BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ + // The bootstrap module itself. + "lib/crates/fabro-model/src/bootstrap_catalog", + // Install / first-run / API-key validation flows that legitimately need + // a built-in catalog before any project settings have been loaded. + "lib/crates/fabro-install/", + "lib/crates/fabro-cli/src/commands/install/", + "lib/crates/fabro-cli/src/shared/install_", + "lib/crates/fabro-cli/src/shared/api_key_validation", + // Test support modules. + "tests/", + "test_support", + "/tests/it/", + "/tests/policy.rs", +]; + +#[test] +#[expect( + clippy::disallowed_methods, + reason = "policy test reads source files synchronously with std::fs" +)] +fn bootstrap_catalog_references_stay_in_allowlist() { + let root = workspace_root(); + let lib_root = root.join("lib"); + let mut violations: Vec<(String, usize, String)> = Vec::new(); + + let walker = WalkDir::new(&lib_root).into_iter().filter_entry(|entry| { + // Skip generated/output directories at any depth. + let name = entry.file_name().to_string_lossy(); + !matches!( + name.as_ref(), + "target" | ".git" | "node_modules" | "dist" | "build" + ) + }); + + for entry in walker.flatten() { + let path = entry.path(); + if !path.is_file() || path.extension().is_none_or(|ext| ext != "rs") { + continue; + } + let Ok(contents) = std::fs::read_to_string(path) else { + continue; + }; + // Cheap early-out: avoids per-line work for the ~99% of files with no + // reference to the symbol. + if !contents.contains("bootstrap_catalog") { + continue; + } + let rel = path.strip_prefix(&root).unwrap_or(path); + let rel_str = rel.to_string_lossy().replace('\\', "/"); + let path_allowed = BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS + .iter() + .any(|frag| rel_str.contains(frag)); + if path_allowed { + continue; + } + for (idx, line) in contents.lines().enumerate() { + if !line.contains("bootstrap_catalog") { + continue; + } + // Skip comments referencing the symbol in prose. + let trimmed = line.trim_start(); + if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') { + continue; + } + violations.push((rel_str.clone(), idx + 1, line.to_string())); + } + } + + assert!( + violations.is_empty(), + "bootstrap_catalog (install-only) referenced from non-allowlisted source files:\n{}\n\nIf this is intentional, add the path fragment to BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS in lib/crates/fabro-dev/tests/it/policy.rs.", + violations + .into_iter() + .map(|(p, l, s)| format!(" {p}:{l}: {}", s.trim())) + .collect::>() + .join("\n"), + ); +} diff --git a/lib/crates/fabro-llm/src/adapter_registry.rs b/lib/crates/fabro-llm/src/adapter_registry.rs new file mode 100644 index 000000000..2707022c0 --- /dev/null +++ b/lib/crates/fabro-llm/src/adapter_registry.rs @@ -0,0 +1,232 @@ +//! Adapter factory registry keyed by stable adapter strings. +//! +//! Mirrors the static [`fabro_model::adapter`] metadata: every metadata key +//! ships with a matching factory in this module. Tests in this file enforce +//! that the registry covers every metadata key and never adds keys that have +//! no metadata. +//! +//! Factories take a pre-built [`AdapterConfig`] derived from resolved +//! credentials + provider settings, and produce a boxed +//! [`ProviderAdapter`] ready to register with the [`crate::Client`]. +//! +//! This is the seam the rest of the workspace will eventually use to retire +//! the per-`Provider`-variant match in [`crate::Client::from_credentials`]. + +use std::collections::HashMap; +use std::sync::Arc; + +use fabro_auth::ApiKeyHeader; +use fabro_model::adapter::{self as model_adapter, AdapterMetadata}; + +use crate::client::auth_value; +use crate::provider::ProviderAdapter; +use crate::providers; + +/// Configuration passed to an adapter factory. All values are pre-resolved +/// from settings + credentials; factories never touch the environment or the +/// vault directly. +#[derive(Debug, Clone)] +pub struct AdapterConfig { + /// Provider ID this adapter will register under (used as the registry + /// name on the resulting adapter). + pub provider_id: String, + /// Authentication header constructed by `fabro-auth` from the resolved + /// credential and the adapter's [`fabro_model::ApiKeyHeaderPolicy`]. + pub auth_header: ApiKeyHeader, + /// Provider base URL override. `None` means use the adapter's built-in + /// default. + pub base_url: Option, + /// Extra HTTP headers attached to every outgoing request. + pub extra_headers: HashMap, + /// OpenAI-only: route through the ChatGPT Codex backend. + pub codex_mode: bool, + /// OpenAI-only: organization ID. + pub org_id: Option, + /// OpenAI-only: project ID. + pub project_id: Option, +} + +impl AdapterConfig { + /// Construct a minimal config with just provider ID and auth header. + pub fn new(provider_id: impl Into, auth_header: ApiKeyHeader) -> Self { + Self { + provider_id: provider_id.into(), + auth_header, + base_url: None, + extra_headers: HashMap::new(), + codex_mode: false, + org_id: None, + project_id: None, + } + } +} + +/// Factory function signature. Takes a fully-resolved [`AdapterConfig`] and +/// returns a registered-ready [`ProviderAdapter`]. +/// +/// Adapter constructors are infallible today; if a future adapter needs to +/// fail at construction time, add a separate fallible factory variant +/// rather than re-shaping every existing factory. +pub type AdapterFactory = fn(AdapterConfig) -> Arc; + +fn build_anthropic(config: AdapterConfig) -> Arc { + let mut adapter = providers::AnthropicAdapter::new(auth_value(&config.auth_header)); + if let Some(base_url) = config.base_url { + adapter = adapter.with_base_url(base_url); + } + if !config.extra_headers.is_empty() { + adapter = adapter.with_default_headers(config.extra_headers); + } + Arc::new(adapter) +} + +fn build_openai(config: AdapterConfig) -> Arc { + let mut adapter = providers::OpenAiAdapter::new(auth_value(&config.auth_header)); + if let Some(base_url) = config.base_url { + adapter = adapter.with_base_url(base_url); + } + if !config.extra_headers.is_empty() { + adapter = adapter.with_default_headers(config.extra_headers); + } + if config.codex_mode { + adapter = adapter.with_codex_mode(); + } + if let Some(org_id) = config.org_id { + adapter = adapter.with_org_id(org_id); + } + if let Some(project_id) = config.project_id { + adapter = adapter.with_project_id(project_id); + } + Arc::new(adapter) +} + +fn build_gemini(config: AdapterConfig) -> Arc { + let mut adapter = providers::GeminiAdapter::new(auth_value(&config.auth_header)); + if let Some(base_url) = config.base_url { + adapter = adapter.with_base_url(base_url); + } + if !config.extra_headers.is_empty() { + adapter = adapter.with_default_headers(config.extra_headers); + } + Arc::new(adapter) +} + +fn build_openai_compatible(config: AdapterConfig) -> Arc { + // `openai_compatible` providers vary widely in base URL; the catalog must + // pre-resolve `[llm.providers.].base_url` before constructing + // `AdapterConfig`. There is no sensible default — silently routing to one + // provider's host would produce wrong-host requests for every other. + let base_url = config.base_url.expect( + "openai_compatible adapter requires a base_url; resolve it from provider settings before \ + building AdapterConfig", + ); + let mut adapter = + providers::OpenAiCompatibleAdapter::new(auth_value(&config.auth_header), base_url) + .with_name(config.provider_id); + if !config.extra_headers.is_empty() { + adapter = adapter.with_default_headers(config.extra_headers); + } + Arc::new(adapter) +} + +/// Single source of truth pairing every adapter key with its factory. Both +/// `factory_for` and `registered_keys` derive from this table. +const FACTORIES: &[(&str, AdapterFactory)] = &[ + (model_adapter::ANTHROPIC.key, build_anthropic), + (model_adapter::OPENAI.key, build_openai), + (model_adapter::GEMINI.key, build_gemini), + ( + model_adapter::OPENAI_COMPATIBLE.key, + build_openai_compatible, + ), +]; + +/// Look up a factory by adapter key. Returns `None` if the key has no factory +/// registered. +#[must_use] +pub fn factory_for(adapter_key: &str) -> Option { + FACTORIES + .iter() + .find_map(|(key, factory)| (*key == adapter_key).then_some(*factory)) +} + +/// Iterate every adapter key with a factory registered. +pub fn registered_keys() -> impl Iterator { + FACTORIES.iter().map(|(key, _)| *key) +} + +/// Look up adapter metadata by key, ensuring the metadata + factory pair +/// remains in sync. +#[must_use] +pub fn metadata_for(adapter_key: &str) -> Option<&'static AdapterMetadata> { + model_adapter::get(adapter_key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_metadata_key_has_a_factory() { + for key in model_adapter::keys() { + assert!( + factory_for(key).is_some(), + "adapter metadata key `{key}` has no matching factory in fabro-llm", + ); + } + } + + #[test] + fn every_factory_has_metadata() { + for key in registered_keys() { + assert!( + metadata_for(key).is_some(), + "fabro-llm factory `{key}` has no matching metadata in fabro-model", + ); + } + } + + #[test] + fn registered_factory_set_matches_metadata_set() { + let metadata: std::collections::BTreeSet<&str> = model_adapter::keys().collect(); + let factories: std::collections::BTreeSet<&str> = registered_keys().collect(); + assert_eq!(metadata, factories); + } + + #[test] + fn unknown_key_returns_none_factory() { + assert!(factory_for("does_not_exist").is_none()); + } + + #[test] + fn anthropic_factory_builds_anthropic_adapter() { + let config = AdapterConfig::new("anthropic", ApiKeyHeader::Custom { + name: "x-api-key".to_string(), + value: "test-key".to_string(), + }); + let adapter = factory_for("anthropic").unwrap()(config); + assert_eq!(adapter.name(), "anthropic"); + } + + #[test] + fn openai_compatible_factory_uses_provider_id_for_name() { + let config = AdapterConfig { + provider_id: "kimi".to_string(), + auth_header: ApiKeyHeader::Bearer("k".to_string()), + base_url: Some("https://api.moonshot.ai/v1".to_string()), + extra_headers: HashMap::new(), + codex_mode: false, + org_id: None, + project_id: None, + }; + let adapter = factory_for("openai_compatible").unwrap()(config); + assert_eq!(adapter.name(), "kimi"); + } + + #[test] + #[should_panic(expected = "openai_compatible adapter requires a base_url")] + fn openai_compatible_factory_panics_without_base_url() { + let config = AdapterConfig::new("kimi", ApiKeyHeader::Bearer("k".to_string())); + let _ = factory_for("openai_compatible").unwrap()(config); + } +} diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index 865cff207..18711d952 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -321,7 +321,7 @@ impl Client { } } -fn auth_value(auth_header: &ApiKeyHeader) -> String { +pub(crate) fn auth_value(auth_header: &ApiKeyHeader) -> String { match auth_header { ApiKeyHeader::Bearer(value) | ApiKeyHeader::Custom { value, .. } => value.clone(), } diff --git a/lib/crates/fabro-llm/src/lib.rs b/lib/crates/fabro-llm/src/lib.rs index 1ac82a93a..45b847011 100644 --- a/lib/crates/fabro-llm/src/lib.rs +++ b/lib/crates/fabro-llm/src/lib.rs @@ -1,3 +1,4 @@ +pub mod adapter_registry; pub mod client; pub mod error; pub mod generate; diff --git a/lib/crates/fabro-llm/src/types.rs b/lib/crates/fabro-llm/src/types.rs index 58e785207..80f46cf83 100644 --- a/lib/crates/fabro-llm/src/types.rs +++ b/lib/crates/fabro-llm/src/types.rs @@ -410,29 +410,10 @@ pub struct RateLimitInfo { } // --- 3.8 ReasoningEffort --- - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, -)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum ReasoningEffort { - Low, - Medium, - High, - XHigh, - Max, -} +// +// Re-exported from `fabro-model` so catalog data, request validation, OpenAPI +// replacement types, and the LLM client share one enum. +pub use fabro_model::ReasoningEffort; // --- 3.6 Request --- @@ -1277,30 +1258,4 @@ mod tests { fn tool_choice_mode_str_named() { assert_eq!(ToolChoice::named("get_weather").mode_str(), "named"); } - - #[test] - fn reasoning_effort_from_str_round_trip() { - use std::str::FromStr; - assert_eq!(ReasoningEffort::from_str("low"), Ok(ReasoningEffort::Low)); - assert_eq!( - ReasoningEffort::from_str("medium"), - Ok(ReasoningEffort::Medium) - ); - assert_eq!(ReasoningEffort::from_str("high"), Ok(ReasoningEffort::High)); - assert_eq!( - ReasoningEffort::from_str("xhigh"), - Ok(ReasoningEffort::XHigh) - ); - assert_eq!(ReasoningEffort::from_str("max"), Ok(ReasoningEffort::Max)); - assert_eq!(ReasoningEffort::XHigh.to_string(), "xhigh"); - assert_eq!(<&'static str>::from(ReasoningEffort::XHigh), "xhigh"); - assert_eq!(ReasoningEffort::Max.to_string(), "max"); - assert_eq!(<&'static str>::from(ReasoningEffort::Max), "max"); - } - - #[test] - fn reasoning_effort_from_str_rejects_unknown() { - use std::str::FromStr; - assert!(ReasoningEffort::from_str("bogus").is_err()); - } } diff --git a/lib/crates/fabro-manifest/src/lib.rs b/lib/crates/fabro-manifest/src/lib.rs index 6d6944ffc..a176dbac6 100644 --- a/lib/crates/fabro-manifest/src/lib.rs +++ b/lib/crates/fabro-manifest/src/lib.rs @@ -67,6 +67,7 @@ pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer { provider: input.provider.map(InterpString::parse), name: input.model.map(InterpString::parse), fallbacks: Vec::new(), + controls: None, }); let sandbox = (input.sandbox.is_some() || input.docker_image.is_some() diff --git a/lib/crates/fabro-model/src/adapter.rs b/lib/crates/fabro-model/src/adapter.rs new file mode 100644 index 000000000..68db1f792 --- /dev/null +++ b/lib/crates/fabro-model/src/adapter.rs @@ -0,0 +1,201 @@ +//! Adapter metadata vocabulary shared by the model catalog and LLM factories. +//! +//! Adapters are Rust-owned: each registered adapter key maps to a static +//! [`AdapterMetadata`] describing how the adapter dispatches agent profiles, +//! formats API key headers, and which native control values it supports. +//! +//! Provider/model catalog rows reference adapters by key. Both the catalog +//! (in `fabro-model`) and the LLM factory registry (in `fabro-llm`) must agree +//! on the same set of adapter keys; the parity is enforced by tests. + +use strum::VariantArray; + +use crate::Speed; +use crate::reasoning::ReasoningEffort; + +/// Internal dispatch key that `fabro-agent` maps to a concrete agent profile. +/// +/// This is **not** a settings field. The agent profile is inferred from the +/// adapter, never set directly in TOML. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AgentProfileKind { + Anthropic, + OpenAi, + Gemini, +} + +/// How an API key for the adapter is converted into an HTTP authentication +/// header. +/// +/// Carries no secret values — the actual key is supplied at request time by +/// `fabro-auth::build_api_key_header(policy, key)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApiKeyHeaderPolicy { + /// Standard `Authorization: Bearer ` header. + Bearer, + /// Custom header name carrying the raw key as its value, e.g. Anthropic's + /// `x-api-key`. + Custom { name: &'static str }, +} + +/// Native control values an adapter knows how to send through its provider +/// API. +#[derive(Debug, Clone, Copy)] +pub struct AdapterControlCapabilities { + /// Reasoning-effort values that can be sent through the provider's native + /// effort field. Models declaring `features.effort = true` may declare + /// `controls.reasoning_effort` only as a non-empty subset of this list. + pub native_reasoning_effort: &'static [ReasoningEffort], + /// Additional speeds (beyond `Speed::Standard`, which is implicit) the + /// adapter supports. Models may declare `controls.speed` only as a + /// subset of this list. + pub additional_speeds: &'static [Speed], +} + +/// Static metadata for a single adapter implementation. +#[derive(Debug, Clone, Copy)] +pub struct AdapterMetadata { + /// Stable adapter key referenced from `[llm.providers.] adapter = + /// "..."`. + pub key: &'static str, + /// Default agent profile dispatched for providers that use this adapter. + pub default_profile: AgentProfileKind, + /// How API keys for this adapter are converted into auth headers. + pub api_key_header: ApiKeyHeaderPolicy, + /// Native control values the adapter can transmit. + pub controls: AdapterControlCapabilities, +} + +/// Every reasoning-effort variant. Re-exposed as a const slice so static +/// adapter metadata can reference it without re-listing variants. +const FULL_REASONING_EFFORTS: &[ReasoningEffort] = ReasoningEffort::VARIANTS; + +const FAST_SPEEDS: &[Speed] = &[Speed::Fast]; + +/// Anthropic — `anthropic` adapter. +pub const ANTHROPIC: AdapterMetadata = AdapterMetadata { + key: "anthropic", + default_profile: AgentProfileKind::Anthropic, + api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-api-key" }, + controls: AdapterControlCapabilities { + native_reasoning_effort: FULL_REASONING_EFFORTS, + additional_speeds: FAST_SPEEDS, + }, +}; + +/// OpenAI — `openai` adapter. +pub const OPENAI: AdapterMetadata = AdapterMetadata { + key: "openai", + default_profile: AgentProfileKind::OpenAi, + api_key_header: ApiKeyHeaderPolicy::Bearer, + controls: AdapterControlCapabilities { + native_reasoning_effort: FULL_REASONING_EFFORTS, + additional_speeds: &[], + }, +}; + +/// Google Gemini — `gemini` adapter. +pub const GEMINI: AdapterMetadata = AdapterMetadata { + key: "gemini", + default_profile: AgentProfileKind::Gemini, + api_key_header: ApiKeyHeaderPolicy::Custom { + name: "x-goog-api-key", + }, + controls: AdapterControlCapabilities { + native_reasoning_effort: FULL_REASONING_EFFORTS, + additional_speeds: &[], + }, +}; + +/// OpenAI-compatible — `openai_compatible` adapter, used by Kimi/Zai/etc. +/// Routes through the OpenAI agent profile but accepts arbitrary `base_url` +/// per provider settings. +pub const OPENAI_COMPATIBLE: AdapterMetadata = AdapterMetadata { + key: "openai_compatible", + default_profile: AgentProfileKind::OpenAi, + api_key_header: ApiKeyHeaderPolicy::Bearer, + controls: AdapterControlCapabilities { + // `openai_compatible` providers vary widely; the catalog requires + // models declaring `features.effort = true` to enumerate exactly + // which effort values their endpoint accepts. + native_reasoning_effort: FULL_REASONING_EFFORTS, + additional_speeds: &[], + }, +}; + +/// All built-in adapter metadata, in stable iteration order. +pub const ALL_ADAPTERS: &[AdapterMetadata] = &[ANTHROPIC, OPENAI, GEMINI, OPENAI_COMPATIBLE]; + +/// Look up adapter metadata by stable key. +#[must_use] +pub fn get(key: &str) -> Option<&'static AdapterMetadata> { + ALL_ADAPTERS.iter().find(|a| a.key == key) +} + +/// Iterate every registered adapter key. +pub fn keys() -> impl Iterator { + ALL_ADAPTERS.iter().map(|a| a.key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_by_known_key() { + assert_eq!(get("anthropic").unwrap().key, "anthropic"); + assert_eq!(get("openai").unwrap().key, "openai"); + assert_eq!(get("gemini").unwrap().key, "gemini"); + assert_eq!(get("openai_compatible").unwrap().key, "openai_compatible"); + } + + #[test] + fn lookup_unknown_key_returns_none() { + assert!(get("does_not_exist").is_none()); + } + + #[test] + fn keys_are_unique_and_match_all_adapters() { + let keys: Vec<&'static str> = keys().collect(); + let mut sorted = keys.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), keys.len(), "duplicate adapter key"); + assert_eq!(sorted.len(), ALL_ADAPTERS.len()); + } + + #[test] + fn anthropic_uses_custom_x_api_key_header() { + match ANTHROPIC.api_key_header { + ApiKeyHeaderPolicy::Custom { name } => assert_eq!(name, "x-api-key"), + ApiKeyHeaderPolicy::Bearer => panic!("expected custom header for anthropic"), + } + } + + #[test] + fn openai_uses_bearer_header() { + assert!(matches!(OPENAI.api_key_header, ApiKeyHeaderPolicy::Bearer)); + } + + #[test] + fn anthropic_supports_fast_speed() { + assert!(ANTHROPIC.controls.additional_speeds.contains(&Speed::Fast)); + } + + #[test] + fn openai_compatible_uses_openai_profile() { + assert_eq!(OPENAI_COMPATIBLE.default_profile, AgentProfileKind::OpenAi); + } + + #[test] + fn every_adapter_supports_full_native_reasoning_effort() { + for adapter in ALL_ADAPTERS { + assert_eq!( + adapter.controls.native_reasoning_effort.len(), + FULL_REASONING_EFFORTS.len(), + "adapter {} should expose all reasoning-effort values", + adapter.key, + ); + } + } +} diff --git a/lib/crates/fabro-model/src/billing.rs b/lib/crates/fabro-model/src/billing.rs index 55aa3e9c3..10b97d564 100644 --- a/lib/crates/fabro-model/src/billing.rs +++ b/lib/crates/fabro-model/src/billing.rs @@ -107,6 +107,7 @@ impl PricePerMTok { Display, EnumString, IntoStaticStr, + strum::VariantArray, )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] diff --git a/lib/crates/fabro-model/src/ids.rs b/lib/crates/fabro-model/src/ids.rs new file mode 100644 index 000000000..0d10da414 --- /dev/null +++ b/lib/crates/fabro-model/src/ids.rs @@ -0,0 +1,149 @@ +//! String-backed provider and model identifiers. +//! +//! Provider and model identity are catalog data, not closed enums. These +//! newtypes give catalog/auth/server seams a single, type-safe wrapper while +//! keeping wire format compatible with plain strings. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Stable provider identifier referenced from settings, vault, and request +/// routing. +/// +/// Wraps a `String` because the set of providers is open-ended and supplied +/// by `[llm.providers]` settings rather than compiled into a Rust enum. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProviderId(String); + +impl ProviderId { + /// Construct a provider ID from any string-like value without validation. + /// Catalog construction is responsible for canonicalisation; consumers + /// only need a wrapper for type clarity. + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + /// Borrow the inner string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consume the wrapper and return the inner `String`. + #[must_use] + pub fn into_inner(self) -> String { + self.0 + } +} + +impl fmt::Display for ProviderId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From<&str> for ProviderId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl From for ProviderId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl AsRef for ProviderId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Stable model identifier — either the canonical catalog ID or one of its +/// declared aliases. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ModelId(String); + +impl ModelId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn into_inner(self) -> String { + self.0 + } +} + +impl fmt::Display for ModelId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From<&str> for ModelId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl From for ModelId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl AsRef for ModelId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_id_is_transparent_string_in_json() { + let id = ProviderId::new("kimi"); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"kimi\""); + let back: ProviderId = serde_json::from_str(&json).unwrap(); + assert_eq!(back, id); + } + + #[test] + fn model_id_is_transparent_string_in_json() { + let id = ModelId::new("kimi-k2.5"); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"kimi-k2.5\""); + let back: ModelId = serde_json::from_str(&json).unwrap(); + assert_eq!(back, id); + } + + #[test] + fn display_writes_inner_string() { + assert_eq!(ProviderId::new("anthropic").to_string(), "anthropic"); + assert_eq!( + ModelId::new("claude-opus-4-7").to_string(), + "claude-opus-4-7" + ); + } + + #[test] + fn ord_is_lexicographic() { + let mut v = [ProviderId::new("zai"), ProviderId::new("anthropic")]; + v.sort(); + assert_eq!(v[0].as_str(), "anthropic"); + assert_eq!(v[1].as_str(), "zai"); + } +} diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/crates/fabro-model/src/lib.rs index 9a95e5815..481929cf2 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -1,10 +1,16 @@ +pub mod adapter; pub mod billing; pub mod catalog; +pub mod ids; pub mod model_ref; pub mod model_test; pub mod provider; +pub mod reasoning; pub mod types; +pub use adapter::{ + AdapterControlCapabilities, AdapterMetadata, AgentProfileKind, ApiKeyHeaderPolicy, +}; pub use billing::{ AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts, GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment, @@ -12,7 +18,9 @@ pub use billing::{ OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros, }; pub use catalog::{Catalog, FallbackTarget}; +pub use ids::{ModelId, ProviderId}; pub use model_ref::ModelHandle; pub use model_test::ModelTestMode; pub use provider::Provider; +pub use reasoning::ReasoningEffort; pub use types::{Model, ModelCosts, ModelFeatures, ModelLimits}; diff --git a/lib/crates/fabro-model/src/reasoning.rs b/lib/crates/fabro-model/src/reasoning.rs new file mode 100644 index 000000000..f99d3b9bb --- /dev/null +++ b/lib/crates/fabro-model/src/reasoning.rs @@ -0,0 +1,94 @@ +//! Shared reasoning-effort enum. +//! +//! `ReasoningEffort` is a Rust-owned vocabulary type. Catalog data, request +//! validation, OpenAPI replacement types, and the LLM client all share one +//! enum so that adding a new effort value remains a Rust change. + +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, + strum::VariantArray, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ReasoningEffort { + Low, + Medium, + High, + XHigh, + Max, +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use strum::VariantArray; + + use super::*; + + #[test] + fn parses_canonical_lowercase_strings() { + assert_eq!( + ReasoningEffort::from_str("low").unwrap(), + ReasoningEffort::Low + ); + assert_eq!( + ReasoningEffort::from_str("medium").unwrap(), + ReasoningEffort::Medium + ); + assert_eq!( + ReasoningEffort::from_str("high").unwrap(), + ReasoningEffort::High + ); + assert_eq!( + ReasoningEffort::from_str("xhigh").unwrap(), + ReasoningEffort::XHigh + ); + assert_eq!( + ReasoningEffort::from_str("max").unwrap(), + ReasoningEffort::Max + ); + } + + #[test] + fn rejects_unknown_strings() { + assert!(ReasoningEffort::from_str("none").is_err()); + assert!(ReasoningEffort::from_str("").is_err()); + assert!(ReasoningEffort::from_str("HIGH").is_err()); + } + + #[test] + fn display_matches_serde_lowercase() { + assert_eq!(ReasoningEffort::XHigh.to_string(), "xhigh"); + assert_eq!(<&'static str>::from(ReasoningEffort::Max), "max"); + } + + #[test] + fn variants_in_ordered_progression() { + let v = ReasoningEffort::VARIANTS; + assert_eq!(v[0], ReasoningEffort::Low); + assert_eq!(v[v.len() - 1], ReasoningEffort::Max); + } + + #[test] + fn round_trip_through_json() { + let json = serde_json::to_string(&ReasoningEffort::High).unwrap(); + assert_eq!(json, "\"high\""); + let parsed: ReasoningEffort = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, ReasoningEffort::High); + } +} diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 388f3f10c..40d88a615 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -41,8 +41,8 @@ pub use run::{ McpTransport, NotificationProviderSettings, NotificationRouteSettings, PullRequestSettings, RunAgentSettings, RunCheckpointSettings, RunExecutionSettings, RunGitSettings, RunGoal, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunInterviewsSettings, - RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, RunScmSettings, - ScmGitHubSettings, TlsMode, + RunModelControls, RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, + RunScmSettings, ScmGitHubSettings, TlsMode, }; pub use server::{ GithubIntegrationSettings, IntegrationWebhooksSettings, IpAllowEntry, LogDestination, diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index 18ae07b4c..0650e6b39 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -146,6 +146,17 @@ pub struct RunModelSettings { pub provider: Option, pub name: Option, pub fallbacks: Vec, + /// Run-level default values for typed model controls + /// (`reasoning_effort`, `speed`). Node and style attributes still win + /// over these defaults. + #[serde(default)] + pub controls: RunModelControls, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct RunModelControls { + pub reasoning_effort: Option, + pub speed: Option, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]