diff --git a/.ai/prompts/code-review-deep-1.md b/.ai/prompts/code-review-deep-1.md index 1d837cfe7..c15a499b6 100644 --- a/.ai/prompts/code-review-deep-1.md +++ b/.ai/prompts/code-review-deep-1.md @@ -17,7 +17,7 @@ Output a report with all the bugs using this format: title of bug brief description of bug - lib/crates/fabro-cli/src/commands/resume.rs + lib/apps/fabro-cli/src/commands/resume.rs 115 115 diff --git a/.ai/prompts/code-review-deep-2.md b/.ai/prompts/code-review-deep-2.md index 6bc81586b..aa7000436 100644 --- a/.ai/prompts/code-review-deep-2.md +++ b/.ai/prompts/code-review-deep-2.md @@ -19,7 +19,7 @@ Output a report with all the bugs using this format: important OR nit yes OR no - lib/crates/fabro-cli/src/commands/resume.rs + lib/apps/fabro-cli/src/commands/resume.rs 115 115 @@ -51,7 +51,7 @@ Here is a real-world example: important no - lib/crates/fabro-cli/src/commands/resume.rs + lib/apps/fabro-cli/src/commands/resume.rs 208 208 diff --git a/.claude/skills/docs/references/mapping.md b/.claude/skills/docs/references/mapping.md index 0a7c9e329..0d6a0d3cf 100644 --- a/.claude/skills/docs/references/mapping.md +++ b/.claude/skills/docs/references/mapping.md @@ -4,32 +4,32 @@ Which source files affect which doc pages. Use this as guidance — also apply j | Source | Docs | |--------|------| -| `lib/crates/fabro-cli/src/main.rs`, `lib/crates/fabro-workflow/src/cli/mod.rs`, `lib/crates/fabro-workflow/src/cli/run.rs` | `docs/public/reference/cli.mdx` | -| `lib/crates/fabro-cli/src/cli_config.rs` | `docs/public/reference/cli-configuration.mdx` | -| `lib/crates/fabro-llm/src/cli.rs` | `docs/public/reference/cli.mdx` | -| `lib/crates/fabro-api/src/serve.rs` | `docs/public/reference/cli.mdx` | -| `lib/crates/fabro-workflow/src/parser/*.rs` | `docs/public/reference/dot-language.mdx` | -| `lib/crates/fabro-workflow/src/condition.rs` | `docs/public/reference/dot-language.mdx` | -| `lib/crates/fabro-workflow/src/cli/validate.rs` | `docs/public/reference/dot-language.mdx` | -| `lib/crates/fabro-workflow/src/stylesheet.rs` | `docs/public/workflows/stylesheets.mdx` | -| `lib/crates/fabro-workflow/src/transform.rs` | `docs/public/workflows/variables.mdx` | -| `lib/crates/fabro-workflow/src/handler/*.rs` | `docs/public/workflows/stages-and-nodes.mdx`, `docs/public/reference/dot-language.mdx` | -| `lib/crates/fabro-workflow/src/handler/human.rs` | `docs/public/workflows/human-in-the-loop.mdx` | -| `lib/crates/fabro-workflow/src/cli/run_config.rs` | `docs/public/execution/run-configuration.mdx` | -| `lib/crates/fabro-workflow/src/engine.rs` | `docs/public/core-concepts/how-arc-works.mdx` | -| `lib/crates/fabro-workflow/src/context/*.rs` | `docs/public/execution/context.mdx` | -| `lib/crates/fabro-workflow/src/checkpoint.rs` | `docs/public/execution/checkpoints.mdx` | -| `lib/crates/fabro-workflow/src/retro.rs`, `lib/crates/fabro-workflow/src/retro_agent.rs` | `docs/public/execution/retros.mdx` | -| `lib/crates/fabro-workflow/src/interviewer/*.rs` | `docs/public/execution/interviews.mdx` | -| `lib/crates/fabro-workflow/src/hook/*.rs` | `docs/public/agents/hooks.mdx` | -| `lib/crates/fabro-workflow/src/daytona_sandbox.rs` | `docs/public/integrations/daytona.mdx`, `docs/public/execution/environments.mdx` | -| `lib/crates/fabro-agent/src/tools.rs`, `lib/crates/fabro-agent/src/tool_registry.rs`, `lib/crates/fabro-agent/src/tool_execution.rs` | `docs/public/agents/tools.mdx` | -| `lib/crates/fabro-agent/src/v4a_patch.rs` | `docs/public/agents/tools.mdx` | -| `lib/crates/fabro-agent/src/cli.rs` | `docs/public/agents/permissions.mdx` | -| `lib/crates/fabro-agent/src/subagent.rs` | `docs/public/agents/subagents.mdx` | -| `lib/crates/fabro-agent/src/mcp_integration.rs` | `docs/public/agents/mcp.mdx` | -| `lib/crates/fabro-llm/src/catalog.rs`, `lib/crates/fabro-llm/src/providers/*.rs` | `docs/public/core-concepts/models.mdx` | -| `lib/crates/fabro-slack/src/*.rs` | `docs/public/integrations/slack.mdx` | -| `lib/crates/fabro-mcp/src/*.rs` | `docs/public/agents/mcp.mdx` | -| `lib/crates/fabro-api/src/*.rs` | `docs/public/api-reference/overview.mdx`, `docs/public/api-reference/demo-mode.mdx` | -| `lib/crates/fabro-api/src/server_config.rs` | `docs/public/administration/server-configuration.mdx` | +| `lib/apps/fabro-cli/src/main.rs`, `lib/components/fabro-workflow/src/cli/mod.rs`, `lib/components/fabro-workflow/src/cli/run.rs` | `docs/public/reference/cli.mdx` | +| `lib/apps/fabro-cli/src/cli_config.rs` | `docs/public/reference/cli-configuration.mdx` | +| `lib/components/fabro-llm/src/cli.rs` | `docs/public/reference/cli.mdx` | +| `lib/foundation/fabro-api/src/serve.rs` | `docs/public/reference/cli.mdx` | +| `lib/components/fabro-workflow/src/parser/*.rs` | `docs/public/reference/dot-language.mdx` | +| `lib/components/fabro-workflow/src/condition.rs` | `docs/public/reference/dot-language.mdx` | +| `lib/components/fabro-workflow/src/cli/validate.rs` | `docs/public/reference/dot-language.mdx` | +| `lib/components/fabro-workflow/src/stylesheet.rs` | `docs/public/workflows/stylesheets.mdx` | +| `lib/components/fabro-workflow/src/transform.rs` | `docs/public/workflows/variables.mdx` | +| `lib/components/fabro-workflow/src/handler/*.rs` | `docs/public/workflows/stages-and-nodes.mdx`, `docs/public/reference/dot-language.mdx` | +| `lib/components/fabro-workflow/src/handler/human.rs` | `docs/public/workflows/human-in-the-loop.mdx` | +| `lib/components/fabro-workflow/src/cli/run_config.rs` | `docs/public/execution/run-configuration.mdx` | +| `lib/components/fabro-workflow/src/engine.rs` | `docs/public/core-concepts/how-arc-works.mdx` | +| `lib/components/fabro-workflow/src/context/*.rs` | `docs/public/execution/context.mdx` | +| `lib/components/fabro-workflow/src/checkpoint.rs` | `docs/public/execution/checkpoints.mdx` | +| `lib/components/fabro-workflow/src/retro.rs`, `lib/components/fabro-workflow/src/retro_agent.rs` | `docs/public/execution/retros.mdx` | +| `lib/components/fabro-workflow/src/interviewer/*.rs` | `docs/public/execution/interviews.mdx` | +| `lib/components/fabro-workflow/src/hook/*.rs` | `docs/public/agents/hooks.mdx` | +| `lib/components/fabro-workflow/src/daytona_sandbox.rs` | `docs/public/integrations/daytona.mdx`, `docs/public/execution/environments.mdx` | +| `lib/components/fabro-agent/src/tools.rs`, `lib/components/fabro-agent/src/tool_registry.rs`, `lib/components/fabro-agent/src/tool_execution.rs` | `docs/public/agents/tools.mdx` | +| `lib/components/fabro-agent/src/v4a_patch.rs` | `docs/public/agents/tools.mdx` | +| `lib/components/fabro-agent/src/cli.rs` | `docs/public/agents/permissions.mdx` | +| `lib/components/fabro-agent/src/subagent.rs` | `docs/public/agents/subagents.mdx` | +| `lib/components/fabro-agent/src/mcp_integration.rs` | `docs/public/agents/mcp.mdx` | +| `lib/components/fabro-llm/src/catalog.rs`, `lib/components/fabro-llm/src/providers/*.rs` | `docs/public/core-concepts/models.mdx` | +| `lib/components/fabro-slack/src/*.rs` | `docs/public/integrations/slack.mdx` | +| `lib/components/fabro-mcp/src/*.rs` | `docs/public/agents/mcp.mdx` | +| `lib/foundation/fabro-api/src/*.rs` | `docs/public/api-reference/overview.mdx`, `docs/public/api-reference/demo-mode.mdx` | +| `lib/foundation/fabro-api/src/server_config.rs` | `docs/public/administration/server-configuration.mdx` | diff --git a/.fabro/workflows/implement-plan/workflow.fabro b/.fabro/workflows/implement-plan/workflow.fabro index 8c7ad4256..e2a59dcae 100644 --- a/.fabro/workflows/implement-plan/workflow.fabro +++ b/.fabro/workflows/implement-plan/workflow.fabro @@ -12,7 +12,7 @@ digraph ImplementPlan { implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="openai/gpt-5.6-sol", provider="openrouter", reasoning_effort="xhigh"] simplify_fable [label="Simplify (Claude Fable 5)", prompt="@prompts/simplify.md", model="anthropic/claude-fable-5", provider="openrouter", reasoning_effort="xhigh"] simplify_sol [label="Simplify (GPT-5.6 Sol)", prompt="@prompts/simplify.md", model="openai/gpt-5.6-sol", provider="openrouter", reasoning_effort="max"] - verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"] + verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", timeout="20m", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", model="anthropic/claude-fable-5", provider="openrouter", reasoning_effort="xhigh", max_visits=3] start -> toolchain diff --git a/.gitattributes b/.gitattributes index 068cf7f02..96454cdd1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ -lib/crates/fabro-spa/assets/** linguist-generated=true -diff +lib/apps/fabro-spa/assets/** linguist-generated=true -diff lib/packages/fabro-api-client/src/** linguist-generated=true diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1580d81df..12ba1b9cc 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -4,7 +4,9 @@ on: push: branches: [main] paths: - - "lib/crates/**" + - "lib/apps/**" + - "lib/components/**" + - "lib/foundation/**" - "test/**" - "Cargo.toml" - "Cargo.lock" @@ -18,7 +20,9 @@ on: pull_request: branches: [main] paths: - - "lib/crates/**" + - "lib/apps/**" + - "lib/components/**" + - "lib/foundation/**" - "test/**" - "Cargo.toml" - "Cargo.lock" @@ -76,7 +80,7 @@ jobs: - name: Verify legacy auth identity removal run: | if git grep -nE 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*"disabled"' \ - -- lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml; then + -- lib/apps lib/components lib/foundation apps lib/packages docs/public/api-reference/fabro-api.yaml; then echo "::error::Legacy auth identities remain in the repository" exit 1 else diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index e2f63a047..064c38c3d 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -5,7 +5,7 @@ on: branches: [main] paths: - "apps/**" - - "lib/crates/fabro-spa/**" + - "lib/apps/fabro-spa/**" - "lib/packages/**" - "package.json" - "bun.lock" @@ -17,7 +17,7 @@ on: branches: [main] paths: - "apps/**" - - "lib/crates/fabro-spa/**" + - "lib/apps/fabro-spa/**" - "lib/packages/**" - "package.json" - "bun.lock" diff --git a/.gitignore b/.gitignore index 5c49b62a9..d9b235fc2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,8 @@ target node_modules apps/fabro-web/dist apps/fabro-web/.dist-builds/ -lib/crates/fabro-spa/assets/* -!lib/crates/fabro-spa/assets/.gitkeep +lib/apps/fabro-spa/assets/* +!lib/apps/fabro-spa/assets/.gitkeep tmp evals/swe-bench/repos/ evals/swe-bench/results/ diff --git a/AGENTS.md b/AGENTS.md index d77efe095..bdc9ab53f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ The OpenAPI spec at `docs/public/api-reference/fabro-api.yaml` is the source of 1. Edit `docs/public/api-reference/fabro-api.yaml` 2. `cargo build -p fabro-api` — build.rs regenerates Rust types and client via progenitor -3. Write/update handler in `lib/crates/fabro-server/src/server.rs`, add route to `build_router()` +3. Write/update handler in `lib/apps/fabro-server/src/server.rs`, add route to `build_router()` 4. `cargo nextest run -p fabro-server` — conformance test catches spec/router drift 5. `cd lib/packages/fabro-api-client && bun run generate` — regenerates TypeScript Axios client @@ -64,7 +64,7 @@ The OpenAPI spec at `docs/public/api-reference/fabro-api.yaml` is the source of - Treat OpenAPI as the source of truth for the wire contract, not as the automatic owner of Rust types. - Before adding or keeping a generated schema type, search the workspace for an existing hand-written Rust type with the same product meaning. -- If the schema and an existing Rust type have the same semantics and serde shape, reuse the existing type via `lib/crates/fabro-api/build.rs` `with_replacement(...)` instead of generating a parallel API type. +- If the schema and an existing Rust type have the same semantics and serde shape, reuse the existing type via `lib/foundation/fabro-api/build.rs` `with_replacement(...)` instead of generating a parallel API type. - If two types are close but not identical, prefer proposing changes that align them into one canonical type rather than accepting small drift. It is usually better to iterate the API now than to create permanently split Rust/API types. - Keep a separate API DTO only when the API is intentionally a projection, summary, or presentation-specific view of internal state. In that case, give it a distinct API-facing name instead of reusing the internal concept name. - Treat `ApiFoo` aliases and `foo_to_api` / `foo_from_api` adapters as a smell unless they represent a real semantic boundary. They should not exist only to bridge accidental duplicate types. @@ -107,7 +107,7 @@ Before merging changes that add or move shared test helpers, verify: Fabro is an AI-powered workflow orchestration platform. Workflows are defined as Graphviz graphs, where each node is a stage (agent, prompt, command, conditional, human, parallel, etc.) executed by the workflow engine. -### Rust crates (`lib/crates/`) +### Rust crates (`lib/apps/`, `lib/components/`, and `lib/foundation/`) - **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune` - **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, and human-in-the-loop interactions - **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments diff --git a/Cargo.lock b/Cargo.lock index 098d71cbf..5e3fb490a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2239,7 +2239,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -2300,7 +2300,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "chrono", "fabro-automation", @@ -2323,7 +2323,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -2348,7 +2348,7 @@ dependencies = [ [[package]] name = "fabro-automation" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -2367,11 +2367,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" [[package]] name = "fabro-checkpoint" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "chrono", "fabro-config", @@ -2387,7 +2387,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "assert_cmd", @@ -2489,7 +2489,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "bytes", @@ -2518,7 +2518,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -2547,7 +2547,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "async-trait", "fabro-types", @@ -2562,7 +2562,7 @@ dependencies = [ [[package]] name = "fabro-db" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -2574,7 +2574,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "assert_cmd", @@ -2593,7 +2593,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "bytes", @@ -2607,7 +2607,7 @@ dependencies = [ [[package]] name = "fabro-environment" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -2629,7 +2629,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "base64", @@ -2651,7 +2651,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "fabro-types", @@ -2659,13 +2659,14 @@ dependencies = [ "nom", "regex", "serde", + "serde_json", "strum 0.28.0", "thiserror 2.0.18", ] [[package]] name = "fabro-hooks" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "async-trait", "fabro-agent", @@ -2688,7 +2689,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "fabro-static", "http 1.4.0", @@ -2698,7 +2699,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "base64", @@ -2717,7 +2718,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "async-trait", "dialoguer", @@ -2732,7 +2733,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -2773,7 +2774,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "clap", "fabro-options-metadata", @@ -2784,7 +2785,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "fabro-api", @@ -2802,7 +2803,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "axum", @@ -2822,7 +2823,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -2849,7 +2850,7 @@ dependencies = [ [[package]] name = "fabro-mcp-store" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "chrono", "fabro-db", @@ -2867,7 +2868,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "fabro-static", "http 1.4.0", @@ -2883,7 +2884,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "axum", @@ -2905,7 +2906,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "serde", "serde_json", @@ -2913,7 +2914,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "cc", "libc", @@ -2922,7 +2923,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "aho-corasick", "ref-cast", @@ -2938,7 +2939,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -2983,7 +2984,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -3075,7 +3076,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "fabro-http", "fabro-interview", @@ -3097,18 +3098,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" [[package]] name = "fabro-store" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "async-trait", "bytes", @@ -3138,7 +3139,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "base64", @@ -3164,7 +3165,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "fabro-types", @@ -3178,7 +3179,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "assert_cmd", @@ -3203,7 +3204,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -3224,7 +3225,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -3238,7 +3239,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "chrono", "clap", @@ -3260,7 +3261,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "console 0.15.11", @@ -3281,7 +3282,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "fabro-acp", "fabro-graphviz", @@ -3294,7 +3295,7 @@ dependencies = [ [[package]] name = "fabro-variable" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -3311,7 +3312,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -3330,7 +3331,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "assert_cmd", @@ -3368,6 +3369,7 @@ dependencies = [ "fabro-util", "fabro-validate", "fabro-vault", + "fabro-workflow", "futures", "git2", "hex", @@ -8493,7 +8495,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "axum", "base64", @@ -8512,7 +8514,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 5bd216ed8..e9f63c9d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,17 @@ [workspace] -members = ["lib/crates/*", "test/twin/openai", "test/twin/github"] -default-members = ["lib/crates/fabro-cli"] +members = [ + "lib/apps/*", + "lib/components/*", + "lib/foundation/*", + "test/twin/openai", + "test/twin/github", +] +default-members = ["lib/apps/fabro-cli"] resolver = "2" [workspace.package] edition = "2021" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.4" license = "MIT" [workspace.dependencies] @@ -84,7 +90,7 @@ hmac = "0.12" sha2 = "0.10" hex = "0.4" insta = "1" -fabro-test = { path = "lib/crates/fabro-test" } +fabro-test = { path = "lib/foundation/fabro-test" } twin-openai = { path = "test/twin/openai" } twin-github = { path = "test/twin/github" } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } @@ -100,11 +106,11 @@ rust-embed = "8" percent-encoding = "2" minijinja = "=2.19.0" miette = { version = "7.6", features = ["fancy"] } -fabro-http = { path = "lib/crates/fabro-http" } -fabro-environment = { path = "lib/crates/fabro-environment" } -fabro-options-metadata = { path = "lib/crates/fabro-options-metadata" } -fabro-redact = { path = "lib/crates/fabro-redact" } -fabro-static = { path = "lib/crates/fabro-static" } +fabro-http = { path = "lib/foundation/fabro-http" } +fabro-environment = { path = "lib/components/fabro-environment" } +fabro-options-metadata = { path = "lib/foundation/fabro-options-metadata" } +fabro-redact = { path = "lib/foundation/fabro-redact" } +fabro-static = { path = "lib/foundation/fabro-static" } graphviz-sys = { git = "https://github.com/fabro-sh/graphviz-sys" } ref-cast = "1" strum = { version = "0.28", features = ["derive"] } diff --git a/apps/fabro-web/app/lib/model-offerings.test.ts b/apps/fabro-web/app/lib/model-offerings.test.ts new file mode 100644 index 000000000..a54a1b0e8 --- /dev/null +++ b/apps/fabro-web/app/lib/model-offerings.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { + modelOfferingKey, + modelOfferingTestArgs, +} from "./model-offerings"; + +describe("model offering identity", () => { + const openai = { id: "portable-model", provider: "openai" }; + const openrouter = { id: "portable-model", provider: "openrouter" }; + + test("keeps duplicate model IDs in independent row state", () => { + expect(modelOfferingKey(openai)).not.toBe(modelOfferingKey(openrouter)); + + const state = new Map([ + [modelOfferingKey(openai), "ok"], + [modelOfferingKey(openrouter), "error"], + ]); + expect(state.get(modelOfferingKey(openai))).toBe("ok"); + expect(state.get(modelOfferingKey(openrouter))).toBe("error"); + }); + + test("includes the row provider in model-test request arguments", () => { + expect(modelOfferingTestArgs(openai)).toEqual([ + "portable-model", + "openai", + ]); + expect(modelOfferingTestArgs(openrouter)).toEqual([ + "portable-model", + "openrouter", + ]); + }); +}); diff --git a/apps/fabro-web/app/lib/model-offerings.ts b/apps/fabro-web/app/lib/model-offerings.ts new file mode 100644 index 000000000..e2bc94d2d --- /dev/null +++ b/apps/fabro-web/app/lib/model-offerings.ts @@ -0,0 +1,13 @@ +import type { Model } from "@qltysh/fabro-api-client"; + +type ModelOfferingIdentity = Pick; + +export function modelOfferingKey(model: ModelOfferingIdentity): string { + return `${model.provider}\u0000${model.id}`; +} + +export function modelOfferingTestArgs( + model: ModelOfferingIdentity, +): [id: string, provider: string] { + return [model.id, model.provider]; +} diff --git a/apps/fabro-web/app/routes/run-overview.tsx b/apps/fabro-web/app/routes/run-overview.tsx index 25262b748..0150d19ea 100644 --- a/apps/fabro-web/app/routes/run-overview.tsx +++ b/apps/fabro-web/app/routes/run-overview.tsx @@ -23,7 +23,7 @@ export const handle = { wide: true, fullHeight: true }; type Direction = "LR" | "TB"; -// Mirrors fabro-graphviz's RANKDIR_RE (lib/crates/fabro-graphviz/src/render.rs) — +// Mirrors fabro-graphviz's RANKDIR_RE (lib/components/fabro-graphviz/src/render.rs) — // keep the accepted `rankdir=` syntax in sync with that regex. const RANKDIR_RE = /rankdir\s*=\s*(\w+)/; diff --git a/apps/fabro-web/app/routes/settings-models.tsx b/apps/fabro-web/app/routes/settings-models.tsx index 57b7429ed..f6e3905df 100644 --- a/apps/fabro-web/app/routes/settings-models.tsx +++ b/apps/fabro-web/app/routes/settings-models.tsx @@ -28,6 +28,10 @@ import { } from "../components/runs-list/sort-header"; import { Tooltip } from "../components/ui"; import { formatContextWindow, formatTokensPerSecond } from "../lib/format"; +import { + modelOfferingKey, + modelOfferingTestArgs, +} from "../lib/model-offerings"; import { useDebouncedValue } from "../hooks/effects"; export function meta() { @@ -277,30 +281,46 @@ function ModelsSection({ providers }: { providers: Provider[] }) { const runSweep = useCallback(async () => { if (running) return; - const ids = rows.map((r) => r.id); - if (ids.length === 0) return; + const offerings = rows.map((model) => ({ + id: model.id, + provider: model.provider, + key: modelOfferingKey(model), + })); + if (offerings.length === 0) return; const seed = new Map(); - for (const id of ids) seed.set(id, { phase: "queued" }); + for (const offering of offerings) { + seed.set(offering.key, { phase: "queued" }); + } setResults(seed); - setSweep({ done: 0, total: ids.length, ok: 0, failed: 0 }); + setSweep({ done: 0, total: offerings.length, ok: 0, failed: 0 }); let cursor = 0; const worker = async () => { - while (cursor < ids.length) { + while (cursor < offerings.length) { const i = cursor; cursor += 1; - const id = ids[i]; + const offering = offerings[i]; setResults((prev) => { const next = new Map(prev); - next.set(id, { phase: "running" }); + next.set(offering.key, { phase: "running" }); return next; }); let outcome: RowState; try { - const result = await apiData(() => modelsApi.testModel(id)); - if (result.status === "ok") { + const result = await apiData(() => + modelsApi.testModel(...modelOfferingTestArgs(offering)), + ); + if ( + result.provider !== offering.provider || + result.model_id !== offering.id + ) { + outcome = { + phase: "error", + message: `Server tested unexpected offering ${result.provider}/${result.model_id}`, + }; + } else if (result.status === "ok") { outcome = { phase: "ok" }; } else if (result.status === "error") { outcome = { @@ -319,7 +339,7 @@ function ModelsSection({ providers }: { providers: Provider[] }) { setResults((prev) => { const next = new Map(prev); - next.set(id, outcome); + next.set(offering.key, outcome); return next; }); setSweep((prev) => @@ -336,7 +356,7 @@ function ModelsSection({ providers }: { providers: Provider[] }) { }; await Promise.all( - Array.from({ length: Math.min(TEST_CONCURRENCY, ids.length) }, () => + Array.from({ length: Math.min(TEST_CONCURRENCY, offerings.length) }, () => worker(), ), ); @@ -435,12 +455,12 @@ function ModelsSection({ providers }: { providers: Provider[] }) { {rows.map((model) => ( ))} @@ -468,7 +488,7 @@ function ModelTableRow({ state: RowState | undefined; }) { return ( - + {providerLabel} diff --git a/docs/internal/cli-workflow-coupling-audit.md b/docs/internal/cli-workflow-coupling-audit.md index 7fdf953ba..198e658f3 100644 --- a/docs/internal/cli-workflow-coupling-audit.md +++ b/docs/internal/cli-workflow-coupling-audit.md @@ -2,7 +2,7 @@ ## Scope -- Production imports under `lib/crates/fabro-cli/src/**` that still reference `fabro_workflow::*` after the server-owned selector/export refactor. +- Production imports under `lib/apps/fabro-cli/src/**` that still reference `fabro_workflow::*` after the server-owned selector/export refactor. - Test-only imports are listed separately so the remaining architectural debt is explicit. ## Completed In This Change @@ -16,30 +16,30 @@ | Path | Direct dependency | Why it still exists | Required remediation track | | --- | --- | --- | --- | -| `lib/crates/fabro-cli/src/commands/pr/create.rs` | `StageOutcome`, `pull_request::maybe_open_pull_request` | CLI still reconstructs store state and runs PR creation logic from the workflow pipeline directly. | Replace with a server API, or extract PR orchestration into a non-engine shared service crate plus API. | -| `lib/crates/fabro-cli/src/commands/run/runner.rs` | `artifact_snapshot::CapturedArtifactInfo`, `artifact_upload::{ArtifactSink, StageArtifactUploader}`, `event::{Emitter, RunEventSink}`, `operations::{self, StartServices}`, `run_control::RunControlState`, `runtime_store::{RunStoreBackend, RunStoreHandle}` | Hidden worker subprocess path still lives inside the CLI crate and embeds the workflow engine directly. | Re-home worker/runtime code outside the user CLI surface, ideally into a dedicated worker crate or binary. | -| `lib/crates/fabro-cli/src/manifest_builder.rs` | `git::{GitSyncStatus, head_sha, sync_status}` | Manifest submission still relies on git helper logic that happens to live in `fabro_workflow`. | Extract git-sync inspection helpers into a non-workflow shared crate/module. | -| `lib/crates/fabro-cli/src/server_client.rs` | `artifact_snapshot::CapturedArtifactInfo` | The upload client reuses a workflow-owned artifact snapshot DTO. | Extract shared artifact snapshot DTOs into `fabro-store`, `fabro-types`, or a dedicated shared crate. | -| `lib/crates/fabro-cli/src/commands/runs/inspect.rs` | `run_status::RunStatus` | CLI output types still depend on engine-owned run status enums. | Extract shared status types into `fabro-types` or switch to API-generated/public store types. | -| `lib/crates/fabro-cli/src/commands/runs/list.rs` | `run_status::RunStatus` | List rendering still depends on engine-owned run status enums. | Extract shared status types into `fabro-types` or switch to API-generated/public store types. | -| `lib/crates/fabro-cli/src/commands/run/attach.rs` | `StageOutcome`, `run_status::RunStatus` | Attach/replay logic still formats engine-owned terminal status types directly. | Extract shared run/conclusion status types into `fabro-types`. | -| `lib/crates/fabro-cli/src/commands/run/output.rs` | `StageOutcome`, `records::Conclusion` | Human-readable completion output still consumes workflow-owned conclusion/status records. | Extract shared conclusion/status DTOs into `fabro-types` or `fabro-store`. | -| `lib/crates/fabro-cli/src/commands/run/wait.rs` | `records::Conclusion`, `run_status::RunStatus` | Wait output still depends on workflow-owned status/conclusion records. | Extract shared conclusion/status DTOs into `fabro-types` or `fabro-store`. | -| `lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs` | `StageOutcome`, `format_cost` | Progress UI still depends on shared stage outcome and workflow-owned cost-formatting helper code. | Move formatting helpers into `fabro-util`. | -| `lib/crates/fabro-cli/src/commands/run/run_progress/info_display.rs` | `event::RunNoticeLevel` | Progress UI still formats workflow-owned notice levels directly. | Extract shared notice/event enums into `fabro-types`. | -| `lib/crates/fabro-cli/src/commands/run/run_progress/event.rs` | `event::RunNoticeLevel` | Progress event translation still depends on workflow-owned notice levels. | Extract shared notice/event enums into `fabro-types`. | +| `lib/apps/fabro-cli/src/commands/pr/create.rs` | `StageOutcome`, `pull_request::maybe_open_pull_request` | CLI still reconstructs store state and runs PR creation logic from the workflow pipeline directly. | Replace with a server API, or extract PR orchestration into a non-engine shared service crate plus API. | +| `lib/apps/fabro-cli/src/commands/run/runner.rs` | `artifact_snapshot::CapturedArtifactInfo`, `artifact_upload::{ArtifactSink, StageArtifactUploader}`, `event::{Emitter, RunEventSink}`, `operations::{self, StartServices}`, `run_control::RunControlState`, `runtime_store::{RunStoreBackend, RunStoreHandle}` | Hidden worker subprocess path still lives inside the CLI crate and embeds the workflow engine directly. | Re-home worker/runtime code outside the user CLI surface, ideally into a dedicated worker crate or binary. | +| `lib/apps/fabro-cli/src/manifest_builder.rs` | `git::{GitSyncStatus, head_sha, sync_status}` | Manifest submission still relies on git helper logic that happens to live in `fabro_workflow`. | Extract git-sync inspection helpers into a non-workflow shared crate/module. | +| `lib/apps/fabro-cli/src/server_client.rs` | `artifact_snapshot::CapturedArtifactInfo` | The upload client reuses a workflow-owned artifact snapshot DTO. | Extract shared artifact snapshot DTOs into `fabro-store`, `fabro-types`, or a dedicated shared crate. | +| `lib/apps/fabro-cli/src/commands/runs/inspect.rs` | `run_status::RunStatus` | CLI output types still depend on engine-owned run status enums. | Extract shared status types into `fabro-types` or switch to API-generated/public store types. | +| `lib/apps/fabro-cli/src/commands/runs/list.rs` | `run_status::RunStatus` | List rendering still depends on engine-owned run status enums. | Extract shared status types into `fabro-types` or switch to API-generated/public store types. | +| `lib/apps/fabro-cli/src/commands/run/attach.rs` | `StageOutcome`, `run_status::RunStatus` | Attach/replay logic still formats engine-owned terminal status types directly. | Extract shared run/conclusion status types into `fabro-types`. | +| `lib/apps/fabro-cli/src/commands/run/output.rs` | `StageOutcome`, `records::Conclusion` | Human-readable completion output still consumes workflow-owned conclusion/status records. | Extract shared conclusion/status DTOs into `fabro-types` or `fabro-store`. | +| `lib/apps/fabro-cli/src/commands/run/wait.rs` | `records::Conclusion`, `run_status::RunStatus` | Wait output still depends on workflow-owned status/conclusion records. | Extract shared conclusion/status DTOs into `fabro-types` or `fabro-store`. | +| `lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs` | `StageOutcome`, `format_cost` | Progress UI still depends on shared stage outcome and workflow-owned cost-formatting helper code. | Move formatting helpers into `fabro-util`. | +| `lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs` | `event::RunNoticeLevel` | Progress UI still formats workflow-owned notice levels directly. | Extract shared notice/event enums into `fabro-types`. | +| `lib/apps/fabro-cli/src/commands/run/run_progress/event.rs` | `event::RunNoticeLevel` | Progress event translation still depends on workflow-owned notice levels. | Extract shared notice/event enums into `fabro-types`. | ## Test-Only Couplings | Path | Direct dependency | Why it still exists | Suggested handling | | --- | --- | --- | --- | -| `lib/crates/fabro-cli/src/commands/dump.rs` test module | `event::{Event, append_event}` | Unit tests synthesize workflow events directly. | Low priority; keep until a lighter-weight event fixture helper exists. | -| `lib/crates/fabro-cli/src/commands/run/wait.rs` test module | `StageOutcome`, `records::Conclusion`, `run_status::RunStatusRecord` | Output tests construct workflow-owned records directly. | Replace with shared fixture builders once conclusion DTOs move out. | -| `lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs` test module | `event::{Event, RunNoticeLevel, to_run_event, to_run_event_at}`, `outcome::billed_model_usage_from_llm` | Progress tests build engine events directly. | Replace with shared event fixture helpers after event DTO extraction. | -| `lib/crates/fabro-cli/src/commands/run/run_progress/event.rs` test module | `event::{Event, to_run_event}` | Event rendering tests depend on engine event constructors. | Replace with shared event fixture helpers after event DTO extraction. | -| `lib/crates/fabro-cli/src/commands/run/runner.rs` test module | `artifact_upload::StageArtifactUploader` | Worker tests still reach into workflow upload internals. | Keep with worker re-home work; not worth separating first. | -| `lib/crates/fabro-cli/tests/it/workflow/real_cli.rs` | `context::Context`, `event::Emitter`, `handler::agent::{CodergenBackend, CodergenResult}`, `handler::llm::cli::AgentCliBackend` | Integration test exercises the real workflow engine directly through CLI harnesses. | Accept as engine integration coverage or move under workflow-owned test support later. | -| `lib/crates/fabro-cli/tests/it/scenario/recovery.rs` | `operations::{RunTimeline, build_timeline}` | Scenario test inspects rewind timeline internals directly. | Replace after server-owned rewind/timeline APIs exist. | +| `lib/apps/fabro-cli/src/commands/dump.rs` test module | `event::{Event, append_event}` | Unit tests synthesize workflow events directly. | Low priority; keep until a lighter-weight event fixture helper exists. | +| `lib/apps/fabro-cli/src/commands/run/wait.rs` test module | `StageOutcome`, `records::Conclusion`, `run_status::RunStatusRecord` | Output tests construct workflow-owned records directly. | Replace with shared fixture builders once conclusion DTOs move out. | +| `lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs` test module | `event::{Event, RunNoticeLevel, to_run_event, to_run_event_at}`, `outcome::billed_model_usage_from_llm` | Progress tests build engine events directly. | Replace with shared event fixture helpers after event DTO extraction. | +| `lib/apps/fabro-cli/src/commands/run/run_progress/event.rs` test module | `event::{Event, to_run_event}` | Event rendering tests depend on engine event constructors. | Replace with shared event fixture helpers after event DTO extraction. | +| `lib/apps/fabro-cli/src/commands/run/runner.rs` test module | `artifact_upload::StageArtifactUploader` | Worker tests still reach into workflow upload internals. | Keep with worker re-home work; not worth separating first. | +| `lib/apps/fabro-cli/tests/it/workflow/real_cli.rs` | `context::Context`, `event::Emitter`, `handler::agent::{CodergenBackend, CodergenResult}`, `handler::llm::cli::AgentCliBackend` | Integration test exercises the real workflow engine directly through CLI harnesses. | Accept as engine integration coverage or move under workflow-owned test support later. | +| `lib/apps/fabro-cli/tests/it/scenario/recovery.rs` | `operations::{RunTimeline, build_timeline}` | Scenario test inspects rewind timeline internals directly. | Replace after server-owned rewind/timeline APIs exist. | ## Follow-Up Order diff --git a/docs/internal/event-schema-competitive-analysis.md b/docs/internal/event-schema-competitive-analysis.md index 9763fdf30..60a1c02ab 100644 --- a/docs/internal/event-schema-competitive-analysis.md +++ b/docs/internal/event-schema-competitive-analysis.md @@ -48,9 +48,9 @@ That envelope is stronger than most comparator systems. It gives Fabro stable to Relevant current Fabro sources: - `docs-internal/events-strategy.md` -- `lib/crates/fabro-workflow/src/event.rs` -- `lib/crates/fabro-types/src/run_event/mod.rs` -- `lib/crates/fabro-agent/src/types.rs` +- `lib/components/fabro-workflow/src/event.rs` +- `lib/foundation/fabro-types/src/run_event/mod.rs` +- `lib/components/fabro-agent/src/types.rs` ## Comparison Matrix diff --git a/docs/internal/events.md b/docs/internal/events.md index 53d10e1e4..eea2d6878 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -1215,26 +1215,6 @@ Emitted when the agent detects a tool-use loop. No properties. -### `agent.turn.limit` - -Emitted when the agent reaches its maximum turn count. - -```json -{ - "id": "...", "ts": "...", "run_id": "...", - "event": "agent.turn.limit", - "node_id": "code", "node_label": "code", - "session_id": "ses_abc", - "properties": { - "max_turns": 25 - } -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `max_turns` | number | Maximum turns allowed | - ### `agent.skill.expanded` ```json diff --git a/docs/internal/fabro-event-schema-v2-concrete-shape.md b/docs/internal/fabro-event-schema-v2-concrete-shape.md index 4d8541b90..78134f619 100644 --- a/docs/internal/fabro-event-schema-v2-concrete-shape.md +++ b/docs/internal/fabro-event-schema-v2-concrete-shape.md @@ -269,21 +269,21 @@ The durable model remains simple: replay ordered events, no duplicate truth laye An engineer implementing this proposal should make only these structural changes unless a later section explicitly says otherwise. -1. Update [`RunEvent`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) to add: +1. Update [`RunEvent`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/mod.rs) to add: - `stage_id` - `parallel_group_id` - `parallel_branch_id` - `tool_call_id` - `actor` -2. Update `RunEvent::to_value()` and `RunEvent` parsing in [`run_event/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) so the new envelope fields serialize and deserialize. -3. Extend `StoredEventFields` and `stored_event_fields()` in [`event.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-workflow/src/event.rs) to populate: +2. Update `RunEvent::to_value()` and `RunEvent` parsing in [`run_event/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/mod.rs) so the new envelope fields serialize and deserialize. +3. Extend `StoredEventFields` and `stored_event_fields()` in [`event.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/components/fabro-workflow/src/event.rs) to populate: - `stage_id` - `parallel_group_id` - `parallel_branch_id` - `tool_call_id` on tool-lifecycle events - `actor` when there is a clear primary actor These values should come from the emitter's current execution context for stage and parallel scope, and from event-specific payloads for `tool_call_id`. -4. Leave [`EventEnvelope`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-store/src/types.rs) structurally unchanged: +4. Leave [`EventEnvelope`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/components/fabro-store/src/types.rs) structurally unchanged: - `seq: u32` - `payload: EventPayload` 5. Update API/SSE envelope serialization so wire JSON is flattened: @@ -304,11 +304,11 @@ An engineer implementing this proposal should make only these structural changes V2 should keep the current hand-coded domain split for prop structs: -- run props in [`run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/run.rs) -- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/stage.rs) -- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/agent.rs) -- infra/setup props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/infra.rs) -- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/misc.rs) +- run props in [`run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/run.rs) +- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/stage.rs) +- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/agent.rs) +- infra/setup props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/infra.rs) +- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/misc.rs) That split is part of the design quality. V2 should keep adding hand-coded prop structs, not collapse everything into generic maps. @@ -374,7 +374,6 @@ V2 keeps the current durable family surface broadly intact. - `agent.error` - `agent.warning` - `agent.loop.detected` -- `agent.turn.limit` - `agent.steering.injected` - `agent.compaction.started` - `agent.compaction.completed` diff --git a/docs/internal/mcp-server-qa-test-plan.md b/docs/internal/mcp-server-qa-test-plan.md index de2fcfc75..c5d7b8e74 100644 --- a/docs/internal/mcp-server-qa-test-plan.md +++ b/docs/internal/mcp-server-qa-test-plan.md @@ -1,6 +1,6 @@ # Fabro MCP Server — QA Test Plan -One-time manual QA pass for the 5 tools exposed by `fabro-mcp-server`. Source of truth: `lib/crates/fabro-mcp-server/src/run_tools/`. +One-time manual QA pass for the 5 tools exposed by `fabro-mcp-server`. Source of truth: `lib/apps/fabro-mcp-server/src/run_tools/`. This plan is **not** a template for adding automated test coverage — it exists to drive a single hands-on sweep against a real running server. Tick boxes as scenarios pass; add notes inline for failures or surprising behavior. Open bugs/PRs for issues found; do not port these scenarios into the Rust test suite. diff --git a/docs/internal/migrations-strategy.md b/docs/internal/migrations-strategy.md index 1aa73370f..318463a58 100644 --- a/docs/internal/migrations-strategy.md +++ b/docs/internal/migrations-strategy.md @@ -9,7 +9,7 @@ Migrations are product-facing compatibility code. Treat them like startup and st Each crate owns the migrations for the data it owns. ```text -lib/crates// +lib/// migrations/ YYYYMMDDSS_descriptive_name.rs src/migrations.rs diff --git a/docs/internal/plan-events-as-source-of-truth-follow-ups.md b/docs/internal/plan-events-as-source-of-truth-follow-ups.md index 650eca25d..1ca2b9fc2 100644 --- a/docs/internal/plan-events-as-source-of-truth-follow-ups.md +++ b/docs/internal/plan-events-as-source-of-truth-follow-ups.md @@ -170,14 +170,14 @@ Required payload fields: Emit from the same places that currently call `put_status`: -- `lib/crates/fabro-workflow/src/operations/create.rs` -- `lib/crates/fabro-workflow/src/operations/start.rs` -- `lib/crates/fabro-workflow/src/operations/resume.rs` -- `lib/crates/fabro-workflow/src/pipeline/finalize.rs` -- `lib/crates/fabro-workflow/src/lifecycle/disk.rs` +- `lib/components/fabro-workflow/src/operations/create.rs` +- `lib/components/fabro-workflow/src/operations/start.rs` +- `lib/components/fabro-workflow/src/operations/resume.rs` +- `lib/components/fabro-workflow/src/pipeline/finalize.rs` +- `lib/components/fabro-workflow/src/lifecycle/disk.rs` - CLI administrative flows that directly mutate status: - - `lib/crates/fabro-cli/src/commands/runs/rm.rs` - - `lib/crates/fabro-cli/src/commands/run/rewind.rs` + - `lib/apps/fabro-cli/src/commands/runs/rm.rs` + - `lib/apps/fabro-cli/src/commands/run/rewind.rs` ### 2. Enrich `checkpoint.completed` to carry a full checkpoint snapshot @@ -200,11 +200,11 @@ Add fields covering: Emitter seam: -- `lib/crates/fabro-workflow/src/lifecycle/event.rs` +- `lib/components/fabro-workflow/src/lifecycle/event.rs` Producer seam for the source checkpoint object: -- `lib/crates/fabro-workflow/src/lifecycle/disk.rs` +- `lib/components/fabro-workflow/src/lifecycle/disk.rs` Design rule: @@ -231,7 +231,7 @@ Add: Producer seam: -- `lib/crates/fabro-workflow/src/pipeline/pull_request.rs` +- `lib/components/fabro-workflow/src/pipeline/pull_request.rs` After this lands, `put_pull_request` should become removable during the later memoized-state cutover. @@ -245,7 +245,7 @@ Do not add a separate storage-shaped event. The final patch is run-level termina Enrich `run.completed`, using the patch already computed from: -- `lib/crates/fabro-workflow/src/lifecycle/git.rs` +- `lib/components/fabro-workflow/src/lifecycle/git.rs` Required payload: @@ -287,9 +287,9 @@ Required projected output: Likely seams: -- `lib/crates/fabro-workflow/src/handler/agent.rs` -- `lib/crates/fabro-workflow/src/handler/llm/api.rs` -- `lib/crates/fabro-workflow/src/pipeline/retro.rs` if retro uses the same forwarded agent session path +- `lib/components/fabro-workflow/src/handler/agent.rs` +- `lib/components/fabro-workflow/src/handler/llm/api.rs` +- `lib/components/fabro-workflow/src/pipeline/retro.rs` if retro uses the same forwarded agent session path - any CLI-backed LLM path if it still produces `provider_used.json` Do not keep the current “read JSON sidecar, then `put_node_provider_used`” pattern once this event exists. @@ -304,7 +304,7 @@ Use the existing terminal parallel event rather than adding a storage-shaped eve Add to `parallel.completed`, emitted from: -- `lib/crates/fabro-workflow/src/handler/parallel.rs` +- `lib/components/fabro-workflow/src/handler/parallel.rs` Required new payload: @@ -346,19 +346,19 @@ Likely files to touch: - `docs-internal/events.md` - `docs-internal/run-directory-keys.md` - `docs-internal/events-strategy.md` -- `lib/crates/fabro-workflow/src/event.rs` -- `lib/crates/fabro-workflow/src/lifecycle/event.rs` -- `lib/crates/fabro-workflow/src/lifecycle/disk.rs` -- `lib/crates/fabro-workflow/src/lifecycle/git.rs` -- `lib/crates/fabro-workflow/src/operations/create.rs` -- `lib/crates/fabro-workflow/src/operations/start.rs` -- `lib/crates/fabro-workflow/src/operations/resume.rs` -- `lib/crates/fabro-workflow/src/pipeline/finalize.rs` -- `lib/crates/fabro-workflow/src/pipeline/pull_request.rs` -- `lib/crates/fabro-workflow/src/handler/agent.rs` -- `lib/crates/fabro-workflow/src/handler/parallel.rs` -- `lib/crates/fabro-cli/src/commands/runs/rm.rs` -- `lib/crates/fabro-cli/src/commands/run/rewind.rs` +- `lib/components/fabro-workflow/src/event.rs` +- `lib/components/fabro-workflow/src/lifecycle/event.rs` +- `lib/components/fabro-workflow/src/lifecycle/disk.rs` +- `lib/components/fabro-workflow/src/lifecycle/git.rs` +- `lib/components/fabro-workflow/src/operations/create.rs` +- `lib/components/fabro-workflow/src/operations/start.rs` +- `lib/components/fabro-workflow/src/operations/resume.rs` +- `lib/components/fabro-workflow/src/pipeline/finalize.rs` +- `lib/components/fabro-workflow/src/pipeline/pull_request.rs` +- `lib/components/fabro-workflow/src/handler/agent.rs` +- `lib/components/fabro-workflow/src/handler/parallel.rs` +- `lib/apps/fabro-cli/src/commands/runs/rm.rs` +- `lib/apps/fabro-cli/src/commands/run/rewind.rs` - tests in `fabro-workflow`, `fabro-cli`, and `fabro-store` ## Phases diff --git a/docs/internal/run-directory-keys.md b/docs/internal/run-directory-keys.md index 91ceeb2a8..1b78fd560 100644 --- a/docs/internal/run-directory-keys.md +++ b/docs/internal/run-directory-keys.md @@ -5,8 +5,8 @@ This document maps the files that still live under a run scratch directory. Dura Scope: - Scratch root: `~/.fabro/scratch/YYYYMMDD-{run_id}/` - This covers local run files only -- Persistent store keys live in `lib/crates/fabro-store/src/keys.rs` -- Artifact object-store keys live in `lib/crates/fabro-store/src/artifact_store.rs` +- Persistent store keys live in `lib/components/fabro-store/src/keys.rs` +- Artifact object-store keys live in `lib/components/fabro-store/src/artifact_store.rs` There is no `_init.json` anymore. Run existence in the database is determined by stored run events, and local scratch directories are managed separately under `scratch/`. diff --git a/docs/internal/slow-test-opportunities-2026-04-07.md b/docs/internal/slow-test-opportunities-2026-04-07.md index 2fe511ff4..790a80c20 100644 --- a/docs/internal/slow-test-opportunities-2026-04-07.md +++ b/docs/internal/slow-test-opportunities-2026-04-07.md @@ -22,7 +22,7 @@ Method: ### [x] 1. Change two slow `exec` mock responses from retriable `500` to non-retriable `400` Files: -- `lib/crates/fabro-cli/tests/it/cmd/exec.rs` +- `lib/apps/fabro-cli/tests/it/cmd/exec.rs` Measured evidence: - `fabro-cli::it::cmd::exec::exec_cli_server_target_overrides_configured_server_target`: `6.858s` median @@ -32,7 +32,7 @@ Measured evidence: - mocked `400`: `0.053s` median Implementation status: -- Implemented in `lib/crates/fabro-cli/tests/it/cmd/exec.rs` +- Implemented in `lib/apps/fabro-cli/tests/it/cmd/exec.rs` - Verified with `ulimit -n 4096` via 5 targeted nextest runs per test - Post-change nextest exec-time medians: - `exec_server_target_uses_remote_transport_instead_of_local_api_key_resolution`: `1.580s` @@ -57,7 +57,7 @@ Cons: ### 2. Short-circuit delete-path worker grace for already-terminal runs Files: -- `lib/crates/fabro-server/src/server.rs` +- `lib/apps/fabro-server/src/server.rs` Measured evidence: - `fabro-cli::it::cmd::system_prune::system_prune_yes_deletes_matching_runs`: `10.477s` @@ -87,11 +87,11 @@ Cons: ### [x] 3. Collapse the five abnormally slow `help` integration tests into one smoke test or a lighter harness Files: -- `lib/crates/fabro-cli/tests/it/cmd/artifact.rs` -- `lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs` -- `lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs` -- `lib/crates/fabro-cli/tests/it/cmd/config.rs` -- `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- `lib/apps/fabro-cli/tests/it/cmd/artifact.rs` +- `lib/apps/fabro-cli/tests/it/cmd/artifact_list.rs` +- `lib/apps/fabro-cli/tests/it/cmd/artifact_cp.rs` +- `lib/apps/fabro-cli/tests/it/cmd/config.rs` +- `lib/apps/fabro-cli/tests/it/cmd/attach.rs` Measured evidence: - Slow `help` tests: @@ -131,8 +131,8 @@ Cons: ### [x] 4. Replace `doctor_no_color_when_no_color_set` with a render-path assertion Files: -- `lib/crates/fabro-cli/tests/it/cmd/doctor.rs` -- `lib/crates/fabro-util/src/check_report.rs` +- `lib/apps/fabro-cli/tests/it/cmd/doctor.rs` +- `lib/foundation/fabro-util/src/check_report.rs` Measured evidence: - `fabro-cli::it::cmd::doctor::doctor_no_color_when_no_color_set`: `5.131s` @@ -144,7 +144,7 @@ Estimated impact: Implementation status: - Implemented by deleting `fabro-cli::it::cmd::doctor::doctor_no_color_when_no_color_set` -- Added a unit-level render assertion in `lib/crates/fabro-cli/src/commands/doctor.rs`: +- Added a unit-level render assertion in `lib/apps/fabro-cli/src/commands/doctor.rs`: - `render_report_text_without_color_has_no_ansi` - Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli render_report_text_without_color_has_no_ansi --status-level fail --final-status-level fail --show-progress none` - Verification result: `1 passed` @@ -166,8 +166,8 @@ Cons: ### [x] 5. Fix local Unix-socket autostart so it doesn't burn the full 5s readiness wait Files: -- `lib/crates/fabro-cli/src/server_client.rs` -- `lib/crates/fabro-cli/tests/it/cmd/server_start.rs` +- `lib/apps/fabro-cli/src/server_client.rs` +- `lib/apps/fabro-cli/tests/it/cmd/server_start.rs` Measured evidence: - Pre-fix 5-run timing for `fabro-cli::it::cmd::server_start::concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up`: @@ -197,7 +197,7 @@ Implementation status: - Implemented by splitting the Unix-socket connection path into: - a single immediate health probe before autostart - the existing retrying readiness wait after autostart -- Kept the original integration test coverage in `lib/crates/fabro-cli/tests/it/cmd/server_start.rs` +- Kept the original integration test coverage in `lib/apps/fabro-cli/tests/it/cmd/server_start.rs` - Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up --status-level fail --final-status-level fail --show-progress none` - Verification result: `1 passed` - Post-change 5-run timing for `fabro-cli::it::cmd::server_start::concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up`: @@ -217,7 +217,7 @@ Implementation status: ### [x] 6. Collapse three lightweight `attach` smoke tests into one scenario-style test Files: -- `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- `lib/apps/fabro-cli/tests/it/cmd/attach.rs` Measured evidence: - `attach_requires_run_arg`: `1.595s` @@ -230,7 +230,7 @@ Estimated impact: - Conservative recoverable time: about `3.15s` Implementation status: -- Implemented by removing the 3 command-owned smoke tests from `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- Implemented by removing the 3 command-owned smoke tests from `lib/apps/fabro-cli/tests/it/cmd/attach.rs` - Added `fabro-cli::it::scenario::smoke::attach_smoke_covers_arg_validation_and_remote_server_behaviors` - Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli attach_smoke_covers_arg_validation_and_remote_server_behaviors --status-level fail --final-status-level fail --show-progress none` - Verification result: `1 passed` @@ -256,7 +256,7 @@ Cons: ### [x] 7. Collapse the three `completion` tests Files: -- `lib/crates/fabro-cli/tests/it/cmd/completion.rs` +- `lib/apps/fabro-cli/tests/it/cmd/completion.rs` Measured evidence: - `completion::generates_zsh_completions`: `1.567s` @@ -295,7 +295,7 @@ Cons: ### [x] 8. Remove or merge the duplicate attach replay test Files: -- `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- `lib/apps/fabro-cli/tests/it/cmd/attach.rs` Measured evidence: - `attach_replays_completed_detached_run`: `2.696s` @@ -303,7 +303,7 @@ Measured evidence: - The two tests are currently identical in code and assertions Implementation status: -- Implemented by removing the duplicate test from `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- Implemented by removing the duplicate test from `lib/apps/fabro-cli/tests/it/cmd/attach.rs` - Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli attach_replays_completed_detached_run --status-level fail --final-status-level fail --show-progress none` - Verification result: `1 passed` @@ -325,7 +325,7 @@ Cons: ### [x] 9. Make `attach_before_completion_streams_to_finished_state` event-driven instead of sleep-driven Files: -- `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- `lib/apps/fabro-cli/tests/it/cmd/attach.rs` Measured evidence: - `attach_before_completion_streams_to_finished_state`: `3.043s` @@ -333,7 +333,7 @@ Measured evidence: - `write_gated_workflow()` adds another fixed `sleep 0.2` Implementation status: -- Implemented by replacing the fixed 1-second gate-release sleep with a real attach-output signal in `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- Implemented by replacing the fixed 1-second gate-release sleep with a real attach-output signal in `lib/apps/fabro-cli/tests/it/cmd/attach.rs` - The test now spawns `fabro attach`, waits for replayed stderr output (`✓ start`), then releases the workflow gate - Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli attach_before_completion_streams_to_finished_state --status-level fail --final-status-level fail --show-progress none` - Verification result: `1 passed` @@ -361,14 +361,14 @@ Cons: ### [x] 10. Remove or parameterize the fixed `sleep 0.2` in `write_gated_workflow()` Files: -- `lib/crates/fabro-cli/tests/it/cmd/support.rs` +- `lib/apps/fabro-cli/tests/it/cmd/support.rs` Measured evidence: - `write_gated_workflow()` hardcodes `sleep 0.2` - The helper is used in 6 cmd tests Implementation status: -- Implemented by deleting the fixed `sleep 0.2` from `write_gated_workflow()` in `lib/crates/fabro-cli/tests/it/cmd/support.rs` +- Implemented by deleting the fixed `sleep 0.2` from `write_gated_workflow()` in `lib/apps/fabro-cli/tests/it/cmd/support.rs` - Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli -E 'test(attach_before_completion_streams_to_finished_state) | test(ctrl_c_cancels_active_run_via_server) | test(rm_force_terminates_active_run_worker) | test(start_rejects_already_active_or_completed_run) | test(start_runs_under_server_ownership_without_launcher_record)' --status-level fail --final-status-level fail --show-progress none` - Verification result: `5 passed` - Targeted 5-pass benchmark with `ulimit -n 4096` over the 5 tests that currently use the helper: diff --git a/docs/internal/testing-strategy.md b/docs/internal/testing-strategy.md index 5ec1534f5..b5fa9488a 100644 --- a/docs/internal/testing-strategy.md +++ b/docs/internal/testing-strategy.md @@ -37,7 +37,7 @@ This is the right place for: If the setup requires direct writes to internal run files or runtime directories, prefer this layer over `fabro-cli/tests/it`. -### `lib/crates/fabro-cli/tests/it/cmd/*.rs` +### `lib/apps/fabro-cli/tests/it/cmd/*.rs` `cmd/*` tests are command-owned tests. @@ -64,7 +64,7 @@ Bad command-test assertions: - behavior primarily owned by another command - runtime internals that only exist because the test planted them by hand -### `lib/crates/fabro-cli/tests/it/workflow/*.rs` +### `lib/apps/fabro-cli/tests/it/workflow/*.rs` `workflow/*` tests are black-box workflow-behavior tests. @@ -79,7 +79,7 @@ Examples: These tests should focus on the workflow's observed behavior, not on CLI help text or command argument validation. -### `lib/crates/fabro-cli/tests/it/scenario/*.rs` +### `lib/apps/fabro-cli/tests/it/scenario/*.rs` `scenario/*` tests are cross-command lifecycle tests. diff --git a/docs/public/agents/outputs.mdx b/docs/public/agents/outputs.mdx index e03b4e08f..b726a9ffd 100644 --- a/docs/public/agents/outputs.mdx +++ b/docs/public/agents/outputs.mdx @@ -66,7 +66,7 @@ JSON objects without recognized fields are ignored. ### Validated routing output -Set `output_schema="routing"` on an agent or prompt node to require Fabro's built-in routing directive schema: +Set `output_schema="routing"` on an agent, prompt, or command node to require Fabro's built-in routing directive schema: ```dot review [ @@ -81,7 +81,9 @@ With `output_schema="routing"`, the routing JSON must be an object with at least Validated routing uses the same reverse scan as normal routing extraction: Fabro validates the last parsable JSON object that contains a recognized routing field. If a routing object is present but malformed or has invalid field types, Fabro repairs that response instead of falling through to a file fallback. -Fabro repairs invalid structured output inside the same LLM context before failing the node. For prompt nodes, Fabro appends the invalid assistant response and a corrective user message to the same message list. For agent nodes using the API backend, Fabro sends the corrective message to the same live agent session. `output_retries` controls these repair turns and defaults to `2`; `output_retries=0` validates once and fails without a repair turn. Negative values are treated as `0`. These repair turns are separate from workflow `max_retries` and do not consume node retry attempts. +Fabro repairs invalid structured output inside the same LLM context before failing an agent or prompt node. For prompt nodes, Fabro appends the invalid assistant response and a corrective user message to the same message list. For agent nodes using the API backend, Fabro sends the corrective message to the same live agent session. `output_retries` controls these repair turns and defaults to `2`; `output_retries=0` validates once and fails without a repair turn. Negative values are treated as `0`. These repair turns are separate from workflow `max_retries` and do not consume node retry attempts. + +Command nodes instead validate only after an exit-code-`0` script, applying the same reverse scan to merged stdout and stderr. Print the intended JSON object last. Invalid output fails deterministically without a repair turn, command retry, or `status.json` fallback; `output_retries`, `retry_policy`, and `max_retries` do not retry the validation failure. Nonzero exits keep their normal command failure behavior without schema validation. ### Routing fallback sources @@ -95,7 +97,9 @@ Agent nodes can provide routing directives through fallback files. Fabro checks This fallback chain applies to normal routing extraction and to `output_schema="routing"`. For validated routing, Fabro only advances to the next source when the current source has no JSON object or no object with recognized routing fields. If the current source contains malformed routing JSON or valid JSON with wrong routing field types, validation fails and Fabro starts the repair loop instead. -Prompt nodes do not use file fallbacks; they validate or extract routing directives from the response text only. +The last-file fallback only reads `.json` and `.md` files (case-insensitive), and the routing JSON must be the final JSON object in the file, with only whitespace after it. Fabro ignores other file types and routing JSON followed by any other content. These restrictions do not apply to the dedicated `status.json` fallback. + +Prompt nodes do not use file fallbacks; they validate or extract routing directives from the response text only. Command nodes likewise have no file fallback and validate only their merged stdout and stderr. If no source provides routing directives, the transition falls through to condition matching, unconditional edges, or weight-based tiebreaking as described in [Transitions](/workflows/transitions). @@ -119,7 +123,7 @@ review -> approve [label="Approve"] ## Custom structured outputs -Agent and prompt nodes can also validate their final JSON object against a JSON Schema file: +Agent, prompt, and command nodes can also validate their final JSON object against a JSON Schema file: ```dot audit [ @@ -130,9 +134,9 @@ audit [ ] ``` -`output_schema="@path/to/schema.json"` uses the same workflow file-reference rules as prompt files: the schema is loaded relative to the workflow file and inlined before execution. The final JSON object in the LLM response is validated with `jsonschema`. +`output_schema="@path/to/schema.json"` uses the same workflow file-reference rules as prompt files: the schema is loaded relative to the workflow file and inlined before execution. The final JSON object in the LLM response, or in a successful command's merged stdout and stderr, is validated with `jsonschema`. -Custom schema validation only reads the response text. It does not fall back to `status.json` or the last file touched by the agent. +Custom schema validation only reads response or command output text. It does not fall back to `status.json` or the last file touched by the agent. When custom schema validation succeeds, Fabro stores the parsed JSON value in context at: @@ -140,12 +144,12 @@ When custom schema validation succeeds, Fabro stores the parsed JSON value in co |---|---| | `output.{node_id}` | The parsed JSON object that matched the custom schema | -For example, node `audit` writes its parsed custom output to `output.audit`. Fabro still stores the raw response text at `response.audit`. +For example, node `audit` writes its parsed custom output to `output.audit`. Fabro still stores raw LLM response text at `response.audit`; command output remains available through `command.output`. -If custom schema validation fails, Fabro sends concise validation feedback to the same prompt conversation or agent session and asks for corrected JSON. After `output_retries` repair turns are exhausted, the node fails terminally with `output schema validation failed after N repair attempt(s)`. +If custom schema validation fails for an agent or prompt node, Fabro sends concise validation feedback to the same prompt conversation or agent session and asks for corrected JSON. After `output_retries` repair turns are exhausted, the node fails terminally with `output schema validation failed after N repair attempt(s)`. A command node instead fails deterministically after its first validation, without a repair turn or command retry. -Structured output validation currently applies to agent and prompt nodes. `backend="acp"` does not support `output_schema` in this release. Custom schemas update `output.{node_id}`; routing schemas update routing fields and `context_updates` instead. +Structured output validation applies to agent, prompt, and command nodes. `backend="acp"` does not support `output_schema` in this release. Custom schemas update `output.{node_id}`; routing schemas update routing fields and merge `context_updates` as flat context keys that edge conditions can read. Edge conditions cannot traverse the custom `output.{node_id}` object. ## Output logging diff --git a/docs/public/agents/subagents.mdx b/docs/public/agents/subagents.mdx index 11cf30c38..597bd96e3 100644 --- a/docs/public/agents/subagents.mdx +++ b/docs/public/agents/subagents.mdx @@ -44,7 +44,6 @@ Sub-agent failures do not automatically fail the parent stage. The parent receiv Common cases: -- **Hits `max_turns`** -- returns normally with its last output - **Panics or errors** -- returned as a failed `wait` result - **`spawn_agent` fails** -- returned immediately as a tool result @@ -97,5 +96,5 @@ Sub-agents are most useful for: Use [child runs](/execution/child-runs) instead when the delegated work should be a separate Fabro run with its own workflow, lifecycle, sandbox, checkpoints, and outputs. -Sub-agents run with no turn limit by default. Pass `max_turns` when you want predictable cost or time bounds. All active sub-agents are cleaned up automatically when the parent session closes. +Sub-agents run until they complete, fail, are cancelled, or hit the session's wall-clock timeout. All active sub-agents are cleaned up automatically when the parent session closes. diff --git a/docs/public/api-reference/client-sdks.mdx b/docs/public/api-reference/client-sdks.mdx index e0fcb4a94..b46291669 100644 --- a/docs/public/api-reference/client-sdks.mdx +++ b/docs/public/api-reference/client-sdks.mdx @@ -43,7 +43,7 @@ The `fabro-api` crate generates Rust structs, enums, and a `reqwest`-based HTTP A `build.rs` script reads `docs/public/api-reference/fabro-api.yaml`, patches it from OpenAPI 3.1 to 3.0 for progenitor compatibility, and generates both types and a client. The generated code is written to `OUT_DIR` and included via: ```rust -// lib/crates/fabro-api/src/lib.rs +// lib/foundation/fabro-api/src/lib.rs include!(concat!(env!("OUT_DIR"), "/codegen.rs")); ``` diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index a39a4c414..ec20f9a6b 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -5452,7 +5452,9 @@ paths: operationId: listModels tags: [Models] summary: List Models - description: Returns a paginated list of available LLM models from the built-in catalog. + description: | + Returns one row per provider/model offering from the catalog. Model IDs + are unique within a provider; `(provider, id)` is the resource identity. parameters: - $ref: "#/components/parameters/ModelProviderFilter" - $ref: "#/components/parameters/ModelQueryFilter" @@ -5487,7 +5489,8 @@ paths: required: true schema: type: string - description: The model identifier. + description: The canonical model ID or an alias. + - $ref: "#/components/parameters/ModelTestProviderParam" - $ref: "#/components/parameters/ModelTestModeParam" responses: "200": @@ -5996,6 +5999,17 @@ components: $ref: "#/components/schemas/ModelTestMode" example: basic + ModelTestProviderParam: + name: provider + in: query + required: false + description: | + Pin the test to this provider's offering. When omitted, the server + selects among ready providers by catalog priority. + schema: + $ref: "#/components/schemas/ProviderId" + example: openrouter + headers: XRequestId: description: > @@ -7766,6 +7780,12 @@ components: $ref: "#/components/schemas/SessionStatus" model: type: ["string", "null"] + description: Canonical model ID selected when the session was created. + provider: + oneOf: + - $ref: "#/components/schemas/ProviderId" + - type: "null" + description: Provider selected when the session was created. active_turn: oneOf: - $ref: "#/components/schemas/SessionTurn" @@ -7798,6 +7818,12 @@ components: $ref: "#/components/schemas/SessionStatus" model: type: ["string", "null"] + description: Canonical model ID selected when the session was created. + provider: + oneOf: + - $ref: "#/components/schemas/ProviderId" + - type: "null" + description: Provider selected when the session was created. active_turn: oneOf: - $ref: "#/components/schemas/SessionTurn" @@ -7832,6 +7858,12 @@ components: $ref: "#/components/schemas/SessionStatus" model: type: ["string", "null"] + description: Canonical model ID selected when the session was created. + provider: + oneOf: + - $ref: "#/components/schemas/ProviderId" + - type: "null" + description: Provider selected when the session was created. active_turn: oneOf: - $ref: "#/components/schemas/SessionTurn" @@ -7857,7 +7889,12 @@ components: type: string model: type: string - description: Catalog model ID or alias, or provider-qualified provider/model reference. Stored as the canonical catalog model ID. + description: | + Catalog model ID or alias. The server selects among ready + providers and stores the canonical model ID. + provider: + $ref: "#/components/schemas/ProviderId" + description: Optional provider pin. Provider-qualified model references remain accepted for compatibility. SubmitTurnRequest: type: object @@ -8190,6 +8227,7 @@ components: - reasoning - reasoning_effort - prompt_cache + - cache_control_breakpoints - sampling_params properties: tools: @@ -8206,6 +8244,12 @@ components: prompt_cache: type: boolean description: Whether the model endpoint supports prompt caching. + cache_control_breakpoints: + type: boolean + description: >- + Whether the endpoint only caches when the request marks the + cacheable prefix with Anthropic-style cache_control breakpoints + (e.g. Claude via OpenRouter). sampling_params: type: boolean description: Whether the model accepts classic sampling parameters (temperature, top_p). @@ -8235,7 +8279,9 @@ components: example: 1.50 Model: - description: An available LLM model from the built-in catalog. + description: | + One provider's offering of an LLM model. The `id` is unique within + `provider`; `(provider, id)` is the stable resource identity. type: object required: - id @@ -8255,7 +8301,7 @@ components: properties: id: type: string - description: Unique model identifier. + description: Canonical human-facing model ID, unique within the provider. example: "claude-opus-4-6" provider: $ref: "#/components/schemas/ProviderId" @@ -8310,12 +8356,15 @@ components: type: object required: - model_id + - provider - status properties: model_id: type: string - description: The model identifier that was tested. + description: The canonical model ID that was tested. example: "claude-opus-4-6" + provider: + $ref: "#/components/schemas/ProviderId" status: type: string enum: @@ -8406,7 +8455,7 @@ components: $ref: "#/components/schemas/CompletionMessage" model: type: string - description: Model ID or alias. Server picks default if omitted. + description: Model ID or alias. Server picks a ready-provider default if omitted. system: type: string description: System prompt (convenience; prepended as a system message). @@ -8442,7 +8491,7 @@ components: description: Reasoning effort level. provider: type: string - description: Provider to route to. + description: Optional provider pin. provider_options: description: Provider-specific options. @@ -8459,12 +8508,15 @@ components: CompletionResponse: type: object - required: [id, model, message, stop_reason, usage] + required: [id, model, provider, message, stop_reason, usage] properties: id: type: string model: type: string + description: Canonical model ID selected for the request. + provider: + $ref: "#/components/schemas/ProviderId" message: $ref: "#/components/schemas/CompletionMessage" stop_reason: @@ -8518,7 +8570,10 @@ components: same format the model writes back via `write_workflow_file`. model: type: string - description: Model id or alias. Server picks the default if omitted. + description: Model ID or alias. Server picks a ready-provider default if omitted. + provider: + $ref: "#/components/schemas/ProviderId" + description: Optional provider pin. PaginatedSavedQueryList: description: Paginated list of saved queries. @@ -10497,7 +10552,11 @@ components: oneOf: - $ref: "#/components/schemas/TodoListProjection" - type: "null" - description: Projected todo / task list for this stage. + description: | + Todo / task list owned by this stage's root agent session. OpenAI + child sessions have separate per-session plans that do not appear + here. Anthropic task lists are root-scoped and shared with child + sessions, so child mutations of that shared list do appear here. subagents: type: array description: Subagents spawned by this stage, in replay/insertion order. diff --git a/docs/public/changelog/2026-03-23.mdx b/docs/public/changelog/2026-03-23.mdx index e3d47ecda..359f5bbc1 100644 --- a/docs/public/changelog/2026-03-23.mdx +++ b/docs/public/changelog/2026-03-23.mdx @@ -5,7 +5,7 @@ date: "2026-03-23" ## Unlimited agent tool rounds -Agent stages no longer have an artificial cap on tool rounds per input or subagent turns. Previously, the default limits could cause agents to stop mid-task on complex operations. Both `max_tool_rounds_per_input` and subagent `max_turns` now default to unlimited, so agents run until they complete their work or hit the context window. +Agent stages no longer have an artificial cap on tool rounds per input or subagent turns, so agents run until they complete their work or are interrupted. ## More diff --git a/docs/public/changelog/2026-05-12.mdx b/docs/public/changelog/2026-05-12.mdx index c7d47f285..96387b4e7 100644 --- a/docs/public/changelog/2026-05-12.mdx +++ b/docs/public/changelog/2026-05-12.mdx @@ -24,7 +24,7 @@ This is useful for workflows that prepare their own workspace, run against alrea - Added `[run.clone]`, `[run.run_branch]`, and `[run.meta_branch]` settings for clone, branch, and metadata branch behavior -- Added `[llm.providers]` and `[llm.models]` settings foundations for configurable provider and model catalog data +- Added provider-scoped `[llm.providers..models.]` settings foundations for configurable provider and model catalog data - Added typed provider `extra_headers` settings for gateway-backed LLM providers diff --git a/docs/public/changelog/2026-05-13.mdx b/docs/public/changelog/2026-05-13.mdx index 3cb981f80..8659dafaa 100644 --- a/docs/public/changelog/2026-05-13.mdx +++ b/docs/public/changelog/2026-05-13.mdx @@ -28,8 +28,7 @@ credentials = ["env:ACME_GATEWAY_API_KEY"] x-portkey-api-key = { env = "PORTKEY_API_KEY" } x-portkey-config = { literal = "@bedrock-prod" } -[llm.models."team-code-large"] -provider = "proxy" +[llm.providers.proxy.models."team-code-large"] api_id = "provider-wire-model-name" default = true ``` diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index 7bda6217b..ab88318dd 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -11,6 +11,29 @@ No single model is best at everything. Fabro lets you assign the right model to ## Model catalog +Fabro keeps five related concepts separate: + +- A **provider** serves requests, such as `openai` or `openrouter`. +- A **model slug** is Fabro's canonical, human-facing model ID, such as `gpt-5.6-sol`. +- An **alias** is another user-facing selector, such as `gpt-56-sol`. +- A **family** is display and matching metadata. It does not affect routing identity. +- An **API ID** is the opaque model string sent on the provider wire. Workflows should never reference it. + +One provider's route to one model slug is an **offering**, identified by `(provider, model slug)`. The same slug and alias may appear on several providers. Within one provider, however, every slug or alias must identify exactly one offering. + +For an unqualified selector, Fabro checks canonical slugs before aliases, filters the candidates to providers whose adapters are ready, and then chooses the highest provider priority. Equal priorities use canonical provider ID order. An explicit provider restricts selection to that provider and is a pin: if it is unavailable, Fabro reports the error instead of silently switching. + +For example, the shared `gpt-56-sol` alias can be portable across direct OpenAI and OpenRouter offerings: + +| Ready providers | Selector | Selected offering | +|---|---|---| +| OpenAI only | `gpt-56-sol` | `openai/gpt-5.6-sol` | +| OpenRouter only | `gpt-56-sol` | `openrouter/gpt-5.6-sol` | +| OpenAI and OpenRouter | `gpt-56-sol` | OpenAI, because it has higher priority | +| Both, with `provider = "openrouter"` | `gpt-56-sol` | OpenRouter, because the provider is pinned | + +Fabro performs this selection once when creating a run and persists the chosen provider and canonical slug in the run settings and graph. Resuming that run does not reconsider provider priority when credentials change. Runtime [model fallbacks](/execution/failures#model-fallbacks) are the separate mechanism for handling a later provider failure. + | Model | Provider | Aliases | Context | Cost (in/out per Mtok) | Speed | |---|---|---|---|---|---| | `claude-fable-5` | anthropic | `fable`, `claude-fable` | 1M | $10.00 / $50.00 | n/a | @@ -46,7 +69,7 @@ Claude Fable 5 is available as an explicit model but is not the default Anthropi ## Configuring providers and models -Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Provider and model IDs are strings, so a server or project can add an OpenAI-compatible provider without a Fabro release. +Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Models are nested under their provider, so two providers can expose the same model slug without overwriting each other. ```toml title="settings.toml" [llm.providers.proxy] @@ -62,8 +85,7 @@ credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"] x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}" x-portkey-config = "@bedrock-prod" -[llm.models."team-code-large"] -provider = "proxy" +[llm.providers.proxy.models."team-code-large"] api_id = "provider-wire-model-name" agent_profile = "anthropic" display_name = "Team Code Large" @@ -73,27 +95,26 @@ small_default = true aliases = ["team-code"] estimated_output_tps = 80 -[llm.models."team-code-large".limits] +[llm.providers.proxy.models."team-code-large".limits] context_window = 200000 max_output = 32000 -[llm.models."team-code-large".features] +[llm.providers.proxy.models."team-code-large".features] tools = true reasoning = true reasoning_effort = "levels" prompt_cache = true -effort = true -[llm.models."team-code-large".controls] +[llm.providers.proxy.models."team-code-large".controls] reasoning_effort = ["low", "medium", "high"] speed = ["fast"] -[llm.models."team-code-large".costs] +[llm.providers.proxy.models."team-code-large".costs] input_cost_per_mtok = 1.50 output_cost_per_mtok = 8.00 cache_input_cost_per_mtok = 0.30 -[llm.models."team-code-large".costs.speed.fast] +[llm.providers.proxy.models."team-code-large".costs.speed.fast] input_cost_per_mtok = 3.00 output_cost_per_mtok = 16.00 cache_input_cost_per_mtok = 0.60 @@ -106,24 +127,27 @@ For [LiteLLM](/integrations/litellm), Fabro ships a disabled provider entry. Ena enabled = true base_url = "http://localhost:4000/v1" -[llm.models."litellm-gpt-5"] -provider = "litellm" +[llm.providers.litellm.models."litellm-gpt-5"] api_id = "gpt-5" display_name = "LiteLLM GPT-5" family = "litellm" default = true -[llm.models."litellm-gpt-5".limits] +[llm.providers.litellm.models."litellm-gpt-5".limits] context_window = 128000 max_output = 8192 -[llm.models."litellm-gpt-5".features] +[llm.providers.litellm.models."litellm-gpt-5".features] tools = true vision = false reasoning = false ``` -`api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same. +`api_id` is the opaque model name sent to that provider's API. It defaults to the exact model slug, so omit it when the two strings match. Fabro does not infer vendor prefixes or rewrite the value. + + +Historical built-in catalog keys that exposed provider API IDs remain accepted as compatibility selectors. Fabro normalizes a primary or node selector such as `openai/gpt-5.6-sol` to the canonical `gpt-5.6-sol` slug before normal provider-aware selection. With no provider pin, the highest-priority ready offering wins; a separate `provider = "openrouter"` pin selects the OpenRouter offering. Fabro also normalizes these keys in legacy top-level `[llm.models]` rows without rewriting the settings file. + Model roles are separate: `default = true` controls normal model selection for workflow execution, while `small_default = true` marks the provider's small/cheap utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's normal default. @@ -139,7 +163,7 @@ Provider fields in configuration, APIs, and model routing are provider ID string ### Poolside -Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same models are also available through the opt-in OpenRouter provider under vendor-namespaced IDs. +Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_id` values. ### OpenRouter @@ -169,11 +193,11 @@ Fabro ships an Ollama provider definition that is disabled by default. Enable it enabled = true ``` -Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.models.]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`. +Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.providers.ollama.models.""]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`. ## Default models -When no model or provider is specified, Fabro checks configured provider credentials and chooses the first configured provider by catalog priority. If no provider credentials are configured, it uses the catalog's global default model. Each provider has a default model: +When no model or provider is specified, Fabro chooses the default offering on the highest-priority ready provider. If no provider adapter is ready, run creation reports that no eligible offering is available. Each provider has its own default model: | Provider | Default model | |---|---| @@ -184,7 +208,7 @@ When no model or provider is specified, Fabro checks configured provider credent | `poolside` | `laguna-s-2.1` | | `zai` | `glm-4.7` | | `minimax` | `minimax-m2.5` | -| `inception` | `mercury` | +| `inception` | `mercury-2` | ## Using models in workflows @@ -221,7 +245,7 @@ fabro run docs/internal/demo/01-hello.fabro --model claude-opus-4-6 fabro run docs/internal/demo/04-pipeline.fabro --model gemini-3.1-pro-preview ``` -These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. The provider is automatically inferred from the model catalog — you only need `--provider` for models not in the catalog or to force a specific provider. +These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. Without `--provider`, Fabro selects among ready offerings by priority. Add `--provider` to pin an exact provider, including for an uncatalogued provider model string. ### Run config TOML @@ -247,7 +271,7 @@ Then launch with: fabro run run.toml ``` -The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable. +The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable. In this field, qualified references keep their established provider-pin meaning: `"openai/gpt-5.6-sol"` selects the direct OpenAI offering. The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win. diff --git a/docs/public/examples/repl-handoff.mdx b/docs/public/examples/repl-handoff.mdx index a89942013..bf05c425e 100644 --- a/docs/public/examples/repl-handoff.mdx +++ b/docs/public/examples/repl-handoff.mdx @@ -73,7 +73,7 @@ Use Claude Code's plan mode to collaborate on an implementation plan. Go back an > /plan Add retry logic to the webhook delivery system Planning... -1. Add RetryPolicy struct to lib/crates/fabro-webhooks/src/policy.rs +1. Add RetryPolicy struct to lib/components/fabro-webhooks/src/policy.rs 2. Implement exponential backoff with jitter 3. Add max_retries field to WebhookConfig 4. Write tests for retry timing, max attempts, and jitter bounds diff --git a/docs/public/execution/context.mdx b/docs/public/execution/context.mdx index 25f6e536d..6f25c39c4 100644 --- a/docs/public/execution/context.mdx +++ b/docs/public/execution/context.mdx @@ -140,6 +140,16 @@ Fidelity can be set at three levels. The first match wins: If none of these are set, fidelity defaults to `compact`. +### Parallel branch fidelity + +The first node in each parallel branch uses this precedence: + +1. `fidelity` on the fork-to-branch edge +2. `fidelity` on the branch node +3. Otherwise, inherit the fork's preamble unchanged + +Fabro renders any branch-specific preambles before fan-out from the fork's context snapshot, then places them into the isolated branch contexts. An explicit branch-level `full` degrades to `summary:high` because concurrent branches cannot share conversation sessions. `thread_id` on a branch node or fork-to-branch edge is inert. + ### Full fidelity and threads `full` fidelity is typically used with `thread_id` to create a shared conversation across multiple nodes. Nodes with the same `thread_id` share a single LLM session, preserving full context continuity: diff --git a/docs/public/execution/failures.mdx b/docs/public/execution/failures.mdx index b76ae98e1..a74eefa9f 100644 --- a/docs/public/execution/failures.mdx +++ b/docs/public/execution/failures.mdx @@ -121,7 +121,15 @@ provider = "anthropic" fallbacks = ["gemini", "openai"] ``` -When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. Each fallback entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference. +When Anthropic fails, Fabro tries Gemini first, then OpenAI. Fallback resolution is provider-aware: + +- A bare provider token such as `"gemini"` selects that provider's closest compatible model. +- A qualified selector such as `"openrouter/gpt-56-sol"` resolves only within that provider. +- A bare model slug or alias considers ready providers and uses provider priority. + +Qualified fallback references always remain provider pins, including strings that were historical built-in API IDs. For example, `"openai/gpt-5.6-sol"` pins the direct OpenAI offering. + +The primary provider and model were already resolved and persisted when the run was created; resuming does not re-run primary selection. Fallbacks are only considered after an eligible runtime failure. ### What triggers failover diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index 1b51a9161..3cb544906 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -138,12 +138,16 @@ name = "claude-sonnet-4-5" | Field | Description | |---|---| -| `name` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). | -| `provider` | Provider name (optional — auto-inferred from the model catalog). Only needed for models not in the catalog or to force a specific provider. | +| `name` | Canonical model slug or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). | +| `provider` | Optional provider pin. When omitted, Fabro selects among ready offerings by provider priority. When present, an unavailable provider is an error rather than permission to switch. | | `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. | Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.]`. +At run creation, Fabro resolves the primary selector and every node selector against the ready-provider snapshot. It persists the selected canonical model slug and provider, so resuming the run does not choose a different provider just because credentials or priorities changed. The configured fallback chain remains available for failures that occur while the materialized run is executing. + +Historical built-in provider API IDs are accepted for compatibility and normalize before this selection. For example, `name = "openai/gpt-5.6-sol"` is treated as the canonical `gpt-5.6-sol` selector; omit `provider` to use readiness and priority, or set `provider` separately to pin an offering. + #### `[run.model.controls]` Set default model controls for all nodes that do not override them in the workflow stylesheet: diff --git a/docs/public/integrations/bedrock.mdx b/docs/public/integrations/bedrock.mdx index 66a03c91c..34781fe59 100644 --- a/docs/public/integrations/bedrock.mdx +++ b/docs/public/integrations/bedrock.mdx @@ -157,6 +157,6 @@ Bedrock-specific request fields pass through verbatim via `provider_options.bedr How Fabro routes model IDs, providers, and fallbacks. - Full reference for `[llm.providers.]` and `[llm.models.]`. + Full reference for provider settings and provider-scoped model offerings. diff --git a/docs/public/integrations/litellm.mdx b/docs/public/integrations/litellm.mdx index 7f1891f8d..5fb1b6955 100644 --- a/docs/public/integrations/litellm.mdx +++ b/docs/public/integrations/litellm.mdx @@ -24,18 +24,17 @@ _version = 1 enabled = true base_url = "http://localhost:4000/v1" -[llm.models."litellm-gpt-5"] -provider = "litellm" +[llm.providers.litellm.models."litellm-gpt-5"] api_id = "gpt-5" display_name = "LiteLLM GPT-5" family = "litellm" default = true -[llm.models."litellm-gpt-5".limits] +[llm.providers.litellm.models."litellm-gpt-5".limits] context_window = 128000 max_output = 8192 -[llm.models."litellm-gpt-5".features] +[llm.providers.litellm.models."litellm-gpt-5".features] tools = true vision = false reasoning = false @@ -94,18 +93,17 @@ digraph Example { Declare each LiteLLM-routed model explicitly so Fabro knows its provider, context window, tool support, and routing defaults: ```toml title="settings.toml" -[llm.models."litellm-fast"] -provider = "litellm" +[llm.providers.litellm.models."litellm-fast"] api_id = "fast-model" display_name = "LiteLLM Fast" family = "litellm" aliases = ["fast"] -[llm.models."litellm-fast".limits] +[llm.providers.litellm.models."litellm-fast".limits] context_window = 64000 max_output = 4096 -[llm.models."litellm-fast".features] +[llm.providers.litellm.models."litellm-fast".features] tools = true vision = false reasoning = false @@ -128,6 +126,6 @@ Only one model for a provider should set `default = true`. You may also mark one How Fabro routes model IDs, providers, and fallbacks. - Full reference for `[llm.providers.]` and `[llm.models.]`. + Full reference for provider settings and provider-scoped model offerings. diff --git a/docs/public/integrations/openrouter.mdx b/docs/public/integrations/openrouter.mdx index b1b6f2534..6f1f046b5 100644 --- a/docs/public/integrations/openrouter.mdx +++ b/docs/public/integrations/openrouter.mdx @@ -44,34 +44,33 @@ export OPENROUTER_API_KEY=sk-or-v1-... ## Included models -The built-in catalog curates frontier and open-weights models under vendor-namespaced IDs: +The built-in catalog gives OpenRouter offerings the same human-facing model slugs used by direct providers. Vendor-namespaced OpenRouter IDs remain opaque `api_id` values: -| Fabro model ID | Notes | +| Fabro model slug | OpenRouter API ID / notes | | --- | --- | -| `anthropic/claude-opus-4-7` | Claude via OpenRouter, Anthropic-style cache billing | -| `anthropic/claude-sonnet-4-6` | Provider default | -| `anthropic/claude-haiku-4-5` | Provider small default | -| `openai/gpt-5.4`, `openai/gpt-5.5` | | -| `google/gemini-3.1-pro-preview`, `google/gemini-3.5-flash` | | -| `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | | -| `moonshotai/kimi-k2.6`, `qwen/qwen3-coder`, `qwen/qwen3.6-flash` | | -| `poolside/laguna-s-2.1`, `poolside/laguna-xs-2.1` | Poolside Laguna coding models with native reasoning and tool use | -| `z-ai/glm-4.6`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro` | | -| `nvidia/nemotron-3-super-120b-a12b`, `mistralai/devstral-2512` | | +| `claude-opus-4-7` | `anthropic/claude-opus-4.7`; Anthropic-style cache billing | +| `claude-sonnet-4-6` | `anthropic/claude-sonnet-4.6`; provider default | +| `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 | +| `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 | +| `nemotron-3-super-120b-a12b`, `devstral-2512` | Vendor-prefixed API IDs | -Any other OpenRouter model can be added as a settings model entry with `provider = "openrouter"` and the OpenRouter slug as `api_id`: +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`: ```toml title="settings.toml" -[llm.models."meta-llama/llama-4-maverick"] -provider = "openrouter" +[llm.providers.openrouter.models."llama-4-maverick"] api_id = "meta-llama/llama-4-maverick" display_name = "Llama 4 Maverick" family = "llama-4" -[llm.models."meta-llama/llama-4-maverick".limits] +[llm.providers.openrouter.models."llama-4-maverick".limits] context_window = 1000000 -[llm.models."meta-llama/llama-4-maverick".features] +[llm.providers.openrouter.models."llama-4-maverick".features] tools = true vision = false reasoning = false @@ -81,8 +80,8 @@ reasoning = false ```bash fabro model list --provider openrouter -fabro model test --model anthropic/claude-sonnet-4-6 -fabro run workflow.fabro --model deepseek/deepseek-v4-flash +fabro model test --provider openrouter --model claude-sonnet-4-6 +fabro run workflow.fabro --provider openrouter --model deepseek-v4-flash ``` When targeting a non-default remote server, pass the same `--server` value to verification commands: @@ -159,6 +158,6 @@ Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, ` How Fabro routes model IDs, providers, and fallbacks. - Full reference for `[llm.providers.]` and `[llm.models.]`. + Full reference for provider settings and provider-scoped model offerings. diff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx index 678d5ac23..8c350d1ba 100644 --- a/docs/public/reference/dot-language.mdx +++ b/docs/public/reference/dot-language.mdx @@ -201,12 +201,12 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be | `prompt` | String | Task instructions for the LLM. Supports file references with `@path/to/file.md` | | `reasoning_effort` | String | `low`, `medium`, or `high` (default: `high`) | | `max_tokens` | Integer | Maximum output tokens | -| `fidelity` | String | How much prior context is passed: `compact`, `full`, `summary:high`, `summary:medium`, `summary:low`, `truncate` | -| `thread_id` | String | Groups nodes into a shared conversation thread | +| `fidelity` | String | How much prior context is passed: `compact`, `full`, `summary:high`, `summary:medium`, `summary:low`, `truncate`. On a node entered directly from a parallel fork, this is overridden by the fork-to-branch edge; explicit `full` degrades to `summary:high`. | +| `thread_id` | String | Groups nodes into a shared conversation thread. Inert when the node is entered directly from a parallel fork. | | `model` | String | Explicit model ID (overrides stylesheet) | | `provider` | String | Explicit provider name (overrides stylesheet). Auto-inferred from the model catalog when omitted. | | `project_memory` | Boolean | When `true` (default), prompt nodes discover and include project docs (`AGENTS.md`, `CLAUDE.md`, etc.) as a system prompt. Set to `false` to disable. | -| `output_schema` | String | Optional structured output validation. Use `routing` for Fabro's built-in routing directive schema, `@path/to/schema.json` for a JSON Schema file, or an inline JSON Schema object string. Supported on agent and prompt nodes. | +| `output_schema` | String | Optional structured output validation. Use `routing` for Fabro's built-in routing directive schema, `@path/to/schema.json` for a JSON Schema file, or an inline JSON Schema object string. Supported on agent, prompt, and command nodes. | | `output_retries` | Integer | Corrective structured-output turns inside the same prompt conversation or agent session. Default `2`; `0` validates once and fails without repair; negative values are treated as `0`. Separate from `max_retries`. | | `backend` | String | Agent execution backend: `api` (default) or `acp`. `api` runs Fabro's tool loop through provider APIs; `acp` runs an Agent Client Protocol stdio agent inside the active sandbox. Prompt nodes are API-only. See [Agents — Backends](/core-concepts/agents#backends). | | `acp.command` | String | Shell command for nodes with `backend="acp"`. Mutually exclusive with `acp.config`. The value is always parsed as a command string, not JSON. | @@ -214,7 +214,7 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be #### Structured output validation -`output_schema` opts an agent or prompt node into strict JSON validation: +`output_schema` opts an agent, prompt, or command node into strict JSON validation: ```dot review [ @@ -235,7 +235,9 @@ audit [ - On validation failure, Fabro sends validation feedback to the same active context before failing: prompt nodes keep the prior assistant response in the message list, and API-backed agent nodes repair in the same live session. - `output_retries` defaults to `2` and controls only these corrective structured-output turns. Negative values are treated as `0`. It is not the same as `max_retries` and does not consume workflow retry attempts. - Custom schema output is stored in context at `output.{node_id}`. Routing schema output updates routing fields and any `context_updates`. -- Agent routing fallbacks still apply to `output_schema="routing"`: response text first, then `status.json`, then the last file touched by the agent. Custom schemas and prompt nodes validate response text only. +- Agent routing fallbacks still apply to `output_schema="routing"`: response text first, then `status.json`, then the last file touched by the agent. The last-file fallback only accepts `.json` and `.md` files (case-insensitive) whose final JSON object contains the routing directive; only whitespace may follow it. Custom schemas and prompt nodes validate response text only. +- Command nodes validate merged stdout and stderr only after the script exits with code `0`, using the same object selection as agents and prompts: custom schemas validate the last JSON object, and `routing` validates the last JSON object containing a recognized routing field. Print the intended JSON object last. A validation error is a deterministic, non-retryable failure with no repair turn or `status.json` fallback. `output_retries`, `retry_policy`, and `max_retries` do not retry it. Nonzero exits retain normal command failure behavior without schema validation. +- For commands, custom schema output is stored at `output.{node_id}`; edge conditions cannot traverse into its fields. The `routing` schema applies routing fields and merges `context_updates` into flat context keys, which conditions can read (for example, `context.kept_count`). - `backend="acp"` with `output_schema` is unsupported in this release. ### Command nodes @@ -244,6 +246,7 @@ audit [ |---|---|---| | `script` | String | Shell command to execute | | `language` | String | `"shell"` (default) or `"python"` | +| `output_schema` | String | Optional structured output validation. Accepts `routing`, `@path/to/schema.json`, or an inline JSON Schema object string. See [Structured output validation](#structured-output-validation). | ### Parallel (fan-out) nodes @@ -251,6 +254,8 @@ audit [ |---|---|---| | `max_parallel` | Integer | Maximum concurrent branches (default: 4). The node always waits for every branch. | +For the first node in each branch, `fidelity` resolves from the fork-to-branch edge, then the branch node; without either, the fork preamble is inherited unchanged. Branch-specific preambles are rendered before fan-out from the fork's context snapshot. Concurrent branches cannot share sessions, so explicit branch `full` becomes `summary:high`, and branch-level `thread_id` is inert. + ### Wait nodes | Attribute | Type | Description | @@ -281,8 +286,8 @@ audit [ | `label` | String | Display text; also used for human gate option matching | | `condition` | String | Boolean expression for conditional routing (see below) | | `weight` | Integer | Priority for tiebreaking (higher wins, default: 0) | -| `fidelity` | String | Override fidelity level for this transition | -| `thread_id` | String | Override thread ID for this transition | +| `fidelity` | String | Override fidelity level for this transition. On a fork-to-branch edge, takes precedence over the branch node; explicit `full` degrades to `summary:high`. | +| `thread_id` | String | Override thread ID for this transition. Inert on fork-to-branch edges. | | `loop_restart` | Boolean | Restart the workflow from this edge's target when taken: stage history and retry counts clear and the context resets to empty (visit counts are kept). Failed outcomes may only take it for `transient_infra` failures — see [Failures](/execution/failures#loop-restart-edges) | | `freeform` | Boolean | When `true` on a human-gate edge, accept free-text input instead of fixed choices | diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index 4332f3adc..9f88c7696 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -84,7 +84,7 @@ pub fn new( | Method | Description | |---|---| | `initialize().await` | Discovers project docs, skills, and MCP servers. Call before `process_input`. | -| `process_input(input).await` | Sends user input and runs the agent loop until the model stops or a limit is hit. | +| `process_input(input).await` | Sends user input and runs the agent loop until the model stops, the session is interrupted, or an error occurs. | | `close()` | Ends the session and emits `SessionEnded`. | | `interrupt()` | Cancels the current `process_input` call. | | `cancel_token()` | Returns a `CancellationToken` for external cancellation. | @@ -110,8 +110,6 @@ All fields are public. Key settings with their defaults: | Field | Default | Description | |---|---|---| -| `max_turns` | `0` (unlimited) | Maximum conversation turns before stopping. | -| `max_tool_rounds_per_input` | `200` | Maximum tool execution rounds per `process_input` call. | | `default_command_timeout_ms` | `10,000` | Default timeout for Bash tool commands. | | `max_command_timeout_ms` | `600,000` | Maximum allowed timeout for Bash tool commands. | | `enable_loop_detection` | `true` | Detect and break out of repetitive tool call patterns. | @@ -221,7 +219,6 @@ Key `AgentEvent` variants: | `ToolCallCompleted { tool_name, tool_call_id, output, is_error }` | A tool call finished. | | `Error { error }` | An `AgentError` occurred. | | `LoopDetected` | The agent is repeating itself. | -| `TurnLimitReached { max_turns }` | Turn limit hit. | | `CompactionStarted` / `CompactionCompleted` | Context window compaction. | | `SubAgentSpawned` / `SubAgentCompleted` | Sub-agent lifecycle. | | `McpServerReady` / `McpServerFailed` | MCP server connection status. | @@ -296,7 +293,7 @@ All fallible `Session` methods return `Result`: | `SessionClosed` | `process_input` was called on a closed session. | | `InvalidState(String)` | The session is in an unexpected state. | | `ToolExecution(String)` | A tool execution failed. | -| `Interrupted(InterruptReason)` | The session was cancelled (`Cancelled`) or timed out (`WallClockTimeout`). | +| `Interrupted(InterruptReason)` | The session was cancelled or timed out. | --- diff --git a/docs/public/reference/user-configuration.mdx b/docs/public/reference/user-configuration.mdx index fe62e4ee7..dd6afd83f 100644 --- a/docs/public/reference/user-configuration.mdx +++ b/docs/public/reference/user-configuration.mdx @@ -35,7 +35,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver |---|---| | CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` | | Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` | -| Shared LLM catalog | `[llm.providers.]`, `[llm.models.]`, model limits, features, controls, and costs | +| Shared LLM catalog | `[llm.providers.]`, provider-scoped `[llm.providers..models.]` offerings, limits, features, controls, and costs | | Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` | `[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `.fabro/project.toml` or `workflow.toml` remain schema-valid but runtime-inert. @@ -97,23 +97,22 @@ credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"] x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}" x-portkey-config = "@bedrock-prod" -[llm.models."team-code-large"] -provider = "proxy" +[llm.providers.proxy.models."team-code-large"] api_id = "provider-wire-model-name" agent_profile = "anthropic" display_name = "Team Code Large" default = true aliases = ["team-code"] -[llm.models."team-code-large".controls] +[llm.providers.proxy.models."team-code-large".controls] reasoning_effort = ["low", "medium", "high"] speed = ["fast"] -[llm.models."team-code-large".costs] +[llm.providers.proxy.models."team-code-large".costs] input_cost_per_mtok = 1.50 output_cost_per_mtok = 8.00 -[llm.models."team-code-large".costs.speed.fast] +[llm.providers.proxy.models."team-code-large".costs.speed.fast] input_cost_per_mtok = 3.00 output_cost_per_mtok = 16.00 @@ -201,19 +200,20 @@ x-team-secret = "{{ secrets.gateway_team_secret }}" | `auth.credentials` | array | required when `auth` present | Ordered credential refs. Accepted forms are `vault:`, `env:`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. | | `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. | | `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are interpolation strings: literal text, an `{{ env.NAME }}` token, or a `{{ secrets.NAME }}` token. Put credentials in a secret and reference them with a `{{ secrets.NAME }}` token, not a bare literal. | -| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. | +| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection; ties use canonical provider ID. | | `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. | | `aliases` | array | `[]` | Additional provider names accepted by model routing and fallback config. | -## `[llm.models.]` +## `[llm.providers..models.]` -Define or override a model in the catalog. The table key is the canonical -model ID Fabro users reference; `api_id` is the model string sent to the -provider API. +Define or override one provider's offering of a model. The table key is the +canonical model slug Fabro users reference. An offering's identity is the +pair `(provider, model slug)`, so different providers may use the same slug +and aliases. `api_id` is the opaque model string sent to this provider's API +and defaults to the exact model slug. ```toml title="settings.toml" -[llm.models."team-code-large"] -provider = "proxy" +[llm.providers.proxy.models."team-code-large"] api_id = "provider-wire-model-name" agent_profile = "anthropic" display_name = "Team Code Large" @@ -224,27 +224,27 @@ enabled = true aliases = ["team-code"] estimated_output_tps = 80 -[llm.models."team-code-large".limits] +[llm.providers.proxy.models."team-code-large".limits] context_window = 200000 max_output = 32000 -[llm.models."team-code-large".features] +[llm.providers.proxy.models."team-code-large".features] tools = true vision = false reasoning = true reasoning_effort = "levels" prompt_cache = true -[llm.models."team-code-large".controls] +[llm.providers.proxy.models."team-code-large".controls] reasoning_effort = ["low", "medium", "high"] speed = ["fast"] -[llm.models."team-code-large".costs] +[llm.providers.proxy.models."team-code-large".costs] input_cost_per_mtok = 1.50 output_cost_per_mtok = 8.00 cache_input_cost_per_mtok = 0.30 -[llm.models."team-code-large".costs.speed.fast] +[llm.providers.proxy.models."team-code-large".costs.speed.fast] input_cost_per_mtok = 3.00 output_cost_per_mtok = 16.00 cache_input_cost_per_mtok = 0.60 @@ -252,8 +252,7 @@ cache_input_cost_per_mtok = 0.60 | Key | Type / values | Default | Description | |---|---|---|---| -| `provider` | string | None | Provider ID this model belongs to. | -| `api_id` | string | model ID | Identifier sent to the provider API. | +| `api_id` | string | model slug | Opaque identifier sent to this provider's API. An explicitly empty value is invalid. | | `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | provider profile | Agent profile override for this model. Model overrides take precedence over provider overrides. | | `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | provider policy | Billing algorithm override for this model — for models whose billing family differs from their provider's (e.g. Claude served through OpenRouter bills Anthropic-style cache reads/writes). | | `display_name` | string | model ID | Human-readable model name. | @@ -263,17 +262,17 @@ cache_input_cost_per_mtok = 0.60 | `default` | boolean | `false` | Whether this is the provider default model. | | `probe` | boolean | `false` | Whether this model should be preferred for provider connectivity probes. Set `false` in a higher-precedence layer to clear an inherited probe marker. | | `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. | -| `aliases` | array | `[]` | Additional model names accepted by routing and fallback config. | +| `aliases` | array | `[]` | Additional model selectors accepted by routing and fallback config. Aliases may repeat across providers, but one selector cannot identify two models within the same provider. | | `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. | -## `[llm.models..limits]` +## `[llm.providers..models..limits]` | Key | Type / values | Default | Description | |---|---|---|---| | `context_window` | integer | None | Maximum context window size in tokens. | | `max_output` | integer | None | Maximum output tokens, if known. | -## `[llm.models..features]` +## `[llm.providers..models..features]` | Key | Type / values | Default | Description | |---|---|---|---| @@ -284,14 +283,14 @@ cache_input_cost_per_mtok = 0.60 | `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. | | `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). | -## `[llm.models..controls]` +## `[llm.providers..models..controls]` | Key | Type / values | Default | Description | |---|---|---|---| | `reasoning_effort` | array | all standard levels when feature is `"levels"` or `"always_adaptive"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. | | `speed` | array | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. | -## `[llm.models..costs]` +## `[llm.providers..models..costs]` | Key | Type / values | Default | Description | |---|---|---|---| @@ -299,10 +298,12 @@ cache_input_cost_per_mtok = 0.60 | `output_cost_per_mtok` | number | None | Output cost in USD per million tokens. | | `cache_input_cost_per_mtok` | number | None | Cached input/read cost in USD per million tokens. | -## `[llm.models..costs.speed.]` +## `[llm.providers..models..costs.speed.]` -Per-speed cost overrides use the same keys as `[llm.models..costs]`. -Each `` key must be declared in `[llm.models..controls].speed`. +Per-speed cost overrides use the same keys as +`[llm.providers..models..costs]`. Each `` key +must be declared in +`[llm.providers..models..controls].speed`. The `standard` speed is implicit and always uses the base cost table. ## `[cli.updates]` diff --git a/docs/public/workflows/stages-and-nodes.mdx b/docs/public/workflows/stages-and-nodes.mdx index 9263d1ea7..819befe7e 100644 --- a/docs/public/workflows/stages-and-nodes.mdx +++ b/docs/public/workflows/stages-and-nodes.mdx @@ -171,6 +171,8 @@ fork -> quality Because the checkout is shared, file changes from one branch are immediately visible to the others. Concurrent writes can race or overwrite each other. Fabro does not isolate branch files, lock paths, detect conflicts, or warn about overlapping writes. Design branches to be read-only or assign each branch disjoint files and directories when deterministic workspace changes matter. +For each branch's first node, fidelity resolves from the fork-to-branch edge, then the branch node; otherwise it inherits the fork preamble unchanged. Fabro renders branch-specific preambles before fan-out from the fork snapshot. Branch-level `full` degrades to `summary:high` because concurrent branches cannot share sessions, and `thread_id` on a branch node or fork-to-branch edge is inert. + ### Merge (fan-in) **Shape:** `tripleoctagon` diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/apps/fabro-cli/Cargo.toml similarity index 55% rename from lib/crates/fabro-cli/Cargo.toml rename to lib/apps/fabro-cli/Cargo.toml index 6f14e385e..44c9982bc 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/apps/fabro-cli/Cargo.toml @@ -18,40 +18,40 @@ sleep_inhibitor = ["dep:core-foundation"] workspace = true [dependencies] -fabro-auth = { path = "../fabro-auth" } -fabro-config = { path = "../fabro-config" } -fabro-environment = { path = "../fabro-environment" } -fabro-llm = { path = "../fabro-llm" } -fabro-model = { path = "../fabro-model" } -fabro-oauth = { path = "../fabro-oauth" } -fabro-github = { path = "../fabro-github" } -fabro-agent = { path = "../fabro-agent" } -fabro-dump = { path = "../fabro-dump" } -fabro-hooks = { path = "../fabro-hooks" } -fabro-install = { path = "../fabro-install" } -fabro-interview = { path = "../fabro-interview" } -fabro-mcp = { path = "../fabro-mcp" } +fabro-auth = { path = "../../foundation/fabro-auth" } +fabro-config = { path = "../../foundation/fabro-config" } +fabro-environment = { path = "../../components/fabro-environment" } +fabro-llm = { path = "../../components/fabro-llm" } +fabro-model = { path = "../../foundation/fabro-model" } +fabro-oauth = { path = "../../foundation/fabro-oauth" } +fabro-github = { path = "../../components/fabro-github" } +fabro-agent = { path = "../../components/fabro-agent" } +fabro-dump = { path = "../../components/fabro-dump" } +fabro-hooks = { path = "../../components/fabro-hooks" } +fabro-install = { path = "../../components/fabro-install" } +fabro-interview = { path = "../../components/fabro-interview" } +fabro-mcp = { path = "../../components/fabro-mcp" } fabro-mcp-server = { path = "../fabro-mcp-server" } -fabro-manifest = { path = "../fabro-manifest" } -fabro-proc = { path = "../fabro-proc" } -fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] } -fabro-checkpoint = { path = "../fabro-checkpoint" } -fabro-graphviz = { path = "../fabro-graphviz" } -fabro-validate = { path = "../fabro-validate" } -fabro-workflow = { path = "../fabro-workflow" } +fabro-manifest = { path = "../../components/fabro-manifest" } +fabro-proc = { path = "../../foundation/fabro-proc" } +fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona"] } +fabro-checkpoint = { path = "../../components/fabro-checkpoint" } +fabro-graphviz = { path = "../../components/fabro-graphviz" } +fabro-validate = { path = "../../components/fabro-validate" } +fabro-workflow = { path = "../../components/fabro-workflow" } fabro-server = { path = "../fabro-server" } -fabro-client = { path = "../fabro-client" } -fabro-api = { path = "../fabro-api" } -fabro-telemetry = { path = "../fabro-telemetry" } -fabro-store = { path = "../fabro-store" } -fabro-vault = { path = "../fabro-vault" } -fabro-types = { path = "../fabro-types", features = ["clap"] } +fabro-client = { path = "../../foundation/fabro-client" } +fabro-api = { path = "../../foundation/fabro-api" } +fabro-telemetry = { path = "../../foundation/fabro-telemetry" } +fabro-store = { path = "../../components/fabro-store" } +fabro-vault = { path = "../../foundation/fabro-vault" } +fabro-types = { path = "../../foundation/fabro-types", features = ["clap"] } fabro-redact.workspace = true -fabro-util = { path = "../fabro-util" } +fabro-util = { path = "../../foundation/fabro-util" } fabro-http.workspace = true fabro-static.workspace = true -fabro-template = { path = "../fabro-template" } -fabro-tool = { path = "../fabro-tool" } +fabro-template = { path = "../../foundation/fabro-template" } +fabro-tool = { path = "../../components/fabro-tool" } clap.workspace = true clap_complete.workspace = true cli-table.workspace = true @@ -111,15 +111,16 @@ core-foundation = { version = "0.9", optional = true } openssl = { version = "0.10", features = ["vendored"] } [build-dependencies] -fabro-build-support = { path = "../build-support" } +fabro-build-support = { path = "../../foundation/build-support" } chrono = { workspace = true } [dev-dependencies] assert_cmd = "2" -fabro-acp = { path = "../fabro-acp", features = ["test-support"] } -fabro-build-support = { path = "../build-support" } +fabro-acp = { path = "../../components/fabro-acp", features = ["test-support"] } +fabro-build-support = { path = "../../foundation/build-support" } fabro-server = { path = "../fabro-server", features = ["test-support"] } -fabro-types = { path = "../fabro-types", features = ["clap", "test-support"] } +fabro-workflow = { path = "../../components/fabro-workflow", features = ["test-support"] } +fabro-types = { path = "../../foundation/fabro-types", features = ["clap", "test-support"] } insta = { workspace = true, features = ["filters"] } paste = "1" predicates = "3" @@ -128,7 +129,7 @@ tempfile = "3" temp-env = "0.3" httpmock = "0.8" fabro-test = { workspace = true } -fabro-macros = { path = "../fabro-macros" } +fabro-macros = { path = "../../foundation/fabro-macros" } hkdf.workspace = true reqwest = { workspace = true, features = ["cookies"] } tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/crates/fabro-cli/build.rs b/lib/apps/fabro-cli/build.rs similarity index 100% rename from lib/crates/fabro-cli/build.rs rename to lib/apps/fabro-cli/build.rs diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/apps/fabro-cli/src/args.rs similarity index 100% rename from lib/crates/fabro-cli/src/args.rs rename to lib/apps/fabro-cli/src/args.rs diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/apps/fabro-cli/src/command_context.rs similarity index 100% rename from lib/crates/fabro-cli/src/command_context.rs rename to lib/apps/fabro-cli/src/command_context.rs diff --git a/lib/crates/fabro-cli/src/commands/artifact/cp.rs b/lib/apps/fabro-cli/src/commands/artifact/cp.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/artifact/cp.rs rename to lib/apps/fabro-cli/src/commands/artifact/cp.rs diff --git a/lib/crates/fabro-cli/src/commands/artifact/list.rs b/lib/apps/fabro-cli/src/commands/artifact/list.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/artifact/list.rs rename to lib/apps/fabro-cli/src/commands/artifact/list.rs diff --git a/lib/crates/fabro-cli/src/commands/artifact/mod.rs b/lib/apps/fabro-cli/src/commands/artifact/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/artifact/mod.rs rename to lib/apps/fabro-cli/src/commands/artifact/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/auth/login.rs b/lib/apps/fabro-cli/src/commands/auth/login.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/auth/login.rs rename to lib/apps/fabro-cli/src/commands/auth/login.rs diff --git a/lib/crates/fabro-cli/src/commands/auth/logout.rs b/lib/apps/fabro-cli/src/commands/auth/logout.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/auth/logout.rs rename to lib/apps/fabro-cli/src/commands/auth/logout.rs diff --git a/lib/crates/fabro-cli/src/commands/auth/mod.rs b/lib/apps/fabro-cli/src/commands/auth/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/auth/mod.rs rename to lib/apps/fabro-cli/src/commands/auth/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/auth/status.rs b/lib/apps/fabro-cli/src/commands/auth/status.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/auth/status.rs rename to lib/apps/fabro-cli/src/commands/auth/status.rs diff --git a/lib/crates/fabro-cli/src/commands/cli_reference.rs b/lib/apps/fabro-cli/src/commands/cli_reference.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/cli_reference.rs rename to lib/apps/fabro-cli/src/commands/cli_reference.rs diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/apps/fabro-cli/src/commands/config/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/config/mod.rs rename to lib/apps/fabro-cli/src/commands/config/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/apps/fabro-cli/src/commands/doctor.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/doctor.rs rename to lib/apps/fabro-cli/src/commands/doctor.rs diff --git a/lib/crates/fabro-cli/src/commands/dump.rs b/lib/apps/fabro-cli/src/commands/dump.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/dump.rs rename to lib/apps/fabro-cli/src/commands/dump.rs diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/apps/fabro-cli/src/commands/exec.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/exec.rs rename to lib/apps/fabro-cli/src/commands/exec.rs diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/apps/fabro-cli/src/commands/graph.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/graph.rs rename to lib/apps/fabro-cli/src/commands/graph.rs diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/install.rs rename to lib/apps/fabro-cli/src/commands/install.rs diff --git a/lib/crates/fabro-cli/src/commands/mcp/mod.rs b/lib/apps/fabro-cli/src/commands/mcp/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/mcp/mod.rs rename to lib/apps/fabro-cli/src/commands/mcp/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/apps/fabro-cli/src/commands/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/mod.rs rename to lib/apps/fabro-cli/src/commands/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/apps/fabro-cli/src/commands/model.rs similarity index 80% rename from lib/crates/fabro-cli/src/commands/model.rs rename to lib/apps/fabro-cli/src/commands/model.rs index e63b926f4..f84bd44e2 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/apps/fabro-cli/src/commands/model.rs @@ -45,10 +45,21 @@ struct CompletedModelTest { status: String, } -fn find_model_by_id_or_alias(models: &[Model], id: &str) -> Option { +fn model_matches_selector(model: &Model, selector: &str) -> bool { + model.id == selector || model.aliases.iter().any(|alias| alias == selector) +} + +fn find_model_by_id_or_alias( + models: &[Model], + id: &str, + provider: Option<&ProviderId>, +) -> Option { models .iter() - .find(|model| model.id == id || model.aliases.iter().any(|alias| alias == id)) + .find(|model| { + provider.is_none_or(|provider| &model.provider == provider) + && model_matches_selector(model, id) + }) .cloned() } @@ -116,7 +127,7 @@ fn model_row(model: &Model, use_color: bool) -> Vec { format_cost(model.costs.output_cost_per_mtok), ); vec![ - model.id.clone().cell().bold(use_color), + model.id.as_str().cell().bold(use_color), model .provider .as_str() @@ -172,9 +183,20 @@ fn print_models_table(models: &[Model], styles: &Styles) { } fn configured_model_test_status( + expected: &Model, result: Result, ) -> (Color, String, bool) { match result { + Ok(resp) if resp.provider != expected.provider || resp.model_id != expected.id.as_str() => { + ( + Color::Red, + format!( + "error: server tested unexpected offering {}/{}", + resp.provider, resp.model_id + ), + true, + ) + } Ok(resp) if resp.status == api_types::ModelTestResultStatus::Ok => { (Color::Green, "ok".to_string(), false) } @@ -197,21 +219,21 @@ fn model_test_row_from_status(model: &Model, status: &str, result_color: Color) let trimmed = status.trim(); match result_color { Color::Green => ModelTestRow { - model: model.id.clone(), + model: model.id.to_string(), provider: model.provider.clone(), result: ModelTestResultKind::Pass, detail: None, error: None, }, Color::Yellow => ModelTestRow { - model: model.id.clone(), + model: model.id.to_string(), provider: model.provider.clone(), result: ModelTestResultKind::Skip, detail: Some(trimmed.to_string()), error: None, }, _ => ModelTestRow { - model: model.id.clone(), + model: model.id.to_string(), provider: model.provider.clone(), result: ModelTestResultKind::Fail, detail: None, @@ -251,21 +273,45 @@ async fn test_models_via_server( let mut skipped = 0u32; let mut skipped_providers: Vec = Vec::new(); if let Some(model_id) = model { - let listed_models = client.list_models(None, Some(model_id)).await?; - let listed_info = find_model_by_id_or_alias(&listed_models, model_id); + let requested_provider = provider.map(ProviderId::new); + let listed_models = client.list_models(provider, Some(model_id)).await?; + let listed_info = find_model_by_id_or_alias(&listed_models, model_id, None); if !json_output { eprint!("Testing {model_id}..."); } - let result = client.test_model(model_id, request_mode).await; + let has_configured_match = listed_models + .iter() + .any(|model| model.configured && model_matches_selector(model, model_id)); + let result = + if requested_provider.is_none() && listed_info.is_some() && !has_configured_match { + None + } else { + Some( + client + .test_model(model_id, requested_provider.as_ref(), request_mode) + .await, + ) + }; if !json_output { eprintln!(" done"); } let (info, result_color, status) = match result { - Ok(resp) => { - let info = find_model_by_id_or_alias(&listed_models, &resp.model_id).with_context( - || format!("Unknown model returned by server: {}", resp.model_id), - )?; + None => { + let info = listed_info.with_context(|| format!("Unknown model: {model_id}"))?; + failures += 1; + skipped += 1; + (info, Color::Yellow, "not configured".to_string()) + } + Some(Ok(resp)) => { + let info = + find_model_by_id_or_alias(&listed_models, &resp.model_id, Some(&resp.provider)) + .with_context(|| { + format!( + "Unknown model returned by server: {}/{}", + resp.provider, resp.model_id + ) + })?; if resp.status == api_types::ModelTestResultStatus::Ok { (info, Color::Green, "ok".to_string()) } else if resp.status == api_types::ModelTestResultStatus::Skip { @@ -280,10 +326,10 @@ async fn test_models_via_server( (info, Color::Red, format!("error: {message}")) } } - Err(err) if err.to_string().contains("Model not found") => { + Some(Err(err)) if err.to_string().contains("Model not found") => { bail!("Unknown model: {model_id}"); } - Err(err) => { + Some(Err(err)) => { let info = listed_info.with_context(|| format!("Unknown model: {model_id}"))?; failures += 1; (info, Color::Red, format!("error: {err}")) @@ -328,11 +374,14 @@ async fn test_models_via_server( .map(|(index, info)| { let client = client.clone(); async move { - let result = client.test_model(&info.id, request_mode).await; + let result = client + .test_model(info.id.as_str(), Some(&info.provider), request_mode) + .await; if !json_output { eprintln!("Testing {}... done", info.id); } - let (result_color, status, failed) = configured_model_test_status(result); + let (result_color, status, failed) = + configured_model_test_status(&info, result); ( CompletedModelTest { index, @@ -478,7 +527,7 @@ mod tests { fn test_model_json(id: &str, provider: ProviderId) -> serde_json::Value { serde_json::to_value(Model { - id: id.to_string(), + id: id.into(), provider, family: "test".to_string(), display_name: format!("{id} display"), @@ -489,12 +538,13 @@ mod tests { training: None, knowledge_cutoff: None, features: ModelFeatures { - tools: true, - vision: false, - reasoning: false, - reasoning_effort: ReasoningEffortFeature::None, - prompt_cache: false, - sampling_params: true, + tools: true, + vision: false, + reasoning: false, + reasoning_effort: ReasoningEffortFeature::None, + prompt_cache: false, + cache_control_breakpoints: false, + sampling_params: true, }, costs: ModelCosts { input_cost_per_mtok: Some(1.0), @@ -512,7 +562,7 @@ mod tests { fn custom_model_json(id: &str, provider: &str) -> serde_json::Value { serde_json::to_value(Model { - id: id.to_string(), + id: id.into(), provider: ProviderId::new(provider), family: "test".to_string(), display_name: format!("{id} display"), @@ -523,12 +573,13 @@ mod tests { training: None, knowledge_cutoff: None, features: ModelFeatures { - tools: true, - vision: false, - reasoning: false, - reasoning_effort: ReasoningEffortFeature::None, - prompt_cache: false, - sampling_params: true, + tools: true, + vision: false, + reasoning: false, + reasoning_effort: ReasoningEffortFeature::None, + prompt_cache: false, + cache_control_breakpoints: false, + sampling_params: true, }, costs: ModelCosts { input_cost_per_mtok: Some(1.0), @@ -610,6 +661,7 @@ mod tests { .body( serde_json::json!({ "model_id": "test-model", + "provider": "anthropic", "status": "ok" }) .to_string(), @@ -618,7 +670,7 @@ mod tests { .await; let client = test_client(&server.url("")); - let response = client.test_model("test-model", None).await.unwrap(); + let response = client.test_model("test-model", None, None).await.unwrap(); assert_eq!(response.status, api_types::ModelTestResultStatus::Ok); assert!(response.error_message.is_none()); @@ -637,6 +689,7 @@ mod tests { .body( serde_json::json!({ "model_id": "test-model", + "provider": "anthropic", "status": "error", "error_message": "timeout" }) @@ -647,7 +700,7 @@ mod tests { let client = test_client(&server.url("")); let response = client - .test_model("test-model", Some(ModelTestMode::Deep)) + .test_model("test-model", None, Some(ModelTestMode::Deep)) .await .unwrap(); @@ -666,6 +719,7 @@ mod tests { .body( serde_json::json!({ "model_id": "kimi-k2.5", + "provider": "kimi", "status": "skip" }) .to_string(), @@ -674,7 +728,7 @@ mod tests { .await; let client = test_client(&server.url("")); - let response = client.test_model("kimi-k2.5", None).await.unwrap(); + let response = client.test_model("kimi-k2.5", None, None).await.unwrap(); assert_eq!(response.status, api_types::ModelTestResultStatus::Skip); assert!(response.error_message.is_none()); @@ -698,7 +752,7 @@ mod tests { .await; let client = test_client(&server.url("")); - let result = client.test_model("bad-model", None).await; + let result = client.test_model("bad-model", None, None).await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Model not found")); } @@ -732,6 +786,7 @@ mod tests { .body( serde_json::json!({ "model_id": "venice-large", + "provider": "venice", "status": "ok" }) .to_string(), @@ -754,6 +809,80 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn bulk_model_test_keeps_duplicate_ids_scoped_by_provider() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method("GET") + .path("/api/v1/models") + .query_param("page[limit]", "100") + .query_param("page[offset]", "0"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": [ + custom_model_json("portable-model", "openai"), + custom_model_json("portable-model", "openrouter") + ], + "meta": { "has_more": false } + }) + .to_string(), + ); + }) + .await; + let openai = server + .mock_async(|when, then| { + when.method("POST") + .path("/api/v1/models/portable-model/test") + .query_param("provider", "openai"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "model_id": "portable-model", + "provider": "openai", + "status": "ok" + }) + .to_string(), + ); + }) + .await; + let openrouter = server + .mock_async(|when, then| { + when.method("POST") + .path("/api/v1/models/portable-model/test") + .query_param("provider", "openrouter"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "model_id": "portable-model", + "provider": "openrouter", + "status": "ok" + }) + .to_string(), + ); + }) + .await; + + test_models_via_server( + &test_client(&server.url("")), + None, + None, + false, + 2, + &Styles::new(false), + true, + ) + .await + .unwrap(); + + openai.assert_async().await; + openrouter.assert_async().await; + } + #[tokio::test] async fn fetch_models_from_server_parses_response() { let server = httpmock::MockServer::start_async().await; diff --git a/lib/crates/fabro-cli/src/commands/parent/link.rs b/lib/apps/fabro-cli/src/commands/parent/link.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/parent/link.rs rename to lib/apps/fabro-cli/src/commands/parent/link.rs diff --git a/lib/crates/fabro-cli/src/commands/parent/mod.rs b/lib/apps/fabro-cli/src/commands/parent/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/parent/mod.rs rename to lib/apps/fabro-cli/src/commands/parent/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/parent/unlink.rs b/lib/apps/fabro-cli/src/commands/parent/unlink.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/parent/unlink.rs rename to lib/apps/fabro-cli/src/commands/parent/unlink.rs diff --git a/lib/crates/fabro-cli/src/commands/parse.rs b/lib/apps/fabro-cli/src/commands/parse.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/parse.rs rename to lib/apps/fabro-cli/src/commands/parse.rs diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/apps/fabro-cli/src/commands/pr/close.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/pr/close.rs rename to lib/apps/fabro-cli/src/commands/pr/close.rs diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/apps/fabro-cli/src/commands/pr/create.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/pr/create.rs rename to lib/apps/fabro-cli/src/commands/pr/create.rs diff --git a/lib/crates/fabro-cli/src/commands/pr/link.rs b/lib/apps/fabro-cli/src/commands/pr/link.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/pr/link.rs rename to lib/apps/fabro-cli/src/commands/pr/link.rs diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/apps/fabro-cli/src/commands/pr/merge.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/pr/merge.rs rename to lib/apps/fabro-cli/src/commands/pr/merge.rs diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/apps/fabro-cli/src/commands/pr/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/pr/mod.rs rename to lib/apps/fabro-cli/src/commands/pr/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/pr/unlink.rs b/lib/apps/fabro-cli/src/commands/pr/unlink.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/pr/unlink.rs rename to lib/apps/fabro-cli/src/commands/pr/unlink.rs diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/apps/fabro-cli/src/commands/pr/view.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/pr/view.rs rename to lib/apps/fabro-cli/src/commands/pr/view.rs diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/apps/fabro-cli/src/commands/preflight.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/preflight.rs rename to lib/apps/fabro-cli/src/commands/preflight.rs diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/apps/fabro-cli/src/commands/provider/login.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/provider/login.rs rename to lib/apps/fabro-cli/src/commands/provider/login.rs diff --git a/lib/crates/fabro-cli/src/commands/provider/mod.rs b/lib/apps/fabro-cli/src/commands/provider/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/provider/mod.rs rename to lib/apps/fabro-cli/src/commands/provider/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/render_graph.rs b/lib/apps/fabro-cli/src/commands/render_graph.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/render_graph.rs rename to lib/apps/fabro-cli/src/commands/render_graph.rs diff --git a/lib/crates/fabro-cli/src/commands/repo/deinit.rs b/lib/apps/fabro-cli/src/commands/repo/deinit.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/repo/deinit.rs rename to lib/apps/fabro-cli/src/commands/repo/deinit.rs diff --git a/lib/crates/fabro-cli/src/commands/repo/init.rs b/lib/apps/fabro-cli/src/commands/repo/init.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/repo/init.rs rename to lib/apps/fabro-cli/src/commands/repo/init.rs diff --git a/lib/crates/fabro-cli/src/commands/repo/mod.rs b/lib/apps/fabro-cli/src/commands/repo/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/repo/mod.rs rename to lib/apps/fabro-cli/src/commands/repo/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/run/ask.rs b/lib/apps/fabro-cli/src/commands/run/ask.rs similarity index 96% rename from lib/crates/fabro-cli/src/commands/run/ask.rs rename to lib/apps/fabro-cli/src/commands/run/ask.rs index 1b38ad734..2c7819186 100644 --- a/lib/crates/fabro-cli/src/commands/run/ask.rs +++ b/lib/apps/fabro-cli/src/commands/run/ask.rs @@ -11,8 +11,9 @@ pub(crate) async fn run(args: AskArgs, base_ctx: &CommandContext) -> Result<()> let run_id = client.resolve_run(&args.run).await?.id; let session = client .create_run_session(run_id, CreateRunSessionRequest { - title: Some(session_title(&args.prompt)), - model: args.model, + title: Some(session_title(&args.prompt)), + model: args.model, + provider: None, }) .await?; let mut stream = client diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/apps/fabro-cli/src/commands/run/attach.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/attach.rs rename to lib/apps/fabro-cli/src/commands/run/attach.rs diff --git a/lib/crates/fabro-cli/src/commands/run/checkpoints.rs b/lib/apps/fabro-cli/src/commands/run/checkpoints.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/checkpoints.rs rename to lib/apps/fabro-cli/src/commands/run/checkpoints.rs diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/apps/fabro-cli/src/commands/run/command.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/command.rs rename to lib/apps/fabro-cli/src/commands/run/command.rs diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/apps/fabro-cli/src/commands/run/cp.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/cp.rs rename to lib/apps/fabro-cli/src/commands/run/cp.rs diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/apps/fabro-cli/src/commands/run/create.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/create.rs rename to lib/apps/fabro-cli/src/commands/run/create.rs diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/apps/fabro-cli/src/commands/run/diff.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/diff.rs rename to lib/apps/fabro-cli/src/commands/run/diff.rs diff --git a/lib/crates/fabro-cli/src/commands/run/events.rs b/lib/apps/fabro-cli/src/commands/run/events.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/events.rs rename to lib/apps/fabro-cli/src/commands/run/events.rs diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/apps/fabro-cli/src/commands/run/fork.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/fork.rs rename to lib/apps/fabro-cli/src/commands/run/fork.rs diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/apps/fabro-cli/src/commands/run/logs.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/logs.rs rename to lib/apps/fabro-cli/src/commands/run/logs.rs diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/apps/fabro-cli/src/commands/run/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/mod.rs rename to lib/apps/fabro-cli/src/commands/run/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/apps/fabro-cli/src/commands/run/output.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/output.rs rename to lib/apps/fabro-cli/src/commands/run/output.rs diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/apps/fabro-cli/src/commands/run/overrides.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/overrides.rs rename to lib/apps/fabro-cli/src/commands/run/overrides.rs diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/apps/fabro-cli/src/commands/run/preview.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/preview.rs rename to lib/apps/fabro-cli/src/commands/run/preview.rs diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/apps/fabro-cli/src/commands/run/resume.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/resume.rs rename to lib/apps/fabro-cli/src/commands/run/resume.rs diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/apps/fabro-cli/src/commands/run/rewind.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/rewind.rs rename to lib/apps/fabro-cli/src/commands/run/rewind.rs diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs similarity index 99% rename from lib/crates/fabro-cli/src/commands/run/run_progress/event.rs rename to lib/apps/fabro-cli/src/commands/run/run_progress/event.rs index 6f8498411..91e8edaaf 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs @@ -323,7 +323,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { EventBody::ParallelCompleted(_) => Some(ProgressEvent::ParallelCompleted), EventBody::AgentMessage(props) => Some(ProgressEvent::AssistantMessage { stage_node_id: node_id, - model: props.model.model_id.clone(), + model: props.model.model_id.to_string(), }), EventBody::AgentToolStarted(props) => Some(ProgressEvent::ToolCallStarted { stage_node_id: node_id, diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/info_display.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/run_progress/info_display.rs rename to lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs rename to lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/renderer.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs rename to lib/apps/fabro-cli/src/commands/run/run_progress/renderer.rs diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/setup_display.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs rename to lib/apps/fabro-cli/src/commands/run/run_progress/setup_display.rs diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs rename to lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/styles.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/styles.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/run_progress/styles.rs rename to lib/apps/fabro-cli/src/commands/run/run_progress/styles.rs diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/apps/fabro-cli/src/commands/run/runner.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/runner.rs rename to lib/apps/fabro-cli/src/commands/run/runner.rs diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/apps/fabro-cli/src/commands/run/ssh.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/ssh.rs rename to lib/apps/fabro-cli/src/commands/run/ssh.rs diff --git a/lib/crates/fabro-cli/src/commands/run/start.rs b/lib/apps/fabro-cli/src/commands/run/start.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/start.rs rename to lib/apps/fabro-cli/src/commands/run/start.rs diff --git a/lib/crates/fabro-cli/src/commands/run/steer.rs b/lib/apps/fabro-cli/src/commands/run/steer.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/steer.rs rename to lib/apps/fabro-cli/src/commands/run/steer.rs diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/apps/fabro-cli/src/commands/run/wait.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/run/wait.rs rename to lib/apps/fabro-cli/src/commands/run/wait.rs diff --git a/lib/crates/fabro-cli/src/commands/runs/approval.rs b/lib/apps/fabro-cli/src/commands/runs/approval.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/runs/approval.rs rename to lib/apps/fabro-cli/src/commands/runs/approval.rs diff --git a/lib/crates/fabro-cli/src/commands/runs/archive.rs b/lib/apps/fabro-cli/src/commands/runs/archive.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/runs/archive.rs rename to lib/apps/fabro-cli/src/commands/runs/archive.rs diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/apps/fabro-cli/src/commands/runs/inspect.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/runs/inspect.rs rename to lib/apps/fabro-cli/src/commands/runs/inspect.rs diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/apps/fabro-cli/src/commands/runs/list.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/runs/list.rs rename to lib/apps/fabro-cli/src/commands/runs/list.rs diff --git a/lib/crates/fabro-cli/src/commands/runs/mod.rs b/lib/apps/fabro-cli/src/commands/runs/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/runs/mod.rs rename to lib/apps/fabro-cli/src/commands/runs/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/apps/fabro-cli/src/commands/runs/rm.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/runs/rm.rs rename to lib/apps/fabro-cli/src/commands/runs/rm.rs diff --git a/lib/crates/fabro-cli/src/commands/sandbox/mod.rs b/lib/apps/fabro-cli/src/commands/sandbox/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/sandbox/mod.rs rename to lib/apps/fabro-cli/src/commands/sandbox/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/secret/list.rs b/lib/apps/fabro-cli/src/commands/secret/list.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/secret/list.rs rename to lib/apps/fabro-cli/src/commands/secret/list.rs diff --git a/lib/crates/fabro-cli/src/commands/secret/mod.rs b/lib/apps/fabro-cli/src/commands/secret/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/secret/mod.rs rename to lib/apps/fabro-cli/src/commands/secret/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/secret/rm.rs b/lib/apps/fabro-cli/src/commands/secret/rm.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/secret/rm.rs rename to lib/apps/fabro-cli/src/commands/secret/rm.rs diff --git a/lib/crates/fabro-cli/src/commands/secret/set.rs b/lib/apps/fabro-cli/src/commands/secret/set.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/secret/set.rs rename to lib/apps/fabro-cli/src/commands/secret/set.rs diff --git a/lib/crates/fabro-cli/src/commands/server/foreground.rs b/lib/apps/fabro-cli/src/commands/server/foreground.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/server/foreground.rs rename to lib/apps/fabro-cli/src/commands/server/foreground.rs diff --git a/lib/crates/fabro-cli/src/commands/server/mod.rs b/lib/apps/fabro-cli/src/commands/server/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/server/mod.rs rename to lib/apps/fabro-cli/src/commands/server/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/apps/fabro-cli/src/commands/server/start.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/server/start.rs rename to lib/apps/fabro-cli/src/commands/server/start.rs diff --git a/lib/crates/fabro-cli/src/commands/server/status.rs b/lib/apps/fabro-cli/src/commands/server/status.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/server/status.rs rename to lib/apps/fabro-cli/src/commands/server/status.rs diff --git a/lib/crates/fabro-cli/src/commands/server/stop.rs b/lib/apps/fabro-cli/src/commands/server/stop.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/server/stop.rs rename to lib/apps/fabro-cli/src/commands/server/stop.rs diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/apps/fabro-cli/src/commands/system/df.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/system/df.rs rename to lib/apps/fabro-cli/src/commands/system/df.rs diff --git a/lib/crates/fabro-cli/src/commands/system/events.rs b/lib/apps/fabro-cli/src/commands/system/events.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/system/events.rs rename to lib/apps/fabro-cli/src/commands/system/events.rs diff --git a/lib/crates/fabro-cli/src/commands/system/info.rs b/lib/apps/fabro-cli/src/commands/system/info.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/system/info.rs rename to lib/apps/fabro-cli/src/commands/system/info.rs diff --git a/lib/crates/fabro-cli/src/commands/system/mod.rs b/lib/apps/fabro-cli/src/commands/system/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/system/mod.rs rename to lib/apps/fabro-cli/src/commands/system/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/apps/fabro-cli/src/commands/system/prune.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/system/prune.rs rename to lib/apps/fabro-cli/src/commands/system/prune.rs diff --git a/lib/crates/fabro-cli/src/commands/system/repair.rs b/lib/apps/fabro-cli/src/commands/system/repair.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/system/repair.rs rename to lib/apps/fabro-cli/src/commands/system/repair.rs diff --git a/lib/crates/fabro-cli/src/commands/uninstall.rs b/lib/apps/fabro-cli/src/commands/uninstall.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/uninstall.rs rename to lib/apps/fabro-cli/src/commands/uninstall.rs diff --git a/lib/crates/fabro-cli/src/commands/upgrade.rs b/lib/apps/fabro-cli/src/commands/upgrade.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/upgrade.rs rename to lib/apps/fabro-cli/src/commands/upgrade.rs diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/apps/fabro-cli/src/commands/validate.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/validate.rs rename to lib/apps/fabro-cli/src/commands/validate.rs diff --git a/lib/crates/fabro-cli/src/commands/variable/get.rs b/lib/apps/fabro-cli/src/commands/variable/get.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/variable/get.rs rename to lib/apps/fabro-cli/src/commands/variable/get.rs diff --git a/lib/crates/fabro-cli/src/commands/variable/list.rs b/lib/apps/fabro-cli/src/commands/variable/list.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/variable/list.rs rename to lib/apps/fabro-cli/src/commands/variable/list.rs diff --git a/lib/crates/fabro-cli/src/commands/variable/mod.rs b/lib/apps/fabro-cli/src/commands/variable/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/variable/mod.rs rename to lib/apps/fabro-cli/src/commands/variable/mod.rs diff --git a/lib/crates/fabro-cli/src/commands/variable/rm.rs b/lib/apps/fabro-cli/src/commands/variable/rm.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/variable/rm.rs rename to lib/apps/fabro-cli/src/commands/variable/rm.rs diff --git a/lib/crates/fabro-cli/src/commands/variable/set.rs b/lib/apps/fabro-cli/src/commands/variable/set.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/variable/set.rs rename to lib/apps/fabro-cli/src/commands/variable/set.rs diff --git a/lib/crates/fabro-cli/src/commands/version.rs b/lib/apps/fabro-cli/src/commands/version.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/version.rs rename to lib/apps/fabro-cli/src/commands/version.rs diff --git a/lib/crates/fabro-cli/src/commands/workflow/create.rs b/lib/apps/fabro-cli/src/commands/workflow/create.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/workflow/create.rs rename to lib/apps/fabro-cli/src/commands/workflow/create.rs diff --git a/lib/crates/fabro-cli/src/commands/workflow/list.rs b/lib/apps/fabro-cli/src/commands/workflow/list.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/workflow/list.rs rename to lib/apps/fabro-cli/src/commands/workflow/list.rs diff --git a/lib/crates/fabro-cli/src/commands/workflow/mod.rs b/lib/apps/fabro-cli/src/commands/workflow/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/workflow/mod.rs rename to lib/apps/fabro-cli/src/commands/workflow/mod.rs diff --git a/lib/crates/fabro-cli/src/gh.rs b/lib/apps/fabro-cli/src/gh.rs similarity index 100% rename from lib/crates/fabro-cli/src/gh.rs rename to lib/apps/fabro-cli/src/gh.rs diff --git a/lib/crates/fabro-cli/src/landing.rs b/lib/apps/fabro-cli/src/landing.rs similarity index 100% rename from lib/crates/fabro-cli/src/landing.rs rename to lib/apps/fabro-cli/src/landing.rs diff --git a/lib/crates/fabro-cli/src/local_server.rs b/lib/apps/fabro-cli/src/local_server.rs similarity index 100% rename from lib/crates/fabro-cli/src/local_server.rs rename to lib/apps/fabro-cli/src/local_server.rs diff --git a/lib/crates/fabro-cli/src/logging.rs b/lib/apps/fabro-cli/src/logging.rs similarity index 100% rename from lib/crates/fabro-cli/src/logging.rs rename to lib/apps/fabro-cli/src/logging.rs diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/apps/fabro-cli/src/main.rs similarity index 100% rename from lib/crates/fabro-cli/src/main.rs rename to lib/apps/fabro-cli/src/main.rs diff --git a/lib/crates/fabro-cli/src/manifest_args.rs b/lib/apps/fabro-cli/src/manifest_args.rs similarity index 100% rename from lib/crates/fabro-cli/src/manifest_args.rs rename to lib/apps/fabro-cli/src/manifest_args.rs diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/apps/fabro-cli/src/server_client.rs similarity index 100% rename from lib/crates/fabro-cli/src/server_client.rs rename to lib/apps/fabro-cli/src/server_client.rs diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/apps/fabro-cli/src/server_runs.rs similarity index 100% rename from lib/crates/fabro-cli/src/server_runs.rs rename to lib/apps/fabro-cli/src/server_runs.rs diff --git a/lib/crates/fabro-cli/src/shared/github.rs b/lib/apps/fabro-cli/src/shared/github.rs similarity index 100% rename from lib/crates/fabro-cli/src/shared/github.rs rename to lib/apps/fabro-cli/src/shared/github.rs diff --git a/lib/crates/fabro-cli/src/shared/mod.rs b/lib/apps/fabro-cli/src/shared/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/shared/mod.rs rename to lib/apps/fabro-cli/src/shared/mod.rs diff --git a/lib/crates/fabro-cli/src/shared/provider_auth.rs b/lib/apps/fabro-cli/src/shared/provider_auth.rs similarity index 98% rename from lib/crates/fabro-cli/src/shared/provider_auth.rs rename to lib/apps/fabro-cli/src/shared/provider_auth.rs index 70e258393..1d3992f1f 100644 --- a/lib/crates/fabro-cli/src/shared/provider_auth.rs +++ b/lib/apps/fabro-cli/src/shared/provider_auth.rs @@ -100,9 +100,10 @@ pub(crate) async fn validate_api_key( .await .context("failed to create LLM client")?; - let probe_model = catalog - .probe_for_provider(provider) - .map_or_else(|| format!("unknown-{provider}"), |model| model.id.clone()); + let probe_model = catalog.probe_for_provider(provider).map_or_else( + || format!("unknown-{provider}"), + |model| model.id.to_string(), + ); let params = GenerateParams::new(probe_model, Arc::new(client)) .provider(provider.to_string()) diff --git a/lib/crates/fabro-cli/src/shared/repo.rs b/lib/apps/fabro-cli/src/shared/repo.rs similarity index 100% rename from lib/crates/fabro-cli/src/shared/repo.rs rename to lib/apps/fabro-cli/src/shared/repo.rs diff --git a/lib/crates/fabro-cli/src/shared/utilities.rs b/lib/apps/fabro-cli/src/shared/utilities.rs similarity index 100% rename from lib/crates/fabro-cli/src/shared/utilities.rs rename to lib/apps/fabro-cli/src/shared/utilities.rs diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/dummy.rs b/lib/apps/fabro-cli/src/sleep_inhibitor/dummy.rs similarity index 100% rename from lib/crates/fabro-cli/src/sleep_inhibitor/dummy.rs rename to lib/apps/fabro-cli/src/sleep_inhibitor/dummy.rs diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/iokit_bindings.rs b/lib/apps/fabro-cli/src/sleep_inhibitor/iokit_bindings.rs similarity index 100% rename from lib/crates/fabro-cli/src/sleep_inhibitor/iokit_bindings.rs rename to lib/apps/fabro-cli/src/sleep_inhibitor/iokit_bindings.rs diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/linux.rs b/lib/apps/fabro-cli/src/sleep_inhibitor/linux.rs similarity index 100% rename from lib/crates/fabro-cli/src/sleep_inhibitor/linux.rs rename to lib/apps/fabro-cli/src/sleep_inhibitor/linux.rs diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/macos.rs b/lib/apps/fabro-cli/src/sleep_inhibitor/macos.rs similarity index 100% rename from lib/crates/fabro-cli/src/sleep_inhibitor/macos.rs rename to lib/apps/fabro-cli/src/sleep_inhibitor/macos.rs diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/mod.rs b/lib/apps/fabro-cli/src/sleep_inhibitor/mod.rs similarity index 100% rename from lib/crates/fabro-cli/src/sleep_inhibitor/mod.rs rename to lib/apps/fabro-cli/src/sleep_inhibitor/mod.rs diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/apps/fabro-cli/src/user_config.rs similarity index 100% rename from lib/crates/fabro-cli/src/user_config.rs rename to lib/apps/fabro-cli/src/user_config.rs diff --git a/lib/crates/fabro-cli/tests/build_git_metadata.rs b/lib/apps/fabro-cli/tests/build_git_metadata.rs similarity index 100% rename from lib/crates/fabro-cli/tests/build_git_metadata.rs rename to lib/apps/fabro-cli/tests/build_git_metadata.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/approve.rs b/lib/apps/fabro-cli/tests/it/cmd/approve.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/approve.rs rename to lib/apps/fabro-cli/tests/it/cmd/approve.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/archive.rs b/lib/apps/fabro-cli/tests/it/cmd/archive.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/archive.rs rename to lib/apps/fabro-cli/tests/it/cmd/archive.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs b/lib/apps/fabro-cli/tests/it/cmd/artifact_cp.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs rename to lib/apps/fabro-cli/tests/it/cmd/artifact_cp.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs b/lib/apps/fabro-cli/tests/it/cmd/artifact_list.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs rename to lib/apps/fabro-cli/tests/it/cmd/artifact_list.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/apps/fabro-cli/tests/it/cmd/attach.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/attach.rs rename to lib/apps/fabro-cli/tests/it/cmd/attach.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/auth.rs b/lib/apps/fabro-cli/tests/it/cmd/auth.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/auth.rs rename to lib/apps/fabro-cli/tests/it/cmd/auth.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/cli_reference.rs b/lib/apps/fabro-cli/tests/it/cmd/cli_reference.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/cli_reference.rs rename to lib/apps/fabro-cli/tests/it/cmd/cli_reference.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/apps/fabro-cli/tests/it/cmd/config.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/config.rs rename to lib/apps/fabro-cli/tests/it/cmd/config.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/apps/fabro-cli/tests/it/cmd/create.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/create.rs rename to lib/apps/fabro-cli/tests/it/cmd/create.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/deny.rs b/lib/apps/fabro-cli/tests/it/cmd/deny.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/deny.rs rename to lib/apps/fabro-cli/tests/it/cmd/deny.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/diff.rs b/lib/apps/fabro-cli/tests/it/cmd/diff.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/diff.rs rename to lib/apps/fabro-cli/tests/it/cmd/diff.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/discord.rs b/lib/apps/fabro-cli/tests/it/cmd/discord.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/discord.rs rename to lib/apps/fabro-cli/tests/it/cmd/discord.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/docs.rs b/lib/apps/fabro-cli/tests/it/cmd/docs.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/docs.rs rename to lib/apps/fabro-cli/tests/it/cmd/docs.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs b/lib/apps/fabro-cli/tests/it/cmd/doctor.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/doctor.rs rename to lib/apps/fabro-cli/tests/it/cmd/doctor.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/dump.rs b/lib/apps/fabro-cli/tests/it/cmd/dump.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/dump.rs rename to lib/apps/fabro-cli/tests/it/cmd/dump.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/events.rs b/lib/apps/fabro-cli/tests/it/cmd/events.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/events.rs rename to lib/apps/fabro-cli/tests/it/cmd/events.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/apps/fabro-cli/tests/it/cmd/exec.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/exec.rs rename to lib/apps/fabro-cli/tests/it/cmd/exec.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/apps/fabro-cli/tests/it/cmd/fabro.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/fabro.rs rename to lib/apps/fabro-cli/tests/it/cmd/fabro.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/fork.rs b/lib/apps/fabro-cli/tests/it/cmd/fork.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/fork.rs rename to lib/apps/fabro-cli/tests/it/cmd/fork.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/graph.rs b/lib/apps/fabro-cli/tests/it/cmd/graph.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/graph.rs rename to lib/apps/fabro-cli/tests/it/cmd/graph.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/apps/fabro-cli/tests/it/cmd/inspect.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/inspect.rs rename to lib/apps/fabro-cli/tests/it/cmd/inspect.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/install.rs b/lib/apps/fabro-cli/tests/it/cmd/install.rs similarity index 95% rename from lib/crates/fabro-cli/tests/it/cmd/install.rs rename to lib/apps/fabro-cli/tests/it/cmd/install.rs index fe2c9cf61..e0eb0b75a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/install.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/install.rs @@ -338,10 +338,16 @@ fn keep_existing_settings_persists_secrets_without_rewriting_server_target() { let mut context = test_context!(); let storage_dir = context.temp_dir.join("install-storage"); context.manage_storage_dir(&storage_dir); - let existing_web_url = unused_loopback_web_url(); - let requested_web_url = unused_loopback_web_url(); - write_http_install_settings(&context, &storage_dir, &existing_web_url, "keep-me"); - login_with_storage_dev_token(&context, &storage_dir, &existing_web_url); + let socket_path = context.temp_dir.join("install-storage.sock"); + let existing_web_url = "https://existing.example.test".to_string(); + let requested_web_url = "https://requested.example.test".to_string(); + write_unix_install_settings( + &context, + &storage_dir, + &socket_path, + &existing_web_url, + "keep-me", + ); let path = fake_gh_path(&context, "ghp_keep_existing"); let output = context @@ -382,15 +388,19 @@ fn keep_existing_settings_persists_secrets_without_rewriting_server_target() { .and_then(toml::Value::as_str), Some(existing_web_url.as_str()) ); + let target = parsed + .get("cli") + .and_then(toml::Value::as_table) + .and_then(|cli| cli.get("target")) + .and_then(toml::Value::as_table) + .expect("cli.target should remain configured"); assert_eq!( - parsed - .get("cli") - .and_then(toml::Value::as_table) - .and_then(|cli| cli.get("target")) - .and_then(toml::Value::as_table) - .and_then(|target| target.get("url")) - .and_then(toml::Value::as_str), - Some(existing_web_url.as_str()) + target.get("type").and_then(toml::Value::as_str), + Some("unix") + ); + assert_eq!( + target.get("path").and_then(toml::Value::as_str), + socket_path.to_str() ); assert!( !settings.contains(&requested_web_url), @@ -874,6 +884,53 @@ mode = "{}" ); } +fn write_unix_install_settings( + context: &fabro_test::TestContext, + storage_dir: &std::path::Path, + socket_path: &std::path::Path, + web_url: &str, + metadata_mode: &str, +) { + write_raw_home_settings( + context, + &format!( + r#" +_version = 1 + +[server.storage] +root = "{}" + +[server.api] +url = "{}/api/v1" + +[server.web] +enabled = true +url = "{}" + +[server.auth] +methods = ["dev-token"] + +[server.listen] +type = "unix" +path = "{}" + +[cli.target] +type = "unix" +path = "{}" + +[project.metadata] +mode = "{}" +"#, + storage_dir.display(), + web_url, + web_url, + socket_path.display(), + socket_path.display(), + metadata_mode + ), + ); +} + fn login_with_storage_dev_token( context: &fabro_test::TestContext, storage_dir: &std::path::Path, diff --git a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs b/lib/apps/fabro-cli/tests/it/cmd/json_global.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/json_global.rs rename to lib/apps/fabro-cli/tests/it/cmd/json_global.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/logs.rs b/lib/apps/fabro-cli/tests/it/cmd/logs.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/logs.rs rename to lib/apps/fabro-cli/tests/it/cmd/logs.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/mcp.rs b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/mcp.rs rename to lib/apps/fabro-cli/tests/it/cmd/mcp.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/apps/fabro-cli/tests/it/cmd/mod.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/mod.rs rename to lib/apps/fabro-cli/tests/it/cmd/mod.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/model.rs b/lib/apps/fabro-cli/tests/it/cmd/model.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/model.rs rename to lib/apps/fabro-cli/tests/it/cmd/model.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/model_list.rs b/lib/apps/fabro-cli/tests/it/cmd/model_list.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/model_list.rs rename to lib/apps/fabro-cli/tests/it/cmd/model_list.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/model_test.rs b/lib/apps/fabro-cli/tests/it/cmd/model_test.rs similarity index 99% rename from lib/crates/fabro-cli/tests/it/cmd/model_test.rs rename to lib/apps/fabro-cli/tests/it/cmd/model_test.rs index f7d731d2d..70e64ac77 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model_test.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/model_test.rs @@ -202,6 +202,7 @@ fn model_test_does_not_announce_unconfigured() { .header("Content-Type", "application/json") .json_body(serde_json::json!({ "model_id": "claude-opus-4-7", + "provider": "anthropic", "status": "ok" })); }); @@ -251,6 +252,7 @@ fn model_test_skipped_footer_sources_from_listing() { .header("Content-Type", "application/json") .json_body(serde_json::json!({ "model_id": "claude-opus-4-7", + "provider": "anthropic", "status": "ok" })); }); @@ -282,6 +284,7 @@ fn model_test_post_list_race_is_a_failure() { .header("Content-Type", "application/json") .json_body(serde_json::json!({ "model_id": "claude-opus-4-7", + "provider": "anthropic", "status": "skip" })); }); @@ -323,6 +326,7 @@ fn model_test_json_partitions_skip_and_fail() { .header("Content-Type", "application/json") .json_body(serde_json::json!({ "model_id": "claude-opus-4-7", + "provider": "anthropic", "status": "skip" })); }); @@ -529,6 +533,7 @@ async fn concurrent_test_model( Json(serde_json::json!({ "model_id": id, + "provider": "anthropic", "status": "ok" })) } diff --git a/lib/crates/fabro-cli/tests/it/cmd/parent.rs b/lib/apps/fabro-cli/tests/it/cmd/parent.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/parent.rs rename to lib/apps/fabro-cli/tests/it/cmd/parent.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/parse.rs b/lib/apps/fabro-cli/tests/it/cmd/parse.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/parse.rs rename to lib/apps/fabro-cli/tests/it/cmd/parse.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr.rs b/lib/apps/fabro-cli/tests/it/cmd/pr.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/pr.rs rename to lib/apps/fabro-cli/tests/it/cmd/pr.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_close.rs b/lib/apps/fabro-cli/tests/it/cmd/pr_close.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/pr_close.rs rename to lib/apps/fabro-cli/tests/it/cmd/pr_close.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs b/lib/apps/fabro-cli/tests/it/cmd/pr_create.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/pr_create.rs rename to lib/apps/fabro-cli/tests/it/cmd/pr_create.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_link.rs b/lib/apps/fabro-cli/tests/it/cmd/pr_link.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/pr_link.rs rename to lib/apps/fabro-cli/tests/it/cmd/pr_link.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs b/lib/apps/fabro-cli/tests/it/cmd/pr_merge.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs rename to lib/apps/fabro-cli/tests/it/cmd/pr_merge.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_unlink.rs b/lib/apps/fabro-cli/tests/it/cmd/pr_unlink.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/pr_unlink.rs rename to lib/apps/fabro-cli/tests/it/cmd/pr_unlink.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs b/lib/apps/fabro-cli/tests/it/cmd/pr_view.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/pr_view.rs rename to lib/apps/fabro-cli/tests/it/cmd/pr_view.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs b/lib/apps/fabro-cli/tests/it/cmd/preflight.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/preflight.rs rename to lib/apps/fabro-cli/tests/it/cmd/preflight.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/provider.rs b/lib/apps/fabro-cli/tests/it/cmd/provider.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/provider.rs rename to lib/apps/fabro-cli/tests/it/cmd/provider.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/provider_login.rs b/lib/apps/fabro-cli/tests/it/cmd/provider_login.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/provider_login.rs rename to lib/apps/fabro-cli/tests/it/cmd/provider_login.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/apps/fabro-cli/tests/it/cmd/ps.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/ps.rs rename to lib/apps/fabro-cli/tests/it/cmd/ps.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/render_graph.rs b/lib/apps/fabro-cli/tests/it/cmd/render_graph.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/render_graph.rs rename to lib/apps/fabro-cli/tests/it/cmd/render_graph.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo.rs b/lib/apps/fabro-cli/tests/it/cmd/repo.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/repo.rs rename to lib/apps/fabro-cli/tests/it/cmd/repo.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo_deinit.rs b/lib/apps/fabro-cli/tests/it/cmd/repo_deinit.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/repo_deinit.rs rename to lib/apps/fabro-cli/tests/it/cmd/repo_deinit.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs b/lib/apps/fabro-cli/tests/it/cmd/repo_init.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/repo_init.rs rename to lib/apps/fabro-cli/tests/it/cmd/repo_init.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/resume.rs b/lib/apps/fabro-cli/tests/it/cmd/resume.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/resume.rs rename to lib/apps/fabro-cli/tests/it/cmd/resume.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs b/lib/apps/fabro-cli/tests/it/cmd/rewind.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/rewind.rs rename to lib/apps/fabro-cli/tests/it/cmd/rewind.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/apps/fabro-cli/tests/it/cmd/rm.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/rm.rs rename to lib/apps/fabro-cli/tests/it/cmd/rm.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/apps/fabro-cli/tests/it/cmd/run.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/run.rs rename to lib/apps/fabro-cli/tests/it/cmd/run.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/apps/fabro-cli/tests/it/cmd/runner.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/runner.rs rename to lib/apps/fabro-cli/tests/it/cmd/runner.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs b/lib/apps/fabro-cli/tests/it/cmd/sandbox_cp.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs rename to lib/apps/fabro-cli/tests/it/cmd/sandbox_cp.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs b/lib/apps/fabro-cli/tests/it/cmd/sandbox_preview.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs rename to lib/apps/fabro-cli/tests/it/cmd/sandbox_preview.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs b/lib/apps/fabro-cli/tests/it/cmd/sandbox_ssh.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs rename to lib/apps/fabro-cli/tests/it/cmd/sandbox_ssh.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret.rs b/lib/apps/fabro-cli/tests/it/cmd/secret.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/secret.rs rename to lib/apps/fabro-cli/tests/it/cmd/secret.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret_list.rs b/lib/apps/fabro-cli/tests/it/cmd/secret_list.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/secret_list.rs rename to lib/apps/fabro-cli/tests/it/cmd/secret_list.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret_rm.rs b/lib/apps/fabro-cli/tests/it/cmd/secret_rm.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/secret_rm.rs rename to lib/apps/fabro-cli/tests/it/cmd/secret_rm.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret_set.rs b/lib/apps/fabro-cli/tests/it/cmd/secret_set.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/secret_set.rs rename to lib/apps/fabro-cli/tests/it/cmd/secret_set.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/send_analytics.rs b/lib/apps/fabro-cli/tests/it/cmd/send_analytics.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/send_analytics.rs rename to lib/apps/fabro-cli/tests/it/cmd/send_analytics.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/send_panic.rs b/lib/apps/fabro-cli/tests/it/cmd/send_panic.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/send_panic.rs rename to lib/apps/fabro-cli/tests/it/cmd/send_panic.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/apps/fabro-cli/tests/it/cmd/server_start.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/server_start.rs rename to lib/apps/fabro-cli/tests/it/cmd/server_start.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_status.rs b/lib/apps/fabro-cli/tests/it/cmd/server_status.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/server_status.rs rename to lib/apps/fabro-cli/tests/it/cmd/server_status.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_stop.rs b/lib/apps/fabro-cli/tests/it/cmd/server_stop.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/server_stop.rs rename to lib/apps/fabro-cli/tests/it/cmd/server_stop.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/start.rs b/lib/apps/fabro-cli/tests/it/cmd/start.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/start.rs rename to lib/apps/fabro-cli/tests/it/cmd/start.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/apps/fabro-cli/tests/it/cmd/support.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/support.rs rename to lib/apps/fabro-cli/tests/it/cmd/support.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/system.rs b/lib/apps/fabro-cli/tests/it/cmd/system.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/system.rs rename to lib/apps/fabro-cli/tests/it/cmd/system.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_df.rs b/lib/apps/fabro-cli/tests/it/cmd/system_df.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/system_df.rs rename to lib/apps/fabro-cli/tests/it/cmd/system_df.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_events.rs b/lib/apps/fabro-cli/tests/it/cmd/system_events.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/system_events.rs rename to lib/apps/fabro-cli/tests/it/cmd/system_events.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_info.rs b/lib/apps/fabro-cli/tests/it/cmd/system_info.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/system_info.rs rename to lib/apps/fabro-cli/tests/it/cmd/system_info.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs b/lib/apps/fabro-cli/tests/it/cmd/system_prune.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/system_prune.rs rename to lib/apps/fabro-cli/tests/it/cmd/system_prune.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_repair.rs b/lib/apps/fabro-cli/tests/it/cmd/system_repair.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/system_repair.rs rename to lib/apps/fabro-cli/tests/it/cmd/system_repair.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/test_panic.rs b/lib/apps/fabro-cli/tests/it/cmd/test_panic.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/test_panic.rs rename to lib/apps/fabro-cli/tests/it/cmd/test_panic.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/top_level.rs b/lib/apps/fabro-cli/tests/it/cmd/top_level.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/top_level.rs rename to lib/apps/fabro-cli/tests/it/cmd/top_level.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs b/lib/apps/fabro-cli/tests/it/cmd/unarchive.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/unarchive.rs rename to lib/apps/fabro-cli/tests/it/cmd/unarchive.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/uninstall.rs b/lib/apps/fabro-cli/tests/it/cmd/uninstall.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/uninstall.rs rename to lib/apps/fabro-cli/tests/it/cmd/uninstall.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/upgrade.rs b/lib/apps/fabro-cli/tests/it/cmd/upgrade.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/upgrade.rs rename to lib/apps/fabro-cli/tests/it/cmd/upgrade.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/validate.rs b/lib/apps/fabro-cli/tests/it/cmd/validate.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/validate.rs rename to lib/apps/fabro-cli/tests/it/cmd/validate.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/variable.rs b/lib/apps/fabro-cli/tests/it/cmd/variable.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/variable.rs rename to lib/apps/fabro-cli/tests/it/cmd/variable.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_get.rs b/lib/apps/fabro-cli/tests/it/cmd/variable_get.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/variable_get.rs rename to lib/apps/fabro-cli/tests/it/cmd/variable_get.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_list.rs b/lib/apps/fabro-cli/tests/it/cmd/variable_list.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/variable_list.rs rename to lib/apps/fabro-cli/tests/it/cmd/variable_list.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs b/lib/apps/fabro-cli/tests/it/cmd/variable_rm.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs rename to lib/apps/fabro-cli/tests/it/cmd/variable_rm.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_set.rs b/lib/apps/fabro-cli/tests/it/cmd/variable_set.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/variable_set.rs rename to lib/apps/fabro-cli/tests/it/cmd/variable_set.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/version.rs b/lib/apps/fabro-cli/tests/it/cmd/version.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/version.rs rename to lib/apps/fabro-cli/tests/it/cmd/version.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/wait.rs b/lib/apps/fabro-cli/tests/it/cmd/wait.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/wait.rs rename to lib/apps/fabro-cli/tests/it/cmd/wait.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs b/lib/apps/fabro-cli/tests/it/cmd/worker_auth.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs rename to lib/apps/fabro-cli/tests/it/cmd/worker_auth.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow.rs b/lib/apps/fabro-cli/tests/it/cmd/workflow.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/workflow.rs rename to lib/apps/fabro-cli/tests/it/cmd/workflow.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs b/lib/apps/fabro-cli/tests/it/cmd/workflow_create.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs rename to lib/apps/fabro-cli/tests/it/cmd/workflow_create.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow_list.rs b/lib/apps/fabro-cli/tests/it/cmd/workflow_list.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/cmd/workflow_list.rs rename to lib/apps/fabro-cli/tests/it/cmd/workflow_list.rs diff --git a/lib/crates/fabro-cli/tests/it/main.rs b/lib/apps/fabro-cli/tests/it/main.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/main.rs rename to lib/apps/fabro-cli/tests/it/main.rs diff --git a/lib/crates/fabro-cli/tests/it/scenario/archive.rs b/lib/apps/fabro-cli/tests/it/scenario/archive.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/scenario/archive.rs rename to lib/apps/fabro-cli/tests/it/scenario/archive.rs diff --git a/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs b/lib/apps/fabro-cli/tests/it/scenario/artifacts.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/scenario/artifacts.rs rename to lib/apps/fabro-cli/tests/it/scenario/artifacts.rs diff --git a/lib/crates/fabro-cli/tests/it/scenario/auth.rs b/lib/apps/fabro-cli/tests/it/scenario/auth.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/scenario/auth.rs rename to lib/apps/fabro-cli/tests/it/scenario/auth.rs diff --git a/lib/crates/fabro-cli/tests/it/scenario/exec.rs b/lib/apps/fabro-cli/tests/it/scenario/exec.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/scenario/exec.rs rename to lib/apps/fabro-cli/tests/it/scenario/exec.rs diff --git a/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs b/lib/apps/fabro-cli/tests/it/scenario/lifecycle.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs rename to lib/apps/fabro-cli/tests/it/scenario/lifecycle.rs diff --git a/lib/crates/fabro-cli/tests/it/scenario/mod.rs b/lib/apps/fabro-cli/tests/it/scenario/mod.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/scenario/mod.rs rename to lib/apps/fabro-cli/tests/it/scenario/mod.rs diff --git a/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs b/lib/apps/fabro-cli/tests/it/scenario/server_lifecycle.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs rename to lib/apps/fabro-cli/tests/it/scenario/server_lifecycle.rs diff --git a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs b/lib/apps/fabro-cli/tests/it/scenario/smoke.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/scenario/smoke.rs rename to lib/apps/fabro-cli/tests/it/scenario/smoke.rs diff --git a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs b/lib/apps/fabro-cli/tests/it/support/auth_harness.rs similarity index 98% rename from lib/crates/fabro-cli/tests/it/support/auth_harness.rs rename to lib/apps/fabro-cli/tests/it/support/auth_harness.rs index cfaf06fd0..315465096 100644 --- a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs +++ b/lib/apps/fabro-cli/tests/it/support/auth_harness.rs @@ -24,6 +24,7 @@ use fabro_server::auth::GithubEndpoints; use fabro_server::jwt_auth::resolve_auth_mode_with_lookup; use fabro_server::server::{RouterOptions, build_router_with_options}; use fabro_server::test_support::TestAppStateBuilder; +use fabro_static::EnvVars; use fabro_test::{GitHubAppState, TestContext, apply_test_isolation}; use serde_json::Value; use tokio::net::TcpListener; @@ -88,7 +89,10 @@ impl RealAuthHarness { .max_concurrent_runs(5) .env_lookup(|_| None) .server_secret_env(secrets) - .vault_entries([("GITHUB_APP_CLIENT_SECRET", github_client_secret.as_str())]) + .vault_entries([ + ("GITHUB_APP_CLIENT_SECRET", github_client_secret.as_str()), + (EnvVars::OPENAI_API_KEY, "test-openai-api-key"), + ]) .build(); let github_base = github_base_url(&twin.base_url); let router = build_router_with_options(state, &auth_mode, RouterOptions { diff --git a/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs b/lib/apps/fabro-cli/tests/it/support/auth_tokens.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/support/auth_tokens.rs rename to lib/apps/fabro-cli/tests/it/support/auth_tokens.rs diff --git a/lib/crates/fabro-cli/tests/it/support/mod.rs b/lib/apps/fabro-cli/tests/it/support/mod.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/support/mod.rs rename to lib/apps/fabro-cli/tests/it/support/mod.rs diff --git a/lib/crates/fabro-cli/tests/it/workflow/acp.rs b/lib/apps/fabro-cli/tests/it/workflow/acp.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/acp.rs rename to lib/apps/fabro-cli/tests/it/workflow/acp.rs diff --git a/lib/crates/fabro-cli/tests/it/workflow/agent_linear.rs b/lib/apps/fabro-cli/tests/it/workflow/agent_linear.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/agent_linear.rs rename to lib/apps/fabro-cli/tests/it/workflow/agent_linear.rs diff --git a/lib/crates/fabro-cli/tests/it/workflow/artifacts.rs b/lib/apps/fabro-cli/tests/it/workflow/artifacts.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/artifacts.rs rename to lib/apps/fabro-cli/tests/it/workflow/artifacts.rs diff --git a/lib/crates/fabro-cli/tests/it/workflow/command_agent_mixed.rs b/lib/apps/fabro-cli/tests/it/workflow/command_agent_mixed.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/command_agent_mixed.rs rename to lib/apps/fabro-cli/tests/it/workflow/command_agent_mixed.rs diff --git a/lib/crates/fabro-cli/tests/it/workflow/command_pipeline.rs b/lib/apps/fabro-cli/tests/it/workflow/command_pipeline.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/command_pipeline.rs rename to lib/apps/fabro-cli/tests/it/workflow/command_pipeline.rs diff --git a/lib/apps/fabro-cli/tests/it/workflow/command_routing.rs b/lib/apps/fabro-cli/tests/it/workflow/command_routing.rs new file mode 100644 index 000000000..1052ae476 --- /dev/null +++ b/lib/apps/fabro-cli/tests/it/workflow/command_routing.rs @@ -0,0 +1,34 @@ +use fabro_test::test_context; + +use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for}; + +sandbox_tests!(command_routing); + +fn scenario_command_routing(sandbox: &str) { + let context = test_context!(); + let workflow = fixture("command_routing.fabro"); + + context.validate().arg(&workflow).assert().success(); + + context + .run_cmd() + .args(["--auto-approve", "--environment", sandbox]) + .arg(workflow) + .timeout(timeout_for(sandbox)) + .assert() + .success(); + + let run_dir = find_run_dir(&context); + let conclusion = read_conclusion(&run_dir); + assert_eq!(conclusion["status"].as_str(), Some("succeeded")); + + let nodes = completed_nodes(&run_dir); + assert!( + nodes.contains(&"kept".to_string()), + "kept node should be in completed_nodes: {nodes:?}" + ); + assert!( + !nodes.contains(&"none".to_string()), + "none node should NOT be in completed_nodes: {nodes:?}" + ); +} diff --git a/lib/crates/fabro-cli/tests/it/workflow/conditional_branching.rs b/lib/apps/fabro-cli/tests/it/workflow/conditional_branching.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/conditional_branching.rs rename to lib/apps/fabro-cli/tests/it/workflow/conditional_branching.rs diff --git a/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs b/lib/apps/fabro-cli/tests/it/workflow/dry_run_examples.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs rename to lib/apps/fabro-cli/tests/it/workflow/dry_run_examples.rs diff --git a/lib/crates/fabro-cli/tests/it/workflow/fixtures/agent_linear.fabro b/lib/apps/fabro-cli/tests/it/workflow/fixtures/agent_linear.fabro similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/fixtures/agent_linear.fabro rename to lib/apps/fabro-cli/tests/it/workflow/fixtures/agent_linear.fabro diff --git a/lib/crates/fabro-cli/tests/it/workflow/fixtures/command_agent_mixed.fabro b/lib/apps/fabro-cli/tests/it/workflow/fixtures/command_agent_mixed.fabro similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/fixtures/command_agent_mixed.fabro rename to lib/apps/fabro-cli/tests/it/workflow/fixtures/command_agent_mixed.fabro diff --git a/lib/crates/fabro-cli/tests/it/workflow/fixtures/command_pipeline.fabro b/lib/apps/fabro-cli/tests/it/workflow/fixtures/command_pipeline.fabro similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/fixtures/command_pipeline.fabro rename to lib/apps/fabro-cli/tests/it/workflow/fixtures/command_pipeline.fabro diff --git a/lib/apps/fabro-cli/tests/it/workflow/fixtures/command_routing.fabro b/lib/apps/fabro-cli/tests/it/workflow/fixtures/command_routing.fabro new file mode 100644 index 000000000..0c47eccff --- /dev/null +++ b/lib/apps/fabro-cli/tests/it/workflow/fixtures/command_routing.fabro @@ -0,0 +1,23 @@ +digraph CommandRouting { + graph [goal="Route on structured output from a command"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + classify [ + label="Classify", + shape=parallelogram, + script="echo '{\"context_updates\":{\"kept_count\":2}}'", + output_schema="routing" + ] + gate [label="Check Count", shape=diamond] + kept [label="Kept", shape=parallelogram, script="echo kept"] + none [label="None", shape=parallelogram, script="echo none"] + + start -> classify -> gate + gate -> kept [label="Kept", condition="context.kept_count > 0"] + gate -> none [label="None"] + kept -> exit + none -> exit +} diff --git a/lib/crates/fabro-cli/tests/it/workflow/fixtures/conditional_branching.fabro b/lib/apps/fabro-cli/tests/it/workflow/fixtures/conditional_branching.fabro similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/fixtures/conditional_branching.fabro rename to lib/apps/fabro-cli/tests/it/workflow/fixtures/conditional_branching.fabro diff --git a/lib/crates/fabro-cli/tests/it/workflow/fixtures/full_stack.fabro b/lib/apps/fabro-cli/tests/it/workflow/fixtures/full_stack.fabro similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/fixtures/full_stack.fabro rename to lib/apps/fabro-cli/tests/it/workflow/fixtures/full_stack.fabro diff --git a/lib/crates/fabro-cli/tests/it/workflow/fixtures/human_gate.fabro b/lib/apps/fabro-cli/tests/it/workflow/fixtures/human_gate.fabro similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/fixtures/human_gate.fabro rename to lib/apps/fabro-cli/tests/it/workflow/fixtures/human_gate.fabro diff --git a/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs b/lib/apps/fabro-cli/tests/it/workflow/full_stack.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/full_stack.rs rename to lib/apps/fabro-cli/tests/it/workflow/full_stack.rs diff --git a/lib/crates/fabro-cli/tests/it/workflow/hooks.rs b/lib/apps/fabro-cli/tests/it/workflow/hooks.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/hooks.rs rename to lib/apps/fabro-cli/tests/it/workflow/hooks.rs diff --git a/lib/crates/fabro-cli/tests/it/workflow/human_gate.rs b/lib/apps/fabro-cli/tests/it/workflow/human_gate.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/human_gate.rs rename to lib/apps/fabro-cli/tests/it/workflow/human_gate.rs diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/apps/fabro-cli/tests/it/workflow/mod.rs similarity index 99% rename from lib/crates/fabro-cli/tests/it/workflow/mod.rs rename to lib/apps/fabro-cli/tests/it/workflow/mod.rs index 365707d2c..5e0dd3738 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/mod.rs @@ -8,6 +8,7 @@ mod agent_linear; mod artifacts; mod command_agent_mixed; mod command_pipeline; +mod command_routing; mod conditional_branching; mod dry_run_examples; mod full_stack; diff --git a/lib/crates/fabro-cli/tests/manifest_path_round_trip.rs b/lib/apps/fabro-cli/tests/manifest_path_round_trip.rs similarity index 100% rename from lib/crates/fabro-cli/tests/manifest_path_round_trip.rs rename to lib/apps/fabro-cli/tests/manifest_path_round_trip.rs diff --git a/lib/crates/fabro-mcp-server/Cargo.toml b/lib/apps/fabro-mcp-server/Cargo.toml similarity index 59% rename from lib/crates/fabro-mcp-server/Cargo.toml rename to lib/apps/fabro-mcp-server/Cargo.toml index de3caa633..cdbf6b65f 100644 --- a/lib/crates/fabro-mcp-server/Cargo.toml +++ b/lib/apps/fabro-mcp-server/Cargo.toml @@ -15,15 +15,15 @@ workspace = true [dependencies] anyhow.workspace = true chrono = { workspace = true, features = ["serde"] } -fabro-api = { path = "../fabro-api" } -fabro-client = { path = "../fabro-client" } -fabro-manifest = { path = "../fabro-manifest" } -fabro-config = { path = "../fabro-config" } -fabro-model = { path = "../fabro-model" } +fabro-api = { path = "../../foundation/fabro-api" } +fabro-client = { path = "../../foundation/fabro-client" } +fabro-manifest = { path = "../../components/fabro-manifest" } +fabro-config = { path = "../../foundation/fabro-config" } +fabro-model = { path = "../../foundation/fabro-model" } fabro-server = { path = "../fabro-server" } -fabro-tool = { path = "../fabro-tool" } -fabro-types = { path = "../fabro-types" } -fabro-util = { path = "../fabro-util" } +fabro-tool = { path = "../../components/fabro-tool" } +fabro-types = { path = "../../foundation/fabro-types" } +fabro-util = { path = "../../foundation/fabro-util" } futures.workspace = true rmcp = { workspace = true, features = ["server", "macros", "schemars", "transport-io"] } schemars = "1.2.1" diff --git a/lib/crates/fabro-mcp-server/src/config.rs b/lib/apps/fabro-mcp-server/src/config.rs similarity index 100% rename from lib/crates/fabro-mcp-server/src/config.rs rename to lib/apps/fabro-mcp-server/src/config.rs diff --git a/lib/crates/fabro-mcp-server/src/lib.rs b/lib/apps/fabro-mcp-server/src/lib.rs similarity index 100% rename from lib/crates/fabro-mcp-server/src/lib.rs rename to lib/apps/fabro-mcp-server/src/lib.rs diff --git a/lib/crates/fabro-mcp-server/src/manifest_builder.rs b/lib/apps/fabro-mcp-server/src/manifest_builder.rs similarity index 100% rename from lib/crates/fabro-mcp-server/src/manifest_builder.rs rename to lib/apps/fabro-mcp-server/src/manifest_builder.rs diff --git a/lib/crates/fabro-mcp-server/src/server.rs b/lib/apps/fabro-mcp-server/src/server.rs similarity index 100% rename from lib/crates/fabro-mcp-server/src/server.rs rename to lib/apps/fabro-mcp-server/src/server.rs diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml similarity index 56% rename from lib/crates/fabro-server/Cargo.toml rename to lib/apps/fabro-server/Cargo.toml index b951b339f..1f2c5bb61 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -21,35 +21,35 @@ required-features = ["test-support"] workspace = true [dependencies] -fabro-automation = { path = "../fabro-automation" } -fabro-auth = { path = "../fabro-auth" } -fabro-install = { path = "../fabro-install" } +fabro-automation = { path = "../../components/fabro-automation" } +fabro-auth = { path = "../../foundation/fabro-auth" } +fabro-install = { path = "../../components/fabro-install" } fabro-spa = { path = "../fabro-spa" } -fabro-config = { path = "../fabro-config" } +fabro-config = { path = "../../foundation/fabro-config" } fabro-environment.workspace = true -fabro-graphviz = { path = "../fabro-graphviz" } -fabro-hooks = { path = "../fabro-hooks" } -fabro-interview = { path = "../fabro-interview" } -fabro-slack = { path = "../fabro-slack" } -fabro-workflow = { path = "../fabro-workflow" } -fabro-validate = { path = "../fabro-validate" } -fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "docker"] } -fabro-github = { path = "../fabro-github" } -fabro-agent = { path = "../fabro-agent" } -fabro-llm = { path = "../fabro-llm" } -fabro-manifest = { path = "../fabro-manifest" } -fabro-mcp-store = { path = "../fabro-mcp-store" } -fabro-model = { path = "../fabro-model" } -fabro-proc = { path = "../fabro-proc" } -fabro-tool = { path = "../fabro-tool" } -fabro-types = { path = "../fabro-types" } -fabro-util = { path = "../fabro-util" } -fabro-api = { path = "../fabro-api" } -fabro-client = { path = "../fabro-client" } -fabro-db = { path = "../fabro-db" } -fabro-store = { path = "../fabro-store" } -fabro-vault = { path = "../fabro-vault" } -fabro-variable = { path = "../fabro-variable" } +fabro-graphviz = { path = "../../components/fabro-graphviz" } +fabro-hooks = { path = "../../components/fabro-hooks" } +fabro-interview = { path = "../../components/fabro-interview" } +fabro-slack = { path = "../../components/fabro-slack" } +fabro-workflow = { path = "../../components/fabro-workflow" } +fabro-validate = { path = "../../components/fabro-validate" } +fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona", "docker"] } +fabro-github = { path = "../../components/fabro-github" } +fabro-agent = { path = "../../components/fabro-agent" } +fabro-llm = { path = "../../components/fabro-llm" } +fabro-manifest = { path = "../../components/fabro-manifest" } +fabro-mcp-store = { path = "../../components/fabro-mcp-store" } +fabro-model = { path = "../../foundation/fabro-model" } +fabro-proc = { path = "../../foundation/fabro-proc" } +fabro-tool = { path = "../../components/fabro-tool" } +fabro-types = { path = "../../foundation/fabro-types" } +fabro-util = { path = "../../foundation/fabro-util" } +fabro-api = { path = "../../foundation/fabro-api" } +fabro-client = { path = "../../foundation/fabro-client" } +fabro-db = { path = "../../foundation/fabro-db" } +fabro-store = { path = "../../components/fabro-store" } +fabro-vault = { path = "../../foundation/fabro-vault" } +fabro-variable = { path = "../../components/fabro-variable" } fabro-http.workspace = true fabro-redact.workspace = true fabro-static.workspace = true @@ -104,7 +104,7 @@ strum.workspace = true sysinfo = { version = "0.35.2", default-features = false, features = ["system", "disk"] } [build-dependencies] -fabro-build-support = { path = "../build-support" } +fabro-build-support = { path = "../../foundation/build-support" } chrono = { workspace = true } [dev-dependencies] @@ -117,7 +117,7 @@ sqlx.workspace = true tracing-subscriber.workspace = true tokio-util.workspace = true tokio-tungstenite.workspace = true -fabro-macros = { path = "../fabro-macros" } -fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] } +fabro-macros = { path = "../../foundation/fabro-macros" } +fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] } fabro-test = { workspace = true } -fabro-types = { path = "../fabro-types", features = ["test-support"] } +fabro-types = { path = "../../foundation/fabro-types", features = ["test-support"] } diff --git a/lib/crates/fabro-server/build.rs b/lib/apps/fabro-server/build.rs similarity index 100% rename from lib/crates/fabro-server/build.rs rename to lib/apps/fabro-server/build.rs diff --git a/lib/crates/fabro-server/migrations/2026051801_legacy_vault_entries.rs b/lib/apps/fabro-server/migrations/2026051801_legacy_vault_entries.rs similarity index 100% rename from lib/crates/fabro-server/migrations/2026051801_legacy_vault_entries.rs rename to lib/apps/fabro-server/migrations/2026051801_legacy_vault_entries.rs diff --git a/lib/crates/fabro-server/migrations/2026052501_optional_server_env_secrets_to_vault.rs b/lib/apps/fabro-server/migrations/2026052501_optional_server_env_secrets_to_vault.rs similarity index 100% rename from lib/crates/fabro-server/migrations/2026052501_optional_server_env_secrets_to_vault.rs rename to lib/apps/fabro-server/migrations/2026052501_optional_server_env_secrets_to_vault.rs diff --git a/lib/crates/fabro-server/src/auth/browser_shell.rs b/lib/apps/fabro-server/src/auth/browser_shell.rs similarity index 100% rename from lib/crates/fabro-server/src/auth/browser_shell.rs rename to lib/apps/fabro-server/src/auth/browser_shell.rs diff --git a/lib/crates/fabro-server/src/auth/cli_flow.rs b/lib/apps/fabro-server/src/auth/cli_flow.rs similarity index 100% rename from lib/crates/fabro-server/src/auth/cli_flow.rs rename to lib/apps/fabro-server/src/auth/cli_flow.rs diff --git a/lib/crates/fabro-server/src/auth/github_endpoints.rs b/lib/apps/fabro-server/src/auth/github_endpoints.rs similarity index 100% rename from lib/crates/fabro-server/src/auth/github_endpoints.rs rename to lib/apps/fabro-server/src/auth/github_endpoints.rs diff --git a/lib/crates/fabro-server/src/auth/jwt.rs b/lib/apps/fabro-server/src/auth/jwt.rs similarity index 100% rename from lib/crates/fabro-server/src/auth/jwt.rs rename to lib/apps/fabro-server/src/auth/jwt.rs diff --git a/lib/crates/fabro-server/src/auth/keys.rs b/lib/apps/fabro-server/src/auth/keys.rs similarity index 100% rename from lib/crates/fabro-server/src/auth/keys.rs rename to lib/apps/fabro-server/src/auth/keys.rs diff --git a/lib/crates/fabro-server/src/auth/mod.rs b/lib/apps/fabro-server/src/auth/mod.rs similarity index 100% rename from lib/crates/fabro-server/src/auth/mod.rs rename to lib/apps/fabro-server/src/auth/mod.rs diff --git a/lib/crates/fabro-server/src/auth/translate.rs b/lib/apps/fabro-server/src/auth/translate.rs similarity index 100% rename from lib/crates/fabro-server/src/auth/translate.rs rename to lib/apps/fabro-server/src/auth/translate.rs diff --git a/lib/crates/fabro-server/src/automation_materializer.rs b/lib/apps/fabro-server/src/automation_materializer.rs similarity index 100% rename from lib/crates/fabro-server/src/automation_materializer.rs rename to lib/apps/fabro-server/src/automation_materializer.rs diff --git a/lib/crates/fabro-server/src/canonical_host.rs b/lib/apps/fabro-server/src/canonical_host.rs similarity index 100% rename from lib/crates/fabro-server/src/canonical_host.rs rename to lib/apps/fabro-server/src/canonical_host.rs diff --git a/lib/crates/fabro-server/src/canonical_origin.rs b/lib/apps/fabro-server/src/canonical_origin.rs similarity index 100% rename from lib/crates/fabro-server/src/canonical_origin.rs rename to lib/apps/fabro-server/src/canonical_origin.rs diff --git a/lib/crates/fabro-server/src/csp.rs b/lib/apps/fabro-server/src/csp.rs similarity index 100% rename from lib/crates/fabro-server/src/csp.rs rename to lib/apps/fabro-server/src/csp.rs diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/apps/fabro-server/src/demo/mod.rs similarity index 100% rename from lib/crates/fabro-server/src/demo/mod.rs rename to lib/apps/fabro-server/src/demo/mod.rs diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/apps/fabro-server/src/diagnostics.rs similarity index 99% rename from lib/crates/fabro-server/src/diagnostics.rs rename to lib/apps/fabro-server/src/diagnostics.rs index b88f93716..988f84404 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/apps/fabro-server/src/diagnostics.rs @@ -254,11 +254,11 @@ async fn probe_single_provider( }; let model_id = model.id.clone(); - let outcome = run_basic_model_probe(&model_id, &provider, client).await; + let outcome = run_basic_model_probe(model_id.as_str(), &provider, client).await; match outcome.status { ModelTestStatus::Ok => ProviderProbeResult { provider, - model_id: Some(model_id), + model_id: Some(model_id.to_string()), status: ProviderProbeStatus::Ok, error_message: None, diagnostic_detail: None, @@ -267,7 +267,12 @@ async fn probe_single_provider( let raw = outcome .error_message .unwrap_or_else(|| "provider probe failed".to_string()); - provider_probe_error(provider, Some(model_id), redact_string(&raw), None) + provider_probe_error( + provider, + Some(model_id.to_string()), + redact_string(&raw), + None, + ) } } } diff --git a/lib/crates/fabro-server/src/error.rs b/lib/apps/fabro-server/src/error.rs similarity index 100% rename from lib/crates/fabro-server/src/error.rs rename to lib/apps/fabro-server/src/error.rs diff --git a/lib/crates/fabro-server/src/git_checkout.rs b/lib/apps/fabro-server/src/git_checkout.rs similarity index 100% rename from lib/crates/fabro-server/src/git_checkout.rs rename to lib/apps/fabro-server/src/git_checkout.rs diff --git a/lib/crates/fabro-server/src/github_webhooks.rs b/lib/apps/fabro-server/src/github_webhooks.rs similarity index 100% rename from lib/crates/fabro-server/src/github_webhooks.rs rename to lib/apps/fabro-server/src/github_webhooks.rs diff --git a/lib/crates/fabro-server/src/install.rs b/lib/apps/fabro-server/src/install.rs similarity index 99% rename from lib/crates/fabro-server/src/install.rs rename to lib/apps/fabro-server/src/install.rs index 841f405b5..12bb5fbb7 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/apps/fabro-server/src/install.rs @@ -2167,7 +2167,7 @@ async fn validate_llm_provider( })? .id .clone(); - let params = GenerateParams::new(probe_model, Arc::new(client)) + let params = GenerateParams::new(probe_model.to_string(), Arc::new(client)) .provider(input.provider.to_string()) .prompt("Say OK") .max_tokens(16); diff --git a/lib/crates/fabro-server/src/interp.rs b/lib/apps/fabro-server/src/interp.rs similarity index 100% rename from lib/crates/fabro-server/src/interp.rs rename to lib/apps/fabro-server/src/interp.rs diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/apps/fabro-server/src/jwt_auth.rs similarity index 100% rename from lib/crates/fabro-server/src/jwt_auth.rs rename to lib/apps/fabro-server/src/jwt_auth.rs diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/apps/fabro-server/src/lib.rs similarity index 100% rename from lib/crates/fabro-server/src/lib.rs rename to lib/apps/fabro-server/src/lib.rs diff --git a/lib/crates/fabro-server/src/manifest_validation.rs b/lib/apps/fabro-server/src/manifest_validation.rs similarity index 100% rename from lib/crates/fabro-server/src/manifest_validation.rs rename to lib/apps/fabro-server/src/manifest_validation.rs diff --git a/lib/crates/fabro-server/src/migrations.rs b/lib/apps/fabro-server/src/migrations.rs similarity index 100% rename from lib/crates/fabro-server/src/migrations.rs rename to lib/apps/fabro-server/src/migrations.rs diff --git a/lib/crates/fabro-server/src/principal_middleware.rs b/lib/apps/fabro-server/src/principal_middleware.rs similarity index 100% rename from lib/crates/fabro-server/src/principal_middleware.rs rename to lib/apps/fabro-server/src/principal_middleware.rs diff --git a/lib/crates/fabro-server/src/request_id.rs b/lib/apps/fabro-server/src/request_id.rs similarity index 100% rename from lib/crates/fabro-server/src/request_id.rs rename to lib/apps/fabro-server/src/request_id.rs diff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/apps/fabro-server/src/run_files.rs similarity index 100% rename from lib/crates/fabro-server/src/run_files.rs rename to lib/apps/fabro-server/src/run_files.rs diff --git a/lib/crates/fabro-server/src/run_files_security.rs b/lib/apps/fabro-server/src/run_files_security.rs similarity index 100% rename from lib/crates/fabro-server/src/run_files_security.rs rename to lib/apps/fabro-server/src/run_files_security.rs diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs similarity index 98% rename from lib/crates/fabro-server/src/run_manifest.rs rename to lib/apps/fabro-server/src/run_manifest.rs index 93c6660c6..259e3d2b5 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -476,19 +476,20 @@ async fn build_preflight_report( let catalog = state.catalog(); let llm_result = state.resolve_llm_client().await; - let configured_providers = match &llm_result { - Ok(result) => result.provider_ids(), - Err(err) => { - warn!(error = ?err, "Failed to resolve LLM client while checking ready providers"); - Vec::new() - } - }; + 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::>(); let materialized = materialize_run( prepared.settings.clone(), graph, catalog.as_ref(), - &configured_providers, - ); + &enabled_providers, + )?; let resolved_run = materialized.run; let server_settings = state.server_settings(); let github_integration = &server_settings.server.integrations.github; @@ -1049,8 +1050,15 @@ 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_model, resolved_provider) = if let Some(info) = catalog.get(node_model) { - (info.id.clone(), info.provider.to_string()) + 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()) }; @@ -1534,6 +1542,7 @@ enabled = {clone_enabled} Catalog::builtin(), &[ProviderId::anthropic()], ) + .unwrap() .run; (prepared, resolved) @@ -2381,9 +2390,8 @@ digraph Demo { assert!(response_mock.calls_async().await >= 1); } - #[tokio::test] - async fn preflight_unknown_llm_provider_reports_not_configured() { - let state = crate::test_support::test_app_state(); + #[test] + fn static_validation_rejects_unknown_llm_provider() { let mut manifest = minimal_manifest(); manifest.workflows.get_mut("workflow.fabro").unwrap().source = r#" digraph Demo { @@ -2399,29 +2407,16 @@ digraph Demo { &manifest, ) .unwrap(); - let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap(); + let Err(error) = validate_prepared_manifest(&prepared, test_catalog()) else { + panic!("unknown provider should fail static validation"); + }; - let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated) - .await - .unwrap(); - - assert!(!ok); - let llm_check = response.checks.sections[0] - .checks - .iter() - .find(|check| check.name == "LLM" && check.summary == "missing-model") - .expect("preflight should include the requested custom LLM provider"); - assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Warning); - assert_eq!( - llm_check.remediation.as_deref(), - Some("Provider \"missing-provider\" is not configured") - ); - assert!( - llm_check - .details - .iter() - .any(|detail| detail.text == "Provider: missing-provider") - ); + assert!(matches!( + error, + WorkflowError::ModelSelection(fabro_model::ModelSelectionError::UnknownProvider { + provider + }) if provider.as_str() == "missing-provider" + )); } #[tokio::test] @@ -2437,17 +2432,16 @@ base_url = "https://api.acme.test/v1" [providers.acme.auth] credentials = ["env:ACME_API_KEY"] -[models."acme-large"] -provider = "acme" +[providers.acme.models."acme-large"] display_name = "Acme Large" family = "acme" default = true aliases = ["vl"] -[models."acme-large".limits] +[providers.acme.models."acme-large".limits] context_window = 128000 -[models."acme-large".features] +[providers.acme.models."acme-large".features] tools = true vision = false reasoning = false @@ -2472,7 +2466,7 @@ digraph Demo { &manifest, ) .unwrap(); - let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap(); + let validated = validate_prepared_manifest(&prepared, state.catalog()).unwrap(); let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated) .await diff --git a/lib/crates/fabro-server/src/run_selector.rs b/lib/apps/fabro-server/src/run_selector.rs similarity index 100% rename from lib/crates/fabro-server/src/run_selector.rs rename to lib/apps/fabro-server/src/run_selector.rs diff --git a/lib/crates/fabro-server/src/run_title_generation.rs b/lib/apps/fabro-server/src/run_title_generation.rs similarity index 100% rename from lib/crates/fabro-server/src/run_title_generation.rs rename to lib/apps/fabro-server/src/run_title_generation.rs diff --git a/lib/crates/fabro-server/src/run_tool_manifest.rs b/lib/apps/fabro-server/src/run_tool_manifest.rs similarity index 100% rename from lib/crates/fabro-server/src/run_tool_manifest.rs rename to lib/apps/fabro-server/src/run_tool_manifest.rs diff --git a/lib/crates/fabro-server/src/security_headers.rs b/lib/apps/fabro-server/src/security_headers.rs similarity index 100% rename from lib/crates/fabro-server/src/security_headers.rs rename to lib/apps/fabro-server/src/security_headers.rs diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/apps/fabro-server/src/serve.rs similarity index 100% rename from lib/crates/fabro-server/src/serve.rs rename to lib/apps/fabro-server/src/serve.rs diff --git a/lib/crates/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs similarity index 99% rename from lib/crates/fabro-server/src/server.rs rename to lib/apps/fabro-server/src/server.rs index ab4c0a726..98dcc4a62 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -1453,7 +1453,7 @@ impl AppState { self.catalog() .default_for_configured_ids(&provider_ids) .id - .clone(), + .to_string(), ) }; AskFabroReadiness { default_model } diff --git a/lib/crates/fabro-server/src/server/automation_scheduler.rs b/lib/apps/fabro-server/src/server/automation_scheduler.rs similarity index 99% rename from lib/crates/fabro-server/src/server/automation_scheduler.rs rename to lib/apps/fabro-server/src/server/automation_scheduler.rs index 4eb802fab..0abd3e988 100644 --- a/lib/crates/fabro-server/src/server/automation_scheduler.rs +++ b/lib/apps/fabro-server/src/server/automation_scheduler.rs @@ -336,6 +336,7 @@ fn run_due_schedules_once<'a>( mod tests { use fabro_api::types::RunManifest; use fabro_automation::{AutomationDraft, AutomationTarget, AutomationTrigger, ScheduleTrigger}; + use fabro_static::EnvVars; use fabro_store::ListRunsQuery; use fabro_types::RunStatus; use serde_json::json; @@ -428,6 +429,7 @@ mod tests { fn test_state_with_materializer(materializer: TestAutomationRunMaterializer) -> Arc { TestAppStateBuilder::new() .env_lookup(|_| None) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) .automation_materializer(materializer) .build() } diff --git a/lib/crates/fabro-server/src/server/handler/artifacts.rs b/lib/apps/fabro-server/src/server/handler/artifacts.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/artifacts.rs rename to lib/apps/fabro-server/src/server/handler/artifacts.rs diff --git a/lib/crates/fabro-server/src/server/handler/automations.rs b/lib/apps/fabro-server/src/server/handler/automations.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/automations.rs rename to lib/apps/fabro-server/src/server/handler/automations.rs diff --git a/lib/crates/fabro-server/src/server/handler/billing.rs b/lib/apps/fabro-server/src/server/handler/billing.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/billing.rs rename to lib/apps/fabro-server/src/server/handler/billing.rs diff --git a/lib/crates/fabro-server/src/server/handler/completions.rs b/lib/apps/fabro-server/src/server/handler/completions.rs similarity index 84% rename from lib/crates/fabro-server/src/server/handler/completions.rs rename to lib/apps/fabro-server/src/server/handler/completions.rs index d5df7299a..b53e56d31 100644 --- a/lib/crates/fabro-server/src/server/handler/completions.rs +++ b/lib/apps/fabro-server/src/server/handler/completions.rs @@ -1,10 +1,13 @@ +use std::collections::HashSet; use std::sync::Arc; +use fabro_model::{Catalog, ModelSelectionError}; + use super::super::{ ApiError, AppState, CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, FinishReason, GenerateParams, IntoResponse, Json, LlmMessage, - LlmRequest, RequiredUser, Response, Router, State, StatusCode, ToolChoice, ToolDefinition, - Ulid, error, generate_object, info, post, warn, + LlmRequest, ProviderId, RequiredUser, Response, Router, State, StatusCode, ToolChoice, + ToolDefinition, Ulid, error, generate_object, info, post, warn, }; use super::llm_sse; @@ -28,21 +31,34 @@ async fn create_completion( State(state): State>, Json(req): Json, ) -> Response { - // Resolve model let catalog = state.catalog(); - let model_id = req - .model - .unwrap_or_else(|| catalog.default_model().id.clone()); - - let catalog_info = catalog.get(&model_id); - - // Resolve provider: explicit request > catalog > None - let explicit_provider = req.provider; - let provider_name = explicit_provider - .clone() - .or_else(|| catalog_info.map(|i| i.provider.to_string())); - - info!(model = %model_id, provider = ?provider_name, "Completion request received"); + let llm_result = match state.resolve_llm_client().await { + Ok(result) => result, + Err(err) => { + error!(error = ?err, "Failed to create LLM client"); + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to resolve LLM providers: {err}"), + ) + .into_response(); + } + }; + for (provider, issue) in &llm_result.auth_issues { + warn!(provider = %provider, error = %issue, "LLM provider unavailable due to auth issue"); + } + for issue in &llm_result.registration_issues { + warn!(provider = %issue.provider, error = %issue.error, "LLM provider unavailable due to registration issue"); + } + let client = llm_result.client; + let (model_id, selected_provider) = match resolve_request_model( + catalog.as_ref(), + &client.provider_ids(), + req.model.as_deref(), + req.provider, + ) { + Ok(selection) => selection, + Err(error) => return ApiError::bad_request(error.to_string()).into_response(), + }; // Build messages list. Request messages are already the canonical // `fabro_types::Message` — the API schema reuses it via build.rs @@ -81,7 +97,7 @@ async fn create_completion( let request = LlmRequest { model: model_id.clone(), messages, - provider: provider_name.clone(), + provider: Some(selected_provider.to_string()), tools, tool_choice, response_format: None, @@ -98,35 +114,15 @@ async fn create_completion( metadata: None, provider_options: req.provider_options, }; + info!( + model = %model_id, + provider = %selected_provider, + "Completion request received" + ); // Force non-streaming for structured output let use_stream = req.stream && req.schema.is_none(); - let llm_result = match state.resolve_llm_client().await { - Ok(result) => result, - Err(err) => { - error!(error = ?err, "Failed to create LLM client"); - return ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to create LLM client: {err}"), - ) - .into_response(); - } - }; - for (provider, issue) in &llm_result.auth_issues { - warn!(provider = %provider, error = %issue, "LLM provider unavailable due to auth issue"); - } - for issue in &llm_result.registration_issues { - warn!(provider = %issue.provider, error = %issue.error, "LLM provider unavailable due to registration issue"); - } - let client = llm_result.client; - if let Some(provider) = explicit_provider.as_deref() { - if !client.has_provider(provider) { - return ApiError::bad_request(format!("Provider \"{provider}\" is not configured")) - .into_response(); - } - } - if use_stream { // Streaming path: forward all StreamEvents as SSE let stream_result = match client.stream(&request).await { @@ -170,6 +166,7 @@ async fn create_completion( Json(CompletionResponse { id: msg_id, model: model_id, + provider: selected_provider, message: response.message, stop_reason, usage: CompletionUsage { @@ -192,6 +189,7 @@ async fn create_completion( Json(CompletionResponse { id: response.id, model: response.model, + provider: ProviderId::new(response.provider), message: response.message, stop_reason, usage: CompletionUsage { @@ -210,3 +208,15 @@ async fn create_completion( } } } + +pub(super) fn resolve_request_model( + catalog: &Catalog, + eligible: &HashSet, + requested_model: Option<&str>, + explicit_provider: Option, +) -> Result<(String, ProviderId), ModelSelectionError> { + let explicit_provider = explicit_provider.map(ProviderId::new); + let selected = + catalog.resolve_selection(requested_model, explicit_provider.as_ref(), eligible)?; + Ok((selected.model, selected.provider)) +} diff --git a/lib/crates/fabro-server/src/server/handler/environments.rs b/lib/apps/fabro-server/src/server/handler/environments.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/environments.rs rename to lib/apps/fabro-server/src/server/handler/environments.rs diff --git a/lib/crates/fabro-server/src/server/handler/events.rs b/lib/apps/fabro-server/src/server/handler/events.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/events.rs rename to lib/apps/fabro-server/src/server/handler/events.rs diff --git a/lib/crates/fabro-server/src/server/handler/graph.rs b/lib/apps/fabro-server/src/server/handler/graph.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/graph.rs rename to lib/apps/fabro-server/src/server/handler/graph.rs diff --git a/lib/crates/fabro-server/src/server/handler/lifecycle.rs b/lib/apps/fabro-server/src/server/handler/lifecycle.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/lifecycle.rs rename to lib/apps/fabro-server/src/server/handler/lifecycle.rs diff --git a/lib/crates/fabro-server/src/server/handler/llm_sse.rs b/lib/apps/fabro-server/src/server/handler/llm_sse.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/llm_sse.rs rename to lib/apps/fabro-server/src/server/handler/llm_sse.rs diff --git a/lib/crates/fabro-server/src/server/handler/mcp_servers.rs b/lib/apps/fabro-server/src/server/handler/mcp_servers.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/mcp_servers.rs rename to lib/apps/fabro-server/src/server/handler/mcp_servers.rs diff --git a/lib/crates/fabro-server/src/server/handler/mod.rs b/lib/apps/fabro-server/src/server/handler/mod.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/mod.rs rename to lib/apps/fabro-server/src/server/handler/mod.rs diff --git a/lib/crates/fabro-server/src/server/handler/models.rs b/lib/apps/fabro-server/src/server/handler/models.rs similarity index 84% rename from lib/crates/fabro-server/src/server/handler/models.rs rename to lib/apps/fabro-server/src/server/handler/models.rs index 3d5341270..06af1d3de 100644 --- a/lib/crates/fabro-server/src/server/handler/models.rs +++ b/lib/apps/fabro-server/src/server/handler/models.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use fabro_auth::ApiCredential; use fabro_llm::client::Client as LlmClient; use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe}; +use fabro_model::ModelSelectionError; use fabro_redact::redact_string; use super::super::{ @@ -40,7 +41,9 @@ struct ModelListParams { #[derive(serde::Deserialize)] struct ModelTestParams { #[serde(default)] - mode: Option, + mode: Option, + #[serde(default)] + provider: Option, } async fn list_models( @@ -62,7 +65,7 @@ async fn list_models( .into_iter() .filter(|model| match &query { Some(query) => { - model.id.to_lowercase().contains(query) + model.id.as_str().to_lowercase().contains(query) || model.display_name.to_lowercase().contains(query) || model .aliases @@ -155,7 +158,7 @@ async fn test_provider_credentials( .into_response(); }; - let outcome = run_basic_model_probe(&model.id, &provider_id, client).await; + let outcome = run_basic_model_probe(model.id.as_str(), &provider_id, client).await; match outcome.status { ModelTestStatus::Ok => ( StatusCode::OK, @@ -204,11 +207,6 @@ async fn test_model( }, None => ModelTestMode::Basic, }; - let catalog = state.catalog(); - let Some(info) = catalog.get(&id) else { - return ApiError::not_found(format!("Model not found: {id}")).into_response(); - }; - let llm_result = match state.resolve_llm_client().await { Ok(result) => result, Err(err) => { @@ -220,6 +218,23 @@ async fn test_model( .into_response(); } }; + let catalog = state.catalog(); + let eligible = llm_result + .provider_ids() + .into_iter() + .collect::>(); + let explicit_provider = params.provider.map(ProviderId::new); + let info = if let Some(provider) = explicit_provider.as_ref() { + match catalog.resolve_on_provider(provider, &id) { + Ok(info) => info, + Err(error) => return model_selection_response(&error), + } + } else { + match catalog.select(&id, None, &eligible) { + Ok(info) => info, + Err(error) => return model_selection_response(&error), + } + }; if let Some((_, issue)) = llm_result .auth_issues .iter() @@ -231,6 +246,7 @@ async fn test_model( if !llm_result.client.has_provider(provider_name) { return Json(serde_json::json!({ "model_id": info.id, + "provider": info.provider, "status": "skip", })) .into_response(); @@ -240,8 +256,24 @@ async fn test_model( let outcome = run_model_test(info, mode, client).await; Json(serde_json::json!({ "model_id": info.id, + "provider": info.provider, "status": <&'static str>::from(outcome.status), "error_message": outcome.error_message, })) .into_response() } + +fn model_selection_response(error: &ModelSelectionError) -> Response { + match error { + ModelSelectionError::UnknownProvider { .. } + | ModelSelectionError::UnknownSelector { .. } + | ModelSelectionError::UnknownSelectorOnProvider { .. } => { + ApiError::not_found(error.to_string()).into_response() + } + ModelSelectionError::ProviderUnavailable { .. } + | ModelSelectionError::NoEligibleOffering { .. } + | ModelSelectionError::NoDefaultModel { .. } => { + ApiError::bad_request(error.to_string()).into_response() + } + } +} diff --git a/lib/crates/fabro-server/src/server/handler/pair.rs b/lib/apps/fabro-server/src/server/handler/pair.rs similarity index 99% rename from lib/crates/fabro-server/src/server/handler/pair.rs rename to lib/apps/fabro-server/src/server/handler/pair.rs index 5b9d36270..67bc1ad05 100644 --- a/lib/crates/fabro-server/src/server/handler/pair.rs +++ b/lib/apps/fabro-server/src/server/handler/pair.rs @@ -883,7 +883,7 @@ mod tests { text: "I found the issue.".to_string(), model: ModelRef { provider: ProviderId::new("openai"), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: None, }, billing: BilledTokenCounts::default(), @@ -916,7 +916,7 @@ mod tests { text: "wrong stage".to_string(), model: ModelRef { provider: ProviderId::new("openai"), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: None, }, billing: BilledTokenCounts::default(), diff --git a/lib/crates/fabro-server/src/server/handler/playground.rs b/lib/apps/fabro-server/src/server/handler/playground.rs similarity index 92% rename from lib/crates/fabro-server/src/server/handler/playground.rs rename to lib/apps/fabro-server/src/server/handler/playground.rs index 0b3111d90..d404c2b58 100644 --- a/lib/crates/fabro-server/src/server/handler/playground.rs +++ b/lib/apps/fabro-server/src/server/handler/playground.rs @@ -109,11 +109,33 @@ async fn create_playground_chat( } let catalog = state.catalog(); - let model_id = req - .model - .unwrap_or_else(|| catalog.default_model().id.clone()); - - info!(model = %model_id, "Playground chat turn"); + let llm_result = match state.resolve_llm_client().await { + Ok(result) => result, + Err(err) => { + error!(error = ?err, "playground: failed to create LLM client"); + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to resolve LLM providers: {err}"), + ) + .into_response(); + } + }; + for (provider, issue) in &llm_result.auth_issues { + warn!(provider = %provider, error = %issue, "playground: provider auth issue"); + } + for issue in &llm_result.registration_issues { + warn!(provider = %issue.provider, error = %issue.error, "playground: provider registration issue"); + } + let client = llm_result.client; + let (model_id, selected_provider) = match super::completions::resolve_request_model( + catalog.as_ref(), + &client.provider_ids(), + req.model.as_deref(), + req.provider.map(|provider| provider.to_string()), + ) { + Ok(selection) => selection, + Err(error) => return ApiError::bad_request(error.to_string()).into_response(), + }; // Request messages are already the canonical `fabro_types::Message` — // the API schema reuses it via build.rs `with_replacement`. @@ -124,7 +146,7 @@ async fn create_playground_chat( let request = LlmRequest { model: model_id, messages, - provider: None, + provider: Some(selected_provider.into_inner()), tools: Some(playground_tools()), tool_choice: Some(ToolChoice::Auto), response_format: None, @@ -137,22 +159,11 @@ async fn create_playground_chat( metadata: None, provider_options: None, }; - - let llm_result = match state.resolve_llm_client().await { - Ok(r) => r, - Err(err) => { - error!(error = ?err, "playground: failed to create LLM client"); - return ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to create LLM client: {err}"), - ) - .into_response(); - } - }; - for (provider, issue) in &llm_result.auth_issues { - warn!(provider = %provider, error = %issue, "playground: provider auth issue"); - } - let client = llm_result.client; + info!( + model = %request.model, + provider = request.provider.as_deref().unwrap_or(""), + "Playground chat turn" + ); let stream_result = match client.stream(&request).await { Ok(s) => s, @@ -238,6 +249,7 @@ mod tests { messages, workflow_fabro, model: None, + provider: None, } } diff --git a/lib/crates/fabro-server/src/server/handler/prompts/playground_system.md b/lib/apps/fabro-server/src/server/handler/prompts/playground_system.md similarity index 100% rename from lib/crates/fabro-server/src/server/handler/prompts/playground_system.md rename to lib/apps/fabro-server/src/server/handler/prompts/playground_system.md diff --git a/lib/crates/fabro-server/src/server/handler/pull_requests.rs b/lib/apps/fabro-server/src/server/handler/pull_requests.rs similarity index 99% rename from lib/crates/fabro-server/src/server/handler/pull_requests.rs rename to lib/apps/fabro-server/src/server/handler/pull_requests.rs index b23af4d82..7acd8fb9d 100644 --- a/lib/crates/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/apps/fabro-server/src/server/handler/pull_requests.rs @@ -321,7 +321,10 @@ async fn create_run_pull_request( } else { let catalog = state.catalog(); let configured = state.ready_llm_provider_ids().await; - catalog.default_for_configured_ids(&configured).id.clone() + catalog + .default_for_configured_ids(&configured) + .id + .to_string() }; let catalog = state.catalog(); diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs similarity index 97% rename from lib/crates/fabro-server/src/server/handler/runs.rs rename to lib/apps/fabro-server/src/server/handler/runs.rs index 6ea48942f..614f2d719 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -52,6 +52,8 @@ 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; pub(super) fn manifest_routes() -> Router> { Router::new() @@ -600,10 +602,23 @@ pub(crate) async fn create_run_from_manifest( .as_ref() .map(LlmClientResult::provider_ids) .unwrap_or_default(); + let run_materialization_provider_ids = { + #[cfg(any(test, feature = "test-support"))] + { + server_test_support::test_run_materialization_provider_ids( + catalog.as_ref(), + &ready_provider_ids, + ) + } + #[cfg(not(any(test, feature = "test-support")))] + { + ready_provider_ids.clone() + } + }; let provenance = run_provenance(&headers, &actor); let mut create_input = run_manifest::create_run_input( prepared.clone(), - ready_provider_ids.clone(), + run_materialization_provider_ids, provenance, web_url.clone(), vars, @@ -625,6 +640,9 @@ pub(crate) async fn create_run_from_manifest( Err(WorkflowError::ValidationFailed { .. } | WorkflowError::Parse(_)) => { return ApiError::bad_request("Validation failed").into_response(); } + Err(err @ (WorkflowError::ModelSelection(_) | WorkflowError::ModelReference(_))) => { + return ApiError::bad_request(err.to_string()).into_response(); + } Err(err) => { return ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, @@ -681,7 +699,7 @@ pub(crate) async fn create_run_from_manifest( workflow, run_inputs, client: llm_result.client, - model_id: title_model_id, + model_id: title_model_id.to_string(), provider_id: title_provider_id, }); } @@ -725,23 +743,6 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) { return; } - let current = match task - .state - .stores - .runs - .get_cached_summary(&task.run_id, Utc::now()) - .await - { - Ok(Some(summary)) => summary, - Ok(None) => return, - Err(err) => { - tracing::debug!(run_id = %task.run_id, error = %err, "Failed to re-read run summary for title update"); - return; - } - }; - if current.title != task.deterministic_title { - return; - } let run_store = match task.state.stores.runs.open_run(&task.run_id).await { Ok(store) => store, Err(err) => { @@ -749,7 +750,8 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) { return; } }; - if let Err(err) = workflow_event::append_event( + let expected_title = task.deterministic_title; + if let Err(err) = workflow_event::append_event_if( &run_store, &task.run_id, &workflow_event::Event::RunTitleUpdated { @@ -758,6 +760,7 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) { system_kind: SystemActorKind::Engine, }), }, + move |projection| projection.title().as_ref() == expected_title, ) .await { diff --git a/lib/crates/fabro-server/src/server/handler/sandbox.rs b/lib/apps/fabro-server/src/server/handler/sandbox.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/sandbox.rs rename to lib/apps/fabro-server/src/server/handler/sandbox.rs diff --git a/lib/crates/fabro-server/src/server/handler/sandboxes.rs b/lib/apps/fabro-server/src/server/handler/sandboxes.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/sandboxes.rs rename to lib/apps/fabro-server/src/server/handler/sandboxes.rs diff --git a/lib/crates/fabro-server/src/server/handler/secrets.rs b/lib/apps/fabro-server/src/server/handler/secrets.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/secrets.rs rename to lib/apps/fabro-server/src/server/handler/secrets.rs diff --git a/lib/crates/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs similarity index 84% rename from lib/crates/fabro-server/src/server/handler/sessions.rs rename to lib/apps/fabro-server/src/server/handler/sessions.rs index 3e24a1f9d..f053207f6 100644 --- a/lib/crates/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -22,7 +22,9 @@ use fabro_api::types::{ }; use fabro_llm::client::Client as LlmClient; use fabro_llm::types::ToolDefinition; -use fabro_model::{AgentProfileKind, Catalog, ModelHandle, ProviderId}; +use fabro_model::{ + AgentProfileKind, Catalog, ModelHandle, ModelSelectionError, ProviderId, catalog, +}; use fabro_sandbox::reconnect::reconnect_for_run; use fabro_static::EnvVars; use fabro_store::{ @@ -35,7 +37,7 @@ use fabro_types::run_event::{ RunSessionTurnFailedProps, RunSessionTurnInterruptedProps, RunSessionTurnStartedProps, RunSessionTurnSucceededProps, RunSessionUserMessageProps, }; -use fabro_types::settings::{ModelRef as SettingsModelRef, ModelRegistry, ResolvedModelRef}; +use fabro_types::settings::ModelRef as SettingsModelRef; use fabro_types::{EventBody, EventEnvelope, RunEvent, RunId, SessionDetail, SessionId, TurnId}; use fabro_workflow::handler::llm::api::register_named_fabro_run_tools; use fabro_workflow::services::FabroRunToolServices; @@ -160,8 +162,24 @@ async fn create_run_session( Ok(store) => store, Err(response) => return response, }; - let model = match canonical_session_model(state.catalog().as_ref(), request.model.as_deref()) { - Ok(model) => model, + let llm_result = match state.resolve_llm_client().await { + Ok(result) => result, + Err(err) => { + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to resolve LLM providers: {err}"), + ) + .into_response(); + } + }; + let eligible = llm_result.provider_ids().into_iter().collect(); + let (provider, model) = match canonical_session_model( + state.catalog().as_ref(), + &eligible, + request.model.as_deref(), + request.provider.as_ref(), + ) { + Ok(selection) => selection, Err(err) => return err.into_response(), }; @@ -180,8 +198,9 @@ async fn create_run_session( run_id, session_id, EventBody::RunSessionCreated(RunSessionCreatedProps { - title: request.title, - model, + title: request.title, + model: Some(model), + provider: Some(provider), }), now, ) @@ -782,17 +801,26 @@ fn selected_session_model( llm_result: &LlmClientResult, session: &ProjectedRunSession, ) -> Result<(ProviderId, String, AgentProfileKind), AskFabroBuildError> { - let configured_provider_ids = llm_result.provider_ids(); - let selected = match session.record.model.as_deref() { - Some(model_id) => catalog.get(model_id).ok_or_else(|| { - AskFabroBuildError::ModelUnavailable(format!( - "session model '{model_id}' is not in the catalog" - )) - })?, - None => catalog.default_for_configured_ids(&configured_provider_ids), - }; - let provider_id = selected.provider.clone(); - let model = selected.id.clone(); + let eligible = llm_result + .provider_ids() + .into_iter() + .collect::>(); + let record = &session.record; + let selected = catalog + .resolve_selection(record.model.as_deref(), record.provider.as_ref(), &eligible) + .map_err(|error| { + // A missing default with no provider pin means no LLM is + // configured at all; every other failure is about the requested + // model/provider. + if record.provider.is_none() + && matches!(error, ModelSelectionError::NoDefaultModel { .. }) + { + AskFabroBuildError::LlmUnconfigured(error.to_string()) + } else { + AskFabroBuildError::ModelUnavailable(error.to_string()) + } + })?; + let (provider_id, model) = (selected.provider, selected.model); let profile_kind = catalog .effective_agent_profile(&provider_id, Some(&model)) .ok_or_else(|| { @@ -805,79 +833,88 @@ fn selected_session_model( fn canonical_session_model( catalog: &Catalog, + eligible: &std::collections::HashSet, requested: Option<&str>, -) -> Result, ApiError> { + explicit_provider: Option<&ProviderId>, +) -> Result<(ProviderId, String), ApiError> { + let explicit_provider = explicit_provider + .map(|provider| { + catalog + .provider(provider) + .map(|provider| provider.id.clone()) + .ok_or_else(|| { + session_selection_error(&ModelSelectionError::UnknownProvider { + provider: provider.clone(), + }) + }) + }) + .transpose()?; let Some(requested) = requested else { - return Ok(None); + let selected = catalog + .resolve_selection(None, explicit_provider.as_ref(), eligible) + .map_err(|error| session_selection_error(&error))?; + return Ok((selected.provider, selected.model)); }; let requested = requested.trim(); if requested.is_empty() { return Err(ApiError::bad_request("Session model must not be empty.")); } + if catalog::legacy_builtin_model(requested).is_some() { + let selected = catalog + .resolve_selection(Some(requested), explicit_provider.as_ref(), eligible) + .map_err(|error| session_selection_error(&error))?; + return Ok((selected.provider, selected.model)); + } let model_ref = requested .parse::() .map_err(|err| ApiError::bad_request(err.to_string()))?; - let registry = CatalogModelRegistry { catalog }; - match model_ref - .resolve(®istry) - .map_err(|err| ApiError::bad_request(err.to_string()))? - { - ResolvedModelRef::Provider(provider) => Err(ApiError::bad_request(format!( - "Session model reference '{provider}' names a provider; include a model ID." - ))), - ResolvedModelRef::Model { - provider: Some(provider), - model, - } => resolve_provider_qualified_session_model(catalog, &provider, &model).map(Some), - ResolvedModelRef::Model { - provider: None, - model, - } => catalog - .get(&model) - .map(|model| Some(model.id.clone())) - .ok_or_else(|| ApiError::bad_request(format!("Unknown session model '{model}'."))), - } + let (qualified_provider, model) = match model_ref { + SettingsModelRef::Qualified { provider, model } => { + let requested_provider = ProviderId::new(provider); + let provider = catalog + .provider(&requested_provider) + .map(|provider| provider.id.clone()) + .ok_or_else(|| { + session_selection_error(&ModelSelectionError::UnknownProvider { + provider: requested_provider, + }) + })?; + if let Some(explicit) = explicit_provider.as_ref() { + if explicit != &provider { + return Err(ApiError::bad_request(format!( + "Session provider pin '{explicit}' conflicts with model reference provider \ + '{provider}'." + ))); + } + } + (Some(provider), model) + } + SettingsModelRef::Bare(model) => { + if explicit_provider.is_none() && catalog.provider(&ProviderId::new(&model)).is_some() { + let detail = if catalog.is_model_selector(&model) { + format!( + "Session model reference '{model}' is ambiguous between a provider and a \ + model selector; supply `provider` or use `provider/model`." + ) + } else { + format!( + "Session model reference '{model}' names a provider; include a model ID." + ) + }; + return Err(ApiError::bad_request(detail)); + } + (None, model) + } + }; + let provider = qualified_provider.as_ref().or(explicit_provider.as_ref()); + let selected = catalog + .resolve_selection(Some(&model), provider, eligible) + .map_err(|error| session_selection_error(&error))?; + Ok((selected.provider, selected.model)) } -fn resolve_provider_qualified_session_model( - catalog: &Catalog, - provider_ref: &str, - model_ref: &str, -) -> Result { - let provider_id = ProviderId::new(provider_ref); - let provider = catalog.provider(&provider_id).ok_or_else(|| { - ApiError::bad_request(format!("Unknown session model provider '{provider_ref}'.")) - })?; - let model = catalog - .get(model_ref) - .ok_or_else(|| ApiError::bad_request(format!("Unknown session model '{model_ref}'.")))?; - if model.provider != provider.id { - return Err(ApiError::bad_request(format!( - "Session model '{model_ref}' belongs to provider '{}', not '{}'.", - model.provider, provider.id - ))); - } - Ok(model.id.clone()) -} - -struct CatalogModelRegistry<'a> { - catalog: &'a Catalog, -} - -impl ModelRegistry for CatalogModelRegistry<'_> { - fn is_provider(&self, token: &str) -> bool { - self.catalog.provider(&ProviderId::new(token)).is_some() - } - - fn is_model(&self, token: &str) -> bool { - self.catalog.get(token).is_some() - } - - fn provider_of(&self, token: &str) -> Option { - self.catalog - .get(token) - .map(|model| model.provider.to_string()) - } +fn session_selection_error(error: &ModelSelectionError) -> ApiError { + ApiError::bad_request(error.to_string()) } fn build_profile( @@ -1275,7 +1312,7 @@ fn agent_event_payload(event_turn_id: TurnId, event: AgentEvent) -> Option Catalog { + let settings: LlmCatalogSettings = toml::from_str( + r#" +[providers.openai] +display_name = "OpenAI" +adapter = "openai" +agent_profile = "openai" +priority = 90 + +[providers.openai.models."gpt-5.6-sol"] +display_name = "GPT-5.6 Sol" +family = "gpt-5" +aliases = ["gpt-56-sol"] +default = true + +[providers.openai.models."gpt-5.6-sol".limits] +context_window = 1000 + +[providers.openai.models."gpt-5.6-sol".features] +tools = true +vision = false +reasoning = false + +[providers.openrouter] +display_name = "OpenRouter" +adapter = "openai_compatible" +agent_profile = "openai" +priority = 25 + +[providers.openrouter.models."gpt-5.6-sol"] +api_id = "openai/gpt-5.6-sol" +display_name = "GPT-5.6 Sol (via OpenRouter)" +family = "gpt-5" +aliases = ["gpt-56-sol"] +default = true + +[providers.openrouter.models."gpt-5.6-sol".limits] +context_window = 1000 + +[providers.openrouter.models."gpt-5.6-sol".features] +tools = true +vision = false +reasoning = false +"#, + ) + .unwrap(); + Catalog::from_settings(&settings).unwrap() + } + + #[test] + fn canonical_session_model_uses_readiness_priority_and_explicit_pins() { + let catalog = portable_session_catalog(); + let openai = ProviderId::openai(); + let openrouter = ProviderId::new("openrouter"); + + assert_eq!( + canonical_session_model( + &catalog, + &std::collections::HashSet::from([openai.clone()]), + Some("gpt-56-sol"), + None, + ) + .unwrap(), + (openai.clone(), "gpt-5.6-sol".to_string()) + ); + assert_eq!( + canonical_session_model( + &catalog, + &std::collections::HashSet::from([openrouter.clone()]), + Some("gpt-56-sol"), + None, + ) + .unwrap(), + (openrouter.clone(), "gpt-5.6-sol".to_string()) + ); + let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]); + assert_eq!( + canonical_session_model(&catalog, &both, Some("gpt-56-sol"), None).unwrap(), + (openai, "gpt-5.6-sol".to_string()) + ); + assert_eq!( + canonical_session_model(&catalog, &both, Some("gpt-56-sol"), Some(&openrouter),) + .unwrap(), + (openrouter.clone(), "gpt-5.6-sol".to_string()) + ); + assert_eq!( + canonical_session_model(&catalog, &both, Some("openrouter/gpt-56-sol"), None,).unwrap(), + (openrouter, "gpt-5.6-sol".to_string()) + ); + } + + #[test] + fn canonical_session_model_preserves_unknown_passthrough_on_selected_provider() { + let catalog = portable_session_catalog(); + let openai = ProviderId::openai(); + let openrouter = ProviderId::new("openrouter"); + let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]); + + assert_eq!( + canonical_session_model(&catalog, &both, Some("future-model"), Some(&openrouter),) + .unwrap(), + (openrouter, "future-model".to_string()) + ); + assert_eq!( + canonical_session_model(&catalog, &both, Some("future-model"), None).unwrap(), + (openai, "future-model".to_string()) + ); + } + + #[test] + fn canonical_session_model_rejects_an_unavailable_explicit_provider() { + let catalog = portable_session_catalog(); + let error = canonical_session_model( + &catalog, + &std::collections::HashSet::from([ProviderId::openai()]), + Some("gpt-56-sol"), + Some(&ProviderId::new("openrouter")), + ) + .unwrap_err(); + + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn canonical_session_model_normalizes_legacy_builtin_selector_before_qualification() { + let catalog = portable_session_catalog(); + let openai = ProviderId::openai(); + let openrouter = ProviderId::new("openrouter"); + let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]); + + assert_eq!( + canonical_session_model(&catalog, &both, Some("openai/gpt-5.6-sol"), None,).unwrap(), + (openai, "gpt-5.6-sol".to_string()) + ); + assert_eq!( + canonical_session_model( + &catalog, + &both, + Some("openai/gpt-5.6-sol"), + Some(&openrouter), + ) + .unwrap(), + (openrouter.clone(), "gpt-5.6-sol".to_string()) + ); + assert_eq!( + canonical_session_model( + &catalog, + &std::collections::HashSet::from([openrouter.clone()]), + Some("openai/gpt-5.6-sol"), + None, + ) + .unwrap(), + (openrouter, "gpt-5.6-sol".to_string()) + ); + } + + #[test] + fn canonical_session_model_still_treats_non_legacy_qualified_model_as_a_pin() { + let catalog = portable_session_catalog(); + let openrouter = ProviderId::new("openrouter"); + + assert_eq!( + canonical_session_model( + &catalog, + &catalog.all_provider_ids(), + Some("openrouter/gpt-56-sol"), + None, + ) + .unwrap(), + (openrouter, "gpt-5.6-sol".to_string()) + ); + } + + #[test] + fn canonical_session_model_rejects_conflicting_non_legacy_provider_pins() { + let catalog = portable_session_catalog(); + let error = canonical_session_model( + &catalog, + &catalog.all_provider_ids(), + Some("openrouter/gpt-56-sol"), + Some(&ProviderId::openai()), + ) + .unwrap_err(); + + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert!( + error + .into_response_entry() + .detail + .contains("conflicts with model reference provider") + ); + } + #[test] fn agent_event_payload_maps_text_delta_to_session_assistant_delta() { let turn_id = TurnId::new(); diff --git a/lib/crates/fabro-server/src/server/handler/steer.rs b/lib/apps/fabro-server/src/server/handler/steer.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/steer.rs rename to lib/apps/fabro-server/src/server/handler/steer.rs diff --git a/lib/crates/fabro-server/src/server/handler/system.rs b/lib/apps/fabro-server/src/server/handler/system.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/system.rs rename to lib/apps/fabro-server/src/server/handler/system.rs diff --git a/lib/crates/fabro-server/src/server/handler/variables.rs b/lib/apps/fabro-server/src/server/handler/variables.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/variables.rs rename to lib/apps/fabro-server/src/server/handler/variables.rs diff --git a/lib/crates/fabro-server/src/server/handler/worker_control.rs b/lib/apps/fabro-server/src/server/handler/worker_control.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/worker_control.rs rename to lib/apps/fabro-server/src/server/handler/worker_control.rs diff --git a/lib/crates/fabro-server/src/server/resource_sampler.rs b/lib/apps/fabro-server/src/server/resource_sampler.rs similarity index 100% rename from lib/crates/fabro-server/src/server/resource_sampler.rs rename to lib/apps/fabro-server/src/server/resource_sampler.rs diff --git a/lib/crates/fabro-server/src/server/session_runtime.rs b/lib/apps/fabro-server/src/server/session_runtime.rs similarity index 100% rename from lib/crates/fabro-server/src/server/session_runtime.rs rename to lib/apps/fabro-server/src/server/session_runtime.rs diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs similarity index 98% rename from lib/crates/fabro-server/src/server/tests.rs rename to lib/apps/fabro-server/src/server/tests.rs index 3d412d5a4..c90e1d10f 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -1553,7 +1553,7 @@ async fn github_webhook_accepts_valid_signature_with_wrong_bearer_token() { #[tokio::test] async fn create_secret_stores_valid_oauth_entries() { - let state = test_app_state(); + let state = TestAppStateBuilder::new().build(); let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() @@ -1993,7 +1993,7 @@ async fn create_secret_rejects_invalid_oauth_name() { #[tokio::test] async fn delete_secret_by_name_removes_file_secret() { - let state = test_app_state(); + let state = TestAppStateBuilder::new().build(); let app = crate::test_support::build_test_router(Arc::clone(&state)); let create_req = Request::builder() @@ -3394,25 +3394,30 @@ async fn create_run_with_explicit_title_skips_generated_title_work() { } #[tokio::test] -async fn create_run_without_ready_llm_provider_skips_generated_title_work() { +async fn create_run_without_ready_llm_provider_rejects_implicit_model_selection() { let state = TestAppStateBuilder::new().env_lookup(|_| None).build(); let app = crate::test_support::build_test_router(Arc::clone(&state)); - let body = post_run_manifest(&app, minimal_manifest_json(MINIMAL_DOT)).await; - let run_id: RunId = body["id"].as_str().unwrap().parse().unwrap(); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .body(Body::from(minimal_manifest_json(MINIMAL_DOT).to_string())) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::BAD_REQUEST).await; - assert_eq!( - state - .stores - .runs - .get_cached_summary(&run_id, Utc::now()) - .await - .unwrap() - .unwrap() - .title, - "Test" + assert!( + body["errors"][0]["detail"] + .as_str() + .is_some_and(|detail| detail.contains("no default model is available")), + "unexpected response: {body}" ); - assert_eq!(title_update_event_count(&state, run_id).await, 0); + assert!(state.runs.lock().expect("runs lock poisoned").is_empty()); } #[tokio::test] @@ -3490,7 +3495,7 @@ async fn generated_title_does_not_overwrite_user_title_edit() { response_json!(response, StatusCode::OK).await; wait_for_mock_hits(&title_mock, 1).await; - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + tokio::time::sleep(std::time::Duration::from_millis(250)).await; assert_eq!( state @@ -3524,7 +3529,10 @@ async fn post_run_manifest(app: &Router, manifest: serde_json::Value) -> serde_j #[tokio::test] async fn post_runs_create_regression_keeps_api_behavior_without_automation_metadata() { - let state = TestAppStateBuilder::new().env_lookup(|_| None).build(); + let state = TestAppStateBuilder::new() + .env_lookup(|_| None) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) + .build(); let app = crate::test_support::build_test_router(Arc::clone(&state)); let mut manifest = minimal_manifest_json(MINIMAL_DOT); manifest["title"] = json!("API title"); @@ -3547,7 +3555,10 @@ async fn post_runs_create_regression_keeps_api_behavior_without_automation_metad #[tokio::test] async fn create_run_from_manifest_helper_persists_without_automation_metadata() { - let state = TestAppStateBuilder::new().env_lookup(|_| None).build(); + let state = TestAppStateBuilder::new() + .env_lookup(|_| None) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) + .build(); let manifest: RunManifest = serde_json::from_value(minimal_manifest_json(MINIMAL_DOT)).unwrap(); let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap(); let run_id = RunId::new(); @@ -3583,7 +3594,10 @@ async fn create_run_from_manifest_helper_persists_without_automation_metadata() #[tokio::test] async fn create_run_from_manifest_helper_persists_automation_metadata() { - let state = TestAppStateBuilder::new().env_lookup(|_| None).build(); + let state = TestAppStateBuilder::new() + .env_lookup(|_| None) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) + .build(); let manifest: RunManifest = serde_json::from_value(minimal_manifest_json(MINIMAL_DOT)).unwrap(); let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap(); let run_id = RunId::new(); @@ -4130,7 +4144,7 @@ fn context_window_event( text: "assistant response".to_string(), model: ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: None, }, usage: TokenCounts::default(), @@ -6094,6 +6108,15 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings( .set("GITHUB_TOKEN", token, SecretType::Token, None) .expect("test github token should be writable"); } + Vault::load(vault_path.clone()) + .expect("test vault should load") + .set( + EnvVars::OPENAI_API_KEY, + "test-openai-api-key", + SecretType::Token, + None, + ) + .expect("test OpenAI credential should be writable"); let db_pool = test_db_pool_for_vault_path(&vault_path).expect("test db pool should build"); let preloaded_vault = crate::test_support::test_secret_snapshot(db_pool.clone()) .expect("test secret snapshot should build"); @@ -6392,7 +6415,7 @@ async fn test_model_unknown_returns_404() { } #[tokio::test] -async fn test_model_alias_returns_canonical_model_id() { +async fn test_model_explicit_provider_alias_returns_canonical_model_id_when_unavailable() { let state = test_app_state_with_env_lookup( default_test_server_settings(), RunLayer::default(), @@ -6403,7 +6426,7 @@ async fn test_model_alias_returns_canonical_model_id() { let req = Request::builder() .method("POST") - .uri(api("/models/sonnet/test")) + .uri(api("/models/sonnet/test?provider=anthropic")) .header("content-type", "application/json") .body(Body::empty()) .unwrap(); @@ -6411,9 +6434,208 @@ async fn test_model_alias_returns_canonical_model_id() { let response = app.oneshot(req).await.unwrap(); let body = response_json!(response, StatusCode::OK).await; assert_eq!(body["model_id"], "claude-sonnet-4-6"); + assert_eq!(body["provider"], "anthropic"); assert_eq!(body["status"], "skip"); } +#[tokio::test] +async fn test_model_unqualified_known_alias_requires_a_ready_provider() { + let state = test_app_state_with_env_lookup( + default_test_server_settings(), + RunLayer::default(), + 5, + |_| None, + ); + let app = crate::test_support::build_test_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(api("/models/sonnet/test")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_status!(response, StatusCode::BAD_REQUEST).await; +} + +#[tokio::test] +async fn model_api_keeps_duplicate_ids_provider_scoped_and_selects_ready_priority() { + let direct_upstream = MockServer::start(); + let aggregator_upstream = MockServer::start(); + let direct_probe = direct_upstream.mock(|when, then| { + when.method(POST) + .path("/chat/completions") + .json_body_includes(r#"{"model":"portable-model"}"#); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "id": "chatcmpl-direct", + "model": "portable-model", + "choices": [{ + "message": {"role": "assistant", "content": "OK"}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2 + } + })); + }); + let aggregator_probe = aggregator_upstream.mock(|when, then| { + when.method(POST) + .path("/chat/completions") + .json_body_includes(r#"{"model":"vendor/portable-model"}"#); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "id": "chatcmpl-aggregator", + "model": "vendor/portable-model", + "choices": [{ + "message": {"role": "assistant", "content": "OK"}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2 + } + })); + }); + let settings: LlmCatalogSettings = toml::from_str(&format!( + r#" +[providers.direct] +display_name = "Direct" +adapter = "openai_compatible" +agent_profile = "openai" +base_url = "{}" +priority = 120 + +[providers.direct.auth] +credentials = ["vault:DIRECT_API_KEY"] + +[providers.direct.models.portable-model] +display_name = "Portable (direct)" +family = "portable" +aliases = ["portable"] +default = true + +[providers.direct.models.portable-model.limits] +context_window = 1000 + +[providers.direct.models.portable-model.features] +tools = false +vision = false +reasoning = false + +[providers.aggregator] +display_name = "Aggregator" +adapter = "openai_compatible" +agent_profile = "openai" +base_url = "{}" +priority = 110 + +[providers.aggregator.auth] +credentials = ["vault:AGGREGATOR_API_KEY"] + +[providers.aggregator.models.portable-model] +api_id = "vendor/portable-model" +display_name = "Portable (aggregator)" +family = "portable" +aliases = ["portable"] +default = true + +[providers.aggregator.models.portable-model.limits] +context_window = 1000 + +[providers.aggregator.models.portable-model.features] +tools = false +vision = false +reasoning = false +"#, + direct_upstream.base_url(), + aggregator_upstream.base_url(), + )) + .unwrap(); + let state = TestAppStateBuilder::new() + .llm_catalog_settings(settings) + .vault_entries([ + ("DIRECT_API_KEY", "direct-test-key"), + ("AGGREGATOR_API_KEY", "aggregator-test-key"), + ]) + .build(); + let app = crate::test_support::build_test_router(state); + + let list = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(api("/models?query=portable-model")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let list = response_json!(list, StatusCode::OK).await; + let rows = list["data"].as_array().unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!( + rows.iter() + .map(|row| row["provider"].as_str().unwrap()) + .collect::>(), + std::collections::BTreeSet::from(["aggregator", "direct"]) + ); + assert!( + rows.iter() + .all(|row| row["id"] == "portable-model" && row["configured"] == true) + ); + + let filtered = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(api("/models?provider=aggregator&query=portable-model")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let filtered = response_json!(filtered, StatusCode::OK).await; + assert_eq!(filtered["data"].as_array().unwrap().len(), 1); + assert_eq!(filtered["data"][0]["provider"], "aggregator"); + + for (query, expected_provider) in [ + ("?provider=direct", "direct"), + ("?provider=aggregator", "aggregator"), + ("", "direct"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(api(&format!("/models/portable/test{query}"))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::OK).await; + assert_eq!(body["model_id"], "portable-model"); + assert_eq!(body["provider"], expected_provider); + assert_eq!(body["status"], "ok"); + } + + direct_probe.assert_calls(2); + aggregator_probe.assert_calls(1); +} + #[tokio::test] async fn test_model_invalid_mode_returns_400() { let state = test_app_state_with_env_lookup( @@ -13483,7 +13705,7 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() { agg.by_model.insert( ModelRef { provider: ProviderId::anthropic(), - model_id: "claude-opus-4-6".to_string(), + model_id: "claude-opus-4-6".into(), speed: None, }, ModelBillingTotals { @@ -13502,7 +13724,7 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() { agg.by_model.insert( ModelRef { provider: ProviderId::anthropic(), - model_id: "claude-opus-4-6".to_string(), + model_id: "claude-opus-4-6".into(), speed: Some(Speed::Fast), }, ModelBillingTotals { @@ -13563,7 +13785,7 @@ async fn get_aggregate_billing_saturates_total_cost_across_models() { agg.by_model.insert( ModelRef { provider: ProviderId::openai(), - model_id: model_id.to_string(), + model_id: model_id.into(), speed: None, }, ModelBillingTotals { @@ -13611,7 +13833,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { fabro_workflow::ProjectionBillingByModel { model: ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: None, }, stages: 1, @@ -13628,7 +13850,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { fabro_workflow::ProjectionBillingByModel { model: ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: Some(Speed::Fast), }, stages: 1, @@ -13655,7 +13877,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { assert_eq!( accumulator.by_model[&ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: None, }] .stages, @@ -13664,7 +13886,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { assert_eq!( accumulator.by_model[&ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: None, }] .billing @@ -13674,7 +13896,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { assert_eq!( accumulator.by_model[&ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: Some(Speed::Fast), }] .stages, @@ -13683,7 +13905,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { assert_eq!( accumulator.by_model[&ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: Some(Speed::Fast), }] .billing @@ -13771,6 +13993,17 @@ level = "debug" manifest_run_defaults_from_toml(source), 5, ); + state + .stores + .vault + .set( + EnvVars::ANTHROPIC_API_KEY, + "test-anthropic-api-key", + SecretType::Token, + None, + ) + .await + .unwrap(); let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() @@ -13936,6 +14169,7 @@ async fn cancel_run_overwrites_pending_pause_request() { async fn cancel_run_requests_worker_runtime_stop_when_control_unavailable() { let runtime = StdArc::new(RecordingWorkerRuntime::default()); let state = TestAppStateBuilder::new() + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) .worker_runtime(runtime.clone()) .build(); let app = crate::test_support::build_test_router(Arc::clone(&state)); @@ -14815,42 +15049,52 @@ async fn create_completion_unknown_provider_returns_clear_error() { let body = response_json!(response, StatusCode::BAD_REQUEST).await; assert_eq!( body["errors"][0]["detail"], - "Provider \"missing-provider\" is not configured" + "unknown model provider 'missing-provider'" ); } #[tokio::test] async fn create_completion_default_model_uses_app_state_catalog() { - let llm_catalog_settings: LlmCatalogSettings = toml::from_str( + let upstream = MockServer::start(); + let completion = upstream.mock(|when, then| { + when.method(POST) + .path("/chat/completions") + .json_body_includes(r#"{"model":"acme-large"}"#); + then.status(500) + .header("content-type", "application/json") + .json_body(json!({"error": {"message": "expected test failure"}})); + }); + let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!( r#" [providers.acme] display_name = "Acme" adapter = "openai_compatible" agent_profile = "openai" -base_url = "https://api.acme.test/v1" +base_url = "{}" priority = 120 [providers.acme.auth] -credentials = ["env:ACME_API_KEY"] +credentials = ["vault:ACME_API_KEY"] -[models."acme-large"] -provider = "acme" +[providers.acme.models."acme-large"] display_name = "Acme Large" family = "acme" default = true -[models."acme-large".limits] +[providers.acme.models."acme-large".limits] context_window = 128000 -[models."acme-large".features] +[providers.acme.models."acme-large".features] tools = true vision = false reasoning = false "#, - ) + upstream.base_url() + )) .expect("catalog fixture should parse"); let state = TestAppStateBuilder::new() .llm_catalog_settings(llm_catalog_settings) + .vault_entries([("ACME_API_KEY", "acme-test-key")]) .build(); let app = crate::test_support::build_test_router(state); @@ -14878,9 +15122,10 @@ reasoning = false body["errors"][0]["detail"] .as_str() .unwrap() - .contains("Provider 'acme' not registered"), + .contains("expected test failure"), "unexpected error body: {body:?}" ); + completion.assert(); } #[tokio::test] diff --git a/lib/crates/fabro-server/src/server_secrets.rs b/lib/apps/fabro-server/src/server_secrets.rs similarity index 100% rename from lib/crates/fabro-server/src/server_secrets.rs rename to lib/apps/fabro-server/src/server_secrets.rs diff --git a/lib/crates/fabro-server/src/spawn_env.rs b/lib/apps/fabro-server/src/spawn_env.rs similarity index 99% rename from lib/crates/fabro-server/src/spawn_env.rs rename to lib/apps/fabro-server/src/spawn_env.rs index e4c58f2e1..bfea6b038 100644 --- a/lib/crates/fabro-server/src/spawn_env.rs +++ b/lib/apps/fabro-server/src/spawn_env.rs @@ -18,6 +18,8 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[ // reach the refresh-ahead loop in the ACP handler. EnvVars::FABRO_PUSH_CRED_REFRESH_AHEAD, EnvVars::FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS, + #[cfg(feature = "test-support")] + "FABRO_TEST_ASSUME_LLM_READY", EnvVars::TERM, EnvVars::NO_COLOR, EnvVars::CLICOLOR, diff --git a/lib/crates/fabro-server/src/startup.rs b/lib/apps/fabro-server/src/startup.rs similarity index 100% rename from lib/crates/fabro-server/src/startup.rs rename to lib/apps/fabro-server/src/startup.rs diff --git a/lib/crates/fabro-server/src/static_files.rs b/lib/apps/fabro-server/src/static_files.rs similarity index 99% rename from lib/crates/fabro-server/src/static_files.rs rename to lib/apps/fabro-server/src/static_files.rs index cb5c500a2..b0a9e623e 100644 --- a/lib/crates/fabro-server/src/static_files.rs +++ b/lib/apps/fabro-server/src/static_files.rs @@ -632,7 +632,7 @@ mod tests { #[tokio::test] async fn dev_disk_only_does_not_fall_back_to_embedded() { // The embedded SPA snapshot has chunk-12xq903b.js (observed in - // lib/crates/fabro-spa/assets/index.html). In disk-only mode that + // lib/apps/fabro-spa/assets/index.html). In disk-only mode that // bytes-on-disk-or-503 contract must hold even for assets the // embedded snapshot would otherwise serve. let temp_dir = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-server/src/test_support.rs b/lib/apps/fabro-server/src/test_support.rs similarity index 94% rename from lib/crates/fabro-server/src/test_support.rs rename to lib/apps/fabro-server/src/test_support.rs index 4394c02a9..8fe368fff 100644 --- a/lib/crates/fabro-server/src/test_support.rs +++ b/lib/apps/fabro-server/src/test_support.rs @@ -17,6 +17,7 @@ use fabro_config::{RunLayer, ServerSettingsBuilder, Storage, envfile}; use fabro_db::DbPool; use fabro_interview::Interviewer; use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings}; +use fabro_model::{Catalog, ProviderId}; use fabro_sandbox::SandboxProviderRegistry; use fabro_static::EnvVars; use fabro_store::{ArtifactStore, Database}; @@ -49,6 +50,27 @@ pub const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; pub const TEST_SESSION_SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const TEST_OPENAI_API_KEY: &str = "test-openai-api-key"; +const FABRO_TEST_ASSUME_LLM_READY: &str = "FABRO_TEST_ASSUME_LLM_READY"; + +/// Supply enabled catalog providers to CLI fixture run materialization. +/// +/// This is scoped to the `test-support` feature and an explicit child-process +/// flag. The CLI suite shares a server whose credential state can change +/// between tests, so the flag deliberately ignores that mutable state. It does +/// not register adapters or make model execution available. +pub(crate) fn test_run_materialization_provider_ids( + catalog: &Catalog, + ready_provider_ids: &[ProviderId], +) -> Vec { + let assume_ready = process_env_var(FABRO_TEST_ASSUME_LLM_READY) + .is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "no")); + if assume_ready { + catalog.all_provider_ids().into_iter().collect() + } else { + ready_provider_ids.to_vec() + } +} pub fn default_test_server_settings() -> ServerSettings { ServerSettingsBuilder::from_toml( @@ -320,13 +342,13 @@ pub fn llm_catalog_settings_with_provider_base_url( } pub fn test_app_state() -> Arc { - TestAppStateBuilder::new().build() + ready_test_app_state_builder().build() } pub fn test_app_state_with_registry_factory( registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - TestAppStateBuilder::new() + ready_test_app_state_builder() .registry_factory(registry_factory_override) .build() } @@ -336,7 +358,7 @@ pub fn test_app_state_with_settings_and_registry_factory( manifest_run_defaults: RunLayer, registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - TestAppStateBuilder::new() + ready_test_app_state_builder() .runtime_settings(server_settings, manifest_run_defaults) .registry_factory(registry_factory_override) .build() @@ -348,7 +370,7 @@ pub fn test_app_state_with_options_and_registry_factory( max_concurrent_runs: usize, registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - TestAppStateBuilder::new() + ready_test_app_state_builder() .runtime_settings(server_settings, manifest_run_defaults) .max_concurrent_runs(max_concurrent_runs) .registry_factory(registry_factory_override) @@ -360,12 +382,16 @@ pub fn test_app_state_with_options( manifest_run_defaults: RunLayer, max_concurrent_runs: usize, ) -> Arc { - TestAppStateBuilder::new() + ready_test_app_state_builder() .runtime_settings(server_settings, manifest_run_defaults) .max_concurrent_runs(max_concurrent_runs) .build() } +fn ready_test_app_state_builder() -> TestAppStateBuilder { + TestAppStateBuilder::new().vault_entries([(EnvVars::OPENAI_API_KEY, TEST_OPENAI_API_KEY)]) +} + pub(crate) fn resolved_runtime_settings_for_tests( server_settings: ServerSettings, manifest_run_defaults: RunLayer, @@ -383,7 +409,7 @@ pub fn test_app_state_with_runtime_settings_and_registry_factory( manifest_run_defaults: RunLayer, registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - TestAppStateBuilder::new() + ready_test_app_state_builder() .runtime_settings(server_settings, manifest_run_defaults) .registry_factory(registry_factory_override) .build() @@ -395,7 +421,7 @@ pub fn test_app_state_with_runtime_settings_and_options_and_registry_factory( max_concurrent_runs: usize, registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - TestAppStateBuilder::new() + ready_test_app_state_builder() .runtime_settings(server_settings, manifest_run_defaults) .max_concurrent_runs(max_concurrent_runs) .registry_factory(registry_factory_override) @@ -407,7 +433,7 @@ pub fn test_app_state_with_runtime_settings_and_options( manifest_run_defaults: RunLayer, max_concurrent_runs: usize, ) -> Arc { - TestAppStateBuilder::new() + ready_test_app_state_builder() .runtime_settings(server_settings, manifest_run_defaults) .max_concurrent_runs(max_concurrent_runs) .build() @@ -475,7 +501,7 @@ pub fn test_app_state_with_runtime_settings_and_session_key( ) .expect("test server env should be writable"); } - TestAppStateBuilder::new() + ready_test_app_state_builder() .runtime_settings(server_settings, manifest_run_defaults) .vault_path(vault_path) .server_env_path(server_env_path) @@ -501,7 +527,7 @@ pub fn test_app_state_with_store( store: Arc, artifact_store: ArtifactStore, ) -> Arc { - TestAppStateBuilder::new() + ready_test_app_state_builder() .runtime_settings(server_settings, manifest_run_defaults) .max_concurrent_runs(max_concurrent_runs) .store_bundle(store, artifact_store) @@ -612,7 +638,7 @@ pub fn test_app_state_with_store_and_runtime_settings( store: Arc, artifact_store: ArtifactStore, ) -> Arc { - TestAppStateBuilder::new() + ready_test_app_state_builder() .runtime_settings(server_settings, manifest_run_defaults) .max_concurrent_runs(max_concurrent_runs) .store_bundle(store, artifact_store) diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/apps/fabro-server/src/web_auth.rs similarity index 100% rename from lib/crates/fabro-server/src/web_auth.rs rename to lib/apps/fabro-server/src/web_auth.rs diff --git a/lib/crates/fabro-server/src/worker_control/bus.rs b/lib/apps/fabro-server/src/worker_control/bus.rs similarity index 100% rename from lib/crates/fabro-server/src/worker_control/bus.rs rename to lib/apps/fabro-server/src/worker_control/bus.rs diff --git a/lib/crates/fabro-server/src/worker_control/local.rs b/lib/apps/fabro-server/src/worker_control/local.rs similarity index 100% rename from lib/crates/fabro-server/src/worker_control/local.rs rename to lib/apps/fabro-server/src/worker_control/local.rs diff --git a/lib/crates/fabro-server/src/worker_control/mod.rs b/lib/apps/fabro-server/src/worker_control/mod.rs similarity index 100% rename from lib/crates/fabro-server/src/worker_control/mod.rs rename to lib/apps/fabro-server/src/worker_control/mod.rs diff --git a/lib/crates/fabro-server/src/worker_runtime.rs b/lib/apps/fabro-server/src/worker_runtime.rs similarity index 100% rename from lib/crates/fabro-server/src/worker_runtime.rs rename to lib/apps/fabro-server/src/worker_runtime.rs diff --git a/lib/crates/fabro-server/src/worker_token.rs b/lib/apps/fabro-server/src/worker_token.rs similarity index 100% rename from lib/crates/fabro-server/src/worker_token.rs rename to lib/apps/fabro-server/src/worker_token.rs diff --git a/lib/crates/fabro-server/tests/fixtures/spa/images/favicon.svg b/lib/apps/fabro-server/tests/fixtures/spa/images/favicon.svg similarity index 100% rename from lib/crates/fabro-server/tests/fixtures/spa/images/favicon.svg rename to lib/apps/fabro-server/tests/fixtures/spa/images/favicon.svg diff --git a/lib/crates/fabro-server/tests/fixtures/spa/index.html b/lib/apps/fabro-server/tests/fixtures/spa/index.html similarity index 100% rename from lib/crates/fabro-server/tests/fixtures/spa/index.html rename to lib/apps/fabro-server/tests/fixtures/spa/index.html diff --git a/lib/crates/fabro-server/tests/it/api/auth_sessions.rs b/lib/apps/fabro-server/tests/it/api/auth_sessions.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/auth_sessions.rs rename to lib/apps/fabro-server/tests/it/api/auth_sessions.rs diff --git a/lib/crates/fabro-server/tests/it/api/automations.rs b/lib/apps/fabro-server/tests/it/api/automations.rs similarity index 99% rename from lib/crates/fabro-server/tests/it/api/automations.rs rename to lib/apps/fabro-server/tests/it/api/automations.rs index 4a2b96de0..f17bab0e8 100644 --- a/lib/crates/fabro-server/tests/it/api/automations.rs +++ b/lib/apps/fabro-server/tests/it/api/automations.rs @@ -7,6 +7,7 @@ use fabro_server::server::build_router; use fabro_server::test_support::{ TestAppStateBuilder, TestAutomationRunMaterializer, build_test_router, test_auth_mode, }; +use fabro_static::EnvVars; use serde_json::{Value, json}; use sqlx::Row as _; use tower::ServiceExt; @@ -86,6 +87,7 @@ fn automation_app_with_fake_materializer() -> (axum::Router, tempfile::TempDir, let state = TestAppStateBuilder::new() .active_config_path(active_config_path) .vault_path(vault_path) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) .automation_materializer(TestAutomationRunMaterializer::succeed( materialized_manifest, submitted_manifest_bytes, diff --git a/lib/crates/fabro-server/tests/it/api/cli_auth_token.rs b/lib/apps/fabro-server/tests/it/api/cli_auth_token.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/cli_auth_token.rs rename to lib/apps/fabro-server/tests/it/api/cli_auth_token.rs diff --git a/lib/crates/fabro-server/tests/it/api/compression.rs b/lib/apps/fabro-server/tests/it/api/compression.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/compression.rs rename to lib/apps/fabro-server/tests/it/api/compression.rs diff --git a/lib/crates/fabro-server/tests/it/api/docs.rs b/lib/apps/fabro-server/tests/it/api/docs.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/docs.rs rename to lib/apps/fabro-server/tests/it/api/docs.rs diff --git a/lib/crates/fabro-server/tests/it/api/environments.rs b/lib/apps/fabro-server/tests/it/api/environments.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/environments.rs rename to lib/apps/fabro-server/tests/it/api/environments.rs diff --git a/lib/crates/fabro-server/tests/it/api/events.rs b/lib/apps/fabro-server/tests/it/api/events.rs similarity index 98% rename from lib/crates/fabro-server/tests/it/api/events.rs rename to lib/apps/fabro-server/tests/it/api/events.rs index e29b51b21..a5c8153c7 100644 --- a/lib/crates/fabro-server/tests/it/api/events.rs +++ b/lib/apps/fabro-server/tests/it/api/events.rs @@ -4,6 +4,7 @@ use std::time::Duration; use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use chrono::{SecondsFormat, Utc}; +use fabro_static::EnvVars; use object_store::ObjectStore; use object_store::memory::InMemory; use tokio::sync::Barrier; @@ -23,6 +24,7 @@ fn app_with_store(object_store: Arc) -> axum::Router { let state = fabro_server::test_support::TestAppStateBuilder::new() .runtime_settings(settings.server_settings, settings.manifest_run_defaults) .env_lookup(|_| None) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) .store_bundle(store, artifact_store) .build(); fabro_server::test_support::build_test_router(state) diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/apps/fabro-server/tests/it/api/install.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/install.rs rename to lib/apps/fabro-server/tests/it/api/install.rs diff --git a/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs b/lib/apps/fabro-server/tests/it/api/install_openai_compatible.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs rename to lib/apps/fabro-server/tests/it/api/install_openai_compatible.rs diff --git a/lib/crates/fabro-server/tests/it/api/mcp_servers.rs b/lib/apps/fabro-server/tests/it/api/mcp_servers.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/mcp_servers.rs rename to lib/apps/fabro-server/tests/it/api/mcp_servers.rs diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/apps/fabro-server/tests/it/api/mod.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/mod.rs rename to lib/apps/fabro-server/tests/it/api/mod.rs diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/apps/fabro-server/tests/it/api/routing.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/routing.rs rename to lib/apps/fabro-server/tests/it/api/routing.rs diff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/apps/fabro-server/tests/it/api/run_files.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/run_files.rs rename to lib/apps/fabro-server/tests/it/api/run_files.rs diff --git a/lib/crates/fabro-server/tests/it/api/runs.rs b/lib/apps/fabro-server/tests/it/api/runs.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/runs.rs rename to lib/apps/fabro-server/tests/it/api/runs.rs diff --git a/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs b/lib/apps/fabro-server/tests/it/api/sandbox_vnc.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs rename to lib/apps/fabro-server/tests/it/api/sandbox_vnc.rs diff --git a/lib/crates/fabro-server/tests/it/api/sessions.rs b/lib/apps/fabro-server/tests/it/api/sessions.rs similarity index 89% rename from lib/crates/fabro-server/tests/it/api/sessions.rs rename to lib/apps/fabro-server/tests/it/api/sessions.rs index dc8217198..c989aa4c3 100644 --- a/lib/crates/fabro-server/tests/it/api/sessions.rs +++ b/lib/apps/fabro-server/tests/it/api/sessions.rs @@ -1,10 +1,10 @@ use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; +use fabro_static::EnvVars; use tower::ServiceExt; use crate::helpers::{ MINIMAL_DOT, api, minimal_manifest_json, response_json, response_status, test_app_state, - test_app_with_no_providers, }; async fn create_run(app: &axum::Router) -> String { @@ -196,6 +196,7 @@ async fn supplied_session_model_alias_is_canonicalized() { .find(|event| event["event"] == "run.session.created") .expect("session creation event should be recorded"); assert_eq!(created_event["properties"]["model"], "gpt-5.4"); + assert_eq!(created_event["properties"]["provider"], "openai"); } #[tokio::test] @@ -203,9 +204,23 @@ async fn provider_qualified_session_model_is_canonicalized() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; - let created = create_session_with_model(&app, &run_id, "Ask Fabro", "openai/gpt-5.4").await; + let created = + create_session_with_model(&app, &run_id, "Ask Fabro", "openai/gpt-5.4-mini").await; - assert_eq!(created["model"], "gpt-5.4"); + assert_eq!(created["model"], "gpt-5.4-mini"); + assert_eq!(created["provider"], "openai"); +} + +#[tokio::test] +async fn unknown_session_models_preserve_passthrough_on_the_selected_provider() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let run_id = create_run(&app).await; + + for model in ["not-a-real-model", "openai/not-a-real-model"] { + let created = create_session_with_model(&app, &run_id, "Ask Fabro", model).await; + assert_eq!(created["model"], "not-a-real-model"); + assert_eq!(created["provider"], "openai"); + } } #[tokio::test] @@ -213,7 +228,7 @@ async fn invalid_session_model_refs_are_rejected_at_creation() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; - for model in ["not-a-real-model", "openai", "openai/", "anthropic/gpt-5.4"] { + for model in ["openai", "openai/", "anthropic/gpt-5.4"] { let response = create_session_response( &app, &run_id, @@ -241,6 +256,7 @@ async fn ambiguous_session_model_refs_are_rejected_at_creation() { ); let state = fabro_server::test_support::TestAppStateBuilder::new() .llm_catalog_settings(catalog_settings) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) .build(); let app = fabro_server::test_support::build_test_router(state); let run_id = create_run(&app).await; @@ -260,13 +276,14 @@ async fn ambiguous_session_model_refs_are_rejected_at_creation() { } #[tokio::test] -async fn session_turn_fails_when_selected_model_provider_is_unconfigured() { - let app = test_app_with_no_providers(); +async fn session_turn_fails_when_selected_model_provider_becomes_unconfigured() { + let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; let created = create_session_with_model(&app, &run_id, "Ask Fabro", "gpt54").await; let session_id = created["id"] .as_str() .expect("session response should include an id"); + delete_openai_credential(&app).await; let request = Request::builder() .method("POST") @@ -347,7 +364,7 @@ async fn unsupported_derived_turn_read_routes_are_removed() { #[tokio::test] async fn session_events_are_filtered_by_session_and_paginated_by_run_sequence() { - let app = test_app_with_no_providers(); + let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; let first = create_session(&app, &run_id, "First").await; let second = create_session(&app, &run_id, "Second").await; @@ -365,6 +382,7 @@ async fn session_events_are_filtered_by_session_and_paginated_by_run_sequence() ) .await; let after_first_created_seq = first_detail["last_seq"].as_u64().unwrap() + 1; + delete_openai_credential(&app).await; let turn_id = fabro_types::TurnId::new(); let submit = Request::builder() @@ -443,13 +461,33 @@ async fn session_sse_events(response: axum::response::Response) -> Vec PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(3) - .expect("fabro-server crate should be nested under lib/crates/fabro-server") + .expect("fabro-server crate should be nested under lib/apps/fabro-server") .to_path_buf() } diff --git a/lib/crates/fabro-server/tests/it/main.rs b/lib/apps/fabro-server/tests/it/main.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/main.rs rename to lib/apps/fabro-server/tests/it/main.rs diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/apps/fabro-server/tests/it/openapi_conformance.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/openapi_conformance.rs rename to lib/apps/fabro-server/tests/it/openapi_conformance.rs diff --git a/lib/crates/fabro-server/tests/it/pagination.rs b/lib/apps/fabro-server/tests/it/pagination.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/pagination.rs rename to lib/apps/fabro-server/tests/it/pagination.rs diff --git a/lib/crates/fabro-server/tests/it/scenario/archive.rs b/lib/apps/fabro-server/tests/it/scenario/archive.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/scenario/archive.rs rename to lib/apps/fabro-server/tests/it/scenario/archive.rs diff --git a/lib/crates/fabro-server/tests/it/scenario/dry_run.rs b/lib/apps/fabro-server/tests/it/scenario/dry_run.rs similarity index 94% rename from lib/crates/fabro-server/tests/it/scenario/dry_run.rs rename to lib/apps/fabro-server/tests/it/scenario/dry_run.rs index 08b1393d9..355d7e96d 100644 --- a/lib/crates/fabro-server/tests/it/scenario/dry_run.rs +++ b/lib/apps/fabro-server/tests/it/scenario/dry_run.rs @@ -55,7 +55,7 @@ async fn dry_run_serve_starts_and_runs_workflow() { } #[tokio::test] -async fn test_model_skip_when_no_providers() { +async fn test_model_known_but_unavailable_returns_bad_request() { let app = test_app_with_no_providers(); let req = Request::builder() @@ -68,12 +68,15 @@ async fn test_model_skip_when_no_providers() { let response = app.oneshot(req).await.unwrap(); let body = response_json( response, - StatusCode::OK, + StatusCode::BAD_REQUEST, "POST /api/v1/models/claude-opus-4-6/test", ) .await; - assert_eq!(body["model_id"], "claude-opus-4-6"); - assert_eq!(body["status"], "skip"); + assert!( + body["errors"][0]["detail"] + .as_str() + .is_some_and(|detail| detail.contains("no offering on an eligible provider")) + ); } #[tokio::test] @@ -114,26 +117,26 @@ async fn dry_run_serve_rejects_invalid_dot() { } #[tokio::test] -async fn completion_no_provider_non_streaming_returns_502() { +async fn completion_no_provider_non_streaming_returns_400() { let app = test_app_with_no_providers(); let response = app.oneshot(completion_request(false)).await.unwrap(); response_status( response, - StatusCode::BAD_GATEWAY, + StatusCode::BAD_REQUEST, "POST /api/v1/completions", ) .await; } #[tokio::test] -async fn completion_no_provider_streaming_returns_502() { +async fn completion_no_provider_streaming_returns_400() { let app = test_app_with_no_providers(); let response = app.oneshot(completion_request(true)).await.unwrap(); response_status( response, - StatusCode::BAD_GATEWAY, + StatusCode::BAD_REQUEST, "POST /api/v1/completions?stream=true", ) .await; diff --git a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs b/lib/apps/fabro-server/tests/it/scenario/lifecycle.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/scenario/lifecycle.rs rename to lib/apps/fabro-server/tests/it/scenario/lifecycle.rs diff --git a/lib/crates/fabro-server/tests/it/scenario/mod.rs b/lib/apps/fabro-server/tests/it/scenario/mod.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/scenario/mod.rs rename to lib/apps/fabro-server/tests/it/scenario/mod.rs diff --git a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs b/lib/apps/fabro-server/tests/it/scenario/run_completion.rs similarity index 98% rename from lib/crates/fabro-server/tests/it/scenario/run_completion.rs rename to lib/apps/fabro-server/tests/it/scenario/run_completion.rs index 4dfec9f30..a0e40e06c 100644 --- a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs +++ b/lib/apps/fabro-server/tests/it/scenario/run_completion.rs @@ -4,6 +4,7 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_auth::EnvCredentialSource; use fabro_model::{Catalog, ProviderId}; +use fabro_static::EnvVars; use fabro_test::{TwinScenario, TwinScenarios, twin_openai}; use fabro_types::RunId; use tokio::time::sleep; @@ -41,7 +42,7 @@ fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String) .expect("test catalog should build"), ); let source_api_key = api_key.clone(); - let env_api_key = api_key; + let env_api_key = api_key.clone(); let llm_source: Arc = Arc::new( EnvCredentialSource::with_env_lookup(Arc::new(move |name| match name { "OPENAI_API_KEY" => Some(source_api_key.clone()), @@ -52,6 +53,7 @@ fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String) .runtime_settings(settings.server_settings, settings.manifest_run_defaults) .max_concurrent_runs(5) .llm_catalog_settings(llm_catalog_settings) + .vault_entries([(EnvVars::OPENAI_API_KEY, api_key)]) .registry_factory(move |interviewer| { let catalog = Arc::clone(&catalog); let llm_source = Arc::clone(&llm_source); diff --git a/lib/crates/fabro-server/tests/it/scenario/sse.rs b/lib/apps/fabro-server/tests/it/scenario/sse.rs similarity index 85% rename from lib/crates/fabro-server/tests/it/scenario/sse.rs rename to lib/apps/fabro-server/tests/it/scenario/sse.rs index cef47a545..d95f1b174 100644 --- a/lib/crates/fabro-server/tests/it/scenario/sse.rs +++ b/lib/apps/fabro-server/tests/it/scenario/sse.rs @@ -60,16 +60,17 @@ async fn sse_stream_contains_expected_event_types() { wait_for_run_status_not_in(&app, &run_id, &["runnable", "starting"]).await; - // Get SSE stream + // Replay from the beginning so the assertion does not depend on whether + // the run advances before the attach request is handled. let req = Request::builder() .method("GET") - .uri(api(&format!("/runs/{run_id}/attach"))) + .uri(api(&format!("/runs/{run_id}/attach?since_seq=1"))) .body(Body::empty()) .unwrap(); let response = checked_response( app.clone().oneshot(req).await.unwrap(), StatusCode::OK, - format!("GET /api/v1/runs/{run_id}/attach"), + format!("GET /api/v1/runs/{run_id}/attach?since_seq=1"), ) .await; @@ -103,18 +104,12 @@ async fn sse_stream_contains_expected_event_types() { } } - // Because we subscribe while the run is only guaranteed to be past - // "runnable", a live stream should include at least one stage event. - // If the run completes before we attach with no unread events, an empty - // stream is still a valid 200 response. - if !event_types.is_empty() { - assert!( - event_types - .iter() - .any(|t| t == "stage.started" || t == "stage.completed"), - "should contain stage events, got: {event_types:?}" - ); - } + assert!( + event_types + .iter() + .any(|t| t == "stage.started" || t == "stage.completed"), + "should contain stage events, got: {event_types:?}" + ); // Pipeline is complete (SSE stream ended), verify checkpoint let cp_body = wait_for_checkpoint(&app, &run_id).await; diff --git a/lib/crates/fabro-server/tests/it/scenario/usage.rs b/lib/apps/fabro-server/tests/it/scenario/usage.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/scenario/usage.rs rename to lib/apps/fabro-server/tests/it/scenario/usage.rs diff --git a/lib/crates/fabro-spa/Cargo.toml b/lib/apps/fabro-spa/Cargo.toml similarity index 100% rename from lib/crates/fabro-spa/Cargo.toml rename to lib/apps/fabro-spa/Cargo.toml diff --git a/lib/crates/fabro-spa/assets/.gitkeep b/lib/apps/fabro-spa/assets/.gitkeep similarity index 100% rename from lib/crates/fabro-spa/assets/.gitkeep rename to lib/apps/fabro-spa/assets/.gitkeep diff --git a/lib/crates/fabro-spa/src/lib.rs b/lib/apps/fabro-spa/src/lib.rs similarity index 100% rename from lib/crates/fabro-spa/src/lib.rs rename to lib/apps/fabro-spa/src/lib.rs diff --git a/lib/crates/fabro-acp/Cargo.toml b/lib/components/fabro-acp/Cargo.toml similarity index 88% rename from lib/crates/fabro-acp/Cargo.toml rename to lib/components/fabro-acp/Cargo.toml index 2fb62b2d9..3e1962dbe 100644 --- a/lib/crates/fabro-acp/Cargo.toml +++ b/lib/components/fabro-acp/Cargo.toml @@ -28,8 +28,8 @@ workspace = true agent-client-protocol.workspace = true agent-client-protocol-tokio.workspace = true fabro-sandbox = { path = "../fabro-sandbox", optional = true } -fabro-types = { path = "../fabro-types", optional = true } -fabro-util = { path = "../fabro-util" } +fabro-types = { path = "../../foundation/fabro-types", optional = true } +fabro-util = { path = "../../foundation/fabro-util" } serde_json.workspace = true shlex = "1" thiserror.workspace = true diff --git a/lib/crates/fabro-acp/src/command.rs b/lib/components/fabro-acp/src/command.rs similarity index 100% rename from lib/crates/fabro-acp/src/command.rs rename to lib/components/fabro-acp/src/command.rs diff --git a/lib/crates/fabro-acp/src/error.rs b/lib/components/fabro-acp/src/error.rs similarity index 100% rename from lib/crates/fabro-acp/src/error.rs rename to lib/components/fabro-acp/src/error.rs diff --git a/lib/crates/fabro-acp/src/lib.rs b/lib/components/fabro-acp/src/lib.rs similarity index 100% rename from lib/crates/fabro-acp/src/lib.rs rename to lib/components/fabro-acp/src/lib.rs diff --git a/lib/crates/fabro-acp/src/session.rs b/lib/components/fabro-acp/src/session.rs similarity index 100% rename from lib/crates/fabro-acp/src/session.rs rename to lib/components/fabro-acp/src/session.rs diff --git a/lib/crates/fabro-acp/src/test_support.rs b/lib/components/fabro-acp/src/test_support.rs similarity index 100% rename from lib/crates/fabro-acp/src/test_support.rs rename to lib/components/fabro-acp/src/test_support.rs diff --git a/lib/crates/fabro-acp/src/transport.rs b/lib/components/fabro-acp/src/transport.rs similarity index 100% rename from lib/crates/fabro-acp/src/transport.rs rename to lib/components/fabro-acp/src/transport.rs diff --git a/lib/crates/fabro-acp/tests/session.rs b/lib/components/fabro-acp/tests/session.rs similarity index 100% rename from lib/crates/fabro-acp/tests/session.rs rename to lib/components/fabro-acp/tests/session.rs diff --git a/lib/crates/fabro-agent/Cargo.toml b/lib/components/fabro-agent/Cargo.toml similarity index 76% rename from lib/crates/fabro-agent/Cargo.toml rename to lib/components/fabro-agent/Cargo.toml index 89f836440..4982a8667 100644 --- a/lib/crates/fabro-agent/Cargo.toml +++ b/lib/components/fabro-agent/Cargo.toml @@ -24,16 +24,16 @@ workspace = true [dependencies] clap.workspace = true anyhow.workspace = true -fabro-auth = { path = "../fabro-auth" } -fabro-config = { path = "../fabro-config", features = ["clap"] } -fabro-types = { path = "../fabro-types", features = ["clap"] } +fabro-auth = { path = "../../foundation/fabro-auth" } +fabro-config = { path = "../../foundation/fabro-config", features = ["clap"] } +fabro-types = { path = "../../foundation/fabro-types", features = ["clap"] } fabro-llm = { path = "../fabro-llm" } -fabro-model = { path = "../fabro-model" } +fabro-model = { path = "../../foundation/fabro-model" } fabro-mcp = { path = "../fabro-mcp" } fabro-sandbox = { path = "../fabro-sandbox" } fabro-static.workspace = true -fabro-util = { path = "../fabro-util" } -fabro-vault = { path = "../fabro-vault" } +fabro-util = { path = "../../foundation/fabro-util" } +fabro-vault = { path = "../../foundation/fabro-vault" } fabro-http.workspace = true thiserror.workspace = true serde.workspace = true @@ -61,6 +61,6 @@ tokio = { workspace = true, features = ["test-util", "macros"] } tempfile = "3" paste = "1" fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] } -fabro-macros = { path = "../fabro-macros" } +fabro-macros = { path = "../../foundation/fabro-macros" } fabro-test = { workspace = true } tracing-subscriber.workspace = true diff --git a/lib/crates/fabro-agent/README.md b/lib/components/fabro-agent/README.md similarity index 98% rename from lib/crates/fabro-agent/README.md rename to lib/components/fabro-agent/README.md index 5cac1be82..69764459e 100644 --- a/lib/crates/fabro-agent/README.md +++ b/lib/components/fabro-agent/README.md @@ -98,8 +98,6 @@ pub trait Sandbox: Send + Sync { ```rust pub struct SessionConfig { - pub max_turns: usize, // 0 = unlimited - pub max_tool_rounds_per_input: usize, // default: 200 pub default_command_timeout_ms: u64, // default: 10s pub max_command_timeout_ms: u64, // default: 600s pub enable_loop_detection: bool, // default: true @@ -134,7 +132,6 @@ let env = Arc::new(LocalSandbox::new( // 4. Configure the session let config = SessionConfig { - max_tool_rounds_per_input: 50, enable_loop_detection: true, user_instructions: Some("Always write tests first".into()), ..SessionConfig::default() diff --git a/lib/crates/fabro-agent/src/agent_profile.rs b/lib/components/fabro-agent/src/agent_profile.rs similarity index 96% rename from lib/crates/fabro-agent/src/agent_profile.rs rename to lib/components/fabro-agent/src/agent_profile.rs index 632ef4655..607d14fa0 100644 --- a/lib/crates/fabro-agent/src/agent_profile.rs +++ b/lib/components/fabro-agent/src/agent_profile.rs @@ -36,13 +36,13 @@ pub trait AgentProfile: Send + Sync { } fn knowledge_cutoff(&self) -> Option { - self.catalog() - .and_then(|catalog| catalog.get(self.model())) + self.catalog_model() .and_then(|m| m.knowledge_cutoff().map(str::to_string)) } fn catalog_model(&self) -> Option<&Model> { - self.catalog().and_then(|catalog| catalog.get(self.model())) + let catalog = self.catalog()?; + catalog.get_on_provider(&self.provider_id(), self.model()) } fn context_window_size(&self) -> usize { diff --git a/lib/crates/fabro-agent/src/apply_patch.lark b/lib/components/fabro-agent/src/apply_patch.lark similarity index 100% rename from lib/crates/fabro-agent/src/apply_patch.lark rename to lib/components/fabro-agent/src/apply_patch.lark diff --git a/lib/crates/fabro-agent/src/apply_patch.rs b/lib/components/fabro-agent/src/apply_patch.rs similarity index 100% rename from lib/crates/fabro-agent/src/apply_patch.rs rename to lib/components/fabro-agent/src/apply_patch.rs diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/components/fabro-agent/src/cli.rs similarity index 96% rename from lib/crates/fabro-agent/src/cli.rs rename to lib/components/fabro-agent/src/cli.rs index b32ef10dd..f14041456 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/components/fabro-agent/src/cli.rs @@ -20,7 +20,7 @@ use fabro_llm::types::{Request, Response}; use fabro_mcp::config::McpServerSettings; #[cfg(test)] use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{AgentProfileKind, Catalog, ModelHandle, ProviderId}; +use fabro_model::{AgentProfileKind, Catalog, ModelHandle, ModelSelectionError, ProviderId}; use fabro_static::EnvVars; use fabro_util::terminal::Styles; use fabro_vault::SecretStore; @@ -255,7 +255,11 @@ fn parse_provider(args: &AgentArgs) -> anyhow::Result { Ok(provider_str.parse()?) } -fn resolve_provider_id(catalog: &Catalog, args: &AgentArgs) -> anyhow::Result { +fn resolve_provider_id( + catalog: &Catalog, + args: &AgentArgs, + eligible_providers: &std::collections::HashSet, +) -> anyhow::Result { if args.provider.is_some() { let requested = parse_provider(args)?; return Ok(catalog @@ -263,8 +267,10 @@ fn resolve_provider_id(catalog: &Catalog, args: &AgentArgs) -> anyhow::Result return Ok(model.provider.clone()), + Err(ModelSelectionError::UnknownSelector { .. }) => {} + Err(error) => return Err(error.into()), } } let requested = parse_provider(args)?; @@ -530,7 +536,7 @@ pub async fn run_with_args_and_client_and_catalog( // threads let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let provider_id = resolve_provider_id(&catalog, &args)?; + let provider_id = resolve_provider_id(&catalog, &args, &client.provider_ids())?; ensure_provider_registered(&client, &provider_id)?; if args.verbose { @@ -550,6 +556,7 @@ pub async fn run_with_args_and_client_and_catalog( "provider '{provider_id}' has no default model in the catalog; pass --model explicitly" ) })? + .to_string() }; let profile_kind = profile_kind_for_provider(&catalog, &provider_id, Some(&model))?; eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}"))); @@ -1066,12 +1073,13 @@ mod tests { max_output: None, }), features: Some(SettingsModelFeatures { - tools: Some(true), - vision: Some(false), - reasoning: Some(false), - reasoning_effort: None, - prompt_cache: None, - sampling_params: None, + tools: Some(true), + vision: Some(false), + reasoning: Some(false), + reasoning_effort: None, + prompt_cache: None, + cache_control_breakpoints: None, + sampling_params: None, }), ..ModelCatalogSettings::default() }); @@ -1089,7 +1097,7 @@ mod tests { }; assert_eq!( - resolve_provider_id(&catalog, &args).unwrap(), + resolve_provider_id(&catalog, &args, &catalog.all_provider_ids()).unwrap(), ProviderId::new("acme-aws") ); } @@ -1121,7 +1129,7 @@ mod tests { }; assert_eq!( - resolve_provider_id(&catalog, &args).unwrap(), + resolve_provider_id(&catalog, &args, &catalog.all_provider_ids()).unwrap(), ProviderId::new("acme-aws") ); } @@ -1151,12 +1159,13 @@ mod tests { max_output: None, }), features: Some(SettingsModelFeatures { - tools: Some(true), - vision: Some(false), - reasoning: Some(false), - reasoning_effort: None, - prompt_cache: None, - sampling_params: None, + tools: Some(true), + vision: Some(false), + reasoning: Some(false), + reasoning_effort: None, + prompt_cache: None, + cache_control_breakpoints: None, + sampling_params: None, }), ..ModelCatalogSettings::default() }); diff --git a/lib/crates/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs similarity index 100% rename from lib/crates/fabro-agent/src/compaction.rs rename to lib/components/fabro-agent/src/compaction.rs diff --git a/lib/crates/fabro-agent/src/config.rs b/lib/components/fabro-agent/src/config.rs similarity index 96% rename from lib/crates/fabro-agent/src/config.rs rename to lib/components/fabro-agent/src/config.rs index c54d49f01..bc22fd75c 100644 --- a/lib/crates/fabro-agent/src/config.rs +++ b/lib/components/fabro-agent/src/config.rs @@ -106,8 +106,6 @@ pub struct ToolSecrets { #[derive(Clone)] pub struct SessionOptions { - pub max_turns: usize, - pub max_tool_rounds_per_input: usize, pub default_command_timeout_ms: u64, pub max_command_timeout_ms: u64, pub reasoning_effort: Option, @@ -149,8 +147,6 @@ pub struct SessionOptions { impl std::fmt::Debug for SessionOptions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SessionOptions") - .field("max_turns", &self.max_turns) - .field("max_tool_rounds_per_input", &self.max_tool_rounds_per_input) .field( "default_command_timeout_ms", &self.default_command_timeout_ms, @@ -196,8 +192,6 @@ impl std::fmt::Debug for SessionOptions { impl Default for SessionOptions { fn default() -> Self { Self { - max_turns: 0, - max_tool_rounds_per_input: 0, default_command_timeout_ms: 10_000, max_command_timeout_ms: 600_000, max_tokens: None, @@ -280,8 +274,6 @@ mod tests { #[test] fn default_config_values() { let config = SessionOptions::default(); - assert_eq!(config.max_turns, 0); - assert_eq!(config.max_tool_rounds_per_input, 0); assert_eq!(config.default_command_timeout_ms, 10_000); assert_eq!(config.max_command_timeout_ms, 600_000); assert!(config.reasoning_effort.is_none()); @@ -328,13 +320,10 @@ mod tests { #[test] fn config_with_custom_values() { let config = SessionOptions { - max_turns: 50, reasoning_effort: Some(ReasoningEffort::High), ..Default::default() }; - assert_eq!(config.max_turns, 50); assert_eq!(config.reasoning_effort, Some(ReasoningEffort::High)); - assert_eq!(config.max_tool_rounds_per_input, 0); } #[test] diff --git a/lib/crates/fabro-agent/src/context_window.rs b/lib/components/fabro-agent/src/context_window.rs similarity index 100% rename from lib/crates/fabro-agent/src/context_window.rs rename to lib/components/fabro-agent/src/context_window.rs diff --git a/lib/crates/fabro-agent/src/docker_sandbox.rs b/lib/components/fabro-agent/src/docker_sandbox.rs similarity index 100% rename from lib/crates/fabro-agent/src/docker_sandbox.rs rename to lib/components/fabro-agent/src/docker_sandbox.rs diff --git a/lib/crates/fabro-agent/src/error.rs b/lib/components/fabro-agent/src/error.rs similarity index 100% rename from lib/crates/fabro-agent/src/error.rs rename to lib/components/fabro-agent/src/error.rs diff --git a/lib/crates/fabro-agent/src/event.rs b/lib/components/fabro-agent/src/event.rs similarity index 100% rename from lib/crates/fabro-agent/src/event.rs rename to lib/components/fabro-agent/src/event.rs diff --git a/lib/crates/fabro-agent/src/file_tracker.rs b/lib/components/fabro-agent/src/file_tracker.rs similarity index 100% rename from lib/crates/fabro-agent/src/file_tracker.rs rename to lib/components/fabro-agent/src/file_tracker.rs diff --git a/lib/crates/fabro-agent/src/history.rs b/lib/components/fabro-agent/src/history.rs similarity index 100% rename from lib/crates/fabro-agent/src/history.rs rename to lib/components/fabro-agent/src/history.rs diff --git a/lib/crates/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs similarity index 100% rename from lib/crates/fabro-agent/src/lib.rs rename to lib/components/fabro-agent/src/lib.rs diff --git a/lib/crates/fabro-agent/src/local_sandbox.rs b/lib/components/fabro-agent/src/local_sandbox.rs similarity index 100% rename from lib/crates/fabro-agent/src/local_sandbox.rs rename to lib/components/fabro-agent/src/local_sandbox.rs diff --git a/lib/crates/fabro-agent/src/loop_detection.rs b/lib/components/fabro-agent/src/loop_detection.rs similarity index 100% rename from lib/crates/fabro-agent/src/loop_detection.rs rename to lib/components/fabro-agent/src/loop_detection.rs diff --git a/lib/crates/fabro-agent/src/mcp_integration.rs b/lib/components/fabro-agent/src/mcp_integration.rs similarity index 100% rename from lib/crates/fabro-agent/src/mcp_integration.rs rename to lib/components/fabro-agent/src/mcp_integration.rs diff --git a/lib/crates/fabro-agent/src/memory.rs b/lib/components/fabro-agent/src/memory.rs similarity index 100% rename from lib/crates/fabro-agent/src/memory.rs rename to lib/components/fabro-agent/src/memory.rs diff --git a/lib/crates/fabro-agent/src/profiles/anthropic.rs b/lib/components/fabro-agent/src/profiles/anthropic.rs similarity index 100% rename from lib/crates/fabro-agent/src/profiles/anthropic.rs rename to lib/components/fabro-agent/src/profiles/anthropic.rs diff --git a/lib/crates/fabro-agent/src/profiles/gemini.rs b/lib/components/fabro-agent/src/profiles/gemini.rs similarity index 100% rename from lib/crates/fabro-agent/src/profiles/gemini.rs rename to lib/components/fabro-agent/src/profiles/gemini.rs diff --git a/lib/crates/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs similarity index 100% rename from lib/crates/fabro-agent/src/profiles/mod.rs rename to lib/components/fabro-agent/src/profiles/mod.rs diff --git a/lib/crates/fabro-agent/src/profiles/openai.rs b/lib/components/fabro-agent/src/profiles/openai.rs similarity index 96% rename from lib/crates/fabro-agent/src/profiles/openai.rs rename to lib/components/fabro-agent/src/profiles/openai.rs index ff0215d5c..4a624e3ef 100644 --- a/lib/crates/fabro-agent/src/profiles/openai.rs +++ b/lib/components/fabro-agent/src/profiles/openai.rs @@ -229,6 +229,11 @@ and focused on the task. {file_edit_failure_guidance} - Do not `git commit` your changes or create new git branches unless explicitly requested. +# Planning + +If you create a checklist or task list, you update item statuses incrementally as each item is \ +completed rather than marking every item done only at the end. + # Validating Your Work If the codebase has tests or the ability to build or run, consider using them to verify your \ @@ -338,6 +343,17 @@ mod tests { assert!(prompt.contains("existing code conventions")); } + #[test] + fn openai_system_prompt_matches_codex_incremental_plan_guidance() { + let profile = OpenAiProfile::new("gpt-5.5"); + let env = MockSandbox::linux(); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); + assert!(prompt.contains( + "update item statuses incrementally as each item is completed rather than \ + marking every item done only at the end" + )); + } + #[test] fn openai_system_prompt_includes_memory() { let profile = OpenAiProfile::new("o3-mini"); diff --git a/lib/crates/fabro-agent/src/question_tools.rs b/lib/components/fabro-agent/src/question_tools.rs similarity index 100% rename from lib/crates/fabro-agent/src/question_tools.rs rename to lib/components/fabro-agent/src/question_tools.rs diff --git a/lib/crates/fabro-agent/src/read_before_write_sandbox.rs b/lib/components/fabro-agent/src/read_before_write_sandbox.rs similarity index 100% rename from lib/crates/fabro-agent/src/read_before_write_sandbox.rs rename to lib/components/fabro-agent/src/read_before_write_sandbox.rs diff --git a/lib/crates/fabro-agent/src/sandbox.rs b/lib/components/fabro-agent/src/sandbox.rs similarity index 100% rename from lib/crates/fabro-agent/src/sandbox.rs rename to lib/components/fabro-agent/src/sandbox.rs diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/components/fabro-agent/src/session.rs similarity index 98% rename from lib/crates/fabro-agent/src/session.rs rename to lib/components/fabro-agent/src/session.rs index 6541ddc6c..759aa8884 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/components/fabro-agent/src/session.rs @@ -1195,10 +1195,6 @@ impl Session { self.config.speed = speed; } - pub const fn set_max_turns(&mut self, max_turns: usize) { - self.config.max_turns = max_turns; - } - #[must_use] pub const fn history(&self) -> &History { &self.history @@ -1210,7 +1206,14 @@ impl Session { } pub async fn process_input(&mut self, input: &str) -> Result<(), Error> { - self.process_input_with_runtime(input, AgentToolRuntime::default()) + self.process_input_with_output(input).await.map(drop) + } + + pub(crate) async fn process_input_with_output( + &mut self, + input: &str, + ) -> Result, Error> { + self.process_input_with_runtime_and_output(input, AgentToolRuntime::default()) .await } @@ -1237,6 +1240,16 @@ impl Session { input: &str, agent_tool_runtime: AgentToolRuntime, ) -> Result<(), Error> { + self.process_input_with_runtime_and_output(input, agent_tool_runtime) + .await + .map(drop) + } + + async fn process_input_with_runtime_and_output( + &mut self, + input: &str, + agent_tool_runtime: AgentToolRuntime, + ) -> Result, Error> { let mut timing = SessionInputTiming::default(); let mut usage = TokenCounts::default(); let mut cost = None; @@ -1322,7 +1335,7 @@ impl Session { timing: &mut SessionInputTiming, usage_accumulator: &mut TokenCounts, cost_accumulator: &mut Option, - ) -> Result<(), Error> { + ) -> Result, Error> { const STREAM_CONSUME_RETRIES: usize = 3; if self.state == SessionState::Closed { @@ -1360,8 +1373,6 @@ impl Session { text: expanded_input.clone(), }); - let mut round_count: usize = 0; - loop { // Top-of-loop: if the previous round's interrupt token fired, // swap in a fresh one before draining and rebuilding state. @@ -1394,26 +1405,6 @@ impl Session { self.wait_for_steer_if_needed().await?; self.drain_steering(); - // Check max_tool_rounds_per_input - if self.config.max_tool_rounds_per_input > 0 - && round_count >= self.config.max_tool_rounds_per_input - { - self.event_emitter - .emit(self.id.clone(), AgentEvent::TurnLimitReached { - max_turns: self.config.max_tool_rounds_per_input, - }); - break; - } - - // Check max_turns - if self.config.max_turns > 0 && self.history.turns().len() >= self.config.max_turns { - self.event_emitter - .emit(self.id.clone(), AgentEvent::TurnLimitReached { - max_turns: self.config.max_turns, - }); - break; - } - // Snapshot the per-round token; it stays stable for this iteration. let round_token = self .round_token @@ -1742,9 +1733,9 @@ impl Session { let model = ModelRef { provider: self.provider_profile.provider_id(), model_id: if response.model.is_empty() { - self.provider_profile.model().to_string() + self.provider_profile.model().into() } else { - response.model.clone() + response.model.clone().into() }, speed: self.config.speed, }; @@ -1773,11 +1764,9 @@ impl Session { if should_continue { continue; } - break; + return Ok((!text.trim().is_empty()).then_some(text)); } - round_count += 1; - // Build a composite cancellation token covering both terminal // cancel and round (steer) interrupt. Tools observe it // cooperatively — they synthesize "Cancelled" results rather @@ -1859,8 +1848,6 @@ impl Session { .emit(self.id.clone(), AgentEvent::LoopDetected); } } - - Ok(()) } async fn compact_if_needed(&mut self) { @@ -2259,8 +2246,9 @@ mod tests { #[tokio::test] async fn text_only_response_natural_completion() { let mut session = make_session(vec![text_response("Hello there!")]).await; - session.process_input("Hi").await.unwrap(); + let output = session.process_input_with_output("Hi").await.unwrap(); + assert_eq!(output.as_deref(), Some("Hello there!")); assert_eq!(session.state(), SessionState::Idle); let turns = session.history().turns(); // UserTurn + AssistantTurn = 2 @@ -2445,55 +2433,12 @@ mod tests { } #[tokio::test] - async fn max_tool_rounds_enforced() { - let mut registry = ToolRegistry::new(); - registry.register(make_echo_tool()); + async fn empty_natural_completion_has_no_output() { + let mut session = make_session(vec![text_response(" ")]).await; - // Respond with tool calls indefinitely - let responses = vec![ - tool_call_response("echo", "call_1", serde_json::json!({"text": "a"})), - tool_call_response("echo", "call_2", serde_json::json!({"text": "b"})), - tool_call_response("echo", "call_3", serde_json::json!({"text": "c"})), - ]; + let output = session.process_input_with_output("Hi").await.unwrap(); - let config = SessionOptions { - max_tool_rounds_per_input: 2, - enable_loop_detection: false, - ..Default::default() - }; - - let mut session = make_session_with_tools_and_config(responses, registry, config).await; - session.process_input("Keep using tools").await.unwrap(); - - // Should stop after 2 rounds: User + (Asst+ToolResult) * 2 = 5 turns - assert_eq!(session.state(), SessionState::Idle); - let turns = session.history().turns(); - assert_eq!(turns.len(), 5); - } - - #[tokio::test] - async fn max_turns_enforced() { - let responses = vec![ - text_response("first"), - text_response("second"), - text_response("should not reach"), - ]; - - let config = SessionOptions { - max_turns: 3, - ..Default::default() - }; - - let mut session = make_session_with_config(responses, config).await; - - // First input: adds User + Assistant = 2 turns - session.process_input("one").await.unwrap(); - assert_eq!(session.history().turns().len(), 2); - - // Second input: adds User (now 3 turns), then max_turns check triggers - session.process_input("two").await.unwrap(); - // Should have 3 turns total (User + Asst + User), max_turns hit before LLM call - assert_eq!(session.history().turns().len(), 3); + assert_eq!(output, None); } #[tokio::test] diff --git a/lib/crates/fabro-agent/src/skills.rs b/lib/components/fabro-agent/src/skills.rs similarity index 100% rename from lib/crates/fabro-agent/src/skills.rs rename to lib/components/fabro-agent/src/skills.rs diff --git a/lib/crates/fabro-agent/src/subagent.rs b/lib/components/fabro-agent/src/subagent.rs similarity index 95% rename from lib/crates/fabro-agent/src/subagent.rs rename to lib/components/fabro-agent/src/subagent.rs index 52cec77c6..aad7b975d 100644 --- a/lib/crates/fabro-agent/src/subagent.rs +++ b/lib/components/fabro-agent/src/subagent.rs @@ -10,7 +10,7 @@ use crate::error::Error; use crate::session::Session; use crate::tool_registry::{RegisteredTool, ToolSource}; use crate::tools::required_str; -use crate::types::{AgentEvent, Message, SessionEvent}; +use crate::types::{AgentEvent, SessionEvent}; pub type SessionFactory = Arc Session + Send + Sync>; @@ -112,15 +112,18 @@ impl SubAgentManager { let task_prompt_for_spawn = task_prompt.clone(); let task = tokio::spawn(async move { session.initialize().await?; - session.process_input(&task_prompt_for_spawn).await?; + let output = session + .process_input_with_output(&task_prompt_for_spawn) + .await? + .ok_or_else(|| { + Error::InvalidState( + "Subagent completed without a non-empty final response".to_string(), + ) + })?; let turns = session.history().turns(); - let last_text = turns.iter().rev().find_map(|t| match t { - Message::Assistant { content, .. } => Some(content.clone()), - _ => None, - }); Ok(SubAgentResult { - output: last_text.unwrap_or_default(), - success: true, + output, + success: true, turns_used: turns.len(), }) }); @@ -314,18 +317,6 @@ pub fn make_spawn_agent_tool( "task": { "type": "string", "description": "The task description for the subagent" - }, - "working_dir": { - "type": "string", - "description": "Working directory for the subagent" - }, - "model": { - "type": "string", - "description": "Model to use for the subagent" - }, - "max_turns": { - "type": "integer", - "description": "Maximum number of turns for the subagent" } }, "required": ["task"] @@ -337,13 +328,6 @@ pub fn make_spawn_agent_tool( Box::pin(async move { let task = required_str(&args, "task")?; - // Extract optional max_turns parameter - let max_turns = args - .get("max_turns") - .and_then(serde_json::Value::as_u64) - .map(|v| usize::try_from(v).unwrap_or(usize::MAX)); - - // Note: working_dir and model require session factory changes to wire through let mut session = session_factory(); // Inherit the parent agent's root session ID so todo tools // that scope by root (e.g. Anthropic tasks) share one list @@ -351,9 +335,6 @@ pub fn make_spawn_agent_tool( if let Some(root) = ctx.root_session_id.as_ref().or(ctx.session_id.as_ref()) { session.set_root_session_id(root.clone()); } - // Default subagent max_turns is 0 (unlimited) per spec (overridable via - // parameter) - session.set_max_turns(max_turns.unwrap_or(0)); let mut mgr = manager.lock().await; mgr.spawn(session, task.to_string(), current_depth) .map_err(|e| e.to_string()) @@ -619,7 +600,11 @@ mod tests { let spawn_tool = make_spawn_agent_tool(manager.clone(), factory, 0); assert_eq!(spawn_tool.definition.name, "spawn_agent"); - assert!(spawn_tool.definition.parameters["properties"]["task"].is_object()); + let spawn_properties = spawn_tool.definition.parameters["properties"] + .as_object() + .unwrap(); + assert_eq!(spawn_properties.len(), 1); + assert!(spawn_properties["task"].is_object()); let spawn_required = spawn_tool.definition.parameters["required"] .as_array() .unwrap(); @@ -831,6 +816,21 @@ mod tests { )); } + #[tokio::test] + async fn empty_final_response_is_not_reported_as_success() { + let mut manager = SubAgentManager::new(3); + let session = make_session(vec![text_response("")]).await; + let agent_id = manager.spawn(session, "Do something".into(), 0).unwrap(); + + let result = manager.wait(&agent_id).await; + + assert!( + matches!(result, Err(Error::InvalidState(message)) if message.contains( + "without a non-empty final response" + )) + ); + } + #[tokio::test] async fn send_input_to_completed_agent_errors() { let mut manager = SubAgentManager::new(3); diff --git a/lib/crates/fabro-agent/src/task_reminder.rs b/lib/components/fabro-agent/src/task_reminder.rs similarity index 100% rename from lib/crates/fabro-agent/src/task_reminder.rs rename to lib/components/fabro-agent/src/task_reminder.rs diff --git a/lib/crates/fabro-agent/src/test_support.rs b/lib/components/fabro-agent/src/test_support.rs similarity index 100% rename from lib/crates/fabro-agent/src/test_support.rs rename to lib/components/fabro-agent/src/test_support.rs diff --git a/lib/crates/fabro-agent/src/todo_runtime.rs b/lib/components/fabro-agent/src/todo_runtime.rs similarity index 100% rename from lib/crates/fabro-agent/src/todo_runtime.rs rename to lib/components/fabro-agent/src/todo_runtime.rs diff --git a/lib/crates/fabro-agent/src/todo_tools.rs b/lib/components/fabro-agent/src/todo_tools.rs similarity index 100% rename from lib/crates/fabro-agent/src/todo_tools.rs rename to lib/components/fabro-agent/src/todo_tools.rs diff --git a/lib/crates/fabro-agent/src/tool_execution.rs b/lib/components/fabro-agent/src/tool_execution.rs similarity index 100% rename from lib/crates/fabro-agent/src/tool_execution.rs rename to lib/components/fabro-agent/src/tool_execution.rs diff --git a/lib/crates/fabro-agent/src/tool_permissions.rs b/lib/components/fabro-agent/src/tool_permissions.rs similarity index 100% rename from lib/crates/fabro-agent/src/tool_permissions.rs rename to lib/components/fabro-agent/src/tool_permissions.rs diff --git a/lib/crates/fabro-agent/src/tool_registry.rs b/lib/components/fabro-agent/src/tool_registry.rs similarity index 100% rename from lib/crates/fabro-agent/src/tool_registry.rs rename to lib/components/fabro-agent/src/tool_registry.rs diff --git a/lib/crates/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs similarity index 100% rename from lib/crates/fabro-agent/src/tools.rs rename to lib/components/fabro-agent/src/tools.rs diff --git a/lib/crates/fabro-agent/src/truncation.rs b/lib/components/fabro-agent/src/truncation.rs similarity index 100% rename from lib/crates/fabro-agent/src/truncation.rs rename to lib/components/fabro-agent/src/truncation.rs diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/components/fabro-agent/src/types.rs similarity index 99% rename from lib/crates/fabro-agent/src/types.rs rename to lib/components/fabro-agent/src/types.rs index 94cb6808e..d77753519 100644 --- a/lib/crates/fabro-agent/src/types.rs +++ b/lib/components/fabro-agent/src/types.rs @@ -284,9 +284,6 @@ pub enum AgentEvent { details: serde_json::Value, }, LoopDetected, - TurnLimitReached { - max_turns: usize, - }, SteeringInjected { text: String, /// Principal that authored the steer. Lifted to top-level @@ -464,9 +461,6 @@ impl AgentEvent { Self::LoopDetected => { warn!(session_id, "Loop detected"); } - Self::TurnLimitReached { max_turns } => { - warn!(session_id, max_turns, "Message limit reached"); - } Self::SteeringInjected { text, .. } => { debug!(session_id, text_len = text.len(), "Steering injected"); } diff --git a/lib/crates/fabro-agent/tests/it/compaction.rs b/lib/components/fabro-agent/tests/it/compaction.rs similarity index 99% rename from lib/crates/fabro-agent/tests/it/compaction.rs rename to lib/components/fabro-agent/tests/it/compaction.rs index 084deae6c..14cb88d5e 100644 --- a/lib/crates/fabro-agent/tests/it/compaction.rs +++ b/lib/components/fabro-agent/tests/it/compaction.rs @@ -53,7 +53,6 @@ fn make_openai_session(cwd: &Path, base_url: String, api_key: String) -> Session let profile: Arc = Arc::new(OpenAiProfile::new(MODEL)); let sandbox = Arc::new(LocalSandbox::new(cwd.to_path_buf())); let options = SessionOptions { - max_turns: 20, enable_context_compaction: true, compaction_threshold_percent: 80, compaction_preserve_turns: 6, diff --git a/lib/crates/fabro-agent/tests/it/guardrails.rs b/lib/components/fabro-agent/tests/it/guardrails.rs similarity index 82% rename from lib/crates/fabro-agent/tests/it/guardrails.rs rename to lib/components/fabro-agent/tests/it/guardrails.rs index 8e1f898fe..cae5461e3 100644 --- a/lib/crates/fabro-agent/tests/it/guardrails.rs +++ b/lib/components/fabro-agent/tests/it/guardrails.rs @@ -17,18 +17,18 @@ fn profile_context_window_matches_catalog_for_default_models() { let profile: Box = match provider.agent_profile { fabro_model::AgentProfileKind::OpenAi if provider.id == ProviderId::openai() => { - Box::new(OpenAiProfile::new(model).with_catalog(Arc::clone(&catalog))) + Box::new(OpenAiProfile::new(model.as_str()).with_catalog(Arc::clone(&catalog))) } fabro_model::AgentProfileKind::OpenAi => Box::new( - OpenAiProfile::new(model) + OpenAiProfile::new(model.as_str()) .with_provider_id(provider.id.clone()) .with_catalog(Arc::clone(&catalog)), ), fabro_model::AgentProfileKind::Gemini => { - Box::new(GeminiProfile::new(model).with_catalog(Arc::clone(&catalog))) + Box::new(GeminiProfile::new(model.as_str()).with_catalog(Arc::clone(&catalog))) } fabro_model::AgentProfileKind::Anthropic => { - Box::new(AnthropicProfile::new(model).with_catalog(Arc::clone(&catalog))) + Box::new(AnthropicProfile::new(model.as_str()).with_catalog(Arc::clone(&catalog))) } }; diff --git a/lib/crates/fabro-agent/tests/it/main.rs b/lib/components/fabro-agent/tests/it/main.rs similarity index 100% rename from lib/crates/fabro-agent/tests/it/main.rs rename to lib/components/fabro-agent/tests/it/main.rs diff --git a/lib/crates/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs similarity index 99% rename from lib/crates/fabro-agent/tests/it/parity_matrix.rs rename to lib/components/fabro-agent/tests/it/parity_matrix.rs index 6b60d1cf7..1bd80ed78 100644 --- a/lib/crates/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/components/fabro-agent/tests/it/parity_matrix.rs @@ -126,11 +126,7 @@ async fn make_session( profile.register_subagent_tools(manager, factory, 0); let profile: Arc = Arc::from(profile); - let config = SessionOptions { - max_turns: 20, - ..SessionOptions::default() - }; - Session::new(client, profile, env, config, None) + Session::new(client, profile, env, SessionOptions::default(), None) } async fn make_session_with_config( @@ -315,20 +311,17 @@ async fn openai_compatible_twin_uses_json_edit_file_tool() { "old_string": "old", "new_string": "new" }), - )), + )) + .text("Done."), ) .load(twin_openai().await) .await; - let config = SessionOptions { - max_turns: 2, - ..SessionOptions::default() - }; let mut session = make_openai_compatible_twin_session( ProviderId::new("litellm"), "gpt-5.4-mini", tmp.path(), - config, + SessionOptions::default(), &twin, ); session.initialize().await.unwrap(); @@ -767,7 +760,6 @@ macro_rules! reasoning_effort_tests { async fn $test_name() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let config = SessionOptions { - max_turns: 20, reasoning_effort: Some(fabro_llm::types::ReasoningEffort::Low), ..SessionOptions::default() }; @@ -846,7 +838,6 @@ macro_rules! loop_detection_tests { async fn $test_name() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let config = SessionOptions { - max_turns: 20, loop_detection_window: 3, ..SessionOptions::default() }; diff --git a/lib/crates/fabro-automation/Cargo.toml b/lib/components/fabro-automation/Cargo.toml similarity index 92% rename from lib/crates/fabro-automation/Cargo.toml rename to lib/components/fabro-automation/Cargo.toml index 6284862e7..9b1b75575 100644 --- a/lib/crates/fabro-automation/Cargo.toml +++ b/lib/components/fabro-automation/Cargo.toml @@ -15,7 +15,7 @@ workspace = true [dependencies] chrono.workspace = true croner.workspace = true -fabro-db = { path = "../fabro-db" } +fabro-db = { path = "../../foundation/fabro-db" } hex.workspace = true serde.workspace = true sha2.workspace = true diff --git a/lib/crates/fabro-automation/migrations/2026071101_file_definitions_to_sqlite.rs b/lib/components/fabro-automation/migrations/2026071101_file_definitions_to_sqlite.rs similarity index 100% rename from lib/crates/fabro-automation/migrations/2026071101_file_definitions_to_sqlite.rs rename to lib/components/fabro-automation/migrations/2026071101_file_definitions_to_sqlite.rs diff --git a/lib/crates/fabro-automation/src/error.rs b/lib/components/fabro-automation/src/error.rs similarity index 100% rename from lib/crates/fabro-automation/src/error.rs rename to lib/components/fabro-automation/src/error.rs diff --git a/lib/crates/fabro-automation/src/id.rs b/lib/components/fabro-automation/src/id.rs similarity index 100% rename from lib/crates/fabro-automation/src/id.rs rename to lib/components/fabro-automation/src/id.rs diff --git a/lib/crates/fabro-automation/src/lib.rs b/lib/components/fabro-automation/src/lib.rs similarity index 100% rename from lib/crates/fabro-automation/src/lib.rs rename to lib/components/fabro-automation/src/lib.rs diff --git a/lib/crates/fabro-automation/src/migrations.rs b/lib/components/fabro-automation/src/migrations.rs similarity index 100% rename from lib/crates/fabro-automation/src/migrations.rs rename to lib/components/fabro-automation/src/migrations.rs diff --git a/lib/crates/fabro-automation/src/model.rs b/lib/components/fabro-automation/src/model.rs similarity index 100% rename from lib/crates/fabro-automation/src/model.rs rename to lib/components/fabro-automation/src/model.rs diff --git a/lib/crates/fabro-automation/src/store.rs b/lib/components/fabro-automation/src/store.rs similarity index 100% rename from lib/crates/fabro-automation/src/store.rs rename to lib/components/fabro-automation/src/store.rs diff --git a/lib/crates/fabro-automation/tests/store.rs b/lib/components/fabro-automation/tests/store.rs similarity index 100% rename from lib/crates/fabro-automation/tests/store.rs rename to lib/components/fabro-automation/tests/store.rs diff --git a/lib/crates/fabro-checkpoint/Cargo.toml b/lib/components/fabro-checkpoint/Cargo.toml similarity index 82% rename from lib/crates/fabro-checkpoint/Cargo.toml rename to lib/components/fabro-checkpoint/Cargo.toml index 3734d01fb..4578f5345 100644 --- a/lib/crates/fabro-checkpoint/Cargo.toml +++ b/lib/components/fabro-checkpoint/Cargo.toml @@ -14,9 +14,9 @@ doctest = false workspace = true [dependencies] -fabro-config = { path = "../fabro-config" } +fabro-config = { path = "../../foundation/fabro-config" } fabro-store = { path = "../fabro-store" } -fabro-types = { path = "../fabro-types" } +fabro-types = { path = "../../foundation/fabro-types" } git2.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/lib/crates/fabro-checkpoint/src/author.rs b/lib/components/fabro-checkpoint/src/author.rs similarity index 100% rename from lib/crates/fabro-checkpoint/src/author.rs rename to lib/components/fabro-checkpoint/src/author.rs diff --git a/lib/crates/fabro-checkpoint/src/branch.rs b/lib/components/fabro-checkpoint/src/branch.rs similarity index 100% rename from lib/crates/fabro-checkpoint/src/branch.rs rename to lib/components/fabro-checkpoint/src/branch.rs diff --git a/lib/crates/fabro-checkpoint/src/error.rs b/lib/components/fabro-checkpoint/src/error.rs similarity index 100% rename from lib/crates/fabro-checkpoint/src/error.rs rename to lib/components/fabro-checkpoint/src/error.rs diff --git a/lib/crates/fabro-checkpoint/src/git.rs b/lib/components/fabro-checkpoint/src/git.rs similarity index 100% rename from lib/crates/fabro-checkpoint/src/git.rs rename to lib/components/fabro-checkpoint/src/git.rs diff --git a/lib/crates/fabro-checkpoint/src/lib.rs b/lib/components/fabro-checkpoint/src/lib.rs similarity index 100% rename from lib/crates/fabro-checkpoint/src/lib.rs rename to lib/components/fabro-checkpoint/src/lib.rs diff --git a/lib/crates/fabro-checkpoint/src/trailer.rs b/lib/components/fabro-checkpoint/src/trailer.rs similarity index 100% rename from lib/crates/fabro-checkpoint/src/trailer.rs rename to lib/components/fabro-checkpoint/src/trailer.rs diff --git a/lib/crates/fabro-dump/Cargo.toml b/lib/components/fabro-dump/Cargo.toml similarity index 75% rename from lib/crates/fabro-dump/Cargo.toml rename to lib/components/fabro-dump/Cargo.toml index d45a6beb9..a630d7cd9 100644 --- a/lib/crates/fabro-dump/Cargo.toml +++ b/lib/components/fabro-dump/Cargo.toml @@ -15,11 +15,11 @@ workspace = true anyhow.workspace = true bytes.workspace = true fabro-store = { path = "../fabro-store" } -fabro-types = { path = "../fabro-types" } +fabro-types = { path = "../../foundation/fabro-types" } futures.workspace = true serde.workspace = true serde_json.workspace = true [dev-dependencies] chrono = { workspace = true, features = ["serde"] } -fabro-types = { path = "../fabro-types", features = ["test-support"] } +fabro-types = { path = "../../foundation/fabro-types", features = ["test-support"] } diff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/components/fabro-dump/src/lib.rs similarity index 100% rename from lib/crates/fabro-dump/src/lib.rs rename to lib/components/fabro-dump/src/lib.rs diff --git a/lib/crates/fabro-environment/Cargo.toml b/lib/components/fabro-environment/Cargo.toml similarity index 80% rename from lib/crates/fabro-environment/Cargo.toml rename to lib/components/fabro-environment/Cargo.toml index ab06a442f..0c153ab04 100644 --- a/lib/crates/fabro-environment/Cargo.toml +++ b/lib/components/fabro-environment/Cargo.toml @@ -14,9 +14,9 @@ workspace = true [dependencies] chrono.workspace = true -fabro-db = { path = "../fabro-db" } -fabro-config = { path = "../fabro-config" } -fabro-types = { path = "../fabro-types" } +fabro-db = { path = "../../foundation/fabro-db" } +fabro-config = { path = "../../foundation/fabro-config" } +fabro-types = { path = "../../foundation/fabro-types" } hex.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/lib/crates/fabro-environment/src/error.rs b/lib/components/fabro-environment/src/error.rs similarity index 100% rename from lib/crates/fabro-environment/src/error.rs rename to lib/components/fabro-environment/src/error.rs diff --git a/lib/crates/fabro-environment/src/id.rs b/lib/components/fabro-environment/src/id.rs similarity index 100% rename from lib/crates/fabro-environment/src/id.rs rename to lib/components/fabro-environment/src/id.rs diff --git a/lib/crates/fabro-environment/src/lib.rs b/lib/components/fabro-environment/src/lib.rs similarity index 100% rename from lib/crates/fabro-environment/src/lib.rs rename to lib/components/fabro-environment/src/lib.rs diff --git a/lib/crates/fabro-environment/src/model.rs b/lib/components/fabro-environment/src/model.rs similarity index 100% rename from lib/crates/fabro-environment/src/model.rs rename to lib/components/fabro-environment/src/model.rs diff --git a/lib/crates/fabro-environment/src/store.rs b/lib/components/fabro-environment/src/store.rs similarity index 100% rename from lib/crates/fabro-environment/src/store.rs rename to lib/components/fabro-environment/src/store.rs diff --git a/lib/crates/fabro-environment/tests/store.rs b/lib/components/fabro-environment/tests/store.rs similarity index 100% rename from lib/crates/fabro-environment/tests/store.rs rename to lib/components/fabro-environment/tests/store.rs diff --git a/lib/crates/fabro-github/Cargo.toml b/lib/components/fabro-github/Cargo.toml similarity index 86% rename from lib/crates/fabro-github/Cargo.toml rename to lib/components/fabro-github/Cargo.toml index b953ee36c..9db73130e 100644 --- a/lib/crates/fabro-github/Cargo.toml +++ b/lib/components/fabro-github/Cargo.toml @@ -19,7 +19,7 @@ serde_json.workspace = true fabro-http.workspace = true fabro-redact.workspace = true fabro-static.workspace = true -fabro-types = { path = "../fabro-types" } +fabro-types = { path = "../../foundation/fabro-types" } jsonwebtoken.workspace = true chrono.workspace = true tracing.workspace = true @@ -28,7 +28,7 @@ base64.workspace = true thiserror.workspace = true [dev-dependencies] -fabro-macros = { path = "../fabro-macros" } +fabro-macros = { path = "../../foundation/fabro-macros" } fabro-test = { workspace = true } tokio = { workspace = true, features = ["test-util", "macros"] } tracing-subscriber.workspace = true diff --git a/lib/crates/fabro-github/src/lib.rs b/lib/components/fabro-github/src/lib.rs similarity index 100% rename from lib/crates/fabro-github/src/lib.rs rename to lib/components/fabro-github/src/lib.rs diff --git a/lib/crates/fabro-github/src/testdata/rsa_private.pem b/lib/components/fabro-github/src/testdata/rsa_private.pem similarity index 100% rename from lib/crates/fabro-github/src/testdata/rsa_private.pem rename to lib/components/fabro-github/src/testdata/rsa_private.pem diff --git a/lib/crates/fabro-github/tests/integration.rs b/lib/components/fabro-github/tests/integration.rs similarity index 100% rename from lib/crates/fabro-github/tests/integration.rs rename to lib/components/fabro-github/tests/integration.rs diff --git a/lib/crates/fabro-graphviz/Cargo.toml b/lib/components/fabro-graphviz/Cargo.toml similarity index 79% rename from lib/crates/fabro-graphviz/Cargo.toml rename to lib/components/fabro-graphviz/Cargo.toml index 98e017097..f78b3b9c2 100644 --- a/lib/crates/fabro-graphviz/Cargo.toml +++ b/lib/components/fabro-graphviz/Cargo.toml @@ -15,9 +15,12 @@ workspace = true [dependencies] anyhow.workspace = true graphviz-sys.workspace = true -fabro-types = { path = "../fabro-types" } +fabro-types = { path = "../../foundation/fabro-types" } nom = "7" regex = { workspace = true } serde = { workspace = true } strum.workspace = true thiserror = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/lib/crates/fabro-graphviz/src/condition.rs b/lib/components/fabro-graphviz/src/condition.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/condition.rs rename to lib/components/fabro-graphviz/src/condition.rs diff --git a/lib/crates/fabro-graphviz/src/error.rs b/lib/components/fabro-graphviz/src/error.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/error.rs rename to lib/components/fabro-graphviz/src/error.rs diff --git a/lib/crates/fabro-graphviz/src/fidelity.rs b/lib/components/fabro-graphviz/src/fidelity.rs similarity index 73% rename from lib/crates/fabro-graphviz/src/fidelity.rs rename to lib/components/fabro-graphviz/src/fidelity.rs index 8b33c98e8..98605c135 100644 --- a/lib/crates/fabro-graphviz/src/fidelity.rs +++ b/lib/components/fabro-graphviz/src/fidelity.rs @@ -1,9 +1,24 @@ +use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, VariantArray}; /// Fidelity mode controlling how much prior context is provided to LLM /// sessions. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Display, EnumString, VariantArray)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + Hash, + Display, + EnumString, + VariantArray, + Serialize, + Deserialize, +)] #[strum(serialize_all = "lowercase")] +#[serde(rename_all = "lowercase")] pub enum Fidelity { /// Complete context, no summarization — sessions share a thread. Full, @@ -14,12 +29,15 @@ pub enum Fidelity { Compact, /// Brief textual summary (~600 token target). #[strum(serialize = "summary:low")] + #[serde(rename = "summary:low")] SummaryLow, /// Moderate textual summary (~1500 token target). #[strum(serialize = "summary:medium")] + #[serde(rename = "summary:medium")] SummaryMedium, /// Detailed per-stage Markdown report. #[strum(serialize = "summary:high")] + #[serde(rename = "summary:high")] SummaryHigh, } @@ -73,4 +91,14 @@ mod tests { fn fidelity_unknown_mode_errors() { assert!("bogus".parse::().is_err()); } + + #[test] + fn fidelity_serde_matches_strum_display() { + for mode in Fidelity::variants() { + let json = serde_json::to_value(mode).unwrap(); + assert_eq!(json, serde_json::Value::String(mode.to_string())); + let parsed: Fidelity = serde_json::from_value(json).unwrap(); + assert_eq!(parsed, *mode); + } + } } diff --git a/lib/crates/fabro-graphviz/src/graph/mod.rs b/lib/components/fabro-graphviz/src/graph/mod.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/graph/mod.rs rename to lib/components/fabro-graphviz/src/graph/mod.rs diff --git a/lib/crates/fabro-graphviz/src/graph/types.rs b/lib/components/fabro-graphviz/src/graph/types.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/graph/types.rs rename to lib/components/fabro-graphviz/src/graph/types.rs diff --git a/lib/crates/fabro-graphviz/src/lib.rs b/lib/components/fabro-graphviz/src/lib.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/lib.rs rename to lib/components/fabro-graphviz/src/lib.rs diff --git a/lib/crates/fabro-graphviz/src/parser/ast.rs b/lib/components/fabro-graphviz/src/parser/ast.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/parser/ast.rs rename to lib/components/fabro-graphviz/src/parser/ast.rs diff --git a/lib/crates/fabro-graphviz/src/parser/grammar.rs b/lib/components/fabro-graphviz/src/parser/grammar.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/parser/grammar.rs rename to lib/components/fabro-graphviz/src/parser/grammar.rs diff --git a/lib/crates/fabro-graphviz/src/parser/lexer.rs b/lib/components/fabro-graphviz/src/parser/lexer.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/parser/lexer.rs rename to lib/components/fabro-graphviz/src/parser/lexer.rs diff --git a/lib/crates/fabro-graphviz/src/parser/mod.rs b/lib/components/fabro-graphviz/src/parser/mod.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/parser/mod.rs rename to lib/components/fabro-graphviz/src/parser/mod.rs diff --git a/lib/crates/fabro-graphviz/src/parser/semantic.rs b/lib/components/fabro-graphviz/src/parser/semantic.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/parser/semantic.rs rename to lib/components/fabro-graphviz/src/parser/semantic.rs diff --git a/lib/crates/fabro-graphviz/src/render.rs b/lib/components/fabro-graphviz/src/render.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/render.rs rename to lib/components/fabro-graphviz/src/render.rs diff --git a/lib/crates/fabro-graphviz/src/stylesheet.rs b/lib/components/fabro-graphviz/src/stylesheet.rs similarity index 100% rename from lib/crates/fabro-graphviz/src/stylesheet.rs rename to lib/components/fabro-graphviz/src/stylesheet.rs diff --git a/lib/crates/fabro-hooks/Cargo.toml b/lib/components/fabro-hooks/Cargo.toml similarity index 75% rename from lib/crates/fabro-hooks/Cargo.toml rename to lib/components/fabro-hooks/Cargo.toml index 559a7499a..02f25fdad 100644 --- a/lib/crates/fabro-hooks/Cargo.toml +++ b/lib/components/fabro-hooks/Cargo.toml @@ -14,12 +14,12 @@ workspace = true [dependencies] fabro-agent = { path = "../fabro-agent" } -fabro-auth = { path = "../fabro-auth" } +fabro-auth = { path = "../../foundation/fabro-auth" } fabro-llm = { path = "../fabro-llm" } -fabro-model = { path = "../fabro-model" } +fabro-model = { path = "../../foundation/fabro-model" } fabro-redact.workspace = true -fabro-types = { path = "../fabro-types" } -fabro-util = { path = "../fabro-util" } +fabro-types = { path = "../../foundation/fabro-types" } +fabro-util = { path = "../../foundation/fabro-util" } fabro-http.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/lib/crates/fabro-hooks/src/bridge.rs b/lib/components/fabro-hooks/src/bridge.rs similarity index 100% rename from lib/crates/fabro-hooks/src/bridge.rs rename to lib/components/fabro-hooks/src/bridge.rs diff --git a/lib/crates/fabro-hooks/src/config.rs b/lib/components/fabro-hooks/src/config.rs similarity index 100% rename from lib/crates/fabro-hooks/src/config.rs rename to lib/components/fabro-hooks/src/config.rs diff --git a/lib/crates/fabro-hooks/src/executor.rs b/lib/components/fabro-hooks/src/executor.rs similarity index 99% rename from lib/crates/fabro-hooks/src/executor.rs rename to lib/components/fabro-hooks/src/executor.rs index a409a49b7..052776af1 100644 --- a/lib/crates/fabro-hooks/src/executor.rs +++ b/lib/components/fabro-hooks/src/executor.rs @@ -323,11 +323,10 @@ impl HookExecutorImpl { } } - /// Resolve a model alias (e.g. "haiku") to a concrete model ID. - fn resolve_model(model: Option<&str>, catalog: &Catalog) -> String { - let model_id = model.unwrap_or("haiku"); - let model_info = catalog.get(model_id); - model_info.map_or(model_id, |m| m.id.as_str()).to_string() + /// Keep the requested selector intact so the ready-provider-aware LLM + /// client can resolve aliases at dispatch time. + fn resolve_model(model: Option<&str>) -> String { + model.unwrap_or("haiku").to_string() } /// Build the user message for prompt/agent hooks. @@ -377,7 +376,7 @@ impl HookExecutorImpl { } }; - let resolved_model = Self::resolve_model(model.as_deref(), catalog.as_ref()); + let resolved_model = Self::resolve_model(model.as_deref()); let user_msg = Self::build_hook_user_message(&prompt, context); Self::execute_llm_with_timeout(definition.timeout(), "prompt", || async move { @@ -446,7 +445,7 @@ impl HookExecutorImpl { } }; - let resolved_model = Self::resolve_model(model.as_deref(), catalog.as_ref()); + let resolved_model = Self::resolve_model(model.as_deref()); let user_msg = Self::build_hook_user_message(&prompt, context); Self::execute_llm_with_timeout(definition.timeout(), "agent", || async move { diff --git a/lib/crates/fabro-hooks/src/lib.rs b/lib/components/fabro-hooks/src/lib.rs similarity index 100% rename from lib/crates/fabro-hooks/src/lib.rs rename to lib/components/fabro-hooks/src/lib.rs diff --git a/lib/crates/fabro-hooks/src/runner.rs b/lib/components/fabro-hooks/src/runner.rs similarity index 100% rename from lib/crates/fabro-hooks/src/runner.rs rename to lib/components/fabro-hooks/src/runner.rs diff --git a/lib/crates/fabro-hooks/src/types.rs b/lib/components/fabro-hooks/src/types.rs similarity index 100% rename from lib/crates/fabro-hooks/src/types.rs rename to lib/components/fabro-hooks/src/types.rs diff --git a/lib/crates/fabro-hooks/tests/host_command_hooks.rs b/lib/components/fabro-hooks/tests/host_command_hooks.rs similarity index 100% rename from lib/crates/fabro-hooks/tests/host_command_hooks.rs rename to lib/components/fabro-hooks/tests/host_command_hooks.rs diff --git a/lib/crates/fabro-install/Cargo.toml b/lib/components/fabro-install/Cargo.toml similarity index 61% rename from lib/crates/fabro-install/Cargo.toml rename to lib/components/fabro-install/Cargo.toml index ccdd8b38c..d5e9ec416 100644 --- a/lib/crates/fabro-install/Cargo.toml +++ b/lib/components/fabro-install/Cargo.toml @@ -15,13 +15,13 @@ base64.workspace = true ring = "0.17" toml.workspace = true tokio.workspace = true -fabro-config = { path = "../fabro-config" } -fabro-db = { path = "../fabro-db" } +fabro-config = { path = "../../foundation/fabro-config" } +fabro-db = { path = "../../foundation/fabro-db" } fabro-environment.workspace = true fabro-static.workspace = true -fabro-types = { path = "../fabro-types" } -fabro-util = { path = "../fabro-util" } -fabro-vault = { path = "../fabro-vault" } +fabro-types = { path = "../../foundation/fabro-types" } +fabro-util = { path = "../../foundation/fabro-util" } +fabro-vault = { path = "../../foundation/fabro-vault" } [dev-dependencies] tempfile = "3" diff --git a/lib/crates/fabro-install/src/lib.rs b/lib/components/fabro-install/src/lib.rs similarity index 100% rename from lib/crates/fabro-install/src/lib.rs rename to lib/components/fabro-install/src/lib.rs diff --git a/lib/crates/fabro-interview/Cargo.toml b/lib/components/fabro-interview/Cargo.toml similarity index 82% rename from lib/crates/fabro-interview/Cargo.toml rename to lib/components/fabro-interview/Cargo.toml index ed18ab877..4c450f54a 100644 --- a/lib/crates/fabro-interview/Cargo.toml +++ b/lib/components/fabro-interview/Cargo.toml @@ -19,8 +19,8 @@ async-trait.workspace = true tokio.workspace = true tracing.workspace = true dialoguer.workspace = true -fabro-util = { path = "../fabro-util" } -fabro-types = { path = "../fabro-types" } +fabro-util = { path = "../../foundation/fabro-util" } +fabro-types = { path = "../../foundation/fabro-types" } [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/crates/fabro-interview/src/auto_approve.rs b/lib/components/fabro-interview/src/auto_approve.rs similarity index 100% rename from lib/crates/fabro-interview/src/auto_approve.rs rename to lib/components/fabro-interview/src/auto_approve.rs diff --git a/lib/crates/fabro-interview/src/callback.rs b/lib/components/fabro-interview/src/callback.rs similarity index 100% rename from lib/crates/fabro-interview/src/callback.rs rename to lib/components/fabro-interview/src/callback.rs diff --git a/lib/crates/fabro-interview/src/console.rs b/lib/components/fabro-interview/src/console.rs similarity index 100% rename from lib/crates/fabro-interview/src/console.rs rename to lib/components/fabro-interview/src/console.rs diff --git a/lib/crates/fabro-interview/src/control.rs b/lib/components/fabro-interview/src/control.rs similarity index 100% rename from lib/crates/fabro-interview/src/control.rs rename to lib/components/fabro-interview/src/control.rs diff --git a/lib/crates/fabro-interview/src/control_protocol.rs b/lib/components/fabro-interview/src/control_protocol.rs similarity index 100% rename from lib/crates/fabro-interview/src/control_protocol.rs rename to lib/components/fabro-interview/src/control_protocol.rs diff --git a/lib/crates/fabro-interview/src/lib.rs b/lib/components/fabro-interview/src/lib.rs similarity index 100% rename from lib/crates/fabro-interview/src/lib.rs rename to lib/components/fabro-interview/src/lib.rs diff --git a/lib/crates/fabro-interview/src/queue.rs b/lib/components/fabro-interview/src/queue.rs similarity index 100% rename from lib/crates/fabro-interview/src/queue.rs rename to lib/components/fabro-interview/src/queue.rs diff --git a/lib/crates/fabro-interview/src/recording.rs b/lib/components/fabro-interview/src/recording.rs similarity index 100% rename from lib/crates/fabro-interview/src/recording.rs rename to lib/components/fabro-interview/src/recording.rs diff --git a/lib/crates/fabro-interview/src/replay.rs b/lib/components/fabro-interview/src/replay.rs similarity index 100% rename from lib/crates/fabro-interview/src/replay.rs rename to lib/components/fabro-interview/src/replay.rs diff --git a/lib/crates/fabro-llm/Cargo.toml b/lib/components/fabro-llm/Cargo.toml similarity index 82% rename from lib/crates/fabro-llm/Cargo.toml rename to lib/components/fabro-llm/Cargo.toml index 9146976b6..b10321253 100644 --- a/lib/crates/fabro-llm/Cargo.toml +++ b/lib/components/fabro-llm/Cargo.toml @@ -39,12 +39,12 @@ aws-smithy-eventstream.workspace = true aws-smithy-runtime-api.workspace = true aws-smithy-types.workspace = true fabro-http.workspace = true -fabro-auth = { path = "../fabro-auth" } -fabro-model = { path = "../fabro-model" } +fabro-auth = { path = "../../foundation/fabro-auth" } +fabro-model = { path = "../../foundation/fabro-model" } fabro-redact.workspace = true fabro-static.workspace = true -fabro-types = { path = "../fabro-types" } -fabro-util = { path = "../fabro-util" } +fabro-types = { path = "../../foundation/fabro-types" } +fabro-util = { path = "../../foundation/fabro-util" } [dev-dependencies] http = "1" @@ -53,6 +53,6 @@ tokio = { workspace = true, features = ["test-util", "macros"] } httpmock = "0.8" serde_json.workspace = true toml.workspace = true -fabro-macros = { path = "../fabro-macros" } +fabro-macros = { path = "../../foundation/fabro-macros" } fabro-test = { workspace = true } tracing-subscriber.workspace = true diff --git a/lib/crates/fabro-llm/README.md b/lib/components/fabro-llm/README.md similarity index 100% rename from lib/crates/fabro-llm/README.md rename to lib/components/fabro-llm/README.md diff --git a/lib/crates/fabro-llm/src/adapter_registry.rs b/lib/components/fabro-llm/src/adapter_registry.rs similarity index 94% rename from lib/crates/fabro-llm/src/adapter_registry.rs rename to lib/components/fabro-llm/src/adapter_registry.rs index 7a5638c93..dd880d200 100644 --- a/lib/crates/fabro-llm/src/adapter_registry.rs +++ b/lib/components/fabro-llm/src/adapter_registry.rs @@ -10,7 +10,9 @@ use std::collections::HashMap; use std::sync::Arc; use fabro_auth::ApiKeyHeader; -use fabro_model::{AdapterKind, AgentProfileKind, BillingPolicy, Catalog, CodecKind, ProviderId}; +use fabro_model::{ + AdapterKind, AgentProfileKind, BillingPolicy, Catalog, CodecKind, Model, ProviderId, +}; use crate::error::Error; use crate::provider::ProviderAdapter; @@ -242,13 +244,11 @@ pub struct Route { pub agent_profile: AgentProfileKind, } -/// Resolve the route for `model_id_or_alias` from the catalog's provider and -/// model rows. Returns `None` when the model or its provider is unknown. +/// Resolve the route for one already-selected catalog offering. #[must_use] -pub fn resolve_route(catalog: &Catalog, model_id_or_alias: &str) -> Option { - let model = catalog.get(model_id_or_alias)?; +pub fn resolve_route(catalog: &Catalog, model: &Model) -> Option { let provider = catalog.provider(&model.provider)?; - let settings = catalog.model_settings(&model.id)?; + let settings = catalog.settings_for(model)?; Some(Route { provider: provider.id.clone(), transport: provider.adapter, @@ -263,6 +263,12 @@ pub fn resolve_route(catalog: &Catalog, model_id_or_alias: &str) -> Option(catalog: &'a Catalog, selector: &str) -> &'a Model { + catalog + .select(selector, None, &catalog.all_provider_ids()) + .unwrap_or_else(|error| panic!("built-in model '{selector}' should resolve: {error}")) + } + /// One row of the route-equivalence table: model id plus the /// `(deployment_id, transport, codec, billing_policy, agent_profile)` /// tuple it must resolve to. @@ -336,7 +342,8 @@ mod tests { ); for (model_id, deployment_id, transport, codec, billing_policy, agent_profile) in expected { - let route = resolve_route(catalog, model_id) + let model = select_from_all(catalog, model_id); + let route = resolve_route(catalog, model) .unwrap_or_else(|| panic!("built-in model '{model_id}' should resolve")); assert_eq!(route.deployment_id, *deployment_id, "{model_id}"); assert_eq!(route.transport, *transport, "{model_id}"); @@ -350,16 +357,20 @@ mod tests { fn resolve_route_follows_model_aliases() { let catalog = Catalog::builtin(); - let by_alias = resolve_route(catalog, "sonnet").expect("alias should resolve"); - let by_id = resolve_route(catalog, "claude-sonnet-4-6").expect("id should resolve"); + let by_alias = resolve_route(catalog, select_from_all(catalog, "sonnet")) + .expect("alias should resolve"); + let by_id = resolve_route(catalog, select_from_all(catalog, "claude-sonnet-4-6")) + .expect("id should resolve"); assert_eq!(by_alias, by_id); assert_eq!(by_alias.provider, ProviderId::anthropic()); } #[test] - fn resolve_route_returns_none_for_unknown_models() { - assert_eq!(resolve_route(Catalog::builtin(), "not-a-model"), None); + fn resolve_route_resolves_by_id_for_model_from_another_catalog_instance() { + let other = Catalog::from_builtin().unwrap(); + let model = select_from_all(&other, "gpt-5.4"); + assert!(resolve_route(Catalog::builtin(), model).is_some()); } #[test] diff --git a/lib/crates/fabro-llm/src/attachments.rs b/lib/components/fabro-llm/src/attachments.rs similarity index 100% rename from lib/crates/fabro-llm/src/attachments.rs rename to lib/components/fabro-llm/src/attachments.rs diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/components/fabro-llm/src/client.rs similarity index 79% rename from lib/crates/fabro-llm/src/client.rs rename to lib/components/fabro-llm/src/client.rs index c72e9ea50..446b0b77a 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/components/fabro-llm/src/client.rs @@ -1,12 +1,12 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use fabro_auth::{ApiCredential, CredentialSource}; -use fabro_model::{AdapterKind, Catalog, ProviderId}; +use fabro_model::{AdapterKind, Catalog, ModelSelectionError, ProviderId}; use tracing::debug; use crate::adapter_registry::{ - self, AdapterConfig, AdapterKindOptions, OpenAiAdapterOptions, factory_for, + AdapterConfig, AdapterKindOptions, OpenAiAdapterOptions, factory_for, }; use crate::cost; use crate::error::{Error, ProviderErrorKind}; @@ -44,6 +44,11 @@ enum RegistrationMode { CollectIssues, } +struct ResolvedRequest { + provider: Arc, + request: Request, +} + impl Client { /// Create a new Client with explicit configuration. #[must_use] @@ -259,44 +264,149 @@ impl Client { ) } - /// Resolve the provider for a request: an explicit `request.provider` - /// wins, then the model's catalog route, then the default provider. - fn resolve_provider(&self, request: &Request) -> Result, Error> { - let route = self - .catalog - .as_ref() - .and_then(|catalog| adapter_registry::resolve_route(catalog, &request.model)); - - let provider_name = request - .provider - .as_deref() - .or_else(|| route.as_ref().map(|route| route.provider.as_str())) - .or(self.default_provider.as_deref()) - .ok_or_else(|| Error::Configuration { - message: "No provider specified and no default provider set".into(), - source: None, - })?; - let provider_name = self.canonical_provider_name(provider_name); - - self.providers - .get(&provider_name) - .cloned() - .ok_or_else(|| Error::Configuration { - message: format!("Provider '{provider_name}' not registered"), - source: None, + fn provider_adapter(&self, provider_name: &str) -> Option> { + let canonical = self.canonical_provider_name(provider_name); + self.providers.get(&canonical).cloned().or_else(|| { + self.providers.iter().find_map(|(name, adapter)| { + (self.canonical_provider_name(name) == canonical).then(|| Arc::clone(adapter)) }) + }) + } + + fn eligible_provider_ids(&self) -> HashSet { + self.providers + .keys() + .map(|provider| ProviderId::new(self.canonical_provider_name(provider))) + .collect() + } + + /// Resolve one concrete provider/model offering and canonicalize a cloned + /// request. Explicit-provider unknown models remain passthrough values. + fn resolve_request_with_adapter(&self, request: &Request) -> Result { + let mut resolved = request.clone(); + let Some(catalog) = &self.catalog else { + let provider_name = request + .provider + .as_deref() + .or(self.default_provider.as_deref()) + .ok_or_else(|| Error::Configuration { + message: "No provider specified and no default provider set".into(), + source: None, + })?; + let provider = + self.provider_adapter(provider_name) + .ok_or_else(|| Error::Configuration { + message: format!("Provider '{provider_name}' not registered"), + source: None, + })?; + resolved.provider = Some(provider.name().to_string()); + return Ok(ResolvedRequest { + provider, + request: resolved, + }); + }; + + let eligible = self.eligible_provider_ids(); + if let Some(explicit) = request.provider.as_deref() { + let explicit = ProviderId::new(explicit); + if catalog.provider(&explicit).is_some() { + let selected = catalog + .resolve_selection(Some(&request.model), Some(&explicit), &eligible) + .map_err(selection_error)?; + let provider = self + .provider_adapter(selected.provider.as_str()) + .ok_or_else(|| { + selection_error(ModelSelectionError::ProviderUnavailable { + provider: selected.provider.clone(), + }) + })?; + resolved.model = selected.model; + resolved.provider = Some(selected.provider.into_inner()); + return Ok(ResolvedRequest { + provider, + request: resolved, + }); + } + + let provider = + self.provider_adapter(explicit.as_str()) + .ok_or_else(|| Error::Configuration { + message: format!("Provider '{explicit}' not registered"), + source: None, + })?; + resolved.provider = Some(provider.name().to_string()); + return Ok(ResolvedRequest { + provider, + request: resolved, + }); + } + + match catalog.select(&request.model, None, &eligible) { + Ok(model) => { + let provider = self + .provider_adapter(model.provider.as_str()) + .ok_or_else(|| { + selection_error(ModelSelectionError::ProviderUnavailable { + provider: model.provider.clone(), + }) + })?; + resolved.model = model.id.to_string(); + resolved.provider = Some(model.provider.to_string()); + Ok(ResolvedRequest { + provider, + request: resolved, + }) + } + Err(ModelSelectionError::UnknownSelector { .. }) => { + let provider_name = + self.default_provider + .as_deref() + .ok_or_else(|| Error::Configuration { + message: "No provider specified and no default provider set".into(), + source: None, + })?; + let provider = + self.provider_adapter(provider_name) + .ok_or_else(|| Error::Configuration { + message: format!("Provider '{provider_name}' not registered"), + source: None, + })?; + resolved.provider = Some(self.canonical_provider_name(provider.name())); + Ok(ResolvedRequest { + provider, + request: resolved, + }) + } + Err(error) => Err(selection_error(error)), + } + } + + /// Resolve the concrete provider/model route and return a canonicalized + /// clone of the request without dispatching it. + /// + /// This is useful at persistence and API boundaries that must expose the + /// selected provider alongside the canonical model ID. The caller-owned + /// request is never modified. + pub fn resolve_request(&self, request: &Request) -> Result { + self.resolve_request_with_adapter(request) + .map(|resolved| resolved.request) } fn validate_request_controls(&self, request: &Request) -> Result<(), Error> { let Some(catalog) = &self.catalog else { return Ok(()); }; - let Some(settings) = catalog.model_settings(&request.model) else { + let Some(provider) = request.provider.as_deref() else { return Ok(()); }; - let model_id = catalog - .get(&request.model) - .map_or(request.model.as_str(), |model| model.id.as_str()); + let Some(model) = catalog.get_on_provider(&ProviderId::new(provider), &request.model) + else { + return Ok(()); + }; + let Some(settings) = catalog.settings_for(model) else { + return Ok(()); + }; + let model_id = model.id.as_str(); if let Some(effort) = request.reasoning_effort { if !settings.controls.reasoning_effort.contains(&effort) { @@ -333,11 +443,11 @@ impl Client { /// registered, or any provider/middleware error encountered during the /// request. pub async fn complete(&self, request: &Request) -> Result { - self.validate_request_controls(request)?; - let provider = self.resolve_provider(request)?; + let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?; + self.validate_request_controls(&request)?; if self.middleware.is_empty() { - return complete_stamped(&provider, self.catalog.as_deref(), request).await; + return complete_stamped(&provider, self.catalog.as_deref(), &request).await; } // Build middleware chain. Cost is stamped at the base so middleware @@ -358,7 +468,7 @@ impl Client { }) }); - chain(request.clone()).await + chain(request).await } /// Send a streaming request (Section 4.2). @@ -369,11 +479,11 @@ impl Client { /// registered, or any provider/middleware error encountered during the /// request. pub async fn stream(&self, request: &Request) -> Result { - self.validate_request_controls(request)?; - let provider = self.resolve_provider(request)?; + let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?; + self.validate_request_controls(&request)?; if self.middleware.is_empty() { - return stream_stamped(&provider, self.catalog.clone(), request).await; + return stream_stamped(&provider, self.catalog.clone(), &request).await; } // Build streaming middleware chain. Cost is stamped at the base so @@ -394,7 +504,7 @@ impl Client { }) }); - chain(request.clone()).await + chain(request).await } /// Count the model-visible input/context tokens for a request without @@ -410,19 +520,19 @@ impl Client { request: &Request, preference: InputTokenCountPreference, ) -> Result { - self.validate_request_controls(request)?; - let provider = self.resolve_provider(request)?; - provider.validate_request(request)?; + let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?; + self.validate_request_controls(&request)?; + provider.validate_request(&request)?; if preference == InputTokenCountPreference::EstimateOnly { - return Ok(estimate_input_tokens(request, provider.name())); + return Ok(estimate_input_tokens(&request, provider.name())); } - match provider.count_input_tokens(request).await { + match provider.count_input_tokens(&request).await { Ok(Some(count)) => Ok(count), Ok(None) if preference == InputTokenCountPreference::PreferProvider => { Ok(fallback_estimate( - request, + &request, provider.name(), "provider_token_count_unsupported", "provider does not support input token counting; returned local estimate", @@ -440,7 +550,7 @@ impl Client { && token_count_fallback_eligible(&error) => { Ok(fallback_estimate( - request, + &request, provider.name(), "provider_token_count_failed", "provider input token counting failed; returned local estimate", @@ -471,6 +581,12 @@ impl Client { .collect() } + /// Canonical IDs for provider adapters that registered successfully. + #[must_use] + pub fn provider_ids(&self) -> HashSet { + self.eligible_provider_ids() + } + /// Check whether a provider adapter is registered. #[must_use] pub fn has_provider(&self, name: &str) -> bool { @@ -489,6 +605,10 @@ impl Client { } } +fn selection_error(error: ModelSelectionError) -> Error { + Error::configuration_error(error.to_string(), error) +} + /// Validate, run, and cost-stamp a blocking request. Shared by /// [`Client::complete`]'s direct path and its middleware-chain base so cost /// stamping stays single-sited. @@ -499,7 +619,19 @@ async fn complete_stamped( ) -> Result { provider.validate_request(request)?; let mut response = provider.complete(request).await?; - cost::apply_estimated_cost(catalog, &request.model, request.speed, &mut response); + let selected_provider = request + .provider + .as_deref() + .unwrap_or_else(|| provider.name()); + response.model.clone_from(&request.model); + response.provider = selected_provider.to_string(); + cost::apply_estimated_cost( + catalog, + selected_provider, + &request.model, + request.speed, + &mut response, + ); Ok(response) } @@ -515,6 +647,10 @@ async fn stream_stamped( let stream = provider.stream(request).await?; Ok(stamp_stream_costs( catalog, + request + .provider + .clone() + .unwrap_or_else(|| provider.name().to_string()), request.model.clone(), request.speed, stream, @@ -526,6 +662,7 @@ async fn stream_stamped( /// [`Client::complete`] stamps on blocking responses. fn stamp_stream_costs( catalog: Option>, + provider: String, model: String, speed: Option, stream: StreamEventStream, @@ -534,8 +671,12 @@ fn stamp_stream_costs( Box::pin(stream.map(move |event| { event.map(|mut event| { - if let StreamEvent::Finish { response, .. } = &mut event { - cost::apply_estimated_cost(catalog.as_deref(), &model, speed, response); + if let StreamEvent::Finish { response, .. } | StreamEvent::StepFinish { response, .. } = + &mut event + { + response.model.clone_from(&model); + response.provider.clone_from(&provider); + cost::apply_estimated_cost(catalog.as_deref(), &provider, &model, speed, response); } event }) @@ -606,7 +747,9 @@ mod tests { use futures::stream; use super::*; + use crate::adapter_registry; use crate::error::ProviderErrorDetail; + use crate::providers::openai_compatible; use crate::types::*; /// A mock provider for testing. @@ -863,6 +1006,169 @@ output_cost_per_mtok = 2.0 Arc::new(Catalog::from_settings(&settings).unwrap()) } + fn portable_model_catalog(openrouter_base_url: &str) -> Arc { + let settings: LlmCatalogSettings = toml::from_str(&format!( + r#" +[providers.openai] +display_name = "OpenAI" +adapter = "openai_compatible" +agent_profile = "openai" +base_url = "https://openai.invalid/v1" +priority = 90 + +[providers.openai.models."gpt-5.6-sol"] +display_name = "GPT-5.6 Sol" +family = "gpt-5" +aliases = ["gpt-56-sol"] +default = true + +[providers.openai.models."gpt-5.6-sol".limits] +context_window = 1000 + +[providers.openai.models."gpt-5.6-sol".features] +tools = true +vision = false +reasoning = false + +[providers.openai.models."gpt-5.6-sol".costs] +input_cost_per_mtok = 1.0 +output_cost_per_mtok = 2.0 + +[providers.openrouter] +display_name = "OpenRouter" +adapter = "openai_compatible" +agent_profile = "openai" +base_url = "{openrouter_base_url}" +priority = 25 + +[providers.openrouter.models."gpt-5.6-sol"] +api_id = "openai/gpt-5.6-sol" +display_name = "GPT-5.6 Sol (via OpenRouter)" +family = "gpt-5" +aliases = ["gpt-56-sol"] +default = true + +[providers.openrouter.models."gpt-5.6-sol".limits] +context_window = 1000 + +[providers.openrouter.models."gpt-5.6-sol".features] +tools = true +vision = false +reasoning = false + +[providers.openrouter.models."gpt-5.6-sol".costs] +input_cost_per_mtok = 10.0 +output_cost_per_mtok = 20.0 +"#, + )) + .unwrap(); + Arc::new(Catalog::from_settings(&settings).unwrap()) + } + + async fn portable_mock_client(catalog: &Arc, providers: &[&str]) -> Client { + let mut client = Client::new(HashMap::new(), None, vec![]); + for provider in providers { + client + .register_provider(Arc::new(MockProvider::new(provider, provider))) + .await + .unwrap(); + } + client.catalog = Some(Arc::clone(catalog)); + client + } + + #[tokio::test] + async fn shared_alias_selects_by_ready_providers_and_priority_without_mutating_request() { + let catalog = portable_model_catalog("https://openrouter.invalid/v1"); + let mut original = test_request(); + original.model = "gpt-56-sol".to_string(); + + let direct = portable_mock_client(&catalog, &["openai"]).await; + let direct_request = direct.resolve_request(&original).unwrap(); + assert_eq!(direct_request.model, "gpt-5.6-sol"); + assert_eq!(direct_request.provider.as_deref(), Some("openai")); + + let aggregator = portable_mock_client(&catalog, &["openrouter"]).await; + let aggregator_request = aggregator.resolve_request(&original).unwrap(); + assert_eq!(aggregator_request.model, "gpt-5.6-sol"); + assert_eq!(aggregator_request.provider.as_deref(), Some("openrouter")); + + let both = portable_mock_client(&catalog, &["openrouter", "openai"]).await; + let both_request = both.resolve_request(&original).unwrap(); + assert_eq!(both_request.provider.as_deref(), Some("openai")); + + assert_eq!(original.model, "gpt-56-sol"); + assert_eq!(original.provider, None); + + let response = aggregator.complete(&original).await.unwrap(); + assert_eq!(response.model, "gpt-5.6-sol"); + assert_eq!(response.provider, "openrouter"); + assert_eq!(response.cost_source, Some(CostSource::Estimated)); + assert_eq!(response.cost_usd, Some(0.0005)); + } + + #[tokio::test] + async fn explicit_provider_pins_shared_alias_and_preserves_unknown_passthrough() { + let catalog = portable_model_catalog("https://openrouter.invalid/v1"); + let client = portable_mock_client(&catalog, &["openai", "openrouter"]).await; + + let mut aliased = test_request(); + aliased.model = "gpt-56-sol".to_string(); + aliased.provider = Some("openrouter".to_string()); + let resolved = client.resolve_request(&aliased).unwrap(); + assert_eq!(resolved.model, "gpt-5.6-sol"); + assert_eq!(resolved.provider.as_deref(), Some("openrouter")); + + let mut unknown = test_request(); + unknown.model = "provider-private-preview".to_string(); + unknown.provider = Some("openrouter".to_string()); + let resolved = client.resolve_request(&unknown).unwrap(); + assert_eq!(resolved.model, "provider-private-preview"); + assert_eq!(resolved.provider.as_deref(), Some("openrouter")); + } + + #[tokio::test] + async fn selected_offering_api_id_reaches_openai_compatible_wire_request() { + let upstream = httpmock::MockServer::start_async().await; + let completion = upstream + .mock_async(|when, then| { + when.method(httpmock::Method::POST) + .path("/chat/completions") + .json_body_includes(r#"{"model":"openai/gpt-5.6-sol"}"#); + then.status(200) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "id": "chatcmpl-portable", + "model": "openai/gpt-5.6-sol", + "choices": [{ + "message": {"role": "assistant", "content": "OK"}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2 + } + })); + }) + .await; + let catalog = portable_model_catalog(&upstream.base_url()); + let adapter = openai_compatible::Adapter::new("test-key", upstream.base_url()) + .with_name("openrouter") + .with_catalog(Arc::clone(&catalog)); + let mut client = Client::new(HashMap::new(), None, vec![]); + client.register_provider(Arc::new(adapter)).await.unwrap(); + client.catalog = Some(catalog); + let mut request = test_request(); + request.model = "gpt-56-sol".to_string(); + + let response = client.complete(&request).await.unwrap(); + + assert_eq!(response.model, "gpt-5.6-sol"); + assert_eq!(response.provider, "openrouter"); + completion.assert_async().await; + } + #[tokio::test] async fn complete_stamps_estimated_cost_from_catalog() { let mut client = Client::new(HashMap::new(), None, vec![]); @@ -1592,7 +1898,10 @@ reasoning = false let mut request = test_request(); request.provider = Some("acme-ai".to_string()); - let provider = client.resolve_provider(&request).unwrap(); + let provider = client + .resolve_request_with_adapter(&request) + .unwrap() + .provider; assert_eq!(provider.name(), "acme"); } @@ -1620,12 +1929,15 @@ reasoning = false let client = client_with_all_catalog_providers(&catalog).await; for model in catalog.list(None) { - let route = adapter_registry::resolve_route(&catalog, &model.id) + let route = adapter_registry::resolve_route(&catalog, model) .expect("built-in model should resolve to a route"); let mut request = test_request(); - request.model = model.id.clone(); + request.model = model.id.to_string(); - let provider = client.resolve_provider(&request).unwrap(); + let provider = client + .resolve_request_with_adapter(&request) + .unwrap() + .provider; assert_eq!(provider.name(), route.provider.as_str(), "{}", model.id); } @@ -1640,7 +1952,10 @@ reasoning = false request.model = "gpt-5.4-mini".to_string(); request.provider = Some("anthropic".to_string()); - let provider = client.resolve_provider(&request).unwrap(); + let provider = client + .resolve_request_with_adapter(&request) + .unwrap() + .provider; assert_eq!(provider.name(), "anthropic"); } @@ -1654,7 +1969,10 @@ reasoning = false let mut request = test_request(); request.model = "model-not-in-any-catalog".to_string(); - let provider = client.resolve_provider(&request).unwrap(); + let provider = client + .resolve_request_with_adapter(&request) + .unwrap() + .provider; assert_eq!(provider.name(), default); } diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/decode.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/decode.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/anthropic_messages/decode.rs rename to lib/components/fabro-llm/src/codec/anthropic_messages/decode.rs diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs similarity index 96% rename from lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs rename to lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs index 360f4210a..5ed999f87 100644 --- a/lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs +++ b/lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs @@ -8,7 +8,8 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use super::SYNTHETIC_TOOL_NAME; -use super::wire::{ApiMessage, ApiRequest, ApiToolDef, CacheControl, CountTokensRequest}; +use super::wire::{ApiMessage, ApiRequest, ApiToolDef, CountTokensRequest}; +use crate::codec::cache::{self, CacheControl}; use crate::codec::{AnthropicVersion, CodecCtx, EncodedRequest, extract_system_prompt}; use crate::types::{ ContentPart, Message, ReasoningEffort, ReasoningEffortFeature, Request, ResponseFormatType, @@ -48,7 +49,7 @@ pub(super) fn encode_count_tokens(ctx: &CodecCtx<'_>) -> EncodedRequest { /// hasn't opted out. fn auto_cache(ctx: &CodecCtx<'_>) -> bool { ctx.model.is_some_and(|m| m.features.prompt_cache) - && is_auto_cache_enabled(ctx.request.provider_options.as_ref()) + && cache::auto_cache_enabled(ctx.request.provider_options.as_ref(), "anthropic") } fn build_headers(ctx: &CodecCtx<'_>) -> Vec<(String, String)> { @@ -441,12 +442,6 @@ fn effort_to_budget_tokens(effort: ReasoningEffort, max_tokens: i64) -> i64 { budget.max(1024) } -fn is_auto_cache_enabled(provider_options: Option<&serde_json::Value>) -> bool { - anthropic_option(provider_options, "auto_cache") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true) -} - fn system_with_cache_control(system: &str) -> serde_json::Value { serde_json::json!([{ "type": "text", @@ -462,18 +457,10 @@ fn apply_cache_control_to_last_tool(tools: &mut [ApiToolDef]) { } fn apply_cache_control_to_conversation_prefix(messages: &mut [ApiMessage]) { - let user_indices: Vec = messages - .iter() - .enumerate() - .filter(|(_, m)| m.role == "user") - .map(|(i, _)| i) - .collect(); - - if user_indices.len() < 2 { + let user_turns: Vec = messages.iter().map(|m| m.role == "user").collect(); + let Some(target_idx) = cache::conversation_breakpoint_index(&user_turns) else { return; - } - - let target_idx = user_indices[user_indices.len() - 2]; + }; if let Some(serde_json::Value::Object(map)) = messages[target_idx].content.last_mut() { map.insert( "cache_control".to_string(), @@ -618,26 +605,26 @@ reasoning = true /// Encode `request` on the direct-Anthropic route, optionally with a /// catalog (for capability-driven behavior like prompt-cache/effort). fn encode_direct(request: &Request, catalog: Option<&Catalog>, stream: bool) -> EncodedRequest { - let deployment_id = common::api_model_id(catalog, &request.model); + let deployment_id = common::api_model_id(catalog, "anthropic", &request.model); let params = direct_params(); let ctx = CodecCtx { request, provider_name: "anthropic", deployment_id: &deployment_id, - model: common::catalog_model(catalog, &request.model), + model: common::catalog_model(catalog, "anthropic", &request.model), params: ¶ms, }; encode(&ctx, stream) } fn encode_count_direct(request: &Request, catalog: Option<&Catalog>) -> EncodedRequest { - let deployment_id = common::api_model_id(catalog, &request.model); + let deployment_id = common::api_model_id(catalog, "anthropic", &request.model); let params = direct_params(); let ctx = CodecCtx { request, provider_name: "anthropic", deployment_id: &deployment_id, - model: common::catalog_model(catalog, &request.model), + model: common::catalog_model(catalog, "anthropic", &request.model), params: ¶ms, }; encode_count_tokens(&ctx) @@ -651,37 +638,6 @@ reasoning = true .map(|(_, value)| value.as_str()) } - // --- auto_cache ---------------------------------------------------------- - - #[test] - fn auto_cache_enabled_by_default() { - assert!(is_auto_cache_enabled(None)); - } - - #[test] - fn auto_cache_enabled_when_true() { - let opts = serde_json::json!({"anthropic": {"auto_cache": true}}); - assert!(is_auto_cache_enabled(Some(&opts))); - } - - #[test] - fn auto_cache_disabled_when_false() { - let opts = serde_json::json!({"anthropic": {"auto_cache": false}}); - assert!(!is_auto_cache_enabled(Some(&opts))); - } - - #[test] - fn auto_cache_enabled_when_key_missing() { - let opts = serde_json::json!({"anthropic": {}}); - assert!(is_auto_cache_enabled(Some(&opts))); - } - - #[test] - fn auto_cache_enabled_when_anthropic_missing() { - let opts = serde_json::json!({"openai": {}}); - assert!(is_auto_cache_enabled(Some(&opts))); - } - // --- prompt-cache helpers ------------------------------------------------ #[test] diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/mod.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/mod.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/anthropic_messages/mod.rs rename to lib/components/fabro-llm/src/codec/anthropic_messages/mod.rs diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/stream.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/anthropic_messages/stream.rs rename to lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/wire.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs similarity index 94% rename from lib/crates/fabro-llm/src/codec/anthropic_messages/wire.rs rename to lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs index 80458dea4..70fb0ede6 100644 --- a/lib/crates/fabro-llm/src/codec/anthropic_messages/wire.rs +++ b/lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs @@ -1,5 +1,7 @@ //! Serde types mirroring the Anthropic Messages wire shapes. +use crate::codec::cache::CacheControl; + #[derive(serde::Serialize)] pub(super) struct ApiRequest { pub model: String, @@ -77,21 +79,6 @@ pub(super) struct ApiToolDef { pub cache_control: Option, } -/// Anthropic `cache_control` annotation. -#[derive(serde::Serialize, Clone)] -pub(super) struct CacheControl { - #[serde(rename = "type")] - pub kind: String, -} - -impl CacheControl { - pub(super) fn ephemeral() -> Self { - Self { - kind: "ephemeral".to_string(), - } - } -} - // --- Response types --- #[derive(serde::Deserialize)] diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/decode.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/bedrock_converse/decode.rs rename to lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs similarity index 99% rename from lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs rename to lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs index a328434de..39c9c5c10 100644 --- a/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs +++ b/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs @@ -342,13 +342,13 @@ fn apply_cache_point_to_conversation_prefix(messages: &mut [Value]) { /// codec). This is the passthrough for `additionalModelRequestFields`, /// `guardrailConfig`, `serviceTier`, and other Converse extensions. fn merge_provider_options(body: &mut Value, provider_options: Option<&Value>, provider_name: &str) { - merge_named_provider_options(body, provider_options, provider_name); + merge_named_provider_options(body, provider_options, provider_name, &[]); } #[cfg(test)] mod tests { - use fabro_model::Catalog; use fabro_model::catalog::LlmCatalogSettings; + use fabro_model::{Catalog, ProviderId}; use serde_json::json; use super::*; @@ -617,7 +617,7 @@ sampling_params = false request: &request, provider_name: "bedrock", deployment_id: "pinned-model", - model: catalog.get("pinned-model"), + model: catalog.get_on_provider(&ProviderId::new("bedrock"), "pinned-model"), params: ¶ms, }; let encoded = encode(&ctx, false).unwrap(); diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/mod.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/bedrock_converse/mod.rs rename to lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/stream.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/bedrock_converse/stream.rs rename to lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs diff --git a/lib/components/fabro-llm/src/codec/cache.rs b/lib/components/fabro-llm/src/codec/cache.rs new file mode 100644 index 000000000..ed1afe8ab --- /dev/null +++ b/lib/components/fabro-llm/src/codec/cache.rs @@ -0,0 +1,98 @@ +//! Shared prompt-cache policy: whether a request opts into explicit +//! Anthropic-style caching and where the conversation breakpoint lands. +//! Dialect codecs apply these decisions to their own wire shapes. + +/// Anthropic-style `cache_control` annotation. +#[derive(serde::Serialize, Clone)] +pub(crate) struct CacheControl { + #[serde(rename = "type")] + pub kind: String, +} + +impl CacheControl { + pub(crate) fn ephemeral() -> Self { + Self { + kind: "ephemeral".to_string(), + } + } +} + +/// Whether automatic prompt caching applies to this request: the +/// `provider_options..auto_cache` opt-out defaults to enabled. +pub(crate) fn auto_cache_enabled( + provider_options: Option<&serde_json::Value>, + namespace: &str, +) -> bool { + provider_options + .and_then(|opts| opts.get(namespace)) + .and_then(|ns| ns.get("auto_cache")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true) +} + +/// Index of the message carrying the conversation-prefix breakpoint: the +/// second-to-last user turn, so each iteration of an agent loop reuses the +/// prefix cached by the previous one. `user_turns[i]` is true when message +/// `i` advances the user side of the conversation (plain user messages, plus +/// tool results on dialects where they are separate messages). `None` until +/// the conversation has at least two user turns. +pub(crate) fn conversation_breakpoint_index(user_turns: &[bool]) -> Option { + let indices: Vec = user_turns + .iter() + .enumerate() + .filter_map(|(i, &is_user)| is_user.then_some(i)) + .collect(); + indices.len().checked_sub(2).map(|nth| indices[nth]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_cache_enabled_by_default() { + assert!(auto_cache_enabled(None, "anthropic")); + } + + #[test] + fn auto_cache_enabled_when_true() { + let opts = serde_json::json!({"anthropic": {"auto_cache": true}}); + assert!(auto_cache_enabled(Some(&opts), "anthropic")); + } + + #[test] + fn auto_cache_disabled_when_false() { + let opts = serde_json::json!({"openrouter": {"auto_cache": false}}); + assert!(!auto_cache_enabled(Some(&opts), "openrouter")); + } + + #[test] + fn auto_cache_enabled_when_key_missing() { + let opts = serde_json::json!({"anthropic": {}}); + assert!(auto_cache_enabled(Some(&opts), "anthropic")); + } + + #[test] + fn auto_cache_reads_only_its_own_namespace() { + let opts = serde_json::json!({"openrouter": {"auto_cache": false}}); + assert!(auto_cache_enabled(Some(&opts), "anthropic")); + } + + #[test] + fn conversation_breakpoint_none_below_two_user_turns() { + assert_eq!(conversation_breakpoint_index(&[]), None); + assert_eq!(conversation_breakpoint_index(&[true]), None); + assert_eq!(conversation_breakpoint_index(&[true, false, false]), None); + } + + #[test] + fn conversation_breakpoint_with_exactly_two_user_turns() { + assert_eq!(conversation_breakpoint_index(&[true, false, true]), Some(0)); + } + + #[test] + fn conversation_breakpoint_targets_second_to_last_user_turn() { + let turns = [true, false, true, false, true]; + assert_eq!(conversation_breakpoint_index(&turns), Some(2)); + } +} diff --git a/lib/crates/fabro-llm/src/codec/gemini_generate/decode.rs b/lib/components/fabro-llm/src/codec/gemini_generate/decode.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/gemini_generate/decode.rs rename to lib/components/fabro-llm/src/codec/gemini_generate/decode.rs diff --git a/lib/crates/fabro-llm/src/codec/gemini_generate/encode.rs b/lib/components/fabro-llm/src/codec/gemini_generate/encode.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/gemini_generate/encode.rs rename to lib/components/fabro-llm/src/codec/gemini_generate/encode.rs diff --git a/lib/crates/fabro-llm/src/codec/gemini_generate/mod.rs b/lib/components/fabro-llm/src/codec/gemini_generate/mod.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/gemini_generate/mod.rs rename to lib/components/fabro-llm/src/codec/gemini_generate/mod.rs diff --git a/lib/crates/fabro-llm/src/codec/gemini_generate/stream.rs b/lib/components/fabro-llm/src/codec/gemini_generate/stream.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/gemini_generate/stream.rs rename to lib/components/fabro-llm/src/codec/gemini_generate/stream.rs diff --git a/lib/crates/fabro-llm/src/codec/gemini_generate/wire.rs b/lib/components/fabro-llm/src/codec/gemini_generate/wire.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/gemini_generate/wire.rs rename to lib/components/fabro-llm/src/codec/gemini_generate/wire.rs diff --git a/lib/crates/fabro-llm/src/codec/mod.rs b/lib/components/fabro-llm/src/codec/mod.rs similarity index 98% rename from lib/crates/fabro-llm/src/codec/mod.rs rename to lib/components/fabro-llm/src/codec/mod.rs index 9ddc1eb0a..5a7759b22 100644 --- a/lib/crates/fabro-llm/src/codec/mod.rs +++ b/lib/components/fabro-llm/src/codec/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod anthropic_messages; pub(crate) mod bedrock_converse; +pub(crate) mod cache; pub(crate) mod gemini_generate; pub(crate) mod openai_compatible; pub(crate) mod openai_responses; @@ -38,11 +39,14 @@ pub(crate) fn split_inclusive_token_total(total: i64, detail: i64) -> (i64, i64) /// Merge `provider_options.` fields into an encoded request /// body. Used by codecs whose provider-options namespace is adapter-name keyed -/// rather than a single fixed provider. +/// rather than a single fixed provider. `known_keys` are control keys the +/// codec consumed itself (e.g. `auto_cache`); they are not re-merged into the +/// body. pub(crate) fn merge_named_provider_options( body: &mut serde_json::Value, provider_options: Option<&serde_json::Value>, provider_name: &str, + known_keys: &[&str], ) { let Some(opts) = provider_options.and_then(|opts| opts.get(provider_name)) else { return; @@ -55,6 +59,9 @@ pub(crate) fn merge_named_provider_options( }; for (key, value) in opts_map { + if known_keys.contains(&key.as_str()) { + continue; + } body_map.insert(key.clone(), value.clone()); } } diff --git a/lib/crates/fabro-llm/src/codec/openai_compatible/mod.rs b/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/openai_compatible/mod.rs rename to lib/components/fabro-llm/src/codec/openai_compatible/mod.rs diff --git a/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs b/lib/components/fabro-llm/src/codec/openai_compatible/request.rs similarity index 68% rename from lib/crates/fabro-llm/src/codec/openai_compatible/request.rs rename to lib/components/fabro-llm/src/codec/openai_compatible/request.rs index 154013725..12be9e84b 100644 --- a/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs +++ b/lib/components/fabro-llm/src/codec/openai_compatible/request.rs @@ -1,10 +1,14 @@ //! Request encoding: canonical `Request` → Chat Completions body. use super::translate; -use super::wire::ApiRequest; -use crate::codec::{CodecCtx, EncodedRequest, merge_named_provider_options}; +use super::wire::{ApiRequest, ChatMessage}; +use crate::codec::{CodecCtx, EncodedRequest, cache, merge_named_provider_options}; use crate::error::Error; +/// Known `provider_options.` keys the codec consumes itself; +/// not re-merged into the body. +const KNOWN_OPTION_KEYS: &[&str] = &["auto_cache"]; + /// Build the Chat Completions request for `ctx.request`. `stream` toggles the /// `stream` body field. The body is assembled as a `serde_json::Value` so /// `provider_options.` fields can be merged in before sending. @@ -13,7 +17,10 @@ use crate::error::Error; /// the Chat Completions tool envelope cannot represent. pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> Result { let request = ctx.request; - let chat_messages = translate::translate_messages(&request.messages); + let mut chat_messages = translate::translate_messages(&request.messages); + if explicit_cache_breakpoints(ctx) { + apply_cache_breakpoints(&mut chat_messages); + } let tools = request .tools .as_ref() @@ -64,6 +71,37 @@ pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> Result) -> bool { + ctx.model + .is_some_and(|m| m.features.prompt_cache && m.features.cache_control_breakpoints) + && cache::auto_cache_enabled(ctx.request.provider_options.as_ref(), ctx.provider_name) +} + +/// Mark the cacheable prefix: the last system message (upstream, tools and +/// system precede the conversation, so this breakpoint covers them too) and +/// the second-to-last user turn. Tool results count as user turns — they ride +/// in user messages on the upstream Anthropic wire. +fn apply_cache_breakpoints(messages: &mut [ChatMessage]) { + if let Some(system) = messages.iter_mut().rev().find(|m| m.role == "system") { + if let Some(content) = system.content.as_mut() { + content.mark_cache_breakpoint(); + } + } + + let user_turns: Vec = messages + .iter() + .map(|m| m.role == "user" || m.role == "tool") + .collect(); + if let Some(idx) = cache::conversation_breakpoint_index(&user_turns) { + if let Some(content) = messages[idx].content.as_mut() { + content.mark_cache_breakpoint(); + } + } +} + /// Merge `provider_options.` fields into the serialized API /// request body. /// @@ -74,12 +112,12 @@ pub(super) fn merge_provider_options( provider_options: Option<&serde_json::Value>, provider_name: &str, ) { - merge_named_provider_options(body, provider_options, provider_name); + merge_named_provider_options(body, provider_options, provider_name, KNOWN_OPTION_KEYS); } #[cfg(test)] mod tests { - use fabro_model::Catalog; + use fabro_model::{Catalog, ProviderId}; use super::super::wire::ApiRequest; use super::*; @@ -183,16 +221,18 @@ mod tests { #[test] fn encode_omits_sampling_params_for_models_that_reject_them() { - let model = Catalog::builtin().get("kimi-k3").unwrap(); + let model = Catalog::builtin() + .get_on_provider(&ProviderId::new("kimi"), "kimi-k3") + .unwrap(); let mut request = minimal_request(); - request.model = model.id.clone(); + request.model = model.id.to_string(); request.temperature = Some(0.7); request.top_p = Some(0.9); let params = CodecParams::default(); let ctx = CodecCtx { request: &request, provider_name: "kimi", - deployment_id: &model.id, + deployment_id: model.id.as_str(), model: Some(model), params: ¶ms, }; @@ -313,4 +353,68 @@ mod tests { merge_provider_options(&mut body, Some(&opts), "groq"); assert_eq!(body["model"], "test"); } + + #[test] + fn merge_provider_options_consumes_auto_cache_control_key() { + let mut body = serde_json::json!({"model": "test"}); + let opts = serde_json::json!({"groq": {"auto_cache": false, "top_k": 5}}); + merge_provider_options(&mut body, Some(&opts), "groq"); + assert!(body.get("auto_cache").is_none()); + assert_eq!(body["top_k"], 5); + } + + // --- apply_cache_breakpoints --------------------------------------------- + + fn chat_message(role: &str, text: &str) -> ChatMessage { + ChatMessage { + role: role.to_string(), + content: Some(super::super::wire::ChatContent::Text(text.to_string())), + reasoning_content: None, + tool_call_id: None, + tool_calls: None, + } + } + + fn marked(message: &ChatMessage) -> bool { + let json = serde_json::to_value(message).unwrap(); + json["content"].is_array() && json["content"][0]["cache_control"]["type"] == "ephemeral" + } + + #[test] + fn cache_breakpoints_on_first_turn_mark_only_the_system_prompt() { + let mut messages = vec![chat_message("system", "sys"), chat_message("user", "task")]; + apply_cache_breakpoints(&mut messages); + assert!(marked(&messages[0])); + assert!(!marked(&messages[1])); + } + + #[test] + fn cache_breakpoints_count_tool_results_as_user_turns() { + let mut messages = vec![ + chat_message("system", "sys"), + chat_message("user", "task"), + chat_message("assistant", "calling a tool"), + chat_message("tool", "tool output"), + chat_message("assistant", "one more"), + chat_message("tool", "more output"), + ]; + apply_cache_breakpoints(&mut messages); + assert!(marked(&messages[0])); + // Second-to-last user turn: the first tool result, not the user task. + assert!(!marked(&messages[1])); + assert!(marked(&messages[3])); + assert!(!marked(&messages[5])); + } + + #[test] + fn cache_breakpoints_without_system_mark_only_the_conversation() { + let mut messages = vec![ + chat_message("user", "task"), + chat_message("assistant", "answer"), + chat_message("user", "follow-up"), + ]; + apply_cache_breakpoints(&mut messages); + assert!(marked(&messages[0])); + assert!(!marked(&messages[2])); + } } diff --git a/lib/crates/fabro-llm/src/codec/openai_compatible/response.rs b/lib/components/fabro-llm/src/codec/openai_compatible/response.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/openai_compatible/response.rs rename to lib/components/fabro-llm/src/codec/openai_compatible/response.rs diff --git a/lib/crates/fabro-llm/src/codec/openai_compatible/stream.rs b/lib/components/fabro-llm/src/codec/openai_compatible/stream.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/openai_compatible/stream.rs rename to lib/components/fabro-llm/src/codec/openai_compatible/stream.rs diff --git a/lib/crates/fabro-llm/src/codec/openai_compatible/translate.rs b/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs similarity index 93% rename from lib/crates/fabro-llm/src/codec/openai_compatible/translate.rs rename to lib/components/fabro-llm/src/codec/openai_compatible/translate.rs index 127af26a2..dbef4831f 100644 --- a/lib/crates/fabro-llm/src/codec/openai_compatible/translate.rs +++ b/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs @@ -1,6 +1,6 @@ //! Pure mapping between canonical types and the Chat Completions wire shapes. -use super::wire::{ChatFunction, ChatMessage, ChatToolCall}; +use super::wire::{ChatContent, ChatFunction, ChatMessage, ChatToolCall}; use crate::error::Error; use crate::types::{ ContentPart, CostSource, FinishReason, Message, Request, ResponseFormat, ResponseFormatType, @@ -67,7 +67,7 @@ pub(super) fn translate_messages(messages: &[Message]) -> Vec { .map_or_else(|| tr.content.to_string(), str::to_string); Some(ChatMessage { role: "tool".to_string(), - content: Some(output), + content: Some(ChatContent::Text(output)), reasoning_content: None, tool_call_id: Some(tr.tool_call_id.clone()), tool_calls: None, @@ -109,7 +109,11 @@ pub(super) fn translate_messages(messages: &[Message]) -> Vec { } let text = content_text_with_fallbacks(&msg.content); - let content = if text.is_empty() { None } else { Some(text) }; + let content = if text.is_empty() { + None + } else { + Some(ChatContent::Text(text)) + }; let tool_calls = if tool_calls.is_empty() { None } else { @@ -276,7 +280,10 @@ mod tests { }; let translated = translate_messages(&[msg]); assert_eq!( - translated[0].content.as_deref(), + translated[0] + .content + .as_ref() + .and_then(ChatContent::as_text), Some("Let me check the weather") ); let tool_calls = translated[0].tool_calls.as_ref().unwrap(); @@ -318,7 +325,13 @@ mod tests { let msg = Message::user("Hello"); let translated = translate_messages(&[msg]); assert_eq!(translated[0].role, "user"); - assert_eq!(translated[0].content.as_deref(), Some("Hello")); + assert_eq!( + translated[0] + .content + .as_ref() + .and_then(ChatContent::as_text), + Some("Hello") + ); assert!(translated[0].tool_calls.is_none()); } @@ -359,7 +372,10 @@ mod tests { }; let translated = translate_messages(&[msg]); assert_eq!( - translated[0].content.as_deref(), + translated[0] + .content + .as_ref() + .and_then(ChatContent::as_text), Some("[Audio content not supported by this provider]") ); } @@ -379,7 +395,10 @@ mod tests { }; let translated = translate_messages(&[msg]); assert_eq!( - translated[0].content.as_deref(), + translated[0] + .content + .as_ref() + .and_then(ChatContent::as_text), Some("[Document 'report.pdf': content type not supported by this provider]") ); } @@ -399,7 +418,10 @@ mod tests { }; let translated = translate_messages(&[msg]); assert_eq!( - translated[0].content.as_deref(), + translated[0] + .content + .as_ref() + .and_then(ChatContent::as_text), Some("[Document content not supported by this provider]") ); } @@ -421,7 +443,10 @@ mod tests { }; let translated = translate_messages(&[msg]); assert_eq!( - translated[0].content.as_deref(), + translated[0] + .content + .as_ref() + .and_then(ChatContent::as_text), Some("Check this: [Audio content not supported by this provider]") ); } diff --git a/lib/crates/fabro-llm/src/codec/openai_compatible/wire.rs b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs similarity index 73% rename from lib/crates/fabro-llm/src/codec/openai_compatible/wire.rs rename to lib/components/fabro-llm/src/codec/openai_compatible/wire.rs index 396b74a72..5bc0a3b93 100644 --- a/lib/crates/fabro-llm/src/codec/openai_compatible/wire.rs +++ b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs @@ -1,5 +1,6 @@ //! Serde types mirroring the OpenAI Chat Completions wire shapes. +use crate::codec::cache::CacheControl; use crate::codec::split_inclusive_token_total; use crate::types::{ReasoningEffort, TokenCounts}; @@ -31,7 +32,7 @@ pub(super) struct ApiRequest { pub(super) struct ChatMessage { pub role: String, #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, + pub content: Option, /// Reasoning/thinking content echoed back for providers that require it /// (Kimi). #[serde(skip_serializing_if = "Option::is_none")] @@ -42,6 +43,56 @@ pub(super) struct ChatMessage { pub tool_calls: Option>, } +/// Message content: plain text, or text parts when a part carries a +/// `cache_control` breakpoint (aggregators fronting Anthropic models forward +/// it upstream). Unmarked messages keep the plain-string form for maximum +/// compatibility with strict Chat Completions servers. +#[derive(serde::Serialize)] +#[serde(untagged)] +pub(super) enum ChatContent { + Text(String), + Parts(Vec), +} + +#[derive(serde::Serialize)] +pub(super) struct ChatTextPart { + #[serde(rename = "type")] + pub kind: String, + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_control: Option, +} + +impl ChatContent { + /// Plain-text view for assertions. + #[cfg(test)] + pub(super) fn as_text(&self) -> Option<&str> { + match self { + Self::Text(text) => Some(text.as_str()), + Self::Parts(_) => None, + } + } + + /// Mark this content as a prompt-cache breakpoint, converting to parts + /// form so the annotation has somewhere to live. + pub(super) fn mark_cache_breakpoint(&mut self) { + match self { + Self::Text(text) => { + *self = Self::Parts(vec![ChatTextPart { + kind: "text".to_string(), + text: std::mem::take(text), + cache_control: Some(CacheControl::ephemeral()), + }]); + } + Self::Parts(parts) => { + if let Some(last) = parts.last_mut() { + last.cache_control = Some(CacheControl::ephemeral()); + } + } + } + } +} + #[derive(serde::Serialize)] pub(super) struct ChatToolCall { pub id: String, @@ -229,9 +280,53 @@ pub(super) struct AccumulatedToolCall { #[cfg(test)] mod tests { - use super::{ApiResponse, ApiUsage, StreamChunk}; + use super::{ApiResponse, ApiUsage, ChatContent, ChatTextPart, StreamChunk}; + use crate::codec::cache::CacheControl; use crate::types::TokenCounts; + #[test] + fn chat_content_text_serializes_as_plain_string() { + let content = ChatContent::Text("Hello".to_string()); + assert_eq!( + serde_json::to_value(&content).unwrap(), + serde_json::json!("Hello") + ); + } + + #[test] + fn mark_cache_breakpoint_converts_text_to_annotated_parts() { + let mut content = ChatContent::Text("Hello".to_string()); + content.mark_cache_breakpoint(); + assert_eq!( + serde_json::to_value(&content).unwrap(), + serde_json::json!([{ + "type": "text", + "text": "Hello", + "cache_control": {"type": "ephemeral"} + }]) + ); + } + + #[test] + fn mark_cache_breakpoint_annotates_last_existing_part() { + let mut content = ChatContent::Parts(vec![ + ChatTextPart { + kind: "text".to_string(), + text: "first".to_string(), + cache_control: None, + }, + ChatTextPart { + kind: "text".to_string(), + text: "second".to_string(), + cache_control: Some(CacheControl::ephemeral()), + }, + ]); + content.mark_cache_breakpoint(); + let json = serde_json::to_value(&content).unwrap(); + assert!(json[0].get("cache_control").is_none()); + assert_eq!(json[1]["cache_control"]["type"], "ephemeral"); + } + #[test] fn token_counts_bound_detail_to_parent_totals() { let usage: ApiUsage = serde_json::from_value(serde_json::json!({ diff --git a/lib/crates/fabro-llm/src/codec/openai_responses/decode.rs b/lib/components/fabro-llm/src/codec/openai_responses/decode.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/openai_responses/decode.rs rename to lib/components/fabro-llm/src/codec/openai_responses/decode.rs diff --git a/lib/crates/fabro-llm/src/codec/openai_responses/encode.rs b/lib/components/fabro-llm/src/codec/openai_responses/encode.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/openai_responses/encode.rs rename to lib/components/fabro-llm/src/codec/openai_responses/encode.rs diff --git a/lib/crates/fabro-llm/src/codec/openai_responses/mod.rs b/lib/components/fabro-llm/src/codec/openai_responses/mod.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/openai_responses/mod.rs rename to lib/components/fabro-llm/src/codec/openai_responses/mod.rs diff --git a/lib/crates/fabro-llm/src/codec/openai_responses/stream.rs b/lib/components/fabro-llm/src/codec/openai_responses/stream.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/openai_responses/stream.rs rename to lib/components/fabro-llm/src/codec/openai_responses/stream.rs diff --git a/lib/crates/fabro-llm/src/codec/openai_responses/wire.rs b/lib/components/fabro-llm/src/codec/openai_responses/wire.rs similarity index 100% rename from lib/crates/fabro-llm/src/codec/openai_responses/wire.rs rename to lib/components/fabro-llm/src/codec/openai_responses/wire.rs diff --git a/lib/crates/fabro-llm/src/cost.rs b/lib/components/fabro-llm/src/cost.rs similarity index 95% rename from lib/crates/fabro-llm/src/cost.rs rename to lib/components/fabro-llm/src/cost.rs index e03ead5de..898dcb362 100644 --- a/lib/crates/fabro-llm/src/cost.rs +++ b/lib/components/fabro-llm/src/cost.rs @@ -25,8 +25,8 @@ pub(crate) fn estimate_cost_usd( let catalog = catalog?; // The billing machinery compares ModelRefs against the catalog's // canonical identity, so resolve model aliases and provider names first. - let model = catalog.get(model)?; let provider = catalog.provider(&ProviderId::new(provider))?; + let model = catalog.get_on_provider(&provider.id, model)?; let model_ref = ModelRef { provider: provider.id.clone(), model_id: model.id.clone(), @@ -47,6 +47,7 @@ pub(crate) fn estimate_cost_usd( /// aliases); the response's provider name selects the billing policy. pub(crate) fn apply_estimated_cost( catalog: Option<&Catalog>, + provider: &str, model: &str, speed: Option, response: &mut Response, @@ -54,7 +55,7 @@ pub(crate) fn apply_estimated_cost( if response.cost_usd.is_some() { return; } - let estimate = estimate_cost_usd(catalog, &response.provider, model, &response.usage, speed); + let estimate = estimate_cost_usd(catalog, provider, model, &response.usage, speed); response.cost_usd = estimate; response.cost_source = estimate.map(|_| CostSource::Estimated); } @@ -214,7 +215,7 @@ output_cost_per_mtok = {output_cost_per_mtok} ..TokenCounts::default() }); - apply_estimated_cost(Some(&catalog), "gpt-test", None, &mut response); + apply_estimated_cost(Some(&catalog), "openai", "gpt-test", None, &mut response); assert_eq!(response.cost_source, Some(CostSource::Estimated)); assert!(response.cost_usd.is_some()); @@ -224,7 +225,7 @@ output_cost_per_mtok = {output_cost_per_mtok} fn apply_estimated_cost_leaves_source_unset_without_estimate() { let mut response = response_with_usage(TokenCounts::default()); - apply_estimated_cost(None, "gpt-test", None, &mut response); + apply_estimated_cost(None, "openai", "gpt-test", None, &mut response); assert_eq!(response.cost_usd, None); assert_eq!(response.cost_source, None); @@ -241,7 +242,7 @@ output_cost_per_mtok = {output_cost_per_mtok} response.cost_usd = Some(0.42); response.cost_source = Some(CostSource::Authoritative); - apply_estimated_cost(Some(&catalog), "gpt-test", None, &mut response); + apply_estimated_cost(Some(&catalog), "openai", "gpt-test", None, &mut response); assert_eq!(response.cost_usd, Some(0.42)); assert_eq!(response.cost_source, Some(CostSource::Authoritative)); diff --git a/lib/crates/fabro-llm/src/error.rs b/lib/components/fabro-llm/src/error.rs similarity index 100% rename from lib/crates/fabro-llm/src/error.rs rename to lib/components/fabro-llm/src/error.rs diff --git a/lib/crates/fabro-llm/src/generate.rs b/lib/components/fabro-llm/src/generate.rs similarity index 100% rename from lib/crates/fabro-llm/src/generate.rs rename to lib/components/fabro-llm/src/generate.rs diff --git a/lib/crates/fabro-llm/src/lib.rs b/lib/components/fabro-llm/src/lib.rs similarity index 100% rename from lib/crates/fabro-llm/src/lib.rs rename to lib/components/fabro-llm/src/lib.rs diff --git a/lib/crates/fabro-llm/src/middleware.rs b/lib/components/fabro-llm/src/middleware.rs similarity index 100% rename from lib/crates/fabro-llm/src/middleware.rs rename to lib/components/fabro-llm/src/middleware.rs diff --git a/lib/crates/fabro-llm/src/model_test.rs b/lib/components/fabro-llm/src/model_test.rs similarity index 87% rename from lib/crates/fabro-llm/src/model_test.rs rename to lib/components/fabro-llm/src/model_test.rs index 47bca353c..aec0a83a7 100644 --- a/lib/crates/fabro-llm/src/model_test.rs +++ b/lib/components/fabro-llm/src/model_test.rs @@ -54,7 +54,7 @@ pub async fn run_model_test( } async fn run_basic_test(info: &Model, client: Arc) -> ModelTestOutcome { - run_basic_model_probe(&info.id, &info.provider, client).await + run_basic_model_probe(info.id.as_str(), &info.provider, client).await } /// Run the cheap single-prompt model availability probe without requiring a @@ -132,7 +132,7 @@ fn build_deep_test_params(info: &Model, client: Arc) -> Option Model { Model { - id: "test-model".to_string(), + id: "test-model".into(), provider: ProviderId::anthropic(), family: "test".to_string(), display_name: "Test Model".to_string(), @@ -223,12 +223,13 @@ mod tests { #[tokio::test] async fn run_model_test_deep_errors_when_model_lacks_tools() { let info = test_model_with(ModelFeatures { - tools: false, - vision: false, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: false, - sampling_params: true, + tools: false, + vision: false, + reasoning: true, + reasoning_effort: ReasoningEffortFeature::Levels, + prompt_cache: false, + cache_control_breakpoints: false, + sampling_params: true, }); let outcome = run_model_test(&info, ModelTestMode::Deep, empty_test_client()).await; @@ -243,12 +244,13 @@ mod tests { #[test] fn deep_test_omits_effort_for_reasoning_without_effort_controls() { let info = test_model_with(ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::None, - prompt_cache: true, - sampling_params: true, + tools: true, + vision: false, + reasoning: true, + reasoning_effort: ReasoningEffortFeature::None, + prompt_cache: true, + cache_control_breakpoints: false, + sampling_params: true, }); let params = build_deep_test_params(&info, empty_test_client()) @@ -260,12 +262,13 @@ mod tests { #[test] fn deep_test_uses_high_effort_when_supported() { let info = test_model_with(ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: true, - sampling_params: true, + tools: true, + vision: false, + reasoning: true, + reasoning_effort: ReasoningEffortFeature::Levels, + prompt_cache: true, + cache_control_breakpoints: false, + sampling_params: true, }); let params = build_deep_test_params(&info, empty_test_client()) diff --git a/lib/crates/fabro-llm/src/provider.rs b/lib/components/fabro-llm/src/provider.rs similarity index 100% rename from lib/crates/fabro-llm/src/provider.rs rename to lib/components/fabro-llm/src/provider.rs diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/components/fabro-llm/src/providers/anthropic.rs similarity index 96% rename from lib/crates/fabro-llm/src/providers/anthropic.rs rename to lib/components/fabro-llm/src/providers/anthropic.rs index d65494b34..83894e9a9 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/components/fabro-llm/src/providers/anthropic.rs @@ -7,7 +7,7 @@ use crate::codec::anthropic_messages::{AnthropicMessages, anthropic_option}; use crate::codec::{AnthropicVersion, Codec, CodecCtx, CodecParams, EncodedRequest}; use crate::error::Error; use crate::provider::{self, ProviderAdapter, StreamEventStream}; -use crate::providers::common::{self as common}; +use crate::providers::common::{self as common, CatalogRoute}; use crate::token_count::{InputTokenCount, InputTokenCountMethod}; use crate::transport::{self, HttpTransport, SseFraming}; use crate::types::{AdapterTimeout, Request, Response, StreamEvent}; @@ -118,7 +118,7 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model(self.catalog.as_deref(), &request.model), + model: self.catalog_model(&request.model), params, } } @@ -210,6 +210,16 @@ fn anthropic_thinking_type(provider_options: Option<&serde_json::Value>) -> Opti .and_then(serde_json::Value::as_str) } +impl common::CatalogRoute for Adapter { + fn catalog(&self) -> Option<&Catalog> { + self.catalog.as_deref() + } + + fn provider_name(&self) -> &str { + &self.provider_name + } +} + #[async_trait::async_trait] impl ProviderAdapter for Adapter { fn name(&self) -> &str { @@ -228,7 +238,7 @@ impl ProviderAdapter for Adapter { self.validate_request(request)?; let resolved = self.resolve_request(request).await; let codec = AnthropicMessages; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); let Some(encoded) = codec.encode_count_tokens(&ctx).transpose()? else { @@ -264,7 +274,7 @@ impl ProviderAdapter for Adapter { let resolved = self.resolve_request(request).await; let codec = AnthropicMessages; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); let encoded = codec.encode(&ctx, false)?; @@ -281,7 +291,7 @@ impl ProviderAdapter for Adapter { let route = self.route_config(); let resolved = self.resolve_request(request).await; let codec = AnthropicMessages; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); let encoded = codec.encode(&ctx, true)?; @@ -307,7 +317,7 @@ impl ProviderAdapter for Adapter { // Always-adaptive models reject manual enabled/disabled thinking // configs at the API, so fail them locally with a clear message // instead. - let model_info = common::catalog_model(self.catalog.as_deref(), &request.model); + let model_info = self.catalog_model(&request.model); if let Some(model) = model_info .filter(|m| m.features.reasoning_effort == ReasoningEffortFeature::AlwaysAdaptive) { diff --git a/lib/crates/fabro-llm/src/providers/bedrock/eventstream.rs b/lib/components/fabro-llm/src/providers/bedrock/eventstream.rs similarity index 100% rename from lib/crates/fabro-llm/src/providers/bedrock/eventstream.rs rename to lib/components/fabro-llm/src/providers/bedrock/eventstream.rs diff --git a/lib/crates/fabro-llm/src/providers/bedrock/mod.rs b/lib/components/fabro-llm/src/providers/bedrock/mod.rs similarity index 98% rename from lib/crates/fabro-llm/src/providers/bedrock/mod.rs rename to lib/components/fabro-llm/src/providers/bedrock/mod.rs index e2cffd49d..deca69bd0 100644 --- a/lib/crates/fabro-llm/src/providers/bedrock/mod.rs +++ b/lib/components/fabro-llm/src/providers/bedrock/mod.rs @@ -29,7 +29,7 @@ use crate::codec::bedrock_converse::BedrockConverse; use crate::codec::{Codec, CodecCtx, CodecParams, EncodedRequest, RawEvent, StreamDecoder}; use crate::error::Error; use crate::provider::{self, ProviderAdapter, StreamEventStream}; -use crate::providers::common::{self as common}; +use crate::providers::common::{self as common, CatalogRoute}; use crate::transport::{self, HttpTransport}; use crate::types::{AdapterTimeout, Request, Response, StreamEvent}; @@ -169,7 +169,7 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model(self.catalog.as_deref(), &request.model), + model: self.catalog_model(&request.model), params, } } @@ -228,6 +228,16 @@ impl Adapter { } } +impl common::CatalogRoute for Adapter { + fn catalog(&self) -> Option<&Catalog> { + self.catalog.as_deref() + } + + fn provider_name(&self) -> &str { + &self.provider_name + } +} + #[async_trait::async_trait] impl ProviderAdapter for Adapter { fn name(&self) -> &str { @@ -239,7 +249,7 @@ impl ProviderAdapter for Adapter { let resolved = self.resolve_request(request).await; let codec = BedrockConverse; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -253,7 +263,7 @@ impl ProviderAdapter for Adapter { let resolved = self.resolve_request(request).await; let codec = BedrockConverse; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); diff --git a/lib/crates/fabro-llm/src/providers/bedrock/sigv4.rs b/lib/components/fabro-llm/src/providers/bedrock/sigv4.rs similarity index 100% rename from lib/crates/fabro-llm/src/providers/bedrock/sigv4.rs rename to lib/components/fabro-llm/src/providers/bedrock/sigv4.rs diff --git a/lib/crates/fabro-llm/src/providers/common.rs b/lib/components/fabro-llm/src/providers/common.rs similarity index 78% rename from lib/crates/fabro-llm/src/providers/common.rs rename to lib/components/fabro-llm/src/providers/common.rs index 8871ff3f7..6eff3b727 100644 --- a/lib/crates/fabro-llm/src/providers/common.rs +++ b/lib/components/fabro-llm/src/providers/common.rs @@ -1,21 +1,42 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use fabro_model::{Catalog, Model}; +use fabro_model::{Catalog, Model, ProviderId}; use fabro_static::EnvVars; use tokio::fs; #[must_use] -pub fn catalog_model<'a>(catalog: Option<&'a Catalog>, model: &str) -> Option<&'a Model> { - catalog.and_then(|catalog| catalog.get(model)) +pub fn catalog_model<'a>( + catalog: Option<&'a Catalog>, + provider: &str, + model: &str, +) -> Option<&'a Model> { + catalog.and_then(|catalog| catalog.get_on_provider(&ProviderId::new(provider), model)) } #[must_use] -pub fn api_model_id(catalog: Option<&Catalog>, model: &str) -> String { +pub fn api_model_id(catalog: Option<&Catalog>, provider: &str, model: &str) -> String { catalog - .and_then(|catalog| catalog.model_settings(model)) + .and_then(|catalog| catalog.model_settings_on_provider(&ProviderId::new(provider), model)) .map_or_else(|| model.to_string(), |settings| settings.api_id.clone()) } +/// Adapters that route models through an optional catalog scoped to one +/// provider name. +pub trait CatalogRoute { + fn catalog(&self) -> Option<&Catalog>; + fn provider_name(&self) -> &str; + + /// Catalog offering for a canonical ID or alias on this provider. + fn catalog_model(&self, model: &str) -> Option<&Model> { + catalog_model(self.catalog(), self.provider_name(), model) + } + + /// Identifier sent to the provider API for a model. + fn api_model_id(&self, model: &str) -> String { + api_model_id(self.catalog(), self.provider_name(), model) + } +} + /// Check if a URL string looks like a local file path. #[must_use] pub fn is_file_path(url: &str) -> bool { diff --git a/lib/crates/fabro-llm/src/providers/fabro_server.rs b/lib/components/fabro-llm/src/providers/fabro_server.rs similarity index 100% rename from lib/crates/fabro-llm/src/providers/fabro_server.rs rename to lib/components/fabro-llm/src/providers/fabro_server.rs diff --git a/lib/crates/fabro-llm/src/providers/gemini.rs b/lib/components/fabro-llm/src/providers/gemini.rs similarity index 94% rename from lib/crates/fabro-llm/src/providers/gemini.rs rename to lib/components/fabro-llm/src/providers/gemini.rs index 2b1f9ee4b..e2a33337b 100644 --- a/lib/crates/fabro-llm/src/providers/gemini.rs +++ b/lib/components/fabro-llm/src/providers/gemini.rs @@ -9,7 +9,7 @@ use crate::error::Error; use crate::provider::{ ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, }; -use crate::providers::common::{self as common}; +use crate::providers::common::{self as common, CatalogRoute}; use crate::token_count::{InputTokenCount, InputTokenCountMethod}; use crate::transport::{self, HttpTransport, SseFraming}; use crate::types::{AdapterTimeout, Request, Response}; @@ -102,7 +102,7 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model(self.catalog.as_deref(), &request.model), + model: self.catalog_model(&request.model), params, } } @@ -125,6 +125,16 @@ impl Adapter { } } +impl common::CatalogRoute for Adapter { + fn catalog(&self) -> Option<&Catalog> { + self.catalog.as_deref() + } + + fn provider_name(&self) -> &str { + &self.provider_name + } +} + #[async_trait::async_trait] impl ProviderAdapter for Adapter { fn name(&self) -> &str { @@ -147,7 +157,7 @@ impl ProviderAdapter for Adapter { let resolved = self.resolve_request(request).await; let codec = GeminiGenerate; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -177,7 +187,7 @@ impl ProviderAdapter for Adapter { let resolved = self.resolve_request(request).await; let codec = GeminiGenerate; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -194,7 +204,7 @@ impl ProviderAdapter for Adapter { let resolved = self.resolve_request(request).await; let codec = GeminiGenerate; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); diff --git a/lib/crates/fabro-llm/src/providers/mod.rs b/lib/components/fabro-llm/src/providers/mod.rs similarity index 100% rename from lib/crates/fabro-llm/src/providers/mod.rs rename to lib/components/fabro-llm/src/providers/mod.rs diff --git a/lib/crates/fabro-llm/src/providers/openai.rs b/lib/components/fabro-llm/src/providers/openai.rs similarity index 97% rename from lib/crates/fabro-llm/src/providers/openai.rs rename to lib/components/fabro-llm/src/providers/openai.rs index ad25d01a9..56eb76c3d 100644 --- a/lib/crates/fabro-llm/src/providers/openai.rs +++ b/lib/components/fabro-llm/src/providers/openai.rs @@ -9,7 +9,7 @@ use crate::error::Error; use crate::provider::{ ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, }; -use crate::providers::common::{self as common}; +use crate::providers::common::{self as common, CatalogRoute}; use crate::token_count::{InputTokenCount, InputTokenCountMethod}; use crate::transport::{self, HttpTransport, SseFraming}; use crate::types::{AdapterTimeout, Request, Response, StreamEvent}; @@ -131,7 +131,7 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model(self.catalog.as_deref(), &request.model), + model: self.catalog_model(&request.model), params, } } @@ -192,6 +192,16 @@ impl Adapter { } } +impl common::CatalogRoute for Adapter { + fn catalog(&self) -> Option<&Catalog> { + self.catalog.as_deref() + } + + fn provider_name(&self) -> &str { + &self.provider_name + } +} + #[async_trait::async_trait] impl ProviderAdapter for Adapter { fn name(&self) -> &str { @@ -214,7 +224,7 @@ impl ProviderAdapter for Adapter { let resolved = self.resolve_request(request).await; let codec = OpenAiResponses; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let params = self.codec_params(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -250,7 +260,7 @@ impl ProviderAdapter for Adapter { let resolved = self.resolve_request(request).await; let codec = OpenAiResponses; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let params = self.codec_params(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -267,7 +277,7 @@ impl ProviderAdapter for Adapter { let resolved = self.resolve_request(request).await; let codec = OpenAiResponses; - let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let deployment_id = self.api_model_id(&resolved.model); let params = self.codec_params(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); diff --git a/lib/crates/fabro-llm/src/providers/openai_compatible.rs b/lib/components/fabro-llm/src/providers/openai_compatible.rs similarity index 94% rename from lib/crates/fabro-llm/src/providers/openai_compatible.rs rename to lib/components/fabro-llm/src/providers/openai_compatible.rs index 9169287b7..b46444636 100644 --- a/lib/crates/fabro-llm/src/providers/openai_compatible.rs +++ b/lib/components/fabro-llm/src/providers/openai_compatible.rs @@ -8,7 +8,7 @@ use crate::error::Error; use crate::provider::{ ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, }; -use crate::providers::common::{self as common}; +use crate::providers::common::{self as common, CatalogRoute}; use crate::transport::{self, HttpTransport, SseFraming}; use crate::types::{AdapterTimeout, Request, Response}; @@ -88,7 +88,7 @@ impl Adapter { /// Resolve the wire model id (catalog `api_id`, falling back to the /// requested model). fn deployment_id(&self, request: &Request) -> String { - common::api_model_id(self.catalog.as_deref(), &request.model) + self.api_model_id(&request.model) } /// Build the borrowed codec context. `deployment_id` and `params` are @@ -103,7 +103,7 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model(self.catalog.as_deref(), &request.model), + model: self.catalog_model(&request.model), params, } } @@ -127,6 +127,16 @@ impl Adapter { } } +impl common::CatalogRoute for Adapter { + fn catalog(&self) -> Option<&Catalog> { + self.catalog.as_deref() + } + + fn provider_name(&self) -> &str { + &self.provider_name + } +} + #[async_trait::async_trait] impl ProviderAdapter for Adapter { fn name(&self) -> &str { diff --git a/lib/crates/fabro-llm/src/retry.rs b/lib/components/fabro-llm/src/retry.rs similarity index 100% rename from lib/crates/fabro-llm/src/retry.rs rename to lib/components/fabro-llm/src/retry.rs diff --git a/lib/crates/fabro-llm/src/token_count.rs b/lib/components/fabro-llm/src/token_count.rs similarity index 100% rename from lib/crates/fabro-llm/src/token_count.rs rename to lib/components/fabro-llm/src/token_count.rs diff --git a/lib/crates/fabro-llm/src/tools.rs b/lib/components/fabro-llm/src/tools.rs similarity index 100% rename from lib/crates/fabro-llm/src/tools.rs rename to lib/components/fabro-llm/src/tools.rs diff --git a/lib/crates/fabro-llm/src/transport.rs b/lib/components/fabro-llm/src/transport.rs similarity index 100% rename from lib/crates/fabro-llm/src/transport.rs rename to lib/components/fabro-llm/src/transport.rs diff --git a/lib/crates/fabro-llm/src/types.rs b/lib/components/fabro-llm/src/types.rs similarity index 100% rename from lib/crates/fabro-llm/src/types.rs rename to lib/components/fabro-llm/src/types.rs diff --git a/lib/crates/fabro-llm/tests/integration.rs b/lib/components/fabro-llm/tests/integration.rs similarity index 95% rename from lib/crates/fabro-llm/tests/integration.rs rename to lib/components/fabro-llm/tests/integration.rs index a7a421afe..25d7e5f00 100644 --- a/lib/crates/fabro-llm/tests/integration.rs +++ b/lib/components/fabro-llm/tests/integration.rs @@ -326,7 +326,7 @@ async fn poolside_laguna_xs_deep_tool_round_trip() { .expect("Poolside client should build from the catalog"), ); let model = catalog - .get("laguna-xs-2.1") + .get_on_provider(&ProviderId::new("poolside"), "laguna-xs-2.1") .expect("direct Poolside Laguna XS should be present"); let outcome = run_model_test(model, ModelTestMode::Deep, client).await; @@ -572,7 +572,7 @@ async fn openrouter_kimi_k3_deep_tool_round_trip() { .expect("OpenRouter client should build from the catalog"), ); let model = catalog - .get("moonshotai/kimi-k3") + .get_on_provider(&ProviderId::new("openrouter"), "kimi-k3") .expect("OpenRouter Kimi K3 should be present"); let outcome = run_model_test(model, ModelTestMode::Deep, client).await; @@ -683,3 +683,26 @@ async fn gemini_multi_turn_cache() { let adapter = GeminiAdapter::new(api_key); run_multi_turn_cache_test(&adapter, "gemini-2.5-flash", 0.5, Some(0.0)).await; } + +/// Prompt caching for Claude routed through OpenRouter: the catalog row opts +/// into explicit `cache_control` breakpoints, and OpenRouter must forward +/// them to Anthropic for cache reads to appear. Guards the end-to-end +/// passthrough the wire tests can't see. +#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] +async fn openrouter_claude_multi_turn_cache() { + let api_key = + std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); + let overrides: LlmCatalogSettings = toml::from_str( + r" +[providers.openrouter] +enabled = true +", + ) + .expect("OpenRouter catalog override should parse"); + let catalog = Catalog::from_builtin_with_overrides(&overrides) + .expect("enabled OpenRouter catalog should build"); + let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") + .with_name("openrouter") + .with_catalog(Arc::new(catalog)); + run_multi_turn_cache_test(&adapter, "claude-haiku-4-5", 0.5, Some(0.0)).await; +} diff --git a/lib/crates/fabro-llm/tests/it/main.rs b/lib/components/fabro-llm/tests/it/main.rs similarity index 100% rename from lib/crates/fabro-llm/tests/it/main.rs rename to lib/components/fabro-llm/tests/it/main.rs diff --git a/lib/crates/fabro-llm/tests/it/support.rs b/lib/components/fabro-llm/tests/it/support.rs similarity index 100% rename from lib/crates/fabro-llm/tests/it/support.rs rename to lib/components/fabro-llm/tests/it/support.rs diff --git a/lib/crates/fabro-llm/tests/it/wire/anthropic.rs b/lib/components/fabro-llm/tests/it/wire/anthropic.rs similarity index 100% rename from lib/crates/fabro-llm/tests/it/wire/anthropic.rs rename to lib/components/fabro-llm/tests/it/wire/anthropic.rs diff --git a/lib/crates/fabro-llm/tests/it/wire/gemini.rs b/lib/components/fabro-llm/tests/it/wire/gemini.rs similarity index 100% rename from lib/crates/fabro-llm/tests/it/wire/gemini.rs rename to lib/components/fabro-llm/tests/it/wire/gemini.rs diff --git a/lib/crates/fabro-llm/tests/it/wire/mod.rs b/lib/components/fabro-llm/tests/it/wire/mod.rs similarity index 100% rename from lib/crates/fabro-llm/tests/it/wire/mod.rs rename to lib/components/fabro-llm/tests/it/wire/mod.rs diff --git a/lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs b/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs similarity index 80% rename from lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs rename to lib/components/fabro-llm/tests/it/wire/openai_compatible.rs index b26416396..c8facd920 100644 --- a/lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +++ b/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs @@ -10,7 +10,8 @@ use fabro_llm::types::{ Message, ReasoningEffort, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition, }; -use fabro_model::Catalog; +use fabro_model::catalog::LlmCatalogSettings; +use fabro_model::{Catalog, ProviderId}; use httpmock::prelude::*; use crate::support::{ @@ -256,6 +257,134 @@ async fn encode_kimi_k3_uses_catalog_reasoning_and_sampling_controls() { assert!(capture.body.get("top_p").is_none()); } +/// Counts JSON objects anywhere in `value` carrying a `cache_control` key. +fn count_cache_control_breakpoints(value: &serde_json::Value) -> usize { + match value { + serde_json::Value::Object(map) => { + usize::from(map.contains_key("cache_control")) + + map + .values() + .map(count_cache_control_breakpoints) + .sum::() + } + serde_json::Value::Array(items) => items.iter().map(count_cache_control_breakpoints).sum(), + _ => 0, + } +} + +/// Builtin catalog with the opt-in OpenRouter provider enabled. +fn openrouter_catalog() -> Arc { + let overrides: LlmCatalogSettings = toml::from_str("[providers.openrouter]\nenabled = true\n") + .expect("override TOML should parse"); + Arc::new( + Catalog::from_builtin_with_overrides(&overrides) + .expect("catalog with OpenRouter enabled should build"), + ) +} + +/// System + tools + two user turns against an OpenRouter model. +fn openrouter_multi_turn(model: &str) -> Request { + Request { + messages: vec![ + Message::system("You are a careful reviewer."), + Message::user("Review this."), + Message::assistant("Looking now."), + Message::user("Focus on the tests."), + ], + ..corpus_tools(model, None) + } +} + +/// OpenRouter serves Claude through this adapter, and Anthropic prompt +/// caching is opt-in per request: OpenRouter only forwards a cache write when +/// the body carries explicit ephemeral `cache_control` breakpoints (OpenAI +/// models cache implicitly; Anthropic models never do). The catalog row +/// declares `cache_control_breakpoints`, so the encoded request must mark the +/// cacheable prefix — otherwise every turn bills at the full uncached input +/// rate. +#[tokio::test] +async fn encode_openrouter_claude_marks_prompt_cache_breakpoints() { + let catalog = openrouter_catalog(); + let model = catalog + .get_on_provider(&ProviderId::new("openrouter"), "claude-fable-5") + .expect("OpenRouter Claude row should exist in the built-in catalog"); + assert!(model.features.prompt_cache); + assert!(model.features.cache_control_breakpoints); + + let request = openrouter_multi_turn("claude-fable-5"); + let capture = encode_capture_with(&request, move |adapter| { + adapter.with_name("openrouter").with_catalog(catalog) + }) + .await; + + assert_eq!(capture.body["model"], "anthropic/claude-fable-5"); + let messages = &capture.body["messages"]; + // The system prompt converts to parts form carrying a breakpoint; it + // covers the tool definitions too (tools precede system upstream). + assert_eq!(messages[0]["content"][0]["type"], "text"); + assert_eq!( + messages[0]["content"][0]["text"], + "You are a careful reviewer." + ); + assert_eq!( + messages[0]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + // The second-to-last user turn carries the conversation breakpoint... + assert_eq!(messages[1]["content"][0]["text"], "Review this."); + assert_eq!( + messages[1]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + // ...and the newest turn stays in plain-string form. + assert_eq!(messages[3]["content"], "Focus on the tests."); + assert_eq!(count_cache_control_breakpoints(&capture.body), 2); +} + +/// Models with implicit (server-side) caching must NOT get breakpoints even +/// though they support prompt caching — the annotation is an Anthropic-ism +/// the catalog row has to opt into. +#[tokio::test] +async fn encode_openrouter_implicit_cache_model_stays_plain() { + let catalog = openrouter_catalog(); + let model = catalog + .get_on_provider(&ProviderId::new("openrouter"), "gpt-5.6-luna") + .expect("OpenRouter GPT row should exist in the built-in catalog"); + assert!(model.features.prompt_cache); + assert!(!model.features.cache_control_breakpoints); + + let request = openrouter_multi_turn("gpt-5.6-luna"); + let capture = encode_capture_with(&request, move |adapter| { + adapter.with_name("openrouter").with_catalog(catalog) + }) + .await; + + assert_eq!(count_cache_control_breakpoints(&capture.body), 0); + assert_eq!( + capture.body["messages"][0]["content"], + "You are a careful reviewer." + ); +} + +/// `provider_options.openrouter.auto_cache = false` disables the breakpoints, +/// and the control key is consumed rather than merged into the body. +#[tokio::test] +async fn encode_openrouter_claude_auto_cache_opt_out() { + let request = Request { + provider_options: Some(serde_json::json!({"openrouter": {"auto_cache": false}})), + ..openrouter_multi_turn("claude-fable-5") + }; + let capture = encode_capture_with(&request, move |adapter| { + adapter + .with_name("openrouter") + .with_catalog(openrouter_catalog()) + }) + .await; + + assert_eq!(count_cache_control_breakpoints(&capture.body), 0); + assert!(capture.body.get("auto_cache").is_none()); +} + /// The provider_options namespace key is the runtime adapter NAME, not a /// static "openai_compatible" key (pinned in-module by /// `provider_options_uses_adapter_name`; this pins it from outside). diff --git a/lib/crates/fabro-llm/tests/it/wire/openai_responses.rs b/lib/components/fabro-llm/tests/it/wire/openai_responses.rs similarity index 100% rename from lib/crates/fabro-llm/tests/it/wire/openai_responses.rs rename to lib/components/fabro-llm/tests/it/wire/openai_responses.rs diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap index 566a6a703..c3b630136 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap similarity index 76% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap index a51d0f135..9fb304d73 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap index 187e2db49..6bb0e2544 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap similarity index 91% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap index 425a60a06..4620d7897 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap index c03f511a7..b10bff658 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap index ca01b2a8d..bf89303f3 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap index b450339fc..2c1ec7d19 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap similarity index 86% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap index 69a05af0e..54793a2b6 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap similarity index 82% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap index 498b4748b..c2c0acfb3 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap similarity index 91% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap index 443e1e221..5fda9d667 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap index bc576585a..6042822b8 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap similarity index 96% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap index f29eca145..5a6979bd6 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap similarity index 82% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap index 1475f5820..5c5107443 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap similarity index 85% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap index 59e4b54ef..45d4779ed 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap similarity index 84% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap index fe60412fb..f505a20d9 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap similarity index 91% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap index e757af0a9..b8c0aa6a1 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap similarity index 86% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap index b50a93df6..c4abd0eda 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap similarity index 92% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap index 5fedc6ff1..1619de37c 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap index d181d4796..552c709ce 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap index 5d7d5214b..868f115d1 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap similarity index 81% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap index 4fbbbaa5f..b9119a960 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap index b114a2048..35eb0a281 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap similarity index 96% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap index f744b1d64..50cce34a4 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap similarity index 91% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap index f4abc02be..438fbc222 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap similarity index 76% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap index d2b78a3a2..6c8cc6f1a 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap index 5456021f0..04d15c0b8 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap similarity index 82% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap index 8b641b028..508c9c998 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap index a7987f9e5..68e599c83 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap similarity index 96% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap index 9eaa938d8..18c46f075 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap similarity index 81% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap index a12eec3d6..d2f4d0df0 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap index 1d0a970e5..f4afb357d 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap index 63678178e..50f5a4167 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs +source: lib/components/fabro-llm/tests/it/wire/anthropic.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap similarity index 96% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap index 4056e3979..2d75a657c 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap similarity index 71% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap index 44df522ff..3f983d663 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap index 2f0106cc6..121057409 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap similarity index 96% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap index b0aa96702..751e5782b 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap new file mode 100644 index 000000000..a8a887691 --- /dev/null +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap @@ -0,0 +1,5 @@ +--- +source: lib/components/fabro-llm/tests/it/wire/gemini.rs +expression: rendered +--- +"length" diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap new file mode 100644 index 000000000..627e69083 --- /dev/null +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap @@ -0,0 +1,5 @@ +--- +source: lib/components/fabro-llm/tests/it/wire/gemini.rs +expression: rendered +--- +"content_filter" diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap index 4a45d6508..b20aec438 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap index eacc7a47f..d32b27d72 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap similarity index 89% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap index d3f6c4a7f..03246da8b 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap similarity index 86% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap index b4605fdbb..a27b54528 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap similarity index 91% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap index 130c517ee..2db4f67e3 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap similarity index 92% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap index af95759cb..a8a547cd6 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap similarity index 80% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap index 105e76111..6e2744c76 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap similarity index 87% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap index e6b616613..71baf9f7a 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap similarity index 85% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap index 83f70e81f..2e71f5623 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap similarity index 87% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap index d16ceda4c..e04165dfd 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap similarity index 90% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap index 7a6a39742..e8fc16720 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap similarity index 88% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap index 1e9350fcd..87e6756df 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap similarity index 91% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap index c0cdbd040..20f0d4724 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap index 186a9afce..9682d4bd0 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap index d31af7c7d..ebab86778 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap index 7bb3b0bfa..9e7fe12ce 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap index 7832bc141..0fee7b81d 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap similarity index 97% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap index d0fdee6ce..245c3cd85 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap similarity index 92% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap index dcf7ef4e2..c3f9bdee7 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap index de753baf2..5ceb7e1d0 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap similarity index 96% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap index 7ff680eb5..22807bad4 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap index f3db67d70..f240b1c76 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap similarity index 92% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap index cb853aaf9..90edf2a99 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap similarity index 96% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap index 88f094ab8..bf9e067fd 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap index e6748c07e..f5e0bd096 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap index c6d3c6a48..2a445751f 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs +source: lib/components/fabro-llm/tests/it/wire/gemini.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap index ff075964d..bd1327b8f 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap index 900b50942..0d52c2d73 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap index f473db75a..cff1eaf40 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap index c212ac7c2..8e2239f87 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap similarity index 75% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap index f647c24c0..101179520 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap similarity index 78% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap index 7b3833469..485ae8446 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap similarity index 78% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap index fcc58e5cf..10995e1e8 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap similarity index 85% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap index 6e4c35fa0..cbb42e14d 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap similarity index 73% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap index 2b0718398..75a4a2634 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap similarity index 69% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap index bb8c27f58..f3e189bfe 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap similarity index 75% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap index 84f716422..24763c82f 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap similarity index 87% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap index 13961444e..7e35babc2 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap similarity index 76% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap index 6a345a9bf..74e26fcd1 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap similarity index 84% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap index 210eaa446..86c83a213 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap similarity index 92% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap index 7b9bdab82..ea913ebfb 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap index 5da2b548a..442e8a42d 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap similarity index 92% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap index a7ace0225..919c82db5 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap similarity index 92% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap index d77d00bef..665fa3c5a 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap index 43e81924f..d601c24dc 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap similarity index 78% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap index fcc58e5cf..10995e1e8 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap index 29f670850..ee27dbea7 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap index 0178193a9..0bb05a55b 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap similarity index 72% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap index 502647610..fa711fab9 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap similarity index 96% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap index fa79eefb0..fdce5d082 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap index da24b9b97..a560e0d83 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- [ diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap new file mode 100644 index 000000000..082fdf19c --- /dev/null +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap @@ -0,0 +1,5 @@ +--- +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs +expression: rendered +--- +[] diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap index dc7dcfe6a..85127a104 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap similarity index 92% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap index 078f9bb3b..eb24f04e8 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap index a732b5d44..675c550b4 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap index 8524cc347..6491674c9 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap similarity index 74% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap index 5241566c3..3fb9cbd0b 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap index bb1e06744..fa27da4b8 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap index 48a5b2b8f..e4597626a 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap index 4c65c6906..98813798b 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap similarity index 87% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap index 5e12b469e..52d5331bf 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap similarity index 88% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap index 93b5f16a3..5fd2a64fd 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap similarity index 91% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap index 655b2e204..f57e05f8b 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap index 990bf91a8..5c98c82d4 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap similarity index 90% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap index 3d74e1cdd..172b57110 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap similarity index 91% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap index 3e049c7fe..473ce2c5d 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap similarity index 92% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap index acfb705af..d672689b1 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap similarity index 83% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap index cd69aff6a..c2f03af2c 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap similarity index 85% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap index c9efcd60f..57d01b2f9 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap similarity index 85% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap index f4ccc269d..05ffc5a7d 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap similarity index 90% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap index 3fa0b83e2..1fe952e6c 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap similarity index 87% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap index 3634e5fb3..783c7ab18 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap similarity index 91% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap index e4b3593b0..a232e2e6a 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap index af2dfe6a9..b4b6aef6c 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap index cc00b7891..cb462f295 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap index fda379759..9c11e0609 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap similarity index 93% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap index b9e9d82e0..3b9ad1745 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap similarity index 96% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap index 97c1654d9..f542622fb 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap similarity index 90% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap index 9b67b8abc..b85c3da20 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap similarity index 73% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap index 68057a6b7..191a90c06 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap index b09f7ef94..e274eb534 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap index 62cc38280..47f067f13 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap index 68b18e7c2..cde7ca175 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap similarity index 84% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap index c5f2d85eb..1dbf93c1a 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap similarity index 97% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap index 66a9c3d89..a4cc2e686 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- [ diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap similarity index 95% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap index d5d935100..01d4e4fd4 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap similarity index 94% rename from lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap rename to lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap index 5c7aca225..165c53cc3 100644 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap @@ -1,5 +1,5 @@ --- -source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs expression: rendered --- { diff --git a/lib/crates/fabro-manifest/Cargo.toml b/lib/components/fabro-manifest/Cargo.toml similarity index 68% rename from lib/crates/fabro-manifest/Cargo.toml rename to lib/components/fabro-manifest/Cargo.toml index 417dc9af0..0cc8a695a 100644 --- a/lib/crates/fabro-manifest/Cargo.toml +++ b/lib/components/fabro-manifest/Cargo.toml @@ -14,12 +14,12 @@ workspace = true [dependencies] anyhow.workspace = true -fabro-api = { path = "../fabro-api" } -fabro-config = { path = "../fabro-config" } +fabro-api = { path = "../../foundation/fabro-api" } +fabro-config = { path = "../../foundation/fabro-config" } fabro-github = { path = "../fabro-github" } fabro-graphviz = { path = "../fabro-graphviz" } -fabro-template = { path = "../fabro-template" } -fabro-types = { path = "../fabro-types" } +fabro-template = { path = "../../foundation/fabro-template" } +fabro-types = { path = "../../foundation/fabro-types" } fabro-workflow = { path = "../fabro-workflow" } git2.workspace = true toml.workspace = true diff --git a/lib/crates/fabro-manifest/src/lib.rs b/lib/components/fabro-manifest/src/lib.rs similarity index 95% rename from lib/crates/fabro-manifest/src/lib.rs rename to lib/components/fabro-manifest/src/lib.rs index 6acac3de9..f2f1b066b 100644 --- a/lib/crates/fabro-manifest/src/lib.rs +++ b/lib/components/fabro-manifest/src/lib.rs @@ -386,16 +386,44 @@ fn collect_workflow_files( for node in graph.nodes.values() { if let Some(prompt_ref) = node.attrs.get("prompt").and_then(AttrValue::as_str) { - if prompt_ref.starts_with('@') { - let bundled = collect_bundled_file( + if !prompt_ref.starts_with('@') { + collect_template_include_files( files, - workflow_base_dir, context.cwd, - prompt_ref.trim_start_matches('@'), - types::ManifestFileRefType::FileInline, - manifest_attr_reference_kind(AttributeScope::Node, "prompt", prompt_ref)?, - Some(workflow.dot_path.clone()), + TemplateSource::new( + workflow.dot_path.clone(), + workflow_template_root.clone(), + prompt_ref.to_owned(), + ), + Some(&workflow.dot_path), + &context.inputs, )?; + } + } + + for (name, value) in &node.attrs { + let Some(value) = value.as_str() else { + continue; + }; + let Some(ReferenceKind::FileInline) = + reference_kind_for_attribute(AttributeScope::Node, name, value) + else { + continue; + }; + let reference = value.strip_prefix('@').ok_or_else(|| { + anyhow!("file inline reference must start with '@': {name}={value}") + })?; + let bundled = collect_bundled_file( + files, + workflow_base_dir, + context.cwd, + reference, + types::ManifestFileRefType::FileInline, + ReferenceKind::FileInline, + Some(workflow.dot_path.clone()), + )?; + + if name == "prompt" { let source = std::fs::read_to_string(&bundled.absolute_path).with_context(|| { format!("Failed to read {}", bundled.absolute_path.display()) @@ -409,18 +437,6 @@ fn collect_workflow_files( Some(&bundled.path), &context.inputs, )?; - } else { - collect_template_include_files( - files, - context.cwd, - TemplateSource::new( - workflow.dot_path.clone(), - workflow_template_root.clone(), - prompt_ref.to_owned(), - ), - Some(&workflow.dot_path), - &context.inputs, - )?; } } @@ -897,6 +913,52 @@ mod tests { )])) } + fn assert_manifest_bundles_output_schema_file(node_attributes: &str) { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path(); + let workflow_dir = project.join(".fabro/workflows/demo"); + let schema_source = r#"{"type":"object","required":["ok"]}"#; + std::fs::create_dir_all(workflow_dir.join("schemas")).unwrap(); + std::fs::write(project.join(".fabro/project.toml"), "_version = 1\n").unwrap(); + std::fs::write( + workflow_dir.join("workflow.toml"), + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n", + ) + .unwrap(); + std::fs::write( + workflow_dir.join("workflow.fabro"), + format!( + r#"digraph Demo {{ + start [shape=Mdiamond] + output [{node_attributes}, output_schema="@schemas/output.schema.json"] + exit [shape=Msquare] + start -> output -> exit + }}"# + ), + ) + .unwrap(); + std::fs::write( + workflow_dir.join("schemas/output.schema.json"), + schema_source, + ) + .unwrap(); + + let built = build_run_manifest(ManifestBuildInput { + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + environment_defaults: test_environment_defaults(), + ..Default::default() + }) + .unwrap(); + + let root = &built.manifest.workflows[".fabro/workflows/demo/workflow.fabro"]; + let schema = root + .files + .get(".fabro/workflows/demo/schemas/output.schema.json") + .expect("output_schema file should be bundled"); + assert_eq!(schema.content, schema_source); + } + #[test] fn build_run_overrides_sets_common_cli_and_mcp_layers() { let overrides = build_run_overrides(RunOverrideInput { @@ -966,6 +1028,17 @@ mod tests { ); } + // Regression coverage for https://github.com/fabro-sh/fabro/issues/476. + #[test] + fn build_manifest_bundles_agent_output_schema_file() { + assert_manifest_bundles_output_schema_file(r#"type="agent", prompt="Return JSON""#); + } + + #[test] + fn build_manifest_bundles_command_output_schema_file() { + assert_manifest_bundles_output_schema_file(r#"type="command", script="echo""#); + } + #[test] fn build_manifest_bundles_imports_prompts_and_children() { let temp = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-mcp-store/Cargo.toml b/lib/components/fabro-mcp-store/Cargo.toml similarity index 84% rename from lib/crates/fabro-mcp-store/Cargo.toml rename to lib/components/fabro-mcp-store/Cargo.toml index 0172d9bae..7fd46b3f0 100644 --- a/lib/crates/fabro-mcp-store/Cargo.toml +++ b/lib/components/fabro-mcp-store/Cargo.toml @@ -14,8 +14,8 @@ workspace = true [dependencies] chrono.workspace = true -fabro-db = { path = "../fabro-db" } -fabro-types = { path = "../fabro-types" } +fabro-db = { path = "../../foundation/fabro-db" } +fabro-types = { path = "../../foundation/fabro-types" } serde.workspace = true serde_json.workspace = true sqlx.workspace = true diff --git a/lib/crates/fabro-mcp-store/src/error.rs b/lib/components/fabro-mcp-store/src/error.rs similarity index 100% rename from lib/crates/fabro-mcp-store/src/error.rs rename to lib/components/fabro-mcp-store/src/error.rs diff --git a/lib/crates/fabro-mcp-store/src/lib.rs b/lib/components/fabro-mcp-store/src/lib.rs similarity index 100% rename from lib/crates/fabro-mcp-store/src/lib.rs rename to lib/components/fabro-mcp-store/src/lib.rs diff --git a/lib/crates/fabro-mcp-store/src/model.rs b/lib/components/fabro-mcp-store/src/model.rs similarity index 100% rename from lib/crates/fabro-mcp-store/src/model.rs rename to lib/components/fabro-mcp-store/src/model.rs diff --git a/lib/crates/fabro-mcp-store/src/store.rs b/lib/components/fabro-mcp-store/src/store.rs similarity index 100% rename from lib/crates/fabro-mcp-store/src/store.rs rename to lib/components/fabro-mcp-store/src/store.rs diff --git a/lib/crates/fabro-mcp-store/tests/store.rs b/lib/components/fabro-mcp-store/tests/store.rs similarity index 100% rename from lib/crates/fabro-mcp-store/tests/store.rs rename to lib/components/fabro-mcp-store/tests/store.rs diff --git a/lib/crates/fabro-mcp/Cargo.toml b/lib/components/fabro-mcp/Cargo.toml similarity index 87% rename from lib/crates/fabro-mcp/Cargo.toml rename to lib/components/fabro-mcp/Cargo.toml index 12f0b551c..78db96688 100644 --- a/lib/crates/fabro-mcp/Cargo.toml +++ b/lib/components/fabro-mcp/Cargo.toml @@ -14,9 +14,9 @@ workspace = true [dependencies] anyhow.workspace = true -fabro-config = { path = "../fabro-config" } +fabro-config = { path = "../../foundation/fabro-config" } fabro-http.workspace = true -fabro-types = { path = "../fabro-types" } +fabro-types = { path = "../../foundation/fabro-types" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/lib/crates/fabro-mcp/src/client.rs b/lib/components/fabro-mcp/src/client.rs similarity index 100% rename from lib/crates/fabro-mcp/src/client.rs rename to lib/components/fabro-mcp/src/client.rs diff --git a/lib/crates/fabro-mcp/src/client_handler.rs b/lib/components/fabro-mcp/src/client_handler.rs similarity index 100% rename from lib/crates/fabro-mcp/src/client_handler.rs rename to lib/components/fabro-mcp/src/client_handler.rs diff --git a/lib/crates/fabro-mcp/src/config.rs b/lib/components/fabro-mcp/src/config.rs similarity index 100% rename from lib/crates/fabro-mcp/src/config.rs rename to lib/components/fabro-mcp/src/config.rs diff --git a/lib/crates/fabro-mcp/src/connection_manager.rs b/lib/components/fabro-mcp/src/connection_manager.rs similarity index 100% rename from lib/crates/fabro-mcp/src/connection_manager.rs rename to lib/components/fabro-mcp/src/connection_manager.rs diff --git a/lib/crates/fabro-mcp/src/http_transport.rs b/lib/components/fabro-mcp/src/http_transport.rs similarity index 100% rename from lib/crates/fabro-mcp/src/http_transport.rs rename to lib/components/fabro-mcp/src/http_transport.rs diff --git a/lib/crates/fabro-mcp/src/lib.rs b/lib/components/fabro-mcp/src/lib.rs similarity index 100% rename from lib/crates/fabro-mcp/src/lib.rs rename to lib/components/fabro-mcp/src/lib.rs diff --git a/lib/crates/fabro-mcp/src/sse_client.rs b/lib/components/fabro-mcp/src/sse_client.rs similarity index 100% rename from lib/crates/fabro-mcp/src/sse_client.rs rename to lib/components/fabro-mcp/src/sse_client.rs diff --git a/lib/crates/fabro-mcp/tests/stdio_integration.rs b/lib/components/fabro-mcp/tests/stdio_integration.rs similarity index 100% rename from lib/crates/fabro-mcp/tests/stdio_integration.rs rename to lib/components/fabro-mcp/tests/stdio_integration.rs diff --git a/lib/crates/fabro-mcp/tests/test_mcp_server.py b/lib/components/fabro-mcp/tests/test_mcp_server.py similarity index 100% rename from lib/crates/fabro-mcp/tests/test_mcp_server.py rename to lib/components/fabro-mcp/tests/test_mcp_server.py diff --git a/lib/crates/fabro-sandbox/Cargo.toml b/lib/components/fabro-sandbox/Cargo.toml similarity index 89% rename from lib/crates/fabro-sandbox/Cargo.toml rename to lib/components/fabro-sandbox/Cargo.toml index dd90d2f0e..c2346d5ef 100644 --- a/lib/crates/fabro-sandbox/Cargo.toml +++ b/lib/components/fabro-sandbox/Cargo.toml @@ -34,9 +34,9 @@ hmac.workspace = true sha2.workspace = true hex.workspace = true uuid.workspace = true -fabro-proc = { path = "../fabro-proc" } +fabro-proc = { path = "../../foundation/fabro-proc" } fabro-static.workspace = true -fabro-util = { path = "../fabro-util" } +fabro-util = { path = "../../foundation/fabro-util" } fabro-redact.workspace = true # local @@ -49,9 +49,9 @@ bollard = { workspace = true, optional = true } tar = { workspace = true, optional = true } # daytona -fabro-config = { path = "../fabro-config", optional = true } +fabro-config = { path = "../../foundation/fabro-config", optional = true } fabro-github = { path = "../fabro-github", optional = true } -fabro-types = { path = "../fabro-types" } +fabro-types = { path = "../../foundation/fabro-types" } chrono = { workspace = true } diff --git a/lib/crates/fabro-sandbox/src/clone_source.rs b/lib/components/fabro-sandbox/src/clone_source.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/clone_source.rs rename to lib/components/fabro-sandbox/src/clone_source.rs diff --git a/lib/crates/fabro-sandbox/src/config.rs b/lib/components/fabro-sandbox/src/config.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/config.rs rename to lib/components/fabro-sandbox/src/config.rs diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/components/fabro-sandbox/src/daytona/mod.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/daytona/mod.rs rename to lib/components/fabro-sandbox/src/daytona/mod.rs diff --git a/lib/crates/fabro-sandbox/src/details.rs b/lib/components/fabro-sandbox/src/details.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/details.rs rename to lib/components/fabro-sandbox/src/details.rs diff --git a/lib/crates/fabro-sandbox/src/docker.rs b/lib/components/fabro-sandbox/src/docker.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/docker.rs rename to lib/components/fabro-sandbox/src/docker.rs diff --git a/lib/crates/fabro-sandbox/src/error.rs b/lib/components/fabro-sandbox/src/error.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/error.rs rename to lib/components/fabro-sandbox/src/error.rs diff --git a/lib/crates/fabro-sandbox/src/from_environment.rs b/lib/components/fabro-sandbox/src/from_environment.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/from_environment.rs rename to lib/components/fabro-sandbox/src/from_environment.rs diff --git a/lib/crates/fabro-sandbox/src/glob_match.rs b/lib/components/fabro-sandbox/src/glob_match.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/glob_match.rs rename to lib/components/fabro-sandbox/src/glob_match.rs diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/components/fabro-sandbox/src/lib.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/lib.rs rename to lib/components/fabro-sandbox/src/lib.rs diff --git a/lib/crates/fabro-sandbox/src/local.rs b/lib/components/fabro-sandbox/src/local.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/local.rs rename to lib/components/fabro-sandbox/src/local.rs diff --git a/lib/crates/fabro-sandbox/src/managed_labels.rs b/lib/components/fabro-sandbox/src/managed_labels.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/managed_labels.rs rename to lib/components/fabro-sandbox/src/managed_labels.rs diff --git a/lib/crates/fabro-sandbox/src/provider.rs b/lib/components/fabro-sandbox/src/provider.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/provider.rs rename to lib/components/fabro-sandbox/src/provider.rs diff --git a/lib/crates/fabro-sandbox/src/provider/daytona.rs b/lib/components/fabro-sandbox/src/provider/daytona.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/provider/daytona.rs rename to lib/components/fabro-sandbox/src/provider/daytona.rs diff --git a/lib/crates/fabro-sandbox/src/provider/docker.rs b/lib/components/fabro-sandbox/src/provider/docker.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/provider/docker.rs rename to lib/components/fabro-sandbox/src/provider/docker.rs diff --git a/lib/crates/fabro-sandbox/src/read_guard.rs b/lib/components/fabro-sandbox/src/read_guard.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/read_guard.rs rename to lib/components/fabro-sandbox/src/read_guard.rs diff --git a/lib/crates/fabro-sandbox/src/reconnect.rs b/lib/components/fabro-sandbox/src/reconnect.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/reconnect.rs rename to lib/components/fabro-sandbox/src/reconnect.rs diff --git a/lib/crates/fabro-sandbox/src/redact.rs b/lib/components/fabro-sandbox/src/redact.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/redact.rs rename to lib/components/fabro-sandbox/src/redact.rs diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/components/fabro-sandbox/src/sandbox.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/sandbox.rs rename to lib/components/fabro-sandbox/src/sandbox.rs diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/components/fabro-sandbox/src/sandbox_spec.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/sandbox_spec.rs rename to lib/components/fabro-sandbox/src/sandbox_spec.rs diff --git a/lib/crates/fabro-sandbox/src/terminal.rs b/lib/components/fabro-sandbox/src/terminal.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/terminal.rs rename to lib/components/fabro-sandbox/src/terminal.rs diff --git a/lib/crates/fabro-sandbox/src/test_support.rs b/lib/components/fabro-sandbox/src/test_support.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/test_support.rs rename to lib/components/fabro-sandbox/src/test_support.rs diff --git a/lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs similarity index 100% rename from lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs rename to lib/components/fabro-sandbox/tests/daytona_streaming_live.rs diff --git a/lib/crates/fabro-sandbox/tests/docker_streaming.rs b/lib/components/fabro-sandbox/tests/docker_streaming.rs similarity index 100% rename from lib/crates/fabro-sandbox/tests/docker_streaming.rs rename to lib/components/fabro-sandbox/tests/docker_streaming.rs diff --git a/lib/crates/fabro-sandbox/tests/error.rs b/lib/components/fabro-sandbox/tests/error.rs similarity index 100% rename from lib/crates/fabro-sandbox/tests/error.rs rename to lib/components/fabro-sandbox/tests/error.rs diff --git a/lib/crates/fabro-slack/Cargo.toml b/lib/components/fabro-slack/Cargo.toml similarity index 93% rename from lib/crates/fabro-slack/Cargo.toml rename to lib/components/fabro-slack/Cargo.toml index 660f42dc4..14a5ad3af 100644 --- a/lib/crates/fabro-slack/Cargo.toml +++ b/lib/components/fabro-slack/Cargo.toml @@ -14,7 +14,7 @@ workspace = true [dependencies] fabro-interview = { path = "../fabro-interview" } -fabro-types = { path = "../fabro-types" } +fabro-types = { path = "../../foundation/fabro-types" } fabro-workflow = { path = "../fabro-workflow" } fabro-http.workspace = true fabro-static.workspace = true diff --git a/lib/crates/fabro-slack/src/blocks.rs b/lib/components/fabro-slack/src/blocks.rs similarity index 100% rename from lib/crates/fabro-slack/src/blocks.rs rename to lib/components/fabro-slack/src/blocks.rs diff --git a/lib/crates/fabro-slack/src/client.rs b/lib/components/fabro-slack/src/client.rs similarity index 100% rename from lib/crates/fabro-slack/src/client.rs rename to lib/components/fabro-slack/src/client.rs diff --git a/lib/crates/fabro-slack/src/config.rs b/lib/components/fabro-slack/src/config.rs similarity index 100% rename from lib/crates/fabro-slack/src/config.rs rename to lib/components/fabro-slack/src/config.rs diff --git a/lib/crates/fabro-slack/src/connection.rs b/lib/components/fabro-slack/src/connection.rs similarity index 100% rename from lib/crates/fabro-slack/src/connection.rs rename to lib/components/fabro-slack/src/connection.rs diff --git a/lib/crates/fabro-slack/src/dispatch.rs b/lib/components/fabro-slack/src/dispatch.rs similarity index 100% rename from lib/crates/fabro-slack/src/dispatch.rs rename to lib/components/fabro-slack/src/dispatch.rs diff --git a/lib/crates/fabro-slack/src/interaction.rs b/lib/components/fabro-slack/src/interaction.rs similarity index 100% rename from lib/crates/fabro-slack/src/interaction.rs rename to lib/components/fabro-slack/src/interaction.rs diff --git a/lib/crates/fabro-slack/src/lib.rs b/lib/components/fabro-slack/src/lib.rs similarity index 100% rename from lib/crates/fabro-slack/src/lib.rs rename to lib/components/fabro-slack/src/lib.rs diff --git a/lib/crates/fabro-slack/src/payload.rs b/lib/components/fabro-slack/src/payload.rs similarity index 100% rename from lib/crates/fabro-slack/src/payload.rs rename to lib/components/fabro-slack/src/payload.rs diff --git a/lib/crates/fabro-slack/src/socket.rs b/lib/components/fabro-slack/src/socket.rs similarity index 100% rename from lib/crates/fabro-slack/src/socket.rs rename to lib/components/fabro-slack/src/socket.rs diff --git a/lib/crates/fabro-slack/src/threads.rs b/lib/components/fabro-slack/src/threads.rs similarity index 100% rename from lib/crates/fabro-slack/src/threads.rs rename to lib/components/fabro-slack/src/threads.rs diff --git a/lib/crates/fabro-store/Cargo.toml b/lib/components/fabro-store/Cargo.toml similarity index 77% rename from lib/crates/fabro-store/Cargo.toml rename to lib/components/fabro-store/Cargo.toml index 234da7ad4..796119e18 100644 --- a/lib/crates/fabro-store/Cargo.toml +++ b/lib/components/fabro-store/Cargo.toml @@ -12,8 +12,8 @@ doctest = false workspace = true [dependencies] -fabro-types = { path = "../fabro-types" } -fabro-util = { path = "../fabro-util" } +fabro-types = { path = "../../foundation/fabro-types" } +fabro-util = { path = "../../foundation/fabro-util" } hex.workspace = true slatedb.workspace = true object_store.workspace = true @@ -34,9 +34,9 @@ futures.workspace = true uuid.workspace = true [dev-dependencies] -fabro-types = { path = "../fabro-types", features = ["test-support"] } +fabro-types = { path = "../../foundation/fabro-types", features = ["test-support"] } tokio = { workspace = true, features = ["test-util", "macros"] } tempfile = "3" ulid.workspace = true insta = { workspace = true } -fabro-db = { path = "../fabro-db" } +fabro-db = { path = "../../foundation/fabro-db" } diff --git a/lib/crates/fabro-store/src/artifact_store.rs b/lib/components/fabro-store/src/artifact_store.rs similarity index 100% rename from lib/crates/fabro-store/src/artifact_store.rs rename to lib/components/fabro-store/src/artifact_store.rs diff --git a/lib/crates/fabro-store/src/error.rs b/lib/components/fabro-store/src/error.rs similarity index 100% rename from lib/crates/fabro-store/src/error.rs rename to lib/components/fabro-store/src/error.rs diff --git a/lib/crates/fabro-store/src/keyed_mutex.rs b/lib/components/fabro-store/src/keyed_mutex.rs similarity index 100% rename from lib/crates/fabro-store/src/keyed_mutex.rs rename to lib/components/fabro-store/src/keyed_mutex.rs diff --git a/lib/crates/fabro-store/src/keys.rs b/lib/components/fabro-store/src/keys.rs similarity index 100% rename from lib/crates/fabro-store/src/keys.rs rename to lib/components/fabro-store/src/keys.rs diff --git a/lib/crates/fabro-store/src/lib.rs b/lib/components/fabro-store/src/lib.rs similarity index 100% rename from lib/crates/fabro-store/src/lib.rs rename to lib/components/fabro-store/src/lib.rs diff --git a/lib/crates/fabro-store/src/record/codec.rs b/lib/components/fabro-store/src/record/codec.rs similarity index 100% rename from lib/crates/fabro-store/src/record/codec.rs rename to lib/components/fabro-store/src/record/codec.rs diff --git a/lib/crates/fabro-store/src/record/mod.rs b/lib/components/fabro-store/src/record/mod.rs similarity index 100% rename from lib/crates/fabro-store/src/record/mod.rs rename to lib/components/fabro-store/src/record/mod.rs diff --git a/lib/crates/fabro-store/src/record/record_id.rs b/lib/components/fabro-store/src/record/record_id.rs similarity index 100% rename from lib/crates/fabro-store/src/record/record_id.rs rename to lib/components/fabro-store/src/record/record_id.rs diff --git a/lib/crates/fabro-store/src/record/repository.rs b/lib/components/fabro-store/src/record/repository.rs similarity index 100% rename from lib/crates/fabro-store/src/record/repository.rs rename to lib/components/fabro-store/src/record/repository.rs diff --git a/lib/crates/fabro-store/src/record/transaction.rs b/lib/components/fabro-store/src/record/transaction.rs similarity index 100% rename from lib/crates/fabro-store/src/record/transaction.rs rename to lib/components/fabro-store/src/record/transaction.rs diff --git a/lib/crates/fabro-store/src/run_sessions.rs b/lib/components/fabro-store/src/run_sessions.rs similarity index 95% rename from lib/crates/fabro-store/src/run_sessions.rs rename to lib/components/fabro-store/src/run_sessions.rs index 4fe396c72..a5998baed 100644 --- a/lib/crates/fabro-store/src/run_sessions.rs +++ b/lib/components/fabro-store/src/run_sessions.rs @@ -77,6 +77,7 @@ impl RunSessionProjection { let mut record = SessionRecord::new(session_id, run_id, envelope.event.ts); record.title.clone_from(&props.title); record.model.clone_from(&props.model); + record.provider.clone_from(&props.provider); let projected = ProjectedRunSession { record, runtime_context: Vec::new(), @@ -254,8 +255,9 @@ mod tests { 1, session_id, EventBody::RunSessionCreated(RunSessionCreatedProps { - title: Some("Ask".to_string()), - model: Some("test-model".to_string()), + title: Some("Ask".to_string()), + model: Some("test-model".to_string()), + provider: None, }), ), event( @@ -318,8 +320,9 @@ mod tests { 1, session_id, EventBody::RunSessionCreated(RunSessionCreatedProps { - title: None, - model: None, + title: None, + model: None, + provider: None, }), ), event( @@ -387,8 +390,9 @@ mod tests { 1, session_id, EventBody::RunSessionCreated(RunSessionCreatedProps { - title: Some("Ask".to_string()), - model: Some("test-model".to_string()), + title: Some("Ask".to_string()), + model: Some("test-model".to_string()), + provider: None, }), ), event( @@ -408,7 +412,7 @@ mod tests { assert!(value.get("runtime_context").is_none()); assert!(value.get("working_dir").is_none()); - assert!(value.get("provider").is_none()); + assert!(value["provider"].is_null()); assert!(value.get("permissions").is_none()); assert!(value.get("deleted_at").is_none()); } @@ -423,8 +427,9 @@ mod tests { 1, session_id, EventBody::RunSessionCreated(RunSessionCreatedProps { - title: None, - model: None, + title: None, + model: None, + provider: None, }), ), event( @@ -439,8 +444,9 @@ mod tests { 3, other_session_id, EventBody::RunSessionCreated(RunSessionCreatedProps { - title: Some("Other".to_string()), - model: None, + title: Some("Other".to_string()), + model: None, + provider: None, }), ), ]; @@ -478,8 +484,9 @@ mod tests { 1, session_id, EventBody::RunSessionCreated(RunSessionCreatedProps { - title: None, - model: None, + title: None, + model: None, + provider: None, }), ), event( diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs similarity index 97% rename from lib/crates/fabro-store/src/run_state.rs rename to lib/components/fabro-store/src/run_state.rs index 44783d4fc..6bb0416ab 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -539,18 +539,27 @@ impl RunProjectionReducer for RunProjection { stage.state = StageState::from(props.status); } EventBody::TodoCreated(props) => { + if !should_project_root_agent_todo_event(stored, props.list_kind) { + return Ok(()); + } let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { return Ok(()); }; - apply_todo_created(stage, props, stored.parent_session_id.as_deref()); + apply_todo_created(stage, props); } EventBody::TodoUpdated(props) => { + if !should_project_root_agent_todo_event(stored, props.list_kind) { + return Ok(()); + } let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { return Ok(()); }; apply_todo_updated(stage, props); } EventBody::TodoDeleted(props) => { + if !should_project_root_agent_todo_event(stored, props.list_kind) { + return Ok(()); + } let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { return Ok(()); }; @@ -674,36 +683,37 @@ impl RunProjectionReducer for RunProjection { } } -fn apply_todo_created( - stage: &mut StageProjection, - props: &TodoCreatedProps, - parent_session_id: Option<&str>, -) { - // OpenAI plan lists are scoped per agent session (`openai_plan:`). - // A child subagent must not displace a different list already in the slot - // (which would clobber the root agent's plan); but it may create the slot - // when empty, or extend its own list. Anthropic task lists are root-scoped - // and pass through unchanged. - if matches!(props.list_kind, TodoListKind::OpenAiPlan) - && parent_session_id.is_some() - && stage - .todos - .as_ref() - .is_some_and(|list| list.list_id != props.list_id) - { - return; +/// Decide whether a TODO event should mutate +/// `StageProjection.root_agent_todos`. +/// +/// OpenAI plan lists are scoped per agent session (`openai_plan:`), +/// so a child/subagent session emits its own list events on the same stage. +/// The root-agent projection excludes those child plans, while the underlying +/// events remain in the run event log. Anthropic task lists are root-scoped +/// (`anthropic_tasks:`) and intentionally shared with +/// subagents, so they always project. +fn should_project_root_agent_todo_event(stored: &RunEvent, list_kind: TodoListKind) -> bool { + match list_kind { + TodoListKind::OpenAiPlan => stored.parent_session_id.is_none(), + TodoListKind::AnthropicTasks => true, } +} + +fn apply_todo_created(stage: &mut StageProjection, props: &TodoCreatedProps) { if stage - .todos + .root_agent_todos .as_ref() .is_none_or(|list| list.list_id != props.list_id || list.kind != props.list_kind) { - stage.todos = Some(TodoListProjection::new( + stage.root_agent_todos = Some(TodoListProjection::new( props.list_kind, props.list_id.clone(), )); } - let list = stage.todos.as_mut().expect("todo list was just inserted"); + let list = stage + .root_agent_todos + .as_mut() + .expect("todo list was just inserted"); list.upsert(TodoProjection { id: props.todo_id.clone(), status: props.status, @@ -720,7 +730,7 @@ fn apply_todo_created( fn apply_todo_updated(stage: &mut StageProjection, props: &TodoUpdatedProps) { if let Some(list) = stage - .todos + .root_agent_todos .as_mut() .filter(|list| list.list_id == props.list_id) { @@ -730,7 +740,7 @@ fn apply_todo_updated(stage: &mut StageProjection, props: &TodoUpdatedProps) { fn apply_todo_deleted(stage: &mut StageProjection, props: &TodoDeletedProps) { let Some(list) = stage - .todos + .root_agent_todos .as_mut() .filter(|list| list.list_id == props.list_id) else { @@ -738,7 +748,7 @@ fn apply_todo_deleted(stage: &mut StageProjection, props: &TodoDeletedProps) { }; list.remove(&props.todo_id); if list.items.is_empty() { - stage.todos = None; + stage.root_agent_todos = None; } } @@ -1061,7 +1071,7 @@ fn run_models(state: &RunProjection) -> Vec { .filter_map(|(_, stage)| stage.model.as_ref()) .map(|model| RunModel { provider: Some(model.provider.to_string()), - name: model.model_id.clone(), + name: model.model_id.to_string(), }) .collect::>(); models.sort_by(|left, right| { @@ -4438,11 +4448,14 @@ mod tests { StageId::new("code", 1) } - fn stage_todos<'a>(state: &'a RunProjection, stage_id: &StageId) -> &'a TodoListProjection { + fn root_agent_todos<'a>( + state: &'a RunProjection, + stage_id: &StageId, + ) -> &'a TodoListProjection { state .stage(stage_id) - .and_then(|stage| stage.todos.as_ref()) - .expect("stage todos present") + .and_then(|stage| stage.root_agent_todos.as_ref()) + .expect("root agent todos present") } fn child_stage_event(seq: u32, body: EventBody, stage_id: StageId) -> EventEnvelope { @@ -4532,7 +4545,7 @@ mod tests { )) .unwrap(); - let projection = stage_todos(&state, &stage_id); + let projection = root_agent_todos(&state, &stage_id); assert_eq!(projection.list_id, list); assert_eq!(projection.items.len(), 2); assert_eq!(projection.items[0].id, "a"); @@ -4567,7 +4580,7 @@ mod tests { )) .unwrap(); - let projection = stage_todos(&state, &stage_id); + let projection = root_agent_todos(&state, &stage_id); assert_eq!(projection.items.len(), 1); assert_eq!(projection.items[0].id, "b"); } @@ -4606,9 +4619,12 @@ mod tests { )) .unwrap(); - assert_eq!(stage_todos(&state, &plan_one).items[0].subject, "p1"); - assert_eq!(stage_todos(&state, &plan_two).items[0].subject, "p2"); - assert_eq!(stage_todos(&state, &claude).items[0].subject, "claude task"); + assert_eq!(root_agent_todos(&state, &plan_one).items[0].subject, "p1"); + assert_eq!(root_agent_todos(&state, &plan_two).items[0].subject, "p2"); + assert_eq!( + root_agent_todos(&state, &claude).items[0].subject, + "claude task" + ); } #[test] @@ -4694,7 +4710,7 @@ mod tests { )) .unwrap(); - let projection = stage_todos(&state, &stage_id); + let projection = root_agent_todos(&state, &stage_id); assert_eq!(projection.list_id, root_list); assert_eq!(projection.kind, TodoListKind::OpenAiPlan); assert_eq!(projection.items.len(), 2); @@ -4705,50 +4721,80 @@ mod tests { } #[test] - fn child_openai_plan_projects_when_no_root_plan_exists() { - // Regression: when a stage's root agent never calls update_plan but - // a subagent does, the subagent's plan must still surface in - // StageProjection.todos. Observed on run 01KSDXK5DJ61CFCK9YSDR8AETQ - // (implement@1), where a gpt-5.5 root delegated to a subagent that - // owned the only plan list — and the sidebar showed no todos. + fn child_openai_plan_does_not_project_when_root_has_no_plan() { let mut state = initialized_projection(); let stage_id = stage_id(); - let child_list = "openai_plan:child_session"; state - .apply_event(&child_stage_event( + .apply_event(&test_stage_event( 1, - created(child_list, TodoListKind::OpenAiPlan, "c-a", 0, "first"), + EventBody::StageStarted(started_props()), stage_id.clone(), )) .unwrap(); state .apply_event(&child_stage_event( 2, - created(child_list, TodoListKind::OpenAiPlan, "c-b", 1, "second"), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&child_stage_event( - 3, - updated_status( - child_list, + created( + "openai_plan:child_session", TodoListKind::OpenAiPlan, "c-a", - TodoStatus::Completed, + 0, + "child work", ), stage_id.clone(), )) .unwrap(); - let projection = stage_todos(&state, &stage_id); - assert_eq!(projection.list_id, child_list); - assert_eq!(projection.kind, TodoListKind::OpenAiPlan); - assert_eq!(projection.items.len(), 2); - assert_eq!(projection.items[0].id, "c-a"); - assert_eq!(projection.items[0].status, TodoStatus::Completed); - assert_eq!(projection.items[1].id, "c-b"); - assert_eq!(projection.items[1].status, TodoStatus::Pending); + let stage = state.stage(&stage_id).expect("stage projection present"); + assert!( + stage.root_agent_todos.is_none(), + "a child session's plan must not become the stage's root plan" + ); + } + + #[test] + fn root_openai_plan_projects_after_earlier_child_plan_event() { + let mut state = initialized_projection(); + let stage_id = stage_id(); + let root_list = "openai_plan:root_session"; + state + .apply_event(&test_stage_event( + 1, + EventBody::StageStarted(started_props()), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&child_stage_event( + 2, + created( + "openai_plan:child_session", + TodoListKind::OpenAiPlan, + "child-a", + 0, + "child work", + ), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&test_stage_event( + 3, + created( + root_list, + TodoListKind::OpenAiPlan, + "root-a", + 0, + "root work", + ), + stage_id.clone(), + )) + .unwrap(); + + let projection = root_agent_todos(&state, &stage_id); + assert_eq!(projection.list_id, root_list); + assert_eq!(projection.items.len(), 1); + assert_eq!(projection.items[0].subject, "root work"); } #[test] @@ -4782,7 +4828,7 @@ mod tests { )) .unwrap(); - let projection = stage_todos(&state, &stage_id); + let projection = root_agent_todos(&state, &stage_id); assert_eq!(projection.list_id, list); assert_eq!(projection.kind, TodoListKind::AnthropicTasks); assert_eq!(projection.items.len(), 1); @@ -4848,7 +4894,7 @@ mod tests { )) .unwrap(); - let todo = &stage_todos(&state, &stage_id).items[0]; + let todo = &root_agent_todos(&state, &stage_id).items[0]; assert!(!todo.metadata.contains_key("k1")); assert_eq!(todo.metadata.get("k2"), Some(&serde_json::json!("v2"))); } diff --git a/lib/crates/fabro-store/src/run_summary_store.rs b/lib/components/fabro-store/src/run_summary_store.rs similarity index 100% rename from lib/crates/fabro-store/src/run_summary_store.rs rename to lib/components/fabro-store/src/run_summary_store.rs diff --git a/lib/crates/fabro-store/src/serializable_projection.rs b/lib/components/fabro-store/src/serializable_projection.rs similarity index 100% rename from lib/crates/fabro-store/src/serializable_projection.rs rename to lib/components/fabro-store/src/serializable_projection.rs diff --git a/lib/crates/fabro-store/src/slate/auth_codes.rs b/lib/components/fabro-store/src/slate/auth_codes.rs similarity index 100% rename from lib/crates/fabro-store/src/slate/auth_codes.rs rename to lib/components/fabro-store/src/slate/auth_codes.rs diff --git a/lib/crates/fabro-store/src/slate/auth_tokens.rs b/lib/components/fabro-store/src/slate/auth_tokens.rs similarity index 100% rename from lib/crates/fabro-store/src/slate/auth_tokens.rs rename to lib/components/fabro-store/src/slate/auth_tokens.rs diff --git a/lib/crates/fabro-store/src/slate/blob_store.rs b/lib/components/fabro-store/src/slate/blob_store.rs similarity index 100% rename from lib/crates/fabro-store/src/slate/blob_store.rs rename to lib/components/fabro-store/src/slate/blob_store.rs diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/components/fabro-store/src/slate/mod.rs similarity index 97% rename from lib/crates/fabro-store/src/slate/mod.rs rename to lib/components/fabro-store/src/slate/mod.rs index 60c40d1c4..7546a741f 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/components/fabro-store/src/slate/mod.rs @@ -803,6 +803,40 @@ mod tests { assert!(matches!(err, Error::ReadOnly)); } + #[tokio::test] + async fn append_event_if_evaluates_latest_projection_before_appending() { + let (_object_store, store) = make_store(); + let run = store.create_run(&test_run_id("run-1")).await.unwrap(); + append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; + let initial_title = run.state().await.unwrap().title().into_owned(); + + run.append_event(&event_payload( + "run-1", + "2026-03-27T12:00:01Z", + "run.title.updated", + &serde_json::json!({ "title": "User title" }), + )) + .await + .unwrap(); + + let generated_update = event_payload( + "run-1", + "2026-03-27T12:00:02Z", + "run.title.updated", + &serde_json::json!({ "title": "Generated title" }), + ); + let appended = run + .append_event_if(&generated_update, |projection| { + projection.title() == initial_title + }) + .await + .unwrap(); + + assert_eq!(appended, None); + assert_eq!(run.state().await.unwrap().title(), "User title"); + assert_eq!(run.list_events().await.unwrap().len(), 2); + } + #[tokio::test] async fn control_request_events_set_pending_control_without_overwriting_status() { let (_object_store, store) = make_store(); diff --git a/lib/crates/fabro-store/src/slate/projection_cache.rs b/lib/components/fabro-store/src/slate/projection_cache.rs similarity index 100% rename from lib/crates/fabro-store/src/slate/projection_cache.rs rename to lib/components/fabro-store/src/slate/projection_cache.rs diff --git a/lib/crates/fabro-store/src/slate/run_catalog_index.rs b/lib/components/fabro-store/src/slate/run_catalog_index.rs similarity index 100% rename from lib/crates/fabro-store/src/slate/run_catalog_index.rs rename to lib/components/fabro-store/src/slate/run_catalog_index.rs diff --git a/lib/crates/fabro-store/src/slate/run_store.rs b/lib/components/fabro-store/src/slate/run_store.rs similarity index 97% rename from lib/crates/fabro-store/src/slate/run_store.rs rename to lib/components/fabro-store/src/slate/run_store.rs index 2b67a066a..a1d7f8441 100644 --- a/lib/crates/fabro-store/src/slate/run_store.rs +++ b/lib/components/fabro-store/src/slate/run_store.rs @@ -169,6 +169,10 @@ impl RunDatabase { async fn projected_state(&self) -> Result { let _state_guard = self.inner.state_lock.lock().await; + self.projected_state_locked().await + } + + async fn projected_state_locked(&self) -> Result { let next_seq = { let cache = self.inner.projection_cache.lock().await; cache.last_seq.saturating_add(1) @@ -254,12 +258,35 @@ impl RunDatabase { Ok(self.append_event_envelope(payload).await?.seq) } + /// Atomically appends `payload` when `predicate` matches the latest run + /// projection. + pub async fn append_event_if( + &self, + payload: &EventPayload, + predicate: impl FnOnce(&RunProjection) -> bool, + ) -> Result> { + if self.read_only { + return Err(Error::ReadOnly); + } + payload.validate(&self.inner.run_id)?; + let _state_guard = self.inner.state_lock.lock().await; + let projection = self.projected_state_locked().await?; + if !predicate(&projection) { + return Ok(None); + } + Ok(Some(self.append_event_envelope_locked(payload).await?.seq)) + } + pub async fn append_event_envelope(&self, payload: &EventPayload) -> Result { if self.read_only { return Err(Error::ReadOnly); } payload.validate(&self.inner.run_id)?; let _state_guard = self.inner.state_lock.lock().await; + self.append_event_envelope_locked(payload).await + } + + async fn append_event_envelope_locked(&self, payload: &EventPayload) -> Result { let seq = self.inner.event_seq.fetch_add(1, Ordering::SeqCst); let event = EventEnvelope { seq, diff --git a/lib/crates/fabro-store/src/test_util.rs b/lib/components/fabro-store/src/test_util.rs similarity index 100% rename from lib/crates/fabro-store/src/test_util.rs rename to lib/components/fabro-store/src/test_util.rs diff --git a/lib/crates/fabro-store/src/types.rs b/lib/components/fabro-store/src/types.rs similarity index 100% rename from lib/crates/fabro-store/src/types.rs rename to lib/components/fabro-store/src/types.rs diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/components/fabro-store/tests/serializable_projection.rs similarity index 100% rename from lib/crates/fabro-store/tests/serializable_projection.rs rename to lib/components/fabro-store/tests/serializable_projection.rs diff --git a/lib/crates/fabro-tool/Cargo.toml b/lib/components/fabro-tool/Cargo.toml similarity index 64% rename from lib/crates/fabro-tool/Cargo.toml rename to lib/components/fabro-tool/Cargo.toml index c50ea6a38..0d3a2862d 100644 --- a/lib/crates/fabro-tool/Cargo.toml +++ b/lib/components/fabro-tool/Cargo.toml @@ -16,10 +16,10 @@ workspace = true anyhow.workspace = true async-trait.workspace = true chrono = { workspace = true, features = ["serde"] } -fabro-api = { path = "../fabro-api" } -fabro-client = { path = "../fabro-client" } -fabro-types = { path = "../fabro-types" } -fabro-util = { path = "../fabro-util" } +fabro-api = { path = "../../foundation/fabro-api" } +fabro-client = { path = "../../foundation/fabro-client" } +fabro-types = { path = "../../foundation/fabro-types" } +fabro-util = { path = "../../foundation/fabro-util" } futures.workspace = true schemars = "1.2.1" serde.workspace = true @@ -29,5 +29,5 @@ tokio.workspace = true toml.workspace = true [dev-dependencies] -fabro-types = { path = "../fabro-types", features = ["test-support"] } +fabro-types = { path = "../../foundation/fabro-types", features = ["test-support"] } tempfile = "3" diff --git a/lib/crates/fabro-tool/src/common.rs b/lib/components/fabro-tool/src/common.rs similarity index 100% rename from lib/crates/fabro-tool/src/common.rs rename to lib/components/fabro-tool/src/common.rs diff --git a/lib/crates/fabro-tool/src/create.rs b/lib/components/fabro-tool/src/create.rs similarity index 100% rename from lib/crates/fabro-tool/src/create.rs rename to lib/components/fabro-tool/src/create.rs diff --git a/lib/crates/fabro-tool/src/events.rs b/lib/components/fabro-tool/src/events.rs similarity index 100% rename from lib/crates/fabro-tool/src/events.rs rename to lib/components/fabro-tool/src/events.rs diff --git a/lib/crates/fabro-tool/src/fabro_client.rs b/lib/components/fabro-tool/src/fabro_client.rs similarity index 100% rename from lib/crates/fabro-tool/src/fabro_client.rs rename to lib/components/fabro-tool/src/fabro_client.rs diff --git a/lib/crates/fabro-tool/src/gather.rs b/lib/components/fabro-tool/src/gather.rs similarity index 100% rename from lib/crates/fabro-tool/src/gather.rs rename to lib/components/fabro-tool/src/gather.rs diff --git a/lib/crates/fabro-tool/src/get.rs b/lib/components/fabro-tool/src/get.rs similarity index 100% rename from lib/crates/fabro-tool/src/get.rs rename to lib/components/fabro-tool/src/get.rs diff --git a/lib/crates/fabro-tool/src/interact.rs b/lib/components/fabro-tool/src/interact.rs similarity index 100% rename from lib/crates/fabro-tool/src/interact.rs rename to lib/components/fabro-tool/src/interact.rs diff --git a/lib/crates/fabro-tool/src/lib.rs b/lib/components/fabro-tool/src/lib.rs similarity index 100% rename from lib/crates/fabro-tool/src/lib.rs rename to lib/components/fabro-tool/src/lib.rs diff --git a/lib/crates/fabro-tool/src/manifest.rs b/lib/components/fabro-tool/src/manifest.rs similarity index 100% rename from lib/crates/fabro-tool/src/manifest.rs rename to lib/components/fabro-tool/src/manifest.rs diff --git a/lib/crates/fabro-tool/src/pair.rs b/lib/components/fabro-tool/src/pair.rs similarity index 100% rename from lib/crates/fabro-tool/src/pair.rs rename to lib/components/fabro-tool/src/pair.rs diff --git a/lib/crates/fabro-tool/src/search.rs b/lib/components/fabro-tool/src/search.rs similarity index 100% rename from lib/crates/fabro-tool/src/search.rs rename to lib/components/fabro-tool/src/search.rs diff --git a/lib/crates/fabro-tracker/Cargo.toml b/lib/components/fabro-tracker/Cargo.toml similarity index 100% rename from lib/crates/fabro-tracker/Cargo.toml rename to lib/components/fabro-tracker/Cargo.toml diff --git a/lib/crates/fabro-tracker/src/fixtures/github-app-test-key.pem b/lib/components/fabro-tracker/src/fixtures/github-app-test-key.pem similarity index 100% rename from lib/crates/fabro-tracker/src/fixtures/github-app-test-key.pem rename to lib/components/fabro-tracker/src/fixtures/github-app-test-key.pem diff --git a/lib/crates/fabro-tracker/src/github.rs b/lib/components/fabro-tracker/src/github.rs similarity index 100% rename from lib/crates/fabro-tracker/src/github.rs rename to lib/components/fabro-tracker/src/github.rs diff --git a/lib/crates/fabro-tracker/src/lib.rs b/lib/components/fabro-tracker/src/lib.rs similarity index 100% rename from lib/crates/fabro-tracker/src/lib.rs rename to lib/components/fabro-tracker/src/lib.rs diff --git a/lib/crates/fabro-tracker/src/linear.rs b/lib/components/fabro-tracker/src/linear.rs similarity index 100% rename from lib/crates/fabro-tracker/src/linear.rs rename to lib/components/fabro-tracker/src/linear.rs diff --git a/lib/crates/fabro-validate/Cargo.toml b/lib/components/fabro-validate/Cargo.toml similarity index 81% rename from lib/crates/fabro-validate/Cargo.toml rename to lib/components/fabro-validate/Cargo.toml index a6c3ef3f9..4287fd1f1 100644 --- a/lib/crates/fabro-validate/Cargo.toml +++ b/lib/components/fabro-validate/Cargo.toml @@ -15,8 +15,8 @@ workspace = true [dependencies] fabro-acp = { path = "../fabro-acp", default-features = false } fabro-graphviz = { path = "../fabro-graphviz" } -fabro-model = { path = "../fabro-model" } -fabro-types = { path = "../fabro-types" } +fabro-model = { path = "../../foundation/fabro-model" } +fabro-types = { path = "../../foundation/fabro-types" } serde = { workspace = true } thiserror = { workspace = true } diff --git a/lib/crates/fabro-validate/src/lib.rs b/lib/components/fabro-validate/src/lib.rs similarity index 100% rename from lib/crates/fabro-validate/src/lib.rs rename to lib/components/fabro-validate/src/lib.rs diff --git a/lib/crates/fabro-validate/src/rules/all_conditional_edges.rs b/lib/components/fabro-validate/src/rules/all_conditional_edges.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/all_conditional_edges.rs rename to lib/components/fabro-validate/src/rules/all_conditional_edges.rs diff --git a/lib/crates/fabro-validate/src/rules/backend_valid.rs b/lib/components/fabro-validate/src/rules/backend_valid.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/backend_valid.rs rename to lib/components/fabro-validate/src/rules/backend_valid.rs diff --git a/lib/crates/fabro-validate/src/rules/condition_syntax.rs b/lib/components/fabro-validate/src/rules/condition_syntax.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/condition_syntax.rs rename to lib/components/fabro-validate/src/rules/condition_syntax.rs diff --git a/lib/crates/fabro-validate/src/rules/direction_valid.rs b/lib/components/fabro-validate/src/rules/direction_valid.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/direction_valid.rs rename to lib/components/fabro-validate/src/rules/direction_valid.rs diff --git a/lib/crates/fabro-validate/src/rules/edge_target_exists.rs b/lib/components/fabro-validate/src/rules/edge_target_exists.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/edge_target_exists.rs rename to lib/components/fabro-validate/src/rules/edge_target_exists.rs diff --git a/lib/crates/fabro-validate/src/rules/exit_no_outgoing.rs b/lib/components/fabro-validate/src/rules/exit_no_outgoing.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/exit_no_outgoing.rs rename to lib/components/fabro-validate/src/rules/exit_no_outgoing.rs diff --git a/lib/crates/fabro-validate/src/rules/fidelity_valid.rs b/lib/components/fabro-validate/src/rules/fidelity_valid.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/fidelity_valid.rs rename to lib/components/fabro-validate/src/rules/fidelity_valid.rs diff --git a/lib/crates/fabro-validate/src/rules/freeform_edge_count.rs b/lib/components/fabro-validate/src/rules/freeform_edge_count.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/freeform_edge_count.rs rename to lib/components/fabro-validate/src/rules/freeform_edge_count.rs diff --git a/lib/crates/fabro-validate/src/rules/goal_gate_has_retry.rs b/lib/components/fabro-validate/src/rules/goal_gate_has_retry.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/goal_gate_has_retry.rs rename to lib/components/fabro-validate/src/rules/goal_gate_has_retry.rs diff --git a/lib/crates/fabro-validate/src/rules/import_error.rs b/lib/components/fabro-validate/src/rules/import_error.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/import_error.rs rename to lib/components/fabro-validate/src/rules/import_error.rs diff --git a/lib/crates/fabro-validate/src/rules/inert_attribute.rs b/lib/components/fabro-validate/src/rules/inert_attribute.rs similarity index 90% rename from lib/crates/fabro-validate/src/rules/inert_attribute.rs rename to lib/components/fabro-validate/src/rules/inert_attribute.rs index a740d97b8..2ae6d0e04 100644 --- a/lib/crates/fabro-validate/src/rules/inert_attribute.rs +++ b/lib/components/fabro-validate/src/rules/inert_attribute.rs @@ -19,7 +19,8 @@ const HANDLER_SPECIFIC_ATTRS: &[(&str, &[&str])] = &[ ("language", &["command"]), ("duration", &["wait"]), ("max_parallel", &["parallel"]), - ("output_schema", &["agent", "prompt"]), + ("output_retries", &["agent", "prompt"]), + ("output_schema", &["agent", "prompt", "command"]), ("prompt", &["agent", "prompt", "parallel.fan_in"]), ]; @@ -147,15 +148,18 @@ mod tests { } #[test] - fn warns_on_output_schema_on_command_node() { + fn warns_on_output_retries_on_command_node() { let mut g = minimal_graph(); g.nodes.insert( "run".to_string(), - node_with_attr("run", "parallelogram", "output_schema", "routing"), + node_with_attr("run", "parallelogram", "output_retries", "2"), ); + let d = Rule.apply(&g); + assert_eq!(d.len(), 1); - assert!(d[0].message.contains("'output_schema'")); + assert!(d[0].message.contains("'output_retries'")); + assert!(d[0].message.contains("agent, prompt")); } #[test] @@ -165,6 +169,10 @@ mod tests { "run".to_string(), node_with_attr("run", "parallelogram", "script", "echo hi"), ); + g.nodes.insert( + "audit".to_string(), + node_with_attr("audit", "parallelogram", "output_schema", "routing"), + ); g.nodes.insert( "pause".to_string(), node_with_attr("pause", "insulator", "duration", "30s"), @@ -173,6 +181,10 @@ mod tests { "work".to_string(), node_with_attr("work", "box", "prompt", "do things"), ); + g.nodes.insert( + "review".to_string(), + node_with_attr("review", "box", "output_retries", "2"), + ); g.nodes.insert( "fork".to_string(), node_with_attr("fork", "component", "max_parallel", "4"), @@ -181,6 +193,10 @@ mod tests { "spec".to_string(), node_with_attr("spec", "tab", "output_schema", "routing"), ); + g.nodes.insert( + "prompt".to_string(), + node_with_attr("prompt", "tab", "output_retries", "2"), + ); assert!(Rule.apply(&g).is_empty()); } diff --git a/lib/crates/fabro-validate/src/rules/join_policy_removed.rs b/lib/components/fabro-validate/src/rules/join_policy_removed.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/join_policy_removed.rs rename to lib/components/fabro-validate/src/rules/join_policy_removed.rs diff --git a/lib/crates/fabro-validate/src/rules/mod.rs b/lib/components/fabro-validate/src/rules/mod.rs similarity index 99% rename from lib/crates/fabro-validate/src/rules/mod.rs rename to lib/components/fabro-validate/src/rules/mod.rs index cb4a5a18a..cfcf26b12 100644 --- a/lib/crates/fabro-validate/src/rules/mod.rs +++ b/lib/components/fabro-validate/src/rules/mod.rs @@ -13,6 +13,7 @@ mod join_policy_removed; mod model_support; mod node_model_known; mod orphan_custom_outcome; +mod parallel_branch; mod parallel_branch_inert_attribute; mod prompt_on_llm_nodes; mod random_selection_no_conditions; diff --git a/lib/crates/fabro-validate/src/rules/model_support.rs b/lib/components/fabro-validate/src/rules/model_support.rs similarity index 97% rename from lib/crates/fabro-validate/src/rules/model_support.rs rename to lib/components/fabro-validate/src/rules/model_support.rs index 91df02991..7bab56a87 100644 --- a/lib/crates/fabro-validate/src/rules/model_support.rs +++ b/lib/components/fabro-validate/src/rules/model_support.rs @@ -7,7 +7,7 @@ pub(super) fn check_model_known( context: &str, node_id: Option, ) -> Option { - if catalog.get(model).is_some() { + if catalog.is_model_selector(model) { return None; } Some(Diagnostic { diff --git a/lib/crates/fabro-validate/src/rules/node_model_known.rs b/lib/components/fabro-validate/src/rules/node_model_known.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/node_model_known.rs rename to lib/components/fabro-validate/src/rules/node_model_known.rs diff --git a/lib/crates/fabro-validate/src/rules/orphan_custom_outcome.rs b/lib/components/fabro-validate/src/rules/orphan_custom_outcome.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/orphan_custom_outcome.rs rename to lib/components/fabro-validate/src/rules/orphan_custom_outcome.rs diff --git a/lib/components/fabro-validate/src/rules/parallel_branch.rs b/lib/components/fabro-validate/src/rules/parallel_branch.rs new file mode 100644 index 000000000..d8a7d2fbd --- /dev/null +++ b/lib/components/fabro-validate/src/rules/parallel_branch.rs @@ -0,0 +1,59 @@ +use std::collections::BTreeSet; + +use fabro_graphviz::graph::{Edge, Graph}; + +pub(super) struct ParallelBranches<'a> { + graph: &'a Graph, + fork_ids: BTreeSet<&'a str>, +} + +impl<'a> ParallelBranches<'a> { + pub(super) fn new(graph: &'a Graph) -> Self { + let fork_ids = graph + .nodes + .values() + .filter(|node| node.handler_type() == Some("parallel")) + .map(|node| node.id.as_str()) + .collect(); + Self { graph, fork_ids } + } + + pub(super) fn is_empty(&self) -> bool { + self.fork_ids.is_empty() + } + + pub(super) fn is_fork_edge(&self, edge: &Edge) -> bool { + self.fork_ids.contains(edge.from.as_str()) + } + + pub(super) fn branch_targets(&self) -> BTreeSet<&str> { + self.graph + .edges + .iter() + .filter(|edge| self.is_fork_edge(edge)) + .map(|edge| edge.to.as_str()) + .collect() + } + + /// True when every incoming edge of `node_id` comes from a parallel fork + /// (and there is at least one). Such a node only ever runs as a branch. + pub(super) fn is_branch_only_node(&self, node_id: &str) -> bool { + let incoming = self.graph.incoming_edges(node_id); + !incoming.is_empty() && incoming.iter().all(|edge| self.is_fork_edge(edge)) + } + + /// The sorted, deduplicated fork parents of a branch-only node, or `None` + /// when the node has a non-fork entry path (or no entry at all). + pub(super) fn branch_only_parents(&self, node_id: &str) -> Option> { + if !self.is_branch_only_node(node_id) { + return None; + } + let parents: BTreeSet<&str> = self + .graph + .incoming_edges(node_id) + .into_iter() + .map(|edge| edge.from.as_str()) + .collect(); + Some(parents.into_iter().map(String::from).collect()) + } +} diff --git a/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs b/lib/components/fabro-validate/src/rules/parallel_branch_inert_attribute.rs similarity index 58% rename from lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs rename to lib/components/fabro-validate/src/rules/parallel_branch_inert_attribute.rs index eb051281e..bac461103 100644 --- a/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs +++ b/lib/components/fabro-validate/src/rules/parallel_branch_inert_attribute.rs @@ -1,18 +1,20 @@ -use std::collections::BTreeSet; - use fabro_graphviz::graph::Graph; +use super::parallel_branch::ParallelBranches; use crate::{Diagnostic, LintRule, Severity}; pub(super) fn rule() -> Box { Box::new(Rule) } -/// Attributes that parallel branch execution does not resolve. Branch nodes -/// are dispatched with a snapshot of the context taken when the parallel node -/// started, so per-branch `fidelity` never changes what a branch sees, and -/// per-branch `thread_id` never replaces the thread inherited in that snapshot. -const BRANCH_IGNORED_ATTRS: &[&str] = &["fidelity", "thread_id"]; +/// Attributes that parallel branch execution does not resolve. Only +/// `thread_id` is inert on branches (concurrent branches cannot share an LLM +/// session); per-branch `fidelity` is honored via pre-rendered preambles. +const BRANCH_IGNORED_ATTRS: &[&str] = &["thread_id"]; + +const FULL_FIDELITY_MESSAGE: &str = "Parallel branches run at most at summary:high; full is degraded at runtime because branches cannot share a session"; + +const THREAD_ID_FIX: &str = "Remove 'thread_id': parallel branches inherit the thread resolved when the parallel node started"; struct Rule; @@ -24,25 +26,31 @@ fn quoted_list(ids: &[String]) -> String { .join(", ") } -fn fix_message(attr: &str, parallel_ids: &[String]) -> String { - match attr { - "fidelity" => { - if parallel_ids.len() == 1 { - format!( - "Set fidelity on the parallel node {} (or its incoming edge) to control what every branch sees", - quoted_list(parallel_ids), - ) - } else { - format!( - "Set fidelity on the parallel nodes {} (or their incoming edges) to control what every branch sees", - quoted_list(parallel_ids), - ) - } - } - "thread_id" => format!( - "Remove '{attr}': parallel branches inherit the thread resolved when the parallel node started" - ), - _ => format!("Remove '{attr}'"), +fn full_fidelity_fix(parallel_ids: &[String]) -> String { + let parent = if parallel_ids.len() == 1 { + format!("parallel node {}", quoted_list(parallel_ids)) + } else { + format!("parallel nodes {}", quoted_list(parallel_ids)) + }; + format!( + "Use fidelity=\"summary:high\" or another lower mode on this branch; to reuse a full session before fan-out, set fidelity=\"full\" on {parent} or its incoming edge" + ) +} + +fn full_fidelity_diagnostic( + rule_name: &str, + node_id: Option, + edge: Option<(String, String)>, + parallel_ids: &[String], +) -> Diagnostic { + Diagnostic { + rule: rule_name.to_string(), + severity: Severity::Warning, + message: FULL_FIDELITY_MESSAGE.to_string(), + node_id, + edge, + fix: Some(full_fidelity_fix(parallel_ids)), + ..Diagnostic::default() } } @@ -52,24 +60,25 @@ impl LintRule for Rule { } fn apply(&self, graph: &Graph) -> Vec { - let parallel_ids: BTreeSet<&str> = graph - .nodes - .values() - .filter(|n| n.handler_type() == Some("parallel")) - .map(|n| n.id.as_str()) - .collect(); - if parallel_ids.is_empty() { + let branches = ParallelBranches::new(graph); + if branches.is_empty() { return Vec::new(); } let mut diagnostics = Vec::new(); - // Branch edges (parallel node -> branch target) carrying an attribute - // that branch dispatch never reads. for edge in &graph.edges { - if !parallel_ids.contains(edge.from.as_str()) { + if !branches.is_fork_edge(edge) { continue; } + if edge.fidelity() == Some("full") { + diagnostics.push(full_fidelity_diagnostic( + self.name(), + None, + Some((edge.from.clone(), edge.to.clone())), + std::slice::from_ref(&edge.from), + )); + } for attr in BRANCH_IGNORED_ATTRS { if !edge.attrs.contains_key(*attr) { continue; @@ -83,42 +92,29 @@ impl LintRule for Rule { ), node_id: None, edge: Some((edge.from.clone(), edge.to.clone())), - fix: Some(fix_message(attr, std::slice::from_ref(&edge.from))), + fix: Some(THREAD_ID_FIX.to_string()), ..Diagnostic::default() }); } } - // Branch target nodes carrying such an attribute — but only when every - // incoming edge comes from a parallel node. A node that is also - // reachable through a normal edge resolves the attribute on that path, - // so it is not inert there. - let branch_targets: BTreeSet<&str> = graph - .edges - .iter() - .filter(|e| parallel_ids.contains(e.from.as_str())) - .map(|e| e.to.as_str()) - .collect(); - for target in branch_targets { - let only_branch_entries = graph - .edges - .iter() - .filter(|e| e.to == target) - .all(|e| parallel_ids.contains(e.from.as_str())); - if !only_branch_entries { + // A node with any normal incoming path still resolves its attributes on + // that path, so branch-only diagnostics do not apply to it. + for target in branches.branch_targets() { + let Some(parents) = branches.branch_only_parents(target) else { continue; - } + }; let Some(node) = graph.nodes.get(target) else { continue; }; - let parents: Vec = graph - .edges - .iter() - .filter(|e| e.to == target && parallel_ids.contains(e.from.as_str())) - .map(|e| e.from.clone()) - .collect::>() - .into_iter() - .collect(); + if node.fidelity() == Some("full") { + diagnostics.push(full_fidelity_diagnostic( + self.name(), + Some(node.id.clone()), + None, + &parents, + )); + } for attr in BRANCH_IGNORED_ATTRS { if !node.attrs.contains_key(*attr) { continue; @@ -133,7 +129,7 @@ impl LintRule for Rule { ), node_id: Some(node.id.clone()), edge: None, - fix: Some(fix_message(attr, &parents)), + fix: Some(THREAD_ID_FIX.to_string()), ..Diagnostic::default() }); } @@ -181,7 +177,7 @@ mod tests { } #[test] - fn warns_on_fidelity_on_branch_node() { + fn accepts_non_full_fidelity_on_branch_node() { let mut g = parallel_graph(); g.nodes .get_mut("branch_a") @@ -191,14 +187,73 @@ mod tests { "fidelity".to_string(), AttrValue::String("truncate".to_string()), ); + + assert!(Rule.apply(&g).is_empty()); + } + + #[test] + fn warns_when_full_fidelity_on_branch_node_degrades() { + let mut g = parallel_graph(); + g.nodes + .get_mut("branch_a") + .expect("graph has branch_a") + .attrs + .insert( + "fidelity".to_string(), + AttrValue::String("full".to_string()), + ); + let d = Rule.apply(&g); + assert_eq!(d.len(), 1); assert_eq!(d[0].severity, Severity::Warning); assert_eq!(d[0].node_id.as_deref(), Some("branch_a")); - assert!(d[0].message.contains("'fidelity'")); + assert!(d[0].message.contains("full")); + assert!(d[0].message.contains("summary:high")); assert!(d[0].fix.as_deref().is_some_and(|f| f.contains("'fork'"))); } + #[test] + fn accepts_every_non_full_fidelity_on_branch_edges() { + for fidelity in [ + "truncate", + "compact", + "summary:low", + "summary:medium", + "summary:high", + ] { + let mut g = parallel_graph(); + g.edges[1].attrs.insert( + "fidelity".to_string(), + AttrValue::String(fidelity.to_string()), + ); + + assert!( + Rule.apply(&g).is_empty(), + "{fidelity} should be accepted on a branch edge" + ); + } + } + + #[test] + fn warns_when_full_fidelity_on_branch_edge_degrades() { + let mut g = parallel_graph(); + g.edges[1].attrs.insert( + "fidelity".to_string(), + AttrValue::String("full".to_string()), + ); + + let d = Rule.apply(&g); + + assert_eq!(d.len(), 1); + assert_eq!( + d[0].edge, + Some(("fork".to_string(), "branch_a".to_string())) + ); + assert!(d[0].message.contains("full")); + assert!(d[0].message.contains("summary:high")); + } + #[test] fn warns_on_thread_id_on_branch_edge() { let mut g = parallel_graph(); @@ -221,6 +276,31 @@ mod tests { ); } + #[test] + fn warns_on_thread_id_on_branch_only_node() { + let mut g = parallel_graph(); + g.nodes + .get_mut("branch_a") + .expect("graph has branch_a") + .attrs + .insert( + "thread_id".to_string(), + AttrValue::String("impl".to_string()), + ); + + let d = Rule.apply(&g); + + assert_eq!(d.len(), 1); + assert_eq!(d[0].node_id.as_deref(), Some("branch_a")); + assert!(d[0].message.contains("'thread_id'")); + assert_eq!( + d[0].fix.as_deref(), + Some( + "Remove 'thread_id': parallel branches inherit the thread resolved when the parallel node started" + ) + ); + } + #[test] fn accepts_fidelity_on_the_parallel_node_itself() { let mut g = parallel_graph(); @@ -265,11 +345,10 @@ mod tests { .attrs .insert( "fidelity".to_string(), - AttrValue::String("truncate".to_string()), + AttrValue::String("full".to_string()), ); let d = Rule.apply(&g); assert_eq!(d.len(), 1); - assert!(d[0].message.contains("'fork', 'fork2'")); let fix = d[0].fix.as_deref().expect("diagnostic has a fix"); assert!(fix.contains("'fork', 'fork2'")); assert!(fix.contains("parallel nodes")); diff --git a/lib/crates/fabro-validate/src/rules/prompt_on_llm_nodes.rs b/lib/components/fabro-validate/src/rules/prompt_on_llm_nodes.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/prompt_on_llm_nodes.rs rename to lib/components/fabro-validate/src/rules/prompt_on_llm_nodes.rs diff --git a/lib/crates/fabro-validate/src/rules/random_selection_no_conditions.rs b/lib/components/fabro-validate/src/rules/random_selection_no_conditions.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/random_selection_no_conditions.rs rename to lib/components/fabro-validate/src/rules/random_selection_no_conditions.rs diff --git a/lib/crates/fabro-validate/src/rules/reachability.rs b/lib/components/fabro-validate/src/rules/reachability.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/reachability.rs rename to lib/components/fabro-validate/src/rules/reachability.rs diff --git a/lib/crates/fabro-validate/src/rules/reserved_keyword_node_id.rs b/lib/components/fabro-validate/src/rules/reserved_keyword_node_id.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/reserved_keyword_node_id.rs rename to lib/components/fabro-validate/src/rules/reserved_keyword_node_id.rs diff --git a/lib/crates/fabro-validate/src/rules/retry_target_exists.rs b/lib/components/fabro-validate/src/rules/retry_target_exists.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/retry_target_exists.rs rename to lib/components/fabro-validate/src/rules/retry_target_exists.rs diff --git a/lib/crates/fabro-validate/src/rules/script_absolute_cd.rs b/lib/components/fabro-validate/src/rules/script_absolute_cd.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/script_absolute_cd.rs rename to lib/components/fabro-validate/src/rules/script_absolute_cd.rs diff --git a/lib/crates/fabro-validate/src/rules/selection_valid.rs b/lib/components/fabro-validate/src/rules/selection_valid.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/selection_valid.rs rename to lib/components/fabro-validate/src/rules/selection_valid.rs diff --git a/lib/crates/fabro-validate/src/rules/start_no_incoming.rs b/lib/components/fabro-validate/src/rules/start_no_incoming.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/start_no_incoming.rs rename to lib/components/fabro-validate/src/rules/start_no_incoming.rs diff --git a/lib/crates/fabro-validate/src/rules/start_node.rs b/lib/components/fabro-validate/src/rules/start_node.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/start_node.rs rename to lib/components/fabro-validate/src/rules/start_node.rs diff --git a/lib/crates/fabro-validate/src/rules/stylesheet_model_known.rs b/lib/components/fabro-validate/src/rules/stylesheet_model_known.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/stylesheet_model_known.rs rename to lib/components/fabro-validate/src/rules/stylesheet_model_known.rs diff --git a/lib/crates/fabro-validate/src/rules/stylesheet_syntax.rs b/lib/components/fabro-validate/src/rules/stylesheet_syntax.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/stylesheet_syntax.rs rename to lib/components/fabro-validate/src/rules/stylesheet_syntax.rs diff --git a/lib/crates/fabro-validate/src/rules/terminal_node.rs b/lib/components/fabro-validate/src/rules/terminal_node.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/terminal_node.rs rename to lib/components/fabro-validate/src/rules/terminal_node.rs diff --git a/lib/crates/fabro-validate/src/rules/test_support.rs b/lib/components/fabro-validate/src/rules/test_support.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/test_support.rs rename to lib/components/fabro-validate/src/rules/test_support.rs diff --git a/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs b/lib/components/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs similarity index 75% rename from lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs rename to lib/components/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs index 457553788..157b2a032 100644 --- a/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs +++ b/lib/components/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs @@ -1,5 +1,6 @@ use fabro_graphviz::graph::Graph; +use super::parallel_branch::ParallelBranches; use crate::{Diagnostic, LintRule, Severity}; pub(super) fn rule() -> Box { @@ -20,9 +21,16 @@ impl LintRule for Rule { fn apply(&self, graph: &Graph) -> Vec { let mut diagnostics = Vec::new(); let graph_default_full = graph.default_fidelity() == Some("full"); + let branches = ParallelBranches::new(graph); + // thread_id is inert on parallel branches, where + // parallel_branch_inert_attribute already says "remove thread_id" — + // advising fidelity="full" there would contradict it. for node in graph.nodes.values() { - if node.thread_id().is_some() && node.fidelity() != Some("full") && !graph_default_full + if node.thread_id().is_some() + && !branches.is_branch_only_node(&node.id) + && node.fidelity() != Some("full") + && !graph_default_full { diagnostics.push(Diagnostic { rule: self.name().to_string(), @@ -41,7 +49,7 @@ impl LintRule for Rule { } for edge in &graph.edges { - if edge.thread_id().is_some() { + if edge.thread_id().is_some() && !branches.is_fork_edge(edge) { let edge_full = edge.fidelity() == Some("full"); let target_full = graph.nodes.get(&edge.to).and_then(|n| n.fidelity()) == Some("full"); @@ -82,12 +90,29 @@ impl LintRule for Rule { #[cfg(test)] mod tests { - use fabro_graphviz::graph::{AttrValue, Edge, Node}; + use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use super::Rule; use crate::rules::test_support::minimal_graph; use crate::{LintRule, Severity}; + fn parallel_graph() -> Graph { + let mut g = minimal_graph(); + let mut fork = Node::new("fork"); + fork.attrs.insert( + "shape".to_string(), + AttrValue::String("component".to_string()), + ); + g.nodes.insert("fork".to_string(), fork); + g.nodes.insert("branch".to_string(), Node::new("branch")); + g.edges = vec![ + Edge::new("start", "fork"), + Edge::new("fork", "branch"), + Edge::new("branch", "exit"), + ]; + g + } + #[test] fn thread_id_requires_fidelity_full_node_warns() { let mut g = minimal_graph(); @@ -206,6 +231,51 @@ mod tests { assert!(d.is_empty()); } + #[test] + fn skips_thread_id_on_parallel_branch_edge() { + let mut g = parallel_graph(); + g.edges[1].attrs.insert( + "thread_id".to_string(), + AttrValue::String("branch-thread".to_string()), + ); + + assert!(Rule.apply(&g).is_empty()); + } + + #[test] + fn skips_thread_id_on_branch_only_node() { + let mut g = parallel_graph(); + g.nodes + .get_mut("branch") + .expect("graph has branch") + .attrs + .insert( + "thread_id".to_string(), + AttrValue::String("branch-thread".to_string()), + ); + + assert!(Rule.apply(&g).is_empty()); + } + + #[test] + fn checks_thread_id_on_branch_node_with_normal_entry() { + let mut g = parallel_graph(); + g.edges.push(Edge::new("start", "branch")); + g.nodes + .get_mut("branch") + .expect("graph has branch") + .attrs + .insert( + "thread_id".to_string(), + AttrValue::String("shared-thread".to_string()), + ); + + let d = Rule.apply(&g); + + assert_eq!(d.len(), 1); + assert_eq!(d[0].node_id.as_deref(), Some("branch")); + } + #[test] fn thread_id_requires_fidelity_full_graph_warns() { let mut g = minimal_graph(); diff --git a/lib/crates/fabro-validate/src/rules/type_known.rs b/lib/components/fabro-validate/src/rules/type_known.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/type_known.rs rename to lib/components/fabro-validate/src/rules/type_known.rs diff --git a/lib/crates/fabro-validate/src/rules/unresolved_file_ref.rs b/lib/components/fabro-validate/src/rules/unresolved_file_ref.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/unresolved_file_ref.rs rename to lib/components/fabro-validate/src/rules/unresolved_file_ref.rs diff --git a/lib/crates/fabro-variable/Cargo.toml b/lib/components/fabro-variable/Cargo.toml similarity index 82% rename from lib/crates/fabro-variable/Cargo.toml rename to lib/components/fabro-variable/Cargo.toml index f709d06a0..7de3fe2a6 100644 --- a/lib/crates/fabro-variable/Cargo.toml +++ b/lib/components/fabro-variable/Cargo.toml @@ -14,8 +14,8 @@ workspace = true [dependencies] chrono.workspace = true -fabro-db = { path = "../fabro-db" } -fabro-types = { path = "../fabro-types" } +fabro-db = { path = "../../foundation/fabro-db" } +fabro-types = { path = "../../foundation/fabro-types" } serde.workspace = true serde_json.workspace = true sqlx.workspace = true diff --git a/lib/crates/fabro-variable/src/lib.rs b/lib/components/fabro-variable/src/lib.rs similarity index 100% rename from lib/crates/fabro-variable/src/lib.rs rename to lib/components/fabro-variable/src/lib.rs diff --git a/lib/crates/fabro-variable/tests/store.rs b/lib/components/fabro-variable/tests/store.rs similarity index 100% rename from lib/crates/fabro-variable/tests/store.rs rename to lib/components/fabro-variable/tests/store.rs diff --git a/lib/crates/fabro-workflow/Cargo.toml b/lib/components/fabro-workflow/Cargo.toml similarity index 75% rename from lib/crates/fabro-workflow/Cargo.toml rename to lib/components/fabro-workflow/Cargo.toml index ca74a4878..932bb0071 100644 --- a/lib/crates/fabro-workflow/Cargo.toml +++ b/lib/components/fabro-workflow/Cargo.toml @@ -13,15 +13,18 @@ readme = "README.md" [lib] doctest = false +[features] +test-support = [] + [lints] workspace = true [dependencies] anyhow.workspace = true -fabro-auth = { path = "../fabro-auth" } +fabro-auth = { path = "../../foundation/fabro-auth" } fabro-acp = { path = "../fabro-acp" } fabro-agent = { path = "../fabro-agent" } -fabro-config = { path = "../fabro-config" } +fabro-config = { path = "../../foundation/fabro-config" } fabro-graphviz = { path = "../fabro-graphviz" } fabro-hooks = { path = "../fabro-hooks" } fabro-validate = { path = "../fabro-validate" } @@ -30,17 +33,17 @@ fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] } fabro-mcp = { path = "../fabro-mcp" } fabro-github = { path = "../fabro-github" } fabro-interview = { path = "../fabro-interview" } -fabro-template = { path = "../fabro-template" } +fabro-template = { path = "../../foundation/fabro-template" } fabro-tool = { path = "../fabro-tool" } -fabro-util = { path = "../fabro-util" } +fabro-util = { path = "../../foundation/fabro-util" } fabro-redact.workspace = true fabro-checkpoint = { path = "../fabro-checkpoint" } fabro-llm = { path = "../fabro-llm" } -fabro-model = { path = "../fabro-model" } -fabro-core = { path = "../fabro-core" } +fabro-model = { path = "../../foundation/fabro-model" } +fabro-core = { path = "../../foundation/fabro-core" } fabro-store = { path = "../fabro-store" } fabro-static.workspace = true -fabro-types = { path = "../fabro-types" } +fabro-types = { path = "../../foundation/fabro-types" } fabro-http.workspace = true thiserror.workspace = true serde.workspace = true @@ -69,11 +72,12 @@ tracing.workspace = true walkdir.workspace = true tempfile = "3" toml.workspace = true -fabro-vault = { path = "../fabro-vault" } +fabro-vault = { path = "../../foundation/fabro-vault" } [dev-dependencies] base64.workspace = true fabro-acp = { path = "../fabro-acp", features = ["test-support"] } -fabro-api = { path = "../fabro-api" } +fabro-workflow = { path = ".", features = ["test-support"] } +fabro-api = { path = "../../foundation/fabro-api" } fabro-environment = { path = "../fabro-environment" } fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "docker", "test-support"] } fabro-mcp = { path = "../fabro-mcp" } @@ -82,7 +86,7 @@ object_store.workspace = true assert_cmd = "2" predicates = "3" httpmock = "0.8" -fabro-macros = { path = "../fabro-macros" } +fabro-macros = { path = "../../foundation/fabro-macros" } fabro-test = { workspace = true } -fabro-types = { path = "../fabro-types", features = ["test-support"] } +fabro-types = { path = "../../foundation/fabro-types", features = ["test-support"] } shlex = "1" diff --git a/lib/crates/fabro-workflow/README.md b/lib/components/fabro-workflow/README.md similarity index 100% rename from lib/crates/fabro-workflow/README.md rename to lib/components/fabro-workflow/README.md diff --git a/lib/crates/fabro-workflow/src/artifact.rs b/lib/components/fabro-workflow/src/artifact.rs similarity index 92% rename from lib/crates/fabro-workflow/src/artifact.rs rename to lib/components/fabro-workflow/src/artifact.rs index 26de9edd8..31b98f6c5 100644 --- a/lib/crates/fabro-workflow/src/artifact.rs +++ b/lib/components/fabro-workflow/src/artifact.rs @@ -142,11 +142,18 @@ pub fn format_artifact_reference(path: &str) -> String { pub fn durable_context_snapshot(context: &Context) -> HashMap { let mut snapshot = context.snapshot(); - snapshot.remove(context::keys::CURRENT_PREAMBLE); + strip_transient_keys(&mut snapshot); normalize_durable_updates(&mut snapshot); snapshot } +/// Remove runtime-only keys that must never reach durable storage. +pub(crate) fn strip_transient_keys(values: &mut HashMap) { + for key in context::keys::TRANSIENT_CONTEXT_KEYS { + values.remove(*key); + } +} + pub fn normalize_durable_updates(updates: &mut HashMap) { for value in updates.values_mut() { normalize_durable_value(value); @@ -160,9 +167,7 @@ pub fn normalize_durable_outcomes(node_outcomes: &mut HashMap) } pub fn normalize_checkpoint_for_resume(checkpoint: &mut Checkpoint) { - checkpoint - .context_values - .remove(context::keys::CURRENT_PREAMBLE); + strip_transient_keys(&mut checkpoint.context_values); normalize_durable_updates(&mut checkpoint.context_values); normalize_durable_outcomes(&mut checkpoint.node_outcomes); } @@ -692,6 +697,59 @@ mod tests { ); } + #[test] + fn durable_context_snapshot_drops_parallel_branch_preambles() { + let context = Context::new(); + context.set( + context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, + serde_json::json!({"branch-a": "runtime only"}), + ); + context.set("response.work", serde_json::json!("durable")); + + let snapshot = durable_context_snapshot(&context); + + assert!(!snapshot.contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES)); + assert_eq!( + snapshot.get("response.work"), + Some(&serde_json::json!("durable")) + ); + } + + #[test] + fn normalize_checkpoint_for_resume_drops_parallel_branch_preambles() { + let mut checkpoint = crate::records::Checkpoint { + timestamp: chrono::Utc::now(), + current_node: "work".to_string(), + completed_nodes: vec!["work".to_string()], + node_retries: HashMap::new(), + context_values: HashMap::from([ + ( + context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES.to_string(), + serde_json::json!({"branch-a": "runtime only"}), + ), + ("response.work".to_string(), serde_json::json!("durable")), + ]), + node_outcomes: HashMap::new(), + next_node_id: Some("exit".to_string()), + git_commit_sha: None, + loop_failure_signatures: HashMap::new(), + restart_failure_signatures: HashMap::new(), + node_visits: HashMap::new(), + }; + + normalize_checkpoint_for_resume(&mut checkpoint); + + assert!( + !checkpoint + .context_values + .contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES) + ); + assert_eq!( + checkpoint.context_values.get("response.work"), + Some(&serde_json::json!("durable")) + ); + } + #[test] fn normalize_checkpoint_for_resume_converts_managed_blob_file_refs_and_drops_preamble() { let blob_id = fabro_types::RunBlobId::new(b"managed"); diff --git a/lib/crates/fabro-workflow/src/artifact_snapshot.rs b/lib/components/fabro-workflow/src/artifact_snapshot.rs similarity index 100% rename from lib/crates/fabro-workflow/src/artifact_snapshot.rs rename to lib/components/fabro-workflow/src/artifact_snapshot.rs diff --git a/lib/crates/fabro-workflow/src/artifact_upload.rs b/lib/components/fabro-workflow/src/artifact_upload.rs similarity index 100% rename from lib/crates/fabro-workflow/src/artifact_upload.rs rename to lib/components/fabro-workflow/src/artifact_upload.rs diff --git a/lib/crates/fabro-workflow/src/billing_rollup.rs b/lib/components/fabro-workflow/src/billing_rollup.rs similarity index 99% rename from lib/crates/fabro-workflow/src/billing_rollup.rs rename to lib/components/fabro-workflow/src/billing_rollup.rs index 9762b27f4..4ba8976bc 100644 --- a/lib/crates/fabro-workflow/src/billing_rollup.rs +++ b/lib/components/fabro-workflow/src/billing_rollup.rs @@ -294,7 +294,7 @@ mod tests { let mut projection = test_projection(); let model = ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: None, }; let stage = projection.stage_entry("agent", 1, first_event_seq(1)); diff --git a/lib/crates/fabro-workflow/src/command_log.rs b/lib/components/fabro-workflow/src/command_log.rs similarity index 100% rename from lib/crates/fabro-workflow/src/command_log.rs rename to lib/components/fabro-workflow/src/command_log.rs diff --git a/lib/crates/fabro-workflow/src/condition.rs b/lib/components/fabro-workflow/src/condition.rs similarity index 100% rename from lib/crates/fabro-workflow/src/condition.rs rename to lib/components/fabro-workflow/src/condition.rs diff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/components/fabro-workflow/src/context.rs similarity index 93% rename from lib/crates/fabro-workflow/src/context.rs rename to lib/components/fabro-workflow/src/context.rs index 1926c42db..87c93e4c7 100644 --- a/lib/crates/fabro-workflow/src/context.rs +++ b/lib/components/fabro-workflow/src/context.rs @@ -25,6 +25,10 @@ pub mod keys { pub const INTERNAL_PARENT_PREAMBLE: &str = "internal.parent_preamble"; pub const INTERNAL_PARALLEL_GROUP_ID: &str = "internal.parallel_group_id"; pub const INTERNAL_PARALLEL_BRANCH_ID: &str = "internal.parallel_branch_id"; + /// Stash of pre-rendered per-branch preambles for a parallel node; see + /// [`super::ParallelBranchPreamble`] for the entry shape and the + /// producer/consumer contract. + pub const INTERNAL_PARALLEL_BRANCH_PREAMBLES: &str = "internal.parallel_branch_preambles"; // --- current.* keys --- pub const CURRENT_PREAMBLE: &str = "current.preamble"; @@ -41,6 +45,10 @@ pub mod keys { pub const PARALLEL_RESULTS: &str = "parallel.results"; pub const PARALLEL_BRANCH_COUNT: &str = "parallel.branch_count"; + /// Runtime-only keys stripped from durable context projections. + pub(crate) const TRANSIENT_CONTEXT_KEYS: &[&str] = + &[CURRENT_PREAMBLE, INTERNAL_PARALLEL_BRANCH_PREAMBLES]; + // --- Prefix constants (for filtering and dynamic keys) --- pub const GRAPH_PREFIX: &str = "graph."; pub const INTERNAL_PREFIX: &str = "internal."; @@ -134,6 +142,7 @@ use std::collections::HashMap; pub use fabro_core::Context; use fabro_graphviz::Fidelity; use fabro_types::{ParallelBranchId, RunId, StageId}; +use serde::{Deserialize, Serialize}; use crate::error::Error; use crate::event::StageScope; @@ -150,6 +159,17 @@ pub(crate) fn context_diff( .collect() } +/// One entry of the [`keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES`] stash. +/// +/// The stash is a JSON array indexed by the parallel node's outgoing-edge +/// order. `null` entries mean the branch inherits the fork's preamble. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ParallelBranchPreamble { + pub(crate) fidelity: Fidelity, + pub(crate) preamble: String, +} + /// Domain-specific typed accessors for workflow context values. pub trait WorkflowContext { fn fidelity(&self) -> Fidelity; diff --git a/lib/crates/fabro-workflow/src/error.rs b/lib/components/fabro-workflow/src/error.rs similarity index 99% rename from lib/crates/fabro-workflow/src/error.rs rename to lib/components/fabro-workflow/src/error.rs index 241787868..75b6d317f 100644 --- a/lib/crates/fabro-workflow/src/error.rs +++ b/lib/components/fabro-workflow/src/error.rs @@ -3,9 +3,11 @@ use std::sync::Arc; use fabro_graphviz::Error as GraphvizError; use fabro_llm::{Error as LlmError, ProviderErrorKind}; +use fabro_model::ModelSelectionError; use fabro_template::TemplateError; pub use fabro_types::failure_signature::FailureSignature; pub use fabro_types::outcome::FailureCategory; +use fabro_types::settings::AmbiguousModelRef; use fabro_types::{ExecOutputTail, FailureReason, RunFailure}; use fabro_util::error::{SharedError, collect_causes, collect_chain, render_with_causes}; use fabro_validate::Diagnostic; @@ -88,8 +90,6 @@ const BUDGET_EXHAUSTED_HINTS: &[&str] = &[ "context length", "budget", "quota exceeded", - "max_turns", - "max turns", "max_tokens", "max tokens", "context window exceeded", @@ -262,6 +262,12 @@ pub enum Error { #[error("Validation failed")] ValidationFailed { diagnostics: Vec }, + #[error("Model selection failed: {0}")] + ModelSelection(#[from] ModelSelectionError), + + #[error("Model reference failed: {0}")] + ModelReference(#[from] AmbiguousModelRef), + #[error("{message}")] Template { message: String, @@ -445,6 +451,8 @@ impl Error { Self::Parse(_) | Self::Validation(_) | Self::ValidationFailed { .. } + | Self::ModelSelection(_) + | Self::ModelReference(_) | Self::Template { .. } | Self::Stylesheet(_) | Self::Checkpoint(_) @@ -466,6 +474,8 @@ impl Error { Self::Parse(_) | Self::Validation(_) | Self::ValidationFailed { .. } + | Self::ModelSelection(_) + | Self::ModelReference(_) | Self::Template { .. } | Self::Stylesheet(_) | Self::Checkpoint(_) @@ -1221,7 +1231,7 @@ mod tests { #[test] fn budget_exhausted_hints_count() { - assert_eq!(BUDGET_EXHAUSTED_HINTS.len(), 12); + assert_eq!(BUDGET_EXHAUSTED_HINTS.len(), 10); } #[test] @@ -1301,22 +1311,6 @@ mod tests { ); } - #[test] - fn classify_reason_max_turns() { - assert_eq!( - classify_failure_reason("hit max_turns limit"), - FailureCategory::BudgetExhausted - ); - } - - #[test] - fn classify_reason_max_turns_space() { - assert_eq!( - classify_failure_reason("max turns reached"), - FailureCategory::BudgetExhausted - ); - } - #[test] fn classify_reason_max_tokens() { assert_eq!( diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/components/fabro-workflow/src/event.rs similarity index 92% rename from lib/crates/fabro-workflow/src/event.rs rename to lib/components/fabro-workflow/src/event.rs index e64b2ae30..a5c1f583e 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/components/fabro-workflow/src/event.rs @@ -18,6 +18,7 @@ pub use self::redaction::{ build_redacted_event_payload, event_payload_from_redacted_json, redacted_event_json, }; pub use self::sink::{ - RunEventLogger, RunEventSink, StoreProgressLogger, append_event, append_event_to_sink, + RunEventLogger, RunEventSink, StoreProgressLogger, append_event, append_event_if, + append_event_to_sink, }; pub use crate::stage_scope::StageScope; diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs similarity index 99% rename from lib/crates/fabro-workflow/src/event/convert.rs rename to lib/components/fabro-workflow/src/event/convert.rs index 5114bdac7..591e5f39e 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -657,12 +657,6 @@ fn event_body_from_event(event: &Event) -> EventBody { AgentEvent::LoopDetected => { EventBody::AgentLoopDetected(fabro_types::AgentLoopDetectedProps { visit: *visit }) } - AgentEvent::TurnLimitReached { max_turns } => { - EventBody::AgentTurnLimitReached(fabro_types::AgentTurnLimitReachedProps { - max_turns: *max_turns, - visit: *visit, - }) - } AgentEvent::SteeringInjected { text, .. } => { EventBody::AgentSteeringInjected(fabro_types::AgentSteeringInjectedProps { text: text.clone(), @@ -2181,7 +2175,7 @@ mod tests { text: "ok".to_string(), model: ModelRef { provider: ProviderId::anthropic(), - model_id: "claude-sonnet".to_string(), + model_id: "claude-sonnet".into(), speed: None, }, usage: LlmTokenCounts::default(), @@ -2211,7 +2205,7 @@ mod tests { text: "ok".to_string(), model: ModelRef { provider: ProviderId::new("custom_proxy"), - model_id: "proxy-model".to_string(), + model_id: "proxy-model".into(), speed: None, }, usage: LlmTokenCounts { @@ -2248,7 +2242,7 @@ mod tests { text: "ok".to_string(), model: ModelRef { provider: ProviderId::new("openrouter"), - model_id: "openai/gpt-5.4".to_string(), + model_id: "openai/gpt-5.4".into(), speed: None, }, usage: LlmTokenCounts { @@ -2302,7 +2296,7 @@ mod tests { text: "ok".to_string(), model: ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: None, }, usage: LlmTokenCounts::default(), diff --git a/lib/crates/fabro-workflow/src/event/emitter.rs b/lib/components/fabro-workflow/src/event/emitter.rs similarity index 100% rename from lib/crates/fabro-workflow/src/event/emitter.rs rename to lib/components/fabro-workflow/src/event/emitter.rs diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/components/fabro-workflow/src/event/events.rs similarity index 100% rename from lib/crates/fabro-workflow/src/event/events.rs rename to lib/components/fabro-workflow/src/event/events.rs diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/components/fabro-workflow/src/event/names.rs similarity index 99% rename from lib/crates/fabro-workflow/src/event/names.rs rename to lib/components/fabro-workflow/src/event/names.rs index 58abe28e7..351e627a6 100644 --- a/lib/crates/fabro-workflow/src/event/names.rs +++ b/lib/components/fabro-workflow/src/event/names.rs @@ -78,7 +78,6 @@ pub fn event_name(event: &Event) -> &'static str { AgentEvent::Error { .. } => "agent.error", AgentEvent::Warning { .. } => "agent.warning", AgentEvent::LoopDetected => "agent.loop.detected", - AgentEvent::TurnLimitReached { .. } => "agent.turn.limit", AgentEvent::SteeringInjected { .. } => "agent.steering.injected", AgentEvent::CompactionStarted { .. } => "agent.compaction.started", AgentEvent::CompactionCompleted { .. } => "agent.compaction.completed", diff --git a/lib/crates/fabro-workflow/src/event/redaction.rs b/lib/components/fabro-workflow/src/event/redaction.rs similarity index 100% rename from lib/crates/fabro-workflow/src/event/redaction.rs rename to lib/components/fabro-workflow/src/event/redaction.rs diff --git a/lib/crates/fabro-workflow/src/event/sink.rs b/lib/components/fabro-workflow/src/event/sink.rs similarity index 96% rename from lib/crates/fabro-workflow/src/event/sink.rs rename to lib/components/fabro-workflow/src/event/sink.rs index 1017b2b29..7d62b7151 100644 --- a/lib/crates/fabro-workflow/src/event/sink.rs +++ b/lib/components/fabro-workflow/src/event/sink.rs @@ -2,7 +2,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use ::fabro_types::{RunEvent, RunId}; +use ::fabro_types::{RunEvent, RunId, RunProjection}; use anyhow::Result; use fabro_store::RunDatabase; use tokio::io::{AsyncWrite, AsyncWriteExt}; @@ -23,6 +23,21 @@ pub async fn append_event(run_store: &RunDatabase, run_id: &RunId, event: &Event .map_err(anyhow::Error::from) } +pub async fn append_event_if( + run_store: &RunDatabase, + run_id: &RunId, + event: &Event, + predicate: impl FnOnce(&RunProjection) -> bool, +) -> Result { + let stored = to_run_event(run_id, event); + let payload = build_redacted_event_payload(&stored, run_id)?; + run_store + .append_event_if(&payload, predicate) + .await + .map(|seq| seq.is_some()) + .map_err(anyhow::Error::from) +} + pub async fn append_event_to_sink( sink: &RunEventSink, run_id: &RunId, diff --git a/lib/crates/fabro-workflow/src/event/stored_fields.rs b/lib/components/fabro-workflow/src/event/stored_fields.rs similarity index 99% rename from lib/crates/fabro-workflow/src/event/stored_fields.rs rename to lib/components/fabro-workflow/src/event/stored_fields.rs index 4ada17b67..285bd9c89 100644 --- a/lib/crates/fabro-workflow/src/event/stored_fields.rs +++ b/lib/components/fabro-workflow/src/event/stored_fields.rs @@ -324,7 +324,7 @@ fn agent_actor_for_event( AgentEvent::AssistantMessage { model, .. } => Some(Principal::Agent { session_id: session_id.map(str::to_string), parent_session_id: parent_session_id.map(str::to_string), - model: Some(model.model_id.clone()), + model: Some(model.model_id.to_string()), }), AgentEvent::ToolCallStarted { .. } | AgentEvent::ToolCallOutputDelta { .. } diff --git a/lib/crates/fabro-workflow/src/event/test_support.rs b/lib/components/fabro-workflow/src/event/test_support.rs similarity index 100% rename from lib/crates/fabro-workflow/src/event/test_support.rs rename to lib/components/fabro-workflow/src/event/test_support.rs diff --git a/lib/crates/fabro-workflow/src/file_resolver.rs b/lib/components/fabro-workflow/src/file_resolver.rs similarity index 100% rename from lib/crates/fabro-workflow/src/file_resolver.rs rename to lib/components/fabro-workflow/src/file_resolver.rs diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/components/fabro-workflow/src/git.rs similarity index 100% rename from lib/crates/fabro-workflow/src/git.rs rename to lib/components/fabro-workflow/src/git.rs diff --git a/lib/crates/fabro-workflow/src/github_token_source.rs b/lib/components/fabro-workflow/src/github_token_source.rs similarity index 100% rename from lib/crates/fabro-workflow/src/github_token_source.rs rename to lib/components/fabro-workflow/src/github_token_source.rs diff --git a/lib/crates/fabro-workflow/src/graph.rs b/lib/components/fabro-workflow/src/graph.rs similarity index 100% rename from lib/crates/fabro-workflow/src/graph.rs rename to lib/components/fabro-workflow/src/graph.rs diff --git a/lib/crates/fabro-workflow/src/graph/routing.rs b/lib/components/fabro-workflow/src/graph/routing.rs similarity index 100% rename from lib/crates/fabro-workflow/src/graph/routing.rs rename to lib/components/fabro-workflow/src/graph/routing.rs diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/components/fabro-workflow/src/handler/agent.rs similarity index 90% rename from lib/crates/fabro-workflow/src/handler/agent.rs rename to lib/components/fabro-workflow/src/handler/agent.rs index 5a063e68e..635b0288f 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/components/fabro-workflow/src/handler/agent.rs @@ -19,6 +19,8 @@ use crate::event::{Emitter, Event, StageScope}; use crate::interview_runtime::WorkflowAgentQuestionRuntime; use crate::outcome::{BilledModelUsage, Outcome, OutcomeExt}; +const LAST_FILE_ROUTING_EXTENSIONS: &[&str] = &["json", "md"]; + /// Result from a `CodergenBackend` invocation. #[allow( clippy::large_enum_variant, @@ -169,8 +171,8 @@ pub(crate) async fn validate_agent_output_sources( } if let Some(path) = last_file_touched { - if let Some(contents) = read_sandbox_file(sandbox, path).await { - return structured_output::validate_response_text(schema, &contents); + if let Some(routing_json) = read_last_file_routing_json(sandbox, path).await { + return structured_output::validate_response_text(schema, &routing_json); } } @@ -181,6 +183,22 @@ async fn read_sandbox_file(sandbox: &Arc, path: &str) -> Option, path: &str) -> Option { + let extension = Path::new(path).extension()?.to_str()?; + if !LAST_FILE_ROUTING_EXTENSIONS + .iter() + .any(|allowed| extension.eq_ignore_ascii_case(allowed)) + { + return None; + } + + let contents = read_sandbox_file(sandbox, path).await?; + structured_output::terminal_json_object(&contents).map(str::to_owned) +} + /// Truncate a string to at most `max_chars` characters (char-boundary safe). pub(crate) fn truncate(s: &str, max_chars: usize) -> &str { if s.len() <= max_chars { @@ -379,7 +397,7 @@ impl Handler for AgentHandler { } else { // 7b. Parse routing directives from response text, falling back to // status.json written by the agent into the sandbox CWD, then to - // the last file the agent wrote. + // a terminal JSON object in an eligible last-written file. let found_in_response = extract_status_fields(&response_text, &mut outcome); if !found_in_response { let mut found_in_status_json = false; @@ -390,9 +408,10 @@ impl Handler for AgentHandler { } if !found_in_status_json { if let Some(ref path) = last_file_touched { - if let Some(contents) = read_sandbox_file(&services.run.sandbox, path).await + if let Some(routing_json) = + read_last_file_routing_json(&services.run.sandbox, path).await { - extract_status_fields(&contents, &mut outcome); + extract_status_fields(&routing_json, &mut outcome); } } } @@ -503,6 +522,67 @@ mod tests { context } + struct LastFileBackend { + path: String, + } + + #[async_trait] + impl CodergenBackend for LastFileBackend { + async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { + Ok(CodergenResult::Text { + text: "Done writing results.".to_string(), + usage: None, + files_touched: vec![self.path.clone()], + last_file_touched: Some(self.path.clone()), + timing: StageTiming::default(), + }) + } + } + + fn sandbox_with_file(path: &str, contents: &str) -> (TempDir, Arc) { + let sandbox_dir = TempDir::new().unwrap(); + std::fs::write(sandbox_dir.path().join(path), contents).unwrap(); + let sandbox: Arc = Arc::new(fabro_agent::LocalSandbox::new( + sandbox_dir.path().to_path_buf(), + )); + (sandbox_dir, sandbox) + } + + async fn execute_with_last_file(path: &str, contents: &str) -> Outcome { + let (_sandbox_dir, sandbox) = sandbox_with_file(path, contents); + + let handler = AgentHandler::new(Some(Box::new(LastFileBackend { + path: path.to_string(), + }))); + let node = Node::new("step"); + let context = test_context(); + let graph = Graph::new("test"); + let tmp = TempDir::new().unwrap(); + + let mut services = EngineServices::test_default(); + services.run = services.run.with_sandbox(sandbox); + + handler + .execute(&node, &context, &graph, tmp.path(), &services) + .await + .unwrap() + } + + async fn validate_routing_with_last_file( + path: &str, + contents: &str, + ) -> Result { + let (_sandbox_dir, sandbox) = sandbox_with_file(path, contents); + + validate_agent_output_sources( + &OutputSchemaKind::Routing, + "Done writing results.", + &sandbox, + Some(path), + ) + .await + } + #[tokio::test] async fn codergen_handler_simulate() { let handler = AgentHandler::new(None); @@ -728,49 +808,13 @@ mod tests { #[tokio::test] async fn codergen_handler_extracts_status_from_last_file_touched() { - struct LastFileBackend; - - #[async_trait] - impl CodergenBackend for LastFileBackend { - async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { - Ok(CodergenResult::Text { - text: "Done writing results.".to_string(), - usage: None, - files_touched: vec!["results.md".to_string()], - last_file_touched: Some("results.md".to_string()), - timing: StageTiming::default(), - }) - } - } - - let sandbox_dir = TempDir::new().unwrap(); - // Write status fields into the file the agent "touched" — no status.json - std::fs::write( - sandbox_dir.path().join("results.md"), + let outcome = execute_with_last_file( + "results.md", r#"# Results {"context_updates": {"verified": "true"}} "#, ) - .unwrap(); - - let handler = AgentHandler::new(Some(Box::new(LastFileBackend))); - let node = Node::new("step"); - let context = test_context(); - let graph = Graph::new("test"); - let tmp = TempDir::new().unwrap(); - - let mut services = EngineServices::test_default(); - services.run = - services - .run - .with_sandbox(std::sync::Arc::new(fabro_agent::LocalSandbox::new( - sandbox_dir.path().to_path_buf(), - ))); - - let outcome = handler - .execute(&node, &context, &graph, tmp.path(), &services) - .await - .unwrap(); + .await; assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded); assert_eq!( @@ -779,6 +823,33 @@ mod tests { ); } + #[tokio::test] + async fn codergen_handler_ignores_nonterminal_status_in_last_markdown_file() { + let outcome = execute_with_last_file( + "results.md", + r#"{"outcome":"failed","failure_reason":"tests failed"} + +All checks passed. +"#, + ) + .await; + + assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded); + assert!(outcome.failure.is_none()); + } + + #[tokio::test] + async fn codergen_handler_ignores_terminal_status_in_disallowed_last_file() { + let outcome = execute_with_last_file( + "command.rs", + r#"{"outcome":"failed","failure_reason":"tests failed"}"#, + ) + .await; + + assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded); + assert!(outcome.failure.is_none()); + } + #[tokio::test] async fn codergen_handler_output_schema_routing_uses_status_json_fallback_when_response_has_no_json() { @@ -816,6 +887,51 @@ mod tests { assert_eq!(outcome.preferred_label.as_deref(), Some("review")); } + #[tokio::test] + async fn validated_routing_accepts_terminal_status_in_json_file_case_insensitively() { + let validated = validate_routing_with_last_file( + "results.JSON", + "# Results\n\n{\"preferred_next_label\":\"review\"}\n", + ) + .await + .unwrap(); + + assert_eq!( + validated.value, + serde_json::json!({"preferred_next_label": "review"}), + ); + } + + #[tokio::test] + async fn validated_routing_ignores_nonterminal_status_in_last_markdown_file() { + let error = validate_routing_with_last_file( + "results.md", + "{\"outcome\":\"failed\",\"failure_reason\":\"tests failed\"}\nAll checks passed.", + ) + .await + .unwrap_err(); + + assert_eq!( + error.kind(), + structured_output::StructuredOutputErrorKind::NoJsonObject, + ); + } + + #[tokio::test] + async fn validated_routing_ignores_terminal_status_in_disallowed_last_file() { + let error = validate_routing_with_last_file( + "command.rs", + r#"{"outcome":"failed","failure_reason":"tests failed"}"#, + ) + .await + .unwrap_err(); + + assert_eq!( + error.kind(), + structured_output::StructuredOutputErrorKind::NoJsonObject, + ); + } + #[tokio::test] async fn codergen_handler_output_schema_routing_rejects_malformed_response_before_status_json_fallback() { diff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/components/fabro-workflow/src/handler/command.rs similarity index 76% rename from lib/crates/fabro-workflow/src/handler/command.rs rename to lib/components/fabro-workflow/src/handler/command.rs index 146324013..54fe0ff20 100644 --- a/lib/crates/fabro-workflow/src/handler/command.rs +++ b/lib/components/fabro-workflow/src/handler/command.rs @@ -6,6 +6,7 @@ use fabro_graphviz::graph::{Graph, Node}; use fabro_types::{CommandTermination, StageTiming}; use fabro_util::shell::shell_quote; +use super::structured_output::{self, StructuredOutputError}; use super::{EngineServices, Handler, NodeTimeoutPolicy}; use crate::command_log::CommandLogRecorder; use crate::context::{Context, keys}; @@ -76,6 +77,8 @@ impl Handler for CommandHandler { ))); } + let output_schema = structured_output::parse_node_output_schema(node)?; + let command = if language == "python" { format!("python3 -c {}", shell_quote(script)) } else { @@ -165,13 +168,31 @@ impl Handler for CommandHandler { } if result.exit_code == Some(0) { - let mut outcome = Outcome::success(); + let validation = output_schema.as_ref().map(|schema| { + ( + schema, + structured_output::validate_response_text(schema, &finalized.output_text), + ) + }); + let mut outcome = if let Some((_, Err(error))) = &validation { + Outcome::fail_deterministic(schema_validation_failure_reason( + script, + error, + &finalized.output_text, + )) + } else { + let mut outcome = Outcome::success(); + outcome.notes = Some(format!("Script completed: {script}")); + outcome + }; outcome.context_updates.insert( keys::COMMAND_OUTPUT.to_string(), serde_json::json!(finalized.output_ref), ); - outcome.notes = Some(format!("Script completed: {script}")); outcome.timing = Some(StageTiming::active_only(0, result.duration_ms)); + if let Some((schema, Ok(validated))) = validation { + structured_output::apply_validated_output(node, schema, &validated, &mut outcome); + } Ok(outcome) } else { let mut reason = format!( @@ -194,6 +215,20 @@ impl Handler for CommandHandler { } } +fn schema_validation_failure_reason( + script: &str, + error: &StructuredOutputError, + output_text: &str, +) -> String { + let mut reason = format!("Script output failed output_schema validation: {script}"); + for message in error.messages() { + reason.push_str("\n- "); + reason.push_str(message); + } + append_output_tail(&mut reason, output_text); + reason +} + fn append_output_tail(reason: &mut String, output: &str) { let output_tail = tail_bytes(output, 4096); if !output_tail.trim().is_empty() { @@ -227,9 +262,12 @@ mod tests { use super::*; use crate::command_log::command_log_path; - use crate::outcome::StageOutcome; + use crate::outcome::{FailureCategory, StageOutcome}; use crate::runtime_store::{RunStoreBackend, RunStoreHandle}; + const PASSED_OUTPUT_SCHEMA: &str = + r#"{"type":"object","required":["passed"],"properties":{"passed":{"type":"boolean"}}}"#; + #[derive(Default)] struct MemoryRunStoreBackend { blobs: Mutex>, @@ -465,6 +503,385 @@ mod tests { assert!(!outcome.context_updates.contains_key("command.stderr")); } + #[tokio::test] + async fn command_custom_output_schema_stores_output_context_key() { + let handler = CommandHandler; + let mut node = Node::new("audit"); + node.attrs.insert( + "script".to_string(), + AttrValue::String(r#"echo '{"passed": true}'"#.to_string()), + ); + node.attrs.insert( + "output_schema".to_string(), + AttrValue::String(PASSED_OUTPUT_SCHEMA.to_string()), + ); + let context = Context::new(); + let graph = Graph::new("test"); + let run_dir = tempfile::tempdir().unwrap(); + let services = make_services(); + + let outcome = handler + .execute(&node, &context, &graph, run_dir.path(), &services) + .await + .unwrap(); + + assert_eq!(outcome.status, StageOutcome::Succeeded); + assert_eq!( + outcome.context_updates.get("output.audit"), + Some(&serde_json::json!({"passed": true})), + ); + let command_output = outcome + .context_updates + .get(keys::COMMAND_OUTPUT) + .expect("command.output should still be set"); + assert!( + command_text(&services, command_output) + .await + .contains(r#"{"passed": true}"#) + ); + } + + #[tokio::test] + async fn command_custom_output_schema_validates_last_json_object() { + let handler = CommandHandler; + let mut node = Node::new("audit"); + node.attrs.insert( + "script".to_string(), + AttrValue::String( + r#"printf '%s\n' 'starting audit' '{"passed": false}' 'final result:' '{"passed": true}'"# + .to_string(), + ), + ); + node.attrs.insert( + "output_schema".to_string(), + AttrValue::String(PASSED_OUTPUT_SCHEMA.to_string()), + ); + let context = Context::new(); + let graph = Graph::new("test"); + let run_dir = tempfile::tempdir().unwrap(); + + let outcome = handler + .execute(&node, &context, &graph, run_dir.path(), &make_services()) + .await + .unwrap(); + + assert_eq!(outcome.status, StageOutcome::Succeeded); + assert_eq!( + outcome.context_updates.get("output.audit"), + Some(&serde_json::json!({"passed": true})), + ); + } + + #[tokio::test] + async fn command_custom_output_schema_failure_is_deterministic() { + let handler = CommandHandler; + let mut node = Node::new("audit"); + node.attrs.insert( + "script".to_string(), + AttrValue::String(r#"echo '{"passed":"yes"}'"#.to_string()), + ); + node.attrs.insert( + "output_schema".to_string(), + AttrValue::String(PASSED_OUTPUT_SCHEMA.to_string()), + ); + node.attrs + .insert("output_retries".to_string(), AttrValue::Integer(7)); + let context = Context::new(); + let graph = Graph::new("test"); + let run_dir = tempfile::tempdir().unwrap(); + let services = make_services(); + + let outcome = handler + .execute(&node, &context, &graph, run_dir.path(), &services) + .await + .unwrap(); + + assert_eq!(outcome.status, StageOutcome::Failed { + retry_requested: false, + }); + assert_eq!( + outcome.failure_category(), + Some(FailureCategory::Deterministic) + ); + let reason = outcome + .failure_reason() + .expect("schema validation failure should have a reason"); + assert!( + reason.contains("Script output failed output_schema validation: echo"), + "unexpected failure reason: {reason}" + ); + assert!( + reason.contains("boolean"), + "validator message should be included: {reason}" + ); + assert!( + reason.contains("## output"), + "output heading missing: {reason}" + ); + assert!( + reason.contains(r#"{"passed":"yes"}"#), + "output tail missing: {reason}" + ); + assert!( + !reason.contains("repair attempt"), + "commands must not claim repair attempts: {reason}" + ); + assert!( + outcome.context_updates.contains_key(keys::COMMAND_OUTPUT), + "command.output should be set on validation failure" + ); + assert!(outcome.timing.is_some()); + assert_eq!(outcome.notes, None); + } + + #[tokio::test] + async fn command_routing_output_schema_no_json_object_fails() { + let handler = CommandHandler; + let mut node = Node::new("route"); + node.attrs.insert( + "script".to_string(), + AttrValue::String("echo not-json".to_string()), + ); + node.attrs.insert( + "output_schema".to_string(), + AttrValue::String("routing".to_string()), + ); + let context = Context::new(); + let graph = Graph::new("test"); + let run_dir = tempfile::tempdir().unwrap(); + + let outcome = handler + .execute(&node, &context, &graph, run_dir.path(), &make_services()) + .await + .unwrap(); + + assert_eq!(outcome.status, StageOutcome::Failed { + retry_requested: false, + }); + assert_eq!( + outcome.failure_category(), + Some(FailureCategory::Deterministic) + ); + let reason = outcome.failure_reason().unwrap(); + assert!(reason.contains("no JSON object found"), "got: {reason}"); + assert!(reason.contains("## output\nnot-json"), "got: {reason}"); + } + + #[tokio::test] + async fn command_routing_output_schema_applies_routing_fields() { + let handler = CommandHandler; + let mut node = Node::new("route"); + node.attrs.insert( + "script".to_string(), + AttrValue::String( + r#"echo '{"preferred_next_label":"fix","context_updates":{"kept_count":2}}'"# + .to_string(), + ), + ); + node.attrs.insert( + "output_schema".to_string(), + AttrValue::String("routing".to_string()), + ); + let context = Context::new(); + let graph = Graph::new("test"); + let run_dir = tempfile::tempdir().unwrap(); + + let outcome = handler + .execute(&node, &context, &graph, run_dir.path(), &make_services()) + .await + .unwrap(); + + assert_eq!(outcome.status, StageOutcome::Succeeded); + assert_eq!(outcome.preferred_label.as_deref(), Some("fix")); + assert_eq!( + outcome.context_updates.get("kept_count"), + Some(&serde_json::json!(2)) + ); + assert!(outcome.context_updates.contains_key(keys::COMMAND_OUTPUT)); + } + + #[tokio::test] + async fn command_routing_output_schema_outcome_failed_override() { + let handler = CommandHandler; + let mut node = Node::new("route"); + node.attrs.insert( + "script".to_string(), + AttrValue::String( + r#"echo '{"outcome":"failed","failure_reason":"tests failed"}'"#.to_string(), + ), + ); + node.attrs.insert( + "output_schema".to_string(), + AttrValue::String("routing".to_string()), + ); + let context = Context::new(); + let graph = Graph::new("test"); + let run_dir = tempfile::tempdir().unwrap(); + + let outcome = handler + .execute(&node, &context, &graph, run_dir.path(), &make_services()) + .await + .unwrap(); + + assert_eq!(outcome.status, StageOutcome::Failed { + retry_requested: false, + }); + assert_eq!(outcome.failure_reason(), Some("tests failed")); + assert_eq!( + outcome.failure_category(), + Some(FailureCategory::Deterministic) + ); + } + + #[tokio::test] + async fn command_invalid_output_schema_fails_before_execution() { + let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult { + stdout: String::new(), + stderr: String::new(), + exit_code: Some(0), + termination: CommandTermination::Exited, + duration_ms: 1, + })); + let handler = CommandHandler; + let mut node = Node::new("audit"); + node.attrs.insert( + "script".to_string(), + AttrValue::String("echo should-not-run".to_string()), + ); + node.attrs.insert( + "output_schema".to_string(), + AttrValue::String("{".to_string()), + ); + let context = Context::new(); + let graph = Graph::new("test"); + let run_dir = tempfile::tempdir().unwrap(); + let mut services = make_spy_services(spy.clone()); + let event_names = Arc::new(std::sync::Mutex::new(Vec::new())); + let captured_event_names = Arc::clone(&event_names); + let emitter = Arc::new(crate::event::Emitter::new(fixtures::RUN_1)); + emitter.on_event(move |event| { + captured_event_names + .lock() + .unwrap() + .push(event.event_name().to_string()); + }); + services.run = services.run.with_emitter(emitter); + + let error = handler + .execute(&node, &context, &graph, run_dir.path(), &services) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("Invalid output_schema"), + "unexpected error: {error}" + ); + assert_eq!( + spy.captured_command(), + None, + "invalid schema must fail before sandbox execution" + ); + assert!( + event_names.lock().unwrap().is_empty(), + "invalid schema must fail before event emission" + ); + } + + #[tokio::test] + async fn command_nonzero_exit_skips_schema_validation() { + let handler = CommandHandler; + let mut node = Node::new("audit"); + node.attrs.insert( + "script".to_string(), + AttrValue::String(r#"echo '{"passed":"bad"}'; exit 1"#.to_string()), + ); + node.attrs.insert( + "output_schema".to_string(), + AttrValue::String(PASSED_OUTPUT_SCHEMA.to_string()), + ); + let context = Context::new(); + let graph = Graph::new("test"); + let run_dir = tempfile::tempdir().unwrap(); + + let outcome = handler + .execute(&node, &context, &graph, run_dir.path(), &make_services()) + .await + .unwrap(); + + assert_eq!(outcome.status, StageOutcome::Failed { + retry_requested: false, + }); + let reason = outcome.failure_reason().unwrap(); + assert!(reason.contains("exit code: 1"), "got: {reason}"); + assert!( + !reason.contains("output_schema validation"), + "nonzero exits must skip schema validation: {reason}" + ); + } + + #[tokio::test] + async fn command_simulate_ignores_output_schema() { + let handler = CommandHandler; + let mut node = Node::new("audit"); + node.attrs.insert( + "script".to_string(), + AttrValue::String("echo should-not-run".to_string()), + ); + node.attrs.insert( + "output_schema".to_string(), + AttrValue::String("{not a valid schema".to_string()), + ); + let context = Context::new(); + let graph = Graph::new("test"); + let run_dir = tempfile::tempdir().unwrap(); + + let outcome = handler + .simulate(&node, &context, &graph, run_dir.path(), &make_services()) + .await + .unwrap(); + + assert_eq!(outcome.status, StageOutcome::Succeeded); + assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]")); + assert_eq!( + outcome.context_updates.get(keys::COMMAND_OUTPUT), + Some(&serde_json::json!("")) + ); + assert!(!outcome.context_updates.contains_key("output.audit")); + } + + #[tokio::test] + async fn command_python_custom_output_schema() { + let handler = CommandHandler; + let mut node = Node::new("audit"); + node.attrs.insert( + "script".to_string(), + AttrValue::String(r#"import json; print(json.dumps({"passed": True}))"#.to_string()), + ); + node.attrs.insert( + "language".to_string(), + AttrValue::String("python".to_string()), + ); + node.attrs.insert( + "output_schema".to_string(), + AttrValue::String(PASSED_OUTPUT_SCHEMA.to_string()), + ); + let context = Context::new(); + let graph = Graph::new("test"); + let run_dir = tempfile::tempdir().unwrap(); + + let outcome = handler + .execute(&node, &context, &graph, run_dir.path(), &make_services()) + .await + .unwrap(); + + assert_eq!(outcome.status, StageOutcome::Succeeded); + assert_eq!( + outcome.context_updates.get("output.audit"), + Some(&serde_json::json!({"passed": true})), + ); + assert!(outcome.context_updates.contains_key(keys::COMMAND_OUTPUT)); + } + #[tokio::test] async fn script_handler_reports_command_duration_as_tool_timing() { let handler = CommandHandler; diff --git a/lib/crates/fabro-workflow/src/handler/conditional.rs b/lib/components/fabro-workflow/src/handler/conditional.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/conditional.rs rename to lib/components/fabro-workflow/src/handler/conditional.rs diff --git a/lib/crates/fabro-workflow/src/handler/exit.rs b/lib/components/fabro-workflow/src/handler/exit.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/exit.rs rename to lib/components/fabro-workflow/src/handler/exit.rs diff --git a/lib/crates/fabro-workflow/src/handler/fan_in.rs b/lib/components/fabro-workflow/src/handler/fan_in.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/fan_in.rs rename to lib/components/fabro-workflow/src/handler/fan_in.rs diff --git a/lib/crates/fabro-workflow/src/handler/human.rs b/lib/components/fabro-workflow/src/handler/human.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/human.rs rename to lib/components/fabro-workflow/src/handler/human.rs diff --git a/lib/crates/fabro-workflow/src/handler/llm/acp.rs b/lib/components/fabro-workflow/src/handler/llm/acp.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/llm/acp.rs rename to lib/components/fabro-workflow/src/handler/llm/acp.rs diff --git a/lib/crates/fabro-workflow/src/handler/llm/activation_lease.rs b/lib/components/fabro-workflow/src/handler/llm/activation_lease.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/llm/activation_lease.rs rename to lib/components/fabro-workflow/src/handler/llm/activation_lease.rs diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs similarity index 96% rename from lib/crates/fabro-workflow/src/handler/llm/api.rs rename to lib/components/fabro-workflow/src/handler/llm/api.rs index 5eda3130f..93ee9fed8 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -24,7 +24,7 @@ use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, ModelRef, ProviderI use fabro_types::settings::run::RunModelControls; use fabro_types::{PermissionLevel, RunId, SessionCapability, StageId, StageTiming}; use serde::de::DeserializeOwned; -use tokio::sync::Mutex as TokioMutex; +use tokio::sync::{Mutex as TokioMutex, mpsc}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -532,16 +532,50 @@ fn emit_agent_tools_available( /// Spawn a task that subscribes to session events and: /// 1. Tracks file changes (write_file/edit_file tool calls) into shared state. /// 2. Forwards non-streaming agent events to the pipeline emitter. +/// +/// The returned handle exposes a per-input barrier. A successful +/// `process_input_with_runtime` emits `ProcessingEnd` after all events for +/// that input, so waiting for the barrier keeps terminal stage events from +/// overtaking queued agent events. +struct EventForwarder { + processing_end_rx: mpsc::UnboundedReceiver<()>, + task: JoinHandle<()>, +} + +impl EventForwarder { + async fn wait_for_processing_end(&mut self) { + if self.processing_end_rx.recv().await.is_none() { + tracing::warn!("Agent event forwarder stopped before processing input events"); + } + } + + fn abort(&self) { + self.task.abort(); + } +} + +impl Drop for EventForwarder { + fn drop(&mut self) { + self.task.abort(); + } +} + fn spawn_event_forwarder( session: &Session, node_id: String, scope: StageScope, emitter: Arc, file_tracking: Arc>, -) { +) -> EventForwarder { let mut rx = session.subscribe(); - tokio::spawn(async move { + let root_session_id = session.id().to_string(); + let (processing_end_tx, processing_end_rx) = mpsc::unbounded_channel(); + let task = tokio::spawn(async move { while let Ok(event) = rx.recv().await { + let is_root_processing_end = event.session_id == root_session_id + && event.parent_session_id.is_none() + && matches!(&event.event, AgentEvent::ProcessingEnd); + // Reset watchdog on every event, including streaming deltas emitter.touch(); @@ -573,8 +607,17 @@ fn spawn_event_forwarder( &scope, ); } + + if is_root_processing_end { + let _ = processing_end_tx.send(()); + } } }); + + EventForwarder { + processing_end_rx, + task, + } } /// LLM backend that delegates to an `agent` Session per invocation. @@ -935,7 +978,7 @@ impl AgentApiBackend { .clone() .unwrap_or_else(|| default_provider.clone()), ), - model_id: request.model.clone(), + model_id: request.model.clone().into(), speed: controls.speed, }), Err(sdk_err) if sdk_err.failover_eligible() && !fallback_chain.is_empty() => { @@ -964,7 +1007,7 @@ impl AgentApiBackend { let max_tokens = node.max_tokens().or_else(|| { self.catalog - .get(&target.model) + .get_on_provider(&ProviderId::new(&target.provider), &target.model) .and_then(|model| model.limits.max_output) }); @@ -983,7 +1026,7 @@ impl AgentApiBackend { response: resp, model: ModelRef { provider: ProviderId::from(target.provider.clone()), - model_id: target.model.clone(), + model_id: target.model.clone().into(), speed: controls.speed, }, }); @@ -1034,9 +1077,11 @@ impl CodergenBackend for AgentApiBackend { let provider_id = provider.provider_id.to_string(); let controls = self.resolve_effective_request_controls(node)?; - let max_tokens = node - .max_tokens() - .or_else(|| self.catalog.get(model).and_then(|m| m.limits.max_output)); + let max_tokens = node.max_tokens().or_else(|| { + self.catalog + .get_on_provider(&provider.provider_id, model) + .and_then(|model| model.limits.max_output) + }); let mut messages = Vec::new(); if let Some(sys) = system_prompt { @@ -1211,7 +1256,7 @@ impl CodergenBackend for AgentApiBackend { let stage_scope = StageScope::for_handler(context, &node.id); // Subscribe to session events: forward to pipeline emitter + track files. - spawn_event_forwarder( + let mut event_forwarder = spawn_event_forwarder( &session, node.id.clone(), stage_scope.clone(), @@ -1282,6 +1327,7 @@ impl CodergenBackend for AgentApiBackend { inference_duration = inference_duration.saturating_add(timing.inference); tool_duration = tool_duration.saturating_add(timing.tool); if process_result.is_ok() { + event_forwarder.wait_for_processing_end().await; total_usage += session.last_input_usage(); UsdMicros::accumulate(&mut total_cost, session.last_input_cost()); } @@ -1313,6 +1359,7 @@ impl CodergenBackend for AgentApiBackend { let mut succeeded = false; bridge.abort(); + event_forwarder.abort(); discard_session(&mut session, &mut lease, emitter); for (index, target) in self.fallback_chain.iter().enumerate() { @@ -1366,7 +1413,7 @@ impl CodergenBackend for AgentApiBackend { bridge.replace(cancel_token.clone(), &session); // Re-subscribe to forward events + track files from the new session - spawn_event_forwarder( + event_forwarder = spawn_event_forwarder( &session, node.id.clone(), stage_scope.clone(), @@ -1418,6 +1465,7 @@ impl CodergenBackend for AgentApiBackend { tool_duration = tool_duration.saturating_add(timing.tool); match process_result { Ok(()) => { + event_forwarder.wait_for_processing_end().await; total_usage += session.last_input_usage(); UsdMicros::accumulate(&mut total_cost, session.last_input_cost()); succeeded = true; @@ -1490,6 +1538,7 @@ impl CodergenBackend for AgentApiBackend { tool_duration = tool_duration.saturating_add(timing.tool); match repair_result { Ok(()) => { + event_forwarder.wait_for_processing_end().await; total_usage += session.last_input_usage(); UsdMicros::accumulate(&mut total_cost, session.last_input_cost()); repair_attempts += 1; @@ -1523,7 +1572,7 @@ impl CodergenBackend for AgentApiBackend { self.catalog.as_ref(), &ModelRef { provider: session.provider_id(), - model_id: session.model().to_string(), + model_id: session.model().into(), speed: billing_controls.speed, }, &total_usage, @@ -1532,6 +1581,7 @@ impl CodergenBackend for AgentApiBackend { // Collect files_touched from the shared tracking state. let (files_touched, last_file_touched) = file_tracking_snapshot(&file_tracking); + drop(event_forwarder); if let Some(lease) = lease.take() { lease.release(); @@ -2529,6 +2579,47 @@ reasoning = false assert!(names.contains(&"close_agent".to_string())); } + #[test] + fn api_backend_provider_pin_wins_over_priority_selection() { + let settings: LlmCatalogSettings = toml::from_str( + r" +[providers.openrouter] +enabled = true +", + ) + .unwrap(); + let backend = AgentApiBackend::new_with_catalog( + "gpt-5.4".to_string(), + ProviderId::from("openrouter"), + Vec::new(), + Arc::new(EnvCredentialSource::new()), + SteeringHub::for_tests(), + Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()), + ); + + let provider = backend.resolve_provider_context("gpt-5.4", None).unwrap(); + + assert_eq!(provider.provider_id, ProviderId::from("openrouter")); + } + + #[test] + fn api_backend_node_provider_attr_overrides_backend_pin() { + let backend = AgentApiBackend::new_with_catalog( + "gpt-5.4".to_string(), + ProviderId::from("openrouter"), + Vec::new(), + Arc::new(EnvCredentialSource::new()), + SteeringHub::for_tests(), + Arc::new(Catalog::from_builtin().unwrap()), + ); + + let provider = backend + .resolve_provider_context("gpt-5.4", Some("openai")) + .unwrap(); + + assert_eq!(provider.provider_id, ProviderId::openai()); + } + #[test] fn api_backend_resolves_custom_catalog_provider_profile() { let settings: LlmCatalogSettings = toml::from_str( @@ -2622,6 +2713,33 @@ reasoning = false assert_eq!(provider.profile_kind, AgentProfileKind::Anthropic); } + #[test] + fn api_backend_preserves_default_provider_for_legacy_model_identifier() { + let settings: LlmCatalogSettings = toml::from_str( + r" +[providers.openrouter] +enabled = true +", + ) + .unwrap(); + let catalog = Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()); + let backend = AgentApiBackend::new_with_catalog( + "openai/gpt-5.4".to_string(), + ProviderId::from("openrouter"), + Vec::new(), + Arc::new(EnvCredentialSource::new()), + SteeringHub::for_tests(), + catalog, + ); + + let provider = backend + .resolve_provider_context("openai/gpt-5.4", None) + .unwrap(); + + assert_eq!(provider.provider_id, ProviderId::from("openrouter")); + assert_eq!(provider.profile_kind, AgentProfileKind::OpenAi); + } + #[test] fn run_model_controls_apply_when_node_omits_controls() { let backend = AgentApiBackend::new_from_env( diff --git a/lib/crates/fabro-workflow/src/handler/llm/changed_files.rs b/lib/components/fabro-workflow/src/handler/llm/changed_files.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/llm/changed_files.rs rename to lib/components/fabro-workflow/src/handler/llm/changed_files.rs diff --git a/lib/crates/fabro-workflow/src/handler/llm/mod.rs b/lib/components/fabro-workflow/src/handler/llm/mod.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/llm/mod.rs rename to lib/components/fabro-workflow/src/handler/llm/mod.rs diff --git a/lib/crates/fabro-workflow/src/handler/llm/preamble.rs b/lib/components/fabro-workflow/src/handler/llm/preamble.rs similarity index 99% rename from lib/crates/fabro-workflow/src/handler/llm/preamble.rs rename to lib/components/fabro-workflow/src/handler/llm/preamble.rs index 6d1bd106d..fbceff0d7 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/preamble.rs +++ b/lib/components/fabro-workflow/src/handler/llm/preamble.rs @@ -597,7 +597,7 @@ mod tests { Catalog::builtin(), &ModelRef { provider: ProviderId::anthropic(), - model_id: model.to_string(), + model_id: model.into(), speed: None, }, &TokenCounts { diff --git a/lib/crates/fabro-workflow/src/handler/llm/router.rs b/lib/components/fabro-workflow/src/handler/llm/router.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/llm/router.rs rename to lib/components/fabro-workflow/src/handler/llm/router.rs diff --git a/lib/crates/fabro-workflow/src/handler/llm/routing.rs b/lib/components/fabro-workflow/src/handler/llm/routing.rs similarity index 85% rename from lib/crates/fabro-workflow/src/handler/llm/routing.rs rename to lib/components/fabro-workflow/src/handler/llm/routing.rs index 952c31939..3ce9618db 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/components/fabro-workflow/src/handler/llm/routing.rs @@ -58,10 +58,20 @@ pub(crate) fn resolve_provider_context( })? .id .clone() - } else if let Some(model) = catalog.get(model) { - model.provider.clone() - } else { + } else if catalog + .get_on_provider(default_provider_id, model) + .is_some() + { + // The run's selected provider is a pin whenever it offers the model. default_provider_id.clone() + } else { + match catalog.select(model, None, &catalog.all_provider_ids()) { + Ok(model) => model.provider.clone(), + Err(fabro_model::ModelSelectionError::UnknownSelector { .. }) => { + default_provider_id.clone() + } + Err(error) => return Err(error.into()), + } }; let provider = catalog.provider(&provider_id).ok_or_else(|| { diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/components/fabro-workflow/src/handler/manager_loop.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/manager_loop.rs rename to lib/components/fabro-workflow/src/handler/manager_loop.rs diff --git a/lib/crates/fabro-workflow/src/handler/mod.rs b/lib/components/fabro-workflow/src/handler/mod.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/mod.rs rename to lib/components/fabro-workflow/src/handler/mod.rs diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/components/fabro-workflow/src/handler/parallel.rs similarity index 74% rename from lib/crates/fabro-workflow/src/handler/parallel.rs rename to lib/components/fabro-workflow/src/handler/parallel.rs index 307647193..6102f1ec4 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/components/fabro-workflow/src/handler/parallel.rs @@ -12,7 +12,7 @@ use tokio::sync::Semaphore; use tokio::task::JoinHandle; use super::{EngineServices, Handler}; -use crate::context::{Context, WorkflowContext, context_diff, keys}; +use crate::context::{Context, ParallelBranchPreamble, WorkflowContext, context_diff, keys}; use crate::error::Error; use crate::event::{Event, RunNoticeCode, RunNoticeLevel, StageScope}; use crate::hook_context::set_hook_node; @@ -36,6 +36,31 @@ struct BranchDispatch { handle: JoinHandle>, } +/// Parse the per-branch preamble stash produced by `FidelityLifecycle`. +/// +/// Outer `None` means the stash is absent, malformed, or has the wrong branch +/// count — every branch then inherits the fork context (legacy behavior). +/// Inner `None` means that single branch inherits. +fn parse_branch_preambles( + value: Option, + branch_count: usize, +) -> Option>> { + let serde_json::Value::Array(entries) = value? else { + return None; + }; + if entries.len() != branch_count { + return None; + } + + entries + .into_iter() + .map(|entry| match entry { + serde_json::Value::Null => Some(None), + entry => serde_json::from_value(entry).ok().map(Some), + }) + .collect() +} + #[async_trait] impl Handler for ParallelHandler { async fn simulate( @@ -92,6 +117,18 @@ async fn run_branches( let max_parallel = usize::try_from(max_parallel).unwrap_or(4).max(1); let semaphore = Arc::new(Semaphore::new(max_parallel)); let shared_graph = Arc::new(graph.clone()); + + let branch_preambles = parse_branch_preambles( + context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), + branches.len(), + ); + // Clear the stash before snapshotting so branch contexts never carry the + // outer array — a nested parallel branch target must not misread it as + // its own. The write-back diff also clears it on the run state. + context.set( + keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, + serde_json::Value::Null, + ); let parent_snapshot = Arc::new(context.snapshot()); let mut dispatches = Vec::with_capacity(branches.len()); @@ -110,6 +147,20 @@ async fn run_branches( keys::INTERNAL_PARALLEL_BRANCH_ID, serde_json::Value::String(parallel_branch_id.to_string()), ); + if let Some(entry) = branch_preambles + .as_ref() + .and_then(|entries| entries.get(branch_index)) + .and_then(Option::as_ref) + { + branch_context.set( + keys::CURRENT_PREAMBLE, + serde_json::Value::String(entry.preamble.clone()), + ); + branch_context.set( + keys::INTERNAL_FIDELITY, + serde_json::Value::String(entry.fidelity.to_string()), + ); + } let mut branch_services = services.clone(); branch_services.dry_run = simulated || services.dry_run; @@ -435,7 +486,7 @@ fn find_join_node(results: &[BranchResult], graph: &Graph) -> Option { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use std::time::Duration; use fabro_graphviz::graph::{AttrValue, Edge}; @@ -517,6 +568,185 @@ mod tests { (node, graph) } + #[derive(Clone, Debug, PartialEq)] + struct BranchContextCapture { + node_id: String, + preamble: String, + fidelity: String, + stash: Option, + } + + struct BranchContextRecordingHandler { + captures: Arc>>, + } + + #[async_trait] + impl Handler for BranchContextRecordingHandler { + async fn execute( + &self, + node: &Node, + context: &Context, + _graph: &Graph, + _run_dir: &Path, + _services: &EngineServices, + ) -> Result { + self.captures.lock().unwrap().push(BranchContextCapture { + node_id: node.id.clone(), + preamble: context.preamble(), + fidelity: context.fidelity().to_string(), + stash: context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), + }); + Ok(Outcome::success()) + } + } + + async fn execute_with_branch_stash( + stash: Option, + duplicate_target: bool, + ) -> (Context, Vec) { + let captures = Arc::new(Mutex::new(Vec::new())); + let recorder = BranchContextRecordingHandler { + captures: Arc::clone(&captures), + }; + let mut registry = super::super::HandlerRegistry::new(Box::new(recorder)); + registry.register( + "record", + Box::new(BranchContextRecordingHandler { + captures: Arc::clone(&captures), + }), + ); + let mut services = EngineServices::test_default(); + services.registry = Arc::new(registry); + + let mut node = Node::new("par"); + node.attrs.insert( + "shape".to_string(), + AttrValue::String("component".to_string()), + ); + let mut branch_a = Node::new("branch_a"); + branch_a + .attrs + .insert("type".to_string(), AttrValue::String("record".to_string())); + let mut branch_b = Node::new("branch_b"); + branch_b + .attrs + .insert("type".to_string(), AttrValue::String("record".to_string())); + + let mut graph = Graph::new("test"); + graph.nodes.insert(node.id.clone(), node.clone()); + graph.nodes.insert(branch_a.id.clone(), branch_a); + graph.nodes.insert(branch_b.id.clone(), branch_b); + graph.edges.push(Edge::new("par", "branch_a")); + graph.edges.push(Edge::new( + "par", + if duplicate_target { + "branch_a" + } else { + "branch_b" + }, + )); + + let context = test_context(); + context.set(keys::CURRENT_PREAMBLE, serde_json::json!("fork preamble")); + context.set(keys::INTERNAL_FIDELITY, serde_json::json!("compact")); + if let Some(stash) = stash { + context.set(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, stash); + } + + let run_dir = tempfile::tempdir().unwrap(); + ParallelHandler + .execute(&node, &context, &graph, run_dir.path(), &services) + .await + .unwrap(); + + let captures = captures.lock().unwrap().clone(); + (context, captures) + } + + #[tokio::test] + async fn parallel_handler_applies_indexed_branch_preambles_and_clears_stash() { + let stash = serde_json::json!([ + {"fidelity": "truncate", "preamble": "branch zero"}, + {"fidelity": "summary:high", "preamble": "branch one"} + ]); + + let (context, mut captures) = execute_with_branch_stash(Some(stash), false).await; + captures.sort_by(|left, right| left.node_id.cmp(&right.node_id)); + + assert_eq!(captures.len(), 2); + assert_eq!(captures[0].node_id, "branch_a"); + assert_eq!(captures[0].preamble, "branch zero"); + assert_eq!(captures[0].fidelity, "truncate"); + assert_eq!(captures[0].stash, Some(serde_json::Value::Null)); + assert_eq!(captures[1].node_id, "branch_b"); + assert_eq!(captures[1].preamble, "branch one"); + assert_eq!(captures[1].fidelity, "summary:high"); + assert_eq!(captures[1].stash, Some(serde_json::Value::Null)); + assert_eq!( + context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), + Some(serde_json::Value::Null) + ); + } + + #[tokio::test] + async fn parallel_handler_uses_edge_index_for_duplicate_targets() { + let stash = serde_json::json!([ + {"fidelity": "truncate", "preamble": "first edge"}, + {"fidelity": "summary:low", "preamble": "second edge"} + ]); + + let (_context, captures) = execute_with_branch_stash(Some(stash), true).await; + let observed = captures + .iter() + .map(|capture| (capture.preamble.as_str(), capture.fidelity.as_str())) + .collect::>(); + + assert_eq!(observed.len(), 2); + assert!(observed.contains(&("first edge", "truncate"))); + assert!(observed.contains(&("second edge", "summary:low"))); + assert!( + captures + .iter() + .all(|capture| capture.stash == Some(serde_json::Value::Null)) + ); + } + + #[tokio::test] + async fn parallel_handler_legacy_stashes_inherit_fork_context() { + for stash in [ + None, + Some(serde_json::Value::Null), + Some(serde_json::json!({ + "fidelity": "truncate", + "preamble": "not an array" + })), + Some(serde_json::json!([ + {"fidelity": "truncate", "preamble": "wrong length"} + ])), + Some(serde_json::json!([ + {"fidelity": "truncate"}, + null + ])), + Some(serde_json::json!([ + {"fidelity": "not-a-fidelity", "preamble": "malformed fidelity"}, + null + ])), + ] { + let (context, captures) = execute_with_branch_stash(stash, false).await; + + assert_eq!(captures.len(), 2); + assert!(captures.iter().all(|capture| { + capture.preamble == "fork preamble" + && capture.fidelity == "compact" + && capture.stash == Some(serde_json::Value::Null) + })); + assert_eq!( + context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), + Some(serde_json::Value::Null) + ); + } + } + #[tokio::test] async fn parallel_handler_no_branches() { let outcome = ParallelHandler diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/components/fabro-workflow/src/handler/prompt.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/prompt.rs rename to lib/components/fabro-workflow/src/handler/prompt.rs diff --git a/lib/crates/fabro-workflow/src/handler/start.rs b/lib/components/fabro-workflow/src/handler/start.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/start.rs rename to lib/components/fabro-workflow/src/handler/start.rs diff --git a/lib/crates/fabro-workflow/src/handler/structured_output.rs b/lib/components/fabro-workflow/src/handler/structured_output.rs similarity index 94% rename from lib/crates/fabro-workflow/src/handler/structured_output.rs rename to lib/components/fabro-workflow/src/handler/structured_output.rs index 546124b43..d36435bf3 100644 --- a/lib/crates/fabro-workflow/src/handler/structured_output.rs +++ b/lib/components/fabro-workflow/src/handler/structured_output.rs @@ -72,7 +72,6 @@ impl StructuredOutputError { self.kind } - #[cfg(test)] #[must_use] pub(crate) fn messages(&self) -> &[String] { &self.messages @@ -264,6 +263,15 @@ fn find_json_objects(text: &str) -> Vec<&str> { results } +/// Return the outermost balanced JSON object that ends the text, ignoring +/// trailing whitespace. +pub(crate) fn terminal_json_object(text: &str) -> Option<&str> { + let trimmed = text.trim_end(); + find_json_objects(trimmed) + .into_iter() + .find(|candidate| trimmed.ends_with(candidate)) +} + pub(crate) fn extract_status_fields(text: &str, outcome: &mut Outcome) -> bool { let candidates = find_json_objects(text); @@ -493,6 +501,31 @@ mod tests { assert!(error.messages()[0].contains("recognized routing field")); } + #[test] + fn terminal_json_object_accepts_final_object_after_prose() { + let object = + terminal_json_object("# Results\n\n{\"context_updates\":{\"verified\":true}}\n\n"); + + assert_eq!(object, Some(r#"{"context_updates":{"verified":true}}"#),); + } + + #[test] + fn terminal_json_object_returns_outermost_nested_object() { + let object = terminal_json_object(r#"Results: {"context_updates":{"verified":true}}"#); + + assert_eq!(object, Some(r#"{"context_updates":{"verified":true}}"#),); + } + + #[test] + fn terminal_json_object_rejects_object_followed_by_content() { + assert_eq!( + terminal_json_object( + "{\"outcome\":\"failed\",\"failure_reason\":\"tests failed\"}\nMore details", + ), + None, + ); + } + #[test] fn routing_json_with_wrong_field_type_is_invalid() { let error = diff --git a/lib/crates/fabro-workflow/src/handler/wait.rs b/lib/components/fabro-workflow/src/handler/wait.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/wait.rs rename to lib/components/fabro-workflow/src/handler/wait.rs diff --git a/lib/crates/fabro-workflow/src/hook_context.rs b/lib/components/fabro-workflow/src/hook_context.rs similarity index 100% rename from lib/crates/fabro-workflow/src/hook_context.rs rename to lib/components/fabro-workflow/src/hook_context.rs diff --git a/lib/crates/fabro-workflow/src/interview_runtime.rs b/lib/components/fabro-workflow/src/interview_runtime.rs similarity index 100% rename from lib/crates/fabro-workflow/src/interview_runtime.rs rename to lib/components/fabro-workflow/src/interview_runtime.rs diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/components/fabro-workflow/src/lib.rs similarity index 99% rename from lib/crates/fabro-workflow/src/lib.rs rename to lib/components/fabro-workflow/src/lib.rs index d5efaaebb..b4d3cc727 100644 --- a/lib/crates/fabro-workflow/src/lib.rs +++ b/lib/components/fabro-workflow/src/lib.rs @@ -332,7 +332,7 @@ pub mod services; mod stage_scope; pub mod static_reference; pub mod steering_hub; -#[doc(hidden)] +#[cfg(any(test, feature = "test-support"))] pub mod test_support; #[doc(hidden)] pub mod transforms; diff --git a/lib/crates/fabro-workflow/src/lifecycle/artifact.rs b/lib/components/fabro-workflow/src/lifecycle/artifact.rs similarity index 100% rename from lib/crates/fabro-workflow/src/lifecycle/artifact.rs rename to lib/components/fabro-workflow/src/lifecycle/artifact.rs diff --git a/lib/crates/fabro-workflow/src/lifecycle/auto_status.rs b/lib/components/fabro-workflow/src/lifecycle/auto_status.rs similarity index 100% rename from lib/crates/fabro-workflow/src/lifecycle/auto_status.rs rename to lib/components/fabro-workflow/src/lifecycle/auto_status.rs diff --git a/lib/crates/fabro-workflow/src/lifecycle/circuit_breaker.rs b/lib/components/fabro-workflow/src/lifecycle/circuit_breaker.rs similarity index 100% rename from lib/crates/fabro-workflow/src/lifecycle/circuit_breaker.rs rename to lib/components/fabro-workflow/src/lifecycle/circuit_breaker.rs diff --git a/lib/crates/fabro-workflow/src/lifecycle/event.rs b/lib/components/fabro-workflow/src/lifecycle/event.rs similarity index 92% rename from lib/crates/fabro-workflow/src/lifecycle/event.rs rename to lib/components/fabro-workflow/src/lifecycle/event.rs index 5828dc86b..f547f5fe5 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/event.rs +++ b/lib/components/fabro-workflow/src/lifecycle/event.rs @@ -14,7 +14,7 @@ use fabro_types::{Principal, RunId, StageTiming}; use super::circuit_breaker::CircuitBreakerLifecycle; use super::git::GitCheckpointResult; -use crate::context::WorkflowContext; +use crate::context::{Context, WorkflowContext}; use crate::event::{Emitter, Event, StageScope}; use crate::graph::{WorkflowGraph, WorkflowNode}; use crate::outcome::{BilledModelUsage, FailureCategory, FailureDetail, Outcome, StageOutcome}; @@ -92,6 +92,16 @@ fn response_from_outcome(node_id: &str, outcome: &Outcome) -> Option { .and_then(|value| value.as_str().map(ToOwned::to_owned)) } +/// Context values for `StageCompleted` events. Unlike +/// `artifact::strip_transient_keys`, this keeps `CURRENT_PREAMBLE` — stage +/// events have always included the active preamble — and drops only the +/// parallel stash, which can embed every branch's rendered preamble. +fn stage_context_values(workflow_context: &Context) -> Option> { + let mut snapshot = workflow_context.snapshot(); + snapshot.remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES); + (!snapshot.is_empty()).then(|| snapshot.into_iter().collect()) +} + pub(super) fn stage_visit(state: &WfRunState, node_id: &str) -> u32 { let visits = state.node_visits.get(node_id).copied().unwrap_or(1); u32::try_from(visits).unwrap_or(u32::MAX) @@ -318,11 +328,7 @@ impl RunLifecycle for EventLifecycle { .collect::>() }), jump_to_node: outcome.jump_to_node.clone(), - context_values: { - let snapshot = state.context.snapshot(); - (!snapshot.is_empty()) - .then(|| snapshot.into_iter().collect::>()) - }, + context_values: stage_context_values(&state.context), node_visits: (!state.node_visits.is_empty()).then(|| { state .node_visits @@ -446,3 +452,26 @@ impl RunLifecycle for EventLifecycle { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stage_context_values_drops_parallel_branch_preambles() { + let workflow_context = Context::new(); + workflow_context.set( + context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, + serde_json::json!([{"fidelity": "summary:high", "preamble": "runtime only"}]), + ); + workflow_context.set("response.work", serde_json::json!("durable")); + + let values = stage_context_values(&workflow_context).expect("snapshot should not be empty"); + + assert!(!values.contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES)); + assert_eq!( + values.get("response.work"), + Some(&serde_json::json!("durable")) + ); + } +} diff --git a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs b/lib/components/fabro-workflow/src/lifecycle/fidelity.rs similarity index 52% rename from lib/crates/fabro-workflow/src/lifecycle/fidelity.rs rename to lib/components/fabro-workflow/src/lifecycle/fidelity.rs index 3f45c8142..37d2c7e33 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs +++ b/lib/components/fabro-workflow/src/lifecycle/fidelity.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -10,10 +11,10 @@ use fabro_core::state::ExecutionState; use fabro_graphviz::graph::types::{Edge as GvEdge, Graph as GvGraph, Node as GvNode}; use crate::artifact; -use crate::context::keys; +use crate::context::{Context, ParallelBranchPreamble, keys}; use crate::graph::{WorkflowGraph, WorkflowNode}; use crate::handler::llm::preamble::build_preamble; -use crate::outcome::BilledModelUsage; +use crate::outcome::{BilledModelUsage, Outcome}; use crate::runtime_store::RunStoreHandle; type WfRunState = ExecutionState>; @@ -61,6 +62,65 @@ impl FidelityLifecycle { "fidelity mutex should not be poisoned: no code panics while holding this lock", ) = flag; } + + /// Render the per-branch preamble stash for a parallel node, indexed by + /// outgoing-edge order (the same order `ParallelHandler` fans out in). + /// `Null` entries inherit the fork's preamble. + fn build_parallel_branch_preambles( + &self, + node_id: &str, + fork_fidelity: keys::Fidelity, + resolved_context: &Context, + resolved_outcomes: &HashMap, + completed_nodes: &[String], + ) -> Vec { + let edges = self.graph.outgoing_edges(node_id); + let mut preambles: Vec = Vec::with_capacity(edges.len()); + let mut rendered: HashMap = HashMap::new(); + + for (branch_index, edge) in edges.into_iter().enumerate() { + let Some(target_node) = self.graph.nodes.get(&edge.to) else { + preambles.push(serde_json::Value::Null); + continue; + }; + let resolution = resolve_parallel_branch_fidelity(edge, target_node, fork_fidelity); + if resolution.requested == Some(keys::Fidelity::Full) { + tracing::warn!( + parallel_node = %node_id, + branch = %edge.to, + branch_index, + effective_fidelity = %keys::Fidelity::Full.degraded(), + "Parallel branch fidelity degraded from full" + ); + } + let Some(branch_fidelity) = resolution.effective else { + preambles.push(serde_json::Value::Null); + continue; + }; + if let Some(&rendered_index) = rendered.get(&branch_fidelity) { + preambles.push(preambles[rendered_index].clone()); + continue; + } + + let entry = ParallelBranchPreamble { + fidelity: branch_fidelity, + preamble: build_preamble( + branch_fidelity, + resolved_context, + &self.graph, + completed_nodes, + resolved_outcomes, + ), + }; + rendered.insert(branch_fidelity, preambles.len()); + preambles.push( + serde_json::to_value(entry) + .expect("ParallelBranchPreamble serialization cannot fail"), + ); + } + + preambles + } } #[async_trait] @@ -78,6 +138,11 @@ impl RunLifecycle for FidelityLifecycle { node: &WorkflowNode, state: &WfRunState, ) -> CoreResult { + state.context.set( + keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, + serde_json::Value::Null, + ); + let incoming = self .incoming_edge_data .lock() @@ -138,7 +203,23 @@ impl RunLifecycle for FidelityLifecycle { .context .set(keys::CURRENT_PREAMBLE, serde_json::json!(preamble)); - // 5. Thread ID resolution via resolve_thread_id: edge → node → graph default → + // 5. Parallel nodes: pre-render per-branch preambles into the stash that + // ParallelHandler consumes at fan-out. + if gv_node.handler_type() == Some("parallel") { + let branch_preambles = self.build_parallel_branch_preambles( + node.id(), + fidelity, + &resolved_context, + &resolved_outcomes, + &state.completed_nodes, + ); + state.context.set( + keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, + serde_json::Value::Array(branch_preambles), + ); + } + + // 6. Thread ID resolution via resolve_thread_id: edge → node → graph default → // class → previous let thread_id = resolve_thread_id( incoming_edge_ref, @@ -147,13 +228,13 @@ impl RunLifecycle for FidelityLifecycle { state.previous_node_id.as_deref(), ); - // 6. Set thread.{tid}.current_node + // 7. Set thread.{tid}.current_node if let Some(ref tid) = thread_id { let key = keys::thread_current_node_key(tid); state.context.set(key, serde_json::json!(node.id())); } - // 7. Set INTERNAL_THREAD_ID (or null) + // 8. Set INTERNAL_THREAD_ID (or null) match thread_id { Some(tid) => { state @@ -167,7 +248,7 @@ impl RunLifecycle for FidelityLifecycle { } } - // 8. Set INTERNAL_NODE_VISIT_COUNT and CURRENT_NODE + // 9. Set INTERNAL_NODE_VISIT_COUNT and CURRENT_NODE let visits = state.node_visits.get(node.id()).copied().unwrap_or(1); state .context @@ -198,6 +279,53 @@ impl RunLifecycle for FidelityLifecycle { } } +#[derive(Debug, Clone, Copy)] +struct ParallelBranchFidelityResolution { + /// The explicit fidelity requested on the edge or node, pre-degradation. + requested: Option, + /// The fidelity to render an entry for; `None` inherits the fork preamble. + effective: Option, +} + +/// Resolve explicit branch fidelity with edge-over-node precedence. +/// +/// Branches with no explicit fidelity inherit the parallel node's preamble. +/// Explicit full fidelity is degraded because concurrent branches cannot share +/// an LLM session. An effective fidelity equal to the parallel node also +/// inherits, avoiding a redundant preamble render. +fn resolve_parallel_branch_fidelity( + edge: &GvEdge, + target_node: &GvNode, + parallel_fidelity: keys::Fidelity, +) -> ParallelBranchFidelityResolution { + let requested = explicit_fidelity(Some(edge), target_node).map(|(fidelity, _)| fidelity); + let effective = requested + .map(keys::Fidelity::degraded) + .filter(|fidelity| *fidelity != parallel_fidelity); + + ParallelBranchFidelityResolution { + requested, + effective, + } +} + +/// Explicit fidelity from the incoming edge attribute, else the node +/// attribute, with the winning source labeled for logging. +fn explicit_fidelity( + incoming_edge: Option<&GvEdge>, + node: &GvNode, +) -> Option<(keys::Fidelity, &'static str)> { + incoming_edge + .and_then(|e| e.fidelity()) + .and_then(|s| s.parse().ok()) + .map(|f| (f, "edge")) + .or_else(|| { + node.fidelity() + .and_then(|s| s.parse().ok()) + .map(|f| (f, "node")) + }) +} + /// Resolve the context fidelity for a node, following the precedence: /// 1. Incoming edge `fidelity` attribute /// 2. Target node `fidelity` attribute @@ -208,13 +336,8 @@ fn resolve_fidelity( node: &GvNode, graph: &GvGraph, ) -> keys::Fidelity { - let (resolved, source) = if let Some(f) = incoming_edge - .and_then(|e| e.fidelity()) - .and_then(|s| s.parse().ok()) - { - (f, "edge") - } else if let Some(f) = node.fidelity().and_then(|s| s.parse().ok()) { - (f, "node") + let (resolved, source) = if let Some((f, source)) = explicit_fidelity(incoming_edge, node) { + (f, source) } else if let Some(f) = graph.default_fidelity().and_then(|s| s.parse().ok()) { (f, "graph") } else { @@ -263,11 +386,214 @@ fn resolve_thread_id( #[cfg(test)] mod tests { + use std::path::Path; + use std::time::Duration; + + use fabro_core::graph::Graph as CoreGraph; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; + use fabro_store::Database; + use fabro_types::fixtures; + use object_store::memory::InMemory; use super::*; + use crate::context::WorkflowContext; use crate::context::keys::Fidelity; + fn str_attr(value: &str) -> AttrValue { + AttrValue::String(value.to_string()) + } + + fn parallel_workflow_graph( + fork_fidelity: Option<&str>, + branch_a_fidelity: Option<&str>, + ) -> WorkflowGraph { + let mut graph = Graph::new("parallel-fidelity"); + let mut start = Node::new("start"); + start + .attrs + .insert("shape".to_string(), str_attr("Mdiamond")); + let mut fork = Node::new("fork"); + fork.attrs + .insert("shape".to_string(), str_attr("component")); + if let Some(fidelity) = fork_fidelity { + fork.attrs + .insert("fidelity".to_string(), str_attr(fidelity)); + } + let mut branch_a = Node::new("branch_a"); + if let Some(fidelity) = branch_a_fidelity { + branch_a + .attrs + .insert("fidelity".to_string(), str_attr(fidelity)); + } + let branch_b = Node::new("branch_b"); + let mut work = Node::new("work"); + work.attrs.insert("shape".to_string(), str_attr("box")); + + graph.nodes.insert(start.id.clone(), start); + graph.nodes.insert(fork.id.clone(), fork); + graph.nodes.insert(branch_a.id.clone(), branch_a); + graph.nodes.insert(branch_b.id.clone(), branch_b); + graph.nodes.insert(work.id.clone(), work); + graph.edges.push(Edge::new("start", "fork")); + graph.edges.push(Edge::new("fork", "branch_a")); + graph.edges.push(Edge::new("fork", "branch_b")); + + WorkflowGraph(Arc::new(graph)) + } + + async fn test_lifecycle(graph: &WorkflowGraph, run_dir: &Path) -> FidelityLifecycle { + let store = Arc::new(Database::new( + Arc::new(InMemory::new()), + "", + Duration::from_millis(1), + None, + )); + let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); + let sandbox: Arc = + Arc::new(fabro_agent::LocalSandbox::new(run_dir.to_path_buf())); + FidelityLifecycle::new( + graph.0.clone(), + sandbox, + RunStoreHandle::local(run_store), + run_dir.to_path_buf(), + ) + } + + #[test] + fn parallel_branch_fidelity_edge_overrides_node() { + let mut node = Node::new("branch"); + node.attrs + .insert("fidelity".to_string(), str_attr("compact")); + let mut edge = Edge::new("fork", "branch"); + edge.attrs + .insert("fidelity".to_string(), str_attr("truncate")); + + let resolved = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::SummaryHigh); + + assert_eq!(resolved.requested, Some(Fidelity::Truncate)); + assert_eq!(resolved.effective, Some(Fidelity::Truncate)); + } + + #[test] + fn parallel_branch_fidelity_without_attribute_inherits() { + let node = Node::new("branch"); + let edge = Edge::new("fork", "branch"); + + let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Compact); + + assert_eq!(resolution.requested, None); + assert_eq!(resolution.effective, None); + } + + #[test] + fn parallel_branch_full_fidelity_degrades_to_summary_high() { + let mut node = Node::new("branch"); + node.attrs.insert("fidelity".to_string(), str_attr("full")); + let edge = Edge::new("fork", "branch"); + + let resolved = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Compact); + + assert_eq!(resolved.requested, Some(Fidelity::Full)); + assert_eq!(resolved.effective, Some(Fidelity::SummaryHigh)); + } + + #[test] + fn parallel_branch_fidelity_equal_to_fork_inherits() { + let mut node = Node::new("branch"); + node.attrs + .insert("fidelity".to_string(), str_attr("summary:high")); + let edge = Edge::new("fork", "branch"); + + let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::SummaryHigh); + + assert_eq!(resolution.requested, Some(Fidelity::SummaryHigh)); + assert_eq!(resolution.effective, None); + } + + #[test] + fn explicit_full_branch_equal_to_degraded_fork_inherits() { + let mut node = Node::new("branch"); + node.attrs.insert("fidelity".to_string(), str_attr("full")); + let edge = Edge::new("fork", "branch"); + + let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::SummaryHigh); + + assert_eq!(resolution.requested, Some(Fidelity::Full)); + assert_eq!(resolution.effective, None); + } + + #[test] + fn full_fork_without_branch_fidelity_does_not_create_entry() { + let node = Node::new("branch"); + let edge = Edge::new("fork", "branch"); + + let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Full); + + assert_eq!(resolution.requested, None); + assert_eq!(resolution.effective, None); + } + + #[tokio::test] + async fn parallel_before_node_rebuilds_branch_preamble_stash() { + let graph = parallel_workflow_graph(None, Some("truncate")); + let run_dir = tempfile::tempdir().unwrap(); + let lifecycle = test_lifecycle(&graph, run_dir.path()).await; + let state: WfRunState = ExecutionState::new(&graph).unwrap(); + let fork = graph.get_node("fork").unwrap(); + + lifecycle.before_node(&fork, &state).await.unwrap(); + state.context.set( + keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, + serde_json::json!(["stale", "entries", "must disappear"]), + ); + lifecycle.before_node(&fork, &state).await.unwrap(); + + let stash = state + .context + .get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES) + .expect("parallel stash should be set"); + let entries = stash.as_array().expect("parallel stash should be an array"); + assert_eq!(entries.len(), 2); + assert!(entries[0].is_object()); + assert!(entries[1].is_null()); + } + + #[tokio::test] + async fn non_parallel_before_node_overwrites_branch_preamble_stash_with_null() { + let graph = parallel_workflow_graph(None, Some("truncate")); + let run_dir = tempfile::tempdir().unwrap(); + let lifecycle = test_lifecycle(&graph, run_dir.path()).await; + let state: WfRunState = ExecutionState::new(&graph).unwrap(); + let fork = graph.get_node("fork").unwrap(); + let work = graph.get_node("work").unwrap(); + + lifecycle.before_node(&fork, &state).await.unwrap(); + lifecycle.before_node(&work, &state).await.unwrap(); + + assert_eq!( + state.context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), + Some(serde_json::Value::Null) + ); + } + + #[tokio::test] + async fn resumed_full_fork_degrades_without_rendering_fallback_branches() { + let graph = parallel_workflow_graph(Some("full"), None); + let run_dir = tempfile::tempdir().unwrap(); + let lifecycle = test_lifecycle(&graph, run_dir.path()).await; + lifecycle.set_degrade_fidelity_on_resume(true); + let state: WfRunState = ExecutionState::new(&graph).unwrap(); + let fork = graph.get_node("fork").unwrap(); + + lifecycle.before_node(&fork, &state).await.unwrap(); + + assert_eq!(state.context.fidelity(), Fidelity::SummaryHigh); + assert_eq!( + state.context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), + Some(serde_json::json!([null, null])) + ); + } + #[test] fn fidelity_defaults_to_compact() { let node = Node::new("work"); diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/components/fabro-workflow/src/lifecycle/git.rs similarity index 100% rename from lib/crates/fabro-workflow/src/lifecycle/git.rs rename to lib/components/fabro-workflow/src/lifecycle/git.rs diff --git a/lib/crates/fabro-workflow/src/lifecycle/hook.rs b/lib/components/fabro-workflow/src/lifecycle/hook.rs similarity index 100% rename from lib/crates/fabro-workflow/src/lifecycle/hook.rs rename to lib/components/fabro-workflow/src/lifecycle/hook.rs diff --git a/lib/crates/fabro-workflow/src/lifecycle/mod.rs b/lib/components/fabro-workflow/src/lifecycle/mod.rs similarity index 100% rename from lib/crates/fabro-workflow/src/lifecycle/mod.rs rename to lib/components/fabro-workflow/src/lifecycle/mod.rs diff --git a/lib/crates/fabro-workflow/src/node_handler.rs b/lib/components/fabro-workflow/src/node_handler.rs similarity index 100% rename from lib/crates/fabro-workflow/src/node_handler.rs rename to lib/components/fabro-workflow/src/node_handler.rs diff --git a/lib/crates/fabro-workflow/src/operations/archive.rs b/lib/components/fabro-workflow/src/operations/archive.rs similarity index 100% rename from lib/crates/fabro-workflow/src/operations/archive.rs rename to lib/components/fabro-workflow/src/operations/archive.rs diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs similarity index 89% rename from lib/crates/fabro-workflow/src/operations/create.rs rename to lib/components/fabro-workflow/src/operations/create.rs index 7d42f2c35..144cf5976 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -303,6 +303,15 @@ fn create_from_source( template_context, goal_override, RenderMode::Structural, + options + .settings + .run + .model + .provider + .as_deref() + .filter(|provider| !provider.is_empty()) + .map(ProviderId::new), + &options.configured_providers, &options.catalog, )?; @@ -325,6 +334,8 @@ pub(super) fn preprocess_and_validate( template_context: TemplateContext, goal_override: Option<&str>, render_mode: RenderMode, + default_provider: Option, + eligible_providers: &[ProviderId], catalog: &Arc, ) -> Result { let mut parsed = pipeline::parse(dot_source)?; @@ -338,6 +349,8 @@ pub(super) fn preprocess_and_validate( render_mode, custom_transforms, catalog: Arc::clone(catalog), + default_provider, + eligible_providers: eligible_providers.iter().cloned().collect(), })?; Ok(pipeline::validate(transformed, catalog.as_ref(), &[])) } @@ -391,7 +404,7 @@ fn persist_validated( validated.graph(), catalog.as_ref(), &configured_providers, - ); + )?; let run_id = run_id.unwrap_or_else(RunId::new); let run_dir = run_dir.unwrap_or_else(|| default_run_dir(&run_id)); @@ -483,6 +496,59 @@ mod tests { Arc::new(Catalog::from_builtin().unwrap()) } + fn portable_model_catalog() -> Arc { + let settings: fabro_model::catalog::LlmCatalogSettings = toml::from_str( + r#" +[providers.openai] +display_name = "OpenAI" +adapter = "openai" +agent_profile = "openai" +priority = 90 + +[providers.openai.models."gpt-5.6-sol"] +display_name = "GPT-5.6 Sol" +family = "gpt-5" +aliases = ["gpt-56-sol"] +default = true + +[providers.openai.models."gpt-5.6-sol".limits] +context_window = 1000 + +[providers.openai.models."gpt-5.6-sol".features] +tools = true +vision = false +reasoning = false + +[providers.openrouter] +display_name = "OpenRouter" +adapter = "openai_compatible" +agent_profile = "openai" +priority = 25 + +[providers.openrouter.models."gpt-5.6-sol"] +api_id = "openai/gpt-5.6-sol" +display_name = "GPT-5.6 Sol (via OpenRouter)" +family = "gpt-5" +aliases = ["gpt-56-sol"] +default = true + +[providers.openrouter.models."gpt-5.6-sol".limits] +context_window = 1000 + +[providers.openrouter.models."gpt-5.6-sol".features] +tools = true +vision = false +reasoning = false +"#, + ) + .unwrap(); + Arc::new(Catalog::from_settings(&settings).unwrap()) + } + + fn test_provider_ids() -> Vec { + Catalog::builtin().all_provider_ids().into_iter().collect() + } + fn validate_dot(dot_source: &str, settings: WorkflowSettings) -> Validated { validate(ValidateInput { workflow: WorkflowInput::DotSource { @@ -511,6 +577,8 @@ mod tests { template_context(Some(&WorkflowSettings::default()), vars), None, RenderMode::Structural, + None, + &test_provider_ids(), &test_catalog(), ) .unwrap() @@ -679,6 +747,8 @@ mod tests { template_context(Some(&WorkflowSettings::default()), HashMap::new()), None, RenderMode::Strict, + None, + &test_provider_ids(), &test_catalog(), ); let Err(err) = result else { @@ -715,6 +785,8 @@ mod tests { template_context(Some(&WorkflowSettings::default()), HashMap::new()), None, RenderMode::Strict, + None, + &test_provider_ids(), &test_catalog(), ); let Err(err) = result else { @@ -1245,7 +1317,7 @@ mod tests { fork_source_ref: None, parent_id: None, provenance: test_support::test_run_provenance(), - configured_providers: Vec::new(), + configured_providers: test_provider_ids(), web_url: None, }, storage_root, @@ -1318,7 +1390,7 @@ mod tests { fork_source_ref: None, parent_id: None, provenance: test_support::test_run_provenance(), - configured_providers: Vec::new(), + configured_providers: test_provider_ids(), web_url: None, }, storage_root.clone(), @@ -1389,6 +1461,126 @@ mod tests { assert!(created.run_dir.is_dir()); } + #[tokio::test] + async fn create_materializes_portable_selectors_for_ready_provider_snapshot_and_pin() { + const MODEL_DOT: &str = r#"digraph Test { + graph [goal="Test"] + start [shape=Mdiamond] + work [prompt="Do work", model="MODEL_SELECTOR"] + exit [shape=Msquare] + start -> work -> exit + }"#; + let catalog = portable_model_catalog(); + let cases = [ + (vec![ProviderId::openai()], None, ProviderId::openai()), + ( + vec![ProviderId::new("openrouter")], + None, + ProviderId::new("openrouter"), + ), + ( + vec![ProviderId::openai(), ProviderId::new("openrouter")], + None, + ProviderId::openai(), + ), + ( + vec![ProviderId::openai(), ProviderId::new("openrouter")], + Some("openrouter"), + ProviderId::new("openrouter"), + ), + ]; + + for selector in ["gpt-56-sol", "openai/gpt-5.6-sol"] { + for (ready, explicit_provider, expected_provider) in &cases { + let dir = tempfile::tempdir().unwrap(); + let mut settings = test_default_settings(); + settings.run.model.name = Some(selector.to_string()); + settings.run.model.provider = explicit_provider.map(str::to_string); + let store = memory_store(); + let created = create( + store.as_ref(), + CreateRunInput { + workflow: WorkflowInput::DotSource { + source: MODEL_DOT.replace("MODEL_SELECTOR", selector), + base_dir: None, + }, + settings, + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: None, + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: None, + run_id: None, + title: None, + automation: None, + git: None, + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + configured_providers: ready.clone(), + web_url: None, + }, + dir.path().join("storage"), + Arc::clone(&catalog), + ) + .await + .unwrap(); + let run_spec = created.persisted.run_spec(); + + assert_eq!( + run_spec.settings.run.model.name.as_deref(), + Some("gpt-5.6-sol"), + "{selector}" + ); + assert_eq!( + run_spec.settings.run.model.provider.as_deref(), + Some(expected_provider.as_str()), + "{selector}" + ); + assert_eq!( + run_spec.graph.nodes["work"] + .attrs + .get("model") + .and_then(AttrValue::as_str), + Some("gpt-5.6-sol"), + "{selector}" + ); + assert_eq!( + run_spec.graph.nodes["work"] + .attrs + .get("provider") + .and_then(AttrValue::as_str), + Some(expected_provider.as_str()), + "{selector}" + ); + + let run_store = store.open_run(&created.run_id).await.unwrap(); + let run_store = run_store.into(); + let reloaded = Persisted::load_from_store(&run_store, &created.run_dir) + .await + .unwrap(); + assert_eq!( + reloaded.run_spec().settings.run.model.provider.as_deref(), + Some(expected_provider.as_str()), + "{selector}" + ); + assert_eq!( + reloaded.run_spec().graph.nodes["work"] + .attrs + .get("provider") + .and_then(AttrValue::as_str), + Some(expected_provider.as_str()), + "{selector}" + ); + assert!( + reloaded.source().contains(selector), + "persisted source should preserve the user's selector '{selector}'" + ); + } + } + } + #[tokio::test] async fn create_persists_secret_tokens_in_run_created_settings_source_form() { let dir = tempfile::tempdir().unwrap(); @@ -1435,7 +1627,7 @@ mod tests { fork_source_ref: None, parent_id: None, provenance: test_support::test_run_provenance(), - configured_providers: Vec::new(), + configured_providers: test_provider_ids(), web_url: None, }, storage_root, @@ -1512,7 +1704,7 @@ mod tests { fork_source_ref: None, parent_id: None, provenance: test_support::test_run_provenance(), - configured_providers: Vec::new(), + configured_providers: test_provider_ids(), web_url: None, }, storage_root, @@ -1559,7 +1751,7 @@ mod tests { fork_source_ref: None, parent_id: None, provenance: test_support::test_run_provenance(), - configured_providers: Vec::new(), + configured_providers: test_provider_ids(), web_url: None, }, storage_root, @@ -1633,7 +1825,7 @@ mod tests { fork_source_ref: None, parent_id: None, provenance: test_support::test_run_provenance(), - configured_providers: Vec::new(), + configured_providers: test_provider_ids(), web_url: None, }, storage_dir.clone(), @@ -1701,7 +1893,7 @@ mod tests { fabro_types::AuthMethod::Github, ), }, - configured_providers: Vec::new(), + configured_providers: test_provider_ids(), web_url: None, }, storage_dir, diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/components/fabro-workflow/src/operations/fork.rs similarity index 100% rename from lib/crates/fabro-workflow/src/operations/fork.rs rename to lib/components/fabro-workflow/src/operations/fork.rs diff --git a/lib/crates/fabro-workflow/src/operations/mod.rs b/lib/components/fabro-workflow/src/operations/mod.rs similarity index 100% rename from lib/crates/fabro-workflow/src/operations/mod.rs rename to lib/components/fabro-workflow/src/operations/mod.rs diff --git a/lib/crates/fabro-workflow/src/operations/resume.rs b/lib/components/fabro-workflow/src/operations/resume.rs similarity index 100% rename from lib/crates/fabro-workflow/src/operations/resume.rs rename to lib/components/fabro-workflow/src/operations/resume.rs diff --git a/lib/crates/fabro-workflow/src/operations/retry.rs b/lib/components/fabro-workflow/src/operations/retry.rs similarity index 100% rename from lib/crates/fabro-workflow/src/operations/retry.rs rename to lib/components/fabro-workflow/src/operations/retry.rs diff --git a/lib/crates/fabro-workflow/src/operations/rewind.rs b/lib/components/fabro-workflow/src/operations/rewind.rs similarity index 100% rename from lib/crates/fabro-workflow/src/operations/rewind.rs rename to lib/components/fabro-workflow/src/operations/rewind.rs diff --git a/lib/crates/fabro-workflow/src/operations/run_store.rs b/lib/components/fabro-workflow/src/operations/run_store.rs similarity index 100% rename from lib/crates/fabro-workflow/src/operations/run_store.rs rename to lib/components/fabro-workflow/src/operations/run_store.rs diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/components/fabro-workflow/src/operations/source.rs similarity index 100% rename from lib/crates/fabro-workflow/src/operations/source.rs rename to lib/components/fabro-workflow/src/operations/source.rs diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs similarity index 89% rename from lib/crates/fabro-workflow/src/operations/start.rs rename to lib/components/fabro-workflow/src/operations/start.rs index 7fa47e938..9c2e3ed8d 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -7,7 +7,7 @@ use fabro_auth::{CredentialSource, EnvCredentialSource, VaultCredentialSource}; use fabro_interview::{AutoApproveInterviewer, Interviewer}; use fabro_llm::client::Client as LlmClient; use fabro_mcp::config::McpServerSettings; -use fabro_model::{Catalog, FallbackTarget, ProviderId}; +use fabro_model::{Catalog, FallbackTarget, ModelSelectionError, ProviderId}; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::from_environment::{ daytona_config_from_environment, docker_config_from_environment_with_secrets, @@ -35,7 +35,6 @@ use crate::event::{ Emitter, Event, EventBody, RunEventLogger, RunEventSink, RunNoticeLevel, append_event_to_sink, }; use crate::handler::HandlerRegistry; -use crate::handler::llm::routing; use crate::outcome::{Outcome, StageOutcome}; use crate::pipeline::{ self, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, PullRequestOptions, @@ -43,12 +42,15 @@ use crate::pipeline::{ }; use crate::records::Checkpoint; use crate::run_control::RunControlState; +use crate::run_materialization::resolve_run_model; use crate::run_metadata::metadata_branch_name; use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions, SetupCommand}; use crate::run_status::{FailureReason, RunStatus}; use crate::runtime_store::RunStoreHandle; use crate::services::FabroRunToolServices; use crate::steering_hub::SteeringHub; +#[cfg(feature = "test-support")] +use crate::test_support as workflow_test_support; use crate::workflow_bundle::{RunDefinition, WorkflowBundle}; struct RunSession { @@ -372,6 +374,13 @@ impl RunSession { let catalog = Arc::clone(&services.catalog); let configured = configured_providers_for_start(services.vault.as_ref(), Arc::clone(&catalog)).await; + #[cfg(feature = "test-support")] + let configured = workflow_test_support::test_configured_provider_ids( + catalog.as_ref(), + configured, + process_env_var("FABRO_TEST_ASSUME_LLM_READY") + .is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "no")), + ); let llm = resolve_start_llm(catalog.as_ref(), &configured, resolved)?; let vault_guard = match services.vault.as_ref() { Some(vault) => Some(vault.read().await), @@ -607,25 +616,15 @@ fn resolve_start_llm( configured: &[ProviderId], settings: &ResolvedRunSettings, ) -> Result { - let model = settings - .model - .name - .clone() - .unwrap_or_else(|| catalog.default_for_configured_ids(configured).id.clone()); - let provider = settings - .model - .provider - .as_deref() - .filter(|value| !value.is_empty()); - - let default_provider_id = catalog - .default_for_configured_ids(configured) - .provider - .clone(); - let provider_context = - routing::resolve_provider_context(catalog, &default_provider_id, &model, provider)?; - let provider_id = provider_context.provider_id; - let fallback_chain = resolve_fallback_chain(catalog, &provider_id, &model, &settings.model); + let eligible = configured.iter().cloned().collect::>(); + let (model, provider_id) = resolve_run_model( + catalog, + &eligible, + settings.model.name.as_deref(), + settings.model.provider.as_deref(), + )?; + let fallback_chain = + resolve_fallback_chain(catalog, &provider_id, &model, &settings.model, &eligible)?; Ok(ResolvedStartLlm { model, @@ -636,44 +635,78 @@ fn resolve_start_llm( fn resolve_fallback_chain( catalog: &Catalog, - _provider: &ProviderId, + provider: &ProviderId, model: &str, settings: &ResolvedRunModelSettings, -) -> Vec { + eligible: &HashSet, +) -> Result, Error> { if settings.fallbacks.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } let registry = CatalogModelRegistry { catalog }; - let primary = catalog.get(model); + let primary = catalog.get_on_provider(provider, model); + let mut chain = Vec::new(); - settings - .fallbacks - .iter() - .filter_map(|model_ref| match model_ref.resolve(®istry).ok()? { + for model_ref in &settings.fallbacks { + match model_ref.resolve(®istry)? { ResolvedModelRef::Provider(provider_name) => { let provider_id = canonical_provider_id(catalog, &provider_name); - let reference = primary?; - catalog - .closest(&provider_id, reference) - .map(|model| FallbackTarget { + if !eligible.contains(&provider_id) { + return Err(ModelSelectionError::ProviderUnavailable { + provider: provider_id, + } + .into()); + } + if let Some(model) = + primary.and_then(|reference| catalog.closest(&provider_id, reference)) + { + chain.push(FallbackTarget { provider: provider_id.to_string(), - model: model.id.clone(), - }) - } - ResolvedModelRef::Model { provider, model } => { - let provider = - provider.map(|provider| canonical_provider_id(catalog, &provider).to_string()); - if let Some(info) = catalog.get(&model) { - let provider = provider.unwrap_or_else(|| info.provider.to_string()); - return Some(FallbackTarget { - provider, - model: info.id.clone(), + model: model.id.to_string(), }); } - provider.map(|provider| FallbackTarget { provider, model }) } - }) - .collect() + ResolvedModelRef::Model { + provider: fallback_provider, + model, + } => { + if let Some(provider) = fallback_provider { + let provider = canonical_provider_id(catalog, &provider); + if !eligible.contains(&provider) { + return Err(ModelSelectionError::ProviderUnavailable { provider }.into()); + } + match catalog.resolve_on_provider(&provider, &model) { + Ok(info) => chain.push(FallbackTarget { + provider: info.provider.to_string(), + model: info.id.to_string(), + }), + Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => { + chain.push(FallbackTarget { + provider: provider.to_string(), + model, + }); + } + Err(error) => return Err(error.into()), + } + } else { + match catalog.select(&model, None, eligible) { + Ok(info) => chain.push(FallbackTarget { + provider: info.provider.to_string(), + model: info.id.to_string(), + }), + Err(ModelSelectionError::UnknownSelector { .. }) => { + chain.push(FallbackTarget { + provider: provider.to_string(), + model, + }); + } + Err(error) => return Err(error.into()), + } + } + } + } + } + Ok(chain) } fn canonical_provider_id(catalog: &Catalog, provider_name: &str) -> ProviderId { @@ -693,13 +726,7 @@ impl ModelRegistry for CatalogModelRegistry<'_> { } fn is_model(&self, token: &str) -> bool { - self.catalog.get(token).is_some() - } - - fn provider_of(&self, token: &str) -> Option { - self.catalog - .get(token) - .map(|model| model.provider.to_string()) + self.catalog.is_model_selector(token) } } @@ -831,7 +858,6 @@ impl RunSession { store_progress_logger.register(self.emitter.as_ref()); let init_options = InitOptions { - run_id: record.run_id, run_store: self.run_store.clone(), dry_run: run_options.dry_run_enabled(), emitter: self.emitter, @@ -1229,6 +1255,59 @@ mod tests { Arc::new(Catalog::from_builtin().expect("default catalog should build")) } + fn test_provider_ids() -> Vec { + Catalog::builtin().all_provider_ids().into_iter().collect() + } + + fn portable_model_catalog() -> Catalog { + let settings: fabro_model::catalog::LlmCatalogSettings = toml::from_str( + r#" +[providers.openai] +display_name = "OpenAI" +adapter = "openai" +agent_profile = "openai" +priority = 90 + +[providers.openai.models."gpt-5.6-sol"] +display_name = "GPT-5.6 Sol" +family = "gpt-5" +aliases = ["gpt-56-sol"] +default = true + +[providers.openai.models."gpt-5.6-sol".limits] +context_window = 1000 + +[providers.openai.models."gpt-5.6-sol".features] +tools = true +vision = false +reasoning = false + +[providers.openrouter] +display_name = "OpenRouter" +adapter = "openai_compatible" +agent_profile = "openai" +priority = 25 + +[providers.openrouter.models."gpt-5.6-sol"] +api_id = "openai/gpt-5.6-sol" +display_name = "GPT-5.6 Sol (via OpenRouter)" +family = "gpt-5" +aliases = ["gpt-56-sol"] +default = true + +[providers.openrouter.models."gpt-5.6-sol".limits] +context_window = 1000 + +[providers.openrouter.models."gpt-5.6-sol".features] +tools = true +vision = false +reasoning = false +"#, + ) + .unwrap(); + Catalog::from_settings(&settings).unwrap() + } + #[test] fn resolve_fallback_chain_resolves_provider_fallbacks() { let catalog = test_catalog(); @@ -1242,7 +1321,9 @@ mod tests { &ProviderId::anthropic(), "claude-opus-4-6", &settings, - ); + &catalog.all_provider_ids(), + ) + .unwrap(); assert_eq!(chain, vec![FallbackTarget { provider: "openai".to_string(), @@ -1263,7 +1344,9 @@ mod tests { &ProviderId::anthropic(), "claude-opus-4-6", &settings, - ); + &catalog.all_provider_ids(), + ) + .unwrap(); assert_eq!(chain, vec![FallbackTarget { provider: "openai".to_string(), @@ -1271,6 +1354,165 @@ mod tests { }]); } + #[test] + fn resolve_fallback_chain_selects_shared_bare_alias_from_ready_providers() { + let catalog = portable_model_catalog(); + let settings = ResolvedRunModelSettings { + fallbacks: vec!["gpt-56-sol".parse::().unwrap()], + ..ResolvedRunModelSettings::default() + }; + + let chain = resolve_fallback_chain( + &catalog, + &ProviderId::openai(), + "gpt-5.6-sol", + &settings, + &HashSet::from([ProviderId::new("openrouter")]), + ) + .unwrap(); + + assert_eq!(chain, vec![FallbackTarget { + provider: "openrouter".to_string(), + model: "gpt-5.6-sol".to_string(), + }]); + } + + #[test] + fn resolve_fallback_chain_resolves_provider_qualified_shared_alias() { + let catalog = portable_model_catalog(); + let settings = ResolvedRunModelSettings { + fallbacks: vec!["openrouter/gpt-56-sol".parse::().unwrap()], + ..ResolvedRunModelSettings::default() + }; + + let chain = resolve_fallback_chain( + &catalog, + &ProviderId::openai(), + "gpt-5.6-sol", + &settings, + &catalog.all_provider_ids(), + ) + .unwrap(); + + assert_eq!(chain, vec![FallbackTarget { + provider: "openrouter".to_string(), + model: "gpt-5.6-sol".to_string(), + }]); + } + + #[test] + fn resolve_fallback_chain_keeps_qualified_legacy_references_as_provider_pins() { + let catalog = test_catalog(); + let settings = ResolvedRunModelSettings { + fallbacks: vec![ + "openai/gpt-5.6-sol".parse::().unwrap(), + "anthropic/claude-fable-5".parse::().unwrap(), + ], + ..ResolvedRunModelSettings::default() + }; + + let chain = resolve_fallback_chain( + catalog.as_ref(), + &ProviderId::anthropic(), + "claude-opus-4-6", + &settings, + &catalog.all_provider_ids(), + ) + .unwrap(); + + assert_eq!(chain, vec![ + FallbackTarget { + provider: "openai".to_string(), + model: "gpt-5.6-sol".to_string(), + }, + FallbackTarget { + provider: "anthropic".to_string(), + model: "claude-fable-5".to_string(), + }, + ]); + } + + #[test] + fn resolve_fallback_chain_propagates_provider_model_ambiguity() { + let settings: fabro_model::catalog::LlmCatalogSettings = toml::from_str( + r#" +[providers.shared] +display_name = "Shared Provider" +adapter = "openai" +agent_profile = "openai" + +[providers.shared.models.default] +display_name = "Default" +family = "test" +default = true + +[providers.shared.models.default.limits] +context_window = 1000 + +[providers.shared.models.default.features] +tools = false +vision = false +reasoning = false + +[providers.other] +display_name = "Other" +adapter = "openai" +agent_profile = "openai" + +[providers.other.models.model] +display_name = "Shared Alias" +family = "test" +aliases = ["shared"] +default = true + +[providers.other.models.model.limits] +context_window = 1000 + +[providers.other.models.model.features] +tools = false +vision = false +reasoning = false +"#, + ) + .unwrap(); + let catalog = Catalog::from_settings(&settings).unwrap(); + let run_model = ResolvedRunModelSettings { + fallbacks: vec!["shared".parse::().unwrap()], + ..ResolvedRunModelSettings::default() + }; + + let error = resolve_fallback_chain( + &catalog, + &ProviderId::new("other"), + "model", + &run_model, + &catalog.all_provider_ids(), + ) + .unwrap_err(); + + assert!(matches!(error, Error::ModelReference(_))); + } + + #[test] + fn materialized_provider_pin_is_not_reselected_when_readiness_changes() { + let catalog = portable_model_catalog(); + let mut settings = ResolvedRunSettings::default(); + settings.model.name = Some("gpt-5.6-sol".to_string()); + settings.model.provider = Some("openai".to_string()); + + let Err(error) = resolve_start_llm(&catalog, &[ProviderId::new("openrouter")], &settings) + else { + panic!("materialized provider pin should remain fixed"); + }; + + assert!(matches!( + error, + Error::ModelSelection(fabro_model::ModelSelectionError::ProviderUnavailable { + provider + }) if provider == ProviderId::openai() + )); + } + #[test] fn resolve_start_llm_infers_provider_from_model_alias() { let overrides: fabro_model::catalog::LlmCatalogSettings = toml::from_str( @@ -1302,9 +1544,9 @@ reasoning = false let mut settings = ResolvedRunSettings::default(); settings.model.name = Some("ac".to_string()); - let resolved = resolve_start_llm(&catalog, &[], &settings).unwrap(); + let resolved = resolve_start_llm(&catalog, &[ProviderId::new("acme")], &settings).unwrap(); - assert_eq!(resolved.model, "ac"); + assert_eq!(resolved.model, "acme-claude"); assert_eq!(resolved.provider_id, ProviderId::new("acme")); } @@ -1529,7 +1771,11 @@ reasoning = false persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await; let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); - let vault = Arc::new(AsyncRwLock::new(token_vault("DEPLOY_TOKEN", "vault-token"))); + let vault = Arc::new(AsyncRwLock::new(start_vault(&[( + "DEPLOY_TOKEN", + "vault-token", + SecretType::Token, + )]))); let session = RunSession::new(&persisted, StartServices { vault: Some(vault), @@ -1587,7 +1833,7 @@ reasoning = false persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await; let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); - let vault = Arc::new(AsyncRwLock::new(temp_vault(&[]))); + let vault = Arc::new(AsyncRwLock::new(start_vault(&[]))); let Err(err) = RunSession::new(&persisted, StartServices { vault: Some(vault), @@ -1703,7 +1949,7 @@ reasoning = false fork_source_ref: None, parent_id: None, provenance: test_support::test_run_provenance(), - configured_providers: Vec::new(), + configured_providers: test_provider_ids(), web_url: None, }, storage_root.to_path_buf(), @@ -1756,7 +2002,7 @@ reasoning = false run_control: None, github_app: None, github_permissions: HashMap::new(), - vault: None, + vault: Some(Arc::new(AsyncRwLock::new(start_vault(&[])))), catalog: test_catalog(), on_node: None, registry_override: Some(registry), @@ -1777,6 +2023,12 @@ reasoning = false temp_vault(&[(name, value, SecretType::Token)]) } + fn start_vault(entries: &[(&str, &str, SecretType)]) -> Vault { + let mut all_entries = vec![("ANTHROPIC_API_KEY", "test-key", SecretType::Token)]; + all_entries.extend_from_slice(entries); + temp_vault(&all_entries) + } + fn vault_secret_lookup(vault: &Vault) -> impl FnMut(&str) -> Option + '_ { move |name| vault_token_lookup(Some(vault), name) } @@ -2183,7 +2435,7 @@ reasoning = false fork_source_ref: None, parent_id: None, provenance: test_support::test_run_provenance(), - configured_providers: Vec::new(), + configured_providers: test_provider_ids(), web_url: None, }, storage_root, diff --git a/lib/crates/fabro-workflow/src/operations/timeline.rs b/lib/components/fabro-workflow/src/operations/timeline.rs similarity index 100% rename from lib/crates/fabro-workflow/src/operations/timeline.rs rename to lib/components/fabro-workflow/src/operations/timeline.rs diff --git a/lib/crates/fabro-workflow/src/operations/validate.rs b/lib/components/fabro-workflow/src/operations/validate.rs similarity index 82% rename from lib/crates/fabro-workflow/src/operations/validate.rs rename to lib/components/fabro-workflow/src/operations/validate.rs index c11315d3c..a95b13920 100644 --- a/lib/crates/fabro-workflow/src/operations/validate.rs +++ b/lib/components/fabro-workflow/src/operations/validate.rs @@ -34,6 +34,11 @@ pub fn validate(input: ValidateInput) -> Result { cwd: input.cwd, }) .map_err(|err| Error::Parse(err.to_string()))?; + let eligible_providers = input + .catalog + .all_provider_ids() + .into_iter() + .collect::>(); preprocess_and_validate( &resolved.raw_source, @@ -47,6 +52,15 @@ pub fn validate(input: ValidateInput) -> Result { template_context(Some(&resolved.settings), input.vars), resolved.goal_override.as_deref(), RenderMode::Structural, + resolved + .settings + .run + .model + .provider + .as_deref() + .filter(|provider| !provider.is_empty()) + .map(fabro_model::ProviderId::new), + &eligible_providers, &input.catalog, ) } diff --git a/lib/crates/fabro-workflow/src/outcome.rs b/lib/components/fabro-workflow/src/outcome.rs similarity index 99% rename from lib/crates/fabro-workflow/src/outcome.rs rename to lib/components/fabro-workflow/src/outcome.rs index 853acd3a6..330969de1 100644 --- a/lib/crates/fabro-workflow/src/outcome.rs +++ b/lib/components/fabro-workflow/src/outcome.rs @@ -156,7 +156,7 @@ mod tests { fn model_ref(provider: ProviderId, model_id: &str, speed: Option) -> ModelRef { ModelRef { provider, - model_id: model_id.to_string(), + model_id: model_id.into(), speed, } } diff --git a/lib/crates/fabro-workflow/src/pipeline/execute.rs b/lib/components/fabro-workflow/src/pipeline/execute.rs similarity index 100% rename from lib/crates/fabro-workflow/src/pipeline/execute.rs rename to lib/components/fabro-workflow/src/pipeline/execute.rs diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs similarity index 99% rename from lib/crates/fabro-workflow/src/pipeline/execute/tests.rs rename to lib/components/fabro-workflow/src/pipeline/execute/tests.rs index e2b96ed37..400050896 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs @@ -256,7 +256,6 @@ async fn execute_test_run_with_options( let initialized = initialize( persisted_workflow(graph, String::new(), &run_options.run_dir, run_id_value), InitOptions { - run_id: run_id_value, run_store: run_store.into(), dry_run: false, emitter: emitter.clone(), @@ -317,7 +316,6 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { let initialized = initialize( persisted_workflow(graph, source, &run_dir, test_run_id("run-test")), InitOptions { - run_id: test_run_id("run-test"), run_store: run_store.into(), dry_run: false, emitter: test_emitter_arc("run-test"), @@ -393,7 +391,6 @@ async fn run_with_lifecycle( let initialized = initialize( persisted_workflow(graph.clone(), String::new(), &run_dir, run_id), InitOptions { - run_id, run_store: run_store.into(), dry_run: false, emitter: emitter.clone(), diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/components/fabro-workflow/src/pipeline/finalize.rs similarity index 99% rename from lib/crates/fabro-workflow/src/pipeline/finalize.rs rename to lib/components/fabro-workflow/src/pipeline/finalize.rs index 6e90991dc..9fdcad717 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/components/fabro-workflow/src/pipeline/finalize.rs @@ -438,6 +438,7 @@ async fn compute_final_patch( (final_patch, diff_summary) } +#[cfg(any(test, feature = "test-support"))] pub(crate) fn billing_from_projection(projection: &RunProjection) -> Option { billing_rollup_from_projection(projection, None).billing_if_present() } diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/components/fabro-workflow/src/pipeline/initialize.rs similarity index 96% rename from lib/crates/fabro-workflow/src/pipeline/initialize.rs rename to lib/components/fabro-workflow/src/pipeline/initialize.rs index 8f1e56ee2..cffb2fd86 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/components/fabro-workflow/src/pipeline/initialize.rs @@ -5,7 +5,8 @@ use std::time::Instant; use fabro_agent::{Sandbox, ToolSecrets}; use fabro_auth::{ - CredentialSource, EnvCredentialSource, VaultCredentialSource, auth_issue_message, + CredentialSource, EnvCredentialSource, ExtraHeadersCredentialSource, VaultCredentialSource, + auth_issue_message, }; use fabro_graphviz::graph; use fabro_hooks::{HookContext, HookDecision, HookEvent, HookExecutionContext, HookRunner}; @@ -260,11 +261,23 @@ fn graph_needs_api_backend(graph: &graph::Graph) -> bool { graph.nodes.values().any(routing::node_needs_api_backend) } -fn build_llm_source(vault: Option>>) -> Arc { - match vault { +/// Trace header attached to every LLM request in a run so gateways that +/// understand it (e.g. OpenRouter broadcast) can group the run's requests +/// into one session. Explicit `extra_headers` provider configuration wins. +const SESSION_ID_HEADER: &str = "x-session-id"; + +fn build_llm_source( + vault: Option>>, + run_id: fabro_types::RunId, +) -> Arc { + let inner: Arc = match vault { Some(vault) => Arc::new(VaultCredentialSource::new(vault)), None => Arc::new(EnvCredentialSource::new()), - } + }; + Arc::new(ExtraHeadersCredentialSource::new( + inner, + HashMap::from([(SESSION_ID_HEADER.to_string(), run_id.to_string())]), + )) } /// INITIALIZE phase: prepare the sandbox, env, and handlers for execution. @@ -277,7 +290,7 @@ pub async fn initialize( options.run_options.run_dir = run_dir.clone(); options.run_options.git = options.git.clone(); - let llm_source = build_llm_source(options.vault.clone()); + let llm_source = build_llm_source(options.vault.clone(), options.run_options.run_id); let tool_secrets = tool_secrets_from_configured_sources(options.vault.as_ref()).await; let catalog = Arc::clone(&options.catalog); let sandbox_git = Arc::new(SandboxGitRuntime::new()); @@ -347,7 +360,7 @@ pub async fn initialize( let sandbox = reconnect_for_run_with_callback( instance, daytona_api_key, - Some(options.run_id), + Some(options.run_options.run_id), Some(Arc::clone(&sandbox_event_callback)), ) .await @@ -811,7 +824,6 @@ mod tests { }); let result = initialize(persisted, InitOptions { - run_id: test_run_id(), run_store: { let store = memory_store(); let inner = store.create_run(&test_run_id()).await.unwrap(); @@ -893,7 +905,6 @@ mod tests { let emitter = Arc::new(crate::event::Emitter::new(test_run_id())); let initialized = initialize(persisted, InitOptions { - run_id: test_run_id(), run_store: { let store = memory_store(); let inner = store.create_run(&test_run_id()).await.unwrap(); @@ -1027,6 +1038,30 @@ mod tests { assert!(!effective_dry_run); } + #[tokio::test] + async fn build_llm_source_appends_run_session_trace_header() { + let mut vault = Vault::from_entries(HashMap::new()); + fabro_auth::vault_set_token(&mut vault, EnvVars::ANTHROPIC_API_KEY, "anthropic-key") + .unwrap(); + let vault = Arc::new(AsyncRwLock::new(vault)); + let run_id = test_run_id(); + let expected_session_id = run_id.to_string(); + + let source = build_llm_source(Some(vault), run_id); + let resolved = source.resolve(test_catalog().as_ref()).await.unwrap(); + + assert!(!resolved.credentials.is_empty()); + for credential in &resolved.credentials { + assert_eq!( + credential + .extra_headers + .get(SESSION_ID_HEADER) + .map(String::as_str), + Some(expected_session_id.as_str()) + ); + } + } + #[tokio::test] async fn initialize_executes_acp_backend_node_from_registry() { let temp = tempfile::tempdir().unwrap(); @@ -1095,7 +1130,6 @@ mod tests { let store = memory_store(); let run_store = store.create_run(&test_run_id()).await.unwrap(); let initialized = initialize(test_persisted(graph, source, &run_dir), InitOptions { - run_id: test_run_id(), run_store: run_store.into(), dry_run: false, emitter: emitter.clone(), @@ -1191,7 +1225,6 @@ mod tests { store_logger.register(&emitter); let initialized = initialize(persisted, InitOptions { - run_id: test_run_id(), run_store: run_store.into(), dry_run: false, emitter: emitter.clone(), @@ -1330,7 +1363,6 @@ mod tests { let emitter = Arc::new(crate::event::Emitter::new(test_run_id())); let result = initialize(persisted, InitOptions { - run_id: test_run_id(), run_store: { let store = memory_store(); let inner = store.create_run(&test_run_id()).await.unwrap(); diff --git a/lib/crates/fabro-workflow/src/pipeline/mod.rs b/lib/components/fabro-workflow/src/pipeline/mod.rs similarity index 82% rename from lib/crates/fabro-workflow/src/pipeline/mod.rs rename to lib/components/fabro-workflow/src/pipeline/mod.rs index ef517e9cc..ac77cc992 100644 --- a/lib/crates/fabro-workflow/src/pipeline/mod.rs +++ b/lib/components/fabro-workflow/src/pipeline/mod.rs @@ -9,9 +9,9 @@ pub(crate) mod types; mod validate; pub use execute::execute; -pub(crate) use finalize::{ - billing_from_projection, build_conclusion_from_store, build_terminal_event, -}; +pub(crate) use finalize::build_conclusion_from_store; +#[cfg(any(test, feature = "test-support"))] +pub(crate) use finalize::{billing_from_projection, build_terminal_event}; pub use finalize::{classify_engine_result, finalize, write_finalize_commit}; pub use initialize::initialize; pub use parse::parse; diff --git a/lib/crates/fabro-workflow/src/pipeline/parse.rs b/lib/components/fabro-workflow/src/pipeline/parse.rs similarity index 100% rename from lib/crates/fabro-workflow/src/pipeline/parse.rs rename to lib/components/fabro-workflow/src/pipeline/parse.rs diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/components/fabro-workflow/src/pipeline/persist.rs similarity index 100% rename from lib/crates/fabro-workflow/src/pipeline/persist.rs rename to lib/components/fabro-workflow/src/pipeline/persist.rs diff --git a/lib/crates/fabro-workflow/src/pipeline/prompts/pr_body.md b/lib/components/fabro-workflow/src/pipeline/prompts/pr_body.md similarity index 100% rename from lib/crates/fabro-workflow/src/pipeline/prompts/pr_body.md rename to lib/components/fabro-workflow/src/pipeline/prompts/pr_body.md diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/components/fabro-workflow/src/pipeline/pull_request.rs similarity index 99% rename from lib/crates/fabro-workflow/src/pipeline/pull_request.rs rename to lib/components/fabro-workflow/src/pipeline/pull_request.rs index 1015b6972..0dcc7745c 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/components/fabro-workflow/src/pipeline/pull_request.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::{Arc, LazyLock}; use fabro_auth::CredentialSource; @@ -5,7 +6,7 @@ use fabro_github::{self as github_app, ssh_url_to_https}; use fabro_graphviz::parser; use fabro_llm::client::Client; use fabro_llm::generate::{GenerateParams, generate_object}; -use fabro_model::Catalog; +use fabro_model::{Catalog, ProviderId}; use fabro_store::RunProjection; use fabro_types::PullRequestLink; use fabro_types::settings::run::MergeStrategy; @@ -66,9 +67,14 @@ const UNKNOWN_MODEL_CTX: usize = 200_000; /// Resolve truncation caps based on the model's context window. Unknown /// models use the baseline 200k context-window assumption. -fn truncation_caps(model: &str, catalog: &Catalog) -> TruncationCaps { +fn truncation_caps( + model: &str, + eligible: &HashSet, + catalog: &Catalog, +) -> TruncationCaps { let ctx = catalog - .get(model) + .select(model, None, eligible) + .ok() .and_then(|m| usize::try_from(m.context_window()).ok()) .unwrap_or(UNKNOWN_MODEL_CTX); @@ -395,7 +401,8 @@ async fn build_pr_content_with_client( let run_spec = run_state.map(|state| state.spec.clone()); let dot_source = run_state.and_then(|state| state.spec.graph_source.clone()); - let caps = truncation_caps(model, catalog); + let eligible = client.provider_ids(); + let caps = truncation_caps(model, &eligible, catalog); let truncated_diff = truncate_chars(diff, caps.diff); let prompt = if let Some(ref plan) = plan_text { @@ -1523,7 +1530,11 @@ mod tests { } ); assert_eq!( - truncation_caps("unknown-model", Catalog::builtin()), + truncation_caps( + "unknown-model", + &Catalog::builtin().all_provider_ids(), + Catalog::builtin(), + ), TruncationCaps { diff: 80_000, plan: 20_000, diff --git a/lib/crates/fabro-workflow/src/pipeline/transform.rs b/lib/components/fabro-workflow/src/pipeline/transform.rs similarity index 82% rename from lib/crates/fabro-workflow/src/pipeline/transform.rs rename to lib/components/fabro-workflow/src/pipeline/transform.rs index 4f14ceeb7..6b76380d0 100644 --- a/lib/crates/fabro-workflow/src/pipeline/transform.rs +++ b/lib/components/fabro-workflow/src/pipeline/transform.rs @@ -63,7 +63,12 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Result TransformOptions { TransformOptions { - current_dir: None, - file_resolver: None, - template_context: fabro_template::TemplateContext::new(), - source_name: None, - render_mode: crate::operations::RenderMode::Strict, - custom_transforms: vec![], - catalog: test_catalog(), + current_dir: None, + file_resolver: None, + template_context: fabro_template::TemplateContext::new(), + source_name: None, + render_mode: crate::operations::RenderMode::Strict, + custom_transforms: vec![], + catalog: test_catalog(), + default_provider: None, + eligible_providers: Catalog::builtin().all_provider_ids(), } } @@ -168,13 +175,15 @@ mod tests { ) .unwrap(); let transformed = transform(parsed, &TransformOptions { - current_dir: Some(dir.path().to_path_buf()), - file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))), - template_context: fabro_template::TemplateContext::new(), - source_name: None, - render_mode: crate::operations::RenderMode::Strict, - custom_transforms: vec![], - catalog: test_catalog(), + current_dir: Some(dir.path().to_path_buf()), + file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))), + template_context: fabro_template::TemplateContext::new(), + source_name: None, + render_mode: crate::operations::RenderMode::Strict, + custom_transforms: vec![], + catalog: test_catalog(), + default_provider: None, + eligible_providers: Catalog::builtin().all_provider_ids(), }) .unwrap(); @@ -215,18 +224,20 @@ mod tests { ) .unwrap(); let transformed = transform(parsed, &TransformOptions { - current_dir: Some(dir.path().to_path_buf()), - file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))), - template_context: fabro_template::TemplateContext::new().with_inputs(HashMap::from([ - ( + current_dir: Some(dir.path().to_path_buf()), + file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))), + template_context: fabro_template::TemplateContext::new().with_inputs(HashMap::from( + [( "task".to_string(), toml::Value::String("Launch".to_string()), - ), - ])), - source_name: None, - render_mode: crate::operations::RenderMode::Strict, - custom_transforms: vec![], - catalog: test_catalog(), + )], + )), + source_name: None, + render_mode: crate::operations::RenderMode::Strict, + custom_transforms: vec![], + catalog: test_catalog(), + default_provider: None, + eligible_providers: Catalog::builtin().all_provider_ids(), }) .unwrap(); @@ -350,13 +361,15 @@ mod tests { ) .unwrap(); let transformed = transform(parsed, &TransformOptions { - current_dir: Some(dir.path().to_path_buf()), - file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))), - template_context: fabro_template::TemplateContext::new(), - source_name: None, - render_mode: crate::operations::RenderMode::Structural, - custom_transforms: vec![], - catalog: test_catalog(), + current_dir: Some(dir.path().to_path_buf()), + file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))), + template_context: fabro_template::TemplateContext::new(), + source_name: None, + render_mode: crate::operations::RenderMode::Structural, + custom_transforms: vec![], + catalog: test_catalog(), + default_provider: None, + eligible_providers: Catalog::builtin().all_provider_ids(), }) .unwrap(); diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/components/fabro-workflow/src/pipeline/types.rs similarity index 95% rename from lib/crates/fabro-workflow/src/pipeline/types.rs rename to lib/components/fabro-workflow/src/pipeline/types.rs index 8449453a8..2b881a24b 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/components/fabro-workflow/src/pipeline/types.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -249,7 +249,6 @@ pub struct SandboxEnvSpec { } pub struct InitOptions { - pub run_id: RunId, pub run_store: RunStoreHandle, pub dry_run: bool, pub emitter: Arc, @@ -324,13 +323,15 @@ pub struct Finalized { /// Options for the TRANSFORM phase. pub struct TransformOptions { - pub current_dir: Option, - pub file_resolver: Option>, - pub template_context: TemplateContext, - pub source_name: Option, - pub render_mode: RenderMode, - pub custom_transforms: Vec>, - pub catalog: Arc, + pub current_dir: Option, + pub file_resolver: Option>, + pub template_context: TemplateContext, + pub source_name: Option, + pub render_mode: RenderMode, + pub custom_transforms: Vec>, + pub catalog: Arc, + pub default_provider: Option, + pub eligible_providers: HashSet, } /// Options for the FINALIZE phase. diff --git a/lib/crates/fabro-workflow/src/pipeline/validate.rs b/lib/components/fabro-workflow/src/pipeline/validate.rs similarity index 86% rename from lib/crates/fabro-workflow/src/pipeline/validate.rs rename to lib/components/fabro-workflow/src/pipeline/validate.rs index 14999afae..06120e8ce 100644 --- a/lib/crates/fabro-workflow/src/pipeline/validate.rs +++ b/lib/components/fabro-workflow/src/pipeline/validate.rs @@ -42,13 +42,15 @@ mod tests { let catalog = test_catalog(); let parsed = parse(dot).unwrap(); let transformed = transform::transform(parsed, &TransformOptions { - current_dir: None, - file_resolver: None, - template_context: fabro_template::TemplateContext::new(), - source_name: None, - render_mode: crate::operations::RenderMode::Strict, - custom_transforms: vec![], - catalog: std::sync::Arc::clone(&catalog), + current_dir: None, + file_resolver: None, + template_context: fabro_template::TemplateContext::new(), + source_name: None, + render_mode: crate::operations::RenderMode::Strict, + custom_transforms: vec![], + catalog: std::sync::Arc::clone(&catalog), + default_provider: None, + eligible_providers: catalog.all_provider_ids(), }) .unwrap(); validate(transformed, catalog.as_ref(), &[]) diff --git a/lib/crates/fabro-workflow/src/pull_request.rs b/lib/components/fabro-workflow/src/pull_request.rs similarity index 100% rename from lib/crates/fabro-workflow/src/pull_request.rs rename to lib/components/fabro-workflow/src/pull_request.rs diff --git a/lib/crates/fabro-workflow/src/records/checkpoint.rs b/lib/components/fabro-workflow/src/records/checkpoint.rs similarity index 100% rename from lib/crates/fabro-workflow/src/records/checkpoint.rs rename to lib/components/fabro-workflow/src/records/checkpoint.rs diff --git a/lib/crates/fabro-workflow/src/records/conclusion.rs b/lib/components/fabro-workflow/src/records/conclusion.rs similarity index 100% rename from lib/crates/fabro-workflow/src/records/conclusion.rs rename to lib/components/fabro-workflow/src/records/conclusion.rs diff --git a/lib/crates/fabro-workflow/src/records/mod.rs b/lib/components/fabro-workflow/src/records/mod.rs similarity index 100% rename from lib/crates/fabro-workflow/src/records/mod.rs rename to lib/components/fabro-workflow/src/records/mod.rs diff --git a/lib/crates/fabro-workflow/src/records/run.rs b/lib/components/fabro-workflow/src/records/run.rs similarity index 100% rename from lib/crates/fabro-workflow/src/records/run.rs rename to lib/components/fabro-workflow/src/records/run.rs diff --git a/lib/crates/fabro-workflow/src/records/start.rs b/lib/components/fabro-workflow/src/records/start.rs similarity index 100% rename from lib/crates/fabro-workflow/src/records/start.rs rename to lib/components/fabro-workflow/src/records/start.rs diff --git a/lib/crates/fabro-workflow/src/retry.rs b/lib/components/fabro-workflow/src/retry.rs similarity index 100% rename from lib/crates/fabro-workflow/src/retry.rs rename to lib/components/fabro-workflow/src/retry.rs diff --git a/lib/crates/fabro-workflow/src/run_control.rs b/lib/components/fabro-workflow/src/run_control.rs similarity index 100% rename from lib/crates/fabro-workflow/src/run_control.rs rename to lib/components/fabro-workflow/src/run_control.rs diff --git a/lib/crates/fabro-workflow/src/run_dir.rs b/lib/components/fabro-workflow/src/run_dir.rs similarity index 100% rename from lib/crates/fabro-workflow/src/run_dir.rs rename to lib/components/fabro-workflow/src/run_dir.rs diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/components/fabro-workflow/src/run_lookup.rs similarity index 100% rename from lib/crates/fabro-workflow/src/run_lookup.rs rename to lib/components/fabro-workflow/src/run_lookup.rs diff --git a/lib/crates/fabro-workflow/src/run_materialization.rs b/lib/components/fabro-workflow/src/run_materialization.rs similarity index 55% rename from lib/crates/fabro-workflow/src/run_materialization.rs rename to lib/components/fabro-workflow/src/run_materialization.rs index b6f9aa873..7e82964ac 100644 --- a/lib/crates/fabro-workflow/src/run_materialization.rs +++ b/lib/components/fabro-workflow/src/run_materialization.rs @@ -1,15 +1,19 @@ +use std::collections::HashSet; + use fabro_graphviz::graph::Graph; -use fabro_model::{Catalog, ProviderId}; +use fabro_model::{Catalog, ModelSelectionError, ProviderId}; use fabro_types::WorkflowSettings; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunGoal; +use crate::error::Error; + pub fn materialize_run( mut settings: WorkflowSettings, graph: &Graph, catalog: &Catalog, configured_providers: &[ProviderId], -) -> WorkflowSettings { +) -> Result { let configured_model = settings.run.model.name.take(); let configured_provider = settings.run.model.provider.take(); let graph_provider = graph @@ -24,26 +28,13 @@ pub fn materialize_run( .map(str::to_string); let provider = configured_provider.or(graph_provider); - let model = configured_model.or(graph_model).unwrap_or_else(|| { - provider - .as_deref() - .map(ProviderId::from) - .and_then(|provider| catalog.default_for_provider(&provider)) - .unwrap_or_else(|| catalog.default_for_configured_ids(configured_providers)) - .id - .clone() - }); - - let (resolved_model, resolved_provider) = match catalog.get(&model) { - Some(info) => ( - info.id.clone(), - provider.or(Some(info.provider.to_string())), - ), - None => (model, provider), - }; + let model = configured_model.or(graph_model); + let eligible = configured_providers.iter().cloned().collect::>(); + let (resolved_model, resolved_provider) = + resolve_run_model(catalog, &eligible, model.as_deref(), provider.as_deref())?; settings.run.model.name = Some(resolved_model); - settings.run.model.provider = resolved_provider; + settings.run.model.provider = Some(resolved_provider.into_inner()); let goal = graph.goal().to_string(); settings.run.goal = if goal.is_empty() { @@ -61,5 +52,18 @@ pub fn materialize_run( settings.run.pull_request = None; } - settings + Ok(settings) +} + +pub(crate) fn resolve_run_model( + catalog: &Catalog, + eligible: &HashSet, + model: Option<&str>, + provider: Option<&str>, +) -> 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)?; + Ok((selected.model, selected.provider)) } diff --git a/lib/crates/fabro-workflow/src/run_metadata.rs b/lib/components/fabro-workflow/src/run_metadata.rs similarity index 100% rename from lib/crates/fabro-workflow/src/run_metadata.rs rename to lib/components/fabro-workflow/src/run_metadata.rs diff --git a/lib/crates/fabro-workflow/src/run_options.rs b/lib/components/fabro-workflow/src/run_options.rs similarity index 100% rename from lib/crates/fabro-workflow/src/run_options.rs rename to lib/components/fabro-workflow/src/run_options.rs diff --git a/lib/crates/fabro-workflow/src/run_status.rs b/lib/components/fabro-workflow/src/run_status.rs similarity index 100% rename from lib/crates/fabro-workflow/src/run_status.rs rename to lib/components/fabro-workflow/src/run_status.rs diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/components/fabro-workflow/src/runtime_store.rs similarity index 100% rename from lib/crates/fabro-workflow/src/runtime_store.rs rename to lib/components/fabro-workflow/src/runtime_store.rs diff --git a/lib/crates/fabro-workflow/src/sandbox_git.rs b/lib/components/fabro-workflow/src/sandbox_git.rs similarity index 100% rename from lib/crates/fabro-workflow/src/sandbox_git.rs rename to lib/components/fabro-workflow/src/sandbox_git.rs diff --git a/lib/crates/fabro-workflow/src/sandbox_git_runtime.rs b/lib/components/fabro-workflow/src/sandbox_git_runtime.rs similarity index 100% rename from lib/crates/fabro-workflow/src/sandbox_git_runtime.rs rename to lib/components/fabro-workflow/src/sandbox_git_runtime.rs diff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/components/fabro-workflow/src/services.rs similarity index 100% rename from lib/crates/fabro-workflow/src/services.rs rename to lib/components/fabro-workflow/src/services.rs diff --git a/lib/crates/fabro-workflow/src/stage_scope.rs b/lib/components/fabro-workflow/src/stage_scope.rs similarity index 100% rename from lib/crates/fabro-workflow/src/stage_scope.rs rename to lib/components/fabro-workflow/src/stage_scope.rs diff --git a/lib/crates/fabro-workflow/src/static_reference.rs b/lib/components/fabro-workflow/src/static_reference.rs similarity index 100% rename from lib/crates/fabro-workflow/src/static_reference.rs rename to lib/components/fabro-workflow/src/static_reference.rs diff --git a/lib/crates/fabro-workflow/src/steering_hub.rs b/lib/components/fabro-workflow/src/steering_hub.rs similarity index 100% rename from lib/crates/fabro-workflow/src/steering_hub.rs rename to lib/components/fabro-workflow/src/steering_hub.rs diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/components/fabro-workflow/src/test_support.rs similarity index 97% rename from lib/crates/fabro-workflow/src/test_support.rs rename to lib/components/fabro-workflow/src/test_support.rs index 35c341a83..edf0d8af2 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/components/fabro-workflow/src/test_support.rs @@ -9,6 +9,8 @@ use fabro_auth::{CredentialSource, EnvCredentialSource}; use fabro_graphviz::graph::Graph as GvGraph; use fabro_interview::AutoApproveInterviewer; use fabro_model::Catalog; +#[cfg(feature = "test-support")] +use fabro_model::ProviderId; use fabro_store::{ArtifactStore, Database, RunProjection}; use object_store::local::LocalFileSystem; @@ -26,6 +28,19 @@ use crate::run_options::RunOptions; use crate::sandbox_git_runtime::SandboxGitRuntime; use crate::services::{EngineServices, RunLocations, RunServices}; +#[cfg(feature = "test-support")] +pub(crate) fn test_configured_provider_ids( + catalog: &Catalog, + configured_provider_ids: Vec, + assume_ready: bool, +) -> Vec { + if assume_ready { + catalog.all_provider_ids().into_iter().collect() + } else { + configured_provider_ids + } +} + /// These helpers stop at EXECUTE, so they emit the terminal event here to /// keep test consumers seeing the same end-of-run signal as production /// (FINALIZE). diff --git a/lib/crates/fabro-workflow/src/transforms/file_inlining.rs b/lib/components/fabro-workflow/src/transforms/file_inlining.rs similarity index 100% rename from lib/crates/fabro-workflow/src/transforms/file_inlining.rs rename to lib/components/fabro-workflow/src/transforms/file_inlining.rs diff --git a/lib/crates/fabro-workflow/src/transforms/import.rs b/lib/components/fabro-workflow/src/transforms/import.rs similarity index 100% rename from lib/crates/fabro-workflow/src/transforms/import.rs rename to lib/components/fabro-workflow/src/transforms/import.rs diff --git a/lib/crates/fabro-workflow/src/transforms/importable_field.rs b/lib/components/fabro-workflow/src/transforms/importable_field.rs similarity index 100% rename from lib/crates/fabro-workflow/src/transforms/importable_field.rs rename to lib/components/fabro-workflow/src/transforms/importable_field.rs diff --git a/lib/crates/fabro-workflow/src/transforms/mod.rs b/lib/components/fabro-workflow/src/transforms/mod.rs similarity index 100% rename from lib/crates/fabro-workflow/src/transforms/mod.rs rename to lib/components/fabro-workflow/src/transforms/mod.rs diff --git a/lib/crates/fabro-workflow/src/transforms/model_resolution.rs b/lib/components/fabro-workflow/src/transforms/model_resolution.rs similarity index 57% rename from lib/crates/fabro-workflow/src/transforms/model_resolution.rs rename to lib/components/fabro-workflow/src/transforms/model_resolution.rs index b58f9f44e..7eecbcfd1 100644 --- a/lib/crates/fabro-workflow/src/transforms/model_resolution.rs +++ b/lib/components/fabro-workflow/src/transforms/model_resolution.rs @@ -1,7 +1,8 @@ +use std::collections::HashSet; use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Graph}; -use fabro_model::Catalog; +use fabro_model::{Catalog, ProviderId}; use super::Transform; use crate::error::Error; @@ -9,19 +10,88 @@ use crate::error::Error; /// Resolves model aliases to canonical IDs and infers the provider from the /// model catalog. pub struct ModelResolutionTransform { - catalog: Arc, + catalog: Arc, + default_provider: Option, + eligible_providers: HashSet, } impl ModelResolutionTransform { #[must_use] pub fn new(catalog: Arc) -> Self { - Self { catalog } + let eligible_providers = catalog.all_provider_ids(); + Self { + catalog, + default_provider: None, + eligible_providers, + } + } + + #[must_use] + pub fn for_eligible(catalog: Arc, eligible_providers: HashSet) -> Self { + Self { + catalog, + default_provider: None, + eligible_providers, + } + } + + #[must_use] + pub fn with_default_provider(mut self, provider: Option) -> Self { + self.default_provider = provider; + 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, + )?; + Ok((selected.model, selected.provider)) } } impl Transform for ModelResolutionTransform { fn apply(&self, graph: Graph) -> Result { let mut graph = graph; + let graph_default_provider = graph + .attrs + .get("default_provider") + .and_then(AttrValue::as_str) + .filter(|provider| !provider.is_empty()) + .map(ProviderId::new); + let requested_default_provider = self + .default_provider + .as_ref() + .or(graph_default_provider.as_ref()); + if let Some(default_model) = graph + .attrs + .get("default_model") + .and_then(AttrValue::as_str) + .map(str::to_string) + { + let (model, provider) = + self.resolve_model(&default_model, requested_default_provider)?; + graph + .attrs + .insert("default_model".to_string(), AttrValue::String(model)); + graph.attrs.insert( + "default_provider".to_string(), + AttrValue::String(provider.to_string()), + ); + } + let default_provider = self.default_provider.clone().or_else(|| { + graph + .attrs + .get("default_provider") + .and_then(AttrValue::as_str) + .filter(|provider| !provider.is_empty()) + .map(ProviderId::new) + }); for node in graph.nodes.values_mut() { let model = node .attrs @@ -29,19 +99,20 @@ impl Transform for ModelResolutionTransform { .and_then(AttrValue::as_str) .map(String::from); if let Some(model) = model { - if let Some(info) = self.catalog.get(&model) { - let canonical_id = info.id.clone(); - let provider = info.provider.to_string(); - // Resolve alias to canonical model ID - if model != canonical_id { - node.attrs - .insert("model".to_string(), AttrValue::String(canonical_id)); - } - if !node.attrs.contains_key("provider") { - node.attrs - .insert("provider".to_string(), AttrValue::String(provider)); - } - } + let explicit_provider = node + .attrs + .get("provider") + .and_then(AttrValue::as_str) + .filter(|provider| !provider.is_empty()) + .map(ProviderId::new) + .or_else(|| default_provider.clone()); + let (model, provider) = self.resolve_model(&model, explicit_provider.as_ref())?; + node.attrs + .insert("model".to_string(), AttrValue::String(model)); + node.attrs.insert( + "provider".to_string(), + AttrValue::String(provider.to_string()), + ); } } @@ -117,7 +188,7 @@ reasoning = false } #[test] - fn provider_inference_does_not_override_explicit_provider() { + fn explicit_provider_allows_unknown_model_passthrough() { let mut graph = Graph::new("test"); let mut node = Node::new("a"); node.attrs.insert( @@ -126,7 +197,7 @@ reasoning = false ); node.attrs.insert( "provider".to_string(), - AttrValue::String("custom".to_string()), + AttrValue::String("openai".to_string()), ); graph.nodes.insert("a".to_string(), node); @@ -137,12 +208,19 @@ reasoning = false .attrs .get("provider") .and_then(AttrValue::as_str), - Some("custom") + Some("openai") + ); + assert_eq!( + graph.nodes["a"] + .attrs + .get("model") + .and_then(AttrValue::as_str), + Some("claude-sonnet-4-5") ); } #[test] - fn provider_inference_unknown_model_leaves_no_provider() { + fn provider_inference_unknown_model_pins_default_eligible_provider() { let mut graph = Graph::new("test"); let mut node = Node::new("a"); node.attrs.insert( @@ -153,7 +231,13 @@ reasoning = false let graph = builtin_transform().apply(graph).unwrap(); - assert_eq!(graph.nodes["a"].attrs.get("provider"), None); + assert_eq!( + graph.nodes["a"] + .attrs + .get("provider") + .and_then(AttrValue::as_str), + Some("anthropic") + ); } #[test] @@ -241,4 +325,29 @@ reasoning = false Some("venice") ); } + + #[test] + fn graph_default_alias_materializes_to_canonical_offering() { + let mut graph = Graph::new("test"); + graph.attrs.insert( + "default_model".to_string(), + AttrValue::String("vl".to_string()), + ); + + let graph = ModelResolutionTransform::new(custom_catalog()) + .apply(graph) + .unwrap(); + + assert_eq!( + graph.attrs.get("default_model").and_then(AttrValue::as_str), + Some("venice-large") + ); + assert_eq!( + graph + .attrs + .get("default_provider") + .and_then(AttrValue::as_str), + Some("venice") + ); + } } diff --git a/lib/crates/fabro-workflow/src/transforms/preamble.rs b/lib/components/fabro-workflow/src/transforms/preamble.rs similarity index 100% rename from lib/crates/fabro-workflow/src/transforms/preamble.rs rename to lib/components/fabro-workflow/src/transforms/preamble.rs diff --git a/lib/crates/fabro-workflow/src/transforms/stylesheet.rs b/lib/components/fabro-workflow/src/transforms/stylesheet.rs similarity index 100% rename from lib/crates/fabro-workflow/src/transforms/stylesheet.rs rename to lib/components/fabro-workflow/src/transforms/stylesheet.rs diff --git a/lib/crates/fabro-workflow/src/transforms/stylesheet_application.rs b/lib/components/fabro-workflow/src/transforms/stylesheet_application.rs similarity index 100% rename from lib/crates/fabro-workflow/src/transforms/stylesheet_application.rs rename to lib/components/fabro-workflow/src/transforms/stylesheet_application.rs diff --git a/lib/crates/fabro-workflow/src/transforms/variable_expansion.rs b/lib/components/fabro-workflow/src/transforms/variable_expansion.rs similarity index 100% rename from lib/crates/fabro-workflow/src/transforms/variable_expansion.rs rename to lib/components/fabro-workflow/src/transforms/variable_expansion.rs diff --git a/lib/crates/fabro-workflow/src/workflow_bundle.rs b/lib/components/fabro-workflow/src/workflow_bundle.rs similarity index 100% rename from lib/crates/fabro-workflow/src/workflow_bundle.rs rename to lib/components/fabro-workflow/src/workflow_bundle.rs diff --git a/lib/crates/fabro-workflow/tests/it/attractor_compat.rs b/lib/components/fabro-workflow/tests/it/attractor_compat.rs similarity index 100% rename from lib/crates/fabro-workflow/tests/it/attractor_compat.rs rename to lib/components/fabro-workflow/tests/it/attractor_compat.rs diff --git a/lib/crates/fabro-workflow/tests/it/cp_integration.rs b/lib/components/fabro-workflow/tests/it/cp_integration.rs similarity index 100% rename from lib/crates/fabro-workflow/tests/it/cp_integration.rs rename to lib/components/fabro-workflow/tests/it/cp_integration.rs diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/components/fabro-workflow/tests/it/daytona_integration.rs similarity index 100% rename from lib/crates/fabro-workflow/tests/it/daytona_integration.rs rename to lib/components/fabro-workflow/tests/it/daytona_integration.rs diff --git a/lib/crates/fabro-workflow/tests/it/git_integration.rs b/lib/components/fabro-workflow/tests/it/git_integration.rs similarity index 100% rename from lib/crates/fabro-workflow/tests/it/git_integration.rs rename to lib/components/fabro-workflow/tests/it/git_integration.rs diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/components/fabro-workflow/tests/it/integration.rs similarity index 97% rename from lib/crates/fabro-workflow/tests/it/integration.rs rename to lib/components/fabro-workflow/tests/it/integration.rs index 3f26b21f9..cc4dda32b 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/components/fabro-workflow/tests/it/integration.rs @@ -33,7 +33,7 @@ use fabro_interview::{ use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings}; use fabro_model::{Catalog, ProviderId}; use fabro_store::{ArtifactKey, ArtifactStore, Database}; -use fabro_types::{RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref}; +use fabro_types::{EventBody, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; use fabro_workflow::error::{Error, FailureSignatureExt}; @@ -1931,6 +1931,82 @@ fn make_graph_with_start_exit(name: &str) -> Graph { graph } +#[tokio::test] +async fn command_schema_validation_failure_does_not_consume_retries() { + let mut graph = make_graph_with_start_exit("CommandSchemaNoRetry"); + let mut audit = Node::new("audit"); + audit.attrs.insert( + "shape".to_string(), + AttrValue::String("parallelogram".to_string()), + ); + audit.attrs.insert( + "script".to_string(), + AttrValue::String(r#"echo '{"passed":"yes"}'"#.to_string()), + ); + audit.attrs.insert( + "output_schema".to_string(), + AttrValue::String( + r#"{"type":"object","required":["passed"],"properties":{"passed":{"type":"boolean"}}}"# + .to_string(), + ), + ); + audit + .attrs + .insert("max_retries".to_string(), AttrValue::Integer(2)); + graph.nodes.insert("audit".to_string(), audit); + graph.edges.push(Edge::new("start", "audit")); + + let emitter = Emitter::default(); + let events = collect_events(&emitter); + let dir = tempfile::tempdir().unwrap(); + let mut registry = HandlerRegistry::new(Box::new(StartHandler)); + registry.register("start", Box::new(StartHandler)); + registry.register("exit", Box::new(ExitHandler)); + registry.register("command", Box::new(CommandHandler)); + let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); + let mut run_options = make_run_options(dir.path()); + run_options.run_id = test_run_id("command-schema-no-retry"); + + let (outcome, state) = engine + .run_with_state(&graph, &run_options) + .await + .expect("deterministic command failure should remain a workflow outcome"); + + assert_eq!(outcome.status, StageOutcome::Failed { + retry_requested: false, + }); + assert_eq!( + outcome.failure_category(), + Some(fabro_workflow::outcome::FailureCategory::Deterministic) + ); + let checkpoint = state + .current_checkpoint() + .expect("checkpoint should be captured"); + let audit_outcome = checkpoint + .node_outcomes + .get("audit") + .expect("audit outcome should be checkpointed"); + assert_eq!(audit_outcome.status, StageOutcome::Failed { + retry_requested: false, + }); + assert_eq!( + audit_outcome.failure_category(), + Some(fabro_workflow::outcome::FailureCategory::Deterministic) + ); + assert_eq!( + checkpoint.node_retries.get("audit").copied().unwrap_or(0), + 0, + "schema validation should not consume node retries" + ); + let command_starts = events + .lock() + .unwrap() + .iter() + .filter(|event| matches!(event.body, EventBody::CommandStarted(_))) + .count(); + assert_eq!(command_starts, 1, "command should execute exactly once"); +} + #[tokio::test] async fn smoke_test_with_mock_codergen_backend() { // Pipeline: @@ -2319,7 +2395,7 @@ reasoning = false ); } -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn workflow_persists_authoritative_openrouter_cost_for_agent_stage() { use fabro_auth::EnvCredentialSource; use fabro_workflow::steering_hub::SteeringHub; @@ -2398,8 +2474,18 @@ base_url = "{}" registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let events_for_listener = Arc::clone(&events); + let emitter = Arc::new(Emitter::default()); + emitter.on_event(move |event| { + if event.event_name() == "agent.message" { + std::thread::sleep(Duration::from_millis(50)); + } + events_for_listener.lock().unwrap().push(event.clone()); + }); + let dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, emitter, local_env()); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2432,6 +2518,22 @@ base_url = "{}" Some(AUTHORITATIVE_COST_USD_MICROS), "provider-reported usage.cost should override the catalog estimate" ); + + let events = events.lock().unwrap(); + let agent_message = events + .iter() + .position(|event| event.event_name() == "agent.message") + .expect("agent message should be emitted"); + let stage_completed = events + .iter() + .position(|event| { + event.event_name() == "stage.completed" && event.node_id.as_deref() == Some("work") + }) + .expect("work stage completion should be emitted"); + assert!( + agent_message < stage_completed, + "agent messages must be forwarded before terminal stage events" + ); } // --------------------------------------------------------------------------- @@ -4795,15 +4897,17 @@ async fn import_e2e_through_engine() { ) .expect("parse should succeed"); let transformed = transform(parsed, &TransformOptions { - current_dir: Some(dir.path().to_path_buf()), - file_resolver: Some(std::sync::Arc::new( + current_dir: Some(dir.path().to_path_buf()), + file_resolver: Some(std::sync::Arc::new( fabro_workflow::file_resolver::FilesystemFileResolver::new(None), )), - template_context: fabro_template::TemplateContext::new(), - source_name: None, - render_mode: fabro_workflow::operations::RenderMode::Strict, - custom_transforms: vec![], - catalog: std::sync::Arc::clone(&catalog), + template_context: fabro_template::TemplateContext::new(), + source_name: None, + render_mode: fabro_workflow::operations::RenderMode::Strict, + custom_transforms: vec![], + catalog: std::sync::Arc::clone(&catalog), + default_provider: None, + eligible_providers: catalog.all_provider_ids(), }) .unwrap(); let validated = validate(transformed, catalog.as_ref(), &[]); @@ -4936,6 +5040,27 @@ struct FidelityCapturingHandler { captures: FidelityCaptures, } +struct ParallelFidelitySeedHandler; + +#[async_trait::async_trait] +impl Handler for ParallelFidelitySeedHandler { + async fn execute( + &self, + _node: &Node, + _context: &Context, + _graph: &Graph, + _run_dir: &Path, + _services: &fabro_workflow::handler::EngineServices, + ) -> Result { + let mut outcome = Outcome::success(); + outcome.context_updates.insert( + "parallel_fidelity_marker".to_string(), + serde_json::json!("marker visible to inherited preambles"), + ); + Ok(outcome) + } +} + #[async_trait::async_trait] impl Handler for FidelityCapturingHandler { async fn execute( @@ -9333,6 +9458,176 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { .expect("report prompt should exist") } +async fn run_parallel_fidelity_capture( + fork_fidelity: Option<&str>, + branch_node_fidelity: Option<&str>, + branch_edge_fidelity: Option<&str>, +) -> FidelityCaptures { + use fabro_workflow::handler::fan_in::FanInHandler; + use fabro_workflow::handler::parallel::ParallelHandler; + + let mut graph = make_graph_with_start_exit("ParallelFidelityTest"); + graph.attrs.insert( + "goal".to_string(), + AttrValue::String("Verify parallel branch context".to_string()), + ); + + let mut seed = Node::new("seed"); + seed.attrs.insert( + "type".to_string(), + AttrValue::String("parallel_fidelity_seed".to_string()), + ); + let mut fork = Node::new("fork"); + fork.attrs.insert( + "shape".to_string(), + AttrValue::String("component".to_string()), + ); + if let Some(fidelity) = fork_fidelity { + fork.attrs.insert( + "fidelity".to_string(), + AttrValue::String(fidelity.to_string()), + ); + } + let mut branch_a = Node::new("branch_a"); + branch_a.attrs.insert( + "type".to_string(), + AttrValue::String("fidelity_capture".to_string()), + ); + if let Some(fidelity) = branch_node_fidelity { + branch_a.attrs.insert( + "fidelity".to_string(), + AttrValue::String(fidelity.to_string()), + ); + } + let mut branch_b = Node::new("branch_b"); + branch_b.attrs.insert( + "type".to_string(), + AttrValue::String("fidelity_capture".to_string()), + ); + let mut fan_in = Node::new("fan_in"); + fan_in.attrs.insert( + "shape".to_string(), + AttrValue::String("tripleoctagon".to_string()), + ); + + graph.nodes.insert(seed.id.clone(), seed); + graph.nodes.insert(fork.id.clone(), fork); + graph.nodes.insert(branch_a.id.clone(), branch_a); + graph.nodes.insert(branch_b.id.clone(), branch_b); + graph.nodes.insert(fan_in.id.clone(), fan_in); + graph.edges.push(Edge::new("start", "seed")); + graph.edges.push(Edge::new("seed", "fork")); + let mut branch_a_edge = Edge::new("fork", "branch_a"); + if let Some(fidelity) = branch_edge_fidelity { + branch_a_edge.attrs.insert( + "fidelity".to_string(), + AttrValue::String(fidelity.to_string()), + ); + } + graph.edges.push(branch_a_edge); + graph.edges.push(Edge::new("fork", "branch_b")); + graph.edges.push(Edge::new("branch_a", "fan_in")); + graph.edges.push(Edge::new("branch_b", "fan_in")); + graph.edges.push(Edge::new("fan_in", "exit")); + + let captures = FidelityCaptures::new(); + let mut registry = HandlerRegistry::new(Box::new(StartHandler)); + registry.register("start", Box::new(StartHandler)); + registry.register("exit", Box::new(ExitHandler)); + registry.register("parallel", Box::new(ParallelHandler)); + registry.register( + "parallel.fan_in", + Box::new(FanInHandler::new(Some(Box::new(MockCodergenBackend)))), + ); + registry.register( + "parallel_fidelity_seed", + Box::new(ParallelFidelitySeedHandler), + ); + registry.register( + "fidelity_capture", + Box::new(FidelityCapturingHandler { + captures: captures.clone(), + }), + ); + + let dir = tempfile::tempdir().expect("parallel fidelity run directory should be created"); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let run_options = RunOptions { + settings: WorkflowSettings::default(), + run_dir: dir.path().to_path_buf(), + cancel_token: CancellationToken::new(), + run_id: test_run_id("parallel-fidelity"), + labels: std::collections::HashMap::new(), + workflow_slug: None, + github_app: None, + base_branch: None, + display_base_sha: None, + pre_run_git: None, + fork_source_ref: None, + git: None, + }; + let (outcome, _state) = engine + .run_with_state(&graph, &run_options) + .await + .expect("parallel fidelity workflow should succeed"); + assert_eq!(outcome.status, StageOutcome::Succeeded); + captures +} + +fn captured_fidelity_preamble(captures: &FidelityCaptures, node_id: &str) -> (String, String) { + let fidelity = captures + .fidelities + .lock() + .unwrap() + .iter() + .find(|(captured_node_id, _)| captured_node_id == node_id) + .map(|(_, fidelity)| fidelity.clone()) + .expect("branch fidelity should be captured"); + let preamble = captures + .preambles + .lock() + .unwrap() + .iter() + .find(|(captured_node_id, _)| captured_node_id == node_id) + .map(|(_, preamble)| preamble.clone()) + .expect("branch preamble should be captured"); + (fidelity, preamble) +} + +#[tokio::test] +async fn parallel_branches_get_per_branch_preambles_by_fidelity() { + let captures = run_parallel_fidelity_capture(None, Some("truncate"), None).await; + + let (branch_a_fidelity, branch_a_preamble) = captured_fidelity_preamble(&captures, "branch_a"); + let (branch_b_fidelity, branch_b_preamble) = captured_fidelity_preamble(&captures, "branch_b"); + + assert_eq!(branch_a_fidelity, "truncate"); + assert!(!branch_a_preamble.contains("parallel_fidelity_marker")); + assert_eq!(branch_b_fidelity, "compact"); + assert!(branch_b_preamble.contains("parallel_fidelity_marker")); +} + +#[tokio::test] +async fn parallel_fork_fidelity_still_applies_to_all_branches() { + let captures = run_parallel_fidelity_capture(Some("truncate"), None, None).await; + + for branch_id in ["branch_a", "branch_b"] { + let (fidelity, preamble) = captured_fidelity_preamble(&captures, branch_id); + assert_eq!(fidelity, "truncate"); + assert!(!preamble.contains("parallel_fidelity_marker")); + } +} + +#[tokio::test] +async fn parallel_branch_edge_fidelity_overrides_node_fidelity() { + let captures = + run_parallel_fidelity_capture(None, Some("summary:high"), Some("truncate")).await; + + let (fidelity, preamble) = captured_fidelity_preamble(&captures, "branch_a"); + assert_eq!(fidelity, "truncate"); + assert!(!preamble.contains("parallel_fidelity_marker")); +} + #[tokio::test] async fn fidelity_prompt_compact() { let prompt = run_fidelity_prompt_pipeline("compact").await; diff --git a/lib/crates/fabro-workflow/tests/it/main.rs b/lib/components/fabro-workflow/tests/it/main.rs similarity index 100% rename from lib/crates/fabro-workflow/tests/it/main.rs rename to lib/components/fabro-workflow/tests/it/main.rs diff --git a/lib/crates/fabro-workflow/tests/materialize_run.rs b/lib/components/fabro-workflow/tests/materialize_run.rs similarity index 95% rename from lib/crates/fabro-workflow/tests/materialize_run.rs rename to lib/components/fabro-workflow/tests/materialize_run.rs index 77fdf6dd0..4fec97585 100644 --- a/lib/crates/fabro-workflow/tests/materialize_run.rs +++ b/lib/components/fabro-workflow/tests/materialize_run.rs @@ -34,7 +34,10 @@ fn materialize_run_applies_graph_and_catalog_defaults() { ..WorkflowSettings::default() }; - let materialized = materialize_run(settings, &graph(source), Catalog::builtin(), &[]); + let materialized = materialize_run(settings, &graph(source), Catalog::builtin(), &[ + ProviderId::anthropic(), + ]) + .unwrap(); let resolved = &materialized.run; assert_eq!(resolved.model.name.as_deref(), Some("claude-sonnet-4-6")); @@ -60,7 +63,8 @@ fn materialize_run_uses_configured_provider_defaults() { &graph(source), Catalog::builtin(), &[ProviderId::openai()], - ); + ) + .unwrap(); let resolved = &materialized.run; assert_eq!(resolved.model.provider.as_deref(), Some("openai")); diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap deleted file mode 100644 index f80ff0a94..000000000 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -"length" diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap deleted file mode 100644 index aedfd4696..000000000 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: lib/crates/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -"content_filter" diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap deleted file mode 100644 index 4c107d93b..000000000 --- a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[] diff --git a/lib/crates/build-support/Cargo.toml b/lib/foundation/build-support/Cargo.toml similarity index 100% rename from lib/crates/build-support/Cargo.toml rename to lib/foundation/build-support/Cargo.toml diff --git a/lib/crates/build-support/git_metadata.rs b/lib/foundation/build-support/git_metadata.rs similarity index 100% rename from lib/crates/build-support/git_metadata.rs rename to lib/foundation/build-support/git_metadata.rs diff --git a/lib/crates/fabro-api/Cargo.toml b/lib/foundation/fabro-api/Cargo.toml similarity index 93% rename from lib/crates/fabro-api/Cargo.toml rename to lib/foundation/fabro-api/Cargo.toml index 298dad17c..bc28670af 100644 --- a/lib/crates/fabro-api/Cargo.toml +++ b/lib/foundation/fabro-api/Cargo.toml @@ -15,7 +15,7 @@ wildcard_imports = "warn" [dependencies] chrono = { workspace = true, features = ["serde"] } -fabro-automation = { path = "../fabro-automation" } +fabro-automation = { path = "../../components/fabro-automation" } fabro-config = { path = "../fabro-config" } fabro-environment.workspace = true fabro-model = { path = "../fabro-model" } diff --git a/lib/crates/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs similarity index 99% rename from lib/crates/fabro-api/build.rs rename to lib/foundation/fabro-api/build.rs index 3fa54d1a6..0dae50823 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -155,7 +155,7 @@ fn spec_path_from_manifest_dir(manifest_dir: &Path) -> PathBuf { manifest_dir .ancestors() .nth(3) - .expect("fabro-api manifest dir should be nested under /lib/crates/fabro-api") + .expect("fabro-api manifest dir should be nested under /lib/foundation/fabro-api") .join("docs/public/api-reference/fabro-api.yaml") } diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs similarity index 100% rename from lib/crates/fabro-api/src/lib.rs rename to lib/foundation/fabro-api/src/lib.rs diff --git a/lib/crates/fabro-api/tests/agent_session_activated_props_round_trip.rs b/lib/foundation/fabro-api/tests/agent_session_activated_props_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/agent_session_activated_props_round_trip.rs rename to lib/foundation/fabro-api/tests/agent_session_activated_props_round_trip.rs diff --git a/lib/crates/fabro-api/tests/automation_round_trip.rs b/lib/foundation/fabro-api/tests/automation_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/automation_round_trip.rs rename to lib/foundation/fabro-api/tests/automation_round_trip.rs diff --git a/lib/crates/fabro-api/tests/billed_token_counts_round_trip.rs b/lib/foundation/fabro-api/tests/billed_token_counts_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/billed_token_counts_round_trip.rs rename to lib/foundation/fabro-api/tests/billed_token_counts_round_trip.rs diff --git a/lib/crates/fabro-api/tests/command_termination_round_trip.rs b/lib/foundation/fabro-api/tests/command_termination_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/command_termination_round_trip.rs rename to lib/foundation/fabro-api/tests/command_termination_round_trip.rs diff --git a/lib/crates/fabro-api/tests/completion_message_round_trip.rs b/lib/foundation/fabro-api/tests/completion_message_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/completion_message_round_trip.rs rename to lib/foundation/fabro-api/tests/completion_message_round_trip.rs diff --git a/lib/crates/fabro-api/tests/cost_source_round_trip.rs b/lib/foundation/fabro-api/tests/cost_source_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/cost_source_round_trip.rs rename to lib/foundation/fabro-api/tests/cost_source_round_trip.rs diff --git a/lib/crates/fabro-api/tests/diff_stats_round_trip.rs b/lib/foundation/fabro-api/tests/diff_stats_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/diff_stats_round_trip.rs rename to lib/foundation/fabro-api/tests/diff_stats_round_trip.rs diff --git a/lib/crates/fabro-api/tests/diff_summary_round_trip.rs b/lib/foundation/fabro-api/tests/diff_summary_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/diff_summary_round_trip.rs rename to lib/foundation/fabro-api/tests/diff_summary_round_trip.rs diff --git a/lib/crates/fabro-api/tests/environment_round_trip.rs b/lib/foundation/fabro-api/tests/environment_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/environment_round_trip.rs rename to lib/foundation/fabro-api/tests/environment_round_trip.rs diff --git a/lib/crates/fabro-api/tests/event_envelope_round_trip.rs b/lib/foundation/fabro-api/tests/event_envelope_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/event_envelope_round_trip.rs rename to lib/foundation/fabro-api/tests/event_envelope_round_trip.rs diff --git a/lib/crates/fabro-api/tests/git_context_round_trip.rs b/lib/foundation/fabro-api/tests/git_context_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/git_context_round_trip.rs rename to lib/foundation/fabro-api/tests/git_context_round_trip.rs diff --git a/lib/crates/fabro-api/tests/interview_option_round_trip.rs b/lib/foundation/fabro-api/tests/interview_option_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/interview_option_round_trip.rs rename to lib/foundation/fabro-api/tests/interview_option_round_trip.rs diff --git a/lib/crates/fabro-api/tests/interview_question_record_round_trip.rs b/lib/foundation/fabro-api/tests/interview_question_record_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/interview_question_record_round_trip.rs rename to lib/foundation/fabro-api/tests/interview_question_record_round_trip.rs diff --git a/lib/crates/fabro-api/tests/mcp_server_round_trip.rs b/lib/foundation/fabro-api/tests/mcp_server_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/mcp_server_round_trip.rs rename to lib/foundation/fabro-api/tests/mcp_server_round_trip.rs diff --git a/lib/crates/fabro-api/tests/mcp_transport_round_trip.rs b/lib/foundation/fabro-api/tests/mcp_transport_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/mcp_transport_round_trip.rs rename to lib/foundation/fabro-api/tests/mcp_transport_round_trip.rs diff --git a/lib/crates/fabro-api/tests/model_costs_round_trip.rs b/lib/foundation/fabro-api/tests/model_costs_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/model_costs_round_trip.rs rename to lib/foundation/fabro-api/tests/model_costs_round_trip.rs diff --git a/lib/crates/fabro-api/tests/model_features_round_trip.rs b/lib/foundation/fabro-api/tests/model_features_round_trip.rs similarity index 73% rename from lib/crates/fabro-api/tests/model_features_round_trip.rs rename to lib/foundation/fabro-api/tests/model_features_round_trip.rs index 8e6f60aef..42f596665 100644 --- a/lib/crates/fabro-api/tests/model_features_round_trip.rs +++ b/lib/foundation/fabro-api/tests/model_features_round_trip.rs @@ -11,12 +11,13 @@ fn model_features_reuses_canonical_type() { #[test] fn model_features_json_matches_openapi_shape() { let features = ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: false, - sampling_params: true, + tools: true, + vision: true, + reasoning: true, + reasoning_effort: ReasoningEffortFeature::Levels, + prompt_cache: false, + cache_control_breakpoints: false, + sampling_params: true, }; let json = serde_json::to_value(&features).unwrap(); @@ -25,6 +26,7 @@ fn model_features_json_matches_openapi_shape() { assert_eq!(json["reasoning"], true); assert_eq!(json["reasoning_effort"], "levels"); assert_eq!(json["prompt_cache"], false); + assert_eq!(json["cache_control_breakpoints"], false); assert_eq!(json["sampling_params"], true); let round_trip: ApiModelFeatures = serde_json::from_value(json).unwrap(); diff --git a/lib/crates/fabro-api/tests/model_limits_round_trip.rs b/lib/foundation/fabro-api/tests/model_limits_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/model_limits_round_trip.rs rename to lib/foundation/fabro-api/tests/model_limits_round_trip.rs diff --git a/lib/crates/fabro-api/tests/model_round_trip.rs b/lib/foundation/fabro-api/tests/model_round_trip.rs similarity index 83% rename from lib/crates/fabro-api/tests/model_round_trip.rs rename to lib/foundation/fabro-api/tests/model_round_trip.rs index 727cd41da..57d3db557 100644 --- a/lib/crates/fabro-api/tests/model_round_trip.rs +++ b/lib/foundation/fabro-api/tests/model_round_trip.rs @@ -13,7 +13,7 @@ fn model_reuses_canonical_type() { #[test] fn model_json_matches_openapi_shape() { let model = Model { - id: "claude-opus-4-7".to_string(), + id: "claude-opus-4-7".into(), provider: ProviderId::anthropic(), family: "claude-4".to_string(), display_name: "Claude Opus 4.7".to_string(), @@ -24,12 +24,13 @@ fn model_json_matches_openapi_shape() { training: Some("2025-08-01".to_string()), knowledge_cutoff: Some("May 2025".to_string()), features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: true, - sampling_params: true, + tools: true, + vision: true, + reasoning: true, + reasoning_effort: ReasoningEffortFeature::Levels, + prompt_cache: true, + cache_control_breakpoints: false, + sampling_params: true, }, costs: ModelCosts { input_cost_per_mtok: Some(5.0), diff --git a/lib/crates/fabro-api/tests/model_test_mode_round_trip.rs b/lib/foundation/fabro-api/tests/model_test_mode_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/model_test_mode_round_trip.rs rename to lib/foundation/fabro-api/tests/model_test_mode_round_trip.rs diff --git a/lib/crates/fabro-api/tests/pair_round_trip.rs b/lib/foundation/fabro-api/tests/pair_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/pair_round_trip.rs rename to lib/foundation/fabro-api/tests/pair_round_trip.rs diff --git a/lib/crates/fabro-api/tests/pending_interview_record_round_trip.rs b/lib/foundation/fabro-api/tests/pending_interview_record_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/pending_interview_record_round_trip.rs rename to lib/foundation/fabro-api/tests/pending_interview_record_round_trip.rs diff --git a/lib/crates/fabro-api/tests/pre_run_push_outcome_round_trip.rs b/lib/foundation/fabro-api/tests/pre_run_push_outcome_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/pre_run_push_outcome_round_trip.rs rename to lib/foundation/fabro-api/tests/pre_run_push_outcome_round_trip.rs diff --git a/lib/crates/fabro-api/tests/principal_round_trip.rs b/lib/foundation/fabro-api/tests/principal_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/principal_round_trip.rs rename to lib/foundation/fabro-api/tests/principal_round_trip.rs diff --git a/lib/crates/fabro-api/tests/provider_id_round_trip.rs b/lib/foundation/fabro-api/tests/provider_id_round_trip.rs similarity index 82% rename from lib/crates/fabro-api/tests/provider_id_round_trip.rs rename to lib/foundation/fabro-api/tests/provider_id_round_trip.rs index 47a1a2ce3..970c7158f 100644 --- a/lib/crates/fabro-api/tests/provider_id_round_trip.rs +++ b/lib/foundation/fabro-api/tests/provider_id_round_trip.rs @@ -23,7 +23,7 @@ fn provider_id_json_matches_openapi_shape_through_model() { ); let model = Model { - id: "venice-custom".to_string(), + id: "venice-custom".into(), provider: ProviderId::new("venice"), family: "venice".to_string(), display_name: "Venice Custom".to_string(), @@ -34,12 +34,13 @@ fn provider_id_json_matches_openapi_shape_through_model() { training: None, knowledge_cutoff: None, features: ModelFeatures { - tools: false, - vision: false, - reasoning: false, - reasoning_effort: ReasoningEffortFeature::None, - prompt_cache: false, - sampling_params: true, + tools: false, + vision: false, + reasoning: false, + reasoning_effort: ReasoningEffortFeature::None, + prompt_cache: false, + cache_control_breakpoints: false, + sampling_params: true, }, costs: ModelCosts { input_cost_per_mtok: None, diff --git a/lib/crates/fabro-api/tests/provider_round_trip.rs b/lib/foundation/fabro-api/tests/provider_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/provider_round_trip.rs rename to lib/foundation/fabro-api/tests/provider_round_trip.rs diff --git a/lib/crates/fabro-api/tests/pull_request_round_trip.rs b/lib/foundation/fabro-api/tests/pull_request_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/pull_request_round_trip.rs rename to lib/foundation/fabro-api/tests/pull_request_round_trip.rs diff --git a/lib/crates/fabro-api/tests/question_type_round_trip.rs b/lib/foundation/fabro-api/tests/question_type_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/question_type_round_trip.rs rename to lib/foundation/fabro-api/tests/question_type_round_trip.rs diff --git a/lib/crates/fabro-api/tests/run_billing_stage_round_trip.rs b/lib/foundation/fabro-api/tests/run_billing_stage_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/run_billing_stage_round_trip.rs rename to lib/foundation/fabro-api/tests/run_billing_stage_round_trip.rs diff --git a/lib/crates/fabro-api/tests/run_event_round_trip.rs b/lib/foundation/fabro-api/tests/run_event_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/run_event_round_trip.rs rename to lib/foundation/fabro-api/tests/run_event_round_trip.rs diff --git a/lib/crates/fabro-api/tests/run_failure_round_trip.rs b/lib/foundation/fabro-api/tests/run_failure_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/run_failure_round_trip.rs rename to lib/foundation/fabro-api/tests/run_failure_round_trip.rs diff --git a/lib/crates/fabro-api/tests/run_integrations_round_trip.rs b/lib/foundation/fabro-api/tests/run_integrations_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/run_integrations_round_trip.rs rename to lib/foundation/fabro-api/tests/run_integrations_round_trip.rs diff --git a/lib/crates/fabro-api/tests/run_projection_round_trip.rs b/lib/foundation/fabro-api/tests/run_projection_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/run_projection_round_trip.rs rename to lib/foundation/fabro-api/tests/run_projection_round_trip.rs diff --git a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs b/lib/foundation/fabro-api/tests/run_sandbox_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/run_sandbox_round_trip.rs rename to lib/foundation/fabro-api/tests/run_sandbox_round_trip.rs diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/foundation/fabro-api/tests/run_summary_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/run_summary_round_trip.rs rename to lib/foundation/fabro-api/tests/run_summary_round_trip.rs diff --git a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs b/lib/foundation/fabro-api/tests/sandbox_details_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/sandbox_details_round_trip.rs rename to lib/foundation/fabro-api/tests/sandbox_details_round_trip.rs diff --git a/lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs b/lib/foundation/fabro-api/tests/sandbox_inventory_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs rename to lib/foundation/fabro-api/tests/sandbox_inventory_round_trip.rs diff --git a/lib/crates/fabro-api/tests/sandbox_services_round_trip.rs b/lib/foundation/fabro-api/tests/sandbox_services_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/sandbox_services_round_trip.rs rename to lib/foundation/fabro-api/tests/sandbox_services_round_trip.rs diff --git a/lib/crates/fabro-api/tests/secret_metadata_round_trip.rs b/lib/foundation/fabro-api/tests/secret_metadata_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/secret_metadata_round_trip.rs rename to lib/foundation/fabro-api/tests/secret_metadata_round_trip.rs diff --git a/lib/crates/fabro-api/tests/secret_type_round_trip.rs b/lib/foundation/fabro-api/tests/secret_type_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/secret_type_round_trip.rs rename to lib/foundation/fabro-api/tests/secret_type_round_trip.rs diff --git a/lib/crates/fabro-api/tests/server_settings_round_trip.rs b/lib/foundation/fabro-api/tests/server_settings_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/server_settings_round_trip.rs rename to lib/foundation/fabro-api/tests/server_settings_round_trip.rs diff --git a/lib/crates/fabro-api/tests/session_contract_round_trip.rs b/lib/foundation/fabro-api/tests/session_contract_round_trip.rs similarity index 97% rename from lib/crates/fabro-api/tests/session_contract_round_trip.rs rename to lib/foundation/fabro-api/tests/session_contract_round_trip.rs index c8b26fb7a..f987f73f8 100644 --- a/lib/crates/fabro-api/tests/session_contract_round_trip.rs +++ b/lib/foundation/fabro-api/tests/session_contract_round_trip.rs @@ -5,6 +5,7 @@ use fabro_api::types::{ SessionDetail as ApiSessionDetail, SessionRecord as ApiSessionRecord, SessionSummary as ApiSessionSummary, SessionTurn as ApiSessionTurn, SubmitTurnRequest, }; +use fabro_model::ProviderId; use fabro_types::{ SessionDetail, SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, TurnId, fixtures, @@ -33,6 +34,7 @@ fn session_detail_round_trips_messages_active_turn_and_last_seq() { title: Some("Ask Fabro".to_string()), status: SessionStatus::Running, model: Some("gpt-5.4".to_string()), + provider: Some(ProviderId::openai()), active_turn: Some(SessionTurn { id: turn_id, started_at: turn_started_at, diff --git a/lib/crates/fabro-api/tests/stage_completion_round_trip.rs b/lib/foundation/fabro-api/tests/stage_completion_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/stage_completion_round_trip.rs rename to lib/foundation/fabro-api/tests/stage_completion_round_trip.rs diff --git a/lib/crates/fabro-api/tests/stage_handler_round_trip.rs b/lib/foundation/fabro-api/tests/stage_handler_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/stage_handler_round_trip.rs rename to lib/foundation/fabro-api/tests/stage_handler_round_trip.rs diff --git a/lib/crates/fabro-api/tests/stage_model_usage_round_trip.rs b/lib/foundation/fabro-api/tests/stage_model_usage_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/stage_model_usage_round_trip.rs rename to lib/foundation/fabro-api/tests/stage_model_usage_round_trip.rs diff --git a/lib/crates/fabro-api/tests/stage_outcome_round_trip.rs b/lib/foundation/fabro-api/tests/stage_outcome_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/stage_outcome_round_trip.rs rename to lib/foundation/fabro-api/tests/stage_outcome_round_trip.rs diff --git a/lib/crates/fabro-api/tests/stage_projection_round_trip.rs b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/stage_projection_round_trip.rs rename to lib/foundation/fabro-api/tests/stage_projection_round_trip.rs diff --git a/lib/crates/fabro-api/tests/stage_state_round_trip.rs b/lib/foundation/fabro-api/tests/stage_state_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/stage_state_round_trip.rs rename to lib/foundation/fabro-api/tests/stage_state_round_trip.rs diff --git a/lib/crates/fabro-api/tests/status_round_trip.rs b/lib/foundation/fabro-api/tests/status_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/status_round_trip.rs rename to lib/foundation/fabro-api/tests/status_round_trip.rs diff --git a/lib/crates/fabro-api/tests/submit_answer_request_round_trip.rs b/lib/foundation/fabro-api/tests/submit_answer_request_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/submit_answer_request_round_trip.rs rename to lib/foundation/fabro-api/tests/submit_answer_request_round_trip.rs diff --git a/lib/crates/fabro-api/tests/system_integrations_round_trip.rs b/lib/foundation/fabro-api/tests/system_integrations_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/system_integrations_round_trip.rs rename to lib/foundation/fabro-api/tests/system_integrations_round_trip.rs diff --git a/lib/crates/fabro-api/tests/variable_round_trip.rs b/lib/foundation/fabro-api/tests/variable_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/variable_round_trip.rs rename to lib/foundation/fabro-api/tests/variable_round_trip.rs diff --git a/lib/crates/fabro-api/tests/workflow_settings_round_trip.rs b/lib/foundation/fabro-api/tests/workflow_settings_round_trip.rs similarity index 100% rename from lib/crates/fabro-api/tests/workflow_settings_round_trip.rs rename to lib/foundation/fabro-api/tests/workflow_settings_round_trip.rs diff --git a/lib/crates/fabro-auth/Cargo.toml b/lib/foundation/fabro-auth/Cargo.toml similarity index 100% rename from lib/crates/fabro-auth/Cargo.toml rename to lib/foundation/fabro-auth/Cargo.toml diff --git a/lib/crates/fabro-auth/src/context.rs b/lib/foundation/fabro-auth/src/context.rs similarity index 100% rename from lib/crates/fabro-auth/src/context.rs rename to lib/foundation/fabro-auth/src/context.rs diff --git a/lib/crates/fabro-auth/src/credential.rs b/lib/foundation/fabro-auth/src/credential.rs similarity index 100% rename from lib/crates/fabro-auth/src/credential.rs rename to lib/foundation/fabro-auth/src/credential.rs diff --git a/lib/crates/fabro-auth/src/credential_source.rs b/lib/foundation/fabro-auth/src/credential_source.rs similarity index 100% rename from lib/crates/fabro-auth/src/credential_source.rs rename to lib/foundation/fabro-auth/src/credential_source.rs diff --git a/lib/crates/fabro-auth/src/env_source.rs b/lib/foundation/fabro-auth/src/env_source.rs similarity index 100% rename from lib/crates/fabro-auth/src/env_source.rs rename to lib/foundation/fabro-auth/src/env_source.rs diff --git a/lib/foundation/fabro-auth/src/extra_headers_source.rs b/lib/foundation/fabro-auth/src/extra_headers_source.rs new file mode 100644 index 000000000..29ad806cd --- /dev/null +++ b/lib/foundation/fabro-auth/src/extra_headers_source.rs @@ -0,0 +1,175 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use fabro_model::{Catalog, ProviderId}; + +use crate::credential_source::{CredentialSource, ResolvedCredentials}; + +/// Decorates another [`CredentialSource`] by appending fixed extra headers to +/// every credential it resolves. +/// +/// Headers already present on a credential (for example from explicit +/// provider configuration) are left untouched. +pub struct ExtraHeadersCredentialSource { + inner: Arc, + headers: HashMap, +} + +impl ExtraHeadersCredentialSource { + #[must_use] + pub fn new(inner: Arc, headers: HashMap) -> Self { + Self { inner, headers } + } +} + +#[async_trait] +impl CredentialSource for ExtraHeadersCredentialSource { + async fn resolve(&self, catalog: &Catalog) -> anyhow::Result { + let mut resolved = self.inner.resolve(catalog).await?; + for credential in &mut resolved.credentials { + for (name, value) in &self.headers { + if credential + .extra_headers + .keys() + .any(|existing| existing.eq_ignore_ascii_case(name)) + { + continue; + } + credential.extra_headers.insert(name.clone(), value.clone()); + } + } + Ok(resolved) + } + + async fn configured_providers(&self, catalog: &Catalog) -> Vec { + self.inner.configured_providers(catalog).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ApiCredential, ResolveError}; + + struct StubSource { + credentials: Vec, + auth_issue_provider: Option, + configured_providers: Vec, + } + + #[async_trait] + impl CredentialSource for StubSource { + async fn resolve(&self, _catalog: &Catalog) -> anyhow::Result { + Ok(ResolvedCredentials { + credentials: self.credentials.clone(), + auth_issues: self + .auth_issue_provider + .iter() + .map(|provider| { + ( + provider.clone(), + ResolveError::RefreshTokenMissing(provider.clone()), + ) + }) + .collect(), + }) + } + + async fn configured_providers(&self, _catalog: &Catalog) -> Vec { + self.configured_providers.clone() + } + } + + fn credential(provider: ProviderId, extra_headers: HashMap) -> ApiCredential { + ApiCredential { + provider, + auth_header: None, + extra_headers, + base_url: None, + codex_mode: false, + org_id: None, + project_id: None, + } + } + + #[tokio::test] + async fn appends_headers_to_every_resolved_credential() { + let source = ExtraHeadersCredentialSource::new( + Arc::new(StubSource { + credentials: vec![ + credential(ProviderId::anthropic(), HashMap::new()), + credential(ProviderId::openai(), HashMap::new()), + ], + auth_issue_provider: None, + configured_providers: Vec::new(), + }), + HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), + ); + + let resolved = source.resolve(Catalog::builtin()).await.unwrap(); + + assert_eq!(resolved.credentials.len(), 2); + for credential in &resolved.credentials { + assert_eq!( + credential + .extra_headers + .get("x-session-id") + .map(String::as_str), + Some("run-123") + ); + } + } + + #[tokio::test] + async fn preserves_case_insensitive_headers_already_set_on_a_credential() { + let source = ExtraHeadersCredentialSource::new( + Arc::new(StubSource { + credentials: vec![credential( + ProviderId::new("openrouter"), + HashMap::from([("X-Session-Id".to_string(), "configured".to_string())]), + )], + auth_issue_provider: None, + configured_providers: Vec::new(), + }), + HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), + ); + + let resolved = source.resolve(Catalog::builtin()).await.unwrap(); + + assert_eq!( + resolved.credentials[0] + .extra_headers + .get("X-Session-Id") + .map(String::as_str), + Some("configured") + ); + assert_eq!(resolved.credentials[0].extra_headers.len(), 1); + } + + #[tokio::test] + async fn passes_through_auth_issues_and_configured_providers() { + let auth_issue_provider = ProviderId::anthropic(); + let configured_provider = ProviderId::gemini(); + let source = ExtraHeadersCredentialSource::new( + Arc::new(StubSource { + credentials: vec![credential(ProviderId::openai(), HashMap::new())], + auth_issue_provider: Some(auth_issue_provider.clone()), + configured_providers: vec![configured_provider.clone()], + }), + HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), + ); + + let resolved = source.resolve(Catalog::builtin()).await.unwrap(); + let [(reported_provider, ResolveError::RefreshTokenMissing(error_provider))] = + resolved.auth_issues.as_slice() + else { + panic!("expected the inner source's refresh-token issue"); + }; + assert_eq!(reported_provider, &auth_issue_provider); + assert_eq!(error_provider, &auth_issue_provider); + + let providers = source.configured_providers(Catalog::builtin()).await; + assert_eq!(providers, vec![configured_provider]); + } +} diff --git a/lib/crates/fabro-auth/src/lib.rs b/lib/foundation/fabro-auth/src/lib.rs similarity index 92% rename from lib/crates/fabro-auth/src/lib.rs rename to lib/foundation/fabro-auth/src/lib.rs index 1bc135149..77c217317 100644 --- a/lib/crates/fabro-auth/src/lib.rs +++ b/lib/foundation/fabro-auth/src/lib.rs @@ -2,6 +2,7 @@ mod context; mod credential; mod credential_source; mod env_source; +mod extra_headers_source; mod refresh; mod resolve; mod sql_vault_source; @@ -15,6 +16,7 @@ pub use context::{AuthContextRequest, AuthContextResponse}; pub use credential::{ApiKeyHeader, OAuthConfig, OAuthCredential, OAuthTokens}; pub use credential_source::{CredentialSource, ResolvedCredentials}; pub use env_source::EnvCredentialSource; +pub use extra_headers_source::ExtraHeadersCredentialSource; pub use refresh::refresh_oauth_credential; pub use resolve::{ ApiCredential, CredentialResolver, CredentialUsage, EnvLookup, ResolveError, diff --git a/lib/crates/fabro-auth/src/refresh.rs b/lib/foundation/fabro-auth/src/refresh.rs similarity index 100% rename from lib/crates/fabro-auth/src/refresh.rs rename to lib/foundation/fabro-auth/src/refresh.rs diff --git a/lib/crates/fabro-auth/src/resolve.rs b/lib/foundation/fabro-auth/src/resolve.rs similarity index 100% rename from lib/crates/fabro-auth/src/resolve.rs rename to lib/foundation/fabro-auth/src/resolve.rs diff --git a/lib/crates/fabro-auth/src/sql_vault_source.rs b/lib/foundation/fabro-auth/src/sql_vault_source.rs similarity index 100% rename from lib/crates/fabro-auth/src/sql_vault_source.rs rename to lib/foundation/fabro-auth/src/sql_vault_source.rs diff --git a/lib/crates/fabro-auth/src/strategies/api_key.rs b/lib/foundation/fabro-auth/src/strategies/api_key.rs similarity index 100% rename from lib/crates/fabro-auth/src/strategies/api_key.rs rename to lib/foundation/fabro-auth/src/strategies/api_key.rs diff --git a/lib/crates/fabro-auth/src/strategies/codex_device.rs b/lib/foundation/fabro-auth/src/strategies/codex_device.rs similarity index 100% rename from lib/crates/fabro-auth/src/strategies/codex_device.rs rename to lib/foundation/fabro-auth/src/strategies/codex_device.rs diff --git a/lib/crates/fabro-auth/src/strategies/mod.rs b/lib/foundation/fabro-auth/src/strategies/mod.rs similarity index 100% rename from lib/crates/fabro-auth/src/strategies/mod.rs rename to lib/foundation/fabro-auth/src/strategies/mod.rs diff --git a/lib/crates/fabro-auth/src/strategy.rs b/lib/foundation/fabro-auth/src/strategy.rs similarity index 100% rename from lib/crates/fabro-auth/src/strategy.rs rename to lib/foundation/fabro-auth/src/strategy.rs diff --git a/lib/crates/fabro-auth/src/vault_ext.rs b/lib/foundation/fabro-auth/src/vault_ext.rs similarity index 100% rename from lib/crates/fabro-auth/src/vault_ext.rs rename to lib/foundation/fabro-auth/src/vault_ext.rs diff --git a/lib/crates/fabro-auth/src/vault_source.rs b/lib/foundation/fabro-auth/src/vault_source.rs similarity index 100% rename from lib/crates/fabro-auth/src/vault_source.rs rename to lib/foundation/fabro-auth/src/vault_source.rs diff --git a/lib/crates/fabro-client/Cargo.toml b/lib/foundation/fabro-client/Cargo.toml similarity index 100% rename from lib/crates/fabro-client/Cargo.toml rename to lib/foundation/fabro-client/Cargo.toml diff --git a/lib/crates/fabro-client/src/auth_store.rs b/lib/foundation/fabro-client/src/auth_store.rs similarity index 100% rename from lib/crates/fabro-client/src/auth_store.rs rename to lib/foundation/fabro-client/src/auth_store.rs diff --git a/lib/crates/fabro-client/src/client.rs b/lib/foundation/fabro-client/src/client.rs similarity index 99% rename from lib/crates/fabro-client/src/client.rs rename to lib/foundation/fabro-client/src/client.rs index 71f185104..be21fe2dd 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/foundation/fabro-client/src/client.rs @@ -833,11 +833,15 @@ impl Client { pub async fn test_model( &self, id: &str, + provider: Option<&ProviderId>, mode: Option, ) -> Result { let response = self .send_api(|client| async move { let mut request = client.test_model().id(id.to_string()); + if let Some(provider) = provider { + request = request.provider(provider.clone()); + } if let Some(mode) = mode { request = request.mode(mode); } diff --git a/lib/crates/fabro-client/src/credential.rs b/lib/foundation/fabro-client/src/credential.rs similarity index 100% rename from lib/crates/fabro-client/src/credential.rs rename to lib/foundation/fabro-client/src/credential.rs diff --git a/lib/crates/fabro-client/src/error.rs b/lib/foundation/fabro-client/src/error.rs similarity index 100% rename from lib/crates/fabro-client/src/error.rs rename to lib/foundation/fabro-client/src/error.rs diff --git a/lib/crates/fabro-client/src/lib.rs b/lib/foundation/fabro-client/src/lib.rs similarity index 100% rename from lib/crates/fabro-client/src/lib.rs rename to lib/foundation/fabro-client/src/lib.rs diff --git a/lib/crates/fabro-client/src/session.rs b/lib/foundation/fabro-client/src/session.rs similarity index 100% rename from lib/crates/fabro-client/src/session.rs rename to lib/foundation/fabro-client/src/session.rs diff --git a/lib/crates/fabro-client/src/sse.rs b/lib/foundation/fabro-client/src/sse.rs similarity index 100% rename from lib/crates/fabro-client/src/sse.rs rename to lib/foundation/fabro-client/src/sse.rs diff --git a/lib/crates/fabro-client/src/target.rs b/lib/foundation/fabro-client/src/target.rs similarity index 100% rename from lib/crates/fabro-client/src/target.rs rename to lib/foundation/fabro-client/src/target.rs diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/foundation/fabro-config/Cargo.toml similarity index 100% rename from lib/crates/fabro-config/Cargo.toml rename to lib/foundation/fabro-config/Cargo.toml diff --git a/lib/crates/fabro-config/migrations/2026050101_legacy_sandbox_to_environments.rs b/lib/foundation/fabro-config/migrations/2026050101_legacy_sandbox_to_environments.rs similarity index 100% rename from lib/crates/fabro-config/migrations/2026050101_legacy_sandbox_to_environments.rs rename to lib/foundation/fabro-config/migrations/2026050101_legacy_sandbox_to_environments.rs diff --git a/lib/crates/fabro-config/migrations/2026052801_settings_environments_to_server_files.rs b/lib/foundation/fabro-config/migrations/2026052801_settings_environments_to_server_files.rs similarity index 100% rename from lib/crates/fabro-config/migrations/2026052801_settings_environments_to_server_files.rs rename to lib/foundation/fabro-config/migrations/2026052801_settings_environments_to_server_files.rs diff --git a/lib/crates/fabro-config/src/bind.rs b/lib/foundation/fabro-config/src/bind.rs similarity index 100% rename from lib/crates/fabro-config/src/bind.rs rename to lib/foundation/fabro-config/src/bind.rs diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/foundation/fabro-config/src/builders.rs similarity index 97% rename from lib/crates/fabro-config/src/builders.rs rename to lib/foundation/fabro-config/src/builders.rs index 1458ba0b9..a1e00907c 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/foundation/fabro-config/src/builders.rs @@ -340,6 +340,12 @@ fn provider_settings_to_catalog( .map(|(name, value)| (name, value.as_source())) .collect() }); + let models = settings + .models + .into_inner() + .into_iter() + .map(|(id, settings)| (id, model_settings_to_catalog(settings))) + .collect(); model_catalog::ProviderCatalogSettings { display_name: settings.display_name, adapter: settings.adapter, @@ -353,6 +359,7 @@ fn provider_settings_to_catalog( priority: settings.priority, enabled: settings.enabled, aliases: settings.aliases, + models, } } @@ -410,12 +417,13 @@ fn model_limits_to_catalog(limits: &LlmModelLimits) -> model_catalog::SettingsMo fn model_features_to_catalog(features: &LlmModelFeatures) -> model_catalog::SettingsModelFeatures { model_catalog::SettingsModelFeatures { - tools: features.tools, - vision: features.vision, - reasoning: features.reasoning, - reasoning_effort: features.reasoning_effort, - prompt_cache: features.prompt_cache, - sampling_params: features.sampling_params, + tools: features.tools, + vision: features.vision, + reasoning: features.reasoning, + reasoning_effort: features.reasoning_effort, + prompt_cache: features.prompt_cache, + cache_control_breakpoints: features.cache_control_breakpoints, + sampling_params: features.sampling_params, } } @@ -863,7 +871,7 @@ reasoning = false assert_eq!( catalog - .get("acme-large") + .get_on_provider(&fabro_model::ProviderId::new("acme"), "acme-large") .map(|model| model.provider.clone()), Some(fabro_model::ProviderId::new("acme")) ); diff --git a/lib/crates/fabro-config/src/daemon.rs b/lib/foundation/fabro-config/src/daemon.rs similarity index 100% rename from lib/crates/fabro-config/src/daemon.rs rename to lib/foundation/fabro-config/src/daemon.rs diff --git a/lib/crates/fabro-config/src/defaults.rs b/lib/foundation/fabro-config/src/defaults.rs similarity index 100% rename from lib/crates/fabro-config/src/defaults.rs rename to lib/foundation/fabro-config/src/defaults.rs diff --git a/lib/crates/fabro-config/src/defaults.toml b/lib/foundation/fabro-config/src/defaults.toml similarity index 100% rename from lib/crates/fabro-config/src/defaults.toml rename to lib/foundation/fabro-config/src/defaults.toml diff --git a/lib/crates/fabro-config/src/envfile.rs b/lib/foundation/fabro-config/src/envfile.rs similarity index 100% rename from lib/crates/fabro-config/src/envfile.rs rename to lib/foundation/fabro-config/src/envfile.rs diff --git a/lib/crates/fabro-config/src/error.rs b/lib/foundation/fabro-config/src/error.rs similarity index 100% rename from lib/crates/fabro-config/src/error.rs rename to lib/foundation/fabro-config/src/error.rs diff --git a/lib/crates/fabro-config/src/home.rs b/lib/foundation/fabro-config/src/home.rs similarity index 100% rename from lib/crates/fabro-config/src/home.rs rename to lib/foundation/fabro-config/src/home.rs diff --git a/lib/crates/fabro-config/src/input_overrides.rs b/lib/foundation/fabro-config/src/input_overrides.rs similarity index 100% rename from lib/crates/fabro-config/src/input_overrides.rs rename to lib/foundation/fabro-config/src/input_overrides.rs diff --git a/lib/crates/fabro-config/src/layers/cli.rs b/lib/foundation/fabro-config/src/layers/cli.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/cli.rs rename to lib/foundation/fabro-config/src/layers/cli.rs diff --git a/lib/crates/fabro-config/src/layers/combine.rs b/lib/foundation/fabro-config/src/layers/combine.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/combine.rs rename to lib/foundation/fabro-config/src/layers/combine.rs diff --git a/lib/crates/fabro-config/src/layers/environment.rs b/lib/foundation/fabro-config/src/layers/environment.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/environment.rs rename to lib/foundation/fabro-config/src/layers/environment.rs diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/foundation/fabro-config/src/layers/llm.rs similarity index 88% rename from lib/crates/fabro-config/src/layers/llm.rs rename to lib/foundation/fabro-config/src/layers/llm.rs index 5fb8a0a64..4f89c55fc 100644 --- a/lib/crates/fabro-config/src/layers/llm.rs +++ b/lib/foundation/fabro-config/src/layers/llm.rs @@ -12,8 +12,7 @@ //! enabled = true //! aliases = ["moonshot"] //! -//! [llm.models."kimi-k2.5"] -//! provider = "kimi" +//! [llm.providers.kimi.models."kimi-k2.5"] //! ... //! ``` //! @@ -29,7 +28,9 @@ use std::collections::{BTreeMap, HashMap}; use fabro_model::catalog::deserialize_knowledge_cutoff; -use fabro_model::{AgentProfileKind, BillingPolicy, CodecKind, ProviderAuthConfig}; +use fabro_model::{ + AgentProfileKind, BillingPolicy, CodecKind, ModelId, ProviderAuthConfig, ProviderId, catalog, +}; pub use fabro_model::{CredentialRef, CredentialRefParseError, ReasoningEffortFeature}; use fabro_types::settings::InterpString; use serde::{Deserialize, Serialize}; @@ -43,7 +44,8 @@ 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. + /// Legacy top-level model definitions. New settings put models below + /// their provider; parsing normalizes this map before layers combine. #[serde(default, skip_serializing_if = "MergeMap::is_empty")] pub models: MergeMap, } @@ -87,13 +89,16 @@ pub struct ProviderSettings { pub enabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub aliases: Option>, + /// Model offerings served by this provider, keyed by canonical model ID. + #[serde(default, skip_serializing_if = "MergeMap::is_empty")] + pub models: MergeMap, } -/// One entry in `[llm.models.]`. +/// One entry in `[llm.providers..models.]`. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ModelSettings { - /// Provider ID this model belongs to. + /// Compatibility-only provider for legacy `[llm.models.]` rows. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, /// Identifier sent to the provider API. Defaults to the catalog model ID @@ -155,6 +160,71 @@ pub struct ModelSettings { pub costs: Option, } +impl LlmLayer { + /// Normalize the temporary legacy model table before this source is + /// combined with any other settings source. Resolution runs against this + /// layer's own providers plus the built-in catalog, because a single + /// source may reference built-in offerings that merge in later. + pub(crate) fn normalize_legacy_models(&mut self) -> Result<(), catalog::LegacyModelError> { + for (provider, settings) in self.providers.iter() { + for (model, settings) in settings.models.iter() { + if settings.provider.is_some() { + return Err(catalog::LegacyModelError::ScopedModelDeclaresProvider { + provider: ProviderId::new(provider.clone()), + model: ModelId::new(model.clone()), + }); + } + } + } + + let legacy_models = std::mem::take(&mut self.models.0); + if legacy_models.is_empty() { + return Ok(()); + } + let mut legacy_models = legacy_models.into_iter().collect::>(); + legacy_models.sort_by(|(left, _), (right, _)| left.cmp(right)); + + let mut index = catalog::LegacyModelIndex::default(); + let mut provider_ids = self.providers.keys().cloned().collect::>(); + provider_ids.sort_unstable(); + for provider_id in &provider_ids { + let settings = self + .providers + .get(provider_id) + .expect("provider ID came from provider map keys"); + let mut model_ids = settings.models.keys().cloned().collect::>(); + model_ids.sort_unstable(); + index.add_provider( + ProviderId::new(provider_id.clone()), + settings.aliases.clone().unwrap_or_default(), + model_ids.into_iter().map(|model_id| { + let model = settings + .models + .get(&model_id) + .expect("model ID came from model map keys"); + let aliases = model.aliases.clone().unwrap_or_default(); + (ModelId::new(model_id), aliases) + }), + ); + } + let index = index.with_builtin()?; + + for (legacy_id, mut settings) in legacy_models { + let explicit_provider = settings.provider.take(); + let (provider, model) = index.resolve(&legacy_id, explicit_provider.as_deref())?; + + let provider_settings = self.providers.entry(provider.to_string()).or_default(); + if provider_settings.models.contains_key(model.as_str()) { + return Err(catalog::LegacyModelError::DuplicateModel { provider, model }); + } + provider_settings + .models + .insert(model.into_inner(), settings); + } + Ok(()) + } +} + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ModelLimits { @@ -168,17 +238,19 @@ pub struct ModelLimits { #[serde(deny_unknown_fields)] pub struct ModelFeatures { #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, + pub tools: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub vision: Option, + pub vision: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning: Option, + pub reasoning: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option, + pub reasoning_effort: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub prompt_cache: Option, + pub prompt_cache: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub sampling_params: Option, + pub cache_control_breakpoints: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sampling_params: Option, } /// User-facing allow-list for native control values Fabro accepts on this diff --git a/lib/crates/fabro-config/src/layers/log_filter.rs b/lib/foundation/fabro-config/src/layers/log_filter.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/log_filter.rs rename to lib/foundation/fabro-config/src/layers/log_filter.rs diff --git a/lib/crates/fabro-config/src/layers/maps.rs b/lib/foundation/fabro-config/src/layers/maps.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/maps.rs rename to lib/foundation/fabro-config/src/layers/maps.rs diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/foundation/fabro-config/src/layers/mod.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/mod.rs rename to lib/foundation/fabro-config/src/layers/mod.rs diff --git a/lib/crates/fabro-config/src/layers/project.rs b/lib/foundation/fabro-config/src/layers/project.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/project.rs rename to lib/foundation/fabro-config/src/layers/project.rs diff --git a/lib/crates/fabro-config/src/layers/run.rs b/lib/foundation/fabro-config/src/layers/run.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/run.rs rename to lib/foundation/fabro-config/src/layers/run.rs diff --git a/lib/crates/fabro-config/src/layers/server.rs b/lib/foundation/fabro-config/src/layers/server.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/server.rs rename to lib/foundation/fabro-config/src/layers/server.rs diff --git a/lib/crates/fabro-config/src/layers/settings.rs b/lib/foundation/fabro-config/src/layers/settings.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/settings.rs rename to lib/foundation/fabro-config/src/layers/settings.rs diff --git a/lib/crates/fabro-config/src/layers/splice_array.rs b/lib/foundation/fabro-config/src/layers/splice_array.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/splice_array.rs rename to lib/foundation/fabro-config/src/layers/splice_array.rs diff --git a/lib/crates/fabro-config/src/layers/workflow.rs b/lib/foundation/fabro-config/src/layers/workflow.rs similarity index 100% rename from lib/crates/fabro-config/src/layers/workflow.rs rename to lib/foundation/fabro-config/src/layers/workflow.rs diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/foundation/fabro-config/src/lib.rs similarity index 100% rename from lib/crates/fabro-config/src/lib.rs rename to lib/foundation/fabro-config/src/lib.rs diff --git a/lib/crates/fabro-config/src/load.rs b/lib/foundation/fabro-config/src/load.rs similarity index 100% rename from lib/crates/fabro-config/src/load.rs rename to lib/foundation/fabro-config/src/load.rs diff --git a/lib/crates/fabro-config/src/logging.rs b/lib/foundation/fabro-config/src/logging.rs similarity index 100% rename from lib/crates/fabro-config/src/logging.rs rename to lib/foundation/fabro-config/src/logging.rs diff --git a/lib/crates/fabro-config/src/migrations.rs b/lib/foundation/fabro-config/src/migrations.rs similarity index 100% rename from lib/crates/fabro-config/src/migrations.rs rename to lib/foundation/fabro-config/src/migrations.rs diff --git a/lib/crates/fabro-config/src/parse.rs b/lib/foundation/fabro-config/src/parse.rs similarity index 64% rename from lib/crates/fabro-config/src/parse.rs rename to lib/foundation/fabro-config/src/parse.rs index b4e008791..47b88b728 100644 --- a/lib/crates/fabro-config/src/parse.rs +++ b/lib/foundation/fabro-config/src/parse.rs @@ -1,5 +1,7 @@ use std::fmt; +use fabro_model::catalog::LegacyModelError; + use crate::SettingsLayer; const CURRENT_VERSION: u32 = 1; @@ -30,6 +32,7 @@ const LEGACY_LLM_KEYS: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParseError { Toml(String), + LlmCatalog(LegacyModelError), Version(VersionError), UnknownTopLevelKey { key: String, @@ -45,6 +48,7 @@ impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Toml(msg) => write!(f, "settings file is not valid TOML: {msg}"), + Self::LlmCatalog(err) => fmt::Display::fmt(err, f), Self::Version(err) => fmt::Display::fmt(err, f), Self::UnknownTopLevelKey { key, hint } => { if let Some(hint) = hint { @@ -118,8 +122,14 @@ pub(crate) fn parse_settings(input: &str) -> Result { } } - raw.try_into::() - .map_err(|e| ParseError::Toml(e.to_string())) + let mut layer = raw + .try_into::() + .map_err(|e| ParseError::Toml(e.to_string()))?; + if let Some(llm) = layer.llm.as_mut() { + llm.normalize_legacy_models() + .map_err(ParseError::LlmCatalog)?; + } + Ok(layer) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -290,10 +300,218 @@ mod tests { #[test] fn accepts_new_llm_models_subtree() { - let parsed = "[llm.models.\"foo\"]\nprovider = \"kimi\"\n" + let parsed = "[llm.providers.kimi.models.\"foo\"]\n" .parse::() .unwrap(); - assert!(parsed.llm.unwrap().models.contains_key("foo")); + assert!( + parsed + .llm + .unwrap() + .providers + .get("kimi") + .unwrap() + .models + .contains_key("foo") + ); + } + + #[test] + fn provider_scoped_model_rejects_redundant_provider_field() { + let error = r#" +[llm.providers.openai.models."gpt-5.4"] +provider = "openai" +"# + .parse::() + .unwrap_err(); + + assert!(matches!( + error, + ParseError::LlmCatalog(LegacyModelError::ScopedModelDeclaresProvider { + provider, + model, + }) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4" + )); + } + + #[test] + fn legacy_model_row_with_provider_normalizes_before_merge() { + use crate::layers::Combine as _; + + let higher = r#" +[llm.models."gpt-5.4"] +provider = "openai" +display_name = "Configured display name" +"# + .parse::() + .unwrap(); + let fallback = r#" +[llm.providers.openai.models."gpt-5.4"] +family = "gpt-5" +"# + .parse::() + .unwrap(); + + let merged = higher.combine(fallback); + let llm = merged.llm.unwrap(); + assert!(llm.models.is_empty()); + let model = llm + .providers + .get("openai") + .unwrap() + .models + .get("gpt-5.4") + .unwrap(); + assert_eq!( + model.display_name.as_deref(), + Some("Configured display name") + ); + assert_eq!(model.family.as_deref(), Some("gpt-5")); + } + + #[test] + fn provider_less_legacy_row_adopts_unique_builtin_offering() { + let parsed = r#" +[llm.models.mercury] +display_name = "Configured Mercury" +"# + .parse::() + .unwrap(); + let llm = parsed.llm.unwrap(); + + assert!(llm.models.is_empty()); + assert!( + llm.providers + .get("inception") + .unwrap() + .models + .contains_key("mercury-2") + ); + } + + #[test] + fn provider_less_legacy_row_rejects_ambiguous_builtin_offering() { + let error = r#" +[llm.models."gpt-5.6-sol"] +display_name = "Ambiguous" +"# + .parse::() + .unwrap_err(); + + assert!(matches!( + error, + ParseError::LlmCatalog(LegacyModelError::AmbiguousModel { + model, + candidates, + }) if model == "gpt-5.6-sol" && candidates.len() >= 2 + )); + } + + #[test] + fn same_source_legacy_and_provider_scoped_rows_conflict() { + let error = r#" +[llm.providers.openai.models."gpt-5.4"] +display_name = "Canonical" + +[llm.models."gpt-5.4"] +provider = "openai" +display_name = "Legacy" +"# + .parse::() + .unwrap_err(); + + assert!(matches!( + error, + ParseError::LlmCatalog(LegacyModelError::DuplicateModel { + provider, + model, + }) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4" + )); + } + + #[test] + fn legacy_builtin_model_id_normalizes_with_explicit_provider() { + let parsed = r#" +[llm.models."openai/gpt-5.6-sol"] +provider = "openrouter" +display_name = "Configured Sol" +"# + .parse::() + .unwrap(); + let llm = parsed.llm.unwrap(); + + assert!(llm.models.is_empty()); + let model = llm + .providers + .get("openrouter") + .unwrap() + .models + .get("gpt-5.6-sol") + .unwrap(); + assert_eq!(model.display_name.as_deref(), Some("Configured Sol")); + assert!(model.provider.is_none()); + } + + #[test] + fn legacy_builtin_model_id_without_provider_uses_historical_catalog_provider() { + let parsed = r#" +[llm.models."anthropic/claude-fable-5"] +display_name = "Configured Fable" +"# + .parse::() + .unwrap(); + let llm = parsed.llm.unwrap(); + + assert!(llm.models.is_empty()); + assert!( + llm.providers + .get("openrouter") + .unwrap() + .models + .contains_key("claude-fable-5") + ); + } + + #[test] + fn same_model_slug_on_different_providers_merges_independently() { + use crate::layers::Combine as _; + + let direct = r#" +[llm.providers.openai.models.shared] +display_name = "Direct" +"# + .parse::() + .unwrap(); + let aggregator = r#" +[llm.providers.openrouter.models.shared] +display_name = "Aggregator" +"# + .parse::() + .unwrap(); + + let merged = direct.combine(aggregator); + let providers = merged.llm.unwrap().providers; + assert_eq!( + providers + .get("openai") + .unwrap() + .models + .get("shared") + .unwrap() + .display_name + .as_deref(), + Some("Direct") + ); + assert_eq!( + providers + .get("openrouter") + .unwrap() + .models + .get("shared") + .unwrap() + .display_name + .as_deref(), + Some("Aggregator") + ); } #[test] diff --git a/lib/crates/fabro-config/src/project.rs b/lib/foundation/fabro-config/src/project.rs similarity index 100% rename from lib/crates/fabro-config/src/project.rs rename to lib/foundation/fabro-config/src/project.rs diff --git a/lib/crates/fabro-config/src/resolve/cli.rs b/lib/foundation/fabro-config/src/resolve/cli.rs similarity index 100% rename from lib/crates/fabro-config/src/resolve/cli.rs rename to lib/foundation/fabro-config/src/resolve/cli.rs diff --git a/lib/crates/fabro-config/src/resolve/environment.rs b/lib/foundation/fabro-config/src/resolve/environment.rs similarity index 100% rename from lib/crates/fabro-config/src/resolve/environment.rs rename to lib/foundation/fabro-config/src/resolve/environment.rs diff --git a/lib/crates/fabro-config/src/resolve/error.rs b/lib/foundation/fabro-config/src/resolve/error.rs similarity index 100% rename from lib/crates/fabro-config/src/resolve/error.rs rename to lib/foundation/fabro-config/src/resolve/error.rs diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/foundation/fabro-config/src/resolve/mod.rs similarity index 100% rename from lib/crates/fabro-config/src/resolve/mod.rs rename to lib/foundation/fabro-config/src/resolve/mod.rs diff --git a/lib/crates/fabro-config/src/resolve/project.rs b/lib/foundation/fabro-config/src/resolve/project.rs similarity index 100% rename from lib/crates/fabro-config/src/resolve/project.rs rename to lib/foundation/fabro-config/src/resolve/project.rs diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/foundation/fabro-config/src/resolve/run.rs similarity index 100% rename from lib/crates/fabro-config/src/resolve/run.rs rename to lib/foundation/fabro-config/src/resolve/run.rs diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/foundation/fabro-config/src/resolve/server.rs similarity index 100% rename from lib/crates/fabro-config/src/resolve/server.rs rename to lib/foundation/fabro-config/src/resolve/server.rs diff --git a/lib/crates/fabro-config/src/resolve/workflow.rs b/lib/foundation/fabro-config/src/resolve/workflow.rs similarity index 100% rename from lib/crates/fabro-config/src/resolve/workflow.rs rename to lib/foundation/fabro-config/src/resolve/workflow.rs diff --git a/lib/crates/fabro-config/src/run.rs b/lib/foundation/fabro-config/src/run.rs similarity index 100% rename from lib/crates/fabro-config/src/run.rs rename to lib/foundation/fabro-config/src/run.rs diff --git a/lib/crates/fabro-config/src/storage.rs b/lib/foundation/fabro-config/src/storage.rs similarity index 100% rename from lib/crates/fabro-config/src/storage.rs rename to lib/foundation/fabro-config/src/storage.rs diff --git a/lib/crates/fabro-config/src/tests/combine.rs b/lib/foundation/fabro-config/src/tests/combine.rs similarity index 100% rename from lib/crates/fabro-config/src/tests/combine.rs rename to lib/foundation/fabro-config/src/tests/combine.rs diff --git a/lib/crates/fabro-config/src/tests/defaults.rs b/lib/foundation/fabro-config/src/tests/defaults.rs similarity index 100% rename from lib/crates/fabro-config/src/tests/defaults.rs rename to lib/foundation/fabro-config/src/tests/defaults.rs diff --git a/lib/crates/fabro-config/src/tests/log_filter.rs b/lib/foundation/fabro-config/src/tests/log_filter.rs similarity index 100% rename from lib/crates/fabro-config/src/tests/log_filter.rs rename to lib/foundation/fabro-config/src/tests/log_filter.rs diff --git a/lib/crates/fabro-config/src/tests/mod.rs b/lib/foundation/fabro-config/src/tests/mod.rs similarity index 100% rename from lib/crates/fabro-config/src/tests/mod.rs rename to lib/foundation/fabro-config/src/tests/mod.rs diff --git a/lib/crates/fabro-config/src/tests/resolve_cli.rs b/lib/foundation/fabro-config/src/tests/resolve_cli.rs similarity index 100% rename from lib/crates/fabro-config/src/tests/resolve_cli.rs rename to lib/foundation/fabro-config/src/tests/resolve_cli.rs diff --git a/lib/crates/fabro-config/src/tests/resolve_project.rs b/lib/foundation/fabro-config/src/tests/resolve_project.rs similarity index 100% rename from lib/crates/fabro-config/src/tests/resolve_project.rs rename to lib/foundation/fabro-config/src/tests/resolve_project.rs diff --git a/lib/crates/fabro-config/src/tests/resolve_root.rs b/lib/foundation/fabro-config/src/tests/resolve_root.rs similarity index 100% rename from lib/crates/fabro-config/src/tests/resolve_root.rs rename to lib/foundation/fabro-config/src/tests/resolve_root.rs diff --git a/lib/crates/fabro-config/src/tests/resolve_run.rs b/lib/foundation/fabro-config/src/tests/resolve_run.rs similarity index 100% rename from lib/crates/fabro-config/src/tests/resolve_run.rs rename to lib/foundation/fabro-config/src/tests/resolve_run.rs diff --git a/lib/crates/fabro-config/src/tests/resolve_server.rs b/lib/foundation/fabro-config/src/tests/resolve_server.rs similarity index 100% rename from lib/crates/fabro-config/src/tests/resolve_server.rs rename to lib/foundation/fabro-config/src/tests/resolve_server.rs diff --git a/lib/crates/fabro-config/src/tests/resolve_workflow.rs b/lib/foundation/fabro-config/src/tests/resolve_workflow.rs similarity index 100% rename from lib/crates/fabro-config/src/tests/resolve_workflow.rs rename to lib/foundation/fabro-config/src/tests/resolve_workflow.rs diff --git a/lib/crates/fabro-config/src/user.rs b/lib/foundation/fabro-config/src/user.rs similarity index 100% rename from lib/crates/fabro-config/src/user.rs rename to lib/foundation/fabro-config/src/user.rs diff --git a/lib/crates/fabro-core/Cargo.toml b/lib/foundation/fabro-core/Cargo.toml similarity index 100% rename from lib/crates/fabro-core/Cargo.toml rename to lib/foundation/fabro-core/Cargo.toml diff --git a/lib/crates/fabro-core/src/context.rs b/lib/foundation/fabro-core/src/context.rs similarity index 100% rename from lib/crates/fabro-core/src/context.rs rename to lib/foundation/fabro-core/src/context.rs diff --git a/lib/crates/fabro-core/src/error.rs b/lib/foundation/fabro-core/src/error.rs similarity index 100% rename from lib/crates/fabro-core/src/error.rs rename to lib/foundation/fabro-core/src/error.rs diff --git a/lib/crates/fabro-core/src/executor.rs b/lib/foundation/fabro-core/src/executor.rs similarity index 100% rename from lib/crates/fabro-core/src/executor.rs rename to lib/foundation/fabro-core/src/executor.rs diff --git a/lib/crates/fabro-core/src/graph.rs b/lib/foundation/fabro-core/src/graph.rs similarity index 100% rename from lib/crates/fabro-core/src/graph.rs rename to lib/foundation/fabro-core/src/graph.rs diff --git a/lib/crates/fabro-core/src/handler.rs b/lib/foundation/fabro-core/src/handler.rs similarity index 100% rename from lib/crates/fabro-core/src/handler.rs rename to lib/foundation/fabro-core/src/handler.rs diff --git a/lib/crates/fabro-core/src/lib.rs b/lib/foundation/fabro-core/src/lib.rs similarity index 100% rename from lib/crates/fabro-core/src/lib.rs rename to lib/foundation/fabro-core/src/lib.rs diff --git a/lib/crates/fabro-core/src/lifecycle.rs b/lib/foundation/fabro-core/src/lifecycle.rs similarity index 100% rename from lib/crates/fabro-core/src/lifecycle.rs rename to lib/foundation/fabro-core/src/lifecycle.rs diff --git a/lib/crates/fabro-core/src/outcome.rs b/lib/foundation/fabro-core/src/outcome.rs similarity index 100% rename from lib/crates/fabro-core/src/outcome.rs rename to lib/foundation/fabro-core/src/outcome.rs diff --git a/lib/crates/fabro-core/src/retry.rs b/lib/foundation/fabro-core/src/retry.rs similarity index 100% rename from lib/crates/fabro-core/src/retry.rs rename to lib/foundation/fabro-core/src/retry.rs diff --git a/lib/crates/fabro-core/src/stall.rs b/lib/foundation/fabro-core/src/stall.rs similarity index 100% rename from lib/crates/fabro-core/src/stall.rs rename to lib/foundation/fabro-core/src/stall.rs diff --git a/lib/crates/fabro-core/src/state.rs b/lib/foundation/fabro-core/src/state.rs similarity index 100% rename from lib/crates/fabro-core/src/state.rs rename to lib/foundation/fabro-core/src/state.rs diff --git a/lib/crates/fabro-core/src/test_fixtures.rs b/lib/foundation/fabro-core/src/test_fixtures.rs similarity index 100% rename from lib/crates/fabro-core/src/test_fixtures.rs rename to lib/foundation/fabro-core/src/test_fixtures.rs diff --git a/lib/crates/fabro-db/Cargo.toml b/lib/foundation/fabro-db/Cargo.toml similarity index 100% rename from lib/crates/fabro-db/Cargo.toml rename to lib/foundation/fabro-db/Cargo.toml diff --git a/lib/crates/fabro-db/migrations/2026063001_variables.sql b/lib/foundation/fabro-db/migrations/2026063001_variables.sql similarity index 100% rename from lib/crates/fabro-db/migrations/2026063001_variables.sql rename to lib/foundation/fabro-db/migrations/2026063001_variables.sql diff --git a/lib/crates/fabro-db/migrations/2026063002_environments.sql b/lib/foundation/fabro-db/migrations/2026063002_environments.sql similarity index 100% rename from lib/crates/fabro-db/migrations/2026063002_environments.sql rename to lib/foundation/fabro-db/migrations/2026063002_environments.sql diff --git a/lib/crates/fabro-db/migrations/2026071101_secrets.sql b/lib/foundation/fabro-db/migrations/2026071101_secrets.sql similarity index 100% rename from lib/crates/fabro-db/migrations/2026071101_secrets.sql rename to lib/foundation/fabro-db/migrations/2026071101_secrets.sql diff --git a/lib/crates/fabro-db/migrations/2026071102_mcp_servers.sql b/lib/foundation/fabro-db/migrations/2026071102_mcp_servers.sql similarity index 100% rename from lib/crates/fabro-db/migrations/2026071102_mcp_servers.sql rename to lib/foundation/fabro-db/migrations/2026071102_mcp_servers.sql diff --git a/lib/crates/fabro-db/migrations/2026071103_automations.sql b/lib/foundation/fabro-db/migrations/2026071103_automations.sql similarity index 100% rename from lib/crates/fabro-db/migrations/2026071103_automations.sql rename to lib/foundation/fabro-db/migrations/2026071103_automations.sql diff --git a/lib/crates/fabro-db/migrations/2026071104_runs.sql b/lib/foundation/fabro-db/migrations/2026071104_runs.sql similarity index 100% rename from lib/crates/fabro-db/migrations/2026071104_runs.sql rename to lib/foundation/fabro-db/migrations/2026071104_runs.sql diff --git a/lib/crates/fabro-db/src/lib.rs b/lib/foundation/fabro-db/src/lib.rs similarity index 100% rename from lib/crates/fabro-db/src/lib.rs rename to lib/foundation/fabro-db/src/lib.rs diff --git a/lib/crates/fabro-db/tests/sqlite.rs b/lib/foundation/fabro-db/tests/sqlite.rs similarity index 100% rename from lib/crates/fabro-db/tests/sqlite.rs rename to lib/foundation/fabro-db/tests/sqlite.rs diff --git a/lib/crates/fabro-dev/Cargo.toml b/lib/foundation/fabro-dev/Cargo.toml similarity index 100% rename from lib/crates/fabro-dev/Cargo.toml rename to lib/foundation/fabro-dev/Cargo.toml diff --git a/lib/crates/fabro-dev/src/commands/bench_tests.rs b/lib/foundation/fabro-dev/src/commands/bench_tests.rs similarity index 100% rename from lib/crates/fabro-dev/src/commands/bench_tests.rs rename to lib/foundation/fabro-dev/src/commands/bench_tests.rs diff --git a/lib/crates/fabro-dev/src/commands/build.rs b/lib/foundation/fabro-dev/src/commands/build.rs similarity index 100% rename from lib/crates/fabro-dev/src/commands/build.rs rename to lib/foundation/fabro-dev/src/commands/build.rs diff --git a/lib/crates/fabro-dev/src/commands/docker_build.rs b/lib/foundation/fabro-dev/src/commands/docker_build.rs similarity index 100% rename from lib/crates/fabro-dev/src/commands/docker_build.rs rename to lib/foundation/fabro-dev/src/commands/docker_build.rs diff --git a/lib/crates/fabro-dev/src/commands/docs.rs b/lib/foundation/fabro-dev/src/commands/docs.rs similarity index 100% rename from lib/crates/fabro-dev/src/commands/docs.rs rename to lib/foundation/fabro-dev/src/commands/docs.rs diff --git a/lib/crates/fabro-dev/src/commands/docs_cli_reference.rs b/lib/foundation/fabro-dev/src/commands/docs_cli_reference.rs similarity index 100% rename from lib/crates/fabro-dev/src/commands/docs_cli_reference.rs rename to lib/foundation/fabro-dev/src/commands/docs_cli_reference.rs diff --git a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs b/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs similarity index 89% rename from lib/crates/fabro-dev/src/commands/docs_options_reference.rs rename to lib/foundation/fabro-dev/src/commands/docs_options_reference.rs index aacc08575..9a2414e0a 100644 --- a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs +++ b/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs @@ -247,19 +247,20 @@ x-team-secret = "{{ secrets.gateway_team_secret }}" | `auth.credentials` | array | required when `auth` present | Ordered credential refs. Accepted forms are `vault:`, `env:`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. | | `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. | | `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are interpolation strings: literal text, an `{{ env.NAME }}` token, or a `{{ secrets.NAME }}` token. Put credentials in a secret and reference them with a `{{ secrets.NAME }}` token, not a bare literal. | -| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. | +| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection; ties use canonical provider ID. | | `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. | | `aliases` | array | `[]` | Additional provider names accepted by model routing and fallback config. | -## `[llm.models.]` +## `[llm.providers..models.]` -Define or override a model in the catalog. The table key is the canonical -model ID Fabro users reference; `api_id` is the model string sent to the -provider API. +Define or override one provider's offering of a model. The table key is the +canonical model slug Fabro users reference. An offering's identity is the +pair `(provider, model slug)`, so different providers may use the same slug +and aliases. `api_id` is the opaque model string sent to this provider's API +and defaults to the exact model slug. ```toml title="settings.toml" -[llm.models."team-code-large"] -provider = "proxy" +[llm.providers.proxy.models."team-code-large"] api_id = "provider-wire-model-name" agent_profile = "anthropic" display_name = "Team Code Large" @@ -270,27 +271,27 @@ enabled = true aliases = ["team-code"] estimated_output_tps = 80 -[llm.models."team-code-large".limits] +[llm.providers.proxy.models."team-code-large".limits] context_window = 200000 max_output = 32000 -[llm.models."team-code-large".features] +[llm.providers.proxy.models."team-code-large".features] tools = true vision = false reasoning = true reasoning_effort = "levels" prompt_cache = true -[llm.models."team-code-large".controls] +[llm.providers.proxy.models."team-code-large".controls] reasoning_effort = ["low", "medium", "high"] speed = ["fast"] -[llm.models."team-code-large".costs] +[llm.providers.proxy.models."team-code-large".costs] input_cost_per_mtok = 1.50 output_cost_per_mtok = 8.00 cache_input_cost_per_mtok = 0.30 -[llm.models."team-code-large".costs.speed.fast] +[llm.providers.proxy.models."team-code-large".costs.speed.fast] input_cost_per_mtok = 3.00 output_cost_per_mtok = 16.00 cache_input_cost_per_mtok = 0.60 @@ -298,8 +299,7 @@ cache_input_cost_per_mtok = 0.60 | Key | Type / values | Default | Description | |---|---|---|---| -| `provider` | string | None | Provider ID this model belongs to. | -| `api_id` | string | model ID | Identifier sent to the provider API. | +| `api_id` | string | model slug | Opaque identifier sent to this provider's API. An explicitly empty value is invalid. | | `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | provider profile | Agent profile override for this model. Model overrides take precedence over provider overrides. | | `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | provider policy | Billing algorithm override for this model — for models whose billing family differs from their provider's (e.g. Claude served through OpenRouter bills Anthropic-style cache reads/writes). | | `display_name` | string | model ID | Human-readable model name. | @@ -309,17 +309,17 @@ cache_input_cost_per_mtok = 0.60 | `default` | boolean | `false` | Whether this is the provider default model. | | `probe` | boolean | `false` | Whether this model should be preferred for provider connectivity probes. Set `false` in a higher-precedence layer to clear an inherited probe marker. | | `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. | -| `aliases` | array | `[]` | Additional model names accepted by routing and fallback config. | +| `aliases` | array | `[]` | Additional model selectors accepted by routing and fallback config. Aliases may repeat across providers, but one selector cannot identify two models within the same provider. | | `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. | -## `[llm.models..limits]` +## `[llm.providers..models..limits]` | Key | Type / values | Default | Description | |---|---|---|---| | `context_window` | integer | None | Maximum context window size in tokens. | | `max_output` | integer | None | Maximum output tokens, if known. | -## `[llm.models..features]` +## `[llm.providers..models..features]` | Key | Type / values | Default | Description | |---|---|---|---| @@ -330,14 +330,14 @@ cache_input_cost_per_mtok = 0.60 | `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. | | `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). | -## `[llm.models..controls]` +## `[llm.providers..models..controls]` | Key | Type / values | Default | Description | |---|---|---|---| | `reasoning_effort` | array | all standard levels when feature is `"levels"` or `"always_adaptive"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. | | `speed` | array | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. | -## `[llm.models..costs]` +## `[llm.providers..models..costs]` | Key | Type / values | Default | Description | |---|---|---|---| @@ -345,10 +345,12 @@ cache_input_cost_per_mtok = 0.60 | `output_cost_per_mtok` | number | None | Output cost in USD per million tokens. | | `cache_input_cost_per_mtok` | number | None | Cached input/read cost in USD per million tokens. | -## `[llm.models..costs.speed.]` +## `[llm.providers..models..costs.speed.]` -Per-speed cost overrides use the same keys as `[llm.models..costs]`. -Each `` key must be declared in `[llm.models..controls].speed`. +Per-speed cost overrides use the same keys as +`[llm.providers..models..costs]`. Each `` key +must be declared in +`[llm.providers..models..controls].speed`. The `standard` speed is implicit and always uses the base cost table. "#, diff --git a/lib/crates/fabro-dev/src/commands/mod.rs b/lib/foundation/fabro-dev/src/commands/mod.rs similarity index 100% rename from lib/crates/fabro-dev/src/commands/mod.rs rename to lib/foundation/fabro-dev/src/commands/mod.rs diff --git a/lib/crates/fabro-dev/src/commands/release.rs b/lib/foundation/fabro-dev/src/commands/release.rs similarity index 100% rename from lib/crates/fabro-dev/src/commands/release.rs rename to lib/foundation/fabro-dev/src/commands/release.rs diff --git a/lib/crates/fabro-dev/src/commands/spa.rs b/lib/foundation/fabro-dev/src/commands/spa.rs similarity index 100% rename from lib/crates/fabro-dev/src/commands/spa.rs rename to lib/foundation/fabro-dev/src/commands/spa.rs diff --git a/lib/crates/fabro-dev/src/commands/spa_check.rs b/lib/foundation/fabro-dev/src/commands/spa_check.rs similarity index 97% rename from lib/crates/fabro-dev/src/commands/spa_check.rs rename to lib/foundation/fabro-dev/src/commands/spa_check.rs index 1fa93ea14..6ac315a79 100644 --- a/lib/crates/fabro-dev/src/commands/spa_check.rs +++ b/lib/foundation/fabro-dev/src/commands/spa_check.rs @@ -14,7 +14,7 @@ const DEFAULT_PAYLOAD_BUDGET_BYTES: u64 = 5 * 1024 * 1024; #[derive(Debug, Args)] pub(crate) struct SpaCheckArgs { - /// Repository root containing lib/crates/fabro-spa/assets. + /// Repository root containing lib/apps/fabro-spa/assets. #[arg(long, hide = true)] root: Option, /// Override the raw asset budget. @@ -36,7 +36,7 @@ pub(crate) fn spa_check(args: SpaCheckArgs) -> Result<()> { let root = args.root.unwrap_or_else(workspace_root); let web_dir = root.join("apps/fabro-web"); let dist_dir = web_dir.join("dist"); - let asset_dir = root.join("lib/crates/fabro-spa/assets"); + let asset_dir = root.join("lib/apps/fabro-spa/assets"); check_spa_asset_budgets( &asset_dir, args.asset_budget_bytes, diff --git a/lib/crates/fabro-dev/src/commands/spa_refresh.rs b/lib/foundation/fabro-dev/src/commands/spa_refresh.rs similarity index 97% rename from lib/crates/fabro-dev/src/commands/spa_refresh.rs rename to lib/foundation/fabro-dev/src/commands/spa_refresh.rs index 2174392ae..a8709ff65 100644 --- a/lib/crates/fabro-dev/src/commands/spa_refresh.rs +++ b/lib/foundation/fabro-dev/src/commands/spa_refresh.rs @@ -14,7 +14,7 @@ const DEFAULT_PAYLOAD_BUDGET_BYTES: u64 = 5 * 1024 * 1024; #[derive(Debug, Args)] pub(crate) struct SpaRefreshArgs { - /// Repository root containing apps/fabro-web and lib/crates/fabro-spa. + /// Repository root containing apps/fabro-web and lib/apps/fabro-spa. #[arg(long, hide = true)] root: Option, /// Override the raw asset budget. @@ -49,7 +49,7 @@ fn spa_refresh_root_with_budgets( ) -> Result<()> { let web_dir = root.join("apps/fabro-web"); let dist_dir = web_dir.join("dist"); - let asset_dir = root.join("lib/crates/fabro-spa/assets"); + let asset_dir = root.join("lib/apps/fabro-spa/assets"); println!("Running bun run build in apps/fabro-web..."); run_bun_build(&web_dir)?; @@ -61,7 +61,7 @@ fn spa_refresh_root_with_budgets( asset_budget_bytes, payload_budget_bytes, )?; - println!("Refreshed lib/crates/fabro-spa/assets"); + println!("Refreshed lib/apps/fabro-spa/assets"); Ok(()) } @@ -411,14 +411,14 @@ mod tests { write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello"); write_file( fixture.path(), - "lib/crates/fabro-spa/assets/index.html", + "lib/apps/fabro-spa/assets/index.html", b"embedded", ); let error = refresh_from_dist( fixture.path(), &fixture.path().join("apps/fabro-web/dist"), - &fixture.path().join("lib/crates/fabro-spa/assets"), + &fixture.path().join("lib/apps/fabro-spa/assets"), 4, 100, ) @@ -431,7 +431,7 @@ mod tests { "budget failure should report raw byte overage: {error:#}" ); assert_eq!( - read_bytes(fixture.path(), "lib/crates/fabro-spa/assets/index.html"), + read_bytes(fixture.path(), "lib/apps/fabro-spa/assets/index.html"), b"embedded" ); } diff --git a/lib/crates/fabro-dev/src/lib.rs b/lib/foundation/fabro-dev/src/lib.rs similarity index 100% rename from lib/crates/fabro-dev/src/lib.rs rename to lib/foundation/fabro-dev/src/lib.rs diff --git a/lib/crates/fabro-dev/src/main.rs b/lib/foundation/fabro-dev/src/main.rs similarity index 100% rename from lib/crates/fabro-dev/src/main.rs rename to lib/foundation/fabro-dev/src/main.rs diff --git a/lib/crates/fabro-dev/tests/it/docker_build.rs b/lib/foundation/fabro-dev/tests/it/docker_build.rs similarity index 100% rename from lib/crates/fabro-dev/tests/it/docker_build.rs rename to lib/foundation/fabro-dev/tests/it/docker_build.rs diff --git a/lib/crates/fabro-dev/tests/it/docker_entrypoint.rs b/lib/foundation/fabro-dev/tests/it/docker_entrypoint.rs similarity index 100% rename from lib/crates/fabro-dev/tests/it/docker_entrypoint.rs rename to lib/foundation/fabro-dev/tests/it/docker_entrypoint.rs diff --git a/lib/crates/fabro-dev/tests/it/docs.rs b/lib/foundation/fabro-dev/tests/it/docs.rs similarity index 100% rename from lib/crates/fabro-dev/tests/it/docs.rs rename to lib/foundation/fabro-dev/tests/it/docs.rs diff --git a/lib/crates/fabro-dev/tests/it/main.rs b/lib/foundation/fabro-dev/tests/it/main.rs similarity index 98% rename from lib/crates/fabro-dev/tests/it/main.rs rename to lib/foundation/fabro-dev/tests/it/main.rs index cbf952b4a..ee503d466 100644 --- a/lib/crates/fabro-dev/tests/it/main.rs +++ b/lib/foundation/fabro-dev/tests/it/main.rs @@ -144,7 +144,7 @@ fn cargo_dev_alias_points_at_fabro_dev() { #[test] fn fabro_dev_does_not_depend_on_fabro_cli() { - let manifest = read_file(&workspace_root(), "lib/crates/fabro-dev/Cargo.toml"); + let manifest = read_file(&workspace_root(), "lib/foundation/fabro-dev/Cargo.toml"); assert!( !manifest.contains("fabro-cli"), "fabro-dev should shell out to fabro-cli instead of depending on it:\n{manifest}" diff --git a/lib/crates/fabro-dev/tests/it/policy.rs b/lib/foundation/fabro-dev/tests/it/policy.rs similarity index 93% rename from lib/crates/fabro-dev/tests/it/policy.rs rename to lib/foundation/fabro-dev/tests/it/policy.rs index 3043c43b2..2f2da24b6 100644 --- a/lib/crates/fabro-dev/tests/it/policy.rs +++ b/lib/foundation/fabro-dev/tests/it/policy.rs @@ -21,15 +21,15 @@ use crate::workspace_root; /// 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", + "lib/foundation/fabro-model/src/bootstrap_catalog", // Public module declaration for the bootstrap hatch. - "lib/crates/fabro-model/src/lib.rs", + "lib/foundation/fabro-model/src/lib.rs", // 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", + "lib/components/fabro-install/", + "lib/apps/fabro-cli/src/commands/install/", + "lib/apps/fabro-cli/src/shared/install_", + "lib/apps/fabro-cli/src/shared/api_key_validation", // Test support modules. "tests/", "test_support", @@ -42,7 +42,7 @@ const BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ /// `Catalog::builtin()` is reserved for `fabro-model` internals and tests. const CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ // The catalog owner may define and test the built-in/default catalog. - "lib/crates/fabro-model/", + "lib/foundation/fabro-model/", // Tests and test support may use built-ins as fixtures. "/tests/", "/tests/it/", @@ -52,11 +52,11 @@ const CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ const TEMPLATE_RENDER_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ // The template crate owns the rendering API and its tests. - "lib/crates/fabro-template/src/lib.rs", + "lib/foundation/fabro-template/src/lib.rs", // Workflow-definition rendering must stay centralized here. - "lib/crates/fabro-workflow/src/transforms/variable_expansion.rs", + "lib/components/fabro-workflow/src/transforms/variable_expansion.rs", // Hook header/env interpolation is a separate system. - "lib/crates/fabro-hooks/src/executor.rs", + "lib/components/fabro-hooks/src/executor.rs", // This policy test names the forbidden patterns. "/tests/it/policy.rs", ]; @@ -79,7 +79,7 @@ fn bootstrap_catalog_references_stay_in_allowlist() { 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.", + "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/foundation/fabro-dev/tests/it/policy.rs.", format_violations(violations), ); } @@ -91,7 +91,7 @@ fn catalog_builtin_references_stay_in_allowlist() { assert!( violations.is_empty(), - "Catalog::builtin() referenced from non-allowlisted production source files:\n{}\n\nRuntime code should use a resolved settings catalog via `Catalog::from_builtin_with_overrides(...)` or an injected `Arc`. If this is intentional test/bootstrap code, add the path fragment to CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS in lib/crates/fabro-dev/tests/it/policy.rs.", + "Catalog::builtin() referenced from non-allowlisted production source files:\n{}\n\nRuntime code should use a resolved settings catalog via `Catalog::from_builtin_with_overrides(...)` or an injected `Arc`. If this is intentional test/bootstrap code, add the path fragment to CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS in lib/foundation/fabro-dev/tests/it/policy.rs.", format_violations(violations), ); } diff --git a/lib/crates/fabro-dev/tests/it/release.rs b/lib/foundation/fabro-dev/tests/it/release.rs similarity index 98% rename from lib/crates/fabro-dev/tests/it/release.rs rename to lib/foundation/fabro-dev/tests/it/release.rs index f6949725e..28fb4cf94 100644 --- a/lib/crates/fabro-dev/tests/it/release.rs +++ b/lib/foundation/fabro-dev/tests/it/release.rs @@ -100,7 +100,7 @@ fn dry_run_computes_stable_version_from_date() { "dry-run should print one SPA refresh command:\n{stdout}" ); assert!( - !stdout.contains("git diff --exit-code -- lib/crates/fabro-spa/assets"), + !stdout.contains("git diff --exit-code -- lib/apps/fabro-spa/assets"), "dry-run should not print a separate SPA asset diff command:\n{stdout}" ); assert!( diff --git a/lib/crates/fabro-dev/tests/it/spa.rs b/lib/foundation/fabro-dev/tests/it/spa.rs similarity index 93% rename from lib/crates/fabro-dev/tests/it/spa.rs rename to lib/foundation/fabro-dev/tests/it/spa.rs index 6eeba4194..dd3749c3f 100644 --- a/lib/crates/fabro-dev/tests/it/spa.rs +++ b/lib/foundation/fabro-dev/tests/it/spa.rs @@ -23,10 +23,10 @@ fn check_passes_when_dist_matches_assets_and_budgets_pass() { write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello"); write_file( fixture.path(), - "lib/crates/fabro-spa/assets/index.html", + "lib/apps/fabro-spa/assets/index.html", b"hello", ); - write_file(fixture.path(), "lib/crates/fabro-spa/assets/.gitkeep", b""); + write_file(fixture.path(), "lib/apps/fabro-spa/assets/.gitkeep", b""); let output = fabro_dev() .args([ @@ -65,7 +65,7 @@ fn check_fails_when_assets_exceed_budget() { write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello"); write_file( fixture.path(), - "lib/crates/fabro-spa/assets/index.html", + "lib/apps/fabro-spa/assets/index.html", b"hello", ); @@ -102,7 +102,7 @@ fn check_fails_when_assets_do_not_match_dist() { write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"current"); write_file( fixture.path(), - "lib/crates/fabro-spa/assets/index.html", + "lib/apps/fabro-spa/assets/index.html", b"embedded", ); @@ -129,7 +129,7 @@ fn check_fails_when_assets_do_not_match_dist() { "stale assets should fail the check:\n{stderr}" ); assert_eq!( - read_bytes(fixture.path(), "lib/crates/fabro-spa/assets/index.html"), + read_bytes(fixture.path(), "lib/apps/fabro-spa/assets/index.html"), b"embedded" ); } @@ -140,7 +140,7 @@ fn check_fails_when_source_map_is_present_in_assets() { write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello"); write_file( fixture.path(), - "lib/crates/fabro-spa/assets/assets/app.js.map", + "lib/apps/fabro-spa/assets/assets/app.js.map", b"map", ); diff --git a/lib/crates/fabro-http/Cargo.toml b/lib/foundation/fabro-http/Cargo.toml similarity index 100% rename from lib/crates/fabro-http/Cargo.toml rename to lib/foundation/fabro-http/Cargo.toml diff --git a/lib/crates/fabro-http/src/lib.rs b/lib/foundation/fabro-http/src/lib.rs similarity index 100% rename from lib/crates/fabro-http/src/lib.rs rename to lib/foundation/fabro-http/src/lib.rs diff --git a/lib/crates/fabro-macros/Cargo.toml b/lib/foundation/fabro-macros/Cargo.toml similarity index 100% rename from lib/crates/fabro-macros/Cargo.toml rename to lib/foundation/fabro-macros/Cargo.toml diff --git a/lib/crates/fabro-macros/src/lib.rs b/lib/foundation/fabro-macros/src/lib.rs similarity index 100% rename from lib/crates/fabro-macros/src/lib.rs rename to lib/foundation/fabro-macros/src/lib.rs diff --git a/lib/crates/fabro-macros/src/options_metadata.rs b/lib/foundation/fabro-macros/src/options_metadata.rs similarity index 100% rename from lib/crates/fabro-macros/src/options_metadata.rs rename to lib/foundation/fabro-macros/src/options_metadata.rs diff --git a/lib/crates/fabro-macros/tests/options_metadata.rs b/lib/foundation/fabro-macros/tests/options_metadata.rs similarity index 100% rename from lib/crates/fabro-macros/tests/options_metadata.rs rename to lib/foundation/fabro-macros/tests/options_metadata.rs diff --git a/lib/crates/fabro-model/Cargo.toml b/lib/foundation/fabro-model/Cargo.toml similarity index 100% rename from lib/crates/fabro-model/Cargo.toml rename to lib/foundation/fabro-model/Cargo.toml diff --git a/lib/crates/fabro-model/src/adapter.rs b/lib/foundation/fabro-model/src/adapter.rs similarity index 100% rename from lib/crates/fabro-model/src/adapter.rs rename to lib/foundation/fabro-model/src/adapter.rs diff --git a/lib/crates/fabro-model/src/billing.rs b/lib/foundation/fabro-model/src/billing.rs similarity index 97% rename from lib/crates/fabro-model/src/billing.rs rename to lib/foundation/fabro-model/src/billing.rs index baa592cb5..6e4de19f0 100644 --- a/lib/crates/fabro-model/src/billing.rs +++ b/lib/foundation/fabro-model/src/billing.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; use crate::catalog::{BillingPolicy, Catalog, CatalogModelSettings}; -use crate::{Model, ModelCosts, ProviderId}; +use crate::{Model, ModelCosts, ModelId, ProviderId}; const TOKENS_PER_MTOK: i128 = 1_000_000; const ANTHROPIC_CACHE_WRITE_5M_NUMERATOR: i64 = 5; @@ -161,7 +161,7 @@ pub enum CostSource { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ModelRef { pub provider: ProviderId, - pub model_id: String, + pub model_id: ModelId, #[serde(default, skip_serializing_if = "Option::is_none")] pub speed: Option, } @@ -344,7 +344,7 @@ impl BilledModelUsage { #[must_use] pub fn model_id(&self) -> &str { - &self.input.usage.model.model_id + self.input.usage.model.model_id.as_str() } #[must_use] @@ -470,13 +470,9 @@ fn anthropic_billing_facts(tokens: &TokenCounts) -> AnthropicBillingFacts { impl Catalog { #[must_use] pub fn pricing_for(&self, model_ref: &ModelRef) -> Option { - let model = self.get(&model_ref.model_id)?; + let model = self.offering(&model_ref.provider, &model_ref.model_id)?; let provider = self.provider(&model_ref.provider)?; - if model.provider != provider.id { - return None; - } - - let settings = self.model_settings(&model.id)?; + let settings = self.settings_for(model)?; let costs = costs_for_speed(model, settings, model_ref.speed)?; pricing_for_model_costs( model, @@ -494,7 +490,7 @@ impl Catalog { tokens: &TokenCounts, ) -> Option { let policy = - self.effective_billing_policy(&model_ref.provider, Some(&model_ref.model_id))?; + self.effective_billing_policy(&model_ref.provider, Some(model_ref.model_id.as_str()))?; ModelBillingFacts::for_policy(policy, tokens) } @@ -748,7 +744,7 @@ mod tests { usage: ModelUsage { model: ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: ModelId::new("gpt-5.4"), speed: None, }, tokens: TokenCounts { @@ -859,12 +855,12 @@ cache_input_cost_per_mtok = 0.3 }; let claude = ModelRef { provider: ProviderId::new("aggregator"), - model_id: "claude-via-aggregator".to_string(), + model_id: ModelId::new("claude-via-aggregator"), speed: None, }; let plain = ModelRef { provider: ProviderId::new("aggregator"), - model_id: "plain-model".to_string(), + model_id: ModelId::new("plain-model"), speed: None, }; @@ -1018,7 +1014,7 @@ cache_input_cost_per_mtok = 0.3 let pricing = ModelPricing { model: ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: ModelId::new("gpt-5.4"), speed: None, }, policy: ModelPricingPolicy::OpenAi(OpenAiModelPricing { @@ -1055,7 +1051,7 @@ cache_input_cost_per_mtok = 0.3 let pricing = Catalog::builtin() .pricing_for(&ModelRef { provider: ProviderId::anthropic(), - model_id: "claude-opus-4-6".to_string(), + model_id: ModelId::new("claude-opus-4-6"), speed: Some(Speed::Fast), }) .unwrap(); @@ -1079,7 +1075,7 @@ cache_input_cost_per_mtok = 0.3 let pricing = Catalog::builtin() .pricing_for(&ModelRef { provider: ProviderId::anthropic(), - model_id: "claude-opus-4-6".to_string(), + model_id: ModelId::new("claude-opus-4-6"), speed: Some(Speed::Standard), }) .unwrap(); @@ -1132,7 +1128,7 @@ cache_input_cost_per_mtok = 0.25 let pricing = catalog .pricing_for(&ModelRef { provider: ProviderId::new("test_anthropic"), - model_id: "test-opus".to_string(), + model_id: ModelId::new("test-opus"), speed: Some(Speed::Fast), }) .unwrap(); @@ -1180,7 +1176,7 @@ cache_input_cost_per_mtok = 0.1 let pricing = catalog .pricing_for(&ModelRef { provider: ProviderId::new("proxy"), - model_id: "proxy-model".to_string(), + model_id: ModelId::new("proxy-model"), speed: None, }) .unwrap(); @@ -1230,7 +1226,7 @@ output_cost_per_mtok = 2.0 catalog .pricing_for(&ModelRef { provider: ProviderId::new("proxy"), - model_id: "canonical-model".to_string(), + model_id: ModelId::new("canonical-model"), speed: None, }) .is_some() @@ -1239,7 +1235,7 @@ output_cost_per_mtok = 2.0 catalog .pricing_for(&ModelRef { provider: ProviderId::new("proxy"), - model_id: "wire-model".to_string(), + model_id: ModelId::new("wire-model"), speed: None, }) .is_none() @@ -1252,7 +1248,7 @@ output_cost_per_mtok = 2.0 Catalog::builtin() .pricing_for(&ModelRef { provider: ProviderId::new("unknown"), - model_id: "claude-opus-4-6".to_string(), + model_id: ModelId::new("claude-opus-4-6"), speed: None, }) .is_none() @@ -1261,7 +1257,7 @@ output_cost_per_mtok = 2.0 Catalog::builtin() .pricing_for(&ModelRef { provider: ProviderId::anthropic(), - model_id: "unknown".to_string(), + model_id: ModelId::new("unknown"), speed: None, }) .is_none() @@ -1270,7 +1266,7 @@ output_cost_per_mtok = 2.0 Catalog::builtin() .pricing_for(&ModelRef { provider: ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: ModelId::new("gpt-5.4"), speed: Some(Speed::Fast), }) .is_none() @@ -1282,7 +1278,7 @@ output_cost_per_mtok = 2.0 let pricing = ModelPricing { model: ModelRef { provider: ProviderId::anthropic(), - model_id: "claude-opus-4-6".to_string(), + model_id: ModelId::new("claude-opus-4-6"), speed: Some(Speed::Fast), }, policy: ModelPricingPolicy::Anthropic(AnthropicModelPricing { @@ -1328,7 +1324,7 @@ output_cost_per_mtok = 2.0 let pricing = ModelPricing { model: ModelRef { provider: ProviderId::gemini(), - model_id: "gemini-3.1-pro-preview".to_string(), + model_id: ModelId::new("gemini-3.1-pro-preview"), speed: None, }, policy: ModelPricingPolicy::Gemini(GeminiModelPricing { diff --git a/lib/crates/fabro-model/src/bootstrap_catalog.rs b/lib/foundation/fabro-model/src/bootstrap_catalog.rs similarity index 100% rename from lib/crates/fabro-model/src/bootstrap_catalog.rs rename to lib/foundation/fabro-model/src/bootstrap_catalog.rs diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs similarity index 72% rename from lib/crates/fabro-model/src/catalog.rs rename to lib/foundation/fabro-model/src/catalog.rs index ce586679e..cac9f878f 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::str::FromStr; use std::sync::LazyLock; @@ -12,7 +12,7 @@ use tracing::warn; use crate::Speed; use crate::adapter::{AdapterKind, AgentProfileKind}; use crate::codec::CodecKind; -use crate::ids::ProviderId; +use crate::ids::{ModelId, ProviderId}; use crate::provider::Provider; use crate::reasoning::ReasoningEffort; use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature}; @@ -32,6 +32,8 @@ struct BuiltinCatalogToml; pub struct LlmCatalogSettings { #[serde(default)] pub providers: HashMap, + /// Legacy `[models.""]` input. Canonical settings place model rows + /// under their provider; this map is normalized before layers merge. #[serde(default)] pub models: HashMap, } @@ -68,11 +70,16 @@ pub struct ProviderCatalogSettings { pub enabled: Option, #[serde(default)] pub aliases: Option>, + /// Model declarations keyed by Fabro's canonical model slug. + #[serde(default)] + pub models: HashMap, } #[derive(Debug, Clone, Default, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] pub struct ModelCatalogSettings { + /// Provider used only by the temporary legacy top-level `[models]` + /// compatibility shape. Canonical provider-scoped rows leave this unset. #[serde(default)] pub provider: Option, #[serde(default)] @@ -132,17 +139,19 @@ pub struct SettingsModelLimits { #[serde(deny_unknown_fields)] pub struct SettingsModelFeatures { #[serde(default)] - pub tools: Option, + pub tools: Option, #[serde(default)] - pub vision: Option, + pub vision: Option, #[serde(default)] - pub reasoning: Option, + pub reasoning: Option, #[serde(default)] - pub reasoning_effort: Option, + pub reasoning_effort: Option, #[serde(default)] - pub prompt_cache: Option, + pub prompt_cache: Option, #[serde(default)] - pub sampling_params: Option, + pub cache_control_breakpoints: Option, + #[serde(default)] + pub sampling_params: Option, } #[derive(Debug, Clone, Default, PartialEq, Deserialize)] @@ -528,11 +537,21 @@ pub enum CatalogBuildError { model: String, provider: ProviderId, }, - #[error("model identifier '{identifier}' is declared by both '{first}' and '{second}'")] - DuplicateModelIdentifier { - identifier: String, - first: String, - second: String, + #[error( + "provider '{provider}' model selector '{selector}' is declared by both '{first}' and '{second}'" + )] + DuplicateProviderModelSelector { + provider: ProviderId, + selector: String, + first: ModelId, + second: ModelId, + }, + #[error(transparent)] + LegacyModel(#[from] LegacyModelError), + #[error("provider '{provider}' model '{model}' has an empty api_id")] + EmptyModelApiId { + provider: ProviderId, + model: ModelId, }, #[error("provider '{provider}' has multiple default models: {models:?}")] MultipleProviderDefaults { @@ -557,6 +576,10 @@ pub enum CatalogBuildError { ReasoningEffortControlsWithoutReasoning { model: String }, #[error("model '{model}' declares reasoning_effort feature but features.reasoning is false")] ReasoningEffortWithoutReasoning { model: String }, + #[error( + "model '{model}' declares cache_control_breakpoints but features.prompt_cache is false" + )] + CacheControlBreakpointsWithoutPromptCache { model: String }, #[error( "model '{model}' must declare at least one reasoning_effort when features.reasoning_effort is levels or always_adaptive" )] @@ -574,17 +597,56 @@ pub enum CatalogBuildError { UndeclaredSpeedCost { model: String, speed: Speed }, } +/// Failure to select one concrete provider/model offering. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ModelSelectionError { + #[error("unknown model provider '{provider}'")] + UnknownProvider { provider: ProviderId }, + #[error("model provider '{provider}' is unavailable")] + ProviderUnavailable { provider: ProviderId }, + #[error("unknown model selector '{selector}'")] + UnknownSelector { selector: String }, + #[error("model selector '{selector}' is unknown on provider '{provider}'")] + UnknownSelectorOnProvider { + selector: String, + provider: ProviderId, + }, + #[error( + "model selector '{selector}' is known but has no offering on an eligible provider; available providers: {providers:?}" + )] + NoEligibleOffering { + selector: String, + providers: Vec, + }, + #[error( + "no default model is available on an eligible provider; providers with defaults: {providers:?}" + )] + NoDefaultModel { providers: Vec }, +} + +/// One provider/model pair chosen by [`Catalog::resolve_selection`]. The +/// model is the canonical catalog ID when the selector matched an offering, +/// or the caller's selector passed through verbatim when it did not. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedModel { + pub provider: ProviderId, + pub model: String, +} + /// Typed model catalog backed by a `Vec`. /// /// Use [`Catalog::builtin()`] for the embedded settings-backed catalog. #[derive(Debug)] pub struct Catalog { - models: Vec, - providers: Vec, - model_settings: HashMap, - model_index: HashMap, - provider_aliases: HashMap, - provider_index: HashMap, + models: Vec, + providers: Vec, + model_settings: HashMap<(ProviderId, ModelId), CatalogModelSettings>, + offering_index: HashMap<(ProviderId, ModelId), usize>, + provider_selector_index: HashMap<(ProviderId, String), usize>, + canonical_candidates: HashMap>, + alias_candidates: HashMap>, + provider_aliases: HashMap, + provider_index: HashMap, } impl Catalog { @@ -596,7 +658,8 @@ impl Catalog { } pub fn from_settings(settings: &LlmCatalogSettings) -> Result { - let mut providers = build_providers(settings)?; + let settings = normalize_catalog_settings(settings.clone(), None)?; + let mut providers = build_providers(&settings)?; providers.sort_by(provider_order); let mut provider_index = HashMap::new(); @@ -617,63 +680,83 @@ impl Catalog { .collect(); let mut models_with_settings = Vec::new(); - let mut model_identifiers = BTreeMap::::new(); - let mut defaults_by_provider = HashMap::>::new(); - let mut small_defaults_by_provider = HashMap::>::new(); + let mut model_identifiers = HashMap::>::new(); + let mut defaults_by_provider = HashMap::>::new(); + let mut small_defaults_by_provider = HashMap::>::new(); - let mut model_ids = settings.models.keys().cloned().collect::>(); - model_ids.sort_unstable(); - for model_id in model_ids { - let model_settings = settings - .models - .get(&model_id) - .expect("model ID came from settings map keys"); - if model_settings.enabled == Some(false) { + let mut provider_ids = settings.providers.keys().cloned().collect::>(); + provider_ids.sort_unstable(); + for provider_id in provider_ids { + if !known_providers.contains(provider_id.as_str()) + || !enabled_providers.contains(provider_id.as_str()) + { continue; } - - let provider_id = - required_model_string(&model_id, model_settings.provider.as_ref(), "provider")?; - if !known_providers.contains(provider_id.as_str()) { - return Err(CatalogBuildError::UnknownModelProvider { - model: model_id, - provider: ProviderId::from(provider_id), - }); - } - if !enabled_providers.contains(provider_id.as_str()) { - continue; - } - let provider = provider_by_id .get(provider_id.as_str()) .expect("enabled provider ID should have provider metadata"); - let (model, resolved_settings) = build_model(&model_id, model_settings, provider)?; + let provider_settings = settings + .providers + .get(&provider_id) + .expect("provider ID came from settings map keys"); + let identifiers = model_identifiers.entry(provider.id.clone()).or_default(); + let mut model_ids = provider_settings.models.keys().cloned().collect::>(); + model_ids.sort_unstable(); + for model_id in model_ids { + let model_settings = provider_settings + .models + .get(&model_id) + .expect("model ID came from provider model map keys"); + if model_settings.enabled == Some(false) { + continue; + } - register_model_identifier(&mut model_identifiers, model.id.clone(), model.id.clone())?; - for alias in &model.aliases { - register_model_identifier(&mut model_identifiers, alias.clone(), model.id.clone())?; - } + if let Some((_, canonical_model)) = legacy_builtin_model(&model_id) { + return Err(LegacyModelError::LegacyIdentifierAsModelId { + identifier: model_id, + provider: provider.id.clone(), + model: canonical_model, + } + .into()); + } - if model.default { - defaults_by_provider - .entry(model.provider.clone()) - .or_default() - .push(model.id.clone()); + let (model, resolved_settings) = build_model(&model_id, model_settings, provider)?; + register_model_identifier( + identifiers, + model.id.as_str().to_string(), + model.id.clone(), + &model.provider, + )?; + for alias in &model.aliases { + register_model_identifier( + identifiers, + alias.clone(), + model.id.clone(), + &model.provider, + )?; + } + + if model.default { + defaults_by_provider + .entry(model.provider.clone()) + .or_default() + .push(model.id.clone()); + } + if model.small_default { + small_defaults_by_provider + .entry(model.provider.clone()) + .or_default() + .push(model.id.clone()); + } + models_with_settings.push((model, resolved_settings)); } - if model.small_default { - small_defaults_by_provider - .entry(model.provider.clone()) - .or_default() - .push(model.id.clone()); - } - models_with_settings.push((model, resolved_settings)); } for (provider, defaults) in defaults_by_provider { if defaults.len() > 1 { return Err(CatalogBuildError::MultipleProviderDefaults { provider, - models: defaults, + models: defaults.into_iter().map(ModelId::into_inner).collect(), }); } } @@ -681,7 +764,10 @@ impl Catalog { if small_defaults.len() > 1 { return Err(CatalogBuildError::MultipleProviderSmallDefaults { provider, - models: small_defaults, + models: small_defaults + .into_iter() + .map(ModelId::into_inner) + .collect(), }); } } @@ -689,21 +775,29 @@ impl Catalog { return Err(CatalogBuildError::NoDefaultModel); } - models_with_settings.sort_by(|(left, _), (right, _)| model_order(left, right)); + models_with_settings.sort_by(|(left, _), (right, _)| { + provider_index[&left.provider] + .cmp(&provider_index[&right.provider]) + .then_with(|| left.id.cmp(&right.id)) + }); warn_multiple_probe_models(&models_with_settings); - let mut model_settings_by_id = HashMap::new(); + let mut model_settings_by_offering = HashMap::new(); let mut models = Vec::new(); for (model, settings) in models_with_settings { - model_settings_by_id.insert(model.id.clone(), settings); + model_settings_by_offering.insert((model.provider.clone(), model.id.clone()), settings); models.push(model); } - let model_index = build_model_index(&models); + let (offering_index, provider_selector_index, canonical_candidates, alias_candidates) = + build_model_indexes(&models); Ok(Self { models, providers, - model_settings: model_settings_by_id, - model_index, + model_settings: model_settings_by_offering, + offering_index, + provider_selector_index, + canonical_candidates, + alias_candidates, provider_aliases, provider_index, }) @@ -712,8 +806,9 @@ impl Catalog { pub fn from_builtin_with_overrides( overrides: &LlmCatalogSettings, ) -> Result { - let builtins = Self::builtin_settings()?; - let settings = merge_catalog_settings(overrides.clone(), builtins); + let builtins = normalize_catalog_settings(Self::builtin_settings()?, None)?; + let overrides = normalize_catalog_settings(overrides.clone(), Some(&builtins))?; + let settings = merge_catalog_settings(overrides, builtins); Self::from_settings(&settings) } @@ -753,21 +848,271 @@ impl Catalog { layer.models.extend(fragment.models); } - Ok(layer) + normalize_catalog_settings(layer, None) } fn from_builtin_toml() -> Result { Self::from_settings(&Self::builtin_settings()?) } - /// Look up a model by ID or alias. + /// Test-only shorthand for selecting from every enabled catalog provider. + /// + /// Production callers must supply an explicit ready-provider snapshot to + /// [`Catalog::select`] or use a provider-scoped lookup. + #[cfg(test)] #[must_use] - pub fn get(&self, id: &str) -> Option<&Model> { - self.model_index - .get(id) + pub(crate) fn get(&self, selector: &str) -> Option<&Model> { + self.candidate_indices(selector) + .and_then(|indices| indices.first()) .and_then(|idx| self.models.get(*idx)) } + /// Look up a selector on exactly one provider, without considering + /// provider availability. Historical built-in API identifiers normalize + /// to their canonical model slug before lookup. + #[must_use] + pub fn get_on_provider(&self, provider: &ProviderId, selector: &str) -> Option<&Model> { + let provider = self.provider(provider)?; + let selector = normalize_legacy_builtin_selector(selector); + self.provider_selector_index + .get(&(provider.id.clone(), selector.into_owned())) + .and_then(|idx| self.models.get(*idx)) + } + + /// Look up a canonical offering by its composite identity. + #[must_use] + pub fn offering(&self, provider: &ProviderId, model: &ModelId) -> Option<&Model> { + let provider = self.provider(provider)?; + self.offering_index + .get(&(provider.id.clone(), model.clone())) + .and_then(|idx| self.models.get(*idx)) + } + + /// Resolve a selector on exactly one provider. + pub fn resolve_on_provider( + &self, + provider: &ProviderId, + selector: &str, + ) -> Result<&Model, ModelSelectionError> { + let provider = + self.provider(provider) + .ok_or_else(|| ModelSelectionError::UnknownProvider { + provider: provider.clone(), + })?; + if let Some(model) = self.get_on_provider(&provider.id, selector) { + return Ok(model); + } + Err(ModelSelectionError::UnknownSelectorOnProvider { + selector: selector.to_string(), + provider: provider.id.clone(), + }) + } + + /// Select one concrete offering for a selector and ready-provider + /// snapshot. + /// + /// Historical built-in API identifiers normalize to their canonical model + /// slug before selection. + /// + /// An explicit provider is a pin. Unqualified selection checks canonical + /// IDs before aliases and uses the catalog's provider priority ordering. + pub fn select<'a>( + &'a self, + selector: &str, + explicit_provider: Option<&ProviderId>, + eligible_providers: &HashSet, + ) -> Result<&'a Model, ModelSelectionError> { + let eligible = eligible_providers + .iter() + .filter_map(|provider| self.provider(provider).map(|provider| provider.id.clone())) + .collect::>(); + + if let Some(explicit_provider) = explicit_provider { + let provider = self.provider(explicit_provider).ok_or_else(|| { + ModelSelectionError::UnknownProvider { + provider: explicit_provider.clone(), + } + })?; + if !eligible.contains(&provider.id) { + return Err(ModelSelectionError::ProviderUnavailable { + provider: provider.id.clone(), + }); + } + return self.resolve_on_provider(&provider.id, selector); + } + + let normalized_selector = normalize_legacy_builtin_selector(selector); + let canonical = self + .canonical_candidates + .get(&ModelId::new(normalized_selector.as_ref())); + if let Some(indices) = canonical { + if let Some(model) = indices + .iter() + .filter_map(|idx| self.models.get(*idx)) + .find(|model| eligible.contains(&model.provider)) + { + return Ok(model); + } + } + + let aliases = self.alias_candidates.get(normalized_selector.as_ref()); + if let Some(indices) = aliases { + if let Some(model) = indices + .iter() + .filter_map(|idx| self.models.get(*idx)) + .find(|model| eligible.contains(&model.provider)) + { + return Ok(model); + } + } + + let mut providers = Vec::new(); + for index in canonical + .into_iter() + .flatten() + .chain(aliases.into_iter().flatten()) + { + let Some(model) = self.models.get(*index) else { + continue; + }; + if !providers.contains(&model.provider) { + providers.push(model.provider.clone()); + } + } + if !providers.is_empty() { + return Err(ModelSelectionError::NoEligibleOffering { + selector: selector.to_string(), + providers, + }); + } + + Err(ModelSelectionError::UnknownSelector { + selector: selector.to_string(), + }) + } + + #[must_use] + pub fn all_provider_ids(&self) -> HashSet { + self.providers + .iter() + .map(|provider| provider.id.clone()) + .collect() + } + + /// Select the highest-priority default model on an eligible provider. + pub fn select_default( + &self, + eligible_providers: &HashSet, + ) -> Result<&Model, ModelSelectionError> { + let eligible = eligible_providers + .iter() + .filter_map(|provider| self.provider(provider).map(|provider| provider.id.clone())) + .collect::>(); + if let Some(model) = self + .models + .iter() + .find(|model| model.default && eligible.contains(&model.provider)) + { + return Ok(model); + } + let mut providers = self + .models + .iter() + .filter(|model| model.default) + .map(|model| model.provider.clone()) + .collect::>(); + providers.sort(); + providers.dedup(); + Err(ModelSelectionError::NoDefaultModel { providers }) + } + + /// Canonicalize a provider ID or alias and require it to be in the + /// eligible snapshot. + pub fn ready_provider( + &self, + provider: &ProviderId, + eligible_providers: &HashSet, + ) -> Result { + let provider = + self.provider(provider) + .ok_or_else(|| ModelSelectionError::UnknownProvider { + provider: provider.clone(), + })?; + let ready = eligible_providers.iter().any(|eligible| { + self.provider(eligible) + .is_some_and(|eligible| eligible.id == provider.id) + }); + if !ready { + return Err(ModelSelectionError::ProviderUnavailable { + provider: provider.id.clone(), + }); + } + Ok(provider.id.clone()) + } + + /// Resolve an optional selector to one provider/model pair, applying the + /// passthrough policy shared by every dispatch boundary: + /// + /// - A selector known to the catalog resolves to its canonical offering. + /// - An unknown selector pinned to a provider passes through verbatim on + /// that provider. + /// - An unqualified unknown selector passes through on the default + /// provider. + /// - No selector picks the default offering (of the pinned provider, when + /// one is given). + pub fn resolve_selection( + &self, + selector: Option<&str>, + explicit_provider: Option<&ProviderId>, + eligible_providers: &HashSet, + ) -> Result { + let Some(selector) = selector else { + let eligible = match explicit_provider { + Some(provider) => { + HashSet::from([self.ready_provider(provider, eligible_providers)?]) + } + None => eligible_providers.clone(), + }; + let offering = self.select_default(&eligible)?; + return Ok(SelectedModel { + provider: offering.provider.clone(), + model: offering.id.to_string(), + }); + }; + match self.select(selector, explicit_provider, eligible_providers) { + Ok(offering) => Ok(SelectedModel { + provider: offering.provider.clone(), + model: offering.id.to_string(), + }), + Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { + Ok(SelectedModel { + provider, + model: selector.to_string(), + }) + } + Err(ModelSelectionError::UnknownSelector { .. }) => { + let default = self.select_default(eligible_providers)?; + Ok(SelectedModel { + provider: default.provider.clone(), + model: selector.to_string(), + }) + } + Err(error) => Err(error), + } + } + + #[must_use] + pub fn is_model_selector(&self, selector: &str) -> bool { + self.candidate_indices(selector).is_some() + } + + fn candidate_indices(&self, selector: &str) -> Option<&Vec> { + let selector = normalize_legacy_builtin_selector(selector); + self.canonical_candidates + .get(&ModelId::new(selector.as_ref())) + .or_else(|| self.alias_candidates.get(selector.as_ref())) + } + #[must_use] pub fn providers(&self) -> &[CatalogProvider] { &self.providers @@ -786,7 +1131,7 @@ impl Catalog { let stats = stats_by_provider.entry(model.provider.clone()).or_default(); stats.model_count = stats.model_count.saturating_add(1); if model.default { - stats.default_model = Some(model.id.clone()); + stats.default_model = Some(model.id.to_string()); } } @@ -818,9 +1163,32 @@ impl Catalog { } #[must_use] - pub fn model_settings(&self, id: &str) -> Option<&CatalogModelSettings> { - let model = self.get(id)?; - self.model_settings.get(&model.id) + pub fn settings_for(&self, model: &Model) -> Option<&CatalogModelSettings> { + self.model_settings + .get(&(model.provider.clone(), model.id.clone())) + } + + /// Test-only shorthand for settings on the highest-priority enabled + /// offering. Production callers must retain the resolved offering and use + /// [`Catalog::settings_for`]. + #[cfg(test)] + #[must_use] + pub(crate) fn model_settings( + &self, + selector: impl AsRef, + ) -> Option<&CatalogModelSettings> { + self.get(selector.as_ref()) + .and_then(|model| self.settings_for(model)) + } + + #[must_use] + pub fn model_settings_on_provider( + &self, + provider: &ProviderId, + selector: &str, + ) -> Option<&CatalogModelSettings> { + let model = self.get_on_provider(provider, selector)?; + self.settings_for(model) } #[must_use] @@ -831,9 +1199,8 @@ impl Catalog { ) -> Option { let provider = self.provider(provider_id)?; let model_profile = model_id_or_alias - .and_then(|model_id| self.get(model_id)) - .filter(|model| model.provider == provider.id) - .and_then(|model| self.model_settings.get(&model.id)) + .and_then(|model_id| self.get_on_provider(&provider.id, model_id)) + .and_then(|model| self.settings_for(model)) .map(|settings| settings.agent_profile); Some(model_profile.unwrap_or(provider.agent_profile)) } @@ -849,9 +1216,8 @@ impl Catalog { ) -> Option { let provider = self.provider(provider_id)?; let model_codec = model_id_or_alias - .and_then(|model_id| self.get(model_id)) - .filter(|model| model.provider == provider.id) - .and_then(|model| self.model_settings.get(&model.id)) + .and_then(|model_id| self.get_on_provider(&provider.id, model_id)) + .and_then(|model| self.settings_for(model)) .map(|settings| settings.codec); Some(model_codec.unwrap_or(provider.codec)) } @@ -867,9 +1233,8 @@ impl Catalog { ) -> Option { let provider = self.provider(provider_id)?; let model_policy = model_id_or_alias - .and_then(|model_id| self.get(model_id)) - .filter(|model| model.provider == provider.id) - .and_then(|model| self.model_settings.get(&model.id)) + .and_then(|model_id| self.get_on_provider(&provider.id, model_id)) + .and_then(|model| self.settings_for(model)) .map(|settings| settings.billing_policy); Some(model_policy.unwrap_or(provider.billing_policy)) } @@ -993,8 +1358,7 @@ impl Catalog { if let Some(model) = self.models.iter().find(|model| { &model.provider == provider_id && self - .model_settings - .get(&model.id) + .settings_for(model) .is_some_and(|settings| settings.probe) }) { return Some(model); @@ -1043,7 +1407,7 @@ impl Catalog { model: &str, fallbacks: &HashMap>, ) -> Vec { - let Some(reference) = self.get(model) else { + let Some(reference) = self.get_on_provider(primary, model) else { return Vec::new(); }; @@ -1057,22 +1421,429 @@ impl Catalog { let provider = ProviderId::from(provider_str.clone()); self.closest(&provider, reference).map(|m| FallbackTarget { provider: provider_str.clone(), - model: m.id.clone(), + model: m.id.to_string(), }) }) .collect() } } -fn build_model_index(models: &[Model]) -> HashMap { - let mut index = HashMap::new(); +type ModelIndexes = ( + HashMap<(ProviderId, ModelId), usize>, + HashMap<(ProviderId, String), usize>, + HashMap>, + HashMap>, +); + +fn build_model_indexes(models: &[Model]) -> ModelIndexes { + let mut offering_index = HashMap::new(); + let mut provider_selector_index = HashMap::new(); + let mut canonical_candidates = HashMap::>::new(); + let mut alias_candidates = HashMap::>::new(); for (idx, model) in models.iter().enumerate() { - index.insert(model.id.clone(), idx); + offering_index.insert((model.provider.clone(), model.id.clone()), idx); + provider_selector_index + .insert((model.provider.clone(), model.id.as_str().to_string()), idx); + canonical_candidates + .entry(model.id.clone()) + .or_default() + .push(idx); for alias in &model.aliases { - index.insert(alias.clone(), idx); + provider_selector_index.insert((model.provider.clone(), alias.clone()), idx); + alias_candidates.entry(alias.clone()).or_default().push(idx); } } - index + ( + offering_index, + provider_selector_index, + canonical_candidates, + alias_candidates, + ) +} + +fn normalize_catalog_settings( + mut settings: LlmCatalogSettings, + known: Option<&LlmCatalogSettings>, +) -> Result { + reject_scoped_provider_fields(&settings)?; + + let legacy_models = std::mem::take(&mut settings.models); + if legacy_models.is_empty() { + return Ok(settings); + } + let mut legacy_models = legacy_models.into_iter().collect::>(); + legacy_models.sort_by(|(left, _), (right, _)| left.cmp(right)); + + let mut index = LegacyModelIndex::default(); + index.add_settings(&settings); + if let Some(known) = known { + index.add_settings(known); + } + + for (legacy_id, mut model_settings) in legacy_models { + let explicit_provider = model_settings.provider.take(); + let (provider, model_id) = index.resolve(&legacy_id, explicit_provider.as_deref())?; + + if !settings.providers.contains_key(provider.as_str()) + && !known.is_some_and(|known| known.providers.contains_key(provider.as_str())) + { + return Err(CatalogBuildError::UnknownModelProvider { + model: legacy_id, + provider, + }); + } + + let provider_settings = settings.providers.entry(provider.to_string()).or_default(); + if provider_settings.models.contains_key(model_id.as_str()) { + return Err(LegacyModelError::DuplicateModel { + provider, + model: model_id, + } + .into()); + } + provider_settings + .models + .insert(model_id.into_inner(), model_settings); + } + Ok(settings) +} + +fn reject_scoped_provider_fields(settings: &LlmCatalogSettings) -> Result<(), LegacyModelError> { + for (provider, settings) in &settings.providers { + for (model, settings) in &settings.models { + if settings.provider.is_some() { + return Err(LegacyModelError::ScopedModelDeclaresProvider { + provider: ProviderId::new(provider.clone()), + model: ModelId::new(model.clone()), + }); + } + } + } + Ok(()) +} + +/// Failure to resolve a legacy top-level `[models.]` row onto its +/// provider. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum LegacyModelError { + #[error("failed to inspect the built-in model catalog: {message}")] + BuiltinCatalog { message: String }, + #[error( + "legacy built-in model identifier '{identifier}' cannot be used as a canonical model ID under provider '{provider}'; use '{model}'" + )] + LegacyIdentifierAsModelId { + identifier: String, + provider: ProviderId, + model: ModelId, + }, + #[error("legacy model row '{model}' omits provider and does not match a unique known offering")] + UnknownModel { model: String }, + #[error( + "legacy model row '{model}' omits provider and matches multiple offerings: {candidates:?}" + )] + AmbiguousModel { + model: String, + candidates: Vec<(ProviderId, ModelId)>, + }, + #[error("legacy model selector '{selector}' is ambiguous on provider '{provider}': {models:?}")] + AmbiguousAlias { + provider: ProviderId, + selector: String, + models: Vec, + }, + #[error("provider-scoped model '{provider}/{model}' must not declare a provider field")] + ScopedModelDeclaresProvider { + provider: ProviderId, + model: ModelId, + }, + #[error( + "provider '{provider}' model '{model}' is defined through both provider-scoped and legacy top-level syntax" + )] + DuplicateModel { + provider: ProviderId, + model: ModelId, + }, +} + +/// Identifier/alias view used to resolve legacy top-level `[models.]` +/// rows onto their provider before provider-scoped settings merge. +/// +/// Both the settings-layer normalization in `fabro-config` and catalog-build +/// normalization here feed this index: local entries first, lower-precedence +/// known entries (e.g. the built-in catalog) after. Canonical IDs always win +/// over aliases; alias ties resolve to the first entry added. +#[derive(Debug, Default)] +pub struct LegacyModelIndex { + providers: Vec, +} + +#[derive(Debug)] +struct LegacyProviderEntry { + id: ProviderId, + aliases: Vec, + models: Vec, +} + +#[derive(Debug)] +struct LegacyModelEntry { + id: ModelId, + aliases: Vec, +} + +impl LegacyModelIndex { + pub fn add_provider( + &mut self, + id: ProviderId, + aliases: Vec, + models: impl IntoIterator)>, + ) { + self.providers.push(LegacyProviderEntry { + id, + aliases, + models: models + .into_iter() + .map(|(id, aliases)| LegacyModelEntry { id, aliases }) + .collect(), + }); + } + + fn add_settings(&mut self, settings: &LlmCatalogSettings) { + let mut provider_ids = settings.providers.keys().collect::>(); + provider_ids.sort_unstable(); + for provider_id in provider_ids { + let provider = &settings.providers[provider_id]; + let mut model_ids = provider.models.keys().collect::>(); + model_ids.sort_unstable(); + self.add_provider( + ProviderId::new(provider_id.clone()), + provider.aliases.clone().unwrap_or_default(), + model_ids.into_iter().map(|model_id| { + let model = &provider.models[model_id]; + ( + ModelId::new(model_id.clone()), + model.aliases.clone().unwrap_or_default(), + ) + }), + ); + } + } + + /// Append the built-in catalog as the lowest-precedence tier. Includes + /// disabled providers because config compatibility normalization happens + /// before runtime availability is known. + pub fn with_builtin(mut self) -> Result { + let builtin = + Catalog::builtin_settings().map_err(|error| LegacyModelError::BuiltinCatalog { + message: error.to_string(), + })?; + self.add_settings(&builtin); + Ok(self) + } + + /// Resolve one legacy row to its provider-scoped address. Historical + /// built-in identifiers normalize to their canonical slug and use their + /// historical provider when no explicit provider is present. Other + /// unknown explicit providers or model selectors pass through verbatim; + /// rows without an explicit provider must match exactly one known + /// offering. + pub fn resolve( + &self, + legacy_id: &str, + explicit_provider: Option<&str>, + ) -> Result<(ProviderId, ModelId), LegacyModelError> { + if let Some((historical_provider, model)) = legacy_builtin_model(legacy_id) { + let provider = explicit_provider.map_or(historical_provider, |explicit| { + self.canonical_provider(explicit) + .unwrap_or_else(|| ProviderId::new(explicit)) + }); + return Ok((provider, model)); + } + if let Some(explicit) = explicit_provider { + let provider = self + .canonical_provider(explicit) + .unwrap_or_else(|| ProviderId::new(explicit)); + let model = self + .canonical_model_on(&provider, legacy_id)? + .unwrap_or_else(|| ModelId::new(legacy_id)); + return Ok((provider, model)); + } + let candidates = self.candidates(legacy_id); + match candidates.as_slice() { + [(provider, model)] => Ok((provider.clone(), model.clone())), + [] => Err(LegacyModelError::UnknownModel { + model: legacy_id.to_string(), + }), + _ => Err(LegacyModelError::AmbiguousModel { + model: legacy_id.to_string(), + candidates, + }), + } + } + + fn canonical_provider(&self, selector: &str) -> Option { + self.providers + .iter() + .find(|provider| provider.id.as_str() == selector) + .or_else(|| { + self.providers + .iter() + .find(|provider| provider.aliases.iter().any(|alias| alias == selector)) + }) + .map(|provider| provider.id.clone()) + } + + fn canonical_model_on( + &self, + provider: &ProviderId, + selector: &str, + ) -> Result, LegacyModelError> { + let models = || { + self.providers + .iter() + .filter(|entry| entry.id == *provider) + .flat_map(|entry| entry.models.iter()) + }; + if models().any(|model| model.id.as_str() == selector) { + return Ok(Some(ModelId::new(selector))); + } + let matches = models() + .filter(|model| model.aliases.iter().any(|alias| alias == selector)) + .map(|model| model.id.clone()) + .collect::>(); + match matches.len() { + 0 => Ok(None), + 1 => Ok(matches.into_iter().next()), + _ => Err(LegacyModelError::AmbiguousAlias { + provider: provider.clone(), + selector: selector.to_string(), + models: matches.into_iter().collect(), + }), + } + } + + fn candidates(&self, selector: &str) -> Vec<(ProviderId, ModelId)> { + let canonical = self + .providers + .iter() + .filter(|entry| { + entry + .models + .iter() + .any(|model| model.id.as_str() == selector) + }) + .map(|entry| (entry.id.clone(), ModelId::new(selector))) + .collect::>(); + if !canonical.is_empty() { + return canonical.into_iter().collect(); + } + self.providers + .iter() + .flat_map(|entry| { + entry + .models + .iter() + .filter(|model| model.aliases.iter().any(|alias| alias == selector)) + .map(|model| (entry.id.clone(), model.id.clone())) + }) + .collect::>() + .into_iter() + .collect() + } +} + +/// Historical built-in catalog keys from before Fabro separated canonical +/// model slugs from provider API identifiers. The provider records the key's +/// original offering for legacy catalog-row normalization; runtime selectors +/// normalize to the model slug and use normal provider-aware selection. +const LEGACY_BUILTIN_MODEL_IDENTIFIERS: &[(&str, &str, &str)] = &[ + ("openai.gpt-5.5", "bedrock-openai", "gpt-5.5"), + ("openai.gpt-5.4", "bedrock-openai", "gpt-5.4"), + ( + "us.anthropic.claude-sonnet-4-6", + "bedrock", + "claude-sonnet-4-6", + ), + ("us.anthropic.claude-opus-4-8", "bedrock", "claude-opus-4-8"), + ( + "us.anthropic.claude-haiku-4-5", + "bedrock", + "claude-haiku-4-5", + ), + ("openai.gpt-oss-120b", "bedrock", "gpt-oss-120b"), + ("openai.gpt-oss-20b", "bedrock", "gpt-oss-20b"), + ("amazon.nova-2-lite", "bedrock", "nova-2-lite"), + ("meta.llama4-maverick", "bedrock", "llama-4-maverick"), + ("mistral.mistral-large-3", "bedrock", "mistral-large-3"), + ("mistral.devstral-2", "bedrock", "devstral-2"), + ("deepseek.v3-2", "bedrock", "deepseek-v3.2"), + ("moonshotai.kimi-k2.5", "bedrock", "kimi-k2.5"), + ("zai.glm-5", "bedrock", "glm-5"), + ("minimax.minimax-m2.5", "bedrock", "minimax-m2.5"), + ("nvidia.nemotron-3-super", "bedrock", "nemotron-3-super"), + ("us.anthropic.claude-fable-5", "bedrock", "claude-fable-5"), + ("anthropic/claude-fable-5", "openrouter", "claude-fable-5"), + ("anthropic/claude-opus-4-8", "openrouter", "claude-opus-4-8"), + ("anthropic/claude-opus-4-7", "openrouter", "claude-opus-4-7"), + ( + "anthropic/claude-sonnet-4-6", + "openrouter", + "claude-sonnet-4-6", + ), + ( + "anthropic/claude-haiku-4-5", + "openrouter", + "claude-haiku-4-5", + ), + ("openai/gpt-5.6-sol", "openrouter", "gpt-5.6-sol"), + ("openai/gpt-5.6-terra", "openrouter", "gpt-5.6-terra"), + ("openai/gpt-5.6-luna", "openrouter", "gpt-5.6-luna"), + ("openai/gpt-5.4", "openrouter", "gpt-5.4"), + ("openai/gpt-5.5", "openrouter", "gpt-5.5"), + ( + "google/gemini-3.1-pro-preview", + "openrouter", + "gemini-3.1-pro-preview", + ), + ("google/gemini-3.5-flash", "openrouter", "gemini-3.5-flash"), + ("xiaomi/mimo-v2.5-pro", "openrouter", "mimo-v2.5-pro"), + ("minimax/minimax-m2.7", "openrouter", "minimax-m2.7"), + ("deepseek/deepseek-v4-pro", "openrouter", "deepseek-v4-pro"), + ( + "deepseek/deepseek-v4-flash", + "openrouter", + "deepseek-v4-flash", + ), + ("moonshotai/kimi-k2.6", "openrouter", "kimi-k2.6"), + ("moonshotai/kimi-k3", "openrouter", "kimi-k3"), + ("poolside/laguna-s-2.1", "openrouter", "laguna-s-2.1"), + ("poolside/laguna-xs-2.1", "openrouter", "laguna-xs-2.1"), + ("qwen/qwen3-coder", "openrouter", "qwen3-coder"), + ("qwen/qwen3.6-flash", "openrouter", "qwen3.6-flash"), + ("z-ai/glm-5.2", "openrouter", "glm-5.2"), + ("z-ai/glm-4.6", "openrouter", "glm-4.6"), + ( + "nvidia/nemotron-3-super-120b-a12b", + "openrouter", + "nemotron-3-super-120b-a12b", + ), + ("mistralai/devstral-2512", "openrouter", "devstral-2512"), +]; + +/// Return the historical provider and canonical model slug for a legacy +/// built-in catalog key. +#[must_use] +pub fn legacy_builtin_model(identifier: &str) -> Option<(ProviderId, ModelId)> { + LEGACY_BUILTIN_MODEL_IDENTIFIERS + .iter() + .find(|(legacy, _, _)| *legacy == identifier) + .map(|(_, provider, model)| (ProviderId::new(*provider), ModelId::new(*model))) +} + +fn normalize_legacy_builtin_selector(selector: &str) -> Cow<'_, str> { + legacy_builtin_model(selector).map_or_else( + || Cow::Borrowed(selector), + |(_, model)| Cow::Owned(model.into_inner()), + ) } fn merge_catalog_settings( @@ -1087,21 +1858,20 @@ fn merge_catalog_settings( fallback.providers.insert(id, provider); } - for (id, model) in higher.models { + fallback +} + +fn merge_provider_settings( + mut higher: ProviderCatalogSettings, + mut fallback: ProviderCatalogSettings, +) -> ProviderCatalogSettings { + for (id, model) in higher.models.drain() { let model = match fallback.models.remove(&id) { Some(fallback_model) => merge_model_settings(model, fallback_model), None => model, }; fallback.models.insert(id, model); } - - fallback -} - -fn merge_provider_settings( - higher: ProviderCatalogSettings, - fallback: ProviderCatalogSettings, -) -> ProviderCatalogSettings { ProviderCatalogSettings { display_name: higher.display_name.or(fallback.display_name), adapter: higher.adapter.or(fallback.adapter), @@ -1115,6 +1885,7 @@ fn merge_provider_settings( priority: higher.priority.or(fallback.priority), enabled: higher.enabled.or(fallback.enabled), aliases: higher.aliases.or(fallback.aliases), + models: fallback.models, } } @@ -1182,12 +1953,15 @@ fn merge_model_features_settings( fallback: &SettingsModelFeatures, ) -> SettingsModelFeatures { SettingsModelFeatures { - tools: higher.tools.or(fallback.tools), - vision: higher.vision.or(fallback.vision), - reasoning: higher.reasoning.or(fallback.reasoning), - reasoning_effort: higher.reasoning_effort.or(fallback.reasoning_effort), - prompt_cache: higher.prompt_cache.or(fallback.prompt_cache), - sampling_params: higher.sampling_params.or(fallback.sampling_params), + tools: higher.tools.or(fallback.tools), + vision: higher.vision.or(fallback.vision), + reasoning: higher.reasoning.or(fallback.reasoning), + reasoning_effort: higher.reasoning_effort.or(fallback.reasoning_effort), + prompt_cache: higher.prompt_cache.or(fallback.prompt_cache), + cache_control_breakpoints: higher + .cache_control_breakpoints + .or(fallback.cache_control_breakpoints), + sampling_params: higher.sampling_params.or(fallback.sampling_params), } } @@ -1418,7 +2192,7 @@ fn build_model( let speed_costs = build_speed_costs(model_id, settings.costs.as_ref(), &controls)?; let model = Model { - id: model_id.to_string(), + id: ModelId::new(model_id), provider: provider.id.clone(), family, display_name, @@ -1436,11 +2210,18 @@ fn build_model( small_default: settings.small_default.unwrap_or_default(), configured: false, }; + let api_id = match settings.api_id.as_ref() { + Some(api_id) if api_id.is_empty() => { + return Err(CatalogBuildError::EmptyModelApiId { + provider: provider.id.clone(), + model: ModelId::new(model_id), + }); + } + Some(api_id) => api_id.clone(), + None => model_id.to_string(), + }; let catalog_settings = CatalogModelSettings { - api_id: settings - .api_id - .clone() - .unwrap_or_else(|| model_id.to_string()), + api_id, codec: resolve_model_codec(model_id, provider, settings.codec)?, billing_policy: settings.billing_policy.unwrap_or(provider.billing_policy), agent_profile: settings.agent_profile.unwrap_or(provider.agent_profile), @@ -1458,7 +2239,7 @@ fn warn_multiple_probe_models(models_with_settings: &[(Model, CatalogModelSettin probes_by_provider .entry(model.provider.clone()) .or_default() - .push(model.id.clone()); + .push(model.id.to_string()); } } @@ -1489,6 +2270,15 @@ fn build_model_features( model: model_id.to_string(), }); } + let prompt_cache = features.prompt_cache.unwrap_or_default(); + let cache_control_breakpoints = features.cache_control_breakpoints.unwrap_or_default(); + if cache_control_breakpoints && !prompt_cache { + return Err( + CatalogBuildError::CacheControlBreakpointsWithoutPromptCache { + model: model_id.to_string(), + }, + ); + } Ok(ModelFeatures { tools: features @@ -1505,7 +2295,8 @@ fn build_model_features( })?, reasoning, reasoning_effort, - prompt_cache: features.prompt_cache.unwrap_or_default(), + prompt_cache, + cache_control_breakpoints, sampling_params: features.sampling_params.unwrap_or(true), }) } @@ -1675,16 +2466,20 @@ fn register_provider_identifier( } fn register_model_identifier( - identifiers: &mut BTreeMap, + identifiers: &mut BTreeMap, identifier: String, - owner: String, + owner: ModelId, + provider: &ProviderId, ) -> Result<(), CatalogBuildError> { match identifiers.get(&identifier) { - Some(existing) if existing != &owner => Err(CatalogBuildError::DuplicateModelIdentifier { - identifier, - first: existing.clone(), - second: owner, - }), + Some(existing) if existing != &owner => { + Err(CatalogBuildError::DuplicateProviderModelSelector { + provider: provider.clone(), + selector: identifier, + first: existing.clone(), + second: owner, + }) + } _ => { identifiers.insert(identifier, owner); Ok(()) @@ -1743,12 +2538,6 @@ fn provider_order(left: &CatalogProvider, right: &CatalogProvider) -> std::cmp:: .then_with(|| left.id.cmp(&right.id)) } -fn model_order(left: &Model, right: &Model) -> std::cmp::Ordering { - left.provider - .cmp(&right.provider) - .then_with(|| left.id.cmp(&right.id)) -} - #[cfg(test)] mod tests { use strum::VariantArray; @@ -1762,6 +2551,54 @@ mod tests { toml::from_str(source).expect("fixture should parse as an LLM settings layer") } + fn portable_model_catalog() -> Catalog { + Catalog::from_settings(&minimal_settings( + r#" +[providers.openai] +display_name = "OpenAI" +adapter = "openai" +agent_profile = "openai" +priority = 90 + +[providers.openai.models."gpt-5.6-sol"] +display_name = "GPT-5.6 Sol" +family = "gpt-5" +aliases = ["gpt-56-sol", "portable"] +default = true + +[providers.openai.models."gpt-5.6-sol".limits] +context_window = 1000 + +[providers.openai.models."gpt-5.6-sol".features] +tools = true +vision = false +reasoning = true + +[providers.openrouter] +display_name = "OpenRouter" +adapter = "openai_compatible" +agent_profile = "openai" +priority = 25 + +[providers.openrouter.models."gpt-5.6-sol"] +api_id = "openai/gpt-5.6-sol" +display_name = "GPT-5.6 Sol (via OpenRouter)" +family = "gpt-5" +aliases = ["gpt-56-sol", "portable"] +default = true + +[providers.openrouter.models."gpt-5.6-sol".limits] +context_window = 1000 + +[providers.openrouter.models."gpt-5.6-sol".features] +tools = true +vision = false +reasoning = true +"#, + )) + .expect("portable model fixture should build") + } + const BEDROCK_SIGV4_LAYER: &str = r#" [providers.bedrock] adapter = "bedrock" @@ -1934,18 +2771,21 @@ enabled = true // provider's Anthropic defaults the other way. assert_eq!( catalog - .model_settings("us.anthropic.claude-sonnet-4-6") + .model_settings_on_provider(&bedrock, "claude-sonnet-4-6") .unwrap() .billing_policy, BillingPolicy::Anthropic ); assert_eq!( - catalog.model_settings("zai.glm-5").unwrap().billing_policy, + catalog + .model_settings_on_provider(&bedrock, "glm-5") + .unwrap() + .billing_policy, BillingPolicy::OpenAi ); assert_eq!( catalog - .model_settings("us.anthropic.claude-haiku-4-5") + .model_settings_on_provider(&bedrock, "claude-haiku-4-5") .unwrap() .api_id, "us.anthropic.claude-haiku-4-5-20251001-v1:0" @@ -1954,17 +2794,17 @@ enabled = true catalog .default_for_provider(&bedrock) .map(|model| model.id.as_str()), - Some("us.anthropic.claude-sonnet-4-6") + Some("claude-sonnet-4-6") ); // Fable 5 ships with sampling params pinned off (the Converse // encoder drops temperature/top_p for it). let fable = catalog - .get("us.anthropic.claude-fable-5") + .get_on_provider(&bedrock, "claude-fable-5") .expect("fable row should be present"); assert!(!fable.features.sampling_params); assert_eq!( catalog - .model_settings("us.anthropic.claude-fable-5") + .model_settings_on_provider(&bedrock, "claude-fable-5") .unwrap() .billing_policy, BillingPolicy::Anthropic @@ -2001,7 +2841,7 @@ enabled = true catalog .default_for_provider(&provider_id) .map(|model| model.id.as_str()), - Some("openai.gpt-5.5") + Some("gpt-5.5") ); } @@ -2115,14 +2955,14 @@ enabled = true // open-weights rows inherit it. assert_eq!( catalog - .model_settings("anthropic/claude-sonnet-4-6") + .model_settings_on_provider(&openrouter, "claude-sonnet-4-6") .unwrap() .billing_policy, BillingPolicy::Anthropic ); assert_eq!( catalog - .model_settings("deepseek/deepseek-v4-flash") + .model_settings_on_provider(&openrouter, "deepseek-v4-flash") .unwrap() .billing_policy, BillingPolicy::OpenAi @@ -2131,7 +2971,7 @@ enabled = true catalog .default_for_provider(&openrouter) .map(|model| model.id.as_str()), - Some("anthropic/claude-sonnet-4-6") + Some("claude-sonnet-4-6") ); } @@ -2147,7 +2987,7 @@ enabled = true let expected = [ ( - "openai/gpt-5.6-sol", + "gpt-5.6-sol", "openai/gpt-5.6-sol", "gpt-5", 1_050_000, @@ -2156,10 +2996,11 @@ enabled = true 0.5, ReasoningEffortFeature::Levels, false, + false, BillingPolicy::OpenAi, ), ( - "openai/gpt-5.6-terra", + "gpt-5.6-terra", "openai/gpt-5.6-terra", "gpt-5", 1_050_000, @@ -2168,10 +3009,11 @@ enabled = true 0.25, ReasoningEffortFeature::Levels, false, + false, BillingPolicy::OpenAi, ), ( - "openai/gpt-5.6-luna", + "gpt-5.6-luna", "openai/gpt-5.6-luna", "gpt-5", 1_050_000, @@ -2180,10 +3022,11 @@ enabled = true 0.1, ReasoningEffortFeature::Levels, false, + false, BillingPolicy::OpenAi, ), ( - "anthropic/claude-opus-4-8", + "claude-opus-4-8", "anthropic/claude-opus-4.8", "claude-4", 1_000_000, @@ -2192,10 +3035,11 @@ enabled = true 0.5, ReasoningEffortFeature::Levels, false, + true, BillingPolicy::Anthropic, ), ( - "anthropic/claude-fable-5", + "claude-fable-5", "anthropic/claude-fable-5", "claude-5", 1_000_000, @@ -2204,6 +3048,7 @@ enabled = true 1.0, ReasoningEffortFeature::AlwaysAdaptive, false, + true, BillingPolicy::Anthropic, ), ]; @@ -2218,11 +3063,12 @@ enabled = true cache_input_cost, reasoning_effort, sampling_params, + cache_control_breakpoints, billing_policy, ) in expected { let model = catalog - .get(id) + .get_on_provider(&ProviderId::new("openrouter"), id) .unwrap_or_else(|| panic!("OpenRouter model '{id}' should be present")); assert_eq!(model.provider, ProviderId::new("openrouter"), "{id}"); assert_eq!(model.family, family, "{id}"); @@ -2234,6 +3080,10 @@ enabled = true assert!(model.features.prompt_cache, "{id}"); assert_eq!(model.features.reasoning_effort, reasoning_effort, "{id}"); assert_eq!(model.features.sampling_params, sampling_params, "{id}"); + assert_eq!( + model.features.cache_control_breakpoints, cache_control_breakpoints, + "{id}" + ); assert_eq!(model.costs.input_cost_per_mtok, Some(input_cost), "{id}"); assert_eq!(model.costs.output_cost_per_mtok, Some(output_cost), "{id}"); assert_eq!( @@ -2243,15 +3093,10 @@ enabled = true ); let settings = catalog - .model_settings(id) + .model_settings_on_provider(&ProviderId::new("openrouter"), id) .unwrap_or_else(|| panic!("OpenRouter settings for '{id}' should be present")); assert_eq!(settings.api_id, api_id, "{id}"); assert_eq!(settings.billing_policy, billing_policy, "{id}"); - assert_eq!( - catalog.get(api_id).map(|model| model.id.as_str()), - Some(id), - "{id}" - ); assert_eq!( settings.controls.reasoning_effort, ReasoningEffort::VARIANTS, @@ -2260,6 +3105,110 @@ enabled = true } } + #[test] + fn builtin_gpt_5_6_short_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::openai(), ProviderId::new("openrouter")] { + for (alias, canonical_id) in [ + ("sol", "gpt-5.6-sol"), + ("terra", "gpt-5.6-terra"), + ("luna", "gpt-5.6-luna"), + ] { + 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, canonical_id, "{alias}"); + } + } + } + + #[test] + fn builtin_legacy_vendor_ids_normalize_for_pinned_and_unpinned_selection() { + 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 [ + ("anthropic/claude-fable-5", "claude-fable-5"), + ("openai/gpt-5.6-sol", "gpt-5.6-sol"), + ] { + 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}"); + } + + let anthropic = ProviderId::anthropic(); + let selector = "anthropic/claude-fable-5"; + let selected = catalog + .resolve_selection( + Some(selector), + None, + &HashSet::from([anthropic.clone(), openrouter.clone()]), + ) + .unwrap(); + assert_eq!(selected.provider, anthropic); + assert_eq!(selected.model, "claude-fable-5"); + + let selected = catalog + .resolve_selection(Some(selector), None, &HashSet::from([openrouter.clone()])) + .unwrap(); + assert_eq!(selected.provider, openrouter); + assert_eq!(selected.model, "claude-fable-5"); + } + + #[test] + fn every_legacy_builtin_identifier_targets_an_existing_offering() { + let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( + r" +[providers.bedrock] +enabled = true + +[providers.bedrock-openai] +enabled = true + +[providers.openrouter] +enabled = true +", + )) + .expect("all providers referenced by the legacy table should build"); + + for (legacy_id, provider_id, canonical_id) in LEGACY_BUILTIN_MODEL_IDENTIFIERS { + let provider = ProviderId::new(*provider_id); + let model = catalog + .resolve_on_provider(&provider, legacy_id) + .unwrap_or_else(|error| { + panic!( + "legacy identifier '{legacy_id}' should resolve on '{provider}': {error}" + ) + }); + + assert_eq!(model.provider, provider, "{legacy_id}"); + assert_eq!(model.id, *canonical_id, "{legacy_id}"); + assert_eq!( + legacy_builtin_model(legacy_id), + Some((provider, ModelId::new(*canonical_id))), + "{legacy_id}" + ); + } + } + #[test] fn builtin_openrouter_includes_glm_5_2_when_enabled() { let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( @@ -2271,11 +3220,11 @@ enabled = true .expect("enabled OpenRouter override should build from the built-in provider settings"); let model = catalog - .get("z-ai/glm-5.2") + .get_on_provider(&ProviderId::new("openrouter"), "glm-5.2") .expect("OpenRouter GLM 5.2 should be present"); insta::assert_debug_snapshot!(model, @r#" Model { - id: "z-ai/glm-5.2", + id: "glm-5.2", provider: openrouter, family: "glm-5", display_name: "GLM 5.2 (via OpenRouter)", @@ -2293,6 +3242,7 @@ enabled = true reasoning: true, reasoning_effort: Levels, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: true, }, costs: ModelCosts { @@ -2315,7 +3265,7 @@ enabled = true "#); let settings = catalog - .model_settings("z-ai/glm-5.2") + .model_settings_on_provider(&ProviderId::new("openrouter"), "glm-5.2") .expect("OpenRouter GLM 5.2 settings should be present"); assert_eq!(settings.api_id, "z-ai/glm-5.2"); assert_eq!(settings.controls.reasoning_effort, vec![ @@ -2335,11 +3285,11 @@ enabled = true .expect("enabled OpenRouter override should build from the built-in provider settings"); let model = catalog - .get("moonshotai/kimi-k3") + .get_on_provider(&ProviderId::new("openrouter"), "kimi-k3") .expect("OpenRouter Kimi K3 should be present"); insta::assert_debug_snapshot!(model, @r#" Model { - id: "moonshotai/kimi-k3", + id: "kimi-k3", provider: openrouter, family: "kimi-k3", display_name: "Kimi K3 (via OpenRouter)", @@ -2357,6 +3307,7 @@ enabled = true reasoning: true, reasoning_effort: AlwaysAdaptive, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: false, }, costs: ModelCosts { @@ -2379,7 +3330,7 @@ enabled = true "#); let settings = catalog - .model_settings("moonshotai/kimi-k3") + .model_settings_on_provider(&ProviderId::new("openrouter"), "kimi-k3") .expect("OpenRouter Kimi K3 settings should be present"); assert_eq!(settings.api_id, "moonshotai/kimi-k3"); assert_eq!(settings.controls.reasoning_effort, vec![ @@ -2400,20 +3351,13 @@ enabled = true .expect("enabled OpenRouter override should build from the built-in provider settings"); let expected = [ - ( - "poolside/laguna-s-2.1", - 1_048_576, - 131_072, - 0.10, - 0.20, - 0.01, - ), - ("poolside/laguna-xs-2.1", 262_144, 32_768, 0.06, 0.12, 0.03), + ("laguna-s-2.1", 1_048_576, 131_072, 0.10, 0.20, 0.01), + ("laguna-xs-2.1", 262_144, 32_768, 0.06, 0.12, 0.03), ]; for (id, context, max_output, input, output, cache_read) in expected { let model = catalog - .get(id) + .get_on_provider(&ProviderId::new("openrouter"), id) .unwrap_or_else(|| panic!("OpenRouter model '{id}' should be present")); assert_eq!(model.provider, ProviderId::new("openrouter"), "{id}"); assert_eq!(model.family, "laguna-2", "{id}"); @@ -2434,9 +3378,9 @@ enabled = true ); let settings = catalog - .model_settings(id) + .model_settings_on_provider(&ProviderId::new("openrouter"), id) .unwrap_or_else(|| panic!("OpenRouter settings for '{id}' should be present")); - assert_eq!(settings.api_id, id, "{id}"); + assert_eq!(settings.api_id, format!("poolside/{id}"), "{id}"); assert!(settings.controls.reasoning_effort.is_empty(), "{id}"); } } @@ -2898,30 +3842,28 @@ adapter = "openai" agent_profile = "openai" enabled = true -[models.one] -provider = "test" +[providers.test.models.one] display_name = "One" family = "test" aliases = ["shared"] -[models.one.limits] +[providers.test.models.one.limits] context_window = 1000 -[models.one.features] +[providers.test.models.one.features] tools = false vision = false reasoning = false -[models.two] -provider = "test" +[providers.test.models.two] display_name = "Two" family = "test" aliases = ["shared"] -[models.two.limits] +[providers.test.models.two.limits] context_window = 1000 -[models.two.features] +[providers.test.models.two.features] tools = false vision = false reasoning = false @@ -2932,8 +3874,297 @@ reasoning = false assert!(matches!( err, - CatalogBuildError::DuplicateModelIdentifier { identifier, first, second } - if identifier == "shared" && first == "one" && second == "two" + CatalogBuildError::DuplicateProviderModelSelector { + provider, + selector, + first, + second, + } if provider == ProviderId::new("test") + && selector == "shared" + && first == "one" + && second == "two" + )); + } + + #[test] + fn provider_scoped_model_rejects_redundant_provider_field() { + let error = Catalog::from_settings(&minimal_settings( + r#" +[providers.test] +display_name = "Test" +adapter = "openai" + +[providers.test.models.one] +provider = "test" +"#, + )) + .unwrap_err(); + + assert!(matches!( + error, + CatalogBuildError::LegacyModel(LegacyModelError::ScopedModelDeclaresProvider { + provider, + model, + }) if provider == ProviderId::new("test") && model == "one" + )); + } + + #[test] + fn provider_scoped_model_rejects_legacy_builtin_id_as_canonical_id() { + let error = Catalog::from_settings(&minimal_settings( + r#" +[providers.openrouter] +display_name = "OpenRouter" +adapter = "openai_compatible" + +[providers.openrouter.models."openai/gpt-5.6-sol"] +"#, + )) + .unwrap_err(); + + assert!(matches!( + error, + CatalogBuildError::LegacyModel( + LegacyModelError::LegacyIdentifierAsModelId { + identifier, + provider, + model, + } + ) if identifier == "openai/gpt-5.6-sol" + && provider == ProviderId::new("openrouter") + && model == "gpt-5.6-sol" + )); + } + + #[test] + fn provider_aware_selection_uses_readiness_priority_and_api_ids() { + let catalog = portable_model_catalog(); + let openai = ProviderId::openai(); + let openrouter = ProviderId::new("openrouter"); + + let offerings = catalog + .list(None) + .into_iter() + .filter(|model| model.id.as_str() == "gpt-5.6-sol") + .collect::>(); + assert_eq!(offerings.len(), 2); + + let direct = catalog + .select("gpt-56-sol", None, &HashSet::from([openai.clone()])) + .unwrap(); + assert_eq!(direct.provider, openai); + assert_eq!(direct.id, "gpt-5.6-sol"); + assert_eq!(catalog.settings_for(direct).unwrap().api_id, "gpt-5.6-sol"); + + let aggregator = catalog + .select("gpt-56-sol", None, &HashSet::from([openrouter.clone()])) + .unwrap(); + assert_eq!(aggregator.provider, openrouter); + assert_eq!(aggregator.id, "gpt-5.6-sol"); + assert_eq!( + catalog.settings_for(aggregator).unwrap().api_id, + "openai/gpt-5.6-sol" + ); + + let both = HashSet::from([ProviderId::openai(), ProviderId::new("openrouter")]); + assert_eq!( + catalog.select("portable", None, &both).unwrap().provider, + ProviderId::openai() + ); + assert_eq!( + catalog + .select("portable", Some(&ProviderId::new("openrouter")), &both,) + .unwrap() + .provider, + ProviderId::new("openrouter") + ); + assert!(matches!( + catalog.select( + "portable", + Some(&ProviderId::new("openrouter")), + &HashSet::from([ProviderId::openai()]), + ), + Err(ModelSelectionError::ProviderUnavailable { provider }) + if provider == ProviderId::new("openrouter") + )); + } + + #[test] + fn legacy_builtin_selector_uses_readiness_priority_and_explicit_pins() { + let catalog = portable_model_catalog(); + let openai = ProviderId::openai(); + let openrouter = ProviderId::new("openrouter"); + let selector = "openai/gpt-5.6-sol"; + + for (eligible, expected_provider) in [ + (HashSet::from([openai.clone()]), openai.clone()), + (HashSet::from([openrouter.clone()]), openrouter.clone()), + ( + HashSet::from([openai.clone(), openrouter.clone()]), + openai.clone(), + ), + ] { + let selected = catalog + .resolve_selection(Some(selector), None, &eligible) + .unwrap(); + assert_eq!(selected.provider, expected_provider); + assert_eq!(selected.model, "gpt-5.6-sol"); + } + + let both = HashSet::from([openai, openrouter.clone()]); + let selected = catalog + .resolve_selection(Some(selector), Some(&openrouter), &both) + .unwrap(); + assert_eq!(selected.provider, openrouter); + assert_eq!(selected.model, "gpt-5.6-sol"); + } + + #[test] + fn equal_provider_priorities_use_canonical_provider_id_as_tie_breaker() { + let catalog = Catalog::from_settings(&minimal_settings( + r#" +[providers.zeta] +display_name = "Zeta" +adapter = "openai" +agent_profile = "openai" +priority = 10 + +[providers.zeta.models.zeta] +display_name = "Zeta" +family = "test" +aliases = ["shared"] +default = true + +[providers.zeta.models.zeta.limits] +context_window = 1000 + +[providers.zeta.models.zeta.features] +tools = false +vision = false +reasoning = false + +[providers.alpha] +display_name = "Alpha" +adapter = "openai" +agent_profile = "openai" +priority = 10 + +[providers.alpha.models.alpha] +display_name = "Alpha" +family = "test" +aliases = ["shared"] +default = true + +[providers.alpha.models.alpha.limits] +context_window = 1000 + +[providers.alpha.models.alpha.features] +tools = false +vision = false +reasoning = false +"#, + )) + .unwrap(); + + let eligible = HashSet::from([ProviderId::new("zeta"), ProviderId::new("alpha")]); + assert_eq!( + catalog.select("shared", None, &eligible).unwrap().provider, + ProviderId::new("alpha") + ); + } + + #[test] + fn canonical_id_wins_over_cross_provider_alias() { + let catalog = Catalog::from_settings(&minimal_settings( + r#" +[providers.direct] +display_name = "Direct" +adapter = "openai" +agent_profile = "openai" +priority = 1 + +[providers.direct.models.shared] +display_name = "Canonical Shared" +family = "test" +default = true + +[providers.direct.models.shared.limits] +context_window = 1000 + +[providers.direct.models.shared.features] +tools = false +vision = false +reasoning = false + +[providers.aggregator] +display_name = "Aggregator" +adapter = "openai" +agent_profile = "openai" +priority = 100 + +[providers.aggregator.models.other] +display_name = "Alias Shared" +family = "test" +aliases = ["shared"] +default = true + +[providers.aggregator.models.other.limits] +context_window = 1000 + +[providers.aggregator.models.other.features] +tools = false +vision = false +reasoning = false +"#, + )) + .unwrap(); + let eligible = HashSet::from([ProviderId::new("direct"), ProviderId::new("aggregator")]); + + let unqualified = catalog.select("shared", None, &eligible).unwrap(); + assert_eq!(unqualified.provider, ProviderId::new("direct")); + assert_eq!(unqualified.id, "shared"); + + let qualified = catalog + .resolve_on_provider(&ProviderId::new("aggregator"), "shared") + .unwrap(); + assert_eq!(qualified.id, "other"); + + let aggregator_only = HashSet::from([ProviderId::new("aggregator")]); + let portable_alias = catalog.select("shared", None, &aggregator_only).unwrap(); + assert_eq!(portable_alias.provider, ProviderId::new("aggregator")); + assert_eq!(portable_alias.id, "other"); + } + + #[test] + fn empty_api_id_is_rejected() { + let error = Catalog::from_settings(&minimal_settings( + r#" +[providers.test] +display_name = "Test" +adapter = "openai" +agent_profile = "openai" + +[providers.test.models.model] +api_id = "" +display_name = "Model" +family = "test" +default = true + +[providers.test.models.model.limits] +context_window = 1000 + +[providers.test.models.model.features] +tools = false +vision = false +reasoning = false +"#, + )) + .unwrap_err(); + + assert!(matches!( + error, + CatalogBuildError::EmptyModelApiId { provider, model } + if provider == ProviderId::new("test") && model == "model" )); } @@ -3068,7 +4299,7 @@ reasoning = false } #[test] - fn catalog_lists_models_by_provider_then_model_id() { + fn catalog_lists_models_by_provider_priority_then_model_id() { let layer = minimal_settings( r#" [providers.zeta] @@ -3133,7 +4364,7 @@ reasoning = false .map(|model| model.id.as_str()) .collect::>(); - assert_eq!(ids, ["alpha_one", "zeta_one", "zeta_two"]); + assert_eq!(ids, ["zeta_one", "zeta_two", "alpha_one"]); assert_eq!(catalog.default_model().id, "zeta_two"); } @@ -3954,6 +5185,61 @@ reasoning = false ); } + #[test] + fn effective_agent_profile_is_scoped_by_provider_for_shared_model_id() { + let layer = minimal_settings( + r#" +[providers.one] +display_name = "One" +adapter = "openai" +agent_profile = "openai" + +[providers.one.models.shared] +display_name = "Shared on One" +family = "test" +default = true + +[providers.one.models.shared.limits] +context_window = 1000 + +[providers.one.models.shared.features] +tools = false +vision = false +reasoning = false + +[providers.two] +display_name = "Two" +adapter = "openai" +agent_profile = "anthropic" + +[providers.two.models.shared] +display_name = "Shared on Two" +family = "test" +default = true +agent_profile = "gemini" + +[providers.two.models.shared.limits] +context_window = 1000 + +[providers.two.models.shared.features] +tools = false +vision = false +reasoning = false +"#, + ); + + let catalog = Catalog::from_settings(&layer).unwrap(); + + assert_eq!( + catalog.effective_agent_profile(&ProviderId::new("one"), Some("shared")), + Some(AgentProfileKind::OpenAi) + ); + assert_eq!( + catalog.effective_agent_profile(&ProviderId::new("two"), Some("shared")), + Some(AgentProfileKind::Gemini) + ); + } + #[test] fn omitted_agent_profile_uses_adapter_default() { let layer = minimal_settings( @@ -4428,6 +5714,39 @@ reasoning_effort = "levels" )); } + #[test] + fn catalog_from_settings_rejects_cache_control_breakpoints_without_prompt_cache() { + let settings = minimal_settings( + r#" +[providers.test] +display_name = "Test" +adapter = "openai_compatible" +agent_profile = "openai" +base_url = "https://example.test/v1" + +[models.model] +provider = "test" +display_name = "Model" +family = "test" + +[models.model.limits] +context_window = 1000 + +[models.model.features] +tools = true +vision = false +reasoning = false +cache_control_breakpoints = true +"#, + ); + + assert!(matches!( + Catalog::from_settings(&settings).unwrap_err(), + CatalogBuildError::CacheControlBreakpointsWithoutPromptCache { model } + if model == "model" + )); + } + #[test] fn catalog_from_settings_rejects_always_adaptive_effort_without_reasoning() { let settings = minimal_settings( @@ -4588,6 +5907,7 @@ sampling_params = false reasoning: true, reasoning_effort: Levels, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: true, }, costs: ModelCosts { @@ -4644,6 +5964,7 @@ sampling_params = false reasoning: true, reasoning_effort: None, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: false, }, costs: ModelCosts { @@ -4692,6 +6013,7 @@ sampling_params = false reasoning: true, reasoning_effort: AlwaysAdaptive, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: false, }, costs: ModelCosts { @@ -4764,6 +6086,7 @@ sampling_params = false reasoning: true, reasoning_effort: Levels, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: true, }, costs: ModelCosts { @@ -4829,6 +6152,7 @@ sampling_params = false reasoning: true, reasoning_effort: Levels, prompt_cache: false, + cache_control_breakpoints: false, sampling_params: true, }, costs: ModelCosts { @@ -4885,6 +6209,7 @@ sampling_params = false reasoning: true, reasoning_effort: Levels, prompt_cache: false, + cache_control_breakpoints: false, sampling_params: true, }, costs: ModelCosts { @@ -4964,7 +6289,7 @@ sampling_params = false fn openai_context_window_can_be_overridden_for_direct_api_usage() { let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( r#" -[models."gpt-5.5".limits] +[providers.openai.models."gpt-5.5".limits] context_window = 1050000 "#, )) diff --git a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml b/lib/foundation/fabro-model/src/catalog/providers/anthropic.toml similarity index 63% rename from lib/crates/fabro-model/src/catalog/providers/anthropic.toml rename to lib/foundation/fabro-model/src/catalog/providers/anthropic.toml index 0a1587197..95649e214 100644 --- a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/anthropic.toml @@ -9,18 +9,16 @@ priority = 100 credentials = ["env:ANTHROPIC_API_KEY", "vault:ANTHROPIC_API_KEY"] header = { custom = "x-api-key" } -[models."claude-fable-5"] -provider = "anthropic" -api_id = "claude-fable-5" +[providers.anthropic.models."claude-fable-5"] display_name = "Claude Fable 5" family = "claude-5" aliases = ["fable", "claude-fable"] -[models."claude-fable-5".limits] +[providers.anthropic.models."claude-fable-5".limits] context_window = 1000000 max_output = 128000 -[models."claude-fable-5".features] +[providers.anthropic.models."claude-fable-5".features] tools = true vision = true reasoning = true @@ -28,14 +26,12 @@ reasoning_effort = "always_adaptive" prompt_cache = true sampling_params = false -[models."claude-fable-5".costs] +[providers.anthropic.models."claude-fable-5".costs] input_cost_per_mtok = 10.0 output_cost_per_mtok = 50.0 cache_input_cost_per_mtok = 1.0 -[models."claude-opus-4-8"] -provider = "anthropic" -api_id = "claude-opus-4-8" +[providers.anthropic.models."claude-opus-4-8"] display_name = "Claude Opus 4.8" family = "claude-4" training = "2026-01-01" @@ -43,11 +39,11 @@ knowledge_cutoff = "Jan 2026" estimated_output_tps = 25 aliases = ["opus", "claude-opus"] -[models."claude-opus-4-8".limits] +[providers.anthropic.models."claude-opus-4-8".limits] context_window = 1000000 max_output = 128000 -[models."claude-opus-4-8".features] +[providers.anthropic.models."claude-opus-4-8".features] tools = true vision = true reasoning = true @@ -55,33 +51,31 @@ reasoning_effort = "levels" prompt_cache = true sampling_params = false -[models."claude-opus-4-8".controls] +[providers.anthropic.models."claude-opus-4-8".controls] speed = ["fast"] -[models."claude-opus-4-8".costs] +[providers.anthropic.models."claude-opus-4-8".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 25.0 cache_input_cost_per_mtok = 0.5 -[models."claude-opus-4-8".costs.speed.fast] +[providers.anthropic.models."claude-opus-4-8".costs.speed.fast] input_cost_per_mtok = 10.0 output_cost_per_mtok = 50.0 cache_input_cost_per_mtok = 1.0 -[models."claude-opus-4-7"] -provider = "anthropic" -api_id = "claude-opus-4-7" +[providers.anthropic.models."claude-opus-4-7"] display_name = "Claude Opus 4.7" family = "claude-4" training = "2025-08-01" knowledge_cutoff = "May 2025" estimated_output_tps = 25 -[models."claude-opus-4-7".limits] +[providers.anthropic.models."claude-opus-4-7".limits] context_window = 1000000 max_output = 128000 -[models."claude-opus-4-7".features] +[providers.anthropic.models."claude-opus-4-7".features] tools = true vision = true reasoning = true @@ -89,82 +83,76 @@ reasoning_effort = "levels" prompt_cache = true sampling_params = false -[models."claude-opus-4-7".controls] +[providers.anthropic.models."claude-opus-4-7".controls] speed = ["fast"] -[models."claude-opus-4-7".costs] +[providers.anthropic.models."claude-opus-4-7".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 25.0 cache_input_cost_per_mtok = 0.5 -[models."claude-opus-4-7".costs.speed.fast] +[providers.anthropic.models."claude-opus-4-7".costs.speed.fast] input_cost_per_mtok = 30.0 output_cost_per_mtok = 150.0 cache_input_cost_per_mtok = 3.0 -[models."claude-opus-4-6"] -provider = "anthropic" -api_id = "claude-opus-4-6" +[providers.anthropic.models."claude-opus-4-6"] display_name = "Claude Opus 4.6" family = "claude-4" training = "2025-08-01" knowledge_cutoff = "May 2025" estimated_output_tps = 25 -[models."claude-opus-4-6".limits] +[providers.anthropic.models."claude-opus-4-6".limits] context_window = 1000000 max_output = 128000 -[models."claude-opus-4-6".features] +[providers.anthropic.models."claude-opus-4-6".features] tools = true vision = true reasoning = true reasoning_effort = "levels" prompt_cache = true -[models."claude-opus-4-6".controls] +[providers.anthropic.models."claude-opus-4-6".controls] speed = ["fast"] -[models."claude-opus-4-6".costs] +[providers.anthropic.models."claude-opus-4-6".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 25.0 cache_input_cost_per_mtok = 0.5 -[models."claude-opus-4-6".costs.speed.fast] +[providers.anthropic.models."claude-opus-4-6".costs.speed.fast] input_cost_per_mtok = 30.0 output_cost_per_mtok = 150.0 cache_input_cost_per_mtok = 3.0 -[models."claude-sonnet-4-5"] -provider = "anthropic" -api_id = "claude-sonnet-4-5" +[providers.anthropic.models."claude-sonnet-4-5"] display_name = "Claude Sonnet 4.5" family = "claude-4" training = "2025-08-01" knowledge_cutoff = "May 2025" estimated_output_tps = 50 -[models."claude-sonnet-4-5".limits] +[providers.anthropic.models."claude-sonnet-4-5".limits] context_window = 200000 max_output = 64000 -[models."claude-sonnet-4-5".features] +[providers.anthropic.models."claude-sonnet-4-5".features] tools = true vision = true reasoning = true prompt_cache = true -[models."claude-sonnet-4-5".controls] +[providers.anthropic.models."claude-sonnet-4-5".controls] reasoning_effort = ["low", "medium", "high", "xhigh", "max"] -[models."claude-sonnet-4-5".costs] +[providers.anthropic.models."claude-sonnet-4-5".costs] input_cost_per_mtok = 3.0 output_cost_per_mtok = 15.0 cache_input_cost_per_mtok = 0.3 -[models."claude-sonnet-4-6"] -provider = "anthropic" -api_id = "claude-sonnet-4-6" +[providers.anthropic.models."claude-sonnet-4-6"] display_name = "Claude Sonnet 4.6" family = "claude-4" training = "2025-08-01" @@ -173,25 +161,23 @@ default = true estimated_output_tps = 50 aliases = ["sonnet", "claude-sonnet"] -[models."claude-sonnet-4-6".limits] +[providers.anthropic.models."claude-sonnet-4-6".limits] context_window = 200000 max_output = 64000 -[models."claude-sonnet-4-6".features] +[providers.anthropic.models."claude-sonnet-4-6".features] tools = true vision = true reasoning = true reasoning_effort = "levels" prompt_cache = true -[models."claude-sonnet-4-6".costs] +[providers.anthropic.models."claude-sonnet-4-6".costs] input_cost_per_mtok = 3.0 output_cost_per_mtok = 15.0 cache_input_cost_per_mtok = 0.3 -[models."claude-haiku-4-5"] -provider = "anthropic" -api_id = "claude-haiku-4-5" +[providers.anthropic.models."claude-haiku-4-5"] display_name = "Claude Haiku 4.5" family = "claude-4" training = "2025-08-01" @@ -201,17 +187,17 @@ aliases = ["haiku", "claude-haiku"] probe = true small_default = true -[models."claude-haiku-4-5".limits] +[providers.anthropic.models."claude-haiku-4-5".limits] context_window = 200000 max_output = 8192 -[models."claude-haiku-4-5".features] +[providers.anthropic.models."claude-haiku-4-5".features] tools = true vision = true reasoning = false prompt_cache = true -[models."claude-haiku-4-5".costs] +[providers.anthropic.models."claude-haiku-4-5".costs] input_cost_per_mtok = 0.8 output_cost_per_mtok = 4.0 cache_input_cost_per_mtok = 0.08 diff --git a/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml b/lib/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml similarity index 81% rename from lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml rename to lib/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml index 20b011b9c..21384d9b2 100644 --- a/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml @@ -35,41 +35,41 @@ credentials = [ # [llm.providers.bedrock-openai] # enabled = true -[models."openai.gpt-5.5"] -provider = "bedrock-openai" +[providers.bedrock-openai.models."gpt-5.5"] +api_id = "openai.gpt-5.5" display_name = "GPT-5.5 (Bedrock)" family = "gpt-5" default = true -[models."openai.gpt-5.5".limits] +[providers.bedrock-openai.models."gpt-5.5".limits] context_window = 272000 max_output = 128000 -[models."openai.gpt-5.5".features] +[providers.bedrock-openai.models."gpt-5.5".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."openai.gpt-5.5".costs] +[providers.bedrock-openai.models."gpt-5.5".costs] input_cost_per_mtok = 5.5 output_cost_per_mtok = 33.0 -[models."openai.gpt-5.4"] -provider = "bedrock-openai" +[providers.bedrock-openai.models."gpt-5.4"] +api_id = "openai.gpt-5.4" display_name = "GPT-5.4 (Bedrock)" family = "gpt-5" -[models."openai.gpt-5.4".limits] +[providers.bedrock-openai.models."gpt-5.4".limits] context_window = 272000 max_output = 128000 -[models."openai.gpt-5.4".features] +[providers.bedrock-openai.models."gpt-5.4".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."openai.gpt-5.4".costs] +[providers.bedrock-openai.models."gpt-5.4".costs] input_cost_per_mtok = 2.75 output_cost_per_mtok = 16.5 diff --git a/lib/crates/fabro-model/src/catalog/providers/bedrock.toml b/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml similarity index 71% rename from lib/crates/fabro-model/src/catalog/providers/bedrock.toml rename to lib/foundation/fabro-model/src/catalog/providers/bedrock.toml index f6f217a4f..aeed22e46 100644 --- a/lib/crates/fabro-model/src/catalog/providers/bedrock.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml @@ -45,68 +45,67 @@ credentials = [ # file because its Bedrock deployment pins sampling parameters and requires an # extra data-sharing opt-in. -[models."us.anthropic.claude-sonnet-4-6"] -provider = "bedrock" +[providers.bedrock.models."claude-sonnet-4-6"] +api_id = "us.anthropic.claude-sonnet-4-6" display_name = "Claude Sonnet 4.6 (Bedrock)" family = "claude-4" billing_policy = "anthropic" default = true -[models."us.anthropic.claude-sonnet-4-6".limits] +[providers.bedrock.models."claude-sonnet-4-6".limits] context_window = 1000000 max_output = 64000 -[models."us.anthropic.claude-sonnet-4-6".features] +[providers.bedrock.models."claude-sonnet-4-6".features] tools = true vision = true reasoning = true prompt_cache = true -[models."us.anthropic.claude-sonnet-4-6".costs] +[providers.bedrock.models."claude-sonnet-4-6".costs] input_cost_per_mtok = 3.0 output_cost_per_mtok = 15.0 cache_input_cost_per_mtok = 0.3 -[models."us.anthropic.claude-opus-4-8"] -provider = "bedrock" +[providers.bedrock.models."claude-opus-4-8"] +api_id = "us.anthropic.claude-opus-4-8" display_name = "Claude Opus 4.8 (Bedrock)" family = "claude-4" billing_policy = "anthropic" -[models."us.anthropic.claude-opus-4-8".limits] +[providers.bedrock.models."claude-opus-4-8".limits] context_window = 1000000 max_output = 128000 -[models."us.anthropic.claude-opus-4-8".features] +[providers.bedrock.models."claude-opus-4-8".features] tools = true vision = true reasoning = true prompt_cache = true -[models."us.anthropic.claude-opus-4-8".costs] +[providers.bedrock.models."claude-opus-4-8".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 25.0 cache_input_cost_per_mtok = 0.5 -[models."us.anthropic.claude-haiku-4-5"] -provider = "bedrock" +[providers.bedrock.models."claude-haiku-4-5"] api_id = "us.anthropic.claude-haiku-4-5-20251001-v1:0" display_name = "Claude Haiku 4.5 (Bedrock)" family = "claude-4" billing_policy = "anthropic" small_default = true -[models."us.anthropic.claude-haiku-4-5".limits] +[providers.bedrock.models."claude-haiku-4-5".limits] context_window = 200000 max_output = 64000 -[models."us.anthropic.claude-haiku-4-5".features] +[providers.bedrock.models."claude-haiku-4-5".features] tools = true vision = true reasoning = false prompt_cache = true -[models."us.anthropic.claude-haiku-4-5".costs] +[providers.bedrock.models."claude-haiku-4-5".costs] input_cost_per_mtok = 1.0 output_cost_per_mtok = 5.0 cache_input_cost_per_mtok = 0.1 @@ -116,149 +115,142 @@ cache_input_cost_per_mtok = 0.1 # GPT-5.5/5.4 are NOT here: on Bedrock they are Responses-API-only on the # bedrock-mantle endpoint (no Converse), a named follow-up route. -[models."openai.gpt-oss-120b"] -provider = "bedrock" +[providers.bedrock.models."gpt-oss-120b"] api_id = "openai.gpt-oss-120b-1:0" display_name = "GPT-OSS 120B (Bedrock)" family = "gpt-oss" billing_policy = "openai" agent_profile = "openai" -[models."openai.gpt-oss-120b".limits] +[providers.bedrock.models."gpt-oss-120b".limits] context_window = 128000 max_output = 16384 -[models."openai.gpt-oss-120b".features] +[providers.bedrock.models."gpt-oss-120b".features] tools = true vision = false reasoning = true -[models."openai.gpt-oss-120b".costs] +[providers.bedrock.models."gpt-oss-120b".costs] input_cost_per_mtok = 0.15 output_cost_per_mtok = 0.60 -[models."openai.gpt-oss-20b"] -provider = "bedrock" +[providers.bedrock.models."gpt-oss-20b"] api_id = "openai.gpt-oss-20b-1:0" display_name = "GPT-OSS 20B (Bedrock)" family = "gpt-oss" billing_policy = "openai" agent_profile = "openai" -[models."openai.gpt-oss-20b".limits] +[providers.bedrock.models."gpt-oss-20b".limits] context_window = 128000 max_output = 16384 -[models."openai.gpt-oss-20b".features] +[providers.bedrock.models."gpt-oss-20b".features] tools = true vision = false reasoning = true -[models."openai.gpt-oss-20b".costs] +[providers.bedrock.models."gpt-oss-20b".costs] input_cost_per_mtok = 0.07 output_cost_per_mtok = 0.30 # ---------- Amazon Nova ---------- -[models."amazon.nova-2-lite"] -provider = "bedrock" +[providers.bedrock.models."nova-2-lite"] api_id = "global.amazon.nova-2-lite-v1:0" display_name = "Nova 2 Lite (Bedrock)" family = "nova-2" billing_policy = "openai" agent_profile = "openai" -[models."amazon.nova-2-lite".limits] +[providers.bedrock.models."nova-2-lite".limits] context_window = 1000000 # Bedrock caps Nova output at 65535 (2^16 - 1); 65536 trips # "maximum tokens exceeds the model limit of 65535" since the prompt handler # defaults max_tokens to max_output. max_output = 65535 -[models."amazon.nova-2-lite".features] +[providers.bedrock.models."nova-2-lite".features] tools = true vision = true reasoning = false -[models."amazon.nova-2-lite".costs] +[providers.bedrock.models."nova-2-lite".costs] input_cost_per_mtok = 0.30 output_cost_per_mtok = 2.50 # ---------- Open-weights ---------- -[models."meta.llama4-maverick"] -provider = "bedrock" +[providers.bedrock.models."llama-4-maverick"] api_id = "us.meta.llama4-maverick-17b-instruct-v1:0" display_name = "Llama 4 Maverick (Bedrock)" family = "llama-4" billing_policy = "openai" agent_profile = "openai" -[models."meta.llama4-maverick".limits] +[providers.bedrock.models."llama-4-maverick".limits] context_window = 1000000 max_output = 8192 -[models."meta.llama4-maverick".features] +[providers.bedrock.models."llama-4-maverick".features] tools = true vision = true reasoning = false -[models."mistral.mistral-large-3"] -provider = "bedrock" +[providers.bedrock.models."mistral-large-3"] api_id = "mistral.mistral-large-3-675b-instruct" display_name = "Mistral Large 3 (Bedrock)" family = "mistral-large" billing_policy = "openai" agent_profile = "openai" -[models."mistral.mistral-large-3".limits] +[providers.bedrock.models."mistral-large-3".limits] context_window = 256000 max_output = 32768 -[models."mistral.mistral-large-3".features] +[providers.bedrock.models."mistral-large-3".features] tools = true vision = true reasoning = false -[models."mistral.mistral-large-3".costs] +[providers.bedrock.models."mistral-large-3".costs] input_cost_per_mtok = 0.50 output_cost_per_mtok = 1.50 -[models."mistral.devstral-2"] -provider = "bedrock" +[providers.bedrock.models."devstral-2"] api_id = "mistral.devstral-2-123b" display_name = "Devstral 2 (Bedrock)" family = "devstral" billing_policy = "openai" agent_profile = "openai" -[models."mistral.devstral-2".limits] +[providers.bedrock.models."devstral-2".limits] context_window = 256000 max_output = 32768 -[models."mistral.devstral-2".features] +[providers.bedrock.models."devstral-2".features] tools = true vision = false reasoning = false -[models."deepseek.v3-2"] -provider = "bedrock" +[providers.bedrock.models."deepseek-v3.2"] api_id = "deepseek.v3.2" display_name = "DeepSeek V3.2 (Bedrock)" family = "deepseek-v3" billing_policy = "openai" agent_profile = "openai" -[models."deepseek.v3-2".limits] +[providers.bedrock.models."deepseek-v3.2".limits] context_window = 164000 max_output = 8192 -[models."deepseek.v3-2".features] +[providers.bedrock.models."deepseek-v3.2".features] tools = true vision = false reasoning = true -[models."deepseek.v3-2".costs] +[providers.bedrock.models."deepseek-v3.2".costs] input_cost_per_mtok = 0.62 output_cost_per_mtok = 1.85 @@ -267,92 +259,90 @@ output_cost_per_mtok = 1.85 # "The provided model identifier is invalid"), so this row needs an explicit # `api_id` confirmed against `aws bedrock list-inference-profiles` before it # ships. Re-add with: -# [models."qwen.qwen3-coder-next"] -# provider = "bedrock" +# [providers.bedrock.models."qwen3-coder-next"] # api_id = "" # display_name = "Qwen3 Coder Next (Bedrock)" # family = "qwen3" # billing_policy = "openai" # agent_profile = "openai" -# [models."qwen.qwen3-coder-next".limits] +# [providers.bedrock.models."qwen3-coder-next".limits] # context_window = 256000 # max_output = 16384 -# [models."qwen.qwen3-coder-next".features] +# [providers.bedrock.models."qwen3-coder-next".features] # tools = true -[models."moonshotai.kimi-k2.5"] -provider = "bedrock" +[providers.bedrock.models."kimi-k2.5"] +api_id = "moonshotai.kimi-k2.5" display_name = "Kimi K2.5 (Bedrock)" family = "kimi-k2" billing_policy = "openai" agent_profile = "openai" -[models."moonshotai.kimi-k2.5".limits] +[providers.bedrock.models."kimi-k2.5".limits] context_window = 262144 max_output = 16384 -[models."moonshotai.kimi-k2.5".features] +[providers.bedrock.models."kimi-k2.5".features] tools = true vision = true reasoning = false -[models."moonshotai.kimi-k2.5".costs] +[providers.bedrock.models."kimi-k2.5".costs] input_cost_per_mtok = 0.60 output_cost_per_mtok = 3.00 -[models."zai.glm-5"] -provider = "bedrock" +[providers.bedrock.models."glm-5"] +api_id = "zai.glm-5" display_name = "GLM 5 (Bedrock)" family = "glm" billing_policy = "openai" agent_profile = "openai" -[models."zai.glm-5".limits] +[providers.bedrock.models."glm-5".limits] context_window = 200000 max_output = 128000 -[models."zai.glm-5".features] +[providers.bedrock.models."glm-5".features] tools = true vision = false reasoning = false -[models."zai.glm-5".costs] +[providers.bedrock.models."glm-5".costs] input_cost_per_mtok = 1.00 output_cost_per_mtok = 3.20 -[models."minimax.minimax-m2.5"] -provider = "bedrock" +[providers.bedrock.models."minimax-m2.5"] +api_id = "minimax.minimax-m2.5" display_name = "MiniMax M2.5 (Bedrock)" family = "minimax-m2" billing_policy = "openai" agent_profile = "openai" -[models."minimax.minimax-m2.5".limits] +[providers.bedrock.models."minimax-m2.5".limits] context_window = 196000 max_output = 8192 -[models."minimax.minimax-m2.5".features] +[providers.bedrock.models."minimax-m2.5".features] tools = true vision = false reasoning = false -[models."minimax.minimax-m2.5".costs] +[providers.bedrock.models."minimax-m2.5".costs] input_cost_per_mtok = 0.30 output_cost_per_mtok = 1.20 -[models."nvidia.nemotron-3-super"] -provider = "bedrock" +[providers.bedrock.models."nemotron-3-super"] api_id = "nvidia.nemotron-super-3-120b" display_name = "Nemotron 3 Super (Bedrock)" family = "nemotron-3" billing_policy = "openai" agent_profile = "openai" -[models."nvidia.nemotron-3-super".limits] +[providers.bedrock.models."nemotron-3-super".limits] context_window = 256000 max_output = 32768 -[models."nvidia.nemotron-3-super".features] +[providers.bedrock.models."nemotron-3-super".features] tools = true vision = false reasoning = false @@ -365,24 +355,24 @@ reasoning = false # reasoning_effort stays undeclared here (requests carrying one are # rejected up front rather than silently dropped). -[models."us.anthropic.claude-fable-5"] -provider = "bedrock" +[providers.bedrock.models."claude-fable-5"] +api_id = "us.anthropic.claude-fable-5" display_name = "Claude Fable 5 (Bedrock)" family = "claude-5" billing_policy = "anthropic" -[models."us.anthropic.claude-fable-5".limits] +[providers.bedrock.models."claude-fable-5".limits] context_window = 1000000 max_output = 128000 -[models."us.anthropic.claude-fable-5".features] +[providers.bedrock.models."claude-fable-5".features] tools = true vision = true reasoning = true prompt_cache = true sampling_params = false -[models."us.anthropic.claude-fable-5".costs] +[providers.bedrock.models."claude-fable-5".costs] input_cost_per_mtok = 10.0 output_cost_per_mtok = 50.0 cache_input_cost_per_mtok = 1.0 diff --git a/lib/crates/fabro-model/src/catalog/providers/gemini.toml b/lib/foundation/fabro-model/src/catalog/providers/gemini.toml similarity index 66% rename from lib/crates/fabro-model/src/catalog/providers/gemini.toml rename to lib/foundation/fabro-model/src/catalog/providers/gemini.toml index 74ffc91de..a03c2a249 100644 --- a/lib/crates/fabro-model/src/catalog/providers/gemini.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/gemini.toml @@ -9,9 +9,7 @@ priority = 80 credentials = ["env:GEMINI_API_KEY", "env:GOOGLE_API_KEY", "vault:GEMINI_API_KEY"] header = { custom = "x-goog-api-key" } -[models."gemini-3.1-pro-preview"] -provider = "gemini" -api_id = "gemini-3.1-pro-preview" +[providers.gemini.models."gemini-3.1-pro-preview"] display_name = "Gemini 3.1 Pro (Preview)" family = "gemini-3" training = "2025-01-01" @@ -19,24 +17,22 @@ knowledge_cutoff = "January 2025" estimated_output_tps = 85 aliases = ["gemini-pro"] -[models."gemini-3.1-pro-preview".limits] +[providers.gemini.models."gemini-3.1-pro-preview".limits] context_window = 1048576 max_output = 65536 -[models."gemini-3.1-pro-preview".features] +[providers.gemini.models."gemini-3.1-pro-preview".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."gemini-3.1-pro-preview".costs] +[providers.gemini.models."gemini-3.1-pro-preview".costs] input_cost_per_mtok = 2.0 output_cost_per_mtok = 12.0 cache_input_cost_per_mtok = 0.5 -[models."gemini-3.1-pro-preview-customtools"] -provider = "gemini" -api_id = "gemini-3.1-pro-preview-customtools" +[providers.gemini.models."gemini-3.1-pro-preview-customtools"] display_name = "Gemini 3.1 Pro Custom Tools (Preview)" family = "gemini-3" training = "2025-01-01" @@ -44,24 +40,22 @@ knowledge_cutoff = "January 2025" estimated_output_tps = 85 aliases = ["gemini-customtools"] -[models."gemini-3.1-pro-preview-customtools".limits] +[providers.gemini.models."gemini-3.1-pro-preview-customtools".limits] context_window = 1048576 max_output = 65536 -[models."gemini-3.1-pro-preview-customtools".features] +[providers.gemini.models."gemini-3.1-pro-preview-customtools".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."gemini-3.1-pro-preview-customtools".costs] +[providers.gemini.models."gemini-3.1-pro-preview-customtools".costs] input_cost_per_mtok = 2.0 output_cost_per_mtok = 12.0 cache_input_cost_per_mtok = 0.5 -[models."gemini-3.5-flash"] -provider = "gemini" -api_id = "gemini-3.5-flash" +[providers.gemini.models."gemini-3.5-flash"] display_name = "Gemini 3.5 Flash" family = "gemini-3" training = "2025-01-01" @@ -70,24 +64,22 @@ default = true estimated_output_tps = 150 aliases = ["gemini-35-flash"] -[models."gemini-3.5-flash".limits] +[providers.gemini.models."gemini-3.5-flash".limits] context_window = 1048576 max_output = 65536 -[models."gemini-3.5-flash".features] +[providers.gemini.models."gemini-3.5-flash".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."gemini-3.5-flash".costs] +[providers.gemini.models."gemini-3.5-flash".costs] input_cost_per_mtok = 1.5 output_cost_per_mtok = 9.0 cache_input_cost_per_mtok = 0.15 -[models."gemini-3-flash-preview"] -provider = "gemini" -api_id = "gemini-3-flash-preview" +[providers.gemini.models."gemini-3-flash-preview"] display_name = "Gemini 3 Flash (Preview)" family = "gemini-3" training = "2025-01-01" @@ -95,24 +87,22 @@ knowledge_cutoff = "January 2025" estimated_output_tps = 150 aliases = ["gemini-flash"] -[models."gemini-3-flash-preview".limits] +[providers.gemini.models."gemini-3-flash-preview".limits] context_window = 1048576 max_output = 65536 -[models."gemini-3-flash-preview".features] +[providers.gemini.models."gemini-3-flash-preview".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."gemini-3-flash-preview".costs] +[providers.gemini.models."gemini-3-flash-preview".costs] input_cost_per_mtok = 0.5 output_cost_per_mtok = 3.0 cache_input_cost_per_mtok = 0.125 -[models."gemini-3.1-flash-lite"] -provider = "gemini" -api_id = "gemini-3.1-flash-lite" +[providers.gemini.models."gemini-3.1-flash-lite"] display_name = "Gemini 3.1 Flash Lite" family = "gemini-3" training = "2025-01-01" @@ -121,17 +111,17 @@ estimated_output_tps = 200 aliases = ["gemini-flash-lite", "gemini-3.1-flash-lite-preview"] small_default = true -[models."gemini-3.1-flash-lite".limits] +[providers.gemini.models."gemini-3.1-flash-lite".limits] context_window = 1048576 max_output = 65536 -[models."gemini-3.1-flash-lite".features] +[providers.gemini.models."gemini-3.1-flash-lite".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."gemini-3.1-flash-lite".costs] +[providers.gemini.models."gemini-3.1-flash-lite".costs] input_cost_per_mtok = 0.25 output_cost_per_mtok = 1.5 cache_input_cost_per_mtok = 0.025 diff --git a/lib/crates/fabro-model/src/catalog/providers/inception.toml b/lib/foundation/fabro-model/src/catalog/providers/inception.toml similarity index 75% rename from lib/crates/fabro-model/src/catalog/providers/inception.toml rename to lib/foundation/fabro-model/src/catalog/providers/inception.toml index 1d2b08a64..965120f27 100644 --- a/lib/crates/fabro-model/src/catalog/providers/inception.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/inception.toml @@ -8,25 +8,23 @@ priority = 40 [providers.inception.auth] credentials = ["env:INCEPTION_API_KEY", "vault:INCEPTION_API_KEY"] -[models."mercury-2"] -provider = "inception" -api_id = "mercury-2" +[providers.inception.models."mercury-2"] display_name = "Mercury 2" family = "mercury" default = true estimated_output_tps = 1000 aliases = ["mercury"] -[models."mercury-2".limits] +[providers.inception.models."mercury-2".limits] context_window = 131072 max_output = 50000 -[models."mercury-2".features] +[providers.inception.models."mercury-2".features] tools = true vision = false reasoning = true reasoning_effort = "levels" -[models."mercury-2".costs] +[providers.inception.models."mercury-2".costs] input_cost_per_mtok = 0.25 output_cost_per_mtok = 0.75 diff --git a/lib/crates/fabro-model/src/catalog/providers/kimi.toml b/lib/foundation/fabro-model/src/catalog/providers/kimi.toml similarity index 75% rename from lib/crates/fabro-model/src/catalog/providers/kimi.toml rename to lib/foundation/fabro-model/src/catalog/providers/kimi.toml index c57779116..daa4b20c2 100644 --- a/lib/crates/fabro-model/src/catalog/providers/kimi.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/kimi.toml @@ -8,46 +8,42 @@ priority = 70 [providers.kimi.auth] credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] -[models."kimi-k2.5"] -provider = "kimi" -api_id = "kimi-k2.5" +[providers.kimi.models."kimi-k2.5"] display_name = "Kimi K2.5" family = "kimi-k2" training = "2025-10-01" knowledge_cutoff = "October 2025" estimated_output_tps = 50 -[models."kimi-k2.5".limits] +[providers.kimi.models."kimi-k2.5".limits] context_window = 262144 max_output = 32768 -[models."kimi-k2.5".features] +[providers.kimi.models."kimi-k2.5".features] tools = true vision = true reasoning = true prompt_cache = true sampling_params = false -[models."kimi-k2.5".costs] +[providers.kimi.models."kimi-k2.5".costs] input_cost_per_mtok = 0.6 output_cost_per_mtok = 3.0 cache_input_cost_per_mtok = 0.1 -[models."kimi-k3"] -provider = "kimi" -api_id = "kimi-k3" +[providers.kimi.models."kimi-k3"] display_name = "Kimi K3" family = "kimi-k3" default = true aliases = ["kimi"] -[models."kimi-k3".limits] +[providers.kimi.models."kimi-k3".limits] context_window = 1048576 # K3 accepts explicit completion budgets up to 1048576, but Fabro also uses # max_output as the default request budget. Match Kimi's 131072-token default. max_output = 131072 -[models."kimi-k3".features] +[providers.kimi.models."kimi-k3".features] tools = true vision = true reasoning = true @@ -55,10 +51,10 @@ reasoning_effort = "always_adaptive" prompt_cache = true sampling_params = false -[models."kimi-k3".controls] +[providers.kimi.models."kimi-k3".controls] reasoning_effort = ["low", "high", "max"] -[models."kimi-k3".costs] +[providers.kimi.models."kimi-k3".costs] input_cost_per_mtok = 3.0 output_cost_per_mtok = 15.0 cache_input_cost_per_mtok = 0.3 diff --git a/lib/crates/fabro-model/src/catalog/providers/litellm.toml b/lib/foundation/fabro-model/src/catalog/providers/litellm.toml similarity index 77% rename from lib/crates/fabro-model/src/catalog/providers/litellm.toml rename to lib/foundation/fabro-model/src/catalog/providers/litellm.toml index 55f5aef15..1307378c1 100644 --- a/lib/crates/fabro-model/src/catalog/providers/litellm.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/litellm.toml @@ -14,18 +14,17 @@ credentials = ["env:LITELLM_API_KEY", "vault:LITELLM_API_KEY"] # enabled = true # base_url = "http://localhost:4000/v1" # -# [llm.models."litellm-gpt-5"] -# provider = "litellm" +# [llm.providers.litellm.models."litellm-gpt-5"] # api_id = "gpt-5" # display_name = "LiteLLM GPT-5" # family = "litellm" # default = true # -# [llm.models."litellm-gpt-5".limits] +# [llm.providers.litellm.models."litellm-gpt-5".limits] # context_window = 128000 # max_output = 8192 # -# [llm.models."litellm-gpt-5".features] +# [llm.providers.litellm.models."litellm-gpt-5".features] # tools = true # vision = false # reasoning = false diff --git a/lib/crates/fabro-model/src/catalog/providers/minimax.toml b/lib/foundation/fabro-model/src/catalog/providers/minimax.toml similarity index 74% rename from lib/crates/fabro-model/src/catalog/providers/minimax.toml rename to lib/foundation/fabro-model/src/catalog/providers/minimax.toml index 172a6fd6a..e68dfc290 100644 --- a/lib/crates/fabro-model/src/catalog/providers/minimax.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/minimax.toml @@ -8,24 +8,22 @@ priority = 50 [providers.minimax.auth] credentials = ["env:MINIMAX_API_KEY", "vault:MINIMAX_API_KEY"] -[models."minimax-m2.5"] -provider = "minimax" -api_id = "minimax-m2.5" +[providers.minimax.models."minimax-m2.5"] display_name = "Minimax M2.5" family = "minimax-m2" default = true estimated_output_tps = 45 aliases = ["minimax"] -[models."minimax-m2.5".limits] +[providers.minimax.models."minimax-m2.5".limits] context_window = 196608 max_output = 16384 -[models."minimax-m2.5".features] +[providers.minimax.models."minimax-m2.5".features] tools = true vision = false reasoning = false -[models."minimax-m2.5".costs] +[providers.minimax.models."minimax-m2.5".costs] input_cost_per_mtok = 0.3 output_cost_per_mtok = 1.2 diff --git a/lib/crates/fabro-model/src/catalog/providers/ollama.toml b/lib/foundation/fabro-model/src/catalog/providers/ollama.toml similarity index 79% rename from lib/crates/fabro-model/src/catalog/providers/ollama.toml rename to lib/foundation/fabro-model/src/catalog/providers/ollama.toml index bf3b16f5e..78dc5db69 100644 --- a/lib/crates/fabro-model/src/catalog/providers/ollama.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/ollama.toml @@ -9,18 +9,17 @@ enabled = false # Example model. Uncomment after `ollama pull qwen3.5` (and `enabled = true` # above) to expose it through the OpenAI-compatible adapter. # -# [models."qwen3.5"] -# provider = "ollama" +# [providers.ollama.models."qwen3.5"] # api_id = "qwen3.5:latest" # display_name = "Qwen3.5" # family = "qwen3.5" # default = true # aliases = ["ollama-qwen3.5"] # -# [models."qwen3.5".limits] +# [providers.ollama.models."qwen3.5".limits] # context_window = 32768 # -# [models."qwen3.5".features] +# [providers.ollama.models."qwen3.5".features] # tools = true # vision = false # reasoning = false diff --git a/lib/crates/fabro-model/src/catalog/providers/openai.toml b/lib/foundation/fabro-model/src/catalog/providers/openai.toml similarity index 66% rename from lib/crates/fabro-model/src/catalog/providers/openai.toml rename to lib/foundation/fabro-model/src/catalog/providers/openai.toml index 56fe7a03c..10b7b2193 100644 --- a/lib/crates/fabro-model/src/catalog/providers/openai.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/openai.toml @@ -8,85 +8,77 @@ priority = 90 [providers.openai.auth] credentials = ["env:OPENAI_API_KEY", "vault:OPENAI_API_KEY", "vault:OPENAI_CODEX"] -[models."gpt-5.6-sol"] -provider = "openai" -api_id = "gpt-5.6-sol" +[providers.openai.models."gpt-5.6-sol"] display_name = "GPT-5.6 Sol" family = "gpt-5" training = "2026-02-16" knowledge_cutoff = "February 16, 2026" default = true -aliases = ["gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"] +aliases = ["sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"] -[models."gpt-5.6-sol".limits] +[providers.openai.models."gpt-5.6-sol".limits] context_window = 272000 max_output = 128000 -[models."gpt-5.6-sol".features] +[providers.openai.models."gpt-5.6-sol".features] tools = true vision = true reasoning = true reasoning_effort = "levels" prompt_cache = true -[models."gpt-5.6-sol".costs] +[providers.openai.models."gpt-5.6-sol".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 30.0 cache_input_cost_per_mtok = 0.5 -[models."gpt-5.6-terra"] -provider = "openai" -api_id = "gpt-5.6-terra" +[providers.openai.models."gpt-5.6-terra"] display_name = "GPT-5.6 Terra" family = "gpt-5" training = "2026-02-16" knowledge_cutoff = "February 16, 2026" -aliases = ["gpt56-terra", "gpt-56-terra"] +aliases = ["terra", "gpt56-terra", "gpt-56-terra"] -[models."gpt-5.6-terra".limits] +[providers.openai.models."gpt-5.6-terra".limits] context_window = 272000 max_output = 128000 -[models."gpt-5.6-terra".features] +[providers.openai.models."gpt-5.6-terra".features] tools = true vision = true reasoning = true reasoning_effort = "levels" prompt_cache = true -[models."gpt-5.6-terra".costs] +[providers.openai.models."gpt-5.6-terra".costs] input_cost_per_mtok = 2.5 output_cost_per_mtok = 15.0 cache_input_cost_per_mtok = 0.25 -[models."gpt-5.6-luna"] -provider = "openai" -api_id = "gpt-5.6-luna" +[providers.openai.models."gpt-5.6-luna"] display_name = "GPT-5.6 Luna" family = "gpt-5" training = "2026-02-16" knowledge_cutoff = "February 16, 2026" -aliases = ["gpt56-luna", "gpt-56-luna"] +aliases = ["luna", "gpt56-luna", "gpt-56-luna"] -[models."gpt-5.6-luna".limits] +[providers.openai.models."gpt-5.6-luna".limits] context_window = 272000 max_output = 128000 -[models."gpt-5.6-luna".features] +[providers.openai.models."gpt-5.6-luna".features] tools = true vision = true reasoning = true reasoning_effort = "levels" prompt_cache = true -[models."gpt-5.6-luna".costs] +[providers.openai.models."gpt-5.6-luna".costs] input_cost_per_mtok = 1.0 output_cost_per_mtok = 6.0 cache_input_cost_per_mtok = 0.1 -[models."gpt-5.4"] -provider = "openai" -api_id = "gpt-5.4" +[providers.openai.models."gpt-5.4"] display_name = "GPT-5.4" family = "gpt-5" training = "2025-08-31" @@ -94,24 +86,22 @@ knowledge_cutoff = "April 2025" estimated_output_tps = 70 aliases = ["gpt54", "gpt-54", "gpt-5.2", "gpt5", "gpt-5.3-codex", "codex"] -[models."gpt-5.4".limits] +[providers.openai.models."gpt-5.4".limits] context_window = 272000 max_output = 128000 -[models."gpt-5.4".features] +[providers.openai.models."gpt-5.4".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."gpt-5.4".costs] +[providers.openai.models."gpt-5.4".costs] input_cost_per_mtok = 2.5 output_cost_per_mtok = 15.0 cache_input_cost_per_mtok = 0.25 -[models."gpt-5.5"] -provider = "openai" -api_id = "gpt-5.5" +[providers.openai.models."gpt-5.5"] display_name = "GPT-5.5" family = "gpt-5" training = "2025-12-01" @@ -119,24 +109,22 @@ knowledge_cutoff = "December 2025" estimated_output_tps = 70 aliases = ["gpt55", "gpt-55"] -[models."gpt-5.5".limits] +[providers.openai.models."gpt-5.5".limits] context_window = 272000 max_output = 128000 -[models."gpt-5.5".features] +[providers.openai.models."gpt-5.5".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."gpt-5.5".costs] +[providers.openai.models."gpt-5.5".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 30.0 cache_input_cost_per_mtok = 0.5 -[models."gpt-5.5-pro"] -provider = "openai" -api_id = "gpt-5.5-pro" +[providers.openai.models."gpt-5.5-pro"] display_name = "GPT-5.5 Pro" family = "gpt-5" training = "2025-12-01" @@ -144,24 +132,22 @@ knowledge_cutoff = "December 2025" estimated_output_tps = 20 aliases = ["gpt55-pro", "gpt-55-pro"] -[models."gpt-5.5-pro".limits] +[providers.openai.models."gpt-5.5-pro".limits] context_window = 1050000 max_output = 128000 -[models."gpt-5.5-pro".features] +[providers.openai.models."gpt-5.5-pro".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."gpt-5.5-pro".costs] +[providers.openai.models."gpt-5.5-pro".costs] input_cost_per_mtok = 30.0 output_cost_per_mtok = 180.0 cache_input_cost_per_mtok = 3.0 -[models."gpt-5.4-pro"] -provider = "openai" -api_id = "gpt-5.4-pro" +[providers.openai.models."gpt-5.4-pro"] display_name = "GPT-5.4 Pro" family = "gpt-5" training = "2025-08-31" @@ -169,24 +155,22 @@ knowledge_cutoff = "April 2025" estimated_output_tps = 20 aliases = ["gpt54-pro", "gpt-54-pro"] -[models."gpt-5.4-pro".limits] +[providers.openai.models."gpt-5.4-pro".limits] context_window = 1047576 max_output = 128000 -[models."gpt-5.4-pro".features] +[providers.openai.models."gpt-5.4-pro".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."gpt-5.4-pro".costs] +[providers.openai.models."gpt-5.4-pro".costs] input_cost_per_mtok = 30.0 output_cost_per_mtok = 180.0 cache_input_cost_per_mtok = 3.0 -[models."gpt-5.4-mini"] -provider = "openai" -api_id = "gpt-5.4-mini" +[providers.openai.models."gpt-5.4-mini"] display_name = "GPT-5.4 Mini" family = "gpt-5" training = "2025-08-31" @@ -196,17 +180,17 @@ aliases = ["gpt54-mini", "gpt-54-mini", "gpt-5.3-codex-spark", "codex-spark"] probe = true small_default = true -[models."gpt-5.4-mini".limits] +[providers.openai.models."gpt-5.4-mini".limits] context_window = 272000 max_output = 128000 -[models."gpt-5.4-mini".features] +[providers.openai.models."gpt-5.4-mini".features] tools = true vision = true reasoning = true reasoning_effort = "levels" -[models."gpt-5.4-mini".costs] +[providers.openai.models."gpt-5.4-mini".costs] input_cost_per_mtok = 0.75 output_cost_per_mtok = 4.5 cache_input_cost_per_mtok = 0.075 diff --git a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml similarity index 60% rename from lib/crates/fabro-model/src/catalog/providers/openrouter.toml rename to lib/foundation/fabro-model/src/catalog/providers/openrouter.toml index d01fcb3dc..b83aa7662 100644 --- a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml @@ -33,121 +33,124 @@ credentials = ["env:OPENROUTER_API_KEY", "vault:OPENROUTER_API_KEY"] # best-effort estimates; OpenRouter returns the authoritative usage.cost # in-band on every response. -[models."anthropic/claude-fable-5"] -provider = "openrouter" +[providers.openrouter.models."claude-fable-5"] api_id = "anthropic/claude-fable-5" display_name = "Claude Fable 5 (via OpenRouter)" family = "claude-5" billing_policy = "anthropic" +aliases = ["fable", "claude-fable"] -[models."anthropic/claude-fable-5".limits] +[providers.openrouter.models."claude-fable-5".limits] context_window = 1000000 max_output = 128000 -[models."anthropic/claude-fable-5".features] +[providers.openrouter.models."claude-fable-5".features] tools = true vision = true reasoning = true reasoning_effort = "always_adaptive" prompt_cache = true +cache_control_breakpoints = true sampling_params = false -[models."anthropic/claude-fable-5".costs] +[providers.openrouter.models."claude-fable-5".costs] input_cost_per_mtok = 10.0 output_cost_per_mtok = 50.0 cache_input_cost_per_mtok = 1.0 -[models."anthropic/claude-opus-4-8"] -provider = "openrouter" +[providers.openrouter.models."claude-opus-4-8"] api_id = "anthropic/claude-opus-4.8" display_name = "Claude Opus 4.8 (via OpenRouter)" family = "claude-4" billing_policy = "anthropic" training = "2026-01-01" knowledge_cutoff = "Jan 2026" -aliases = ["anthropic/claude-opus-4.8"] +aliases = ["opus", "claude-opus"] -[models."anthropic/claude-opus-4-8".limits] +[providers.openrouter.models."claude-opus-4-8".limits] context_window = 1000000 max_output = 128000 -[models."anthropic/claude-opus-4-8".features] +[providers.openrouter.models."claude-opus-4-8".features] tools = true vision = true reasoning = true reasoning_effort = "levels" prompt_cache = true +cache_control_breakpoints = true sampling_params = false -[models."anthropic/claude-opus-4-8".costs] +[providers.openrouter.models."claude-opus-4-8".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 25.0 cache_input_cost_per_mtok = 0.5 -[models."anthropic/claude-opus-4-7"] -provider = "openrouter" +[providers.openrouter.models."claude-opus-4-7"] api_id = "anthropic/claude-opus-4.7" display_name = "Claude Opus 4.7 (via OpenRouter)" family = "claude-4" billing_policy = "anthropic" -[models."anthropic/claude-opus-4-7".limits] +[providers.openrouter.models."claude-opus-4-7".limits] context_window = 1000000 max_output = 128000 -[models."anthropic/claude-opus-4-7".features] +[providers.openrouter.models."claude-opus-4-7".features] tools = true vision = true reasoning = true prompt_cache = true +cache_control_breakpoints = true -[models."anthropic/claude-opus-4-7".costs] +[providers.openrouter.models."claude-opus-4-7".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 25.0 cache_input_cost_per_mtok = 0.5 -[models."anthropic/claude-sonnet-4-6"] -provider = "openrouter" +[providers.openrouter.models."claude-sonnet-4-6"] api_id = "anthropic/claude-sonnet-4.6" display_name = "Claude Sonnet 4.6 (via OpenRouter)" family = "claude-4" billing_policy = "anthropic" default = true +aliases = ["sonnet", "claude-sonnet"] -[models."anthropic/claude-sonnet-4-6".limits] +[providers.openrouter.models."claude-sonnet-4-6".limits] context_window = 1000000 max_output = 64000 -[models."anthropic/claude-sonnet-4-6".features] +[providers.openrouter.models."claude-sonnet-4-6".features] tools = true vision = true reasoning = true prompt_cache = true +cache_control_breakpoints = true -[models."anthropic/claude-sonnet-4-6".costs] +[providers.openrouter.models."claude-sonnet-4-6".costs] input_cost_per_mtok = 3.0 output_cost_per_mtok = 15.0 cache_input_cost_per_mtok = 0.3 -[models."anthropic/claude-haiku-4-5"] -provider = "openrouter" +[providers.openrouter.models."claude-haiku-4-5"] api_id = "anthropic/claude-haiku-4.5" display_name = "Claude Haiku 4.5 (via OpenRouter)" family = "claude-4" billing_policy = "anthropic" small_default = true +aliases = ["haiku", "claude-haiku"] -[models."anthropic/claude-haiku-4-5".limits] +[providers.openrouter.models."claude-haiku-4-5".limits] context_window = 200000 max_output = 8192 -[models."anthropic/claude-haiku-4-5".features] +[providers.openrouter.models."claude-haiku-4-5".features] tools = true vision = true reasoning = false prompt_cache = true +cache_control_breakpoints = true -[models."anthropic/claude-haiku-4-5".costs] +[providers.openrouter.models."claude-haiku-4-5".costs] input_cost_per_mtok = 1.0 output_cost_per_mtok = 5.0 cache_input_cost_per_mtok = 0.1 @@ -158,19 +161,19 @@ cache_input_cost_per_mtok = 0.1 # long-context threshold. Authoritative in-band usage.cost covers # long-context and cache-write pricing. -[models."openai/gpt-5.6-sol"] -provider = "openrouter" +[providers.openrouter.models."gpt-5.6-sol"] api_id = "openai/gpt-5.6-sol" 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"] -[models."openai/gpt-5.6-sol".limits] +[providers.openrouter.models."gpt-5.6-sol".limits] context_window = 1050000 max_output = 128000 -[models."openai/gpt-5.6-sol".features] +[providers.openrouter.models."gpt-5.6-sol".features] tools = true vision = true reasoning = true @@ -178,24 +181,24 @@ reasoning_effort = "levels" prompt_cache = true sampling_params = false -[models."openai/gpt-5.6-sol".costs] +[providers.openrouter.models."gpt-5.6-sol".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 30.0 cache_input_cost_per_mtok = 0.5 -[models."openai/gpt-5.6-terra"] -provider = "openrouter" +[providers.openrouter.models."gpt-5.6-terra"] api_id = "openai/gpt-5.6-terra" 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"] -[models."openai/gpt-5.6-terra".limits] +[providers.openrouter.models."gpt-5.6-terra".limits] context_window = 1050000 max_output = 128000 -[models."openai/gpt-5.6-terra".features] +[providers.openrouter.models."gpt-5.6-terra".features] tools = true vision = true reasoning = true @@ -203,24 +206,24 @@ reasoning_effort = "levels" prompt_cache = true sampling_params = false -[models."openai/gpt-5.6-terra".costs] +[providers.openrouter.models."gpt-5.6-terra".costs] input_cost_per_mtok = 2.5 output_cost_per_mtok = 15.0 cache_input_cost_per_mtok = 0.25 -[models."openai/gpt-5.6-luna"] -provider = "openrouter" +[providers.openrouter.models."gpt-5.6-luna"] api_id = "openai/gpt-5.6-luna" 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"] -[models."openai/gpt-5.6-luna".limits] +[providers.openrouter.models."gpt-5.6-luna".limits] context_window = 1050000 max_output = 128000 -[models."openai/gpt-5.6-luna".features] +[providers.openrouter.models."gpt-5.6-luna".features] tools = true vision = true reasoning = true @@ -228,197 +231,189 @@ reasoning_effort = "levels" prompt_cache = true sampling_params = false -[models."openai/gpt-5.6-luna".costs] +[providers.openrouter.models."gpt-5.6-luna".costs] input_cost_per_mtok = 1.0 output_cost_per_mtok = 6.0 cache_input_cost_per_mtok = 0.1 -[models."openai/gpt-5.4"] -provider = "openrouter" +[providers.openrouter.models."gpt-5.4"] api_id = "openai/gpt-5.4" display_name = "GPT-5.4 (via OpenRouter)" family = "gpt-5" +aliases = ["gpt54", "gpt-54", "gpt-5.2", "gpt5", "gpt-5.3-codex", "codex"] -[models."openai/gpt-5.4".limits] +[providers.openrouter.models."gpt-5.4".limits] context_window = 1050000 max_output = 32768 -[models."openai/gpt-5.4".features] +[providers.openrouter.models."gpt-5.4".features] tools = true vision = true reasoning = true -[models."openai/gpt-5.4".costs] +[providers.openrouter.models."gpt-5.4".costs] input_cost_per_mtok = 2.5 output_cost_per_mtok = 15.0 -[models."openai/gpt-5.5"] -provider = "openrouter" +[providers.openrouter.models."gpt-5.5"] api_id = "openai/gpt-5.5" display_name = "GPT-5.5 (via OpenRouter)" family = "gpt-5" +aliases = ["gpt55", "gpt-55"] -[models."openai/gpt-5.5".limits] +[providers.openrouter.models."gpt-5.5".limits] context_window = 1050000 max_output = 32768 -[models."openai/gpt-5.5".features] +[providers.openrouter.models."gpt-5.5".features] tools = true vision = true reasoning = true -[models."openai/gpt-5.5".costs] +[providers.openrouter.models."gpt-5.5".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 30.0 # ---------- Google Gemini via OpenRouter ---------- -[models."google/gemini-3.1-pro-preview"] -provider = "openrouter" +[providers.openrouter.models."gemini-3.1-pro-preview"] api_id = "google/gemini-3.1-pro-preview" display_name = "Gemini 3.1 Pro Preview (via OpenRouter)" family = "gemini-3" -[models."google/gemini-3.1-pro-preview".limits] +[providers.openrouter.models."gemini-3.1-pro-preview".limits] context_window = 1048576 max_output = 65536 -[models."google/gemini-3.1-pro-preview".features] +[providers.openrouter.models."gemini-3.1-pro-preview".features] tools = true vision = true reasoning = true -[models."google/gemini-3.1-pro-preview".costs] +[providers.openrouter.models."gemini-3.1-pro-preview".costs] input_cost_per_mtok = 2.0 output_cost_per_mtok = 12.0 -[models."google/gemini-3.5-flash"] -provider = "openrouter" +[providers.openrouter.models."gemini-3.5-flash"] api_id = "google/gemini-3.5-flash" display_name = "Gemini 3.5 Flash (via OpenRouter)" family = "gemini-3" -[models."google/gemini-3.5-flash".limits] +[providers.openrouter.models."gemini-3.5-flash".limits] context_window = 1048576 max_output = 65536 -[models."google/gemini-3.5-flash".features] +[providers.openrouter.models."gemini-3.5-flash".features] tools = true vision = true reasoning = false -[models."google/gemini-3.5-flash".costs] +[providers.openrouter.models."gemini-3.5-flash".costs] input_cost_per_mtok = 1.5 output_cost_per_mtok = 9.0 # ---------- Open-weights models ---------- -[models."xiaomi/mimo-v2.5-pro"] -provider = "openrouter" +[providers.openrouter.models."mimo-v2.5-pro"] api_id = "xiaomi/mimo-v2.5-pro" display_name = "Xiaomi MiMo v2.5 Pro" family = "mimo-v2" -[models."xiaomi/mimo-v2.5-pro".limits] +[providers.openrouter.models."mimo-v2.5-pro".limits] context_window = 1050000 max_output = 16384 -[models."xiaomi/mimo-v2.5-pro".features] +[providers.openrouter.models."mimo-v2.5-pro".features] tools = true vision = false reasoning = false -[models."xiaomi/mimo-v2.5-pro".costs] +[providers.openrouter.models."mimo-v2.5-pro".costs] input_cost_per_mtok = 0.435 output_cost_per_mtok = 0.87 -[models."minimax/minimax-m2.7"] -provider = "openrouter" +[providers.openrouter.models."minimax-m2.7"] api_id = "minimax/minimax-m2.7" display_name = "MiniMax M2.7" family = "minimax-m2" -[models."minimax/minimax-m2.7".limits] +[providers.openrouter.models."minimax-m2.7".limits] context_window = 200000 max_output = 16384 -[models."minimax/minimax-m2.7".features] +[providers.openrouter.models."minimax-m2.7".features] tools = true vision = false reasoning = false -[models."minimax/minimax-m2.7".costs] +[providers.openrouter.models."minimax-m2.7".costs] input_cost_per_mtok = 0.28 output_cost_per_mtok = 1.20 -[models."deepseek/deepseek-v4-pro"] -provider = "openrouter" +[providers.openrouter.models."deepseek-v4-pro"] api_id = "deepseek/deepseek-v4-pro" display_name = "DeepSeek V4 Pro" family = "deepseek-v4" -[models."deepseek/deepseek-v4-pro".limits] +[providers.openrouter.models."deepseek-v4-pro".limits] context_window = 1050000 max_output = 16384 -[models."deepseek/deepseek-v4-pro".features] +[providers.openrouter.models."deepseek-v4-pro".features] tools = true vision = false reasoning = true -[models."deepseek/deepseek-v4-pro".costs] +[providers.openrouter.models."deepseek-v4-pro".costs] input_cost_per_mtok = 0.435 output_cost_per_mtok = 0.87 -[models."deepseek/deepseek-v4-flash"] -provider = "openrouter" +[providers.openrouter.models."deepseek-v4-flash"] api_id = "deepseek/deepseek-v4-flash" display_name = "DeepSeek V4 Flash" family = "deepseek-v4" -[models."deepseek/deepseek-v4-flash".limits] +[providers.openrouter.models."deepseek-v4-flash".limits] context_window = 1050000 max_output = 16384 -[models."deepseek/deepseek-v4-flash".features] +[providers.openrouter.models."deepseek-v4-flash".features] tools = true vision = false reasoning = false -[models."deepseek/deepseek-v4-flash".costs] +[providers.openrouter.models."deepseek-v4-flash".costs] input_cost_per_mtok = 0.10 output_cost_per_mtok = 0.20 -[models."moonshotai/kimi-k2.6"] -provider = "openrouter" +[providers.openrouter.models."kimi-k2.6"] api_id = "moonshotai/kimi-k2.6" display_name = "Kimi K2.6" family = "kimi-k2" -[models."moonshotai/kimi-k2.6".limits] +[providers.openrouter.models."kimi-k2.6".limits] context_window = 262144 max_output = 16384 -[models."moonshotai/kimi-k2.6".features] +[providers.openrouter.models."kimi-k2.6".features] tools = true vision = false reasoning = false -[models."moonshotai/kimi-k2.6".costs] +[providers.openrouter.models."kimi-k2.6".costs] input_cost_per_mtok = 0.73 output_cost_per_mtok = 3.49 -[models."moonshotai/kimi-k3"] -provider = "openrouter" +[providers.openrouter.models."kimi-k3"] api_id = "moonshotai/kimi-k3" display_name = "Kimi K3 (via OpenRouter)" family = "kimi-k3" -[models."moonshotai/kimi-k3".limits] +[providers.openrouter.models."kimi-k3".limits] context_window = 1048576 max_output = 131072 -[models."moonshotai/kimi-k3".features] +[providers.openrouter.models."kimi-k3".features] tools = true vision = true reasoning = true @@ -426,47 +421,45 @@ reasoning_effort = "always_adaptive" prompt_cache = true sampling_params = false -[models."moonshotai/kimi-k3".controls] +[providers.openrouter.models."kimi-k3".controls] reasoning_effort = ["low", "high", "max"] -[models."moonshotai/kimi-k3".costs] +[providers.openrouter.models."kimi-k3".costs] input_cost_per_mtok = 3.0 output_cost_per_mtok = 15.0 cache_input_cost_per_mtok = 0.3 -[models."poolside/laguna-s-2.1"] -provider = "openrouter" +[providers.openrouter.models."laguna-s-2.1"] api_id = "poolside/laguna-s-2.1" display_name = "Laguna S 2.1 (via OpenRouter)" family = "laguna-2" -[models."poolside/laguna-s-2.1".limits] +[providers.openrouter.models."laguna-s-2.1".limits] context_window = 1048576 max_output = 131072 -[models."poolside/laguna-s-2.1".features] +[providers.openrouter.models."laguna-s-2.1".features] tools = true vision = false reasoning = true prompt_cache = true sampling_params = true -[models."poolside/laguna-s-2.1".costs] +[providers.openrouter.models."laguna-s-2.1".costs] input_cost_per_mtok = 0.10 output_cost_per_mtok = 0.20 cache_input_cost_per_mtok = 0.01 -[models."poolside/laguna-xs-2.1"] -provider = "openrouter" +[providers.openrouter.models."laguna-xs-2.1"] api_id = "poolside/laguna-xs-2.1" display_name = "Laguna XS 2.1 (via OpenRouter)" family = "laguna-2" -[models."poolside/laguna-xs-2.1".limits] +[providers.openrouter.models."laguna-xs-2.1".limits] context_window = 262144 max_output = 32768 -[models."poolside/laguna-xs-2.1".features] +[providers.openrouter.models."laguna-xs-2.1".features] tools = true vision = false reasoning = true @@ -475,127 +468,121 @@ sampling_params = true # Current promotional rate. OpenRouter's authoritative in-band usage.cost # supersedes this estimate on completed responses. -[models."poolside/laguna-xs-2.1".costs] +[providers.openrouter.models."laguna-xs-2.1".costs] input_cost_per_mtok = 0.06 output_cost_per_mtok = 0.12 cache_input_cost_per_mtok = 0.03 -[models."qwen/qwen3-coder"] -provider = "openrouter" +[providers.openrouter.models."qwen3-coder"] api_id = "qwen/qwen3-coder" display_name = "Qwen3 Coder" family = "qwen3" -[models."qwen/qwen3-coder".limits] +[providers.openrouter.models."qwen3-coder".limits] context_window = 1050000 max_output = 16384 -[models."qwen/qwen3-coder".features] +[providers.openrouter.models."qwen3-coder".features] tools = true vision = false reasoning = false -[models."qwen/qwen3-coder".costs] +[providers.openrouter.models."qwen3-coder".costs] input_cost_per_mtok = 0.22 output_cost_per_mtok = 1.80 -[models."qwen/qwen3.6-flash"] -provider = "openrouter" +[providers.openrouter.models."qwen3.6-flash"] api_id = "qwen/qwen3.6-flash" display_name = "Qwen3.6 Flash" family = "qwen3" -[models."qwen/qwen3.6-flash".limits] +[providers.openrouter.models."qwen3.6-flash".limits] context_window = 1000000 max_output = 16384 -[models."qwen/qwen3.6-flash".features] +[providers.openrouter.models."qwen3.6-flash".features] tools = true vision = false reasoning = false -[models."qwen/qwen3.6-flash".costs] +[providers.openrouter.models."qwen3.6-flash".costs] input_cost_per_mtok = 0.1875 output_cost_per_mtok = 1.125 -[models."z-ai/glm-5.2"] -provider = "openrouter" +[providers.openrouter.models."glm-5.2"] api_id = "z-ai/glm-5.2" display_name = "GLM 5.2 (via OpenRouter)" family = "glm-5" -[models."z-ai/glm-5.2".limits] +[providers.openrouter.models."glm-5.2".limits] context_window = 1048576 max_output = 131072 -[models."z-ai/glm-5.2".features] +[providers.openrouter.models."glm-5.2".features] tools = true vision = false reasoning = true reasoning_effort = "levels" prompt_cache = true -[models."z-ai/glm-5.2".controls] +[providers.openrouter.models."glm-5.2".controls] reasoning_effort = ["high", "xhigh"] -[models."z-ai/glm-5.2".costs] +[providers.openrouter.models."glm-5.2".costs] input_cost_per_mtok = 0.784 output_cost_per_mtok = 2.464 cache_input_cost_per_mtok = 0.1456 -[models."z-ai/glm-4.6"] -provider = "openrouter" +[providers.openrouter.models."glm-4.6"] api_id = "z-ai/glm-4.6" display_name = "GLM 4.6" family = "glm-4" -[models."z-ai/glm-4.6".limits] +[providers.openrouter.models."glm-4.6".limits] context_window = 203000 max_output = 16384 -[models."z-ai/glm-4.6".features] +[providers.openrouter.models."glm-4.6".features] tools = true vision = false reasoning = false -[models."z-ai/glm-4.6".costs] +[providers.openrouter.models."glm-4.6".costs] input_cost_per_mtok = 0.43 output_cost_per_mtok = 1.74 -[models."nvidia/nemotron-3-super-120b-a12b"] -provider = "openrouter" +[providers.openrouter.models."nemotron-3-super-120b-a12b"] api_id = "nvidia/nemotron-3-super-120b-a12b" display_name = "NVIDIA Nemotron 3 Super 120B" family = "nemotron-3" -[models."nvidia/nemotron-3-super-120b-a12b".limits] +[providers.openrouter.models."nemotron-3-super-120b-a12b".limits] context_window = 1000000 max_output = 16384 -[models."nvidia/nemotron-3-super-120b-a12b".features] +[providers.openrouter.models."nemotron-3-super-120b-a12b".features] tools = true vision = false reasoning = false -[models."nvidia/nemotron-3-super-120b-a12b".costs] +[providers.openrouter.models."nemotron-3-super-120b-a12b".costs] input_cost_per_mtok = 0.09 output_cost_per_mtok = 0.45 -[models."mistralai/devstral-2512"] -provider = "openrouter" +[providers.openrouter.models."devstral-2512"] api_id = "mistralai/devstral-2512" display_name = "Devstral 2512" family = "devstral" -[models."mistralai/devstral-2512".limits] +[providers.openrouter.models."devstral-2512".limits] context_window = 262144 max_output = 16384 -[models."mistralai/devstral-2512".features] +[providers.openrouter.models."devstral-2512".features] tools = true vision = false reasoning = false -[models."mistralai/devstral-2512".costs] +[providers.openrouter.models."devstral-2512".costs] input_cost_per_mtok = 0.40 output_cost_per_mtok = 2.00 diff --git a/lib/crates/fabro-model/src/catalog/providers/poolside.toml b/lib/foundation/fabro-model/src/catalog/providers/poolside.toml similarity index 76% rename from lib/crates/fabro-model/src/catalog/providers/poolside.toml rename to lib/foundation/fabro-model/src/catalog/providers/poolside.toml index 8fd1b6855..65ce87246 100644 --- a/lib/crates/fabro-model/src/catalog/providers/poolside.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/poolside.toml @@ -8,19 +8,18 @@ priority = 65 [providers.poolside.auth] credentials = ["env:POOLSIDE_API_KEY", "vault:POOLSIDE_API_KEY"] -[models."laguna-s-2.1"] -provider = "poolside" +[providers.poolside.models."laguna-s-2.1"] api_id = "poolside/laguna-s-2.1" display_name = "Laguna S 2.1" family = "laguna-2" default = true aliases = ["laguna", "laguna-s"] -[models."laguna-s-2.1".limits] +[providers.poolside.models."laguna-s-2.1".limits] context_window = 1048576 max_output = 131072 -[models."laguna-s-2.1".features] +[providers.poolside.models."laguna-s-2.1".features] tools = true vision = false reasoning = true @@ -30,13 +29,12 @@ sampling_params = true # Poolside Platform is free for a limited preview period. Keep the published # paid hosted rate as Fabro's durable estimate for paid access and post-preview # usage. -[models."laguna-s-2.1".costs] +[providers.poolside.models."laguna-s-2.1".costs] input_cost_per_mtok = 0.10 output_cost_per_mtok = 0.20 cache_input_cost_per_mtok = 0.01 -[models."laguna-xs-2.1"] -provider = "poolside" +[providers.poolside.models."laguna-xs-2.1"] api_id = "poolside/laguna-xs-2.1" display_name = "Laguna XS 2.1" family = "laguna-2" @@ -44,11 +42,11 @@ small_default = true probe = true aliases = ["laguna-xs"] -[models."laguna-xs-2.1".limits] +[providers.poolside.models."laguna-xs-2.1".limits] context_window = 262144 max_output = 32768 -[models."laguna-xs-2.1".features] +[providers.poolside.models."laguna-xs-2.1".features] tools = true vision = false reasoning = true @@ -57,7 +55,7 @@ sampling_params = true # Poolside Platform is free for a limited preview period. These are Poolside's # published paid endpoint rates. -[models."laguna-xs-2.1".costs] +[providers.poolside.models."laguna-xs-2.1".costs] input_cost_per_mtok = 0.10 output_cost_per_mtok = 0.20 cache_input_cost_per_mtok = 0.05 diff --git a/lib/crates/fabro-model/src/catalog/providers/venice.toml b/lib/foundation/fabro-model/src/catalog/providers/venice.toml similarity index 61% rename from lib/crates/fabro-model/src/catalog/providers/venice.toml rename to lib/foundation/fabro-model/src/catalog/providers/venice.toml index bb91aeada..dc97c4ab4 100644 --- a/lib/crates/fabro-model/src/catalog/providers/venice.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/venice.toml @@ -8,43 +8,39 @@ aliases = ["venice-ai"] [providers.venice.auth] credentials = ["env:VENICE_API_KEY", "vault:VENICE_API_KEY"] -[models."venice-uncensored-1-2"] -provider = "venice" -api_id = "venice-uncensored-1-2" +[providers.venice.models."venice-uncensored-1-2"] display_name = "Venice Uncensored 1.2" family = "venice-uncensored" default = true aliases = ["venice-uncensored", "vu"] -[models."venice-uncensored-1-2".limits] +[providers.venice.models."venice-uncensored-1-2".limits] context_window = 128000 max_output = 8192 -[models."venice-uncensored-1-2".features] +[providers.venice.models."venice-uncensored-1-2".features] tools = true vision = true reasoning = false -[models."venice-uncensored-1-2".costs] +[providers.venice.models."venice-uncensored-1-2".costs] input_cost_per_mtok = 0.2 output_cost_per_mtok = 0.9 -[models."venice-uncensored-role-play"] -provider = "venice" -api_id = "venice-uncensored-role-play" +[providers.venice.models."venice-uncensored-role-play"] display_name = "Venice Uncensored Role Play" family = "venice-uncensored" aliases = ["venice-roleplay", "vrp"] -[models."venice-uncensored-role-play".limits] +[providers.venice.models."venice-uncensored-role-play".limits] context_window = 128000 max_output = 4096 -[models."venice-uncensored-role-play".features] +[providers.venice.models."venice-uncensored-role-play".features] tools = true vision = true reasoning = false -[models."venice-uncensored-role-play".costs] +[providers.venice.models."venice-uncensored-role-play".costs] input_cost_per_mtok = 0.5 output_cost_per_mtok = 2.0 diff --git a/lib/crates/fabro-model/src/catalog/providers/zai.toml b/lib/foundation/fabro-model/src/catalog/providers/zai.toml similarity index 70% rename from lib/crates/fabro-model/src/catalog/providers/zai.toml rename to lib/foundation/fabro-model/src/catalog/providers/zai.toml index e720df4da..c6d70cd64 100644 --- a/lib/crates/fabro-model/src/catalog/providers/zai.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/zai.toml @@ -8,50 +8,46 @@ priority = 60 [providers.zai.auth] credentials = ["env:ZAI_API_KEY", "vault:ZAI_API_KEY"] -[models."glm-5.2"] -provider = "zai" -api_id = "glm-5.2" +[providers.zai.models."glm-5.2"] display_name = "GLM 5.2" family = "glm-5" default = true aliases = ["glm", "glm5"] -[models."glm-5.2".limits] +[providers.zai.models."glm-5.2".limits] context_window = 1048576 max_output = 131072 -[models."glm-5.2".features] +[providers.zai.models."glm-5.2".features] tools = true vision = false reasoning = true reasoning_effort = "levels" prompt_cache = true -[models."glm-5.2".controls] +[providers.zai.models."glm-5.2".controls] reasoning_effort = ["high", "max"] -[models."glm-5.2".costs] +[providers.zai.models."glm-5.2".costs] input_cost_per_mtok = 1.4 output_cost_per_mtok = 4.4 cache_input_cost_per_mtok = 0.26 -[models."glm-4.7"] -provider = "zai" -api_id = "glm-4.7" +[providers.zai.models."glm-4.7"] display_name = "GLM 4.7" family = "glm-4" estimated_output_tps = 100 aliases = ["glm4"] -[models."glm-4.7".limits] +[providers.zai.models."glm-4.7".limits] context_window = 202752 max_output = 16384 -[models."glm-4.7".features] +[providers.zai.models."glm-4.7".features] tools = true vision = false reasoning = false -[models."glm-4.7".costs] +[providers.zai.models."glm-4.7".costs] input_cost_per_mtok = 0.6 output_cost_per_mtok = 2.2 diff --git a/lib/crates/fabro-model/src/codec.rs b/lib/foundation/fabro-model/src/codec.rs similarity index 100% rename from lib/crates/fabro-model/src/codec.rs rename to lib/foundation/fabro-model/src/codec.rs diff --git a/lib/crates/fabro-model/src/ids.rs b/lib/foundation/fabro-model/src/ids.rs similarity index 86% rename from lib/crates/fabro-model/src/ids.rs rename to lib/foundation/fabro-model/src/ids.rs index 18d1bdd86..02d6db0d7 100644 --- a/lib/crates/fabro-model/src/ids.rs +++ b/lib/foundation/fabro-model/src/ids.rs @@ -101,9 +101,12 @@ impl AsRef for ProviderId { } } -/// Stable model identifier — either the canonical catalog ID or one of its -/// declared aliases. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +/// Stable canonical, human-facing model identifier. +/// +/// Aliases are alternate selectors for a model offering; they are not model +/// IDs. The same `ModelId` may be offered by more than one provider, so a +/// concrete catalog offering is identified by `(ProviderId, ModelId)`. +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct ModelId(String); @@ -129,6 +132,12 @@ impl fmt::Display for ModelId { } } +impl fmt::Debug for ModelId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + impl From<&str> for ModelId { fn from(s: &str) -> Self { Self(s.to_string()) @@ -147,6 +156,18 @@ impl AsRef for ModelId { } } +impl PartialEq for ModelId { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for ModelId { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/foundation/fabro-model/src/lib.rs similarity index 93% rename from lib/crates/fabro-model/src/lib.rs rename to lib/foundation/fabro-model/src/lib.rs index 8dfd96f23..5f3511096 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/foundation/fabro-model/src/lib.rs @@ -19,7 +19,7 @@ pub use billing::{ }; pub use catalog::{ ApiKeyHeaderPolicy, BillingPolicy, Catalog, CredentialRef, CredentialRefParseError, - FallbackTarget, ProviderAuthConfig, + FallbackTarget, ModelSelectionError, ProviderAuthConfig, SelectedModel, }; pub use codec::CodecKind; pub use ids::{ModelId, ProviderId}; diff --git a/lib/crates/fabro-model/src/model_ref.rs b/lib/foundation/fabro-model/src/model_ref.rs similarity index 98% rename from lib/crates/fabro-model/src/model_ref.rs rename to lib/foundation/fabro-model/src/model_ref.rs index c9560a049..f8d1f0a8f 100644 --- a/lib/crates/fabro-model/src/model_ref.rs +++ b/lib/foundation/fabro-model/src/model_ref.rs @@ -22,7 +22,7 @@ impl ModelHandle { #[must_use] pub fn model_id(&self) -> &str { match self { - Self::Resolved(m) => &m.id, + Self::Resolved(m) => m.id.as_str(), Self::ByName { model, .. } => model, } } diff --git a/lib/crates/fabro-model/src/model_test.rs b/lib/foundation/fabro-model/src/model_test.rs similarity index 100% rename from lib/crates/fabro-model/src/model_test.rs rename to lib/foundation/fabro-model/src/model_test.rs diff --git a/lib/crates/fabro-model/src/provider.rs b/lib/foundation/fabro-model/src/provider.rs similarity index 100% rename from lib/crates/fabro-model/src/provider.rs rename to lib/foundation/fabro-model/src/provider.rs diff --git a/lib/crates/fabro-model/src/reasoning.rs b/lib/foundation/fabro-model/src/reasoning.rs similarity index 100% rename from lib/crates/fabro-model/src/reasoning.rs rename to lib/foundation/fabro-model/src/reasoning.rs diff --git a/lib/crates/fabro-model/src/types.rs b/lib/foundation/fabro-model/src/types.rs similarity index 86% rename from lib/crates/fabro-model/src/types.rs rename to lib/foundation/fabro-model/src/types.rs index 8dcbd3270..ae3804784 100644 --- a/lib/crates/fabro-model/src/types.rs +++ b/lib/foundation/fabro-model/src/types.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::ids::ProviderId; +use crate::ids::{ModelId, ProviderId}; // --- 2.9 Model --- @@ -41,21 +41,28 @@ fn default_true() -> bool { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ModelFeatures { - pub tools: bool, - pub vision: bool, - pub reasoning: bool, + pub tools: bool, + pub vision: bool, + pub reasoning: bool, /// Whether this model endpoint supports a native reasoning-effort /// parameter. User-facing allowed effort values live in catalog controls. #[serde(default)] - pub reasoning_effort: ReasoningEffortFeature, + pub reasoning_effort: ReasoningEffortFeature, /// Whether this model endpoint supports prompt caching annotations. #[serde(default)] - pub prompt_cache: bool, + pub prompt_cache: bool, + /// Whether the endpoint only caches when the request marks the cacheable + /// prefix with Anthropic-style `cache_control` breakpoints. Set on + /// OpenAI-compatible routes fronting Anthropic models (e.g. Claude via + /// OpenRouter); dialects whose caching mechanism is implied (native + /// Anthropic, Bedrock) ignore it. + #[serde(default)] + pub cache_control_breakpoints: bool, /// Whether the model endpoint accepts classic sampling parameters /// (`temperature`, `top_p`). Models with always-on adaptive behavior /// reject them. #[serde(default = "default_true")] - pub sampling_params: bool, + pub sampling_params: bool, } impl ModelFeatures { @@ -78,7 +85,7 @@ pub struct ModelCosts { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Model { - pub id: String, + pub id: ModelId, pub provider: ProviderId, pub family: String, pub display_name: String, @@ -102,7 +109,7 @@ pub struct Model { impl Model { pub fn id(&self) -> &str { - &self.id + self.id.as_str() } pub fn provider(&self) -> &ProviderId { @@ -210,7 +217,7 @@ mod tests { #[test] fn inherent_methods_return_correct_values() { let info = Model { - id: "model-id".to_string(), + id: ModelId::new("model-id"), provider: ProviderId::new("provider-id"), family: "family".to_string(), display_name: "Display Name".to_string(), @@ -221,12 +228,13 @@ mod tests { training: Some("training".to_string()), knowledge_cutoff: Some("knowledge-cutoff".to_string()), features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: true, - sampling_params: true, + tools: true, + vision: true, + reasoning: true, + reasoning_effort: ReasoningEffortFeature::Levels, + prompt_cache: true, + cache_control_breakpoints: false, + sampling_params: true, }, costs: ModelCosts { input_cost_per_mtok: Some(1.0), diff --git a/lib/crates/fabro-oauth/Cargo.toml b/lib/foundation/fabro-oauth/Cargo.toml similarity index 100% rename from lib/crates/fabro-oauth/Cargo.toml rename to lib/foundation/fabro-oauth/Cargo.toml diff --git a/lib/crates/fabro-oauth/examples/login.rs b/lib/foundation/fabro-oauth/examples/login.rs similarity index 100% rename from lib/crates/fabro-oauth/examples/login.rs rename to lib/foundation/fabro-oauth/examples/login.rs diff --git a/lib/crates/fabro-oauth/src/lib.rs b/lib/foundation/fabro-oauth/src/lib.rs similarity index 100% rename from lib/crates/fabro-oauth/src/lib.rs rename to lib/foundation/fabro-oauth/src/lib.rs diff --git a/lib/crates/fabro-options-metadata/Cargo.toml b/lib/foundation/fabro-options-metadata/Cargo.toml similarity index 100% rename from lib/crates/fabro-options-metadata/Cargo.toml rename to lib/foundation/fabro-options-metadata/Cargo.toml diff --git a/lib/crates/fabro-options-metadata/src/lib.rs b/lib/foundation/fabro-options-metadata/src/lib.rs similarity index 100% rename from lib/crates/fabro-options-metadata/src/lib.rs rename to lib/foundation/fabro-options-metadata/src/lib.rs diff --git a/lib/crates/fabro-proc/Cargo.toml b/lib/foundation/fabro-proc/Cargo.toml similarity index 100% rename from lib/crates/fabro-proc/Cargo.toml rename to lib/foundation/fabro-proc/Cargo.toml diff --git a/lib/crates/fabro-proc/build.rs b/lib/foundation/fabro-proc/build.rs similarity index 100% rename from lib/crates/fabro-proc/build.rs rename to lib/foundation/fabro-proc/build.rs diff --git a/lib/crates/fabro-proc/c/capture_argv.c b/lib/foundation/fabro-proc/c/capture_argv.c similarity index 100% rename from lib/crates/fabro-proc/c/capture_argv.c rename to lib/foundation/fabro-proc/c/capture_argv.c diff --git a/lib/crates/fabro-proc/src/flock.rs b/lib/foundation/fabro-proc/src/flock.rs similarity index 100% rename from lib/crates/fabro-proc/src/flock.rs rename to lib/foundation/fabro-proc/src/flock.rs diff --git a/lib/crates/fabro-proc/src/lib.rs b/lib/foundation/fabro-proc/src/lib.rs similarity index 100% rename from lib/crates/fabro-proc/src/lib.rs rename to lib/foundation/fabro-proc/src/lib.rs diff --git a/lib/crates/fabro-proc/src/pre_exec.rs b/lib/foundation/fabro-proc/src/pre_exec.rs similarity index 100% rename from lib/crates/fabro-proc/src/pre_exec.rs rename to lib/foundation/fabro-proc/src/pre_exec.rs diff --git a/lib/crates/fabro-proc/src/signal.rs b/lib/foundation/fabro-proc/src/signal.rs similarity index 100% rename from lib/crates/fabro-proc/src/signal.rs rename to lib/foundation/fabro-proc/src/signal.rs diff --git a/lib/crates/fabro-proc/src/title.rs b/lib/foundation/fabro-proc/src/title.rs similarity index 100% rename from lib/crates/fabro-proc/src/title.rs rename to lib/foundation/fabro-proc/src/title.rs diff --git a/lib/crates/fabro-redact/Cargo.toml b/lib/foundation/fabro-redact/Cargo.toml similarity index 100% rename from lib/crates/fabro-redact/Cargo.toml rename to lib/foundation/fabro-redact/Cargo.toml diff --git a/lib/crates/fabro-redact/build.rs b/lib/foundation/fabro-redact/build.rs similarity index 100% rename from lib/crates/fabro-redact/build.rs rename to lib/foundation/fabro-redact/build.rs diff --git a/lib/crates/fabro-redact/data/gitleaks.toml b/lib/foundation/fabro-redact/data/gitleaks.toml similarity index 100% rename from lib/crates/fabro-redact/data/gitleaks.toml rename to lib/foundation/fabro-redact/data/gitleaks.toml diff --git a/lib/crates/fabro-redact/src/entropy.rs b/lib/foundation/fabro-redact/src/entropy.rs similarity index 100% rename from lib/crates/fabro-redact/src/entropy.rs rename to lib/foundation/fabro-redact/src/entropy.rs diff --git a/lib/crates/fabro-redact/src/gitleaks.rs b/lib/foundation/fabro-redact/src/gitleaks.rs similarity index 100% rename from lib/crates/fabro-redact/src/gitleaks.rs rename to lib/foundation/fabro-redact/src/gitleaks.rs diff --git a/lib/crates/fabro-redact/src/jsonl.rs b/lib/foundation/fabro-redact/src/jsonl.rs similarity index 100% rename from lib/crates/fabro-redact/src/jsonl.rs rename to lib/foundation/fabro-redact/src/jsonl.rs diff --git a/lib/crates/fabro-redact/src/lib.rs b/lib/foundation/fabro-redact/src/lib.rs similarity index 100% rename from lib/crates/fabro-redact/src/lib.rs rename to lib/foundation/fabro-redact/src/lib.rs diff --git a/lib/crates/fabro-redact/src/safe_url.rs b/lib/foundation/fabro-redact/src/safe_url.rs similarity index 100% rename from lib/crates/fabro-redact/src/safe_url.rs rename to lib/foundation/fabro-redact/src/safe_url.rs diff --git a/lib/crates/fabro-static/Cargo.toml b/lib/foundation/fabro-static/Cargo.toml similarity index 100% rename from lib/crates/fabro-static/Cargo.toml rename to lib/foundation/fabro-static/Cargo.toml diff --git a/lib/crates/fabro-static/src/env_vars.rs b/lib/foundation/fabro-static/src/env_vars.rs similarity index 100% rename from lib/crates/fabro-static/src/env_vars.rs rename to lib/foundation/fabro-static/src/env_vars.rs diff --git a/lib/crates/fabro-static/src/lib.rs b/lib/foundation/fabro-static/src/lib.rs similarity index 100% rename from lib/crates/fabro-static/src/lib.rs rename to lib/foundation/fabro-static/src/lib.rs diff --git a/lib/crates/fabro-static/src/secret_registry.rs b/lib/foundation/fabro-static/src/secret_registry.rs similarity index 100% rename from lib/crates/fabro-static/src/secret_registry.rs rename to lib/foundation/fabro-static/src/secret_registry.rs diff --git a/lib/crates/fabro-telemetry/Cargo.toml b/lib/foundation/fabro-telemetry/Cargo.toml similarity index 100% rename from lib/crates/fabro-telemetry/Cargo.toml rename to lib/foundation/fabro-telemetry/Cargo.toml diff --git a/lib/crates/fabro-telemetry/src/anonymous_id.rs b/lib/foundation/fabro-telemetry/src/anonymous_id.rs similarity index 100% rename from lib/crates/fabro-telemetry/src/anonymous_id.rs rename to lib/foundation/fabro-telemetry/src/anonymous_id.rs diff --git a/lib/crates/fabro-telemetry/src/buffer.rs b/lib/foundation/fabro-telemetry/src/buffer.rs similarity index 100% rename from lib/crates/fabro-telemetry/src/buffer.rs rename to lib/foundation/fabro-telemetry/src/buffer.rs diff --git a/lib/crates/fabro-telemetry/src/context.rs b/lib/foundation/fabro-telemetry/src/context.rs similarity index 100% rename from lib/crates/fabro-telemetry/src/context.rs rename to lib/foundation/fabro-telemetry/src/context.rs diff --git a/lib/crates/fabro-telemetry/src/event.rs b/lib/foundation/fabro-telemetry/src/event.rs similarity index 100% rename from lib/crates/fabro-telemetry/src/event.rs rename to lib/foundation/fabro-telemetry/src/event.rs diff --git a/lib/crates/fabro-telemetry/src/git.rs b/lib/foundation/fabro-telemetry/src/git.rs similarity index 100% rename from lib/crates/fabro-telemetry/src/git.rs rename to lib/foundation/fabro-telemetry/src/git.rs diff --git a/lib/crates/fabro-telemetry/src/lib.rs b/lib/foundation/fabro-telemetry/src/lib.rs similarity index 100% rename from lib/crates/fabro-telemetry/src/lib.rs rename to lib/foundation/fabro-telemetry/src/lib.rs diff --git a/lib/crates/fabro-telemetry/src/panic.rs b/lib/foundation/fabro-telemetry/src/panic.rs similarity index 100% rename from lib/crates/fabro-telemetry/src/panic.rs rename to lib/foundation/fabro-telemetry/src/panic.rs diff --git a/lib/crates/fabro-telemetry/src/sanitize.rs b/lib/foundation/fabro-telemetry/src/sanitize.rs similarity index 100% rename from lib/crates/fabro-telemetry/src/sanitize.rs rename to lib/foundation/fabro-telemetry/src/sanitize.rs diff --git a/lib/crates/fabro-telemetry/src/sender.rs b/lib/foundation/fabro-telemetry/src/sender.rs similarity index 100% rename from lib/crates/fabro-telemetry/src/sender.rs rename to lib/foundation/fabro-telemetry/src/sender.rs diff --git a/lib/crates/fabro-telemetry/src/spawn.rs b/lib/foundation/fabro-telemetry/src/spawn.rs similarity index 100% rename from lib/crates/fabro-telemetry/src/spawn.rs rename to lib/foundation/fabro-telemetry/src/spawn.rs diff --git a/lib/crates/fabro-template/Cargo.toml b/lib/foundation/fabro-template/Cargo.toml similarity index 100% rename from lib/crates/fabro-template/Cargo.toml rename to lib/foundation/fabro-template/Cargo.toml diff --git a/lib/crates/fabro-template/src/dependency.rs b/lib/foundation/fabro-template/src/dependency.rs similarity index 100% rename from lib/crates/fabro-template/src/dependency.rs rename to lib/foundation/fabro-template/src/dependency.rs diff --git a/lib/crates/fabro-template/src/lib.rs b/lib/foundation/fabro-template/src/lib.rs similarity index 100% rename from lib/crates/fabro-template/src/lib.rs rename to lib/foundation/fabro-template/src/lib.rs diff --git a/lib/crates/fabro-template/src/store.rs b/lib/foundation/fabro-template/src/store.rs similarity index 100% rename from lib/crates/fabro-template/src/store.rs rename to lib/foundation/fabro-template/src/store.rs diff --git a/lib/crates/fabro-test/Cargo.toml b/lib/foundation/fabro-test/Cargo.toml similarity index 93% rename from lib/crates/fabro-test/Cargo.toml rename to lib/foundation/fabro-test/Cargo.toml index eebfcf09c..be07f0968 100644 --- a/lib/crates/fabro-test/Cargo.toml +++ b/lib/foundation/fabro-test/Cargo.toml @@ -17,7 +17,7 @@ anyhow.workspace = true assert_cmd = "2" axum = { workspace = true } fabro-config = { path = "../fabro-config" } -fabro-install = { path = "../fabro-install" } +fabro-install = { path = "../../components/fabro-install" } fabro-proc = { path = "../fabro-proc" } fabro-static.workspace = true fabro-types = { path = "../fabro-types" } diff --git a/lib/crates/fabro-test/src/http_assert.rs b/lib/foundation/fabro-test/src/http_assert.rs similarity index 100% rename from lib/crates/fabro-test/src/http_assert.rs rename to lib/foundation/fabro-test/src/http_assert.rs diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/foundation/fabro-test/src/lib.rs similarity index 99% rename from lib/crates/fabro-test/src/lib.rs rename to lib/foundation/fabro-test/src/lib.rs index bfa267320..74a5f6727 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/foundation/fabro-test/src/lib.rs @@ -85,6 +85,7 @@ const TEST_SESSION_SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; +const FABRO_TEST_ASSUME_LLM_READY: &str = "FABRO_TEST_ASSUME_LLM_READY"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TestMode { @@ -187,6 +188,7 @@ pub fn isolated_env(home_dir: &Path) -> HashMap { EnvVars::FABRO_TEST_IN_MEMORY_STORE.to_string(), "1".to_string(), ); + env.insert(FABRO_TEST_ASSUME_LLM_READY.to_string(), "1".to_string()); env } @@ -210,6 +212,7 @@ fn apply_test_isolation_with_lookup( .env(EnvVars::FABRO_SUPPRESS_OPEN_BROWSER, "1"); cmd.env(EnvVars::FABRO_SERVER_MAX_CONCURRENT_RUNS, "64"); cmd.env(EnvVars::FABRO_TEST_IN_MEMORY_STORE, "1"); + cmd.env(FABRO_TEST_ASSUME_LLM_READY, "1"); } /// Create a fresh tempdir containing an empty `storage/` subdirectory, for @@ -494,8 +497,15 @@ fn with_session_lock(root: &Path, f: impl FnOnce() -> T) -> T { // cleanup_session_root can remove_dir_all between the two calls. let deadline = std::time::Instant::now() + SESSION_LOCK_TIMEOUT; let lock_file = loop { - std::fs::create_dir_all(root) - .unwrap_or_else(|err| panic!("failed to create {}: {err}", root.display())); + if let Err(err) = std::fs::create_dir_all(root) { + assert!( + std::time::Instant::now() < deadline, + "failed to create {} after retries: {err}", + root.display() + ); + std::thread::sleep(Duration::from_millis(10)); + continue; + } ensure_parent_dir(&lock_path); match File::create(&lock_path) { Ok(f) => break f, diff --git a/lib/crates/fabro-types/Cargo.toml b/lib/foundation/fabro-types/Cargo.toml similarity index 100% rename from lib/crates/fabro-types/Cargo.toml rename to lib/foundation/fabro-types/Cargo.toml diff --git a/lib/crates/fabro-types/src/artifact.rs b/lib/foundation/fabro-types/src/artifact.rs similarity index 100% rename from lib/crates/fabro-types/src/artifact.rs rename to lib/foundation/fabro-types/src/artifact.rs diff --git a/lib/crates/fabro-types/src/auth.rs b/lib/foundation/fabro-types/src/auth.rs similarity index 100% rename from lib/crates/fabro-types/src/auth.rs rename to lib/foundation/fabro-types/src/auth.rs diff --git a/lib/crates/fabro-types/src/billing.rs b/lib/foundation/fabro-types/src/billing.rs similarity index 100% rename from lib/crates/fabro-types/src/billing.rs rename to lib/foundation/fabro-types/src/billing.rs diff --git a/lib/crates/fabro-types/src/blob_ref.rs b/lib/foundation/fabro-types/src/blob_ref.rs similarity index 100% rename from lib/crates/fabro-types/src/blob_ref.rs rename to lib/foundation/fabro-types/src/blob_ref.rs diff --git a/lib/crates/fabro-types/src/checkpoint.rs b/lib/foundation/fabro-types/src/checkpoint.rs similarity index 100% rename from lib/crates/fabro-types/src/checkpoint.rs rename to lib/foundation/fabro-types/src/checkpoint.rs diff --git a/lib/crates/fabro-types/src/command_output.rs b/lib/foundation/fabro-types/src/command_output.rs similarity index 100% rename from lib/crates/fabro-types/src/command_output.rs rename to lib/foundation/fabro-types/src/command_output.rs diff --git a/lib/crates/fabro-types/src/conclusion.rs b/lib/foundation/fabro-types/src/conclusion.rs similarity index 100% rename from lib/crates/fabro-types/src/conclusion.rs rename to lib/foundation/fabro-types/src/conclusion.rs diff --git a/lib/crates/fabro-types/src/dense.rs b/lib/foundation/fabro-types/src/dense.rs similarity index 100% rename from lib/crates/fabro-types/src/dense.rs rename to lib/foundation/fabro-types/src/dense.rs diff --git a/lib/crates/fabro-types/src/diff.rs b/lib/foundation/fabro-types/src/diff.rs similarity index 100% rename from lib/crates/fabro-types/src/diff.rs rename to lib/foundation/fabro-types/src/diff.rs diff --git a/lib/crates/fabro-types/src/event_envelope.rs b/lib/foundation/fabro-types/src/event_envelope.rs similarity index 100% rename from lib/crates/fabro-types/src/event_envelope.rs rename to lib/foundation/fabro-types/src/event_envelope.rs diff --git a/lib/crates/fabro-types/src/failure_signature.rs b/lib/foundation/fabro-types/src/failure_signature.rs similarity index 100% rename from lib/crates/fabro-types/src/failure_signature.rs rename to lib/foundation/fabro-types/src/failure_signature.rs diff --git a/lib/crates/fabro-types/src/graph.rs b/lib/foundation/fabro-types/src/graph.rs similarity index 100% rename from lib/crates/fabro-types/src/graph.rs rename to lib/foundation/fabro-types/src/graph.rs diff --git a/lib/crates/fabro-types/src/id.rs b/lib/foundation/fabro-types/src/id.rs similarity index 100% rename from lib/crates/fabro-types/src/id.rs rename to lib/foundation/fabro-types/src/id.rs diff --git a/lib/crates/fabro-types/src/interview.rs b/lib/foundation/fabro-types/src/interview.rs similarity index 100% rename from lib/crates/fabro-types/src/interview.rs rename to lib/foundation/fabro-types/src/interview.rs diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs similarity index 100% rename from lib/crates/fabro-types/src/lib.rs rename to lib/foundation/fabro-types/src/lib.rs diff --git a/lib/crates/fabro-types/src/llm_backend.rs b/lib/foundation/fabro-types/src/llm_backend.rs similarity index 100% rename from lib/crates/fabro-types/src/llm_backend.rs rename to lib/foundation/fabro-types/src/llm_backend.rs diff --git a/lib/crates/fabro-types/src/manifest_path.rs b/lib/foundation/fabro-types/src/manifest_path.rs similarity index 100% rename from lib/crates/fabro-types/src/manifest_path.rs rename to lib/foundation/fabro-types/src/manifest_path.rs diff --git a/lib/crates/fabro-types/src/mcp_store.rs b/lib/foundation/fabro-types/src/mcp_store.rs similarity index 100% rename from lib/crates/fabro-types/src/mcp_store.rs rename to lib/foundation/fabro-types/src/mcp_store.rs diff --git a/lib/crates/fabro-types/src/outcome.rs b/lib/foundation/fabro-types/src/outcome.rs similarity index 100% rename from lib/crates/fabro-types/src/outcome.rs rename to lib/foundation/fabro-types/src/outcome.rs diff --git a/lib/crates/fabro-types/src/pair.rs b/lib/foundation/fabro-types/src/pair.rs similarity index 100% rename from lib/crates/fabro-types/src/pair.rs rename to lib/foundation/fabro-types/src/pair.rs diff --git a/lib/crates/fabro-types/src/parallel.rs b/lib/foundation/fabro-types/src/parallel.rs similarity index 100% rename from lib/crates/fabro-types/src/parallel.rs rename to lib/foundation/fabro-types/src/parallel.rs diff --git a/lib/crates/fabro-types/src/principal.rs b/lib/foundation/fabro-types/src/principal.rs similarity index 100% rename from lib/crates/fabro-types/src/principal.rs rename to lib/foundation/fabro-types/src/principal.rs diff --git a/lib/crates/fabro-types/src/pull_request.rs b/lib/foundation/fabro-types/src/pull_request.rs similarity index 100% rename from lib/crates/fabro-types/src/pull_request.rs rename to lib/foundation/fabro-types/src/pull_request.rs diff --git a/lib/crates/fabro-types/src/repository.rs b/lib/foundation/fabro-types/src/repository.rs similarity index 100% rename from lib/crates/fabro-types/src/repository.rs rename to lib/foundation/fabro-types/src/repository.rs diff --git a/lib/crates/fabro-types/src/run.rs b/lib/foundation/fabro-types/src/run.rs similarity index 100% rename from lib/crates/fabro-types/src/run.rs rename to lib/foundation/fabro-types/src/run.rs diff --git a/lib/crates/fabro-types/src/run_blob_id.rs b/lib/foundation/fabro-types/src/run_blob_id.rs similarity index 100% rename from lib/crates/fabro-types/src/run_blob_id.rs rename to lib/foundation/fabro-types/src/run_blob_id.rs diff --git a/lib/crates/fabro-types/src/run_event/agent.rs b/lib/foundation/fabro-types/src/run_event/agent.rs similarity index 98% rename from lib/crates/fabro-types/src/run_event/agent.rs rename to lib/foundation/fabro-types/src/run_event/agent.rs index d61ccfb14..a38c07df2 100644 --- a/lib/crates/fabro-types/src/run_event/agent.rs +++ b/lib/foundation/fabro-types/src/run_event/agent.rs @@ -195,12 +195,6 @@ pub struct AgentLoopDetectedProps { pub visit: u32, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentTurnLimitReachedProps { - pub max_turns: usize, - pub visit: u32, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentSteeringInjectedProps { pub text: String, @@ -385,7 +379,7 @@ mod tests { fn sample_model_ref() -> ModelRef { ModelRef { provider: fabro_model::ProviderId::openai(), - model_id: "gpt-5".to_string(), + model_id: "gpt-5".into(), speed: None, } } diff --git a/lib/crates/fabro-types/src/run_event/infra.rs b/lib/foundation/fabro-types/src/run_event/infra.rs similarity index 100% rename from lib/crates/fabro-types/src/run_event/infra.rs rename to lib/foundation/fabro-types/src/run_event/infra.rs diff --git a/lib/crates/fabro-types/src/run_event/misc.rs b/lib/foundation/fabro-types/src/run_event/misc.rs similarity index 100% rename from lib/crates/fabro-types/src/run_event/misc.rs rename to lib/foundation/fabro-types/src/run_event/misc.rs diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs similarity index 99% rename from lib/crates/fabro-types/src/run_event/mod.rs rename to lib/foundation/fabro-types/src/run_event/mod.rs index 205b17d74..fae15a9b7 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -214,8 +214,6 @@ pub enum EventBody { AgentWarning(AgentWarningProps), #[serde(rename = "agent.loop.detected")] AgentLoopDetected(AgentLoopDetectedProps), - #[serde(rename = "agent.turn.limit")] - AgentTurnLimitReached(AgentTurnLimitReachedProps), #[serde(rename = "agent.steering.injected")] AgentSteeringInjected(AgentSteeringInjectedProps), #[serde(rename = "agent.pair.user_message")] @@ -490,7 +488,6 @@ impl EventBody { Self::AgentError(_) => "agent.error", Self::AgentWarning(_) => "agent.warning", Self::AgentLoopDetected(_) => "agent.loop.detected", - Self::AgentTurnLimitReached(_) => "agent.turn.limit", Self::AgentSteeringInjected(_) => "agent.steering.injected", Self::AgentPairUserMessage(_) => "agent.pair.user_message", Self::AgentPairSystemMessage(_) => "agent.pair.system_message", @@ -659,7 +656,6 @@ fn is_known_event_name(event: &str) -> bool { | "agent.error" | "agent.warning" | "agent.loop.detected" - | "agent.turn.limit" | "agent.steering.injected" | "agent.pair.user_message" | "agent.pair.system_message" @@ -2123,7 +2119,7 @@ mod tests { text: "ok".to_string(), model: crate::ModelRef { provider: fabro_model::ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: None, }, billing: BilledTokenCounts::default(), @@ -2176,7 +2172,7 @@ mod tests { text: "ok".to_string(), model: crate::ModelRef { provider: fabro_model::ProviderId::openai(), - model_id: "gpt-5.4".to_string(), + model_id: "gpt-5.4".into(), speed: None, }, billing: BilledTokenCounts::default(), diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/foundation/fabro-types/src/run_event/run.rs similarity index 100% rename from lib/crates/fabro-types/src/run_event/run.rs rename to lib/foundation/fabro-types/src/run_event/run.rs diff --git a/lib/crates/fabro-types/src/run_event/session.rs b/lib/foundation/fabro-types/src/run_event/session.rs similarity index 80% rename from lib/crates/fabro-types/src/run_event/session.rs rename to lib/foundation/fabro-types/src/run_event/session.rs index a44c04003..78321afa4 100644 --- a/lib/crates/fabro-types/src/run_event/session.rs +++ b/lib/foundation/fabro-types/src/run_event/session.rs @@ -1,3 +1,4 @@ +use fabro_model::ProviderId; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -6,9 +7,11 @@ use crate::TurnId; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunSessionCreatedProps { #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, + pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -107,3 +110,22 @@ pub struct RunSessionTurnInterruptedProps { #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::RunSessionCreatedProps; + + #[test] + fn session_created_deserializes_legacy_payload_without_provider() { + let props: RunSessionCreatedProps = serde_json::from_value(json!({ + "title": "Legacy session", + "model": "gpt-5.4" + })) + .unwrap(); + + assert_eq!(props.model.as_deref(), Some("gpt-5.4")); + assert_eq!(props.provider, None); + } +} diff --git a/lib/crates/fabro-types/src/run_event/stage.rs b/lib/foundation/fabro-types/src/run_event/stage.rs similarity index 100% rename from lib/crates/fabro-types/src/run_event/stage.rs rename to lib/foundation/fabro-types/src/run_event/stage.rs diff --git a/lib/crates/fabro-types/src/run_event/todo.rs b/lib/foundation/fabro-types/src/run_event/todo.rs similarity index 100% rename from lib/crates/fabro-types/src/run_event/todo.rs rename to lib/foundation/fabro-types/src/run_event/todo.rs diff --git a/lib/crates/fabro-types/src/run_failure.rs b/lib/foundation/fabro-types/src/run_failure.rs similarity index 100% rename from lib/crates/fabro-types/src/run_failure.rs rename to lib/foundation/fabro-types/src/run_failure.rs diff --git a/lib/crates/fabro-types/src/run_id.rs b/lib/foundation/fabro-types/src/run_id.rs similarity index 100% rename from lib/crates/fabro-types/src/run_id.rs rename to lib/foundation/fabro-types/src/run_id.rs diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/foundation/fabro-types/src/run_projection.rs similarity index 98% rename from lib/crates/fabro-types/src/run_projection.rs rename to lib/foundation/fabro-types/src/run_projection.rs index f251d6182..b7aaba516 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/foundation/fabro-types/src/run_projection.rs @@ -350,8 +350,13 @@ pub struct StageProjection { pub usage: BilledTokenCounts, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub todos: Option, + /// Todo/task list owned by the stage's root agent session. + /// + /// OpenAI child sessions own separate per-session plans and do not appear + /// here. Anthropic task lists are root-scoped and shared with child + /// sessions, so child mutations of that shared list do appear here. + #[serde(default, rename = "todos", skip_serializing_if = "Option::is_none")] + pub root_agent_todos: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub subagents: Vec, #[serde(default, skip_serializing_if = "SkillsProjection::is_empty")] @@ -438,7 +443,7 @@ impl StageProjection { timing: None, usage: BilledTokenCounts::default(), model: None, - todos: None, + root_agent_todos: None, subagents: Vec::new(), skills: SkillsProjection::default(), permission_level: None, diff --git a/lib/crates/fabro-types/src/run_sandbox.rs b/lib/foundation/fabro-types/src/run_sandbox.rs similarity index 100% rename from lib/crates/fabro-types/src/run_sandbox.rs rename to lib/foundation/fabro-types/src/run_sandbox.rs diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/foundation/fabro-types/src/run_summary.rs similarity index 100% rename from lib/crates/fabro-types/src/run_summary.rs rename to lib/foundation/fabro-types/src/run_summary.rs diff --git a/lib/crates/fabro-types/src/run_title.rs b/lib/foundation/fabro-types/src/run_title.rs similarity index 100% rename from lib/crates/fabro-types/src/run_title.rs rename to lib/foundation/fabro-types/src/run_title.rs diff --git a/lib/crates/fabro-types/src/sandbox_details.rs b/lib/foundation/fabro-types/src/sandbox_details.rs similarity index 100% rename from lib/crates/fabro-types/src/sandbox_details.rs rename to lib/foundation/fabro-types/src/sandbox_details.rs diff --git a/lib/crates/fabro-types/src/sandbox_inventory.rs b/lib/foundation/fabro-types/src/sandbox_inventory.rs similarity index 100% rename from lib/crates/fabro-types/src/sandbox_inventory.rs rename to lib/foundation/fabro-types/src/sandbox_inventory.rs diff --git a/lib/crates/fabro-types/src/sandbox_provider.rs b/lib/foundation/fabro-types/src/sandbox_provider.rs similarity index 100% rename from lib/crates/fabro-types/src/sandbox_provider.rs rename to lib/foundation/fabro-types/src/sandbox_provider.rs diff --git a/lib/crates/fabro-types/src/sandbox_services.rs b/lib/foundation/fabro-types/src/sandbox_services.rs similarity index 100% rename from lib/crates/fabro-types/src/sandbox_services.rs rename to lib/foundation/fabro-types/src/sandbox_services.rs diff --git a/lib/crates/fabro-types/src/secret.rs b/lib/foundation/fabro-types/src/secret.rs similarity index 100% rename from lib/crates/fabro-types/src/secret.rs rename to lib/foundation/fabro-types/src/secret.rs diff --git a/lib/crates/fabro-types/src/session.rs b/lib/foundation/fabro-types/src/session.rs similarity index 84% rename from lib/crates/fabro-types/src/session.rs rename to lib/foundation/fabro-types/src/session.rs index 4e704e239..b825338d9 100644 --- a/lib/crates/fabro-types/src/session.rs +++ b/lib/foundation/fabro-types/src/session.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use fabro_model::ProviderId; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; @@ -63,6 +64,8 @@ pub struct SessionRecord { pub status: SessionStatus, pub model: Option, #[serde(default)] + pub provider: Option, + #[serde(default)] pub active_turn: Option, pub created_at: DateTime, pub updated_at: DateTime, @@ -76,6 +79,7 @@ impl SessionRecord { title: None, status: SessionStatus::Idle, model: None, + provider: None, active_turn: None, created_at: now, updated_at: now, @@ -91,6 +95,8 @@ pub struct SessionSummary { pub status: SessionStatus, pub model: Option, #[serde(default)] + pub provider: Option, + #[serde(default)] pub active_turn: Option, pub created_at: DateTime, pub updated_at: DateTime, @@ -104,6 +110,7 @@ impl From<&SessionRecord> for SessionSummary { title: record.title.clone(), status: record.status, model: record.model.clone(), + provider: record.provider.clone(), active_turn: record.active_turn.clone(), created_at: record.created_at, updated_at: record.updated_at, @@ -181,13 +188,33 @@ impl SessionMessage { #[cfg(test)] mod tests { + use chrono::Utc; use serde_json::json; - use super::SessionStatus; + use super::{SessionId, SessionRecord, SessionStatus}; + use crate::fixtures; #[test] fn session_status_rejects_removed_terminal_states() { assert!(serde_json::from_value::(json!("closed")).is_err()); assert!(serde_json::from_value::(json!("deleted")).is_err()); } + + #[test] + fn session_record_deserializes_legacy_json_without_provider() { + let mut value = serde_json::to_value(SessionRecord::new( + SessionId::new(), + fixtures::RUN_1, + Utc::now(), + )) + .unwrap(); + value + .as_object_mut() + .expect("session record should serialize as an object") + .remove("provider"); + + let record: SessionRecord = serde_json::from_value(value).unwrap(); + + assert_eq!(record.provider, None); + } } diff --git a/lib/crates/fabro-types/src/settings/cli.rs b/lib/foundation/fabro-types/src/settings/cli.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/cli.rs rename to lib/foundation/fabro-types/src/settings/cli.rs diff --git a/lib/crates/fabro-types/src/settings/duration.rs b/lib/foundation/fabro-types/src/settings/duration.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/duration.rs rename to lib/foundation/fabro-types/src/settings/duration.rs diff --git a/lib/crates/fabro-types/src/settings/interp.rs b/lib/foundation/fabro-types/src/settings/interp.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/interp.rs rename to lib/foundation/fabro-types/src/settings/interp.rs diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/foundation/fabro-types/src/settings/mod.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/mod.rs rename to lib/foundation/fabro-types/src/settings/mod.rs diff --git a/lib/crates/fabro-types/src/settings/model_ref.rs b/lib/foundation/fabro-types/src/settings/model_ref.rs similarity index 93% rename from lib/crates/fabro-types/src/settings/model_ref.rs rename to lib/foundation/fabro-types/src/settings/model_ref.rs index ee912ff51..eb12263da 100644 --- a/lib/crates/fabro-types/src/settings/model_ref.rs +++ b/lib/foundation/fabro-types/src/settings/model_ref.rs @@ -142,9 +142,6 @@ pub enum ResolvedModelRef { pub trait ModelRegistry { fn is_provider(&self, token: &str) -> bool; fn is_model(&self, token: &str) -> bool; - /// Returns the canonical provider for a bare model token, when the registry - /// knows of a unique provider for that model. - fn provider_of(&self, token: &str) -> Option; } impl ModelRef { @@ -169,17 +166,13 @@ impl ModelRef { let is_model = registry.is_model(token); match (is_provider, is_model) { (true, false) => Ok(ResolvedModelRef::Provider(token.clone())), - (false, true) => Ok(ResolvedModelRef::Model { - provider: registry.provider_of(token), - model: token.clone(), - }), (true, true) => Err(AmbiguousModelRef { input: token.clone(), providers: vec![token.clone()], models: vec![token.clone()], }), - // Unknown tokens flow through as bare models — provider TBD at runtime. - (false, false) => Ok(ResolvedModelRef::Model { + // Known and unknown bare models leave provider selection to the runtime. + (false, _) => Ok(ResolvedModelRef::Model { provider: None, model: token.clone(), }), @@ -237,13 +230,6 @@ mod tests { fn is_model(&self, token: &str) -> bool { self.models.contains(&token) } - fn provider_of(&self, token: &str) -> Option { - if self.models.contains(&token) { - Some("test".to_owned()) - } else { - None - } - } } #[test] @@ -309,7 +295,7 @@ mod tests { }; let resolved = ModelRef::Bare("gpt-5.4".into()).resolve(®).unwrap(); assert_eq!(resolved, ResolvedModelRef::Model { - provider: Some("test".into()), + provider: None, model: "gpt-5.4".into(), }); } diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/foundation/fabro-types/src/settings/project.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/project.rs rename to lib/foundation/fabro-types/src/settings/project.rs diff --git a/lib/crates/fabro-types/src/settings/public_url.rs b/lib/foundation/fabro-types/src/settings/public_url.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/public_url.rs rename to lib/foundation/fabro-types/src/settings/public_url.rs diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/foundation/fabro-types/src/settings/run.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/run.rs rename to lib/foundation/fabro-types/src/settings/run.rs diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/foundation/fabro-types/src/settings/server.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/server.rs rename to lib/foundation/fabro-types/src/settings/server.rs diff --git a/lib/crates/fabro-types/src/settings/size.rs b/lib/foundation/fabro-types/src/settings/size.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/size.rs rename to lib/foundation/fabro-types/src/settings/size.rs diff --git a/lib/crates/fabro-types/src/settings/workflow.rs b/lib/foundation/fabro-types/src/settings/workflow.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/workflow.rs rename to lib/foundation/fabro-types/src/settings/workflow.rs diff --git a/lib/crates/fabro-types/src/stage_completion.rs b/lib/foundation/fabro-types/src/stage_completion.rs similarity index 100% rename from lib/crates/fabro-types/src/stage_completion.rs rename to lib/foundation/fabro-types/src/stage_completion.rs diff --git a/lib/crates/fabro-types/src/stage_handler.rs b/lib/foundation/fabro-types/src/stage_handler.rs similarity index 100% rename from lib/crates/fabro-types/src/stage_handler.rs rename to lib/foundation/fabro-types/src/stage_handler.rs diff --git a/lib/crates/fabro-types/src/stage_id.rs b/lib/foundation/fabro-types/src/stage_id.rs similarity index 100% rename from lib/crates/fabro-types/src/stage_id.rs rename to lib/foundation/fabro-types/src/stage_id.rs diff --git a/lib/crates/fabro-types/src/start.rs b/lib/foundation/fabro-types/src/start.rs similarity index 100% rename from lib/crates/fabro-types/src/start.rs rename to lib/foundation/fabro-types/src/start.rs diff --git a/lib/crates/fabro-types/src/status.rs b/lib/foundation/fabro-types/src/status.rs similarity index 100% rename from lib/crates/fabro-types/src/status.rs rename to lib/foundation/fabro-types/src/status.rs diff --git a/lib/crates/fabro-types/src/steering.rs b/lib/foundation/fabro-types/src/steering.rs similarity index 100% rename from lib/crates/fabro-types/src/steering.rs rename to lib/foundation/fabro-types/src/steering.rs diff --git a/lib/crates/fabro-types/src/system_integrations.rs b/lib/foundation/fabro-types/src/system_integrations.rs similarity index 100% rename from lib/crates/fabro-types/src/system_integrations.rs rename to lib/foundation/fabro-types/src/system_integrations.rs diff --git a/lib/crates/fabro-types/src/test_support.rs b/lib/foundation/fabro-types/src/test_support.rs similarity index 100% rename from lib/crates/fabro-types/src/test_support.rs rename to lib/foundation/fabro-types/src/test_support.rs diff --git a/lib/crates/fabro-types/src/timing.rs b/lib/foundation/fabro-types/src/timing.rs similarity index 100% rename from lib/crates/fabro-types/src/timing.rs rename to lib/foundation/fabro-types/src/timing.rs diff --git a/lib/crates/fabro-types/src/todo.rs b/lib/foundation/fabro-types/src/todo.rs similarity index 100% rename from lib/crates/fabro-types/src/todo.rs rename to lib/foundation/fabro-types/src/todo.rs diff --git a/lib/crates/fabro-types/src/transcript.rs b/lib/foundation/fabro-types/src/transcript.rs similarity index 100% rename from lib/crates/fabro-types/src/transcript.rs rename to lib/foundation/fabro-types/src/transcript.rs diff --git a/lib/crates/fabro-types/src/variable.rs b/lib/foundation/fabro-types/src/variable.rs similarity index 100% rename from lib/crates/fabro-types/src/variable.rs rename to lib/foundation/fabro-types/src/variable.rs diff --git a/lib/crates/fabro-types/tests/run_event_serde.rs b/lib/foundation/fabro-types/tests/run_event_serde.rs similarity index 100% rename from lib/crates/fabro-types/tests/run_event_serde.rs rename to lib/foundation/fabro-types/tests/run_event_serde.rs diff --git a/lib/crates/fabro-types/tests/run_failure_serde.rs b/lib/foundation/fabro-types/tests/run_failure_serde.rs similarity index 100% rename from lib/crates/fabro-types/tests/run_failure_serde.rs rename to lib/foundation/fabro-types/tests/run_failure_serde.rs diff --git a/lib/crates/fabro-types/tests/run_spec_methods.rs b/lib/foundation/fabro-types/tests/run_spec_methods.rs similarity index 100% rename from lib/crates/fabro-types/tests/run_spec_methods.rs rename to lib/foundation/fabro-types/tests/run_spec_methods.rs diff --git a/lib/crates/fabro-types/tests/run_spec_serde.rs b/lib/foundation/fabro-types/tests/run_spec_serde.rs similarity index 100% rename from lib/crates/fabro-types/tests/run_spec_serde.rs rename to lib/foundation/fabro-types/tests/run_spec_serde.rs diff --git a/lib/crates/fabro-types/tests/sandbox_inventory_serde.rs b/lib/foundation/fabro-types/tests/sandbox_inventory_serde.rs similarity index 100% rename from lib/crates/fabro-types/tests/sandbox_inventory_serde.rs rename to lib/foundation/fabro-types/tests/sandbox_inventory_serde.rs diff --git a/lib/crates/fabro-types/tests/sandbox_model_serde.rs b/lib/foundation/fabro-types/tests/sandbox_model_serde.rs similarity index 100% rename from lib/crates/fabro-types/tests/sandbox_model_serde.rs rename to lib/foundation/fabro-types/tests/sandbox_model_serde.rs diff --git a/lib/crates/fabro-types/tests/stage_handler.rs b/lib/foundation/fabro-types/tests/stage_handler.rs similarity index 100% rename from lib/crates/fabro-types/tests/stage_handler.rs rename to lib/foundation/fabro-types/tests/stage_handler.rs diff --git a/lib/crates/fabro-util/Cargo.toml b/lib/foundation/fabro-util/Cargo.toml similarity index 100% rename from lib/crates/fabro-util/Cargo.toml rename to lib/foundation/fabro-util/Cargo.toml diff --git a/lib/crates/fabro-util/src/backoff.rs b/lib/foundation/fabro-util/src/backoff.rs similarity index 100% rename from lib/crates/fabro-util/src/backoff.rs rename to lib/foundation/fabro-util/src/backoff.rs diff --git a/lib/crates/fabro-util/src/browser.rs b/lib/foundation/fabro-util/src/browser.rs similarity index 100% rename from lib/crates/fabro-util/src/browser.rs rename to lib/foundation/fabro-util/src/browser.rs diff --git a/lib/crates/fabro-util/src/check_report.rs b/lib/foundation/fabro-util/src/check_report.rs similarity index 100% rename from lib/crates/fabro-util/src/check_report.rs rename to lib/foundation/fabro-util/src/check_report.rs diff --git a/lib/crates/fabro-util/src/dev_token.rs b/lib/foundation/fabro-util/src/dev_token.rs similarity index 100% rename from lib/crates/fabro-util/src/dev_token.rs rename to lib/foundation/fabro-util/src/dev_token.rs diff --git a/lib/crates/fabro-util/src/env.rs b/lib/foundation/fabro-util/src/env.rs similarity index 100% rename from lib/crates/fabro-util/src/env.rs rename to lib/foundation/fabro-util/src/env.rs diff --git a/lib/crates/fabro-util/src/error.rs b/lib/foundation/fabro-util/src/error.rs similarity index 100% rename from lib/crates/fabro-util/src/error.rs rename to lib/foundation/fabro-util/src/error.rs diff --git a/lib/crates/fabro-util/src/exit.rs b/lib/foundation/fabro-util/src/exit.rs similarity index 100% rename from lib/crates/fabro-util/src/exit.rs rename to lib/foundation/fabro-util/src/exit.rs diff --git a/lib/crates/fabro-util/src/home.rs b/lib/foundation/fabro-util/src/home.rs similarity index 100% rename from lib/crates/fabro-util/src/home.rs rename to lib/foundation/fabro-util/src/home.rs diff --git a/lib/crates/fabro-util/src/json.rs b/lib/foundation/fabro-util/src/json.rs similarity index 100% rename from lib/crates/fabro-util/src/json.rs rename to lib/foundation/fabro-util/src/json.rs diff --git a/lib/crates/fabro-util/src/lib.rs b/lib/foundation/fabro-util/src/lib.rs similarity index 100% rename from lib/crates/fabro-util/src/lib.rs rename to lib/foundation/fabro-util/src/lib.rs diff --git a/lib/crates/fabro-util/src/path.rs b/lib/foundation/fabro-util/src/path.rs similarity index 100% rename from lib/crates/fabro-util/src/path.rs rename to lib/foundation/fabro-util/src/path.rs diff --git a/lib/crates/fabro-util/src/printer.rs b/lib/foundation/fabro-util/src/printer.rs similarity index 100% rename from lib/crates/fabro-util/src/printer.rs rename to lib/foundation/fabro-util/src/printer.rs diff --git a/lib/crates/fabro-util/src/run_log.rs b/lib/foundation/fabro-util/src/run_log.rs similarity index 100% rename from lib/crates/fabro-util/src/run_log.rs rename to lib/foundation/fabro-util/src/run_log.rs diff --git a/lib/crates/fabro-util/src/session_secret.rs b/lib/foundation/fabro-util/src/session_secret.rs similarity index 100% rename from lib/crates/fabro-util/src/session_secret.rs rename to lib/foundation/fabro-util/src/session_secret.rs diff --git a/lib/crates/fabro-util/src/shell.rs b/lib/foundation/fabro-util/src/shell.rs similarity index 100% rename from lib/crates/fabro-util/src/shell.rs rename to lib/foundation/fabro-util/src/shell.rs diff --git a/lib/crates/fabro-util/src/terminal.rs b/lib/foundation/fabro-util/src/terminal.rs similarity index 100% rename from lib/crates/fabro-util/src/terminal.rs rename to lib/foundation/fabro-util/src/terminal.rs diff --git a/lib/crates/fabro-util/src/text.rs b/lib/foundation/fabro-util/src/text.rs similarity index 100% rename from lib/crates/fabro-util/src/text.rs rename to lib/foundation/fabro-util/src/text.rs diff --git a/lib/crates/fabro-util/src/time.rs b/lib/foundation/fabro-util/src/time.rs similarity index 100% rename from lib/crates/fabro-util/src/time.rs rename to lib/foundation/fabro-util/src/time.rs diff --git a/lib/crates/fabro-util/src/version.rs b/lib/foundation/fabro-util/src/version.rs similarity index 100% rename from lib/crates/fabro-util/src/version.rs rename to lib/foundation/fabro-util/src/version.rs diff --git a/lib/crates/fabro-util/src/warnings.rs b/lib/foundation/fabro-util/src/warnings.rs similarity index 100% rename from lib/crates/fabro-util/src/warnings.rs rename to lib/foundation/fabro-util/src/warnings.rs diff --git a/lib/crates/fabro-util/tests/dev_token.rs b/lib/foundation/fabro-util/tests/dev_token.rs similarity index 100% rename from lib/crates/fabro-util/tests/dev_token.rs rename to lib/foundation/fabro-util/tests/dev_token.rs diff --git a/lib/crates/fabro-util/tests/error_chain.rs b/lib/foundation/fabro-util/tests/error_chain.rs similarity index 100% rename from lib/crates/fabro-util/tests/error_chain.rs rename to lib/foundation/fabro-util/tests/error_chain.rs diff --git a/lib/crates/fabro-vault/Cargo.toml b/lib/foundation/fabro-vault/Cargo.toml similarity index 100% rename from lib/crates/fabro-vault/Cargo.toml rename to lib/foundation/fabro-vault/Cargo.toml diff --git a/lib/crates/fabro-vault/src/lib.rs b/lib/foundation/fabro-vault/src/lib.rs similarity index 100% rename from lib/crates/fabro-vault/src/lib.rs rename to lib/foundation/fabro-vault/src/lib.rs diff --git a/lib/crates/fabro-vault/src/store.rs b/lib/foundation/fabro-vault/src/store.rs similarity index 100% rename from lib/crates/fabro-vault/src/store.rs rename to lib/foundation/fabro-vault/src/store.rs diff --git a/lib/crates/fabro-vault/tests/store.rs b/lib/foundation/fabro-vault/tests/store.rs similarity index 100% rename from lib/crates/fabro-vault/tests/store.rs rename to lib/foundation/fabro-vault/tests/store.rs diff --git a/lib/packages/fabro-api-client/src/api/models-api.ts b/lib/packages/fabro-api-client/src/api/models-api.ts index 783f597ad..1f34aeb76 100644 --- a/lib/packages/fabro-api-client/src/api/models-api.ts +++ b/lib/packages/fabro-api-client/src/api/models-api.ts @@ -43,7 +43,7 @@ import type { ProviderTestList } from '../models'; export const ModelsApiAxiosParamCreator = function (configuration?: Configuration) { return { /** - * Returns a paginated list of available LLM models from the built-in catalog. + * Returns one row per provider/model offering from the catalog. Model IDs are unique within a provider; `(provider, id)` is the resource identity. * @summary List Models * @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set. * @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`. @@ -137,12 +137,13 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio /** * Tests a model by sending a simple prompt and reporting pass/fail. * @summary Test Model - * @param {string} id The model identifier. + * @param {string} id The canonical model ID or an alias. + * @param {string} [provider] Pin the test to this provider\'s offering. When omitted, the server selects among ready providers by catalog priority. * @param {ModelTestMode} [mode] Test mode for the single-model test endpoint. Defaults to `basic`. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - testModel: async (id: string, mode?: ModelTestMode, options: RawAxiosRequestConfig = {}): Promise => { + testModel: async (id: string, provider?: string, mode?: ModelTestMode, options: RawAxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('testModel', 'id', id) const localVarPath = `/api/v1/models/{id}/test` @@ -164,6 +165,10 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (provider !== undefined) { + localVarQueryParameter['provider'] = provider; + } + if (mode !== undefined) { localVarQueryParameter['mode'] = mode; } @@ -270,7 +275,7 @@ export const ModelsApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = ModelsApiAxiosParamCreator(configuration) return { /** - * Returns a paginated list of available LLM models from the built-in catalog. + * Returns one row per provider/model offering from the catalog. Model IDs are unique within a provider; `(provider, id)` is the resource identity. * @summary List Models * @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set. * @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`. @@ -300,13 +305,14 @@ export const ModelsApiFp = function(configuration?: Configuration) { /** * Tests a model by sending a simple prompt and reporting pass/fail. * @summary Test Model - * @param {string} id The model identifier. + * @param {string} id The canonical model ID or an alias. + * @param {string} [provider] Pin the test to this provider\'s offering. When omitted, the server selects among ready providers by catalog priority. * @param {ModelTestMode} [mode] Test mode for the single-model test endpoint. Defaults to `basic`. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async testModel(id: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.testModel(id, mode, options); + async testModel(id: string, provider?: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.testModel(id, provider, mode, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['ModelsApi.testModel']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -347,7 +353,7 @@ export const ModelsApiFactory = function (configuration?: Configuration, basePat const localVarFp = ModelsApiFp(configuration) return { /** - * Returns a paginated list of available LLM models from the built-in catalog. + * Returns one row per provider/model offering from the catalog. Model IDs are unique within a provider; `(provider, id)` is the resource identity. * @summary List Models * @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set. * @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`. @@ -371,13 +377,25 @@ export const ModelsApiFactory = function (configuration?: Configuration, basePat /** * Tests a model by sending a simple prompt and reporting pass/fail. * @summary Test Model - * @param {string} id The model identifier. + * @param {string} id The canonical model ID or an alias. + * @param {string} [provider] Pin the test to this provider\'s offering. When omitted, the server selects among ready providers by catalog priority. * @param {ModelTestMode} [mode] Test mode for the single-model test endpoint. Defaults to `basic`. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - testModel(id: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.testModel(id, mode, options).then((request) => request(axios, basePath)); + testModel(id: string, provider?: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.testModel(id, provider, mode, options).then((request) => request(axios, basePath)); + }, + /** + * Validates an LLM provider API key against the server\'s effective catalog without persisting it. + * @summary Test Provider Credentials + * @param {string} provider The provider identifier. + * @param {ProviderCredentialTestRequest} providerCredentialTestRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + testProviderCredentials(provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.testProviderCredentials(provider, providerCredentialTestRequest, options).then((request) => request(axios, basePath)); }, /** * Validates an LLM provider API key against the server\'s effective catalog without persisting it. @@ -407,7 +425,7 @@ export const ModelsApiFactory = function (configuration?: Configuration, basePat */ export class ModelsApi extends BaseAPI { /** - * Returns a paginated list of available LLM models from the built-in catalog. + * Returns one row per provider/model offering from the catalog. Model IDs are unique within a provider; `(provider, id)` is the resource identity. * @summary List Models * @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set. * @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`. @@ -433,13 +451,26 @@ export class ModelsApi extends BaseAPI { /** * Tests a model by sending a simple prompt and reporting pass/fail. * @summary Test Model - * @param {string} id The model identifier. + * @param {string} id The canonical model ID or an alias. + * @param {string} [provider] Pin the test to this provider\'s offering. When omitted, the server selects among ready providers by catalog priority. * @param {ModelTestMode} [mode] Test mode for the single-model test endpoint. Defaults to `basic`. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public testModel(id: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig) { - return ModelsApiFp(this.configuration).testModel(id, mode, options).then((request) => request(this.axios, this.basePath)); + public testModel(id: string, provider?: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig) { + return ModelsApiFp(this.configuration).testModel(id, provider, mode, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Validates an LLM provider API key against the server\'s effective catalog without persisting it. + * @summary Test Provider Credentials + * @param {string} provider The provider identifier. + * @param {ProviderCredentialTestRequest} providerCredentialTestRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public testProviderCredentials(provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options?: RawAxiosRequestConfig) { + return ModelsApiFp(this.configuration).testProviderCredentials(provider, providerCredentialTestRequest, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/lib/packages/fabro-api-client/src/models/completion-response.ts b/lib/packages/fabro-api-client/src/models/completion-response.ts index 02c8f4b6d..5b3d34e4b 100644 --- a/lib/packages/fabro-api-client/src/models/completion-response.ts +++ b/lib/packages/fabro-api-client/src/models/completion-response.ts @@ -25,7 +25,14 @@ import type { CostSource } from './cost-source'; export interface CompletionResponse { 'id': string; + /** + * Canonical model ID selected for the request. + */ 'model': string; + /** + * LLM provider identifier. + */ + 'provider': string; 'message': CompletionMessage; /** * Why generation stopped (end_turn, max_tokens, tool_calls). diff --git a/lib/packages/fabro-api-client/src/models/create-completion-request.ts b/lib/packages/fabro-api-client/src/models/create-completion-request.ts index 2722bc9b9..923431e9b 100644 --- a/lib/packages/fabro-api-client/src/models/create-completion-request.ts +++ b/lib/packages/fabro-api-client/src/models/create-completion-request.ts @@ -29,7 +29,7 @@ export interface CreateCompletionRequest { */ 'messages': Array; /** - * Model ID or alias. Server picks default if omitted. + * Model ID or alias. Server picks a ready-provider default if omitted. */ 'model'?: string; /** @@ -58,7 +58,7 @@ export interface CreateCompletionRequest { */ 'reasoning_effort'?: string; /** - * Provider to route to. + * Optional provider pin. */ 'provider'?: string; 'provider_options'?: any; diff --git a/lib/packages/fabro-api-client/src/models/create-playground-chat-request.ts b/lib/packages/fabro-api-client/src/models/create-playground-chat-request.ts index d0a12f33c..1d1ddc216 100644 --- a/lib/packages/fabro-api-client/src/models/create-playground-chat-request.ts +++ b/lib/packages/fabro-api-client/src/models/create-playground-chat-request.ts @@ -30,7 +30,11 @@ export interface CreatePlaygroundChatRequest { */ 'workflow_fabro': string; /** - * Model id or alias. Server picks the default if omitted. + * Model ID or alias. Server picks a ready-provider default if omitted. */ 'model'?: string; + /** + * Optional provider pin. + */ + 'provider'?: string; } diff --git a/lib/packages/fabro-api-client/src/models/create-run-session-request.ts b/lib/packages/fabro-api-client/src/models/create-run-session-request.ts index 6b11c4b39..bea185e6d 100644 --- a/lib/packages/fabro-api-client/src/models/create-run-session-request.ts +++ b/lib/packages/fabro-api-client/src/models/create-run-session-request.ts @@ -17,7 +17,11 @@ export interface CreateRunSessionRequest { 'title'?: string; /** - * Catalog model ID or alias, or provider-qualified provider/model reference. Stored as the canonical catalog model ID. + * Catalog model ID or alias. The server selects among ready providers and stores the canonical model ID. */ 'model'?: string; + /** + * Optional provider pin. Provider-qualified model references remain accepted for compatibility. + */ + 'provider'?: string; } diff --git a/lib/packages/fabro-api-client/src/models/model-features.ts b/lib/packages/fabro-api-client/src/models/model-features.ts index 7eb314d8c..5f23381b3 100644 --- a/lib/packages/fabro-api-client/src/models/model-features.ts +++ b/lib/packages/fabro-api-client/src/models/model-features.ts @@ -38,6 +38,10 @@ export interface ModelFeatures { * Whether the model endpoint supports prompt caching. */ 'prompt_cache': boolean; + /** + * Whether the endpoint only caches when the request marks the cacheable prefix with Anthropic-style cache_control breakpoints (e.g. Claude via OpenRouter). + */ + 'cache_control_breakpoints': boolean; /** * Whether the model accepts classic sampling parameters (temperature, top_p). */ diff --git a/lib/packages/fabro-api-client/src/models/model-test-result.ts b/lib/packages/fabro-api-client/src/models/model-test-result.ts index 0155da8e7..cba04c056 100644 --- a/lib/packages/fabro-api-client/src/models/model-test-result.ts +++ b/lib/packages/fabro-api-client/src/models/model-test-result.ts @@ -19,9 +19,13 @@ */ export interface ModelTestResult { /** - * The model identifier that was tested. + * The canonical model ID that was tested. */ 'model_id': string; + /** + * LLM provider identifier. + */ + 'provider': string; /** * Whether the model responded successfully, failed, or was skipped because its provider is not configured. */ diff --git a/lib/packages/fabro-api-client/src/models/model.ts b/lib/packages/fabro-api-client/src/models/model.ts index f02cb0e51..d3326e2db 100644 --- a/lib/packages/fabro-api-client/src/models/model.ts +++ b/lib/packages/fabro-api-client/src/models/model.ts @@ -24,11 +24,11 @@ import type { ModelFeatures } from './model-features'; import type { ModelLimits } from './model-limits'; /** - * An available LLM model from the built-in catalog. + * One provider\'s offering of an LLM model. The `id` is unique within `provider`; `(provider, id)` is the stable resource identity. */ export interface Model { /** - * Unique model identifier. + * Canonical human-facing model ID, unique within the provider. */ 'id': string; /** diff --git a/lib/packages/fabro-api-client/src/models/session-detail.ts b/lib/packages/fabro-api-client/src/models/session-detail.ts index d2f072227..e1dce8d26 100644 --- a/lib/packages/fabro-api-client/src/models/session-detail.ts +++ b/lib/packages/fabro-api-client/src/models/session-detail.ts @@ -34,7 +34,14 @@ export interface SessionDetail { 'run_id': string; 'title'?: string | null; 'status': SessionStatus; + /** + * Canonical model ID selected when the session was created. + */ 'model'?: string | null; + /** + * LLM provider identifier. + */ + 'provider'?: string | null; 'active_turn': SessionTurn | null; 'created_at': string; 'updated_at': string; diff --git a/lib/packages/fabro-api-client/src/models/session-record.ts b/lib/packages/fabro-api-client/src/models/session-record.ts index 94aa77aa7..5bb2ee06d 100644 --- a/lib/packages/fabro-api-client/src/models/session-record.ts +++ b/lib/packages/fabro-api-client/src/models/session-record.ts @@ -31,7 +31,14 @@ export interface SessionRecord { 'run_id': string; 'title'?: string | null; 'status': SessionStatus; + /** + * Canonical model ID selected when the session was created. + */ 'model'?: string | null; + /** + * LLM provider identifier. + */ + 'provider'?: string | null; 'active_turn': SessionTurn | null; 'created_at': string; 'updated_at': string; diff --git a/lib/packages/fabro-api-client/src/models/session-summary.ts b/lib/packages/fabro-api-client/src/models/session-summary.ts index 4c5e3c75e..6520d77c7 100644 --- a/lib/packages/fabro-api-client/src/models/session-summary.ts +++ b/lib/packages/fabro-api-client/src/models/session-summary.ts @@ -31,7 +31,14 @@ export interface SessionSummary { 'run_id': string; 'title'?: string | null; 'status': SessionStatus; + /** + * Canonical model ID selected when the session was created. + */ 'model'?: string | null; + /** + * LLM provider identifier. + */ + 'provider'?: string | null; 'active_turn': SessionTurn | null; 'created_at': string; 'updated_at': string; diff --git a/test/twin/openai/src/openai/models.rs b/test/twin/openai/src/openai/models.rs index abd2e47e7..c6b33bc31 100644 --- a/test/twin/openai/src/openai/models.rs +++ b/test/twin/openai/src/openai/models.rs @@ -625,15 +625,34 @@ impl ChatCompletionsRequest { #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct ChatMessage { - pub role: String, - pub content: Value, + pub role: String, + pub content: Option, + pub reasoning_content: Option, + pub tool_call_id: Option, + pub tool_calls: Option>, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ChatMessageToolCall { + pub id: String, + #[serde(rename = "type")] + pub kind: String, + pub function: ChatMessageToolCallFunction, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ChatMessageToolCallFunction { + pub name: String, + pub arguments: String, } impl ChatMessage { fn extract_texts(&self) -> Vec { - match &self.content { - Value::String(text) => vec![normalize_whitespace(text)], - Value::Array(parts) => parts + match self.content.as_ref() { + Some(Value::String(text)) => vec![normalize_whitespace(text)], + Some(Value::Array(parts)) => parts .iter() .filter_map(|part| { part.get("text") @@ -647,13 +666,21 @@ impl ChatMessage { fn contains_reasoning_content(&self) -> bool { self.role == "assistant" - && self.content.as_array().is_some_and(|parts| { - parts.iter().any(|part| { - part.get("type") - .and_then(Value::as_str) - .is_some_and(|kind| kind == "reasoning") - }) - }) + && (self + .reasoning_content + .as_deref() + .is_some_and(|reasoning| !reasoning.trim().is_empty()) + || self + .content + .as_ref() + .and_then(Value::as_array) + .is_some_and(|parts| { + parts.iter().any(|part| { + part.get("type") + .and_then(Value::as_str) + .is_some_and(|kind| kind == "reasoning") + }) + })) } } @@ -680,14 +707,24 @@ fn validate_chat_message(message: &ChatMessage) -> Result<(), OpenAiError> { )); } - match &message.content { - Value::String(_) => Ok(()), - Value::Array(parts) if !parts.is_empty() => { + validate_chat_tool_fields(message)?; + + match message.content.as_ref() { + Some(Value::String(_)) => Ok(()), + Some(Value::Array(parts)) if !parts.is_empty() => { for part in parts { validate_chat_message_part(part, &message.role)?; } Ok(()) } + None if message.role == "assistant" + && message + .tool_calls + .as_ref() + .is_some_and(|tool_calls| !tool_calls.is_empty()) => + { + Ok(()) + } _ => Err(OpenAiError::invalid_request( "messages", "unsupported message content shape", @@ -695,6 +732,56 @@ fn validate_chat_message(message: &ChatMessage) -> Result<(), OpenAiError> { } } +fn validate_chat_tool_fields(message: &ChatMessage) -> Result<(), OpenAiError> { + if message.role == "tool" { + if message + .tool_call_id + .as_deref() + .is_none_or(|tool_call_id| tool_call_id.trim().is_empty()) + { + return Err(OpenAiError::invalid_request( + "messages", + "tool messages require a tool_call_id", + )); + } + } else if message.tool_call_id.is_some() { + return Err(OpenAiError::invalid_request( + "messages", + "tool_call_id is only supported on tool messages", + )); + } + + let Some(tool_calls) = &message.tool_calls else { + return Ok(()); + }; + if message.role != "assistant" { + return Err(OpenAiError::invalid_request( + "messages", + "tool_calls are only supported on assistant messages", + )); + } + if tool_calls.is_empty() { + return Err(OpenAiError::invalid_request( + "messages", + "tool_calls must not be empty", + )); + } + for tool_call in tool_calls { + if tool_call.id.trim().is_empty() + || tool_call.kind != "function" + || tool_call.function.name.trim().is_empty() + || tool_call.function.arguments.trim().is_empty() + { + return Err(OpenAiError::invalid_request( + "messages", + "invalid assistant tool call", + )); + } + } + + Ok(()) +} + fn validate_chat_message_part(part: &Value, role: &str) -> Result<(), OpenAiError> { let Some(object) = part.as_object() else { return Err(OpenAiError::invalid_request( diff --git a/test/twin/openai/tests/chat_completions_contract.rs b/test/twin/openai/tests/chat_completions_contract.rs index db1ace170..6fe4c27fe 100644 --- a/test/twin/openai/tests/chat_completions_contract.rs +++ b/test/twin/openai/tests/chat_completions_contract.rs @@ -90,6 +90,44 @@ async fn chat_completions_accepts_supported_openai_compatible_fields() { ); } +#[tokio::test] +async fn chat_completions_accepts_tool_call_history() { + let server = common::spawn_server().await.expect("server should start"); + + let response = server + .post_chat(json!({ + "model": "gpt-test", + "messages": [ + { "role": "user", "content": "replace old with new" }, + { + "role": "assistant", + "tool_calls": [{ + "id": "call_edit", + "type": "function", + "function": { + "name": "edit_file", + "arguments": "{\"old\":\"old\",\"new\":\"new\"}" + } + }] + }, + { + "role": "tool", + "content": "Updated data.txt", + "tool_call_id": "call_edit" + } + ], + "stream": false + })) + .await; + + assert_eq!(response.status(), 200); + let body = response.json::().await.expect("json"); + assert_eq!( + body["choices"][0]["message"]["content"], + "deterministic: replace old with new" + ); +} + #[tokio::test] async fn chat_completions_supports_scripted_tool_call_and_json_schema() { let server = common::spawn_server().await.expect("server should start");