From 65cdf52061be28e11033bf893b6ee65bb10e4a94 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 10:12:25 -0400 Subject: [PATCH 01/45] feat: make model aliases provider-aware --- .../fabro-web/app/lib/model-offerings.test.ts | 32 + apps/fabro-web/app/lib/model-offerings.ts | 13 + apps/fabro-web/app/routes/settings-models.tsx | 48 +- docs/public/api-reference/fabro-api.yaml | 68 +- docs/public/changelog/2026-05-12.mdx | 2 +- docs/public/changelog/2026-05-13.mdx | 3 +- docs/public/core-concepts/models.mdx | 62 +- docs/public/execution/failures.mdx | 8 +- docs/public/execution/run-configuration.mdx | 6 +- docs/public/integrations/bedrock.mdx | 2 +- docs/public/integrations/litellm.mdx | 16 +- docs/public/integrations/openrouter.mdx | 39 +- docs/public/reference/user-configuration.mdx | 57 +- lib/crates/fabro-agent/src/agent_profile.rs | 6 +- lib/crates/fabro-agent/src/cli.rs | 21 +- lib/crates/fabro-agent/src/session.rs | 4 +- lib/crates/fabro-agent/tests/it/guardrails.rs | 8 +- .../fabro-api/tests/model_round_trip.rs | 2 +- .../fabro-api/tests/provider_id_round_trip.rs | 2 +- .../tests/session_contract_round_trip.rs | 2 + lib/crates/fabro-cli/Cargo.toml | 1 + lib/crates/fabro-cli/src/commands/model.rs | 170 ++- lib/crates/fabro-cli/src/commands/run/ask.rs | 5 +- .../src/commands/run/run_progress/event.rs | 2 +- .../fabro-cli/src/shared/provider_auth.rs | 7 +- .../fabro-cli/tests/it/cmd/model_test.rs | 5 + .../tests/it/support/auth_harness.rs | 6 +- lib/crates/fabro-client/src/client.rs | 4 + lib/crates/fabro-config/src/builders.rs | 9 +- lib/crates/fabro-config/src/layers/llm.rs | 292 +++- lib/crates/fabro-config/src/layers/mod.rs | 6 +- lib/crates/fabro-config/src/lib.rs | 25 +- lib/crates/fabro-config/src/parse.rs | 203 ++- .../src/commands/docs_options_reference.rs | 46 +- lib/crates/fabro-hooks/src/executor.rs | 13 +- lib/crates/fabro-llm/src/adapter_registry.rs | 33 +- lib/crates/fabro-llm/src/client.rs | 431 +++++- .../src/codec/anthropic_messages/encode.rs | 8 +- .../src/codec/bedrock_converse/encode.rs | 4 +- .../src/codec/openai_compatible/request.rs | 10 +- lib/crates/fabro-llm/src/cost.rs | 11 +- lib/crates/fabro-llm/src/model_test.rs | 6 +- .../fabro-llm/src/providers/anthropic.rs | 27 +- .../fabro-llm/src/providers/bedrock/mod.rs | 18 +- lib/crates/fabro-llm/src/providers/common.rs | 14 +- lib/crates/fabro-llm/src/providers/gemini.rs | 24 +- lib/crates/fabro-llm/src/providers/openai.rs | 24 +- .../src/providers/openai_compatible.rs | 8 +- lib/crates/fabro-llm/tests/integration.rs | 4 +- lib/crates/fabro-model/src/billing.rs | 46 +- lib/crates/fabro-model/src/catalog.rs | 1290 ++++++++++++++--- .../src/catalog/providers/anthropic.toml | 84 +- .../src/catalog/providers/bedrock-openai.toml | 20 +- .../src/catalog/providers/bedrock.toml | 142 +- .../src/catalog/providers/gemini.toml | 50 +- .../src/catalog/providers/inception.toml | 10 +- .../src/catalog/providers/kimi.toml | 22 +- .../src/catalog/providers/litellm.toml | 7 +- .../src/catalog/providers/minimax.toml | 10 +- .../src/catalog/providers/ollama.toml | 7 +- .../src/catalog/providers/openai.toml | 86 +- .../src/catalog/providers/openrouter.toml | 248 ++-- .../src/catalog/providers/poolside.toml | 18 +- .../src/catalog/providers/venice.toml | 20 +- .../src/catalog/providers/zai.toml | 22 +- lib/crates/fabro-model/src/ids.rs | 35 +- lib/crates/fabro-model/src/lib.rs | 2 +- lib/crates/fabro-model/src/model_ref.rs | 2 +- lib/crates/fabro-model/src/types.rs | 8 +- lib/crates/fabro-server/src/diagnostics.rs | 11 +- lib/crates/fabro-server/src/install.rs | 2 +- lib/crates/fabro-server/src/run_manifest.rs | 76 +- lib/crates/fabro-server/src/server.rs | 2 +- .../src/server/automation_scheduler.rs | 1 + .../src/server/handler/completions.rs | 141 +- .../fabro-server/src/server/handler/models.rs | 48 +- .../fabro-server/src/server/handler/pair.rs | 4 +- .../src/server/handler/playground.rs | 58 +- .../src/server/handler/pull_requests.rs | 5 +- .../fabro-server/src/server/handler/runs.rs | 22 +- .../src/server/handler/sessions.rs | 379 ++++- lib/crates/fabro-server/src/server/tests.rs | 325 ++++- lib/crates/fabro-server/src/spawn_env.rs | 2 + lib/crates/fabro-server/src/test_support.rs | 48 +- .../fabro-server/tests/it/api/automations.rs | 2 + .../fabro-server/tests/it/api/events.rs | 2 + .../fabro-server/tests/it/api/sessions.rs | 54 +- .../fabro-server/tests/it/scenario/dry_run.rs | 19 +- .../tests/it/scenario/run_completion.rs | 4 +- lib/crates/fabro-store/src/run_sessions.rs | 33 +- lib/crates/fabro-store/src/run_state.rs | 2 +- lib/crates/fabro-test/src/lib.rs | 3 + lib/crates/fabro-types/src/run_event/agent.rs | 2 +- lib/crates/fabro-types/src/run_event/mod.rs | 4 +- .../fabro-types/src/run_event/session.rs | 7 +- lib/crates/fabro-types/src/session.rs | 7 + .../fabro-types/src/settings/model_ref.rs | 20 +- .../fabro-validate/src/rules/model_support.rs | 2 +- lib/crates/fabro-workflow/Cargo.toml | 3 + .../fabro-workflow/src/billing_rollup.rs | 2 +- lib/crates/fabro-workflow/src/error.rs | 12 + .../fabro-workflow/src/event/convert.rs | 6 +- .../fabro-workflow/src/event/stored_fields.rs | 2 +- .../fabro-workflow/src/handler/llm/api.rs | 16 +- .../src/handler/llm/preamble.rs | 2 +- .../fabro-workflow/src/handler/llm/routing.rs | 10 +- .../fabro-workflow/src/operations/create.rs | 179 ++- .../fabro-workflow/src/operations/start.rs | 394 ++++- .../fabro-workflow/src/operations/validate.rs | 14 + lib/crates/fabro-workflow/src/outcome.rs | 2 +- .../src/pipeline/pull_request.rs | 21 +- .../fabro-workflow/src/pipeline/transform.rs | 77 +- .../fabro-workflow/src/pipeline/types.rs | 18 +- .../fabro-workflow/src/pipeline/validate.rs | 16 +- .../fabro-workflow/src/run_materialization.rs | 89 +- lib/crates/fabro-workflow/src/test_support.rs | 15 + .../src/transforms/model_resolution.rs | 163 ++- .../fabro-workflow/tests/it/integration.rs | 16 +- .../fabro-workflow/tests/materialize_run.rs | 8 +- .../src/.openapi-generator/FILES | 2 + .../fabro-api-client/src/api/models-api.ts | 124 +- .../src/models/completion-response.ts | 7 + .../src/models/create-completion-request.ts | 4 +- .../models/create-playground-chat-request.ts | 6 +- .../src/models/create-run-session-request.ts | 6 +- .../src/models/hook-definition.ts | 3 + .../fabro-api-client/src/models/index.ts | 2 + .../src/models/model-test-result.ts | 6 +- .../fabro-api-client/src/models/model.ts | 4 +- .../provider-credential-test-request.ts | 22 + .../provider-credential-test-response.ts | 22 + .../src/models/session-detail.ts | 7 + .../src/models/session-record.ts | 7 + .../src/models/session-summary.ts | 7 + 134 files changed, 5065 insertions(+), 1460 deletions(-) create mode 100644 apps/fabro-web/app/lib/model-offerings.test.ts create mode 100644 apps/fabro-web/app/lib/model-offerings.ts create mode 100644 lib/packages/fabro-api-client/src/models/provider-credential-test-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/provider-credential-test-response.ts 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/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/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index d6e384405..ca80fd423 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 @@ -8235,7 +8272,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 +8294,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 +8349,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 +8448,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 +8484,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 +8501,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 +8563,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. 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..efa60f8cf 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. + + +Built-in catalog keys that formerly exposed an aggregator's wire API ID are retired selectors. Fabro rejects them with their replacement address instead of silently routing them. Some contain a slash and look provider-qualified: for example, the old OpenRouter key `openai/gpt-5.6-sol` is retired in favor of `openrouter/gpt-5.6-sol`. To pin the direct OpenAI offering, set `provider = "openai"` and `name = "gpt-5.6-sol"` as separate fields. + 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 diff --git a/docs/public/execution/failures.mdx b/docs/public/execution/failures.mdx index 6884d5708..ef91658be 100644 --- a/docs/public/execution/failures.mdx +++ b/docs/public/execution/failures.mdx @@ -121,7 +121,13 @@ 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. + +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..43bb9a3d1 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -138,12 +138,14 @@ 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. + #### `[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/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/lib/crates/fabro-agent/src/agent_profile.rs b/lib/crates/fabro-agent/src/agent_profile.rs index 632ef4655..607d14fa0 100644 --- a/lib/crates/fabro-agent/src/agent_profile.rs +++ b/lib/crates/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/cli.rs b/lib/crates/fabro-agent/src/cli.rs index b32ef10dd..749f38b0a 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/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}"))); @@ -1089,7 +1096,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 +1128,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") ); } diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 2bd840bda..dfc754d1f 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -1718,9 +1718,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, }; diff --git a/lib/crates/fabro-agent/tests/it/guardrails.rs b/lib/crates/fabro-agent/tests/it/guardrails.rs index 8e1f898fe..cae5461e3 100644 --- a/lib/crates/fabro-agent/tests/it/guardrails.rs +++ b/lib/crates/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-api/tests/model_round_trip.rs b/lib/crates/fabro-api/tests/model_round_trip.rs index 727cd41da..93d4e7692 100644 --- a/lib/crates/fabro-api/tests/model_round_trip.rs +++ b/lib/crates/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(), diff --git a/lib/crates/fabro-api/tests/provider_id_round_trip.rs b/lib/crates/fabro-api/tests/provider_id_round_trip.rs index 47a1a2ce3..e648b008f 100644 --- a/lib/crates/fabro-api/tests/provider_id_round_trip.rs +++ b/lib/crates/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(), diff --git a/lib/crates/fabro-api/tests/session_contract_round_trip.rs b/lib/crates/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/crates/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-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index 6f14e385e..4c84def39 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -119,6 +119,7 @@ assert_cmd = "2" fabro-acp = { path = "../fabro-acp", features = ["test-support"] } fabro-build-support = { path = "../build-support" } fabro-server = { path = "../fabro-server", features = ["test-support"] } +fabro-workflow = { path = "../fabro-workflow", features = ["test-support"] } fabro-types = { path = "../fabro-types", features = ["clap", "test-support"] } insta = { workspace = true, features = ["filters"] } paste = "1" diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index e63b926f4..d5d41bb20 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -45,10 +45,17 @@ struct CompletedModelTest { status: String, } -fn find_model_by_id_or_alias(models: &[Model], id: &str) -> Option { +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.id == id || model.aliases.iter().any(|alias| alias == id)) + }) .cloned() } @@ -116,7 +123,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 +179,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 +215,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 +269,46 @@ 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.id == model_id || model.aliases.iter().any(|alias| alias == 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 +323,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 +371,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 +524,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"), @@ -512,7 +558,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"), @@ -610,6 +656,7 @@ mod tests { .body( serde_json::json!({ "model_id": "test-model", + "provider": "anthropic", "status": "ok" }) .to_string(), @@ -618,7 +665,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 +684,7 @@ mod tests { .body( serde_json::json!({ "model_id": "test-model", + "provider": "anthropic", "status": "error", "error_message": "timeout" }) @@ -647,7 +695,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 +714,7 @@ mod tests { .body( serde_json::json!({ "model_id": "kimi-k2.5", + "provider": "kimi", "status": "skip" }) .to_string(), @@ -674,7 +723,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 +747,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 +781,7 @@ mod tests { .body( serde_json::json!({ "model_id": "venice-large", + "provider": "venice", "status": "ok" }) .to_string(), @@ -754,6 +804,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/run/ask.rs b/lib/crates/fabro-cli/src/commands/run/ask.rs index 1b38ad734..2c7819186 100644 --- a/lib/crates/fabro-cli/src/commands/run/ask.rs +++ b/lib/crates/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/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs index a4a72f0bc..4cdc36056 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/crates/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/shared/provider_auth.rs b/lib/crates/fabro-cli/src/shared/provider_auth.rs index 70e258393..1d3992f1f 100644 --- a/lib/crates/fabro-cli/src/shared/provider_auth.rs +++ b/lib/crates/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/tests/it/cmd/model_test.rs b/lib/crates/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/crates/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/support/auth_harness.rs b/lib/crates/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/crates/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-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index 71f185104..be21fe2dd 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/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-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index 1458ba0b9..1ffdc650d 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/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, } } @@ -863,7 +870,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/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs index 5fb8a0a64..32169b980 100644 --- a/lib/crates/fabro-config/src/layers/llm.rs +++ b/lib/crates/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"] //! ... //! ``` //! @@ -26,10 +25,12 @@ //! Resolution against the static adapter registry happens in `fabro-model` //! when the resolved [`Catalog`](fabro_model::Catalog) is built. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, 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,279 @@ pub struct ModelSettings { pub costs: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LlmNormalizationError { + BuiltinCatalog { + message: String, + }, + RetiredModel { + identifier: String, + provider: ProviderId, + model: ModelId, + }, + UnknownLegacyModel { + model: String, + }, + AmbiguousLegacyModel { + model: String, + candidates: Vec<(ProviderId, ModelId)>, + }, + AmbiguousProviderModel { + provider: ProviderId, + selector: String, + models: Vec, + }, + DuplicateModelDefinition { + provider: ProviderId, + model: ModelId, + }, + ScopedModelDeclaresProvider { + provider: ProviderId, + model: ModelId, + }, +} + +impl std::fmt::Display for LlmNormalizationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BuiltinCatalog { message } => { + write!(f, "failed to inspect the built-in model catalog: {message}") + } + Self::RetiredModel { + identifier, + provider, + model, + } => write!( + f, + "model identifier '{identifier}' has been retired; use '{provider}/{model}' instead" + ), + Self::UnknownLegacyModel { model } => write!( + f, + "legacy model row '{model}' omits provider and does not match a unique known offering" + ), + Self::AmbiguousLegacyModel { model, candidates } => write!( + f, + "legacy model row '{model}' omits provider and matches multiple offerings: {candidates:?}" + ), + Self::AmbiguousProviderModel { + provider, + selector, + models, + } => write!( + f, + "legacy model selector '{selector}' is ambiguous on provider '{provider}': {models:?}" + ), + Self::DuplicateModelDefinition { provider, model } => write!( + f, + "provider '{provider}' model '{model}' is defined through both provider-scoped and legacy top-level syntax" + ), + Self::ScopedModelDeclaresProvider { provider, model } => write!( + f, + "provider-scoped model '{provider}/{model}' must not declare a provider field" + ), + } + } +} + +impl std::error::Error for LlmNormalizationError {} + +impl LlmLayer { + /// Normalize the temporary legacy model table before this source is + /// combined with any other settings source. + pub(crate) fn normalize_legacy_models(&mut self) -> Result<(), LlmNormalizationError> { + for (provider, settings) in self.providers.iter() { + for (model, settings) in settings.models.iter() { + if settings.provider.is_some() { + return Err(LlmNormalizationError::ScopedModelDeclaresProvider { + provider: ProviderId::new(provider.clone()), + model: ModelId::new(model.clone()), + }); + } + } + } + + let mut legacy_models = std::mem::take(&mut self.models.0) + .into_iter() + .collect::>(); + legacy_models.sort_by(|(left, _), (right, _)| left.cmp(right)); + + for (legacy_id, mut settings) in legacy_models { + if let Some((provider, model)) = catalog::retired_model_replacement(&legacy_id) { + return Err(LlmNormalizationError::RetiredModel { + identifier: legacy_id, + provider, + model, + }); + } + + let explicit_provider = settings.provider.take(); + let (provider, model) = if let Some(provider) = explicit_provider { + let provider = self.canonical_provider(&provider)?; + let model = self + .canonical_model_on_provider(&provider, &legacy_id)? + .unwrap_or_else(|| ModelId::new(legacy_id.clone())); + (provider, model) + } else { + let candidates = self.model_candidates(&legacy_id)?; + match candidates.as_slice() { + [(provider, model)] => (provider.clone(), model.clone()), + [] => { + return Err(LlmNormalizationError::UnknownLegacyModel { model: legacy_id }); + } + _ => { + return Err(LlmNormalizationError::AmbiguousLegacyModel { + model: legacy_id, + candidates, + }); + } + } + }; + + let provider_settings = self.providers.entry(provider.to_string()).or_default(); + if provider_settings.models.contains_key(model.as_str()) { + return Err(LlmNormalizationError::DuplicateModelDefinition { provider, model }); + } + provider_settings + .models + .insert(model.into_inner(), settings); + } + Ok(()) + } + + fn canonical_provider(&self, selector: &str) -> Result { + if self.providers.contains_key(selector) { + return Ok(ProviderId::new(selector)); + } + let mut aliases = self + .providers + .iter() + .filter(|(_, settings)| { + settings + .aliases + .as_ref() + .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) + }) + .map(|(provider, _)| ProviderId::new(provider.clone())) + .collect::>(); + aliases.sort(); + if let Some(provider) = aliases.into_iter().next() { + return Ok(provider); + } + catalog::builtin_provider_id(selector) + .map_err(|error| normalization_catalog_error(&error)) + .map(|provider| provider.unwrap_or_else(|| ProviderId::new(selector))) + } + + fn canonical_model_on_provider( + &self, + provider: &ProviderId, + selector: &str, + ) -> Result, LlmNormalizationError> { + let current = self + .providers + .get(provider.as_str()) + .map(|settings| &settings.models); + if current.is_some_and(|models| models.contains_key(selector)) { + return Ok(Some(ModelId::new(selector))); + } + + let builtin_canonical = catalog::builtin_canonical_model_offerings(selector) + .map_err(|error| normalization_catalog_error(&error))? + .into_iter() + .find_map(|(candidate_provider, model)| { + (candidate_provider == *provider).then_some(model) + }); + if builtin_canonical.is_some() { + return Ok(builtin_canonical); + } + + let mut aliases = current + .into_iter() + .flat_map(|models| models.iter()) + .filter(|(_, settings)| { + settings + .aliases + .as_ref() + .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) + }) + .map(|(model, _)| ModelId::new(model.clone())) + .collect::>(); + aliases.extend( + catalog::builtin_alias_model_offerings(selector) + .map_err(|error| normalization_catalog_error(&error))? + .into_iter() + .filter_map(|(candidate_provider, model)| { + (candidate_provider == *provider).then_some(model) + }), + ); + match aliases.len() { + 0 => Ok(None), + 1 => Ok(aliases.into_iter().next()), + _ => Err(LlmNormalizationError::AmbiguousProviderModel { + provider: provider.clone(), + selector: selector.to_string(), + models: aliases.into_iter().collect(), + }), + } + } + + fn model_candidates( + &self, + selector: &str, + ) -> Result, LlmNormalizationError> { + let mut canonical = self + .providers + .iter() + .filter(|(_, settings)| settings.models.contains_key(selector)) + .map(|(provider, _)| { + ( + ProviderId::new(provider.clone()), + ModelId::new(selector.to_string()), + ) + }) + .collect::>(); + canonical.extend( + catalog::builtin_canonical_model_offerings(selector) + .map_err(|error| normalization_catalog_error(&error))?, + ); + if !canonical.is_empty() { + return Ok(canonical.into_iter().collect()); + } + + let mut aliases = + self.providers + .iter() + .flat_map(|(provider, settings)| { + settings + .models + .iter() + .filter(move |(_, settings)| { + settings.aliases.as_ref().is_some_and(|aliases| { + aliases.iter().any(|alias| alias == selector) + }) + }) + .map(move |(model, _)| { + ( + ProviderId::new(provider.clone()), + ModelId::new(model.clone()), + ) + }) + }) + .collect::>(); + aliases.extend( + catalog::builtin_alias_model_offerings(selector) + .map_err(|error| normalization_catalog_error(&error))?, + ); + Ok(aliases.into_iter().collect()) + } +} + +fn normalization_catalog_error(error: &catalog::CatalogBuildError) -> LlmNormalizationError { + LlmNormalizationError::BuiltinCatalog { + message: error.to_string(), + } +} + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ModelLimits { diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs index c3fa1632c..c25838781 100644 --- a/lib/crates/fabro-config/src/layers/mod.rs +++ b/lib/crates/fabro-config/src/layers/mod.rs @@ -21,9 +21,9 @@ pub use environment::{ EnvironmentNetworkLayer, EnvironmentResourcesLayer, RunEnvironmentLayer, }; pub use llm::{ - CostRates, CredentialRef, CredentialRefParseError, LlmLayer, ModelControls, ModelCostTable, - ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, ModelSettings, - ProviderSettings, ReasoningEffortFeature, + CostRates, CredentialRef, CredentialRefParseError, LlmLayer, LlmNormalizationError, + ModelControls, ModelCostTable, ModelFeatures as LlmModelFeatures, + ModelLimits as LlmModelLimits, ModelSettings, ProviderSettings, ReasoningEffortFeature, }; pub use log_filter::LogFilter; pub use maps::{MergeMap, ReplaceMap, StickyMap}; diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index f6097b9be..694ab1773 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -46,18 +46,19 @@ pub use layers::{ CredentialRefParseError, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, GitAuthorLayer, GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, - InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, - McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings, - NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, - PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer, - RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, - RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, - RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, - RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, - ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, - ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer, - ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, - ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, + InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, + LlmNormalizationError, LogFilter, McpEntryLayer, MergeMap, ModelControls, ModelCostTable, + ModelRefOrSplice, ModelSettings, NotificationProviderLayer, NotificationRouteLayer, + ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, ProviderSettings, + ReasoningEffortFeature, ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, + RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, + RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, + RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, + RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, + ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, + ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer, + ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer, + SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, }; pub use logging::{resolve_log_destination, resolve_log_destination_with_env}; pub use parse::ParseError; diff --git a/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs index b4e008791..025e0b130 100644 --- a/lib/crates/fabro-config/src/parse.rs +++ b/lib/crates/fabro-config/src/parse.rs @@ -1,6 +1,7 @@ use std::fmt; use crate::SettingsLayer; +use crate::layers::LlmNormalizationError; const CURRENT_VERSION: u32 = 1; @@ -30,6 +31,7 @@ const LEGACY_LLM_KEYS: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParseError { Toml(String), + LlmCatalog(LlmNormalizationError), Version(VersionError), UnknownTopLevelKey { key: String, @@ -45,6 +47,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 +121,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 +299,196 @@ 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(LlmNormalizationError::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(LlmNormalizationError::AmbiguousLegacyModel { + 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(LlmNormalizationError::DuplicateModelDefinition { + provider, + model, + }) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4" + )); + } + + #[test] + fn retired_legacy_model_id_names_replacement() { + let error = r#" +[llm.models."openai/gpt-5.6-sol"] +provider = "openrouter" +"# + .parse::() + .unwrap_err(); + + assert!(matches!( + error, + ParseError::LlmCatalog(LlmNormalizationError::RetiredModel { + identifier, + provider, + model, + }) if identifier == "openai/gpt-5.6-sol" + && provider.as_str() == "openrouter" + && model.as_str() == "gpt-5.6-sol" + )); + } + + #[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-dev/src/commands/docs_options_reference.rs b/lib/crates/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/crates/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-hooks/src/executor.rs b/lib/crates/fabro-hooks/src/executor.rs index a409a49b7..052776af1 100644 --- a/lib/crates/fabro-hooks/src/executor.rs +++ b/lib/crates/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-llm/src/adapter_registry.rs b/lib/crates/fabro-llm/src/adapter_registry.rs index 7a5638c93..810f6d6f4 100644 --- a/lib/crates/fabro-llm/src/adapter_registry.rs +++ b/lib/crates/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_returns_none_for_model_from_another_catalog() { + 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/client.rs b/lib/crates/fabro-llm/src/client.rs index c72e9ea50..e40a8ede2 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/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,150 @@ 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 let Some(catalog_provider) = catalog.provider(&explicit) { + let provider = self + .provider_adapter(catalog_provider.id.as_str()) + .ok_or_else(|| { + selection_error(ModelSelectionError::ProviderUnavailable { + provider: catalog_provider.id.clone(), + }) + })?; + match catalog.resolve_on_provider(&catalog_provider.id, &request.model) { + Ok(model) => resolved.model = model.id.to_string(), + Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => {} + Err(error) => return Err(selection_error(error)), + } + resolved.provider = Some(catalog_provider.id.to_string()); + 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 +444,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 +469,7 @@ impl Client { }) }); - chain(request.clone()).await + chain(request).await } /// Send a streaming request (Section 4.2). @@ -369,11 +480,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 +505,7 @@ impl Client { }) }); - chain(request.clone()).await + chain(request).await } /// Count the model-visible input/context tokens for a request without @@ -410,19 +521,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 +551,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 +582,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 +606,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 +620,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 +648,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 +663,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 +672,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 +748,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 +1007,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 +1899,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 +1930,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 +1953,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 +1970,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/encode.rs b/lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs index 360f4210a..2803c221c 100644 --- a/lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs +++ b/lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs @@ -618,26 +618,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) diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs b/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs index a328434de..54a38314e 100644 --- a/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs +++ b/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs @@ -347,8 +347,8 @@ fn merge_provider_options(body: &mut Value, provider_options: Option<&Value>, pr #[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/openai_compatible/request.rs b/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs index 154013725..72920b34c 100644 --- a/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs +++ b/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs @@ -79,7 +79,7 @@ pub(super) fn merge_provider_options( #[cfg(test)] mod tests { - use fabro_model::Catalog; + use fabro_model::{Catalog, ProviderId}; use super::super::wire::ApiRequest; use super::*; @@ -183,16 +183,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, }; diff --git a/lib/crates/fabro-llm/src/cost.rs b/lib/crates/fabro-llm/src/cost.rs index e03ead5de..898dcb362 100644 --- a/lib/crates/fabro-llm/src/cost.rs +++ b/lib/crates/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/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs index 47bca353c..3d8ea8eb0 100644 --- a/lib/crates/fabro-llm/src/model_test.rs +++ b/lib/crates/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(), diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index d65494b34..539b2b0fc 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -118,7 +118,11 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model(self.catalog.as_deref(), &request.model), + model: common::catalog_model( + self.catalog.as_deref(), + &self.provider_name, + &request.model, + ), params, } } @@ -228,7 +232,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &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 +272,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &resolved.model, + ); let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); let encoded = codec.encode(&ctx, false)?; @@ -281,7 +293,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &resolved.model, + ); let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); let encoded = codec.encode(&ctx, true)?; @@ -307,7 +323,8 @@ 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 = + common::catalog_model(self.catalog.as_deref(), &self.provider_name, &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/mod.rs b/lib/crates/fabro-llm/src/providers/bedrock/mod.rs index e2cffd49d..97c15dc02 100644 --- a/lib/crates/fabro-llm/src/providers/bedrock/mod.rs +++ b/lib/crates/fabro-llm/src/providers/bedrock/mod.rs @@ -169,7 +169,11 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model(self.catalog.as_deref(), &request.model), + model: common::catalog_model( + self.catalog.as_deref(), + &self.provider_name, + &request.model, + ), params, } } @@ -239,7 +243,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &resolved.model, + ); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -253,7 +261,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &resolved.model, + ); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); diff --git a/lib/crates/fabro-llm/src/providers/common.rs b/lib/crates/fabro-llm/src/providers/common.rs index 8871ff3f7..f85397935 100644 --- a/lib/crates/fabro-llm/src/providers/common.rs +++ b/lib/crates/fabro-llm/src/providers/common.rs @@ -1,18 +1,22 @@ 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()) } diff --git a/lib/crates/fabro-llm/src/providers/gemini.rs b/lib/crates/fabro-llm/src/providers/gemini.rs index 2b1f9ee4b..a9e2fab0f 100644 --- a/lib/crates/fabro-llm/src/providers/gemini.rs +++ b/lib/crates/fabro-llm/src/providers/gemini.rs @@ -102,7 +102,11 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model(self.catalog.as_deref(), &request.model), + model: common::catalog_model( + self.catalog.as_deref(), + &self.provider_name, + &request.model, + ), params, } } @@ -147,7 +151,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &resolved.model, + ); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -177,7 +185,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &resolved.model, + ); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -194,7 +206,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &resolved.model, + ); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); diff --git a/lib/crates/fabro-llm/src/providers/openai.rs b/lib/crates/fabro-llm/src/providers/openai.rs index ad25d01a9..185af1714 100644 --- a/lib/crates/fabro-llm/src/providers/openai.rs +++ b/lib/crates/fabro-llm/src/providers/openai.rs @@ -131,7 +131,11 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model(self.catalog.as_deref(), &request.model), + model: common::catalog_model( + self.catalog.as_deref(), + &self.provider_name, + &request.model, + ), params, } } @@ -214,7 +218,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &resolved.model, + ); let params = self.codec_params(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -250,7 +258,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &resolved.model, + ); let params = self.codec_params(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -267,7 +279,11 @@ 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 = common::api_model_id( + self.catalog.as_deref(), + &self.provider_name, + &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/crates/fabro-llm/src/providers/openai_compatible.rs index 9169287b7..3feb0b577 100644 --- a/lib/crates/fabro-llm/src/providers/openai_compatible.rs +++ b/lib/crates/fabro-llm/src/providers/openai_compatible.rs @@ -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) + common::api_model_id(self.catalog.as_deref(), &self.provider_name, &request.model) } /// Build the borrowed codec context. `deployment_id` and `params` are @@ -103,7 +103,11 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model(self.catalog.as_deref(), &request.model), + model: common::catalog_model( + self.catalog.as_deref(), + &self.provider_name, + &request.model, + ), params, } } diff --git a/lib/crates/fabro-llm/tests/integration.rs b/lib/crates/fabro-llm/tests/integration.rs index a7a421afe..9eb558fd5 100644 --- a/lib/crates/fabro-llm/tests/integration.rs +++ b/lib/crates/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; diff --git a/lib/crates/fabro-model/src/billing.rs b/lib/crates/fabro-model/src/billing.rs index d75992d7a..0f571bfb3 100644 --- a/lib/crates/fabro-model/src/billing.rs +++ b/lib/crates/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; @@ -138,7 +138,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, } @@ -321,7 +321,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] @@ -435,13 +435,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, @@ -459,7 +455,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) } @@ -713,7 +709,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 { @@ -785,12 +781,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, }; @@ -919,7 +915,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 { @@ -956,7 +952,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(); @@ -980,7 +976,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(); @@ -1033,7 +1029,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(); @@ -1081,7 +1077,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(); @@ -1131,7 +1127,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() @@ -1140,7 +1136,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() @@ -1153,7 +1149,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() @@ -1162,7 +1158,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() @@ -1171,7 +1167,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() @@ -1183,7 +1179,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 { @@ -1229,7 +1225,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/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index ce586679e..ee362d414 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/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)] @@ -528,11 +535,46 @@ pub enum CatalogBuildError { model: String, provider: ProviderId, }, - #[error("model identifier '{identifier}' is declared by both '{first}' and '{second}'")] - DuplicateModelIdentifier { + #[error( + "provider '{provider}' model selector '{selector}' is declared by both '{first}' and '{second}'" + )] + DuplicateProviderModelSelector { + provider: ProviderId, + selector: String, + first: ModelId, + second: ModelId, + }, + #[error( + "provider '{provider}' model '{model}' is defined through both provider-scoped and legacy top-level syntax" + )] + DuplicateProviderModelDefinition { + provider: ProviderId, + model: ModelId, + }, + #[error("provider-scoped model '{provider}/{model}' must not declare a provider field")] + ScopedModelDeclaresProvider { + provider: ProviderId, + model: ModelId, + }, + #[error("legacy model row '{model}' omits provider and does not match a unique known offering")] + LegacyModelProviderUnknown { model: String }, + #[error( + "legacy model row '{model}' omits provider and matches multiple providers: {providers:?}" + )] + LegacyModelProviderAmbiguous { + model: String, + providers: Vec, + }, + #[error("model identifier '{identifier}' has been retired; use '{provider}/{model}' instead")] + RetiredModelIdentifier { identifier: String, - first: String, - second: String, + provider: ProviderId, + model: ModelId, + }, + #[error("provider '{provider}' model '{model}' has an empty api_id")] + EmptyModelApiId { + provider: ProviderId, + model: ModelId, }, #[error("provider '{provider}' has multiple default models: {models:?}")] MultipleProviderDefaults { @@ -574,17 +616,49 @@ 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("model identifier '{identifier}' has been retired; use '{provider}/{model}' instead")] + RetiredModelIdentifier { + identifier: String, + provider: ProviderId, + model: ModelId, + }, +} + /// 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 +670,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 +692,84 @@ 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((replacement_provider, replacement_model)) = + retired_model_address(&model_id) + { + return Err(CatalogBuildError::RetiredModelIdentifier { + identifier: model_id, + provider: replacement_provider, + model: replacement_model, + }); + } - 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 +777,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 +788,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 +819,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 +861,204 @@ 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. + #[must_use] + pub fn get_on_provider(&self, provider: &ProviderId, selector: &str) -> Option<&Model> { + let provider = self.provider(provider)?; + self.provider_selector_index + .get(&(provider.id.clone(), selector.to_string())) + .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> { + if let Some((replacement_provider, replacement_model)) = retired_model_address(selector) { + return Err(ModelSelectionError::RetiredModelIdentifier { + identifier: selector.to_string(), + provider: replacement_provider, + model: replacement_model, + }); + } + 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. + /// + /// 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> { + if let Some((replacement_provider, replacement_model)) = retired_model_address(selector) { + return Err(ModelSelectionError::RetiredModelIdentifier { + identifier: selector.to_string(), + provider: replacement_provider, + model: replacement_model, + }); + } + 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 canonical = self.canonical_candidates.get(&ModelId::new(selector)); + 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(selector); + 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::NoEligibleOffering { + selector: "".to_string(), + providers, + }) + } + + #[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> { + self.canonical_candidates + .get(&ModelId::new(selector)) + .or_else(|| self.alias_candidates.get(selector)) + } + #[must_use] pub fn providers(&self) -> &[CatalogProvider] { &self.providers @@ -786,7 +1077,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 +1109,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 +1145,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 +1162,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 +1179,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 +1304,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 +1353,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 +1367,359 @@ 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 { + for (provider, settings) in &settings.providers { + for (model, settings) in &settings.models { + if settings.provider.is_some() { + return Err(CatalogBuildError::ScopedModelDeclaresProvider { + provider: ProviderId::new(provider.clone()), + model: ModelId::new(model.clone()), + }); + } + } + } + + let legacy_models = std::mem::take(&mut settings.models); + for (legacy_id, mut model_settings) in legacy_models { + if let Some((provider, model)) = retired_model_address(&legacy_id) { + return Err(CatalogBuildError::RetiredModelIdentifier { + identifier: legacy_id, + provider, + model, + }); + } + + let explicit_provider = model_settings.provider.take(); + let (provider, model_id) = if let Some(provider) = explicit_provider { + let provider = canonical_settings_provider(&provider, &settings, known) + .unwrap_or_else(|| ProviderId::new(provider)); + let model_id = canonical_settings_model(&provider, &legacy_id, &settings, known) + .unwrap_or_else(|| ModelId::new(legacy_id.clone())); + (provider, model_id) + } else { + let candidates = settings_model_candidates(&legacy_id, &settings, known); + match candidates.as_slice() { + [(provider, model)] => (provider.clone(), model.clone()), + [] => { + return Err(CatalogBuildError::LegacyModelProviderUnknown { model: legacy_id }); + } + _ => { + return Err(CatalogBuildError::LegacyModelProviderAmbiguous { + model: legacy_id, + providers: candidates + .into_iter() + .map(|(provider, _)| provider) + .collect(), + }); + } + } + }; + + 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(CatalogBuildError::DuplicateProviderModelDefinition { + provider, + model: model_id, + }); + } + provider_settings + .models + .insert(model_id.into_inner(), model_settings); + } + Ok(settings) +} + +fn canonical_settings_provider( + selector: &str, + settings: &LlmCatalogSettings, + known: Option<&LlmCatalogSettings>, +) -> Option { + let providers = || { + settings + .providers + .iter() + .chain(known.into_iter().flat_map(|known| known.providers.iter())) + }; + providers() + .find(|(id, _)| id.as_str() == selector) + .or_else(|| { + providers().find(|(_, provider)| { + provider + .aliases + .as_ref() + .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) + }) + }) + .map(|(id, _)| ProviderId::new(id.clone())) +} + +fn canonical_settings_model( + provider: &ProviderId, + selector: &str, + settings: &LlmCatalogSettings, + known: Option<&LlmCatalogSettings>, +) -> Option { + let models = || { + settings + .providers + .get(provider.as_str()) + .into_iter() + .chain(known.and_then(|known| known.providers.get(provider.as_str()))) + .flat_map(|provider| provider.models.iter()) + }; + models() + .find(|(id, _)| id.as_str() == selector) + .or_else(|| { + models().find(|(_, model)| { + model + .aliases + .as_ref() + .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) + }) + }) + .map(|(id, _)| ModelId::new(id.clone())) +} + +fn settings_model_candidates( + selector: &str, + settings: &LlmCatalogSettings, + known: Option<&LlmCatalogSettings>, +) -> Vec<(ProviderId, ModelId)> { + let collect = |matches: &dyn Fn(&str, &ModelCatalogSettings) -> bool| { + let mut candidates = BTreeSet::<(ProviderId, ModelId)>::new(); + for (provider_id, provider) in settings + .providers + .iter() + .chain(known.into_iter().flat_map(|known| known.providers.iter())) + { + for (model_id, model) in &provider.models { + if matches(model_id, model) { + candidates.insert(( + ProviderId::new(provider_id.clone()), + ModelId::new(model_id.clone()), + )); + } + } + } + candidates.into_iter().collect::>() + }; + + let canonical = collect(&|model_id, _| model_id == selector); + if canonical.is_empty() { + collect(&|_, model| { + model + .aliases + .as_ref() + .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) + }) + } else { + canonical + } +} + +/// Return every built-in offering whose canonical model ID is `selector`. +/// +/// This includes disabled providers because config compatibility +/// normalization happens before runtime availability is known. +pub fn builtin_canonical_model_offerings( + selector: &str, +) -> Result, CatalogBuildError> { + let settings = Catalog::builtin_settings()?; + Ok(settings + .providers + .iter() + .filter_map(|(provider, settings)| { + settings.models.get_key_value(selector).map(|(model, _)| { + ( + ProviderId::new(provider.clone()), + ModelId::new(model.clone()), + ) + }) + }) + .collect()) +} + +/// Return every built-in offering that declares `selector` as an alias. +/// +/// This includes disabled providers because config compatibility +/// normalization happens before runtime availability is known. +pub fn builtin_alias_model_offerings( + selector: &str, +) -> Result, CatalogBuildError> { + let settings = Catalog::builtin_settings()?; + let mut offerings = settings + .providers + .iter() + .flat_map(|(provider, settings)| { + settings + .models + .iter() + .filter(move |(_, settings)| { + settings + .aliases + .as_ref() + .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) + }) + .map(move |(model, _)| { + ( + ProviderId::new(provider.clone()), + ModelId::new(model.clone()), + ) + }) + }) + .collect::>(); + offerings.sort(); + offerings.dedup(); + Ok(offerings) +} + +/// Resolve a built-in provider ID or alias without filtering disabled rows. +pub fn builtin_provider_id(selector: &str) -> Result, CatalogBuildError> { + let settings = Catalog::builtin_settings()?; + Ok(canonical_settings_provider(selector, &settings, None)) +} + +/// Built-in catalog keys retired when provider API identifiers stopped being +/// Fabro model IDs. Keep this list explicit so old workflow and persisted-run +/// references fail with an actionable replacement instead of silently +/// selecting another route. +const RETIRED_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 replacement address for a retired built-in catalog key. +#[must_use] +pub fn retired_model_replacement(identifier: &str) -> Option<(ProviderId, ModelId)> { + RETIRED_MODEL_IDENTIFIERS + .iter() + .find(|(retired, _, _)| *retired == identifier) + .map(|(_, provider, model)| (ProviderId::new(*provider), ModelId::new(*model))) +} + +fn retired_model_address(identifier: &str) -> Option<(ProviderId, ModelId)> { + retired_model_replacement(identifier) } fn merge_catalog_settings( @@ -1087,21 +1734,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 +1761,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, } } @@ -1418,7 +2065,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 +2083,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 +2112,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()); } } @@ -1675,16 +2329,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 +2401,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 +2414,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 +2634,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 +2657,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 +2704,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 +2818,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 +2834,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 +2850,7 @@ enabled = true let expected = [ ( - "openai/gpt-5.6-sol", + "gpt-5.6-sol", "openai/gpt-5.6-sol", "gpt-5", 1_050_000, @@ -2159,7 +2862,7 @@ enabled = true BillingPolicy::OpenAi, ), ( - "openai/gpt-5.6-terra", + "gpt-5.6-terra", "openai/gpt-5.6-terra", "gpt-5", 1_050_000, @@ -2171,7 +2874,7 @@ enabled = true BillingPolicy::OpenAi, ), ( - "openai/gpt-5.6-luna", + "gpt-5.6-luna", "openai/gpt-5.6-luna", "gpt-5", 1_050_000, @@ -2183,7 +2886,7 @@ enabled = true BillingPolicy::OpenAi, ), ( - "anthropic/claude-opus-4-8", + "claude-opus-4-8", "anthropic/claude-opus-4.8", "claude-4", 1_000_000, @@ -2195,7 +2898,7 @@ enabled = true BillingPolicy::Anthropic, ), ( - "anthropic/claude-fable-5", + "claude-fable-5", "anthropic/claude-fable-5", "claude-5", 1_000_000, @@ -2222,7 +2925,7 @@ enabled = true ) 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}"); @@ -2243,15 +2946,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 +2958,33 @@ 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_openrouter_includes_glm_5_2_when_enabled() { let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( @@ -2271,11 +2996,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)", @@ -2315,7 +3040,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 +3060,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)", @@ -2379,7 +3104,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 +3125,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 +3152,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 +3616,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 +3648,238 @@ 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::ScopedModelDeclaresProvider { provider, model } + if provider == ProviderId::new("test") && model == "one" + )); + } + + #[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 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 +4014,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 +4079,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"); } @@ -4964,7 +5910,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/crates/fabro-model/src/catalog/providers/anthropic.toml index 0a1587197..95649e214 100644 --- a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml +++ b/lib/crates/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/crates/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/crates/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/crates/fabro-model/src/catalog/providers/bedrock.toml index f6f217a4f..aeed22e46 100644 --- a/lib/crates/fabro-model/src/catalog/providers/bedrock.toml +++ b/lib/crates/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/crates/fabro-model/src/catalog/providers/gemini.toml index 74ffc91de..a03c2a249 100644 --- a/lib/crates/fabro-model/src/catalog/providers/gemini.toml +++ b/lib/crates/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/crates/fabro-model/src/catalog/providers/inception.toml index 1d2b08a64..965120f27 100644 --- a/lib/crates/fabro-model/src/catalog/providers/inception.toml +++ b/lib/crates/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/crates/fabro-model/src/catalog/providers/kimi.toml index c57779116..daa4b20c2 100644 --- a/lib/crates/fabro-model/src/catalog/providers/kimi.toml +++ b/lib/crates/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/crates/fabro-model/src/catalog/providers/litellm.toml index 55f5aef15..1307378c1 100644 --- a/lib/crates/fabro-model/src/catalog/providers/litellm.toml +++ b/lib/crates/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/crates/fabro-model/src/catalog/providers/minimax.toml index 172a6fd6a..e68dfc290 100644 --- a/lib/crates/fabro-model/src/catalog/providers/minimax.toml +++ b/lib/crates/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/crates/fabro-model/src/catalog/providers/ollama.toml index bf3b16f5e..78dc5db69 100644 --- a/lib/crates/fabro-model/src/catalog/providers/ollama.toml +++ b/lib/crates/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/crates/fabro-model/src/catalog/providers/openai.toml index 56fe7a03c..10b7b2193 100644 --- a/lib/crates/fabro-model/src/catalog/providers/openai.toml +++ b/lib/crates/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/crates/fabro-model/src/catalog/providers/openrouter.toml index d01fcb3dc..4ef7d7c63 100644 --- a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml +++ b/lib/crates/fabro-model/src/catalog/providers/openrouter.toml @@ -33,18 +33,18 @@ 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 @@ -52,26 +52,25 @@ reasoning_effort = "always_adaptive" prompt_cache = 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 @@ -79,75 +78,74 @@ reasoning_effort = "levels" prompt_cache = 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 -[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 -[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 -[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 +156,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 +176,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 +201,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 +226,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 +416,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 +463,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/crates/fabro-model/src/catalog/providers/poolside.toml index 8fd1b6855..65ce87246 100644 --- a/lib/crates/fabro-model/src/catalog/providers/poolside.toml +++ b/lib/crates/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/crates/fabro-model/src/catalog/providers/venice.toml index bb91aeada..dc97c4ab4 100644 --- a/lib/crates/fabro-model/src/catalog/providers/venice.toml +++ b/lib/crates/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/crates/fabro-model/src/catalog/providers/zai.toml index e720df4da..c6d70cd64 100644 --- a/lib/crates/fabro-model/src/catalog/providers/zai.toml +++ b/lib/crates/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/ids.rs b/lib/crates/fabro-model/src/ids.rs index 18d1bdd86..3429ae094 100644 --- a/lib/crates/fabro-model/src/ids.rs +++ b/lib/crates/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()) @@ -141,12 +150,32 @@ impl From for ModelId { } } +impl FromStr for ModelId { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self::from(s)) + } +} + impl AsRef for ModelId { fn as_ref(&self) -> &str { &self.0 } } +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/crates/fabro-model/src/lib.rs index 8dfd96f23..337c00fdd 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/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, }; pub use codec::CodecKind; pub use ids::{ModelId, ProviderId}; diff --git a/lib/crates/fabro-model/src/model_ref.rs b/lib/crates/fabro-model/src/model_ref.rs index c9560a049..f8d1f0a8f 100644 --- a/lib/crates/fabro-model/src/model_ref.rs +++ b/lib/crates/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/types.rs b/lib/crates/fabro-model/src/types.rs index 8dcbd3270..fb0164804 100644 --- a/lib/crates/fabro-model/src/types.rs +++ b/lib/crates/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 --- @@ -78,7 +78,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 +102,7 @@ pub struct Model { impl Model { pub fn id(&self) -> &str { - &self.id + self.id.as_str() } pub fn provider(&self) -> &ProviderId { @@ -210,7 +210,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(), diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index b88f93716..988f84404 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/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/install.rs b/lib/crates/fabro-server/src/install.rs index 841f405b5..12bb5fbb7 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/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/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 93c6660c6..259e3d2b5 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/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/server.rs b/lib/crates/fabro-server/src/server.rs index ab4c0a726..98dcc4a62 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/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/crates/fabro-server/src/server/automation_scheduler.rs index 4eb802fab..9499d4170 100644 --- a/lib/crates/fabro-server/src/server/automation_scheduler.rs +++ b/lib/crates/fabro-server/src/server/automation_scheduler.rs @@ -428,6 +428,7 @@ mod tests { fn test_state_with_materializer(materializer: TestAutomationRunMaterializer) -> Arc { TestAppStateBuilder::new() .env_lookup(|_| None) + .vault_entries([("OPENAI_API_KEY", "test-openai-api-key")]) .automation_materializer(materializer) .build() } diff --git a/lib/crates/fabro-server/src/server/handler/completions.rs b/lib/crates/fabro-server/src/server/handler/completions.rs index d5df7299a..3d36f9036 100644 --- a/lib/crates/fabro-server/src/server/handler/completions.rs +++ b/lib/crates/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, + 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,27 @@ async fn create_completion( metadata: None, provider_options: req.provider_options, }; + let request = match client.resolve_request(&request) { + Ok(request) => request, + Err(error) => return ApiError::bad_request(error.to_string()).into_response(), + }; + let selected_provider = ProviderId::new( + request + .provider + .as_deref() + .expect("resolved LLM request always has a provider"), + ); + let selected_model = request.model.clone(); + + info!( + model = %selected_model, + 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 { @@ -169,7 +177,8 @@ async fn create_completion( let stop_reason = finish_reason_to_api_stop_reason(&response.finish_reason); Json(CompletionResponse { id: msg_id, - model: model_id, + model: selected_model, + provider: selected_provider, message: response.message, stop_reason, usage: CompletionUsage { @@ -192,6 +201,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 +220,48 @@ async fn create_completion( } } } + +pub(super) fn resolve_request_model( + catalog: &Catalog, + eligible: &HashSet, + requested_model: Option, + explicit_provider: Option, +) -> Result<(String, ProviderId), ModelSelectionError> { + let explicit_provider = explicit_provider.map(ProviderId::new); + if let Some(model) = requested_model { + return match catalog.select(&model, explicit_provider.as_ref(), eligible) { + Ok(offering) => Ok((offering.id.to_string(), offering.provider.clone())), + Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { + Ok((model, provider)) + } + Err(ModelSelectionError::UnknownSelector { .. }) => { + let default = catalog.select_default(eligible)?; + Ok((model, default.provider.clone())) + } + Err(error) => Err(error), + }; + } + + let eligible = if let Some(requested_provider) = explicit_provider { + let provider = catalog.provider(&requested_provider).ok_or_else(|| { + ModelSelectionError::UnknownProvider { + provider: requested_provider.clone(), + } + })?; + let provider_is_ready = eligible.iter().any(|eligible_provider| { + catalog + .provider(eligible_provider) + .is_some_and(|eligible_provider| eligible_provider.id == provider.id) + }); + if !provider_is_ready { + return Err(ModelSelectionError::ProviderUnavailable { + provider: provider.id.clone(), + }); + } + HashSet::from([provider.id.clone()]) + } else { + eligible.clone() + }; + let default = catalog.select_default(&eligible)?; + Ok((default.id.to_string(), default.provider.clone())) +} diff --git a/lib/crates/fabro-server/src/server/handler/models.rs b/lib/crates/fabro-server/src/server/handler/models.rs index 3d5341270..9b639af99 100644 --- a/lib/crates/fabro-server/src/server/handler/models.rs +++ b/lib/crates/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::RetiredModelIdentifier { .. } + | ModelSelectionError::ProviderUnavailable { .. } + | ModelSelectionError::NoEligibleOffering { .. } => { + ApiError::bad_request(error.to_string()).into_response() + } + } +} diff --git a/lib/crates/fabro-server/src/server/handler/pair.rs b/lib/crates/fabro-server/src/server/handler/pair.rs index 8304bf1ec..8367e34ad 100644 --- a/lib/crates/fabro-server/src/server/handler/pair.rs +++ b/lib/crates/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(), @@ -915,7 +915,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/crates/fabro-server/src/server/handler/playground.rs index 0b3111d90..046cf2ef3 100644 --- a/lib/crates/fabro-server/src/server/handler/playground.rs +++ b/lib/crates/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, + 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.to_string()), tools: Some(playground_tools()), tool_choice: Some(ToolChoice::Auto), response_format: None, @@ -137,22 +159,15 @@ 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(); - } + let request = match client.resolve_request(&request) { + Ok(request) => request, + Err(error) => return ApiError::bad_request(error.to_string()).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 +253,7 @@ mod tests { messages, workflow_fabro, model: None, + provider: None, } } diff --git a/lib/crates/fabro-server/src/server/handler/pull_requests.rs b/lib/crates/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/crates/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/crates/fabro-server/src/server/handler/runs.rs index 6ea48942f..e3e7f3dd2 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/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(_)) => { + 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, }); } diff --git a/lib/crates/fabro-server/src/server/handler/sessions.rs b/lib/crates/fabro-server/src/server/handler/sessions.rs index 3e24a1f9d..eb72bd386 100644 --- a/lib/crates/fabro-server/src/server/handler/sessions.rs +++ b/lib/crates/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,41 @@ 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 eligible = llm_result + .provider_ids() + .into_iter() + .collect::>(); + let (provider_id, model) = if let Some(model_id) = session.record.model.as_deref() { + match catalog.select(model_id, session.record.provider.as_ref(), &eligible) { + Ok(selected) => (selected.provider.clone(), selected.id.to_string()), + Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { + (provider, model_id.to_string()) + } + Err(ModelSelectionError::UnknownSelector { .. }) + if session.record.provider.is_none() => + { + let selected = catalog + .select_default(&eligible) + .map_err(|error| AskFabroBuildError::LlmUnconfigured(error.to_string()))?; + (selected.provider.clone(), model_id.to_string()) + } + Err(error) => { + return Err(AskFabroBuildError::ModelUnavailable(error.to_string())); + } + } + } else { + let selected = if let Some(provider) = session.record.provider.as_ref() { + let provider_only = std::collections::HashSet::from([provider.clone()]); + catalog + .select_default(&provider_only) + .map_err(|error| AskFabroBuildError::ModelUnavailable(error.to_string()))? + } else { + catalog + .select_default(&eligible) + .map_err(|error| AskFabroBuildError::LlmUnconfigured(error.to_string()))? + }; + (selected.provider.clone(), selected.id.to_string()) }; - let provider_id = selected.provider.clone(); - let model = selected.id.clone(); let profile_kind = catalog .effective_agent_profile(&provider_id, Some(&model)) .ok_or_else(|| { @@ -805,79 +848,117 @@ 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 eligible = if let Some(provider) = explicit_provider.as_ref() { + let provider_is_ready = eligible.iter().any(|eligible_provider| { + catalog + .provider(eligible_provider) + .is_some_and(|eligible_provider| eligible_provider.id == *provider) + }); + if !provider_is_ready { + return Err(session_selection_error( + &ModelSelectionError::ProviderUnavailable { + provider: provider.clone(), + }, + )); + } + std::collections::HashSet::from([provider.clone()]) + } else { + eligible.clone() + }; + let model = catalog + .select_default(&eligible) + .map_err(|error| session_selection_error(&error))?; + return Ok((model.provider.clone(), model.id.to_string())); }; let requested = requested.trim(); if requested.is_empty() { return Err(ApiError::bad_request("Session model must not be empty.")); } + if let Some((provider, model)) = catalog::retired_model_replacement(requested) { + return Err(session_selection_error( + &ModelSelectionError::RetiredModelIdentifier { + identifier: requested.to_string(), + provider, + 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()); + match catalog.select(&model, provider, eligible) { + Ok(selected) => Ok((selected.provider.clone(), selected.id.to_string())), + Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { + Ok((provider, model)) + } + Err(ModelSelectionError::UnknownSelector { .. }) if provider.is_none() => { + let selected = catalog + .select_default(eligible) + .map_err(|error| session_selection_error(&error))?; + Ok((selected.provider.clone(), model)) + } + Err(error) => Err(session_selection_error(&error)), } } -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 +1356,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_rejects_retired_wire_identifier_before_qualification() { + let catalog = Catalog::from_builtin().unwrap(); + let error = canonical_session_model( + &catalog, + &catalog.all_provider_ids(), + Some("openai/gpt-5.6-sol"), + None, + ) + .unwrap_err(); + + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert!( + error + .into_response_entry() + .detail + .contains("openrouter/gpt-5.6-sol") + ); + } + #[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/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index f7fed5606..ca29053a9 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/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 offering on an eligible provider")), + "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] @@ -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(), @@ -6092,6 +6106,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"); @@ -6390,7 +6413,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(), @@ -6401,7 +6424,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(); @@ -6409,9 +6432,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( @@ -13481,7 +13703,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 { @@ -13500,7 +13722,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 { @@ -13567,7 +13789,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, @@ -13584,7 +13806,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, @@ -13611,7 +13833,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, @@ -13620,7 +13842,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 @@ -13630,7 +13852,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, @@ -13639,7 +13861,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 @@ -13727,6 +13949,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() @@ -13892,6 +14125,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)); @@ -14771,42 +15005,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); @@ -14834,9 +15078,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/spawn_env.rs b/lib/crates/fabro-server/src/spawn_env.rs index e4c58f2e1..bfea6b038 100644 --- a/lib/crates/fabro-server/src/spawn_env.rs +++ b/lib/crates/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/test_support.rs b/lib/crates/fabro-server/src/test_support.rs index 4394c02a9..8fe368fff 100644 --- a/lib/crates/fabro-server/src/test_support.rs +++ b/lib/crates/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/tests/it/api/automations.rs b/lib/crates/fabro-server/tests/it/api/automations.rs index 4a2b96de0..f17bab0e8 100644 --- a/lib/crates/fabro-server/tests/it/api/automations.rs +++ b/lib/crates/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/events.rs b/lib/crates/fabro-server/tests/it/api/events.rs index e29b51b21..a5c8153c7 100644 --- a/lib/crates/fabro-server/tests/it/api/events.rs +++ b/lib/crates/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/sessions.rs b/lib/crates/fabro-server/tests/it/api/sessions.rs index dc8217198..c989aa4c3 100644 --- a/lib/crates/fabro-server/tests/it/api/sessions.rs +++ b/lib/crates/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 = 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-store/src/run_sessions.rs b/lib/crates/fabro-store/src/run_sessions.rs index 4fe396c72..a5998baed 100644 --- a/lib/crates/fabro-store/src/run_sessions.rs +++ b/lib/crates/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/crates/fabro-store/src/run_state.rs index ec67aa4f0..0d21ba098 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -1036,7 +1036,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| { diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index bfa267320..9a3e9bc86 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/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 diff --git a/lib/crates/fabro-types/src/run_event/agent.rs b/lib/crates/fabro-types/src/run_event/agent.rs index 64b2ede3d..ada131b09 100644 --- a/lib/crates/fabro-types/src/run_event/agent.rs +++ b/lib/crates/fabro-types/src/run_event/agent.rs @@ -382,7 +382,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/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index e76558f3e..bfb396663 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -2135,7 +2135,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(), @@ -2187,7 +2187,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/session.rs b/lib/crates/fabro-types/src/run_event/session.rs index a44c04003..504d4f653 100644 --- a/lib/crates/fabro-types/src/run_event/session.rs +++ b/lib/crates/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)] diff --git a/lib/crates/fabro-types/src/session.rs b/lib/crates/fabro-types/src/session.rs index 4e704e239..eaad05181 100644 --- a/lib/crates/fabro-types/src/session.rs +++ b/lib/crates/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, diff --git a/lib/crates/fabro-types/src/settings/model_ref.rs b/lib/crates/fabro-types/src/settings/model_ref.rs index ee912ff51..eb12263da 100644 --- a/lib/crates/fabro-types/src/settings/model_ref.rs +++ b/lib/crates/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-validate/src/rules/model_support.rs b/lib/crates/fabro-validate/src/rules/model_support.rs index 91df02991..7bab56a87 100644 --- a/lib/crates/fabro-validate/src/rules/model_support.rs +++ b/lib/crates/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-workflow/Cargo.toml b/lib/crates/fabro-workflow/Cargo.toml index ca74a4878..fc7166d7a 100644 --- a/lib/crates/fabro-workflow/Cargo.toml +++ b/lib/crates/fabro-workflow/Cargo.toml @@ -13,6 +13,9 @@ readme = "README.md" [lib] doctest = false +[features] +test-support = [] + [lints] workspace = true diff --git a/lib/crates/fabro-workflow/src/billing_rollup.rs b/lib/crates/fabro-workflow/src/billing_rollup.rs index 9762b27f4..4ba8976bc 100644 --- a/lib/crates/fabro-workflow/src/billing_rollup.rs +++ b/lib/crates/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/error.rs b/lib/crates/fabro-workflow/src/error.rs index 241787868..71ded36ac 100644 --- a/lib/crates/fabro-workflow/src/error.rs +++ b/lib/crates/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; @@ -262,6 +264,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 +453,8 @@ impl Error { Self::Parse(_) | Self::Validation(_) | Self::ValidationFailed { .. } + | Self::ModelSelection(_) + | Self::ModelReference(_) | Self::Template { .. } | Self::Stylesheet(_) | Self::Checkpoint(_) @@ -466,6 +476,8 @@ impl Error { Self::Parse(_) | Self::Validation(_) | Self::ValidationFailed { .. } + | Self::ModelSelection(_) + | Self::ModelReference(_) | Self::Template { .. } | Self::Stylesheet(_) | Self::Checkpoint(_) diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 5f0fc9959..8aa10c200 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -2112,7 +2112,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(), @@ -2140,7 +2140,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 { @@ -2192,7 +2192,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/stored_fields.rs b/lib/crates/fabro-workflow/src/event/stored_fields.rs index 4ada17b67..285bd9c89 100644 --- a/lib/crates/fabro-workflow/src/event/stored_fields.rs +++ b/lib/crates/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/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index c1f27a7a3..9cb5469bb 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -935,7 +935,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 +964,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 +983,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 +1034,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 { @@ -1513,7 +1515,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, diff --git a/lib/crates/fabro-workflow/src/handler/llm/preamble.rs b/lib/crates/fabro-workflow/src/handler/llm/preamble.rs index 6d1bd106d..fbceff0d7 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/preamble.rs +++ b/lib/crates/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/routing.rs b/lib/crates/fabro-workflow/src/handler/llm/routing.rs index 952c31939..0bb05b216 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/routing.rs @@ -58,10 +58,14 @@ pub(crate) fn resolve_provider_context( })? .id .clone() - } else if let Some(model) = catalog.get(model) { - model.provider.clone() } else { - default_provider_id.clone() + 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/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 7d42f2c35..488bb5c74 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/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,97 @@ mod tests { assert!(created.run_dir.is_dir()); } + #[tokio::test] + async fn create_materializes_shared_alias_for_ready_provider_snapshot_and_pin() { + const ALIAS_DOT: &str = r#"digraph Test { + graph [goal="Test"] + start [shape=Mdiamond] + work [prompt="Do work", model="gpt-56-sol"] + 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 (ready, explicit_provider, expected_provider) in cases { + let dir = tempfile::tempdir().unwrap(); + let mut settings = test_default_settings(); + settings.run.model.name = Some("gpt-56-sol".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: ALIAS_DOT.to_string(), + 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, + 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") + ); + assert_eq!( + run_spec.settings.run.model.provider.as_deref(), + Some(expected_provider.as_str()) + ); + assert_eq!( + run_spec.graph.nodes["work"] + .attrs + .get("model") + .and_then(AttrValue::as_str), + Some("gpt-5.6-sol") + ); + assert_eq!( + run_spec.graph.nodes["work"] + .attrs + .get("provider") + .and_then(AttrValue::as_str), + Some(expected_provider.as_str()) + ); + } + } + #[tokio::test] async fn create_persists_secret_tokens_in_run_created_settings_source_form() { let dir = tempfile::tempdir().unwrap(); @@ -1435,7 +1598,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 +1675,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 +1722,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 +1796,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 +1864,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/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 7fa47e938..5972d6cc4 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/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, catalog}; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::from_environment::{ daytona_config_from_environment, docker_config_from_environment_with_secrets, @@ -43,12 +43,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 +375,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 +617,25 @@ 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 eligible = configured.iter().cloned().collect::>(); + let (model, provider) = resolve_run_model( + catalog, + &eligible, + settings.model.name.as_deref(), + settings.model.provider.as_deref(), + )?; + let provider_id = ProviderId::new( + provider.expect("catalog-backed run model resolution always selects a provider"), + ); + let provider_context = routing::resolve_provider_context( + catalog, + &provider_id, + &model, + Some(provider_id.as_str()), + )?; let provider_id = provider_context.provider_id; - let fallback_chain = resolve_fallback_chain(catalog, &provider_id, &model, &settings.model); + let fallback_chain = + resolve_fallback_chain(catalog, &provider_id, &model, &settings.model, &eligible)?; Ok(ResolvedStartLlm { model, @@ -636,44 +646,87 @@ 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 { + let identifier = model_ref.to_string(); + if let Some((provider, model)) = catalog::retired_model_replacement(&identifier) { + return Err(ModelSelectionError::RetiredModelIdentifier { + identifier, + provider, + model, + } + .into()); + } + 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 +746,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) } } @@ -1229,6 +1276,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 +1342,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 +1365,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 +1375,162 @@ 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_rejects_retired_wire_identifier_before_qualification() { + let catalog = test_catalog(); + let settings = ResolvedRunModelSettings { + fallbacks: vec!["openai/gpt-5.6-sol".parse::().unwrap()], + ..ResolvedRunModelSettings::default() + }; + + let error = resolve_fallback_chain( + catalog.as_ref(), + &ProviderId::anthropic(), + "claude-opus-4-6", + &settings, + &catalog.all_provider_ids(), + ) + .unwrap_err(); + + assert!(matches!( + error, + Error::ModelSelection(ModelSelectionError::RetiredModelIdentifier { + identifier, + provider, + model, + }) if identifier == "openai/gpt-5.6-sol" + && provider == ProviderId::new("openrouter") + && model == fabro_model::ModelId::new("gpt-5.6-sol") + )); + } + + #[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 +1562,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 +1789,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 +1851,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 +1967,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 +2020,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 +2041,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 +2453,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/validate.rs b/lib/crates/fabro-workflow/src/operations/validate.rs index c11315d3c..a95b13920 100644 --- a/lib/crates/fabro-workflow/src/operations/validate.rs +++ b/lib/crates/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/crates/fabro-workflow/src/outcome.rs index 532d26860..7d3c7096c 100644 --- a/lib/crates/fabro-workflow/src/outcome.rs +++ b/lib/crates/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/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 1015b6972..0dcc7745c 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/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/crates/fabro-workflow/src/pipeline/transform.rs index 4f14ceeb7..6b76380d0 100644 --- a/lib/crates/fabro-workflow/src/pipeline/transform.rs +++ b/lib/crates/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/crates/fabro-workflow/src/pipeline/types.rs index 8449453a8..dc5b25c9e 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/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; @@ -324,13 +324,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/crates/fabro-workflow/src/pipeline/validate.rs index 14999afae..06120e8ce 100644 --- a/lib/crates/fabro-workflow/src/pipeline/validate.rs +++ b/lib/crates/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/run_materialization.rs b/lib/crates/fabro-workflow/src/run_materialization.rs index b6f9aa873..14ffa735a 100644 --- a/lib/crates/fabro-workflow/src/run_materialization.rs +++ b/lib/crates/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,23 +28,10 @@ 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; @@ -61,5 +52,63 @@ 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, Option), ModelSelectionError> { + if let Some(provider) = provider.filter(|provider| !provider.is_empty()) { + let requested = ProviderId::new(provider); + let provider = + catalog + .provider(&requested) + .ok_or_else(|| ModelSelectionError::UnknownProvider { + provider: requested.clone(), + })?; + let canonical_provider = provider.id.clone(); + let canonical_eligible = eligible.iter().any(|eligible| { + catalog + .provider(eligible) + .is_some_and(|provider| provider.id == canonical_provider) + }); + if !canonical_eligible { + return Err(ModelSelectionError::ProviderUnavailable { + provider: canonical_provider, + }); + } + if let Some(model) = model { + return match catalog.resolve_on_provider(&provider.id, model) { + Ok(offering) => Ok((offering.id.to_string(), Some(offering.provider.to_string()))), + Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => { + Ok((model.to_string(), Some(provider.id.to_string()))) + } + Err(error) => Err(error), + }; + } + let offering = catalog.default_for_provider(&provider.id).ok_or_else(|| { + ModelSelectionError::UnknownSelectorOnProvider { + selector: "".to_string(), + provider: provider.id.clone(), + } + })?; + return Ok((offering.id.to_string(), Some(offering.provider.to_string()))); + } + + if let Some(model) = model { + return match catalog.select(model, None, eligible) { + Ok(offering) => Ok((offering.id.to_string(), Some(offering.provider.to_string()))), + Err(ModelSelectionError::UnknownSelector { .. }) => { + let default = catalog.select_default(eligible)?; + Ok((model.to_string(), Some(default.provider.to_string()))) + } + Err(error) => Err(error), + }; + } + + let default = catalog.select_default(eligible)?; + Ok((default.id.to_string(), Some(default.provider.to_string()))) } diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index 1acf410a4..4094b539d 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/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/model_resolution.rs b/lib/crates/fabro-workflow/src/transforms/model_resolution.rs index b58f9f44e..fc130bfb1 100644 --- a/lib/crates/fabro-workflow/src/transforms/model_resolution.rs +++ b/lib/crates/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, ModelSelectionError, ProviderId}; use super::Transform; use crate::error::Error; @@ -9,19 +10,100 @@ 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> { + match self + .catalog + .select(model, explicit_provider, &self.eligible_providers) + { + Ok(info) => Ok((info.id.to_string(), info.provider.clone())), + Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { + Ok((model.to_string(), provider)) + } + Err(ModelSelectionError::UnknownSelector { .. }) => { + let provider = self + .catalog + .select_default(&self.eligible_providers)? + .provider + .clone(); + Ok((model.to_string(), provider)) + } + Err(error) => Err(error.into()), + } } } 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 +111,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 +200,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 +209,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 +220,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 +243,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 +337,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/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 3ceeced5d..086b443af 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -4680,15 +4680,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(), &[]); diff --git a/lib/crates/fabro-workflow/tests/materialize_run.rs b/lib/crates/fabro-workflow/tests/materialize_run.rs index 77fdf6dd0..4fec97585 100644 --- a/lib/crates/fabro-workflow/tests/materialize_run.rs +++ b/lib/crates/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/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 6c4bc5e4e..57f760daa 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -293,6 +293,8 @@ models/principal-webhook.ts models/principal-worker.ts models/principal.ts models/project-namespace.ts +models/provider-credential-test-request.ts +models/provider-credential-test-response.ts models/provider-list.ts models/provider-test-list.ts models/provider-test-result.ts 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 6b19c88c3..03a90fac4 100644 --- a/lib/packages/fabro-api-client/src/api/models-api.ts +++ b/lib/packages/fabro-api-client/src/api/models-api.ts @@ -30,6 +30,10 @@ import type { ModelTestResult } from '../models'; // @ts-ignore import type { PaginatedModelList } from '../models'; // @ts-ignore +import type { ProviderCredentialTestRequest } from '../models'; +// @ts-ignore +import type { ProviderCredentialTestResponse } from '../models'; +// @ts-ignore import type { ProviderList } from '../models'; // @ts-ignore import type { ProviderTestList } from '../models'; @@ -39,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`. @@ -133,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` @@ -160,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; } @@ -175,6 +184,51 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio options: localVarRequestOptions, }; }, + /** + * 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: async (provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'provider' is not null or undefined + assertParamExists('testProviderCredentials', 'provider', provider) + // verify required parameter 'providerCredentialTestRequest' is not null or undefined + assertParamExists('testProviderCredentials', 'providerCredentialTestRequest', providerCredentialTestRequest) + const localVarPath = `/api/v1/providers/{provider}/credentials/test` + .replace(`{${"provider"}}`, encodeURIComponent(String(provider))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(providerCredentialTestRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200. * @summary Test Providers @@ -221,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`. @@ -251,17 +305,32 @@ 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); }, + /** + * 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} + */ + async testProviderCredentials(provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.testProviderCredentials(provider, providerCredentialTestRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ModelsApi.testProviderCredentials']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200. * @summary Test Providers @@ -284,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`. @@ -308,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)); }, /** * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200. @@ -333,7 +414,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`. @@ -359,13 +440,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/hook-definition.ts b/lib/packages/fabro-api-client/src/models/hook-definition.ts index a7d2f7cf1..85092d14f 100644 --- a/lib/packages/fabro-api-client/src/models/hook-definition.ts +++ b/lib/packages/fabro-api-client/src/models/hook-definition.ts @@ -27,6 +27,9 @@ export interface HookDefinition { 'type'?: HookDefinitionTypeEnum | null; 'url'?: string | null; 'headers'?: { [key: string]: string; } | null; + /** + * Allowlist of environment variable names that an http hook header may read via `{{ env.NAME }}`. An empty list (the default) permits no env vars in headers. + */ 'allowed_env_vars'?: Array; 'tls'?: TlsMode; 'prompt'?: string | null; diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 7b51ee78c..c8c1e5538 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -264,6 +264,8 @@ export * from './principal-webhook'; export * from './principal-worker'; export * from './project-namespace'; export * from './provider'; +export * from './provider-credential-test-request'; +export * from './provider-credential-test-response'; export * from './provider-list'; export * from './provider-test-list'; export * from './provider-test-result'; 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/provider-credential-test-request.ts b/lib/packages/fabro-api-client/src/models/provider-credential-test-request.ts new file mode 100644 index 000000000..390b00848 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/provider-credential-test-request.ts @@ -0,0 +1,22 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * API key to validate against an LLM provider without persisting it. + */ +export interface ProviderCredentialTestRequest { + 'api_key': string; +} diff --git a/lib/packages/fabro-api-client/src/models/provider-credential-test-response.ts b/lib/packages/fabro-api-client/src/models/provider-credential-test-response.ts new file mode 100644 index 000000000..b74a688eb --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/provider-credential-test-response.ts @@ -0,0 +1,22 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Successful response from provider credential validation. + */ +export interface ProviderCredentialTestResponse { + 'ok': boolean; +} 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; From 05e65070954d2dcbb7588eeb9a35fac455c7019a Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 11:07:47 -0400 Subject: [PATCH 02/45] refactor: unify model selection and legacy-model normalization Consolidate duplicated resolution logic introduced by the provider-aware model alias work: - Add Catalog::resolve_selection (with SelectedModel and ready_provider) as the single home for the select -> passthrough -> default-fallback policy, replacing six hand-rolled copies across fabro-server, fabro-workflow, and fabro-llm. - Move legacy [models] row resolution into a shared LegacyModelIndex and LegacyModelError in fabro-model; delete fabro-config's parallel implementation and its LlmNormalizationError enum, plus the now-unused builtin_* catalog helpers. - Drop redundant client.resolve_request calls (and their full-request clones) from the completions and playground handlers. - Remove the redundant resolve_provider_context round-trip in resolve_start_llm and make resolve_run_model return a ProviderId instead of a never-None Option. - Replace the "" sentinel selector with a dedicated ModelSelectionError::NoDefaultModel variant. - Add a CatalogRoute trait so provider adapters call self.api_model_id(...) instead of threading catalog/provider args. - Delete the unused FromStr impl for ModelId; dedupe the CLI's id-or-alias predicate. Co-Authored-By: Claude Fable 5 --- lib/crates/fabro-cli/src/commands/model.rs | 13 +- lib/crates/fabro-config/src/layers/llm.rs | 284 ++------- lib/crates/fabro-config/src/layers/mod.rs | 6 +- lib/crates/fabro-config/src/lib.rs | 25 +- lib/crates/fabro-config/src/parse.rs | 13 +- lib/crates/fabro-llm/src/client.rs | 17 +- .../fabro-llm/src/providers/anthropic.rs | 39 +- .../fabro-llm/src/providers/bedrock/mod.rs | 30 +- lib/crates/fabro-llm/src/providers/common.rs | 17 + lib/crates/fabro-llm/src/providers/gemini.rs | 36 +- lib/crates/fabro-llm/src/providers/openai.rs | 36 +- .../src/providers/openai_compatible.rs | 20 +- lib/crates/fabro-model/src/catalog.rs | 568 +++++++++++------- lib/crates/fabro-model/src/ids.rs | 8 - lib/crates/fabro-model/src/lib.rs | 2 +- .../src/server/handler/completions.rs | 59 +- .../fabro-server/src/server/handler/models.rs | 3 +- .../src/server/handler/playground.rs | 8 +- .../src/server/handler/sessions.rs | 83 +-- lib/crates/fabro-server/src/server/tests.rs | 2 +- .../fabro-workflow/src/operations/start.rs | 13 +- .../fabro-workflow/src/run_materialization.rs | 59 +- .../src/transforms/model_resolution.rs | 26 +- 23 files changed, 560 insertions(+), 807 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index d5d41bb20..4e5bd6f39 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -45,6 +45,10 @@ struct CompletedModelTest { status: String, } +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, @@ -54,7 +58,7 @@ fn find_model_by_id_or_alias( .iter() .find(|model| { provider.is_none_or(|provider| &model.provider == provider) - && (model.id == id || model.aliases.iter().any(|alias| alias == id)) + && model_matches_selector(model, id) }) .cloned() } @@ -275,10 +279,9 @@ async fn test_models_via_server( if !json_output { eprint!("Testing {model_id}..."); } - let has_configured_match = listed_models.iter().any(|model| { - model.configured - && (model.id == model_id || model.aliases.iter().any(|alias| alias == model_id)) - }); + 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 diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs index 32169b980..ca29d2bbd 100644 --- a/lib/crates/fabro-config/src/layers/llm.rs +++ b/lib/crates/fabro-config/src/layers/llm.rs @@ -25,7 +25,7 @@ //! Resolution against the static adapter registry happens in `fabro-model` //! when the resolved [`Catalog`](fabro_model::Catalog) is built. -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, HashMap}; use fabro_model::catalog::deserialize_knowledge_cutoff; use fabro_model::{ @@ -160,90 +160,16 @@ pub struct ModelSettings { pub costs: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LlmNormalizationError { - BuiltinCatalog { - message: String, - }, - RetiredModel { - identifier: String, - provider: ProviderId, - model: ModelId, - }, - UnknownLegacyModel { - model: String, - }, - AmbiguousLegacyModel { - model: String, - candidates: Vec<(ProviderId, ModelId)>, - }, - AmbiguousProviderModel { - provider: ProviderId, - selector: String, - models: Vec, - }, - DuplicateModelDefinition { - provider: ProviderId, - model: ModelId, - }, - ScopedModelDeclaresProvider { - provider: ProviderId, - model: ModelId, - }, -} - -impl std::fmt::Display for LlmNormalizationError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::BuiltinCatalog { message } => { - write!(f, "failed to inspect the built-in model catalog: {message}") - } - Self::RetiredModel { - identifier, - provider, - model, - } => write!( - f, - "model identifier '{identifier}' has been retired; use '{provider}/{model}' instead" - ), - Self::UnknownLegacyModel { model } => write!( - f, - "legacy model row '{model}' omits provider and does not match a unique known offering" - ), - Self::AmbiguousLegacyModel { model, candidates } => write!( - f, - "legacy model row '{model}' omits provider and matches multiple offerings: {candidates:?}" - ), - Self::AmbiguousProviderModel { - provider, - selector, - models, - } => write!( - f, - "legacy model selector '{selector}' is ambiguous on provider '{provider}': {models:?}" - ), - Self::DuplicateModelDefinition { provider, model } => write!( - f, - "provider '{provider}' model '{model}' is defined through both provider-scoped and legacy top-level syntax" - ), - Self::ScopedModelDeclaresProvider { provider, model } => write!( - f, - "provider-scoped model '{provider}/{model}' must not declare a provider field" - ), - } - } -} - -impl std::error::Error for LlmNormalizationError {} - impl LlmLayer { /// Normalize the temporary legacy model table before this source is - /// combined with any other settings source. - pub(crate) fn normalize_legacy_models(&mut self) -> Result<(), LlmNormalizationError> { + /// 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(LlmNormalizationError::ScopedModelDeclaresProvider { + return Err(catalog::LegacyModelError::ScopedModelDeclaresProvider { provider: ProviderId::new(provider.clone()), model: ModelId::new(model.clone()), }); @@ -251,46 +177,45 @@ impl LlmLayer { } } - let mut legacy_models = std::mem::take(&mut self.models.0) - .into_iter() - .collect::>(); + 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)); - for (legacy_id, mut settings) in legacy_models { - if let Some((provider, model)) = catalog::retired_model_replacement(&legacy_id) { - return Err(LlmNormalizationError::RetiredModel { - identifier: legacy_id, - provider, - model, - }); - } + 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) = if let Some(provider) = explicit_provider { - let provider = self.canonical_provider(&provider)?; - let model = self - .canonical_model_on_provider(&provider, &legacy_id)? - .unwrap_or_else(|| ModelId::new(legacy_id.clone())); - (provider, model) - } else { - let candidates = self.model_candidates(&legacy_id)?; - match candidates.as_slice() { - [(provider, model)] => (provider.clone(), model.clone()), - [] => { - return Err(LlmNormalizationError::UnknownLegacyModel { model: legacy_id }); - } - _ => { - return Err(LlmNormalizationError::AmbiguousLegacyModel { - model: legacy_id, - candidates, - }); - } - } - }; + 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(LlmNormalizationError::DuplicateModelDefinition { provider, model }); + return Err(catalog::LegacyModelError::DuplicateModel { provider, model }); } provider_settings .models @@ -298,139 +223,6 @@ impl LlmLayer { } Ok(()) } - - fn canonical_provider(&self, selector: &str) -> Result { - if self.providers.contains_key(selector) { - return Ok(ProviderId::new(selector)); - } - let mut aliases = self - .providers - .iter() - .filter(|(_, settings)| { - settings - .aliases - .as_ref() - .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) - }) - .map(|(provider, _)| ProviderId::new(provider.clone())) - .collect::>(); - aliases.sort(); - if let Some(provider) = aliases.into_iter().next() { - return Ok(provider); - } - catalog::builtin_provider_id(selector) - .map_err(|error| normalization_catalog_error(&error)) - .map(|provider| provider.unwrap_or_else(|| ProviderId::new(selector))) - } - - fn canonical_model_on_provider( - &self, - provider: &ProviderId, - selector: &str, - ) -> Result, LlmNormalizationError> { - let current = self - .providers - .get(provider.as_str()) - .map(|settings| &settings.models); - if current.is_some_and(|models| models.contains_key(selector)) { - return Ok(Some(ModelId::new(selector))); - } - - let builtin_canonical = catalog::builtin_canonical_model_offerings(selector) - .map_err(|error| normalization_catalog_error(&error))? - .into_iter() - .find_map(|(candidate_provider, model)| { - (candidate_provider == *provider).then_some(model) - }); - if builtin_canonical.is_some() { - return Ok(builtin_canonical); - } - - let mut aliases = current - .into_iter() - .flat_map(|models| models.iter()) - .filter(|(_, settings)| { - settings - .aliases - .as_ref() - .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) - }) - .map(|(model, _)| ModelId::new(model.clone())) - .collect::>(); - aliases.extend( - catalog::builtin_alias_model_offerings(selector) - .map_err(|error| normalization_catalog_error(&error))? - .into_iter() - .filter_map(|(candidate_provider, model)| { - (candidate_provider == *provider).then_some(model) - }), - ); - match aliases.len() { - 0 => Ok(None), - 1 => Ok(aliases.into_iter().next()), - _ => Err(LlmNormalizationError::AmbiguousProviderModel { - provider: provider.clone(), - selector: selector.to_string(), - models: aliases.into_iter().collect(), - }), - } - } - - fn model_candidates( - &self, - selector: &str, - ) -> Result, LlmNormalizationError> { - let mut canonical = self - .providers - .iter() - .filter(|(_, settings)| settings.models.contains_key(selector)) - .map(|(provider, _)| { - ( - ProviderId::new(provider.clone()), - ModelId::new(selector.to_string()), - ) - }) - .collect::>(); - canonical.extend( - catalog::builtin_canonical_model_offerings(selector) - .map_err(|error| normalization_catalog_error(&error))?, - ); - if !canonical.is_empty() { - return Ok(canonical.into_iter().collect()); - } - - let mut aliases = - self.providers - .iter() - .flat_map(|(provider, settings)| { - settings - .models - .iter() - .filter(move |(_, settings)| { - settings.aliases.as_ref().is_some_and(|aliases| { - aliases.iter().any(|alias| alias == selector) - }) - }) - .map(move |(model, _)| { - ( - ProviderId::new(provider.clone()), - ModelId::new(model.clone()), - ) - }) - }) - .collect::>(); - aliases.extend( - catalog::builtin_alias_model_offerings(selector) - .map_err(|error| normalization_catalog_error(&error))?, - ); - Ok(aliases.into_iter().collect()) - } -} - -fn normalization_catalog_error(error: &catalog::CatalogBuildError) -> LlmNormalizationError { - LlmNormalizationError::BuiltinCatalog { - message: error.to_string(), - } } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs index c25838781..c3fa1632c 100644 --- a/lib/crates/fabro-config/src/layers/mod.rs +++ b/lib/crates/fabro-config/src/layers/mod.rs @@ -21,9 +21,9 @@ pub use environment::{ EnvironmentNetworkLayer, EnvironmentResourcesLayer, RunEnvironmentLayer, }; pub use llm::{ - CostRates, CredentialRef, CredentialRefParseError, LlmLayer, LlmNormalizationError, - ModelControls, ModelCostTable, ModelFeatures as LlmModelFeatures, - ModelLimits as LlmModelLimits, ModelSettings, ProviderSettings, ReasoningEffortFeature, + CostRates, CredentialRef, CredentialRefParseError, LlmLayer, ModelControls, ModelCostTable, + ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, ModelSettings, + ProviderSettings, ReasoningEffortFeature, }; pub use log_filter::LogFilter; pub use maps::{MergeMap, ReplaceMap, StickyMap}; diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 694ab1773..f6097b9be 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -46,19 +46,18 @@ pub use layers::{ CredentialRefParseError, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, GitAuthorLayer, GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, - InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, - LlmNormalizationError, LogFilter, McpEntryLayer, MergeMap, ModelControls, ModelCostTable, - ModelRefOrSplice, ModelSettings, NotificationProviderLayer, NotificationRouteLayer, - ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, ProviderSettings, - ReasoningEffortFeature, ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, - RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, - RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, - RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, - RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, - ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, - ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer, - ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer, - SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, + InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, + McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings, + NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, + PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer, + RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, + RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, + RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, + RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, + ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, + ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer, + ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, + ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, }; pub use logging::{resolve_log_destination, resolve_log_destination_with_env}; pub use parse::ParseError; diff --git a/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs index 025e0b130..fb72c35af 100644 --- a/lib/crates/fabro-config/src/parse.rs +++ b/lib/crates/fabro-config/src/parse.rs @@ -1,7 +1,8 @@ use std::fmt; +use fabro_model::catalog::LegacyModelError; + use crate::SettingsLayer; -use crate::layers::LlmNormalizationError; const CURRENT_VERSION: u32 = 1; @@ -31,7 +32,7 @@ const LEGACY_LLM_KEYS: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParseError { Toml(String), - LlmCatalog(LlmNormalizationError), + LlmCatalog(LegacyModelError), Version(VersionError), UnknownTopLevelKey { key: String, @@ -325,7 +326,7 @@ provider = "openai" assert!(matches!( error, - ParseError::LlmCatalog(LlmNormalizationError::ScopedModelDeclaresProvider { + ParseError::LlmCatalog(LegacyModelError::ScopedModelDeclaresProvider { provider, model, }) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4" @@ -398,7 +399,7 @@ display_name = "Ambiguous" assert!(matches!( error, - ParseError::LlmCatalog(LlmNormalizationError::AmbiguousLegacyModel { + ParseError::LlmCatalog(LegacyModelError::AmbiguousModel { model, candidates, }) if model == "gpt-5.6-sol" && candidates.len() >= 2 @@ -420,7 +421,7 @@ display_name = "Legacy" assert!(matches!( error, - ParseError::LlmCatalog(LlmNormalizationError::DuplicateModelDefinition { + ParseError::LlmCatalog(LegacyModelError::DuplicateModel { provider, model, }) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4" @@ -438,7 +439,7 @@ provider = "openrouter" assert!(matches!( error, - ParseError::LlmCatalog(LlmNormalizationError::RetiredModel { + ParseError::LlmCatalog(LegacyModelError::Retired { identifier, provider, model, diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index e40a8ede2..446b0b77a 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -309,20 +309,19 @@ impl Client { let eligible = self.eligible_provider_ids(); if let Some(explicit) = request.provider.as_deref() { let explicit = ProviderId::new(explicit); - if let Some(catalog_provider) = catalog.provider(&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(catalog_provider.id.as_str()) + .provider_adapter(selected.provider.as_str()) .ok_or_else(|| { selection_error(ModelSelectionError::ProviderUnavailable { - provider: catalog_provider.id.clone(), + provider: selected.provider.clone(), }) })?; - match catalog.resolve_on_provider(&catalog_provider.id, &request.model) { - Ok(model) => resolved.model = model.id.to_string(), - Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => {} - Err(error) => return Err(selection_error(error)), - } - resolved.provider = Some(catalog_provider.id.to_string()); + resolved.model = selected.model; + resolved.provider = Some(selected.provider.into_inner()); return Ok(ResolvedRequest { provider, request: resolved, diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index 539b2b0fc..83894e9a9 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/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,11 +118,7 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model( - self.catalog.as_deref(), - &self.provider_name, - &request.model, - ), + model: self.catalog_model(&request.model), params, } } @@ -214,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 { @@ -232,11 +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(), - &self.provider_name, - &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 { @@ -272,11 +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(), - &self.provider_name, - &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)?; @@ -293,11 +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(), - &self.provider_name, - &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)?; @@ -323,8 +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(), &self.provider_name, &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/mod.rs b/lib/crates/fabro-llm/src/providers/bedrock/mod.rs index 97c15dc02..deca69bd0 100644 --- a/lib/crates/fabro-llm/src/providers/bedrock/mod.rs +++ b/lib/crates/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,11 +169,7 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model( - self.catalog.as_deref(), - &self.provider_name, - &request.model, - ), + model: self.catalog_model(&request.model), params, } } @@ -232,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 { @@ -243,11 +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(), - &self.provider_name, - &resolved.model, - ); + let deployment_id = self.api_model_id(&resolved.model); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -261,11 +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(), - &self.provider_name, - &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/common.rs b/lib/crates/fabro-llm/src/providers/common.rs index f85397935..6eff3b727 100644 --- a/lib/crates/fabro-llm/src/providers/common.rs +++ b/lib/crates/fabro-llm/src/providers/common.rs @@ -20,6 +20,23 @@ pub fn api_model_id(catalog: Option<&Catalog>, provider: &str, model: &str) -> S .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/gemini.rs b/lib/crates/fabro-llm/src/providers/gemini.rs index a9e2fab0f..e2a33337b 100644 --- a/lib/crates/fabro-llm/src/providers/gemini.rs +++ b/lib/crates/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,11 +102,7 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model( - self.catalog.as_deref(), - &self.provider_name, - &request.model, - ), + model: self.catalog_model(&request.model), params, } } @@ -129,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 { @@ -151,11 +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(), - &self.provider_name, - &resolved.model, - ); + let deployment_id = self.api_model_id(&resolved.model); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -185,11 +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(), - &self.provider_name, - &resolved.model, - ); + let deployment_id = self.api_model_id(&resolved.model); let params = CodecParams::default(); let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); @@ -206,11 +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(), - &self.provider_name, - &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/openai.rs b/lib/crates/fabro-llm/src/providers/openai.rs index 185af1714..56eb76c3d 100644 --- a/lib/crates/fabro-llm/src/providers/openai.rs +++ b/lib/crates/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,11 +131,7 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model( - self.catalog.as_deref(), - &self.provider_name, - &request.model, - ), + model: self.catalog_model(&request.model), params, } } @@ -196,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 { @@ -218,11 +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(), - &self.provider_name, - &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); @@ -258,11 +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(), - &self.provider_name, - &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); @@ -279,11 +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(), - &self.provider_name, - &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/crates/fabro-llm/src/providers/openai_compatible.rs index 3feb0b577..b46444636 100644 --- a/lib/crates/fabro-llm/src/providers/openai_compatible.rs +++ b/lib/crates/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(), &self.provider_name, &request.model) + self.api_model_id(&request.model) } /// Build the borrowed codec context. `deployment_id` and `params` are @@ -103,11 +103,7 @@ impl Adapter { request, provider_name: &self.provider_name, deployment_id, - model: common::catalog_model( - self.catalog.as_deref(), - &self.provider_name, - &request.model, - ), + model: self.catalog_model(&request.model), params, } } @@ -131,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-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index ee362d414..ea089cfba 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -544,33 +544,8 @@ pub enum CatalogBuildError { first: ModelId, second: ModelId, }, - #[error( - "provider '{provider}' model '{model}' is defined through both provider-scoped and legacy top-level syntax" - )] - DuplicateProviderModelDefinition { - provider: ProviderId, - model: ModelId, - }, - #[error("provider-scoped model '{provider}/{model}' must not declare a provider field")] - ScopedModelDeclaresProvider { - provider: ProviderId, - model: ModelId, - }, - #[error("legacy model row '{model}' omits provider and does not match a unique known offering")] - LegacyModelProviderUnknown { model: String }, - #[error( - "legacy model row '{model}' omits provider and matches multiple providers: {providers:?}" - )] - LegacyModelProviderAmbiguous { - model: String, - providers: Vec, - }, - #[error("model identifier '{identifier}' has been retired; use '{provider}/{model}' instead")] - RetiredModelIdentifier { - identifier: String, - provider: ProviderId, - model: ModelId, - }, + #[error(transparent)] + LegacyModel(#[from] LegacyModelError), #[error("provider '{provider}' model '{model}' has an empty api_id")] EmptyModelApiId { provider: ProviderId, @@ -637,6 +612,10 @@ pub enum ModelSelectionError { selector: String, providers: Vec, }, + #[error( + "no default model is available on an eligible provider; providers with defaults: {providers:?}" + )] + NoDefaultModel { providers: Vec }, #[error("model identifier '{identifier}' has been retired; use '{provider}/{model}' instead")] RetiredModelIdentifier { identifier: String, @@ -645,6 +624,15 @@ pub enum ModelSelectionError { }, } +/// 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. @@ -724,13 +712,14 @@ impl Catalog { } if let Some((replacement_provider, replacement_model)) = - retired_model_address(&model_id) + retired_model_replacement(&model_id) { - return Err(CatalogBuildError::RetiredModelIdentifier { + return Err(LegacyModelError::Retired { identifier: model_id, provider: replacement_provider, model: replacement_model, - }); + } + .into()); } let (model, resolved_settings) = build_model(&model_id, model_settings, provider)?; @@ -905,7 +894,8 @@ impl Catalog { provider: &ProviderId, selector: &str, ) -> Result<&Model, ModelSelectionError> { - if let Some((replacement_provider, replacement_model)) = retired_model_address(selector) { + if let Some((replacement_provider, replacement_model)) = retired_model_replacement(selector) + { return Err(ModelSelectionError::RetiredModelIdentifier { identifier: selector.to_string(), provider: replacement_provider, @@ -937,7 +927,8 @@ impl Catalog { explicit_provider: Option<&ProviderId>, eligible_providers: &HashSet, ) -> Result<&'a Model, ModelSelectionError> { - if let Some((replacement_provider, replacement_model)) = retired_model_address(selector) { + if let Some((replacement_provider, replacement_model)) = retired_model_replacement(selector) + { return Err(ModelSelectionError::RetiredModelIdentifier { identifier: selector.to_string(), provider: replacement_provider, @@ -1042,10 +1033,82 @@ impl Catalog { .collect::>(); providers.sort(); providers.dedup(); - Err(ModelSelectionError::NoEligibleOffering { - selector: "".to_string(), - providers, - }) + 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] @@ -1411,52 +1474,24 @@ fn normalize_catalog_settings( mut settings: LlmCatalogSettings, known: Option<&LlmCatalogSettings>, ) -> Result { - for (provider, settings) in &settings.providers { - for (model, settings) in &settings.models { - if settings.provider.is_some() { - return Err(CatalogBuildError::ScopedModelDeclaresProvider { - provider: ProviderId::new(provider.clone()), - model: ModelId::new(model.clone()), - }); - } - } - } + reject_scoped_provider_fields(&settings)?; let legacy_models = std::mem::take(&mut settings.models); - for (legacy_id, mut model_settings) in legacy_models { - if let Some((provider, model)) = retired_model_address(&legacy_id) { - return Err(CatalogBuildError::RetiredModelIdentifier { - identifier: legacy_id, - provider, - model, - }); - } + 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) = if let Some(provider) = explicit_provider { - let provider = canonical_settings_provider(&provider, &settings, known) - .unwrap_or_else(|| ProviderId::new(provider)); - let model_id = canonical_settings_model(&provider, &legacy_id, &settings, known) - .unwrap_or_else(|| ModelId::new(legacy_id.clone())); - (provider, model_id) - } else { - let candidates = settings_model_candidates(&legacy_id, &settings, known); - match candidates.as_slice() { - [(provider, model)] => (provider.clone(), model.clone()), - [] => { - return Err(CatalogBuildError::LegacyModelProviderUnknown { model: legacy_id }); - } - _ => { - return Err(CatalogBuildError::LegacyModelProviderAmbiguous { - model: legacy_id, - providers: candidates - .into_iter() - .map(|(provider, _)| provider) - .collect(), - }); - } - } - }; + 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())) @@ -1469,10 +1504,11 @@ fn normalize_catalog_settings( let provider_settings = settings.providers.entry(provider.to_string()).or_default(); if provider_settings.models.contains_key(model_id.as_str()) { - return Err(CatalogBuildError::DuplicateProviderModelDefinition { + return Err(LegacyModelError::DuplicateModel { provider, model: model_id, - }); + } + .into()); } provider_settings .models @@ -1481,154 +1517,242 @@ fn normalize_catalog_settings( Ok(settings) } -fn canonical_settings_provider( - selector: &str, - settings: &LlmCatalogSettings, - known: Option<&LlmCatalogSettings>, -) -> Option { - let providers = || { - settings - .providers - .iter() - .chain(known.into_iter().flat_map(|known| known.providers.iter())) - }; - providers() - .find(|(id, _)| id.as_str() == selector) - .or_else(|| { - providers().find(|(_, provider)| { - provider - .aliases - .as_ref() - .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) - }) - }) - .map(|(id, _)| ProviderId::new(id.clone())) -} - -fn canonical_settings_model( - provider: &ProviderId, - selector: &str, - settings: &LlmCatalogSettings, - known: Option<&LlmCatalogSettings>, -) -> Option { - let models = || { - settings - .providers - .get(provider.as_str()) - .into_iter() - .chain(known.and_then(|known| known.providers.get(provider.as_str()))) - .flat_map(|provider| provider.models.iter()) - }; - models() - .find(|(id, _)| id.as_str() == selector) - .or_else(|| { - models().find(|(_, model)| { - model - .aliases - .as_ref() - .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) - }) - }) - .map(|(id, _)| ModelId::new(id.clone())) -} - -fn settings_model_candidates( - selector: &str, - settings: &LlmCatalogSettings, - known: Option<&LlmCatalogSettings>, -) -> Vec<(ProviderId, ModelId)> { - let collect = |matches: &dyn Fn(&str, &ModelCatalogSettings) -> bool| { - let mut candidates = BTreeSet::<(ProviderId, ModelId)>::new(); - for (provider_id, provider) in settings - .providers - .iter() - .chain(known.into_iter().flat_map(|known| known.providers.iter())) - { - for (model_id, model) in &provider.models { - if matches(model_id, model) { - candidates.insert(( - ProviderId::new(provider_id.clone()), - ModelId::new(model_id.clone()), - )); - } +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()), + }); } } - candidates.into_iter().collect::>() - }; - - let canonical = collect(&|model_id, _| model_id == selector); - if canonical.is_empty() { - collect(&|_, model| { - model - .aliases - .as_ref() - .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) - }) - } else { - canonical } + Ok(()) } -/// Return every built-in offering whose canonical model ID is `selector`. -/// -/// This includes disabled providers because config compatibility -/// normalization happens before runtime availability is known. -pub fn builtin_canonical_model_offerings( - selector: &str, -) -> Result, CatalogBuildError> { - let settings = Catalog::builtin_settings()?; - Ok(settings - .providers - .iter() - .filter_map(|(provider, settings)| { - settings.models.get_key_value(selector).map(|(model, _)| { - ( - ProviderId::new(provider.clone()), - ModelId::new(model.clone()), - ) - }) - }) - .collect()) +/// 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("model identifier '{identifier}' has been retired; use '{provider}/{model}' instead")] + Retired { + 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, + }, } -/// Return every built-in offering that declares `selector` as an alias. +/// Identifier/alias view used to resolve legacy top-level `[models.]` +/// rows onto their provider before provider-scoped settings merge. /// -/// This includes disabled providers because config compatibility -/// normalization happens before runtime availability is known. -pub fn builtin_alias_model_offerings( - selector: &str, -) -> Result, CatalogBuildError> { - let settings = Catalog::builtin_settings()?; - let mut offerings = settings - .providers - .iter() - .flat_map(|(provider, settings)| { - settings - .models - .iter() - .filter(move |(_, settings)| { - settings - .aliases - .as_ref() - .is_some_and(|aliases| aliases.iter().any(|alias| alias == selector)) - }) - .map(move |(model, _)| { +/// 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]; ( - ProviderId::new(provider.clone()), - ModelId::new(model.clone()), + ModelId::new(model_id.clone()), + model.aliases.clone().unwrap_or_default(), ) - }) - }) - .collect::>(); - offerings.sort(); - offerings.dedup(); - Ok(offerings) -} + }), + ); + } + } -/// Resolve a built-in provider ID or alias without filtering disabled rows. -pub fn builtin_provider_id(selector: &str) -> Result, CatalogBuildError> { - let settings = Catalog::builtin_settings()?; - Ok(canonical_settings_provider(selector, &settings, None)) + /// 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. An unknown + /// explicit provider or model selector passes 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((provider, model)) = retired_model_replacement(legacy_id) { + return Err(LegacyModelError::Retired { + identifier: legacy_id.to_string(), + 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() + } } /// Built-in catalog keys retired when provider API identifiers stopped being @@ -1718,10 +1842,6 @@ pub fn retired_model_replacement(identifier: &str) -> Option<(ProviderId, ModelI .map(|(_, provider, model)| (ProviderId::new(*provider), ModelId::new(*model))) } -fn retired_model_address(identifier: &str) -> Option<(ProviderId, ModelId)> { - retired_model_replacement(identifier) -} - fn merge_catalog_settings( higher: LlmCatalogSettings, mut fallback: LlmCatalogSettings, @@ -3676,8 +3796,10 @@ provider = "test" assert!(matches!( error, - CatalogBuildError::ScopedModelDeclaresProvider { provider, model } - if provider == ProviderId::new("test") && model == "one" + CatalogBuildError::LegacyModel(LegacyModelError::ScopedModelDeclaresProvider { + provider, + model, + }) if provider == ProviderId::new("test") && model == "one" )); } diff --git a/lib/crates/fabro-model/src/ids.rs b/lib/crates/fabro-model/src/ids.rs index 3429ae094..02d6db0d7 100644 --- a/lib/crates/fabro-model/src/ids.rs +++ b/lib/crates/fabro-model/src/ids.rs @@ -150,14 +150,6 @@ impl From for ModelId { } } -impl FromStr for ModelId { - type Err = std::convert::Infallible; - - fn from_str(s: &str) -> Result { - Ok(Self::from(s)) - } -} - impl AsRef for ModelId { fn as_ref(&self) -> &str { &self.0 diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/crates/fabro-model/src/lib.rs index 337c00fdd..5f3511096 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -19,7 +19,7 @@ pub use billing::{ }; pub use catalog::{ ApiKeyHeaderPolicy, BillingPolicy, Catalog, CredentialRef, CredentialRefParseError, - FallbackTarget, ModelSelectionError, ProviderAuthConfig, + FallbackTarget, ModelSelectionError, ProviderAuthConfig, SelectedModel, }; pub use codec::CodecKind; pub use ids::{ModelId, ProviderId}; diff --git a/lib/crates/fabro-server/src/server/handler/completions.rs b/lib/crates/fabro-server/src/server/handler/completions.rs index 3d36f9036..b53e56d31 100644 --- a/lib/crates/fabro-server/src/server/handler/completions.rs +++ b/lib/crates/fabro-server/src/server/handler/completions.rs @@ -53,7 +53,7 @@ async fn create_completion( let (model_id, selected_provider) = match resolve_request_model( catalog.as_ref(), &client.provider_ids(), - req.model, + req.model.as_deref(), req.provider, ) { Ok(selection) => selection, @@ -114,20 +114,8 @@ async fn create_completion( metadata: None, provider_options: req.provider_options, }; - let request = match client.resolve_request(&request) { - Ok(request) => request, - Err(error) => return ApiError::bad_request(error.to_string()).into_response(), - }; - let selected_provider = ProviderId::new( - request - .provider - .as_deref() - .expect("resolved LLM request always has a provider"), - ); - let selected_model = request.model.clone(); - info!( - model = %selected_model, + model = %model_id, provider = %selected_provider, "Completion request received" ); @@ -177,7 +165,7 @@ async fn create_completion( let stop_reason = finish_reason_to_api_stop_reason(&response.finish_reason); Json(CompletionResponse { id: msg_id, - model: selected_model, + model: model_id, provider: selected_provider, message: response.message, stop_reason, @@ -224,44 +212,11 @@ async fn create_completion( pub(super) fn resolve_request_model( catalog: &Catalog, eligible: &HashSet, - requested_model: Option, + requested_model: Option<&str>, explicit_provider: Option, ) -> Result<(String, ProviderId), ModelSelectionError> { let explicit_provider = explicit_provider.map(ProviderId::new); - if let Some(model) = requested_model { - return match catalog.select(&model, explicit_provider.as_ref(), eligible) { - Ok(offering) => Ok((offering.id.to_string(), offering.provider.clone())), - Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { - Ok((model, provider)) - } - Err(ModelSelectionError::UnknownSelector { .. }) => { - let default = catalog.select_default(eligible)?; - Ok((model, default.provider.clone())) - } - Err(error) => Err(error), - }; - } - - let eligible = if let Some(requested_provider) = explicit_provider { - let provider = catalog.provider(&requested_provider).ok_or_else(|| { - ModelSelectionError::UnknownProvider { - provider: requested_provider.clone(), - } - })?; - let provider_is_ready = eligible.iter().any(|eligible_provider| { - catalog - .provider(eligible_provider) - .is_some_and(|eligible_provider| eligible_provider.id == provider.id) - }); - if !provider_is_ready { - return Err(ModelSelectionError::ProviderUnavailable { - provider: provider.id.clone(), - }); - } - HashSet::from([provider.id.clone()]) - } else { - eligible.clone() - }; - let default = catalog.select_default(&eligible)?; - Ok((default.id.to_string(), default.provider.clone())) + 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/models.rs b/lib/crates/fabro-server/src/server/handler/models.rs index 9b639af99..59b6b8623 100644 --- a/lib/crates/fabro-server/src/server/handler/models.rs +++ b/lib/crates/fabro-server/src/server/handler/models.rs @@ -272,7 +272,8 @@ fn model_selection_response(error: &ModelSelectionError) -> Response { } ModelSelectionError::RetiredModelIdentifier { .. } | ModelSelectionError::ProviderUnavailable { .. } - | ModelSelectionError::NoEligibleOffering { .. } => { + | ModelSelectionError::NoEligibleOffering { .. } + | ModelSelectionError::NoDefaultModel { .. } => { ApiError::bad_request(error.to_string()).into_response() } } diff --git a/lib/crates/fabro-server/src/server/handler/playground.rs b/lib/crates/fabro-server/src/server/handler/playground.rs index 046cf2ef3..d404c2b58 100644 --- a/lib/crates/fabro-server/src/server/handler/playground.rs +++ b/lib/crates/fabro-server/src/server/handler/playground.rs @@ -130,7 +130,7 @@ async fn create_playground_chat( let (model_id, selected_provider) = match super::completions::resolve_request_model( catalog.as_ref(), &client.provider_ids(), - req.model, + req.model.as_deref(), req.provider.map(|provider| provider.to_string()), ) { Ok(selection) => selection, @@ -146,7 +146,7 @@ async fn create_playground_chat( let request = LlmRequest { model: model_id, messages, - provider: Some(selected_provider.to_string()), + provider: Some(selected_provider.into_inner()), tools: Some(playground_tools()), tool_choice: Some(ToolChoice::Auto), response_format: None, @@ -159,10 +159,6 @@ async fn create_playground_chat( metadata: None, provider_options: None, }; - let request = match client.resolve_request(&request) { - Ok(request) => request, - Err(error) => return ApiError::bad_request(error.to_string()).into_response(), - }; info!( model = %request.model, provider = request.provider.as_deref().unwrap_or(""), diff --git a/lib/crates/fabro-server/src/server/handler/sessions.rs b/lib/crates/fabro-server/src/server/handler/sessions.rs index eb72bd386..936b8e582 100644 --- a/lib/crates/fabro-server/src/server/handler/sessions.rs +++ b/lib/crates/fabro-server/src/server/handler/sessions.rs @@ -805,37 +805,22 @@ fn selected_session_model( .provider_ids() .into_iter() .collect::>(); - let (provider_id, model) = if let Some(model_id) = session.record.model.as_deref() { - match catalog.select(model_id, session.record.provider.as_ref(), &eligible) { - Ok(selected) => (selected.provider.clone(), selected.id.to_string()), - Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { - (provider, model_id.to_string()) - } - Err(ModelSelectionError::UnknownSelector { .. }) - if session.record.provider.is_none() => + 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 { .. }) { - let selected = catalog - .select_default(&eligible) - .map_err(|error| AskFabroBuildError::LlmUnconfigured(error.to_string()))?; - (selected.provider.clone(), model_id.to_string()) + AskFabroBuildError::LlmUnconfigured(error.to_string()) + } else { + AskFabroBuildError::ModelUnavailable(error.to_string()) } - Err(error) => { - return Err(AskFabroBuildError::ModelUnavailable(error.to_string())); - } - } - } else { - let selected = if let Some(provider) = session.record.provider.as_ref() { - let provider_only = std::collections::HashSet::from([provider.clone()]); - catalog - .select_default(&provider_only) - .map_err(|error| AskFabroBuildError::ModelUnavailable(error.to_string()))? - } else { - catalog - .select_default(&eligible) - .map_err(|error| AskFabroBuildError::LlmUnconfigured(error.to_string()))? - }; - (selected.provider.clone(), selected.id.to_string()) - }; + })?; + let (provider_id, model) = (selected.provider, selected.model); let profile_kind = catalog .effective_agent_profile(&provider_id, Some(&model)) .ok_or_else(|| { @@ -865,27 +850,10 @@ fn canonical_session_model( }) .transpose()?; let Some(requested) = requested else { - let eligible = if let Some(provider) = explicit_provider.as_ref() { - let provider_is_ready = eligible.iter().any(|eligible_provider| { - catalog - .provider(eligible_provider) - .is_some_and(|eligible_provider| eligible_provider.id == *provider) - }); - if !provider_is_ready { - return Err(session_selection_error( - &ModelSelectionError::ProviderUnavailable { - provider: provider.clone(), - }, - )); - } - std::collections::HashSet::from([provider.clone()]) - } else { - eligible.clone() - }; - let model = catalog - .select_default(&eligible) + let selected = catalog + .resolve_selection(None, explicit_provider.as_ref(), eligible) .map_err(|error| session_selection_error(&error))?; - return Ok((model.provider.clone(), model.id.to_string())); + return Ok((selected.provider, selected.model)); }; let requested = requested.trim(); if requested.is_empty() { @@ -942,19 +910,10 @@ fn canonical_session_model( } }; let provider = qualified_provider.as_ref().or(explicit_provider.as_ref()); - match catalog.select(&model, provider, eligible) { - Ok(selected) => Ok((selected.provider.clone(), selected.id.to_string())), - Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { - Ok((provider, model)) - } - Err(ModelSelectionError::UnknownSelector { .. }) if provider.is_none() => { - let selected = catalog - .select_default(eligible) - .map_err(|error| session_selection_error(&error))?; - Ok((selected.provider.clone(), model)) - } - Err(error) => Err(session_selection_error(&error)), - } + let selected = catalog + .resolve_selection(Some(&model), provider, eligible) + .map_err(|error| session_selection_error(&error))?; + Ok((selected.provider, selected.model)) } fn session_selection_error(error: &ModelSelectionError) -> ApiError { diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index ca29053a9..74095bbf5 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -3414,7 +3414,7 @@ async fn create_run_without_ready_llm_provider_rejects_implicit_model_selection( assert!( body["errors"][0]["detail"] .as_str() - .is_some_and(|detail| detail.contains("no offering on an eligible provider")), + .is_some_and(|detail| detail.contains("no default model is available")), "unexpected response: {body}" ); assert!(state.runs.lock().expect("runs lock poisoned").is_empty()); diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 5972d6cc4..b1e33f4c7 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -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, @@ -618,22 +617,12 @@ fn resolve_start_llm( settings: &ResolvedRunSettings, ) -> Result { let eligible = configured.iter().cloned().collect::>(); - let (model, provider) = resolve_run_model( + let (model, provider_id) = resolve_run_model( catalog, &eligible, settings.model.name.as_deref(), settings.model.provider.as_deref(), )?; - let provider_id = ProviderId::new( - provider.expect("catalog-backed run model resolution always selects a provider"), - ); - let provider_context = routing::resolve_provider_context( - catalog, - &provider_id, - &model, - Some(provider_id.as_str()), - )?; - let provider_id = provider_context.provider_id; let fallback_chain = resolve_fallback_chain(catalog, &provider_id, &model, &settings.model, &eligible)?; diff --git a/lib/crates/fabro-workflow/src/run_materialization.rs b/lib/crates/fabro-workflow/src/run_materialization.rs index 14ffa735a..7e82964ac 100644 --- a/lib/crates/fabro-workflow/src/run_materialization.rs +++ b/lib/crates/fabro-workflow/src/run_materialization.rs @@ -34,7 +34,7 @@ pub fn materialize_run( 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() { @@ -60,55 +60,10 @@ pub(crate) fn resolve_run_model( eligible: &HashSet, model: Option<&str>, provider: Option<&str>, -) -> Result<(String, Option), ModelSelectionError> { - if let Some(provider) = provider.filter(|provider| !provider.is_empty()) { - let requested = ProviderId::new(provider); - let provider = - catalog - .provider(&requested) - .ok_or_else(|| ModelSelectionError::UnknownProvider { - provider: requested.clone(), - })?; - let canonical_provider = provider.id.clone(); - let canonical_eligible = eligible.iter().any(|eligible| { - catalog - .provider(eligible) - .is_some_and(|provider| provider.id == canonical_provider) - }); - if !canonical_eligible { - return Err(ModelSelectionError::ProviderUnavailable { - provider: canonical_provider, - }); - } - if let Some(model) = model { - return match catalog.resolve_on_provider(&provider.id, model) { - Ok(offering) => Ok((offering.id.to_string(), Some(offering.provider.to_string()))), - Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => { - Ok((model.to_string(), Some(provider.id.to_string()))) - } - Err(error) => Err(error), - }; - } - let offering = catalog.default_for_provider(&provider.id).ok_or_else(|| { - ModelSelectionError::UnknownSelectorOnProvider { - selector: "".to_string(), - provider: provider.id.clone(), - } - })?; - return Ok((offering.id.to_string(), Some(offering.provider.to_string()))); - } - - if let Some(model) = model { - return match catalog.select(model, None, eligible) { - Ok(offering) => Ok((offering.id.to_string(), Some(offering.provider.to_string()))), - Err(ModelSelectionError::UnknownSelector { .. }) => { - let default = catalog.select_default(eligible)?; - Ok((model.to_string(), Some(default.provider.to_string()))) - } - Err(error) => Err(error), - }; - } - - let default = catalog.select_default(eligible)?; - Ok((default.id.to_string(), Some(default.provider.to_string()))) +) -> 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/transforms/model_resolution.rs b/lib/crates/fabro-workflow/src/transforms/model_resolution.rs index fc130bfb1..7eecbcfd1 100644 --- a/lib/crates/fabro-workflow/src/transforms/model_resolution.rs +++ b/lib/crates/fabro-workflow/src/transforms/model_resolution.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Graph}; -use fabro_model::{Catalog, ModelSelectionError, ProviderId}; +use fabro_model::{Catalog, ProviderId}; use super::Transform; use crate::error::Error; @@ -46,24 +46,12 @@ impl ModelResolutionTransform { model: &str, explicit_provider: Option<&ProviderId>, ) -> Result<(String, ProviderId), Error> { - match self - .catalog - .select(model, explicit_provider, &self.eligible_providers) - { - Ok(info) => Ok((info.id.to_string(), info.provider.clone())), - Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { - Ok((model.to_string(), provider)) - } - Err(ModelSelectionError::UnknownSelector { .. }) => { - let provider = self - .catalog - .select_default(&self.eligible_providers)? - .provider - .clone(); - Ok((model.to_string(), provider)) - } - Err(error) => Err(error.into()), - } + let selected = self.catalog.resolve_selection( + Some(model), + explicit_provider, + &self.eligible_providers, + )?; + Ok((selected.model, selected.provider)) } } From 14317ccaf79fd72d6cb6aa188e55daf3eb2ebcf6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 12:12:09 -0400 Subject: [PATCH 03/45] fix: preserve legacy model selector compatibility --- docs/public/core-concepts/models.mdx | 8 +- docs/public/execution/failures.mdx | 2 + docs/public/execution/run-configuration.mdx | 2 + lib/crates/fabro-config/src/parse.rs | 48 +++-- lib/crates/fabro-model/src/catalog.rs | 202 +++++++++++++----- .../fabro-server/src/server/handler/models.rs | 3 +- .../src/server/handler/sessions.rs | 73 +++++-- .../fabro-workflow/src/operations/start.rs | 42 ++-- 8 files changed, 271 insertions(+), 109 deletions(-) diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index efa60f8cf..ab88318dd 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -145,9 +145,9 @@ reasoning = false `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. - -Built-in catalog keys that formerly exposed an aggregator's wire API ID are retired selectors. Fabro rejects them with their replacement address instead of silently routing them. Some contain a slash and look provider-qualified: for example, the old OpenRouter key `openai/gpt-5.6-sol` is retired in favor of `openrouter/gpt-5.6-sol`. To pin the direct OpenAI offering, set `provider = "openai"` and `name = "gpt-5.6-sol"` as separate fields. - + +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. @@ -271,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/execution/failures.mdx b/docs/public/execution/failures.mdx index ef91658be..1caa84184 100644 --- a/docs/public/execution/failures.mdx +++ b/docs/public/execution/failures.mdx @@ -127,6 +127,8 @@ When Anthropic fails, Fabro tries Gemini first, then OpenAI. Fallback resolution - 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 43bb9a3d1..3cb544906 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -146,6 +146,8 @@ Provider values are catalog provider ID strings. Built-in IDs like `anthropic` a 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/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs index fb72c35af..47b88b728 100644 --- a/lib/crates/fabro-config/src/parse.rs +++ b/lib/crates/fabro-config/src/parse.rs @@ -429,24 +429,46 @@ display_name = "Legacy" } #[test] - fn retired_legacy_model_id_names_replacement() { - let error = r#" + 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_err(); + .unwrap(); + let llm = parsed.llm.unwrap(); - assert!(matches!( - error, - ParseError::LlmCatalog(LegacyModelError::Retired { - identifier, - provider, - model, - }) if identifier == "openai/gpt-5.6-sol" - && provider.as_str() == "openrouter" - && model.as_str() == "gpt-5.6-sol" - )); + 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] diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index ea089cfba..5877bd5f2 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -616,12 +616,6 @@ pub enum ModelSelectionError { "no default model is available on an eligible provider; providers with defaults: {providers:?}" )] NoDefaultModel { providers: Vec }, - #[error("model identifier '{identifier}' has been retired; use '{provider}/{model}' instead")] - RetiredModelIdentifier { - identifier: String, - provider: ProviderId, - model: ModelId, - }, } /// One provider/model pair chosen by [`Catalog::resolve_selection`]. The @@ -711,13 +705,11 @@ impl Catalog { continue; } - if let Some((replacement_provider, replacement_model)) = - retired_model_replacement(&model_id) - { - return Err(LegacyModelError::Retired { + if let Some((_, canonical_model)) = legacy_builtin_model(&model_id) { + return Err(LegacyModelError::LegacyIdentifierAsModelId { identifier: model_id, - provider: replacement_provider, - model: replacement_model, + provider: provider.id.clone(), + model: canonical_model, } .into()); } @@ -870,12 +862,14 @@ impl Catalog { } /// Look up a selector on exactly one provider, without considering - /// provider availability. + /// 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.to_string())) + .get(&(provider.id.clone(), selector.into_owned())) .and_then(|idx| self.models.get(*idx)) } @@ -894,14 +888,6 @@ impl Catalog { provider: &ProviderId, selector: &str, ) -> Result<&Model, ModelSelectionError> { - if let Some((replacement_provider, replacement_model)) = retired_model_replacement(selector) - { - return Err(ModelSelectionError::RetiredModelIdentifier { - identifier: selector.to_string(), - provider: replacement_provider, - model: replacement_model, - }); - } let provider = self.provider(provider) .ok_or_else(|| ModelSelectionError::UnknownProvider { @@ -919,6 +905,9 @@ impl Catalog { /// 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>( @@ -927,14 +916,6 @@ impl Catalog { explicit_provider: Option<&ProviderId>, eligible_providers: &HashSet, ) -> Result<&'a Model, ModelSelectionError> { - if let Some((replacement_provider, replacement_model)) = retired_model_replacement(selector) - { - return Err(ModelSelectionError::RetiredModelIdentifier { - identifier: selector.to_string(), - provider: replacement_provider, - model: replacement_model, - }); - } let eligible = eligible_providers .iter() .filter_map(|provider| self.provider(provider).map(|provider| provider.id.clone())) @@ -954,7 +935,10 @@ impl Catalog { return self.resolve_on_provider(&provider.id, selector); } - let canonical = self.canonical_candidates.get(&ModelId::new(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() @@ -965,7 +949,7 @@ impl Catalog { } } - let aliases = self.alias_candidates.get(selector); + let aliases = self.alias_candidates.get(normalized_selector.as_ref()); if let Some(indices) = aliases { if let Some(model) = indices .iter() @@ -1117,9 +1101,10 @@ impl Catalog { } fn candidate_indices(&self, selector: &str) -> Option<&Vec> { + let selector = normalize_legacy_builtin_selector(selector); self.canonical_candidates - .get(&ModelId::new(selector)) - .or_else(|| self.alias_candidates.get(selector)) + .get(&ModelId::new(selector.as_ref())) + .or_else(|| self.alias_candidates.get(selector.as_ref())) } #[must_use] @@ -1537,8 +1522,10 @@ fn reject_scoped_provider_fields(settings: &LlmCatalogSettings) -> Result<(), Le pub enum LegacyModelError { #[error("failed to inspect the built-in model catalog: {message}")] BuiltinCatalog { message: String }, - #[error("model identifier '{identifier}' has been retired; use '{provider}/{model}' instead")] - Retired { + #[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, @@ -1647,20 +1634,23 @@ impl LegacyModelIndex { Ok(self) } - /// Resolve one legacy row to its provider-scoped address. An unknown - /// explicit provider or model selector passes through verbatim; rows - /// without an explicit provider must match exactly one known offering. + /// 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((provider, model)) = retired_model_replacement(legacy_id) { - return Err(LegacyModelError::Retired { - identifier: legacy_id.to_string(), - provider, - model, + 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 @@ -1755,11 +1745,11 @@ impl LegacyModelIndex { } } -/// Built-in catalog keys retired when provider API identifiers stopped being -/// Fabro model IDs. Keep this list explicit so old workflow and persisted-run -/// references fail with an actionable replacement instead of silently -/// selecting another route. -const RETIRED_MODEL_IDENTIFIERS: &[(&str, &str, &str)] = &[ +/// 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"), ( @@ -1833,15 +1823,23 @@ const RETIRED_MODEL_IDENTIFIERS: &[(&str, &str, &str)] = &[ ("mistralai/devstral-2512", "openrouter", "devstral-2512"), ]; -/// Return the replacement address for a retired built-in catalog key. +/// Return the historical provider and canonical model slug for a legacy +/// built-in catalog key. #[must_use] -pub fn retired_model_replacement(identifier: &str) -> Option<(ProviderId, ModelId)> { - RETIRED_MODEL_IDENTIFIERS +pub fn legacy_builtin_model(identifier: &str) -> Option<(ProviderId, ModelId)> { + LEGACY_BUILTIN_MODEL_IDENTIFIERS .iter() - .find(|(retired, _, _)| *retired == identifier) + .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( higher: LlmCatalogSettings, mut fallback: LlmCatalogSettings, @@ -3105,6 +3103,47 @@ enabled = true } } + #[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 builtin_openrouter_includes_glm_5_2_when_enabled() { let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( @@ -3803,6 +3842,33 @@ provider = "test" )); } + #[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(); @@ -3856,6 +3922,36 @@ provider = "test" )); } + #[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( diff --git a/lib/crates/fabro-server/src/server/handler/models.rs b/lib/crates/fabro-server/src/server/handler/models.rs index 59b6b8623..06af1d3de 100644 --- a/lib/crates/fabro-server/src/server/handler/models.rs +++ b/lib/crates/fabro-server/src/server/handler/models.rs @@ -270,8 +270,7 @@ fn model_selection_response(error: &ModelSelectionError) -> Response { | ModelSelectionError::UnknownSelectorOnProvider { .. } => { ApiError::not_found(error.to_string()).into_response() } - ModelSelectionError::RetiredModelIdentifier { .. } - | ModelSelectionError::ProviderUnavailable { .. } + ModelSelectionError::ProviderUnavailable { .. } | ModelSelectionError::NoEligibleOffering { .. } | ModelSelectionError::NoDefaultModel { .. } => { ApiError::bad_request(error.to_string()).into_response() diff --git a/lib/crates/fabro-server/src/server/handler/sessions.rs b/lib/crates/fabro-server/src/server/handler/sessions.rs index 936b8e582..f053207f6 100644 --- a/lib/crates/fabro-server/src/server/handler/sessions.rs +++ b/lib/crates/fabro-server/src/server/handler/sessions.rs @@ -859,14 +859,11 @@ fn canonical_session_model( if requested.is_empty() { return Err(ApiError::bad_request("Session model must not be empty.")); } - if let Some((provider, model)) = catalog::retired_model_replacement(requested) { - return Err(session_selection_error( - &ModelSelectionError::RetiredModelIdentifier { - identifier: requested.to_string(), - provider, - model, - }, - )); + 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::() @@ -1715,13 +1712,63 @@ reasoning = false } #[test] - fn canonical_session_model_rejects_retired_wire_identifier_before_qualification() { - let catalog = Catalog::from_builtin().unwrap(); + 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("openai/gpt-5.6-sol"), - None, + Some("openrouter/gpt-56-sol"), + Some(&ProviderId::openai()), ) .unwrap_err(); @@ -1730,7 +1777,7 @@ reasoning = false error .into_response_entry() .detail - .contains("openrouter/gpt-5.6-sol") + .contains("conflicts with model reference provider") ); } diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index b1e33f4c7..25f7a8014 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -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, ModelSelectionError, ProviderId, catalog}; +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, @@ -648,15 +648,6 @@ fn resolve_fallback_chain( let mut chain = Vec::new(); for model_ref in &settings.fallbacks { - let identifier = model_ref.to_string(); - if let Some((provider, model)) = catalog::retired_model_replacement(&identifier) { - return Err(ModelSelectionError::RetiredModelIdentifier { - identifier, - provider, - model, - } - .into()); - } match model_ref.resolve(®istry)? { ResolvedModelRef::Provider(provider_name) => { let provider_id = canonical_provider_id(catalog, &provider_name); @@ -1411,32 +1402,35 @@ reasoning = false } #[test] - fn resolve_fallback_chain_rejects_retired_wire_identifier_before_qualification() { + 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()], + fallbacks: vec![ + "openai/gpt-5.6-sol".parse::().unwrap(), + "anthropic/claude-fable-5".parse::().unwrap(), + ], ..ResolvedRunModelSettings::default() }; - let error = resolve_fallback_chain( + let chain = resolve_fallback_chain( catalog.as_ref(), &ProviderId::anthropic(), "claude-opus-4-6", &settings, &catalog.all_provider_ids(), ) - .unwrap_err(); + .unwrap(); - assert!(matches!( - error, - Error::ModelSelection(ModelSelectionError::RetiredModelIdentifier { - identifier, - provider, - model, - }) if identifier == "openai/gpt-5.6-sol" - && provider == ProviderId::new("openrouter") - && model == fabro_model::ModelId::new("gpt-5.6-sol") - )); + 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] From 7440ae69618a2e885f77bde209584e4c452c9709 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 16:25:36 +0000 Subject: [PATCH 04/45] fabro(01KY7WQ92JWT90307EBQY6P2HV): toolchain (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7WQ92JWT90307EBQY6P2HV Fabro-Completed: 2 Fabro-Checkpoint: 3c006e3a4b80d7a6c11890332ef6052f71062f27 ⚒️ Generated with [Fabro](https://fabro.sh) From 160767f59bdaab91c3af19932cc2809d73776e3b Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 16:28:08 +0000 Subject: [PATCH 05/45] fabro(01KY7WQ92JWT90307EBQY6P2HV): preflight_compile (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7WQ92JWT90307EBQY6P2HV Fabro-Completed: 3 Fabro-Checkpoint: f7737024fc56d55f9158767e711d32bbe3b90826 ⚒️ Generated with [Fabro](https://fabro.sh) From 79979c1733f7766ebaed245fbc8377abd75a7884 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 16:30:53 +0000 Subject: [PATCH 06/45] fabro(01KY7WQ92JWT90307EBQY6P2HV): preflight_lint (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7WQ92JWT90307EBQY6P2HV Fabro-Completed: 4 Fabro-Checkpoint: 75f83585ca081763f8f00309448c396980b3b69f ⚒️ Generated with [Fabro](https://fabro.sh) From a2650503e8517d0bd41c3fd6d09e79d41e709514 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 16:47:50 +0000 Subject: [PATCH 07/45] fabro(01KY7Y01REECZ24XXTMBZ3PPV9): toolchain (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7Y01REECZ24XXTMBZ3PPV9 Fabro-Completed: 2 Fabro-Checkpoint: 970f0d7912c042bbab4648a91d1f17accdac29b9 ⚒️ Generated with [Fabro](https://fabro.sh) From dfddfb747b5f438c1ad16bcfaae09b7d7d01f018 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 16:50:14 +0000 Subject: [PATCH 08/45] fabro(01KY7Y01REECZ24XXTMBZ3PPV9): preflight_compile (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7Y01REECZ24XXTMBZ3PPV9 Fabro-Completed: 3 Fabro-Checkpoint: 4bc92b4b11014acd4f58de58881ba25509ade1ae ⚒️ Generated with [Fabro](https://fabro.sh) From 1879fe0906049f3bd4d113f5de2736c7f3ff5af7 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 16:52:52 +0000 Subject: [PATCH 09/45] fabro(01KY7Y01REECZ24XXTMBZ3PPV9): preflight_lint (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7Y01REECZ24XXTMBZ3PPV9 Fabro-Completed: 4 Fabro-Checkpoint: bb25ac7d9f129de489aa019914494d2e5e49e5d9 ⚒️ Generated with [Fabro](https://fabro.sh) From 4f697c527c50f888c6a50c93a55a9dec8bb3c53f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 13:11:49 -0400 Subject: [PATCH 10/45] fix: preserve provider pins during model routing --- lib/crates/fabro-model/src/catalog.rs | 91 +++++++++++ lib/crates/fabro-server/src/server/tests.rs | 2 +- .../fabro-types/src/run_event/session.rs | 19 +++ lib/crates/fabro-types/src/session.rs | 22 ++- .../fabro-workflow/src/event/convert.rs | 2 +- .../fabro-workflow/src/handler/llm/api.rs | 27 ++++ .../fabro-workflow/src/handler/llm/routing.rs | 5 + .../fabro-workflow/src/operations/create.rs | 149 +++++++++++------- 8 files changed, 254 insertions(+), 63 deletions(-) diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 5877bd5f2..69790f96d 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -3144,6 +3144,42 @@ enabled = true 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( @@ -5118,6 +5154,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( diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 9ecdabcae..7aafcec03 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -13785,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 { diff --git a/lib/crates/fabro-types/src/run_event/session.rs b/lib/crates/fabro-types/src/run_event/session.rs index 504d4f653..78321afa4 100644 --- a/lib/crates/fabro-types/src/run_event/session.rs +++ b/lib/crates/fabro-types/src/run_event/session.rs @@ -110,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/session.rs b/lib/crates/fabro-types/src/session.rs index eaad05181..b825338d9 100644 --- a/lib/crates/fabro-types/src/session.rs +++ b/lib/crates/fabro-types/src/session.rs @@ -188,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-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index b2ba1d715..528371880 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -2184,7 +2184,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 { diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 573542d9f..4e60dfbff 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -2624,6 +2624,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/routing.rs b/lib/crates/fabro-workflow/src/handler/llm/routing.rs index 0bb05b216..b7d499b8c 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/routing.rs @@ -58,6 +58,11 @@ pub(crate) fn resolve_provider_context( })? .id .clone() + } else if catalog + .get_on_provider(default_provider_id, model) + .is_some() + { + default_provider_id.clone() } else { match catalog.select(model, None, &catalog.all_provider_ids()) { Ok(model) => model.provider.clone(), diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 488bb5c74..144cf5976 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -1462,11 +1462,11 @@ reasoning = false } #[tokio::test] - async fn create_materializes_shared_alias_for_ready_provider_snapshot_and_pin() { - const ALIAS_DOT: &str = r#"digraph 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="gpt-56-sol"] + work [prompt="Do work", model="MODEL_SELECTOR"] exit [shape=Msquare] start -> work -> exit }"#; @@ -1490,65 +1490,94 @@ reasoning = false ), ]; - for (ready, explicit_provider, expected_provider) in cases { - let dir = tempfile::tempdir().unwrap(); - let mut settings = test_default_settings(); - settings.run.model.name = Some("gpt-56-sol".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: ALIAS_DOT.to_string(), - base_dir: None, + 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, }, - 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, - web_url: None, - }, - dir.path().join("storage"), - Arc::clone(&catalog), - ) - .await - .unwrap(); - let run_spec = created.persisted.run_spec(); + 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") - ); - assert_eq!( - run_spec.settings.run.model.provider.as_deref(), - Some(expected_provider.as_str()) - ); - assert_eq!( - run_spec.graph.nodes["work"] - .attrs - .get("model") - .and_then(AttrValue::as_str), - Some("gpt-5.6-sol") - ); - assert_eq!( - run_spec.graph.nodes["work"] - .attrs - .get("provider") - .and_then(AttrValue::as_str), - Some(expected_provider.as_str()) - ); + 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}'" + ); + } } } From 831b7506645ae2f4c44c44ab988e86c6cc7a8d22 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 13:13:45 -0400 Subject: [PATCH 11/45] fix: honor the run's provider pin during request-time routing Merging main brought in billing tests that construct ModelRef with String model ids and an integration test that pins an OpenRouter run via the backend's provider id. The ModelRef sites now use ModelId conversions. The integration test also exposed a real regression: resolve_provider_context ignored the persisted run provider whenever the model selector resolved globally, re-routing pinned OpenRouter runs to a higher-priority provider for nodes without explicit model/provider attrs. Request-time routing now treats the run's selected provider as a pin with custom-model passthrough, matching transform-time selection semantics. Co-Authored-By: Claude Fable 5 --- lib/crates/fabro-server/src/server/tests.rs | 2 +- .../fabro-workflow/src/event/convert.rs | 2 +- .../fabro-workflow/src/handler/llm/api.rs | 41 +++++++++++++++++++ .../fabro-workflow/src/handler/llm/routing.rs | 22 ++++++++-- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 9ecdabcae..7aafcec03 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -13785,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 { diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index b2ba1d715..528371880 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -2184,7 +2184,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 { diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 573542d9f..349960574 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -2531,6 +2531,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( diff --git a/lib/crates/fabro-workflow/src/handler/llm/routing.rs b/lib/crates/fabro-workflow/src/handler/llm/routing.rs index 0bb05b216..cfe7a85a0 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/routing.rs @@ -59,10 +59,24 @@ pub(crate) fn resolve_provider_context( .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() + // The run's selected provider is a pin, matching transform-time + // semantics: a selector the pinned provider does not offer passes + // through to it as a custom model rather than re-selecting a + // higher-priority provider. + match catalog.resolve_selection( + Some(model), + Some(default_provider_id), + &catalog.all_provider_ids(), + ) { + Ok(selected) => selected.provider, + Err(fabro_model::ModelSelectionError::UnknownProvider { .. }) => { + 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()), + } } Err(error) => return Err(error.into()), } From 96102854d8b00239bc0ea95bee7a9366121aaa7f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 13:13:45 -0400 Subject: [PATCH 12/45] chore: address review feedback - rename resolve_route catalog-instance test to describe its actual id-based resolution assertion - use EnvVars::OPENAI_API_KEY instead of a raw string in the automation scheduler test fixture Co-Authored-By: Claude Fable 5 --- lib/crates/fabro-llm/src/adapter_registry.rs | 2 +- lib/crates/fabro-server/src/server/automation_scheduler.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/crates/fabro-llm/src/adapter_registry.rs b/lib/crates/fabro-llm/src/adapter_registry.rs index 810f6d6f4..dd880d200 100644 --- a/lib/crates/fabro-llm/src/adapter_registry.rs +++ b/lib/crates/fabro-llm/src/adapter_registry.rs @@ -367,7 +367,7 @@ mod tests { } #[test] - fn resolve_route_returns_none_for_model_from_another_catalog() { + 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()); diff --git a/lib/crates/fabro-server/src/server/automation_scheduler.rs b/lib/crates/fabro-server/src/server/automation_scheduler.rs index 9499d4170..0abd3e988 100644 --- a/lib/crates/fabro-server/src/server/automation_scheduler.rs +++ b/lib/crates/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,7 +429,7 @@ mod tests { fn test_state_with_materializer(materializer: TestAutomationRunMaterializer) -> Arc { TestAppStateBuilder::new() .env_lookup(|_| None) - .vault_entries([("OPENAI_API_KEY", "test-openai-api-key")]) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) .automation_materializer(materializer) .build() } From 712b7702a283b325baa77f42c2a44e4eb524b1f0 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 13:16:09 -0400 Subject: [PATCH 13/45] fix: simplify provider-pin routing after concurrent fixes merged Both this branch and the remote qa branch fixed the same provider-pin regression; the merge stacked the two implementations. Keep the remote's semantics: pin the run's provider whenever it offers the model, otherwise fall back to priority selection. Co-Authored-By: Claude Fable 5 --- .../fabro-workflow/src/handler/llm/routing.rs | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/lib/crates/fabro-workflow/src/handler/llm/routing.rs b/lib/crates/fabro-workflow/src/handler/llm/routing.rs index a838ac30a..3ce9618db 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/routing.rs @@ -62,26 +62,13 @@ pub(crate) fn resolve_provider_context( .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 { - // The run's selected provider is a pin, matching transform-time - // semantics: a selector the pinned provider does not offer passes - // through to it as a custom model rather than re-selecting a - // higher-priority provider. - match catalog.resolve_selection( - Some(model), - Some(default_provider_id), - &catalog.all_provider_ids(), - ) { - Ok(selected) => selected.provider, - Err(fabro_model::ModelSelectionError::UnknownProvider { .. }) => { - 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()), - } + 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()), } From f68187bfc674cb7b826be838c08a0072fdff4581 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 17:16:41 +0000 Subject: [PATCH 14/45] fabro(01KY7WQ92JWT90307EBQY6P2HV): implement (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7WQ92JWT90307EBQY6P2HV Fabro-Completed: 5 ⚒️ Generated with [Fabro](https://fabro.sh) --- docs/public/agents/outputs.mdx | 20 +- docs/public/reference/dot-language.mdx | 7 +- .../tests/it/workflow/command_routing.rs | 34 ++ .../workflow/fixtures/command_routing.fabro | 23 + lib/crates/fabro-cli/tests/it/workflow/mod.rs | 1 + .../src/rules/inert_attribute.rs | 8 +- .../fabro-workflow/src/handler/command.rs | 429 +++++++++++++++++- .../src/handler/structured_output.rs | 1 - .../fabro-workflow/tests/it/integration.rs | 90 +++- 9 files changed, 594 insertions(+), 19 deletions(-) create mode 100644 lib/crates/fabro-cli/tests/it/workflow/command_routing.rs create mode 100644 lib/crates/fabro-cli/tests/it/workflow/fixtures/command_routing.fabro diff --git a/docs/public/agents/outputs.mdx b/docs/public/agents/outputs.mdx index e03b4e08f..061afaf21 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, using the last JSON object in 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,7 @@ 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. +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 +121,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 +132,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 +142,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/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx index f01fbf2ed..c9a2bbccb 100644 --- a/docs/public/reference/dot-language.mdx +++ b/docs/public/reference/dot-language.mdx @@ -206,7 +206,7 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be | `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 [ @@ -236,6 +236,8 @@ audit [ - `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. +- Command nodes validate the last JSON object in merged stdout and stderr only after the script exits with code `0`; 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}` and is not directly addressable by edge conditions. 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 diff --git a/lib/crates/fabro-cli/tests/it/workflow/command_routing.rs b/lib/crates/fabro-cli/tests/it/workflow/command_routing.rs new file mode 100644 index 000000000..1052ae476 --- /dev/null +++ b/lib/crates/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/fixtures/command_routing.fabro b/lib/crates/fabro-cli/tests/it/workflow/fixtures/command_routing.fabro new file mode 100644 index 000000000..0c47eccff --- /dev/null +++ b/lib/crates/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/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index 365707d2c..5e0dd3738 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/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-validate/src/rules/inert_attribute.rs b/lib/crates/fabro-validate/src/rules/inert_attribute.rs index fef9c5454..f604794f8 100644 --- a/lib/crates/fabro-validate/src/rules/inert_attribute.rs +++ b/lib/crates/fabro-validate/src/rules/inert_attribute.rs @@ -20,7 +20,7 @@ const HANDLER_SPECIFIC_ATTRS: &[(&str, &[&str])] = &[ ("duration", &["wait"]), ("join_policy", &["parallel"]), ("max_parallel", &["parallel"]), - ("output_schema", &["agent", "prompt"]), + ("output_schema", &["agent", "prompt", "command"]), ("prompt", &["agent", "prompt", "parallel.fan_in"]), ]; @@ -152,15 +152,13 @@ mod tests { } #[test] - fn warns_on_output_schema_on_command_node() { + fn accepts_output_schema_on_command_node() { let mut g = minimal_graph(); g.nodes.insert( "run".to_string(), node_with_attr("run", "parallelogram", "output_schema", "routing"), ); - let d = Rule.apply(&g); - assert_eq!(d.len(), 1); - assert!(d[0].message.contains("'output_schema'")); + assert!(Rule.apply(&g).is_empty()); } #[test] diff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs index 146324013..ac044999b 100644 --- a/lib/crates/fabro-workflow/src/handler/command.rs +++ b/lib/crates/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 { @@ -172,6 +175,34 @@ impl Handler for CommandHandler { ); outcome.notes = Some(format!("Script completed: {script}")); outcome.timing = Some(StageTiming::active_only(0, result.duration_ms)); + + if let Some(schema) = output_schema.as_ref() { + match structured_output::validate_response_text(schema, &finalized.output_text) { + Ok(validated) => { + structured_output::apply_validated_output( + node, + schema, + &validated, + &mut outcome, + ); + } + Err(error) => { + let reason = schema_validation_failure_reason( + script, + &error, + &finalized.output_text, + ); + let mut failed = Outcome::fail_deterministic(reason); + failed.context_updates.insert( + keys::COMMAND_OUTPUT.to_string(), + serde_json::json!(finalized.output_ref), + ); + failed.timing = Some(StageTiming::active_only(0, result.duration_ms)); + return Ok(failed); + } + } + } + Ok(outcome) } else { let mut reason = format!( @@ -194,6 +225,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 +272,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 +513,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/structured_output.rs b/lib/crates/fabro-workflow/src/handler/structured_output.rs index 546124b43..15669462a 100644 --- a/lib/crates/fabro-workflow/src/handler/structured_output.rs +++ b/lib/crates/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 diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 87ebeee4d..fd4b526f3 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/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,94 @@ 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 run_options = RunOptions { + settings: WorkflowSettings::default(), + run_dir: dir.path().to_path_buf(), + cancel_token: CancellationToken::new(), + run_id: test_run_id("command-schema-no-retry"), + 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("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: From 1448d996e2bf74268cc969d88580768a149ca036 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Thu, 23 Jul 2026 13:26:20 -0400 Subject: [PATCH 15/45] feat(llm): send x-session-id trace header with the run ID Tag every LLM request in a run with an x-session-id header carrying the run ID, so gateways that understand session tracing (e.g. OpenRouter broadcast) can group a run's requests into one session. Adds ExtraHeadersCredentialSource to fabro-auth: a CredentialSource decorator that appends fixed headers to every resolved credential, leaving operator-configured extra_headers untouched. The run pipeline wraps its vault/env source with it, so agent stages, prompt stages, hooks, and PR-content generation all pick up the header through the existing extra_headers plumbing with no fabro-llm changes. Co-Authored-By: Claude Fable 5 --- .../fabro-auth/src/extra_headers_source.rs | 164 ++++++++++++++++++ lib/crates/fabro-auth/src/lib.rs | 2 + .../fabro-workflow/src/pipeline/initialize.rs | 49 +++++- 3 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 lib/crates/fabro-auth/src/extra_headers_source.rs diff --git a/lib/crates/fabro-auth/src/extra_headers_source.rs b/lib/crates/fabro-auth/src/extra_headers_source.rs new file mode 100644 index 000000000..2e57ee6ea --- /dev/null +++ b/lib/crates/fabro-auth/src/extra_headers_source.rs @@ -0,0 +1,164 @@ +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 { + credential + .extra_headers + .entry(name.clone()) + .or_insert_with(|| 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_issues: Vec<(ProviderId, ResolveError)>, + } + + #[async_trait] + impl CredentialSource for StubSource { + async fn resolve(&self, _catalog: &Catalog) -> anyhow::Result { + Ok(ResolvedCredentials { + credentials: self.credentials.clone(), + auth_issues: self + .auth_issues + .iter() + .map(|(provider, _)| { + ( + provider.clone(), + ResolveError::NotConfigured(provider.clone()), + ) + }) + .collect(), + }) + } + + async fn configured_providers(&self, _catalog: &Catalog) -> Vec { + self.credentials + .iter() + .map(|c| c.provider.clone()) + .collect() + } + } + + fn credential(provider: &str, extra_headers: HashMap) -> ApiCredential { + ApiCredential { + provider: ProviderId::new(provider), + auth_header: None, + extra_headers, + base_url: None, + codex_mode: false, + org_id: None, + project_id: None, + } + } + + fn catalog() -> Catalog { + Catalog::from_builtin().unwrap() + } + + #[tokio::test] + async fn appends_headers_to_every_resolved_credential() { + let source = ExtraHeadersCredentialSource::new( + Arc::new(StubSource { + credentials: vec![ + credential("anthropic", HashMap::new()), + credential("openai", HashMap::new()), + ], + auth_issues: Vec::new(), + }), + HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), + ); + + let resolved = source.resolve(&catalog()).await.unwrap(); + + assert_eq!(resolved.credentials.len(), 2); + for credential in &resolved.credentials { + assert_eq!( + credential.extra_headers.get("x-session-id"), + Some(&"run-123".to_string()) + ); + } + } + + #[tokio::test] + async fn preserves_headers_already_set_on_a_credential() { + let source = ExtraHeadersCredentialSource::new( + Arc::new(StubSource { + credentials: vec![credential( + "openrouter", + HashMap::from([("x-session-id".to_string(), "configured".to_string())]), + )], + auth_issues: Vec::new(), + }), + HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), + ); + + let resolved = source.resolve(&catalog()).await.unwrap(); + + assert_eq!( + resolved.credentials[0].extra_headers.get("x-session-id"), + Some(&"configured".to_string()) + ); + } + + #[tokio::test] + async fn passes_through_auth_issues_and_configured_providers() { + let provider = ProviderId::new("anthropic"); + let source = ExtraHeadersCredentialSource::new( + Arc::new(StubSource { + credentials: vec![credential("openai", HashMap::new())], + auth_issues: vec![( + provider.clone(), + ResolveError::NotConfigured(provider.clone()), + )], + }), + HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), + ); + + let resolved = source.resolve(&catalog()).await.unwrap(); + assert_eq!(resolved.auth_issues.len(), 1); + assert_eq!(resolved.auth_issues[0].0, provider); + + let providers = source.configured_providers(&catalog()).await; + assert_eq!(providers, vec![ProviderId::new("openai")]); + } +} diff --git a/lib/crates/fabro-auth/src/lib.rs b/lib/crates/fabro-auth/src/lib.rs index 1bc135149..77c217317 100644 --- a/lib/crates/fabro-auth/src/lib.rs +++ b/lib/crates/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-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 54a6f1f45..ffe53e180 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/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()); @@ -1028,6 +1041,32 @@ mod tests { assert!(!effective_dry_run); } + #[tokio::test] + async fn build_llm_source_appends_run_session_trace_header() { + let dir = tempfile::tempdir().unwrap(); + let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); + vault + .set( + "ANTHROPIC_API_KEY", + "anthropic-key", + SecretType::Token, + None, + ) + .unwrap(); + let vault = Arc::new(AsyncRwLock::new(vault)); + + let source = build_llm_source(Some(vault), test_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), + Some(&test_run_id().to_string()) + ); + } + } + #[tokio::test] async fn initialize_executes_acp_backend_node_from_registry() { let temp = tempfile::tempdir().unwrap(); From e8db4e1a9b4b328e871b37edeb0ea117b185c9fa Mon Sep 17 00:00:00 2001 From: Release Repro Date: Thu, 23 Jul 2026 13:38:18 -0400 Subject: [PATCH 16/45] refactor(llm): simplify session trace header plumbing --- .../fabro-auth/src/extra_headers_source.rs | 99 ++++++++++--------- .../fabro-workflow/src/operations/start.rs | 1 - .../src/pipeline/execute/tests.rs | 3 - .../fabro-workflow/src/pipeline/initialize.rs | 29 +++--- .../fabro-workflow/src/pipeline/types.rs | 1 - 5 files changed, 66 insertions(+), 67 deletions(-) diff --git a/lib/crates/fabro-auth/src/extra_headers_source.rs b/lib/crates/fabro-auth/src/extra_headers_source.rs index 2e57ee6ea..29ad806cd 100644 --- a/lib/crates/fabro-auth/src/extra_headers_source.rs +++ b/lib/crates/fabro-auth/src/extra_headers_source.rs @@ -29,10 +29,14 @@ impl CredentialSource for ExtraHeadersCredentialSource { let mut resolved = self.inner.resolve(catalog).await?; for credential in &mut resolved.credentials { for (name, value) in &self.headers { - credential + if credential .extra_headers - .entry(name.clone()) - .or_insert_with(|| value.clone()); + .keys() + .any(|existing| existing.eq_ignore_ascii_case(name)) + { + continue; + } + credential.extra_headers.insert(name.clone(), value.clone()); } } Ok(resolved) @@ -49,8 +53,9 @@ mod tests { use crate::{ApiCredential, ResolveError}; struct StubSource { - credentials: Vec, - auth_issues: Vec<(ProviderId, ResolveError)>, + credentials: Vec, + auth_issue_provider: Option, + configured_providers: Vec, } #[async_trait] @@ -59,12 +64,12 @@ mod tests { Ok(ResolvedCredentials { credentials: self.credentials.clone(), auth_issues: self - .auth_issues + .auth_issue_provider .iter() - .map(|(provider, _)| { + .map(|provider| { ( provider.clone(), - ResolveError::NotConfigured(provider.clone()), + ResolveError::RefreshTokenMissing(provider.clone()), ) }) .collect(), @@ -72,16 +77,13 @@ mod tests { } async fn configured_providers(&self, _catalog: &Catalog) -> Vec { - self.credentials - .iter() - .map(|c| c.provider.clone()) - .collect() + self.configured_providers.clone() } } - fn credential(provider: &str, extra_headers: HashMap) -> ApiCredential { + fn credential(provider: ProviderId, extra_headers: HashMap) -> ApiCredential { ApiCredential { - provider: ProviderId::new(provider), + provider, auth_header: None, extra_headers, base_url: None, @@ -91,74 +93,83 @@ mod tests { } } - fn catalog() -> Catalog { - Catalog::from_builtin().unwrap() - } - #[tokio::test] async fn appends_headers_to_every_resolved_credential() { let source = ExtraHeadersCredentialSource::new( Arc::new(StubSource { - credentials: vec![ - credential("anthropic", HashMap::new()), - credential("openai", HashMap::new()), + credentials: vec![ + credential(ProviderId::anthropic(), HashMap::new()), + credential(ProviderId::openai(), HashMap::new()), ], - auth_issues: Vec::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()).await.unwrap(); + 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"), - Some(&"run-123".to_string()) + credential + .extra_headers + .get("x-session-id") + .map(String::as_str), + Some("run-123") ); } } #[tokio::test] - async fn preserves_headers_already_set_on_a_credential() { + async fn preserves_case_insensitive_headers_already_set_on_a_credential() { let source = ExtraHeadersCredentialSource::new( Arc::new(StubSource { - credentials: vec![credential( - "openrouter", - HashMap::from([("x-session-id".to_string(), "configured".to_string())]), + credentials: vec![credential( + ProviderId::new("openrouter"), + HashMap::from([("X-Session-Id".to_string(), "configured".to_string())]), )], - auth_issues: Vec::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()).await.unwrap(); + let resolved = source.resolve(Catalog::builtin()).await.unwrap(); assert_eq!( - resolved.credentials[0].extra_headers.get("x-session-id"), - Some(&"configured".to_string()) + 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 provider = ProviderId::new("anthropic"); + let auth_issue_provider = ProviderId::anthropic(); + let configured_provider = ProviderId::gemini(); let source = ExtraHeadersCredentialSource::new( Arc::new(StubSource { - credentials: vec![credential("openai", HashMap::new())], - auth_issues: vec![( - provider.clone(), - ResolveError::NotConfigured(provider.clone()), - )], + 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()).await.unwrap(); - assert_eq!(resolved.auth_issues.len(), 1); - assert_eq!(resolved.auth_issues[0].0, provider); + 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()).await; - assert_eq!(providers, vec![ProviderId::new("openai")]); + let providers = source.configured_providers(Catalog::builtin()).await; + assert_eq!(providers, vec![configured_provider]); } } diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 7fa47e938..8d0be3d5f 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -831,7 +831,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, diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index e2b96ed37..400050896 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/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/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index ffe53e180..e20b2fb61 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -360,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 @@ -825,7 +825,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(); @@ -907,7 +906,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(); @@ -1043,26 +1041,24 @@ mod tests { #[tokio::test] async fn build_llm_source_appends_run_session_trace_header() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault - .set( - "ANTHROPIC_API_KEY", - "anthropic-key", - SecretType::Token, - None, - ) + 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), test_run_id()); + 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), - Some(&test_run_id().to_string()) + credential + .extra_headers + .get(SESSION_ID_HEADER) + .map(String::as_str), + Some(expected_session_id.as_str()) ); } } @@ -1135,7 +1131,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(), @@ -1231,7 +1226,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(), @@ -1370,7 +1364,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/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs index 8449453a8..69a03c9fe 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/fabro-workflow/src/pipeline/types.rs @@ -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, From 0432c019ee3d3004866d9254d77ff2ef8e900e6b Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 13:38:55 -0400 Subject: [PATCH 17/45] chore: address review feedback on error mapping and test-support gating - Return 400 (not 500) for WorkflowError::ModelReference from run creation, matching ModelSelection: an ambiguous model/provider token is user input, not a server fault. - Gate fabro-workflow's test_support module behind cfg(any(test, feature = "test-support")) so the feature actually controls exposure, per the repo's test-support boundary guidance. Add the self dev-dependency so tests/it keeps compiling, and gate the pipeline helpers that only test_support consumed. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + lib/crates/fabro-server/src/server/handler/runs.rs | 2 +- lib/crates/fabro-workflow/Cargo.toml | 1 + lib/crates/fabro-workflow/src/lib.rs | 2 +- lib/crates/fabro-workflow/src/pipeline/finalize.rs | 1 + lib/crates/fabro-workflow/src/pipeline/mod.rs | 6 +++--- 6 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 098d71cbf..c4bffa44c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3368,6 +3368,7 @@ dependencies = [ "fabro-util", "fabro-validate", "fabro-vault", + "fabro-workflow", "futures", "git2", "hex", diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs index e3e7f3dd2..57562fc4c 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/fabro-server/src/server/handler/runs.rs @@ -640,7 +640,7 @@ 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(_)) => { + Err(err @ (WorkflowError::ModelSelection(_) | WorkflowError::ModelReference(_))) => { return ApiError::bad_request(err.to_string()).into_response(); } Err(err) => { diff --git a/lib/crates/fabro-workflow/Cargo.toml b/lib/crates/fabro-workflow/Cargo.toml index fc7166d7a..e719de2fd 100644 --- a/lib/crates/fabro-workflow/Cargo.toml +++ b/lib/crates/fabro-workflow/Cargo.toml @@ -76,6 +76,7 @@ fabro-vault = { path = "../fabro-vault" } [dev-dependencies] base64.workspace = true fabro-acp = { path = "../fabro-acp", features = ["test-support"] } +fabro-workflow = { path = ".", features = ["test-support"] } fabro-api = { path = "../fabro-api" } fabro-environment = { path = "../fabro-environment" } fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "docker", "test-support"] } diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs index d5efaaebb..b4d3cc727 100644 --- a/lib/crates/fabro-workflow/src/lib.rs +++ b/lib/crates/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/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index 6e90991dc..9fdcad717 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/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/mod.rs b/lib/crates/fabro-workflow/src/pipeline/mod.rs index ef517e9cc..ac77cc992 100644 --- a/lib/crates/fabro-workflow/src/pipeline/mod.rs +++ b/lib/crates/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; From 1a2bd7966dc15ac5228546964879e1b4b577ad85 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 17:41:29 +0000 Subject: [PATCH 18/45] fabro(01KY7Y01REECZ24XXTMBZ3PPV9): implement (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7Y01REECZ24XXTMBZ3PPV9 Fabro-Completed: 5 Fabro-Checkpoint: 1f2ff54692f4e8627086993be6aafa876c57e3fa ⚒️ Generated with [Fabro](https://fabro.sh) --- docs/public/execution/context.mdx | 10 + docs/public/reference/dot-language.mdx | 10 +- docs/public/workflows/stages-and-nodes.mdx | 2 + lib/crates/fabro-validate/src/rules/mod.rs | 1 + .../src/rules/parallel_branch.rs | 56 ++++ .../rules/parallel_branch_inert_attribute.rs | 203 ++++++++---- .../rules/thread_id_requires_fidelity_full.rs | 73 ++++- lib/crates/fabro-workflow/src/artifact.rs | 57 ++++ lib/crates/fabro-workflow/src/context.rs | 1 + .../fabro-workflow/src/handler/parallel.rs | 238 +++++++++++++- .../fabro-workflow/src/lifecycle/event.rs | 37 ++- .../fabro-workflow/src/lifecycle/fidelity.rs | 291 ++++++++++++++++++ .../fabro-workflow/tests/it/integration.rs | 191 ++++++++++++ 13 files changed, 1093 insertions(+), 77 deletions(-) create mode 100644 lib/crates/fabro-validate/src/rules/parallel_branch.rs diff --git a/docs/public/execution/context.mdx b/docs/public/execution/context.mdx index c67012b3c..a08b2d960 100644 --- a/docs/public/execution/context.mdx +++ b/docs/public/execution/context.mdx @@ -139,6 +139,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/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx index f01fbf2ed..3e407242b 100644 --- a/docs/public/reference/dot-language.mdx +++ b/docs/public/reference/dot-language.mdx @@ -201,8 +201,8 @@ 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. | @@ -252,6 +252,8 @@ audit [ | `join_policy` | String | When the merge can proceed: `wait_all` (default), `first_success` | | `max_parallel` | Integer | Maximum concurrent branches (default: 4) | +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 | @@ -282,8 +284,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/workflows/stages-and-nodes.mdx b/docs/public/workflows/stages-and-nodes.mdx index 1dd0f71ac..1b8c252c2 100644 --- a/docs/public/workflows/stages-and-nodes.mdx +++ b/docs/public/workflows/stages-and-nodes.mdx @@ -170,6 +170,8 @@ fork -> quality | `join_policy` | When the merge can proceed (see table below) | | `max_parallel` | Maximum concurrent branches (default: 4) | +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. + **Join policies:** | Policy | Behavior | diff --git a/lib/crates/fabro-validate/src/rules/mod.rs b/lib/crates/fabro-validate/src/rules/mod.rs index 428004240..87075bc17 100644 --- a/lib/crates/fabro-validate/src/rules/mod.rs +++ b/lib/crates/fabro-validate/src/rules/mod.rs @@ -12,6 +12,7 @@ mod inert_attribute; 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/parallel_branch.rs b/lib/crates/fabro-validate/src/rules/parallel_branch.rs new file mode 100644 index 000000000..2f1cba2e7 --- /dev/null +++ b/lib/crates/fabro-validate/src/rules/parallel_branch.rs @@ -0,0 +1,56 @@ +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() + } + + pub(super) fn is_branch_only_node(&self, node_id: &str) -> bool { + self.branch_only_parents(node_id).is_some() + } + + pub(super) fn branch_only_parents(&self, node_id: &str) -> Option> { + let mut incoming = self + .graph + .edges + .iter() + .filter(|edge| edge.to == node_id) + .peekable(); + incoming.peek()?; + + incoming + .map(|edge| self.is_fork_edge(edge).then(|| edge.from.clone())) + .collect::>>() + .map(|parents| parents.into_iter().collect()) + } +} diff --git a/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs b/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs index eb051281e..da36dbbea 100644 --- a/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs +++ b/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs @@ -1,18 +1,16 @@ -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. Per-branch +/// preambles now honor fidelity, while `thread_id` remains inert because +/// concurrent branches cannot share an LLM session. +const BRANCH_IGNORED_ATTRS: &[&str] = &["thread_id"]; struct Rule; @@ -24,21 +22,8 @@ fn quoted_list(ids: &[String]) -> String { .join(", ") } -fn fix_message(attr: &str, parallel_ids: &[String]) -> String { +fn fix_message(attr: &str) -> 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" ), @@ -46,30 +31,49 @@ fn fix_message(attr: &str, parallel_ids: &[String]) -> String { } } +fn full_fidelity_message() -> String { + "parallel branches run at most at summary:high; full is degraded at runtime because branches cannot share a session".to_string() +} + +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" + ) +} + impl LintRule for Rule { fn name(&self) -> &'static str { "parallel_branch_inert_attribute" } 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(Diagnostic { + rule: self.name().to_string(), + severity: Severity::Warning, + message: full_fidelity_message(), + node_id: None, + edge: Some((edge.from.clone(), edge.to.clone())), + fix: Some(full_fidelity_fix(std::slice::from_ref(&edge.from))), + ..Diagnostic::default() + }); + } for attr in BRANCH_IGNORED_ATTRS { if !edge.attrs.contains_key(*attr) { continue; @@ -83,42 +87,32 @@ 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(fix_message(attr)), ..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(Diagnostic { + rule: self.name().to_string(), + severity: Severity::Warning, + message: full_fidelity_message(), + node_id: Some(node.id.clone()), + edge: None, + fix: Some(full_fidelity_fix(&parents)), + ..Diagnostic::default() + }); + } for attr in BRANCH_IGNORED_ATTRS { if !node.attrs.contains_key(*attr) { continue; @@ -133,7 +127,7 @@ impl LintRule for Rule { ), node_id: Some(node.id.clone()), edge: None, - fix: Some(fix_message(attr, &parents)), + fix: Some(fix_message(attr)), ..Diagnostic::default() }); } @@ -181,7 +175,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 +185,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 +274,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 +343,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/thread_id_requires_fidelity_full.rs b/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs index 457553788..a73335da5 100644 --- a/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs +++ b/lib/crates/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,13 @@ 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); 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 +46,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 +87,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 +228,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-workflow/src/artifact.rs b/lib/crates/fabro-workflow/src/artifact.rs index 3f9f9eb7d..975fee26d 100644 --- a/lib/crates/fabro-workflow/src/artifact.rs +++ b/lib/crates/fabro-workflow/src/artifact.rs @@ -79,6 +79,7 @@ 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); + snapshot.remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES); normalize_durable_updates(&mut snapshot); snapshot } @@ -99,6 +100,9 @@ pub fn normalize_checkpoint_for_resume(checkpoint: &mut Checkpoint) { checkpoint .context_values .remove(context::keys::CURRENT_PREAMBLE); + checkpoint + .context_values + .remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES); normalize_durable_updates(&mut checkpoint.context_values); normalize_durable_outcomes(&mut checkpoint.node_outcomes); } @@ -515,6 +519,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/context.rs b/lib/crates/fabro-workflow/src/context.rs index af1233586..b911e2210 100644 --- a/lib/crates/fabro-workflow/src/context.rs +++ b/lib/crates/fabro-workflow/src/context.rs @@ -25,6 +25,7 @@ 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"; + pub const INTERNAL_PARALLEL_BRANCH_PREAMBLES: &str = "internal.parallel_branch_preambles"; // --- current.* keys --- pub const CURRENT_PREAMBLE: &str = "current.preamble"; diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index 22aa2aa9c..ac4e4f3e1 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -4,6 +4,7 @@ use std::time::Instant; use async_trait::async_trait; use fabro_agent::{Sandbox, WorktreeOptions, WorktreeSandbox}; +use fabro_graphviz::Fidelity; use fabro_graphviz::graph::{AttrValue, Graph, Node}; use fabro_hooks::{HookContext, HookEvent}; use fabro_types::{ParallelBranchId, RunId, StageId}; @@ -56,6 +57,39 @@ struct BranchResult { worktree_path: Option, } +struct BranchPreamble { + fidelity: Fidelity, + preamble: String, +} + +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), + serde_json::Value::Object(entry) if entry.len() == 2 => { + let fidelity = entry.get("fidelity")?.as_str()?.parse().ok()?; + let preamble = entry.get("preamble")?.as_str()?; + Some(Some(BranchPreamble { + fidelity, + preamble: preamble.to_string(), + })) + } + _ => None, + }) + .collect() +} + #[async_trait] impl Handler for ParallelHandler { async fn simulate( @@ -220,6 +254,10 @@ impl Handler for ParallelHandler { None }; + let branch_preambles = parse_branch_preambles( + context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), + branches.len(), + ); let mut branch_setups: Vec = Vec::new(); for (branch_index, edge) in branches.iter().enumerate() { let target_id = edge.to.clone(); @@ -236,6 +274,21 @@ impl Handler for ParallelHandler { 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::json!(&entry.preamble)); + branch_context.set( + keys::INTERNAL_FIDELITY, + serde_json::json!(entry.fidelity.to_string()), + ); + } + branch_context.set( + keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, + serde_json::Value::Null, + ); let (branch_sandbox, worktree_path): (Arc, Option) = if let ( Some(ref gs), @@ -297,6 +350,10 @@ impl Handler for ParallelHandler { worktree_path, }); } + context.set( + keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, + serde_json::Value::Null, + ); // --- Fan out: concurrent execution --- let mut handles = Vec::new(); @@ -693,7 +750,7 @@ fn parallel_branch_commit_cmd( #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use std::time::Duration; use fabro_graphviz::graph::{AttrValue, Edge}; @@ -756,6 +813,185 @@ mod tests { context } + #[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 services = make_services(); diff --git a/lib/crates/fabro-workflow/src/lifecycle/event.rs b/lib/crates/fabro-workflow/src/lifecycle/event.rs index 5828dc86b..3965d7261 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/event.rs +++ b/lib/crates/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,12 @@ fn response_from_outcome(node_id: &str, outcome: &Outcome) -> Option { .and_then(|value| value.as_str().map(ToOwned::to_owned)) } +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 +324,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 +448,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/crates/fabro-workflow/src/lifecycle/fidelity.rs index 3f45c8142..3f0b24228 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs @@ -78,6 +78,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,6 +143,46 @@ impl RunLifecycle for FidelityLifecycle { .context .set(keys::CURRENT_PREAMBLE, serde_json::json!(preamble)); + if gv_node.handler_type() == Some("parallel") { + let mut branch_preambles = Vec::new(); + for (branch_index, edge) in self.graph.outgoing_edges(node.id()).iter().enumerate() { + let Some(target_node) = self.graph.nodes.get(&edge.to) else { + branch_preambles.push(serde_json::Value::Null); + continue; + }; + let resolution = resolve_parallel_branch_fidelity(edge, target_node, fidelity); + if resolution.requested() == Some(keys::Fidelity::Full) { + tracing::warn!( + parallel_node = %node.id(), + branch = %edge.to, + branch_index, + fidelity = %keys::Fidelity::Full, + effective_fidelity = %keys::Fidelity::SummaryHigh, + "Parallel branch fidelity degraded" + ); + } + let Some(branch_fidelity) = resolution.entry_fidelity() else { + branch_preambles.push(serde_json::Value::Null); + continue; + }; + let branch_preamble = build_preamble( + branch_fidelity, + &resolved_context, + &self.graph, + &state.completed_nodes, + &resolved_outcomes, + ); + branch_preambles.push(serde_json::json!({ + "fidelity": branch_fidelity.to_string(), + "preamble": branch_preamble, + })); + } + state.context.set( + keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, + serde_json::Value::Array(branch_preambles), + ); + } + // 5. Thread ID resolution via resolve_thread_id: edge → node → graph default → // class → previous let thread_id = resolve_thread_id( @@ -198,6 +243,47 @@ impl RunLifecycle for FidelityLifecycle { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ParallelBranchFidelityResolution { + requested: Option, + effective: Option, +} + +impl ParallelBranchFidelityResolution { + fn requested(self) -> Option { + self.requested + } + + fn entry_fidelity(self) -> Option { + self.effective + } +} + +/// 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 = edge + .fidelity() + .and_then(|value| value.parse().ok()) + .or_else(|| target_node.fidelity().and_then(|value| value.parse().ok())); + let effective = requested + .map(keys::Fidelity::degraded) + .filter(|fidelity| *fidelity != parallel_fidelity); + + ParallelBranchFidelityResolution { + requested, + effective, + } +} + /// Resolve the context fidelity for a node, following the precedence: /// 1. Incoming edge `fidelity` attribute /// 2. Target node `fidelity` attribute @@ -263,11 +349,216 @@ 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 fidelity_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(), fidelity_attr("Mdiamond")); + let mut fork = Node::new("fork"); + fork.attrs + .insert("shape".to_string(), fidelity_attr("component")); + if let Some(fidelity) = fork_fidelity { + fork.attrs + .insert("fidelity".to_string(), fidelity_attr(fidelity)); + } + let mut branch_a = Node::new("branch_a"); + if let Some(fidelity) = branch_a_fidelity { + branch_a + .attrs + .insert("fidelity".to_string(), fidelity_attr(fidelity)); + } + let branch_b = Node::new("branch_b"); + let mut work = Node::new("work"); + work.attrs.insert("shape".to_string(), fidelity_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(), fidelity_attr("compact")); + let mut edge = Edge::new("fork", "branch"); + edge.attrs + .insert("fidelity".to_string(), fidelity_attr("truncate")); + + let resolved = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::SummaryHigh); + + assert_eq!(resolved.requested(), Some(Fidelity::Truncate)); + assert_eq!(resolved.entry_fidelity(), 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.entry_fidelity(), None); + } + + #[test] + fn parallel_branch_full_fidelity_degrades_to_summary_high() { + let mut node = Node::new("branch"); + node.attrs + .insert("fidelity".to_string(), fidelity_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.entry_fidelity(), Some(Fidelity::SummaryHigh)); + } + + #[test] + fn parallel_branch_fidelity_equal_to_fork_inherits() { + let mut node = Node::new("branch"); + node.attrs + .insert("fidelity".to_string(), fidelity_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.entry_fidelity(), None); + } + + #[test] + fn explicit_full_branch_equal_to_degraded_fork_inherits() { + let mut node = Node::new("branch"); + node.attrs + .insert("fidelity".to_string(), fidelity_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.entry_fidelity(), 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.entry_fidelity(), 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/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 87ebeee4d..88e1f1aa9 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -4936,6 +4936,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 +9354,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; From ad15da74612e2c1a2f105e2966de3a7c1cad01b3 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 17:48:44 +0000 Subject: [PATCH 19/45] fabro(01KY7WQ92JWT90307EBQY6P2HV): simplify_fable (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7WQ92JWT90307EBQY6P2HV Fabro-Completed: 6 ⚒️ Generated with [Fabro](https://fabro.sh) --- docs/public/agents/outputs.mdx | 2 +- docs/public/reference/dot-language.mdx | 4 +- .../src/rules/inert_attribute.rs | 14 ++--- .../fabro-workflow/src/handler/command.rs | 57 ++++++++----------- .../src/handler/structured_output.rs | 18 ++++-- .../fabro-workflow/tests/it/integration.rs | 4 +- 6 files changed, 44 insertions(+), 55 deletions(-) diff --git a/docs/public/agents/outputs.mdx b/docs/public/agents/outputs.mdx index 061afaf21..09c08f635 100644 --- a/docs/public/agents/outputs.mdx +++ b/docs/public/agents/outputs.mdx @@ -83,7 +83,7 @@ Validated routing uses the same reverse scan as normal routing extraction: Fabro 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, using the last JSON object in 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. +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 diff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx index c9a2bbccb..638cea0a0 100644 --- a/docs/public/reference/dot-language.mdx +++ b/docs/public/reference/dot-language.mdx @@ -236,8 +236,8 @@ audit [ - `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. -- Command nodes validate the last JSON object in merged stdout and stderr only after the script exits with code `0`; 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}` and is not directly addressable by edge conditions. The `routing` schema applies routing fields and merges `context_updates` into flat context keys, which conditions can read (for example, `context.kept_count`). +- 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 diff --git a/lib/crates/fabro-validate/src/rules/inert_attribute.rs b/lib/crates/fabro-validate/src/rules/inert_attribute.rs index f604794f8..7d303dcdf 100644 --- a/lib/crates/fabro-validate/src/rules/inert_attribute.rs +++ b/lib/crates/fabro-validate/src/rules/inert_attribute.rs @@ -151,16 +151,6 @@ mod tests { assert_eq!(d.len(), 2); } - #[test] - fn accepts_output_schema_on_command_node() { - let mut g = minimal_graph(); - g.nodes.insert( - "run".to_string(), - node_with_attr("run", "parallelogram", "output_schema", "routing"), - ); - assert!(Rule.apply(&g).is_empty()); - } - #[test] fn accepts_attrs_on_their_own_handler_types() { let mut g = minimal_graph(); @@ -168,6 +158,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"), diff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs index ac044999b..0b744d988 100644 --- a/lib/crates/fabro-workflow/src/handler/command.rs +++ b/lib/crates/fabro-workflow/src/handler/command.rs @@ -168,41 +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) = output_schema.as_ref() { - match structured_output::validate_response_text(schema, &finalized.output_text) { - Ok(validated) => { - structured_output::apply_validated_output( - node, - schema, - &validated, - &mut outcome, - ); - } - Err(error) => { - let reason = schema_validation_failure_reason( - script, - &error, - &finalized.output_text, - ); - let mut failed = Outcome::fail_deterministic(reason); - failed.context_updates.insert( - keys::COMMAND_OUTPUT.to_string(), - serde_json::json!(finalized.output_ref), - ); - failed.timing = Some(StageTiming::active_only(0, result.duration_ms)); - return Ok(failed); - } - } + if let Some((schema, Ok(validated))) = validation { + structured_output::apply_validated_output(node, schema, &validated, &mut outcome); } - Ok(outcome) } else { let mut reason = format!( @@ -230,11 +220,10 @@ fn schema_validation_failure_reason( 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); - } + let mut reason = format!( + "Script output failed output_schema validation: {script}\n{}", + error.bulleted_messages() + ); append_output_tail(&mut reason, output_text); reason } diff --git a/lib/crates/fabro-workflow/src/handler/structured_output.rs b/lib/crates/fabro-workflow/src/handler/structured_output.rs index 15669462a..d28a82636 100644 --- a/lib/crates/fabro-workflow/src/handler/structured_output.rs +++ b/lib/crates/fabro-workflow/src/handler/structured_output.rs @@ -72,11 +72,22 @@ impl StructuredOutputError { self.kind } + #[cfg(test)] #[must_use] pub(crate) fn messages(&self) -> &[String] { &self.messages } + /// One `- {message}` bullet line per validator error, joined by newlines. + #[must_use] + pub(crate) fn bulleted_messages(&self) -> String { + self.messages + .iter() + .map(|message| format!("- {message}")) + .collect::>() + .join("\n") + } + #[must_use] pub(crate) fn allows_routing_fallback(&self) -> bool { matches!( @@ -97,12 +108,7 @@ impl StructuredOutputError { "Return a single JSON object that satisfies the configured JSON Schema.".to_string() } }; - let errors = self - .messages - .iter() - .map(|message| format!("- {message}")) - .collect::>() - .join("\n"); + let errors = self.bulleted_messages(); format!( "Your previous response did not satisfy the node's output_schema.\n\n\ Validation errors:\n{errors}\n\n\ diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index fd4b526f3..dedc58df9 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/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::{EventBody, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref}; +use fabro_types::{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}; @@ -2014,7 +2014,7 @@ async fn command_schema_validation_failure_does_not_consume_retries() { .lock() .unwrap() .iter() - .filter(|event| matches!(event.body, EventBody::CommandStarted(_))) + .filter(|event| event.event_name() == "command.started") .count(); assert_eq!(command_starts, 1, "command should execute exactly once"); } From 187e10879a47d650f5748d1225fa6484674401ca Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 14:18:04 -0400 Subject: [PATCH 20/45] fix(server): make generated title updates atomic --- .../fabro-server/src/server/handler/runs.rs | 21 ++---------- lib/crates/fabro-server/src/server/tests.rs | 2 +- lib/crates/fabro-store/src/slate/mod.rs | 34 +++++++++++++++++++ lib/crates/fabro-store/src/slate/run_store.rs | 27 +++++++++++++++ lib/crates/fabro-workflow/src/event.rs | 3 +- lib/crates/fabro-workflow/src/event/sink.rs | 17 +++++++++- 6 files changed, 83 insertions(+), 21 deletions(-) diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs index 57562fc4c..614f2d719 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/fabro-server/src/server/handler/runs.rs @@ -743,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) => { @@ -767,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 { @@ -776,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/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 7aafcec03..c90e1d10f 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -3495,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 diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index 60c40d1c4..7546a741f 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/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/run_store.rs b/lib/crates/fabro-store/src/slate/run_store.rs index 2b67a066a..a1d7f8441 100644 --- a/lib/crates/fabro-store/src/slate/run_store.rs +++ b/lib/crates/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-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index e64b2ae30..a5c1f583e 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/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/sink.rs b/lib/crates/fabro-workflow/src/event/sink.rs index 1017b2b29..7d62b7151 100644 --- a/lib/crates/fabro-workflow/src/event/sink.rs +++ b/lib/crates/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, From 5d7ecd7348ca8fe798f90d3231f9d4910f381c49 Mon Sep 17 00:00:00 2001 From: "fabro-releases[bot]" Date: Thu, 23 Jul 2026 18:35:29 +0000 Subject: [PATCH 21/45] Bump version to 0.303.0-nightly.3 --- Cargo.lock | 102 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c4bffa44c..d02be647e 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.3" 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.3" dependencies = [ "anyhow", "async-trait", @@ -2300,7 +2300,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "chrono", "fabro-automation", @@ -2323,7 +2323,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "async-trait", @@ -2348,7 +2348,7 @@ dependencies = [ [[package]] name = "fabro-automation" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "chrono", @@ -2367,11 +2367,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" [[package]] name = "fabro-checkpoint" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "chrono", "fabro-config", @@ -2387,7 +2387,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "assert_cmd", @@ -2489,7 +2489,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "bytes", @@ -2518,7 +2518,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "chrono", @@ -2547,7 +2547,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" 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.3" dependencies = [ "anyhow", "chrono", @@ -2574,7 +2574,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "assert_cmd", @@ -2593,7 +2593,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "bytes", @@ -2607,7 +2607,7 @@ dependencies = [ [[package]] name = "fabro-environment" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "chrono", @@ -2629,7 +2629,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "base64", @@ -2651,7 +2651,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "fabro-types", @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "async-trait", "fabro-agent", @@ -2688,7 +2688,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "fabro-static", "http 1.4.0", @@ -2698,7 +2698,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "base64", @@ -2717,7 +2717,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "async-trait", "dialoguer", @@ -2732,7 +2732,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "async-trait", @@ -2773,7 +2773,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "clap", "fabro-options-metadata", @@ -2784,7 +2784,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "fabro-api", @@ -2802,7 +2802,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "axum", @@ -2822,7 +2822,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "chrono", @@ -2849,7 +2849,7 @@ dependencies = [ [[package]] name = "fabro-mcp-store" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "chrono", "fabro-db", @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "fabro-static", "http 1.4.0", @@ -2883,7 +2883,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "axum", @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "serde", "serde_json", @@ -2913,7 +2913,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "cc", "libc", @@ -2922,7 +2922,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "aho-corasick", "ref-cast", @@ -2938,7 +2938,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "async-trait", @@ -2983,7 +2983,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "async-trait", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "fabro-http", "fabro-interview", @@ -3097,18 +3097,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" [[package]] name = "fabro-store" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "async-trait", "bytes", @@ -3138,7 +3138,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "base64", @@ -3164,7 +3164,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "fabro-types", @@ -3178,7 +3178,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "assert_cmd", @@ -3203,7 +3203,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "async-trait", @@ -3224,7 +3224,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "async-trait", @@ -3238,7 +3238,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "chrono", "clap", @@ -3260,7 +3260,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "console 0.15.11", @@ -3281,7 +3281,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "fabro-acp", "fabro-graphviz", @@ -3294,7 +3294,7 @@ dependencies = [ [[package]] name = "fabro-variable" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "chrono", @@ -3311,7 +3311,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "chrono", @@ -3330,7 +3330,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "assert_cmd", @@ -8494,7 +8494,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "axum", "base64", @@ -8513,7 +8513,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 5bd216ed8..32978ca47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.303.0-nightly.2" +version = "0.303.0-nightly.3" license = "MIT" [workspace.dependencies] From 3a28bc02499b67f565a5be0d734f515509aea130 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 18:51:58 +0000 Subject: [PATCH 22/45] fabro(01KY7WQ92JWT90307EBQY6P2HV): simplify_sol (failed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7WQ92JWT90307EBQY6P2HV Fabro-Completed: 7 ⚒️ Generated with [Fabro](https://fabro.sh) --- .../src/rules/inert_attribute.rs | 24 +++++++++++++++++++ .../fabro-workflow/src/handler/command.rs | 9 +++---- .../src/handler/structured_output.rs | 18 +++++--------- .../fabro-workflow/tests/it/integration.rs | 20 ++++------------ 4 files changed, 39 insertions(+), 32 deletions(-) diff --git a/lib/crates/fabro-validate/src/rules/inert_attribute.rs b/lib/crates/fabro-validate/src/rules/inert_attribute.rs index 7d303dcdf..6c547d26f 100644 --- a/lib/crates/fabro-validate/src/rules/inert_attribute.rs +++ b/lib/crates/fabro-validate/src/rules/inert_attribute.rs @@ -20,6 +20,7 @@ const HANDLER_SPECIFIC_ATTRS: &[(&str, &[&str])] = &[ ("duration", &["wait"]), ("join_policy", &["parallel"]), ("max_parallel", &["parallel"]), + ("output_retries", &["agent", "prompt"]), ("output_schema", &["agent", "prompt", "command"]), ("prompt", &["agent", "prompt", "parallel.fan_in"]), ]; @@ -151,6 +152,21 @@ mod tests { assert_eq!(d.len(), 2); } + #[test] + 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_retries", "2"), + ); + + let d = Rule.apply(&g); + + assert_eq!(d.len(), 1); + assert!(d[0].message.contains("'output_retries'")); + assert!(d[0].message.contains("agent, prompt")); + } + #[test] fn accepts_attrs_on_their_own_handler_types() { let mut g = minimal_graph(); @@ -170,6 +186,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", "join_policy", "wait_all"), @@ -178,6 +198,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-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs index 0b744d988..54fe0ff20 100644 --- a/lib/crates/fabro-workflow/src/handler/command.rs +++ b/lib/crates/fabro-workflow/src/handler/command.rs @@ -220,10 +220,11 @@ fn schema_validation_failure_reason( error: &StructuredOutputError, output_text: &str, ) -> String { - let mut reason = format!( - "Script output failed output_schema validation: {script}\n{}", - error.bulleted_messages() - ); + 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 } diff --git a/lib/crates/fabro-workflow/src/handler/structured_output.rs b/lib/crates/fabro-workflow/src/handler/structured_output.rs index d28a82636..15669462a 100644 --- a/lib/crates/fabro-workflow/src/handler/structured_output.rs +++ b/lib/crates/fabro-workflow/src/handler/structured_output.rs @@ -72,22 +72,11 @@ impl StructuredOutputError { self.kind } - #[cfg(test)] #[must_use] pub(crate) fn messages(&self) -> &[String] { &self.messages } - /// One `- {message}` bullet line per validator error, joined by newlines. - #[must_use] - pub(crate) fn bulleted_messages(&self) -> String { - self.messages - .iter() - .map(|message| format!("- {message}")) - .collect::>() - .join("\n") - } - #[must_use] pub(crate) fn allows_routing_fallback(&self) -> bool { matches!( @@ -108,7 +97,12 @@ impl StructuredOutputError { "Return a single JSON object that satisfies the configured JSON Schema.".to_string() } }; - let errors = self.bulleted_messages(); + let errors = self + .messages + .iter() + .map(|message| format!("- {message}")) + .collect::>() + .join("\n"); format!( "Your previous response did not satisfy the node's output_schema.\n\n\ Validation errors:\n{errors}\n\n\ diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index dedc58df9..5d95ab4e9 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/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}; @@ -1964,20 +1964,8 @@ async fn command_schema_validation_failure_does_not_consume_retries() { registry.register("exit", Box::new(ExitHandler)); registry.register("command", Box::new(CommandHandler)); let engine = WorkflowRunner::new(registry, Arc::new(emitter), 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("command-schema-no-retry"), - 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 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) @@ -2014,7 +2002,7 @@ async fn command_schema_validation_failure_does_not_consume_retries() { .lock() .unwrap() .iter() - .filter(|event| event.event_name() == "command.started") + .filter(|event| matches!(event.body, EventBody::CommandStarted(_))) .count(); assert_eq!(command_starts, 1, "command should execute exactly once"); } From e75c3dcd37c7a2aac7709b9146f9911c1663d5c5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 14:58:01 -0400 Subject: [PATCH 23/45] fix(test): replay SSE events before asserting stage types --- .../fabro-server/tests/it/scenario/sse.rs | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/lib/crates/fabro-server/tests/it/scenario/sse.rs b/lib/crates/fabro-server/tests/it/scenario/sse.rs index cef47a545..d95f1b174 100644 --- a/lib/crates/fabro-server/tests/it/scenario/sse.rs +++ b/lib/crates/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; From 5c7ac5da2ed0a2570bdce04d648dd6dbedda37ad Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 19:02:01 +0000 Subject: [PATCH 24/45] fabro(01KY7WQ92JWT90307EBQY6P2HV): verify (failed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7WQ92JWT90307EBQY6P2HV Fabro-Completed: 8 ⚒️ Generated with [Fabro](https://fabro.sh) From 51853779bbcf9e685c4ca3bc0a9d0af650640e45 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 19:09:17 +0000 Subject: [PATCH 25/45] fabro(01KY7WQ92JWT90307EBQY6P2HV): fixup (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7WQ92JWT90307EBQY6P2HV Fabro-Completed: 9 ⚒️ Generated with [Fabro](https://fabro.sh) From 56119990beb0cfbcbec078e331389ec8becc4e6c Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 19:10:39 +0000 Subject: [PATCH 26/45] fabro(01KY7Y01REECZ24XXTMBZ3PPV9): simplify_fable (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7Y01REECZ24XXTMBZ3PPV9 Fabro-Completed: 6 Fabro-Checkpoint: 497f92f4d91a807f2d66934a0a7e9ccc36cec5fd ⚒️ Generated with [Fabro](https://fabro.sh) --- Cargo.lock | 1 + lib/crates/fabro-graphviz/Cargo.toml | 3 + lib/crates/fabro-graphviz/src/fidelity.rs | 30 ++- .../src/rules/parallel_branch.rs | 27 +-- .../rules/parallel_branch_inert_attribute.rs | 74 +++---- .../rules/thread_id_requires_fidelity_full.rs | 3 + lib/crates/fabro-workflow/src/artifact.rs | 17 +- lib/crates/fabro-workflow/src/context.rs | 24 +++ .../fabro-workflow/src/handler/parallel.rs | 47 ++-- .../fabro-workflow/src/lifecycle/event.rs | 4 + .../fabro-workflow/src/lifecycle/fidelity.rs | 202 ++++++++++-------- 11 files changed, 262 insertions(+), 170 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 098d71cbf..6a4a8069d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2659,6 +2659,7 @@ dependencies = [ "nom", "regex", "serde", + "serde_json", "strum 0.28.0", "thiserror 2.0.18", ] diff --git a/lib/crates/fabro-graphviz/Cargo.toml b/lib/crates/fabro-graphviz/Cargo.toml index 98e017097..3b33151d8 100644 --- a/lib/crates/fabro-graphviz/Cargo.toml +++ b/lib/crates/fabro-graphviz/Cargo.toml @@ -21,3 +21,6 @@ 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/fidelity.rs b/lib/crates/fabro-graphviz/src/fidelity.rs index 8b33c98e8..98605c135 100644 --- a/lib/crates/fabro-graphviz/src/fidelity.rs +++ b/lib/crates/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-validate/src/rules/parallel_branch.rs b/lib/crates/fabro-validate/src/rules/parallel_branch.rs index 2f1cba2e7..d8a7d2fbd 100644 --- a/lib/crates/fabro-validate/src/rules/parallel_branch.rs +++ b/lib/crates/fabro-validate/src/rules/parallel_branch.rs @@ -35,22 +35,25 @@ impl<'a> ParallelBranches<'a> { .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 { - self.branch_only_parents(node_id).is_some() + 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> { - let mut incoming = self + if !self.is_branch_only_node(node_id) { + return None; + } + let parents: BTreeSet<&str> = self .graph - .edges - .iter() - .filter(|edge| edge.to == node_id) - .peekable(); - incoming.peek()?; - - incoming - .map(|edge| self.is_fork_edge(edge).then(|| edge.from.clone())) - .collect::>>() - .map(|parents| parents.into_iter().collect()) + .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/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs index da36dbbea..bac461103 100644 --- a/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs +++ b/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs @@ -7,11 +7,15 @@ pub(super) fn rule() -> Box { Box::new(Rule) } -/// Attributes that parallel branch execution does not resolve. Per-branch -/// preambles now honor fidelity, while `thread_id` remains inert because -/// concurrent branches cannot share an LLM session. +/// 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; /// Renders one or more parallel-node ids as `'a'` or `'a', 'b'`. @@ -22,19 +26,6 @@ fn quoted_list(ids: &[String]) -> String { .join(", ") } -fn fix_message(attr: &str) -> String { - match attr { - "thread_id" => format!( - "Remove '{attr}': parallel branches inherit the thread resolved when the parallel node started" - ), - _ => format!("Remove '{attr}'"), - } -} - -fn full_fidelity_message() -> String { - "parallel branches run at most at summary:high; full is degraded at runtime because branches cannot share a session".to_string() -} - fn full_fidelity_fix(parallel_ids: &[String]) -> String { let parent = if parallel_ids.len() == 1 { format!("parallel node {}", quoted_list(parallel_ids)) @@ -46,6 +37,23 @@ fn full_fidelity_fix(parallel_ids: &[String]) -> String { ) } +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() + } +} + impl LintRule for Rule { fn name(&self) -> &'static str { "parallel_branch_inert_attribute" @@ -64,15 +72,12 @@ impl LintRule for Rule { continue; } if edge.fidelity() == Some("full") { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: full_fidelity_message(), - node_id: None, - edge: Some((edge.from.clone(), edge.to.clone())), - fix: Some(full_fidelity_fix(std::slice::from_ref(&edge.from))), - ..Diagnostic::default() - }); + 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) { @@ -87,7 +92,7 @@ impl LintRule for Rule { ), node_id: None, edge: Some((edge.from.clone(), edge.to.clone())), - fix: Some(fix_message(attr)), + fix: Some(THREAD_ID_FIX.to_string()), ..Diagnostic::default() }); } @@ -103,15 +108,12 @@ impl LintRule for Rule { continue; }; if node.fidelity() == Some("full") { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: full_fidelity_message(), - node_id: Some(node.id.clone()), - edge: None, - fix: Some(full_fidelity_fix(&parents)), - ..Diagnostic::default() - }); + 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) { @@ -127,7 +129,7 @@ impl LintRule for Rule { ), node_id: Some(node.id.clone()), edge: None, - fix: Some(fix_message(attr)), + fix: Some(THREAD_ID_FIX.to_string()), ..Diagnostic::default() }); } diff --git a/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs b/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs index a73335da5..157b2a032 100644 --- a/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs +++ b/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs @@ -23,6 +23,9 @@ impl LintRule for Rule { 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() && !branches.is_branch_only_node(&node.id) diff --git a/lib/crates/fabro-workflow/src/artifact.rs b/lib/crates/fabro-workflow/src/artifact.rs index 975fee26d..bbf7f7ed8 100644 --- a/lib/crates/fabro-workflow/src/artifact.rs +++ b/lib/crates/fabro-workflow/src/artifact.rs @@ -78,12 +78,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); - snapshot.remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES); + strip_transient_keys(&mut snapshot); normalize_durable_updates(&mut snapshot); snapshot } +/// Remove runtime-only keys that must never reach durable storage or events. +pub 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); @@ -97,12 +103,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); - checkpoint - .context_values - .remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES); + strip_transient_keys(&mut checkpoint.context_values); normalize_durable_updates(&mut checkpoint.context_values); normalize_durable_outcomes(&mut checkpoint.node_outcomes); } diff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/crates/fabro-workflow/src/context.rs index b911e2210..a890c0e8f 100644 --- a/lib/crates/fabro-workflow/src/context.rs +++ b/lib/crates/fabro-workflow/src/context.rs @@ -25,6 +25,9 @@ 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 --- @@ -45,6 +48,12 @@ pub mod keys { pub const PARALLEL_FAN_IN_BEST_OUTCOME: &str = "parallel.fan_in.best_outcome"; pub const PARALLEL_FAN_IN_BEST_HEAD_SHA: &str = "parallel.fan_in.best_head_sha"; + /// Runtime-only keys stripped from durable context projections + /// (checkpoint snapshots and resume normalization). Add new transient + /// keys here so both strip sites stay in sync. + pub 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."; @@ -136,9 +145,24 @@ pub mod keys { pub use fabro_core::Context; use fabro_graphviz::Fidelity; use fabro_types::{ParallelBranchId, StageId}; +use serde::{Deserialize, Serialize}; use crate::event::StageScope; +/// 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 (`Graph::outgoing_edges` preserves declaration order, so producer and +/// consumer align even with duplicate targets). `null` entries mean the branch +/// inherits the fork's preamble. `FidelityLifecycle::before_node` produces the +/// stash; `ParallelHandler::execute` consumes and clears it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ParallelBranchPreamble { + pub fidelity: Fidelity, + pub 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/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index ac4e4f3e1..32e4a8b10 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -4,14 +4,13 @@ use std::time::Instant; use async_trait::async_trait; use fabro_agent::{Sandbox, WorktreeOptions, WorktreeSandbox}; -use fabro_graphviz::Fidelity; use fabro_graphviz::graph::{AttrValue, Graph, Node}; use fabro_hooks::{HookContext, HookEvent}; use fabro_types::{ParallelBranchId, RunId, StageId}; use tokio::sync::Semaphore; use super::{EngineServices, Handler}; -use crate::context::{Context, WorkflowContext, keys}; +use crate::context::{Context, ParallelBranchPreamble, WorkflowContext, keys}; use crate::error::Error; use crate::event::{Event, RunNoticeCode, RunNoticeLevel, StageScope}; use crate::git::sanitize_ref_component; @@ -57,15 +56,15 @@ struct BranchResult { worktree_path: Option, } -struct BranchPreamble { - fidelity: Fidelity, - preamble: String, -} - +/// 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>> { +) -> Option>> { let serde_json::Value::Array(entries) = value? else { return None; }; @@ -77,15 +76,7 @@ fn parse_branch_preambles( .into_iter() .map(|entry| match entry { serde_json::Value::Null => Some(None), - serde_json::Value::Object(entry) if entry.len() == 2 => { - let fidelity = entry.get("fidelity")?.as_str()?.parse().ok()?; - let preamble = entry.get("preamble")?.as_str()?; - Some(Some(BranchPreamble { - fidelity, - preamble: preamble.to_string(), - })) - } - _ => None, + entry => serde_json::from_value(entry).ok().map(Some), }) .collect() } @@ -258,6 +249,13 @@ impl Handler for ParallelHandler { context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), branches.len(), ); + // Clear the stash before forking 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 mut branch_setups: Vec = Vec::new(); for (branch_index, edge) in branches.iter().enumerate() { let target_id = edge.to.clone(); @@ -279,16 +277,15 @@ impl Handler for ParallelHandler { .and_then(|entries| entries.get(branch_index)) .and_then(Option::as_ref) { - branch_context.set(keys::CURRENT_PREAMBLE, serde_json::json!(&entry.preamble)); + branch_context.set( + keys::CURRENT_PREAMBLE, + serde_json::Value::String(entry.preamble.clone()), + ); branch_context.set( keys::INTERNAL_FIDELITY, - serde_json::json!(entry.fidelity.to_string()), + serde_json::Value::String(entry.fidelity.to_string()), ); } - branch_context.set( - keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, - serde_json::Value::Null, - ); let (branch_sandbox, worktree_path): (Arc, Option) = if let ( Some(ref gs), @@ -350,10 +347,6 @@ impl Handler for ParallelHandler { worktree_path, }); } - context.set( - keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, - serde_json::Value::Null, - ); // --- Fan out: concurrent execution --- let mut handles = Vec::new(); diff --git a/lib/crates/fabro-workflow/src/lifecycle/event.rs b/lib/crates/fabro-workflow/src/lifecycle/event.rs index 3965d7261..f547f5fe5 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/event.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/event.rs @@ -92,6 +92,10 @@ 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); diff --git a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs b/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs index 3f0b24228..437550cc1 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs +++ b/lib/crates/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,60 @@ 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 mut rendered: HashMap = HashMap::new(); + self.graph + .outgoing_edges(node_id) + .into_iter() + .enumerate() + .map(|(branch_index, edge)| { + let Some(target_node) = self.graph.nodes.get(&edge.to) else { + return serde_json::Value::Null; + }; + 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 { + return serde_json::Value::Null; + }; + rendered + .entry(branch_fidelity) + .or_insert_with(|| { + let entry = ParallelBranchPreamble { + fidelity: branch_fidelity, + preamble: build_preamble( + branch_fidelity, + resolved_context, + &self.graph, + completed_nodes, + resolved_outcomes, + ), + }; + serde_json::to_value(entry) + .expect("ParallelBranchPreamble serialization cannot fail") + }) + .clone() + }) + .collect() + } } #[async_trait] @@ -143,47 +198,23 @@ impl RunLifecycle for FidelityLifecycle { .context .set(keys::CURRENT_PREAMBLE, serde_json::json!(preamble)); + // 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 mut branch_preambles = Vec::new(); - for (branch_index, edge) in self.graph.outgoing_edges(node.id()).iter().enumerate() { - let Some(target_node) = self.graph.nodes.get(&edge.to) else { - branch_preambles.push(serde_json::Value::Null); - continue; - }; - let resolution = resolve_parallel_branch_fidelity(edge, target_node, fidelity); - if resolution.requested() == Some(keys::Fidelity::Full) { - tracing::warn!( - parallel_node = %node.id(), - branch = %edge.to, - branch_index, - fidelity = %keys::Fidelity::Full, - effective_fidelity = %keys::Fidelity::SummaryHigh, - "Parallel branch fidelity degraded" - ); - } - let Some(branch_fidelity) = resolution.entry_fidelity() else { - branch_preambles.push(serde_json::Value::Null); - continue; - }; - let branch_preamble = build_preamble( - branch_fidelity, - &resolved_context, - &self.graph, - &state.completed_nodes, - &resolved_outcomes, - ); - branch_preambles.push(serde_json::json!({ - "fidelity": branch_fidelity.to_string(), - "preamble": branch_preamble, - })); - } + 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), ); } - // 5. Thread ID resolution via resolve_thread_id: edge → node → graph default → + // 6. Thread ID resolution via resolve_thread_id: edge → node → graph default → // class → previous let thread_id = resolve_thread_id( incoming_edge_ref, @@ -192,13 +223,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 @@ -212,7 +243,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 @@ -243,22 +274,14 @@ impl RunLifecycle for FidelityLifecycle { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[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, } -impl ParallelBranchFidelityResolution { - fn requested(self) -> Option { - self.requested - } - - fn entry_fidelity(self) -> Option { - self.effective - } -} - /// Resolve explicit branch fidelity with edge-over-node precedence. /// /// Branches with no explicit fidelity inherit the parallel node's preamble. @@ -270,10 +293,7 @@ fn resolve_parallel_branch_fidelity( target_node: &GvNode, parallel_fidelity: keys::Fidelity, ) -> ParallelBranchFidelityResolution { - let requested = edge - .fidelity() - .and_then(|value| value.parse().ok()) - .or_else(|| target_node.fidelity().and_then(|value| value.parse().ok())); + let requested = explicit_fidelity(Some(edge), target_node).map(|(fidelity, _)| fidelity); let effective = requested .map(keys::Fidelity::degraded) .filter(|fidelity| *fidelity != parallel_fidelity); @@ -284,6 +304,23 @@ fn resolve_parallel_branch_fidelity( } } +/// 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 @@ -294,13 +331,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 { @@ -362,7 +394,7 @@ mod tests { use crate::context::WorkflowContext; use crate::context::keys::Fidelity; - fn fidelity_attr(value: &str) -> AttrValue { + fn str_attr(value: &str) -> AttrValue { AttrValue::String(value.to_string()) } @@ -374,23 +406,23 @@ mod tests { let mut start = Node::new("start"); start .attrs - .insert("shape".to_string(), fidelity_attr("Mdiamond")); + .insert("shape".to_string(), str_attr("Mdiamond")); let mut fork = Node::new("fork"); fork.attrs - .insert("shape".to_string(), fidelity_attr("component")); + .insert("shape".to_string(), str_attr("component")); if let Some(fidelity) = fork_fidelity { fork.attrs - .insert("fidelity".to_string(), fidelity_attr(fidelity)); + .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(), fidelity_attr(fidelity)); + .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(), fidelity_attr("box")); + work.attrs.insert("shape".to_string(), str_attr("box")); graph.nodes.insert(start.id.clone(), start); graph.nodes.insert(fork.id.clone(), fork); @@ -426,15 +458,15 @@ mod tests { fn parallel_branch_fidelity_edge_overrides_node() { let mut node = Node::new("branch"); node.attrs - .insert("fidelity".to_string(), fidelity_attr("compact")); + .insert("fidelity".to_string(), str_attr("compact")); let mut edge = Edge::new("fork", "branch"); edge.attrs - .insert("fidelity".to_string(), fidelity_attr("truncate")); + .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.entry_fidelity(), Some(Fidelity::Truncate)); + assert_eq!(resolved.requested, Some(Fidelity::Truncate)); + assert_eq!(resolved.effective, Some(Fidelity::Truncate)); } #[test] @@ -444,47 +476,45 @@ mod tests { let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Compact); - assert_eq!(resolution.requested(), None); - assert_eq!(resolution.entry_fidelity(), None); + 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(), fidelity_attr("full")); + 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.entry_fidelity(), Some(Fidelity::SummaryHigh)); + 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(), fidelity_attr("summary:high")); + .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.entry_fidelity(), None); + 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(), fidelity_attr("full")); + 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.entry_fidelity(), None); + assert_eq!(resolution.requested, Some(Fidelity::Full)); + assert_eq!(resolution.effective, None); } #[test] @@ -494,8 +524,8 @@ mod tests { let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Full); - assert_eq!(resolution.requested(), None); - assert_eq!(resolution.entry_fidelity(), None); + assert_eq!(resolution.requested, None); + assert_eq!(resolution.effective, None); } #[tokio::test] From fe5ed3fcec78d3b6073de926956aa6c34c2cbc01 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 19:15:12 +0000 Subject: [PATCH 27/45] fabro(01KY7WQ92JWT90307EBQY6P2HV): verify (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7WQ92JWT90307EBQY6P2HV Fabro-Completed: 10 ⚒️ Generated with [Fabro](https://fabro.sh) From 7ff153d2229d37c004b513397d656cc3f617c59d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 15:21:56 -0400 Subject: [PATCH 28/45] chore: bump verify timeout to 20m --- .fabro/workflows/implement-plan/workflow.fabro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 02437fb18a3ff3dc2d92f1084929fc7c50d19429 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 15:28:19 -0400 Subject: [PATCH 29/45] fix(workflow): constrain last-file routing fallback --- docs/public/agents/outputs.mdx | 2 + docs/public/reference/dot-language.mdx | 2 +- .../fabro-workflow/src/handler/agent.rs | 202 ++++++++++++++---- .../src/handler/structured_output.rs | 34 +++ 4 files changed, 195 insertions(+), 45 deletions(-) diff --git a/docs/public/agents/outputs.mdx b/docs/public/agents/outputs.mdx index e03b4e08f..d62073dea 100644 --- a/docs/public/agents/outputs.mdx +++ b/docs/public/agents/outputs.mdx @@ -95,6 +95,8 @@ 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. +The last-file fallback only reads `.json` and `.md` files (case-insensitive), and the routing JSON must be the final standalone object in the file, followed only by whitespace. 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. 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). diff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx index f01fbf2ed..2184ab1d4 100644 --- a/docs/public/reference/dot-language.mdx +++ b/docs/public/reference/dot-language.mdx @@ -235,7 +235,7 @@ 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 standalone JSON object contains the routing directive; only whitespace may follow it. Custom schemas and prompt nodes validate response text only. - `backend="acp"` with `output_schema` is unsupported in this release. ### Command nodes diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 16dbe0230..9ba8c2fe1 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/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,19 @@ 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 { @@ -382,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; @@ -393,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); } } } @@ -506,6 +522,68 @@ 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(), + }) + } + } + + async fn execute_with_last_file(path: &str, contents: &str) -> Outcome { + let sandbox_dir = TempDir::new().unwrap(); + std::fs::write(sandbox_dir.path().join(path), contents).unwrap(); + + 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(std::sync::Arc::new(fabro_agent::LocalSandbox::new( + sandbox_dir.path().to_path_buf(), + ))); + + 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 = 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(), + )); + + 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); @@ -731,49 +809,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!( @@ -782,6 +824,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() { @@ -819,6 +888,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/structured_output.rs b/lib/crates/fabro-workflow/src/handler/structured_output.rs index 546124b43..ea2c2aad1 100644 --- a/lib/crates/fabro-workflow/src/handler/structured_output.rs +++ b/lib/crates/fabro-workflow/src/handler/structured_output.rs @@ -264,6 +264,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 +502,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 = From 886371e4748ad00d0456e16f36365756a28620d9 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 15:47:17 -0400 Subject: [PATCH 30/45] refactor(workflow): clean up last-file routing fallback review nits Document the contract of read_last_file_routing_json (terminal JSON extraction only; routing validation happens downstream), extract a shared sandbox_with_file test helper, and drop the misleading "standalone" wording from the fallback docs. Co-Authored-By: Claude Fable 5 --- docs/public/agents/outputs.mdx | 2 +- docs/public/reference/dot-language.mdx | 2 +- .../fabro-workflow/src/handler/agent.rs | 26 ++++++++++--------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/public/agents/outputs.mdx b/docs/public/agents/outputs.mdx index d62073dea..28e3659a6 100644 --- a/docs/public/agents/outputs.mdx +++ b/docs/public/agents/outputs.mdx @@ -95,7 +95,7 @@ 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. -The last-file fallback only reads `.json` and `.md` files (case-insensitive), and the routing JSON must be the final standalone object in the file, followed only by whitespace. Fabro ignores other file types and routing JSON followed by any other content. These restrictions do not apply to the dedicated `status.json` fallback. +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. diff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx index 2184ab1d4..1543e5938 100644 --- a/docs/public/reference/dot-language.mdx +++ b/docs/public/reference/dot-language.mdx @@ -235,7 +235,7 @@ 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. The last-file fallback only accepts `.json` and `.md` files (case-insensitive) whose final standalone JSON object contains the routing directive; only whitespace may follow it. 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. - `backend="acp"` with `output_schema` is unsupported in this release. ### Command nodes diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 9ba8c2fe1..850c9de6d 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -183,6 +183,9 @@ 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 @@ -539,9 +542,17 @@ mod tests { } } - async fn execute_with_last_file(path: &str, contents: &str) -> Outcome { + 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(), @@ -552,12 +563,7 @@ mod tests { 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(), - ))); + services.run = services.run.with_sandbox(sandbox); handler .execute(&node, &context, &graph, tmp.path(), &services) @@ -569,11 +575,7 @@ mod tests { path: &str, contents: &str, ) -> Result { - 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(), - )); + let (_sandbox_dir, sandbox) = sandbox_with_file(path, contents); validate_agent_output_sources( &OutputSchemaKind::Routing, From c9b53031282fa360d27e5de2ac147bb955a64fca Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 15:52:25 -0400 Subject: [PATCH 31/45] fix(workflow): flush agent events before stage completion --- .../fabro-workflow/src/handler/llm/api.rs | 58 +++++++++++++++++-- .../fabro-workflow/tests/it/integration.rs | 30 +++++++++- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 2b0ffc1df..93ee9fed8 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/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. @@ -1213,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(), @@ -1284,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()); } @@ -1315,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() { @@ -1368,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(), @@ -1420,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; @@ -1492,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; @@ -1534,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(); diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 2b817434a..71a1e1103 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -2319,7 +2319,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 +2398,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 +2442,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" + ); } // --------------------------------------------------------------------------- From 6f8576f15c530b624438c81ef3609f92bc2675af Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 15:54:24 -0400 Subject: [PATCH 32/45] fix(manifest): bundle output schema file references --- lib/crates/fabro-manifest/src/lib.rs | 111 ++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 19 deletions(-) diff --git a/lib/crates/fabro-manifest/src/lib.rs b/lib/crates/fabro-manifest/src/lib.rs index 6acac3de9..f2f1b066b 100644 --- a/lib/crates/fabro-manifest/src/lib.rs +++ b/lib/crates/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(); From 8c37f5fae55624edb1f922f509a478f454edc7d5 Mon Sep 17 00:00:00 2001 From: "fabro-releases[bot]" Date: Thu, 23 Jul 2026 20:10:14 +0000 Subject: [PATCH 33/45] Bump version to 0.303.0-nightly.4 --- Cargo.lock | 102 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d02be647e..014915d5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2239,7 +2239,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.303.0-nightly.3" +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.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -2300,7 +2300,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "chrono", "fabro-automation", @@ -2323,7 +2323,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -2348,7 +2348,7 @@ dependencies = [ [[package]] name = "fabro-automation" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -2367,11 +2367,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" [[package]] name = "fabro-checkpoint" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "chrono", "fabro-config", @@ -2387,7 +2387,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "assert_cmd", @@ -2489,7 +2489,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "bytes", @@ -2518,7 +2518,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -2547,7 +2547,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.303.0-nightly.3" +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.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -2574,7 +2574,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "assert_cmd", @@ -2593,7 +2593,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "bytes", @@ -2607,7 +2607,7 @@ dependencies = [ [[package]] name = "fabro-environment" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -2629,7 +2629,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "base64", @@ -2651,7 +2651,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "fabro-types", @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "async-trait", "fabro-agent", @@ -2688,7 +2688,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "fabro-static", "http 1.4.0", @@ -2698,7 +2698,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "base64", @@ -2717,7 +2717,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "async-trait", "dialoguer", @@ -2732,7 +2732,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -2773,7 +2773,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "clap", "fabro-options-metadata", @@ -2784,7 +2784,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "fabro-api", @@ -2802,7 +2802,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "axum", @@ -2822,7 +2822,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -2849,7 +2849,7 @@ dependencies = [ [[package]] name = "fabro-mcp-store" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "chrono", "fabro-db", @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "fabro-static", "http 1.4.0", @@ -2883,7 +2883,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "axum", @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "serde", "serde_json", @@ -2913,7 +2913,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "cc", "libc", @@ -2922,7 +2922,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "aho-corasick", "ref-cast", @@ -2938,7 +2938,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -2983,7 +2983,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "fabro-http", "fabro-interview", @@ -3097,18 +3097,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" [[package]] name = "fabro-store" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "async-trait", "bytes", @@ -3138,7 +3138,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "base64", @@ -3164,7 +3164,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "fabro-types", @@ -3178,7 +3178,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "assert_cmd", @@ -3203,7 +3203,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -3224,7 +3224,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-trait", @@ -3238,7 +3238,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "chrono", "clap", @@ -3260,7 +3260,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "console 0.15.11", @@ -3281,7 +3281,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "fabro-acp", "fabro-graphviz", @@ -3294,7 +3294,7 @@ dependencies = [ [[package]] name = "fabro-variable" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -3311,7 +3311,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "chrono", @@ -3330,7 +3330,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "assert_cmd", @@ -8494,7 +8494,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "axum", "base64", @@ -8513,7 +8513,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 32978ca47..6cdec975b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.303.0-nightly.3" +version = "0.303.0-nightly.4" license = "MIT" [workspace.dependencies] From 9b85653a2dc62007a5ec2ce8cc8b8f1f4c817ef0 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 20:29:19 +0000 Subject: [PATCH 34/45] fabro(01KY7Y01REECZ24XXTMBZ3PPV9): simplify_sol (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7Y01REECZ24XXTMBZ3PPV9 Fabro-Completed: 7 Fabro-Checkpoint: 334aea8fdc267cb8b5d7ba3e67bfb0d6d0341d61 ⚒️ Generated with [Fabro](https://fabro.sh) --- lib/crates/fabro-workflow/src/artifact.rs | 4 +- lib/crates/fabro-workflow/src/context.rs | 17 ++-- .../fabro-workflow/src/lifecycle/fidelity.rs | 87 ++++++++++--------- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/lib/crates/fabro-workflow/src/artifact.rs b/lib/crates/fabro-workflow/src/artifact.rs index bbf7f7ed8..8ea8e38f0 100644 --- a/lib/crates/fabro-workflow/src/artifact.rs +++ b/lib/crates/fabro-workflow/src/artifact.rs @@ -83,8 +83,8 @@ pub fn durable_context_snapshot(context: &Context) -> HashMap { snapshot } -/// Remove runtime-only keys that must never reach durable storage or events. -pub fn strip_transient_keys(values: &mut HashMap) { +/// 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); } diff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/crates/fabro-workflow/src/context.rs index a890c0e8f..b02a8dc51 100644 --- a/lib/crates/fabro-workflow/src/context.rs +++ b/lib/crates/fabro-workflow/src/context.rs @@ -48,10 +48,8 @@ pub mod keys { pub const PARALLEL_FAN_IN_BEST_OUTCOME: &str = "parallel.fan_in.best_outcome"; pub const PARALLEL_FAN_IN_BEST_HEAD_SHA: &str = "parallel.fan_in.best_head_sha"; - /// Runtime-only keys stripped from durable context projections - /// (checkpoint snapshots and resume normalization). Add new transient - /// keys here so both strip sites stay in sync. - pub const TRANSIENT_CONTEXT_KEYS: &[&str] = + /// 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) --- @@ -152,15 +150,12 @@ use crate::event::StageScope; /// 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 (`Graph::outgoing_edges` preserves declaration order, so producer and -/// consumer align even with duplicate targets). `null` entries mean the branch -/// inherits the fork's preamble. `FidelityLifecycle::before_node` produces the -/// stash; `ParallelHandler::execute` consumes and clears it. +/// order. `null` entries mean the branch inherits the fork's preamble. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ParallelBranchPreamble { - pub fidelity: Fidelity, - pub preamble: String, +pub(crate) struct ParallelBranchPreamble { + pub(crate) fidelity: Fidelity, + pub(crate) preamble: String, } /// Domain-specific typed accessors for workflow context values. diff --git a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs b/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs index 437550cc1..37d2c7e33 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs @@ -74,47 +74,52 @@ impl FidelityLifecycle { resolved_outcomes: &HashMap, completed_nodes: &[String], ) -> Vec { - let mut rendered: HashMap = HashMap::new(); - self.graph - .outgoing_edges(node_id) - .into_iter() - .enumerate() - .map(|(branch_index, edge)| { - let Some(target_node) = self.graph.nodes.get(&edge.to) else { - return serde_json::Value::Null; - }; - 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 { - return serde_json::Value::Null; - }; - rendered - .entry(branch_fidelity) - .or_insert_with(|| { - let entry = ParallelBranchPreamble { - fidelity: branch_fidelity, - preamble: build_preamble( - branch_fidelity, - resolved_context, - &self.graph, - completed_nodes, - resolved_outcomes, - ), - }; - serde_json::to_value(entry) - .expect("ParallelBranchPreamble serialization cannot fail") - }) - .clone() - }) - .collect() + 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 } } From a89528caa625ff53e4c5b4b38d7215b98140bef3 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 20:33:27 +0000 Subject: [PATCH 35/45] fabro(01KY7Y01REECZ24XXTMBZ3PPV9): verify (failed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7Y01REECZ24XXTMBZ3PPV9 Fabro-Completed: 8 Fabro-Checkpoint: 69abcb72b5ece4f5bcc3d4cf75cc67c48f9b1837 ⚒️ Generated with [Fabro](https://fabro.sh) From e736786bf22aaeb077241357fb3ad2616cb74b71 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 16:46:40 -0400 Subject: [PATCH 36/45] fix(test): eliminate install and session-root races --- lib/crates/fabro-cli/tests/it/cmd/install.rs | 81 +++++++++++++++++--- lib/crates/fabro-test/src/lib.rs | 11 ++- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/lib/crates/fabro-cli/tests/it/cmd/install.rs b/lib/crates/fabro-cli/tests/it/cmd/install.rs index fe2c9cf61..e0eb0b75a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/install.rs +++ b/lib/crates/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-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index 9a3e9bc86..74a5f6727 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -497,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, From a58d65fd3c2a2a972939fb46489ca2d475881cc0 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 20:51:43 +0000 Subject: [PATCH 37/45] fabro(01KY7Y01REECZ24XXTMBZ3PPV9): fixup (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7Y01REECZ24XXTMBZ3PPV9 Fabro-Completed: 9 Fabro-Checkpoint: 3cefb504a7a71a4e26595ec35e6c867e5fe45ca0 ⚒️ Generated with [Fabro](https://fabro.sh) From fd4826742339911be63216b6c7fed34a3ec1dabd Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 20:57:21 +0000 Subject: [PATCH 38/45] fabro(01KY7Y01REECZ24XXTMBZ3PPV9): verify (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KY7Y01REECZ24XXTMBZ3PPV9 Fabro-Completed: 10 Fabro-Checkpoint: cecbfc763b42d83f12a72c9303c5ea2bac51c54f ⚒️ Generated with [Fabro](https://fabro.sh) From e5f029022951a9ccfe104f8522e6e0356dbfd30e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 17:55:41 -0400 Subject: [PATCH 39/45] fix(llm): send cache_control breakpoints for Claude via OpenRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic prompt caching is opt-in per request: without explicit ephemeral cache_control breakpoints in the body, no cache writes or reads ever happen. The OpenAI-compatible codec never emitted them, so every run on openrouter Claude models billed the full conversation at the uncached input rate on every turn (0 cache tokens on the billing page, confirmed by OpenRouter's activity portal). - Add a `cache_control_breakpoints` model feature declaring that a route only caches when the request marks the cacheable prefix; set it on the builtin OpenRouter Claude rows. Catalog build rejects the flag without `prompt_cache`. - Teach the Chat Completions wire shape a parts-form content variant so a message can carry the annotation; unmarked messages keep the plain-string form for compatibility with strict servers. - Mark the last system message (covers tools + system upstream) and the second-to-last user turn, counting tool results as user turns — mirroring the anthropic codec's placement so agent loops get incremental cache hits. - Extract the shared placement/opt-out policy into codec::cache and refactor the anthropic codec onto it; anthropic wire snapshots are unchanged. - Honor `provider_options..auto_cache = false` as an opt-out and consume the control key instead of merging it into the body. - Mirror the new feature through settings (fabro-config), the OpenAPI schema, and the generated TypeScript client. Co-Authored-By: Claude Fable 5 --- docs/public/api-reference/fabro-api.yaml | 7 + lib/crates/fabro-agent/src/cli.rs | 26 ++-- .../tests/model_features_round_trip.rs | 14 +- .../fabro-api/tests/model_round_trip.rs | 13 +- .../fabro-api/tests/provider_id_round_trip.rs | 13 +- lib/crates/fabro-cli/src/commands/model.rs | 26 ++-- lib/crates/fabro-config/src/builders.rs | 13 +- lib/crates/fabro-config/src/layers/llm.rs | 14 +- .../src/codec/anthropic_messages/encode.rs | 56 +------- .../src/codec/anthropic_messages/wire.rs | 17 +-- .../src/codec/bedrock_converse/encode.rs | 2 +- lib/crates/fabro-llm/src/codec/cache.rs | 98 +++++++++++++ lib/crates/fabro-llm/src/codec/mod.rs | 9 +- .../src/codec/openai_compatible/request.rs | 110 ++++++++++++++- .../src/codec/openai_compatible/translate.rs | 43 ++++-- .../src/codec/openai_compatible/wire.rs | 99 ++++++++++++- lib/crates/fabro-llm/src/model_test.rs | 39 +++--- .../tests/it/wire/openai_compatible.rs | 131 +++++++++++++++++- lib/crates/fabro-model/src/catalog.rs | 96 +++++++++++-- .../src/catalog/providers/openrouter.toml | 5 + lib/crates/fabro-model/src/types.rs | 32 +++-- .../src/models/model-features.ts | 4 + 22 files changed, 687 insertions(+), 180 deletions(-) create mode 100644 lib/crates/fabro-llm/src/codec/cache.rs diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index ca80fd423..4640686a2 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -8227,6 +8227,7 @@ components: - reasoning - reasoning_effort - prompt_cache + - cache_control_breakpoints - sampling_params properties: tools: @@ -8243,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). diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 749f38b0a..f14041456 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -1073,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() }); @@ -1158,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-api/tests/model_features_round_trip.rs b/lib/crates/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/crates/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_round_trip.rs b/lib/crates/fabro-api/tests/model_round_trip.rs index 93d4e7692..57d3db557 100644 --- a/lib/crates/fabro-api/tests/model_round_trip.rs +++ b/lib/crates/fabro-api/tests/model_round_trip.rs @@ -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/provider_id_round_trip.rs b/lib/crates/fabro-api/tests/provider_id_round_trip.rs index e648b008f..970c7158f 100644 --- a/lib/crates/fabro-api/tests/provider_id_round_trip.rs +++ b/lib/crates/fabro-api/tests/provider_id_round_trip.rs @@ -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-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index 4e5bd6f39..f84bd44e2 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -538,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), @@ -572,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), diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index 1ffdc650d..a1e00907c 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -417,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, } } diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs index ca29d2bbd..4f89c55fc 100644 --- a/lib/crates/fabro-config/src/layers/llm.rs +++ b/lib/crates/fabro-config/src/layers/llm.rs @@ -238,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-llm/src/codec/anthropic_messages/encode.rs b/lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs index 2803c221c..5ed999f87 100644 --- a/lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs +++ b/lib/crates/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(), @@ -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/wire.rs b/lib/crates/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/crates/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/encode.rs b/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs index 54a38314e..39c9c5c10 100644 --- a/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs +++ b/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs @@ -342,7 +342,7 @@ 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)] diff --git a/lib/crates/fabro-llm/src/codec/cache.rs b/lib/crates/fabro-llm/src/codec/cache.rs new file mode 100644 index 000000000..ed1afe8ab --- /dev/null +++ b/lib/crates/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/mod.rs b/lib/crates/fabro-llm/src/codec/mod.rs index 9ddc1eb0a..5a7759b22 100644 --- a/lib/crates/fabro-llm/src/codec/mod.rs +++ b/lib/crates/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/request.rs b/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs index 72920b34c..12be9e84b 100644 --- a/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs +++ b/lib/crates/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,7 +112,7 @@ 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)] @@ -315,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/translate.rs b/lib/crates/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/crates/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/crates/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/crates/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/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs index 3d8ea8eb0..aec0a83a7 100644 --- a/lib/crates/fabro-llm/src/model_test.rs +++ b/lib/crates/fabro-llm/src/model_test.rs @@ -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/tests/it/wire/openai_compatible.rs b/lib/crates/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/crates/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-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 69790f96d..cac9f878f 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -139,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)] @@ -574,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" )] @@ -1947,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), } } @@ -2261,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 @@ -2277,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), }) } @@ -2977,6 +2996,7 @@ enabled = true 0.5, ReasoningEffortFeature::Levels, false, + false, BillingPolicy::OpenAi, ), ( @@ -2989,6 +3009,7 @@ enabled = true 0.25, ReasoningEffortFeature::Levels, false, + false, BillingPolicy::OpenAi, ), ( @@ -3001,6 +3022,7 @@ enabled = true 0.1, ReasoningEffortFeature::Levels, false, + false, BillingPolicy::OpenAi, ), ( @@ -3013,6 +3035,7 @@ enabled = true 0.5, ReasoningEffortFeature::Levels, false, + true, BillingPolicy::Anthropic, ), ( @@ -3025,6 +3048,7 @@ enabled = true 1.0, ReasoningEffortFeature::AlwaysAdaptive, false, + true, BillingPolicy::Anthropic, ), ]; @@ -3039,6 +3063,7 @@ enabled = true cache_input_cost, reasoning_effort, sampling_params, + cache_control_breakpoints, billing_policy, ) in expected { @@ -3055,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!( @@ -3213,6 +3242,7 @@ enabled = true reasoning: true, reasoning_effort: Levels, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: true, }, costs: ModelCosts { @@ -3277,6 +3307,7 @@ enabled = true reasoning: true, reasoning_effort: AlwaysAdaptive, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: false, }, costs: ModelCosts { @@ -5683,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( @@ -5843,6 +5907,7 @@ sampling_params = false reasoning: true, reasoning_effort: Levels, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: true, }, costs: ModelCosts { @@ -5899,6 +5964,7 @@ sampling_params = false reasoning: true, reasoning_effort: None, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: false, }, costs: ModelCosts { @@ -5947,6 +6013,7 @@ sampling_params = false reasoning: true, reasoning_effort: AlwaysAdaptive, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: false, }, costs: ModelCosts { @@ -6019,6 +6086,7 @@ sampling_params = false reasoning: true, reasoning_effort: Levels, prompt_cache: true, + cache_control_breakpoints: false, sampling_params: true, }, costs: ModelCosts { @@ -6084,6 +6152,7 @@ sampling_params = false reasoning: true, reasoning_effort: Levels, prompt_cache: false, + cache_control_breakpoints: false, sampling_params: true, }, costs: ModelCosts { @@ -6140,6 +6209,7 @@ sampling_params = false reasoning: true, reasoning_effort: Levels, prompt_cache: false, + cache_control_breakpoints: false, sampling_params: true, }, costs: ModelCosts { diff --git a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml b/lib/crates/fabro-model/src/catalog/providers/openrouter.toml index 4ef7d7c63..b83aa7662 100644 --- a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml +++ b/lib/crates/fabro-model/src/catalog/providers/openrouter.toml @@ -50,6 +50,7 @@ vision = true reasoning = true reasoning_effort = "always_adaptive" prompt_cache = true +cache_control_breakpoints = true sampling_params = false [providers.openrouter.models."claude-fable-5".costs] @@ -76,6 +77,7 @@ vision = true reasoning = true reasoning_effort = "levels" prompt_cache = true +cache_control_breakpoints = true sampling_params = false [providers.openrouter.models."claude-opus-4-8".costs] @@ -98,6 +100,7 @@ tools = true vision = true reasoning = true prompt_cache = true +cache_control_breakpoints = true [providers.openrouter.models."claude-opus-4-7".costs] input_cost_per_mtok = 5.0 @@ -121,6 +124,7 @@ tools = true vision = true reasoning = true prompt_cache = true +cache_control_breakpoints = true [providers.openrouter.models."claude-sonnet-4-6".costs] input_cost_per_mtok = 3.0 @@ -144,6 +148,7 @@ tools = true vision = true reasoning = false prompt_cache = true +cache_control_breakpoints = true [providers.openrouter.models."claude-haiku-4-5".costs] input_cost_per_mtok = 1.0 diff --git a/lib/crates/fabro-model/src/types.rs b/lib/crates/fabro-model/src/types.rs index fb0164804..ae3804784 100644 --- a/lib/crates/fabro-model/src/types.rs +++ b/lib/crates/fabro-model/src/types.rs @@ -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 { @@ -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/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). */ From 47bc772f7bb200c8047e532bac3dd954f7cbe0c5 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Thu, 23 Jul 2026 17:58:24 -0400 Subject: [PATCH 40/45] refactor: organize crates into three layers --- .ai/prompts/code-review-deep-1.md | 2 +- .ai/prompts/code-review-deep-2.md | 4 +- .claude/skills/docs/references/mapping.md | 58 +++++++-------- .gitattributes | 2 +- .github/workflows/rust.yml | 10 ++- .github/workflows/typescript.yml | 4 +- .gitignore | 4 +- AGENTS.md | 6 +- Cargo.toml | 22 +++--- apps/fabro-web/app/routes/run-overview.tsx | 2 +- docs/internal/cli-workflow-coupling-audit.md | 40 +++++------ .../event-schema-competitive-analysis.md | 6 +- .../fabro-event-schema-v2-concrete-shape.md | 18 ++--- docs/internal/mcp-server-qa-test-plan.md | 2 +- docs/internal/migrations-strategy.md | 2 +- ...an-events-as-source-of-truth-follow-ups.md | 56 +++++++-------- docs/internal/run-directory-keys.md | 4 +- .../slow-test-opportunities-2026-04-07.md | 46 ++++++------ docs/internal/testing-strategy.md | 6 +- docs/public/api-reference/client-sdks.mdx | 2 +- docs/public/examples/repl-handoff.mdx | 2 +- lib/{crates => apps}/fabro-cli/Cargo.toml | 70 +++++++++---------- lib/{crates => apps}/fabro-cli/build.rs | 0 lib/{crates => apps}/fabro-cli/src/args.rs | 0 .../fabro-cli/src/command_context.rs | 0 .../fabro-cli/src/commands/artifact/cp.rs | 0 .../fabro-cli/src/commands/artifact/list.rs | 0 .../fabro-cli/src/commands/artifact/mod.rs | 0 .../fabro-cli/src/commands/auth/login.rs | 0 .../fabro-cli/src/commands/auth/logout.rs | 0 .../fabro-cli/src/commands/auth/mod.rs | 0 .../fabro-cli/src/commands/auth/status.rs | 0 .../fabro-cli/src/commands/cli_reference.rs | 0 .../fabro-cli/src/commands/config/mod.rs | 0 .../fabro-cli/src/commands/doctor.rs | 0 .../fabro-cli/src/commands/dump.rs | 0 .../fabro-cli/src/commands/exec.rs | 0 .../fabro-cli/src/commands/graph.rs | 0 .../fabro-cli/src/commands/install.rs | 0 .../fabro-cli/src/commands/mcp/mod.rs | 0 .../fabro-cli/src/commands/mod.rs | 0 .../fabro-cli/src/commands/model.rs | 0 .../fabro-cli/src/commands/parent/link.rs | 0 .../fabro-cli/src/commands/parent/mod.rs | 0 .../fabro-cli/src/commands/parent/unlink.rs | 0 .../fabro-cli/src/commands/parse.rs | 0 .../fabro-cli/src/commands/pr/close.rs | 0 .../fabro-cli/src/commands/pr/create.rs | 0 .../fabro-cli/src/commands/pr/link.rs | 0 .../fabro-cli/src/commands/pr/merge.rs | 0 .../fabro-cli/src/commands/pr/mod.rs | 0 .../fabro-cli/src/commands/pr/unlink.rs | 0 .../fabro-cli/src/commands/pr/view.rs | 0 .../fabro-cli/src/commands/preflight.rs | 0 .../fabro-cli/src/commands/provider/login.rs | 0 .../fabro-cli/src/commands/provider/mod.rs | 0 .../fabro-cli/src/commands/render_graph.rs | 0 .../fabro-cli/src/commands/repo/deinit.rs | 0 .../fabro-cli/src/commands/repo/init.rs | 0 .../fabro-cli/src/commands/repo/mod.rs | 0 .../fabro-cli/src/commands/run/ask.rs | 0 .../fabro-cli/src/commands/run/attach.rs | 0 .../fabro-cli/src/commands/run/checkpoints.rs | 0 .../fabro-cli/src/commands/run/command.rs | 0 .../fabro-cli/src/commands/run/cp.rs | 0 .../fabro-cli/src/commands/run/create.rs | 0 .../fabro-cli/src/commands/run/diff.rs | 0 .../fabro-cli/src/commands/run/events.rs | 0 .../fabro-cli/src/commands/run/fork.rs | 0 .../fabro-cli/src/commands/run/logs.rs | 0 .../fabro-cli/src/commands/run/mod.rs | 0 .../fabro-cli/src/commands/run/output.rs | 0 .../fabro-cli/src/commands/run/overrides.rs | 0 .../fabro-cli/src/commands/run/preview.rs | 0 .../fabro-cli/src/commands/run/resume.rs | 0 .../fabro-cli/src/commands/run/rewind.rs | 0 .../src/commands/run/run_progress/event.rs | 0 .../commands/run/run_progress/info_display.rs | 0 .../src/commands/run/run_progress/mod.rs | 0 .../src/commands/run/run_progress/renderer.rs | 0 .../run/run_progress/setup_display.rs | 0 .../run/run_progress/stage_display.rs | 0 .../src/commands/run/run_progress/styles.rs | 0 .../fabro-cli/src/commands/run/runner.rs | 0 .../fabro-cli/src/commands/run/ssh.rs | 0 .../fabro-cli/src/commands/run/start.rs | 0 .../fabro-cli/src/commands/run/steer.rs | 0 .../fabro-cli/src/commands/run/wait.rs | 0 .../fabro-cli/src/commands/runs/approval.rs | 0 .../fabro-cli/src/commands/runs/archive.rs | 0 .../fabro-cli/src/commands/runs/inspect.rs | 0 .../fabro-cli/src/commands/runs/list.rs | 0 .../fabro-cli/src/commands/runs/mod.rs | 0 .../fabro-cli/src/commands/runs/rm.rs | 0 .../fabro-cli/src/commands/sandbox/mod.rs | 0 .../fabro-cli/src/commands/secret/list.rs | 0 .../fabro-cli/src/commands/secret/mod.rs | 0 .../fabro-cli/src/commands/secret/rm.rs | 0 .../fabro-cli/src/commands/secret/set.rs | 0 .../src/commands/server/foreground.rs | 0 .../fabro-cli/src/commands/server/mod.rs | 0 .../fabro-cli/src/commands/server/start.rs | 0 .../fabro-cli/src/commands/server/status.rs | 0 .../fabro-cli/src/commands/server/stop.rs | 0 .../fabro-cli/src/commands/system/df.rs | 0 .../fabro-cli/src/commands/system/events.rs | 0 .../fabro-cli/src/commands/system/info.rs | 0 .../fabro-cli/src/commands/system/mod.rs | 0 .../fabro-cli/src/commands/system/prune.rs | 0 .../fabro-cli/src/commands/system/repair.rs | 0 .../fabro-cli/src/commands/uninstall.rs | 0 .../fabro-cli/src/commands/upgrade.rs | 0 .../fabro-cli/src/commands/validate.rs | 0 .../fabro-cli/src/commands/variable/get.rs | 0 .../fabro-cli/src/commands/variable/list.rs | 0 .../fabro-cli/src/commands/variable/mod.rs | 0 .../fabro-cli/src/commands/variable/rm.rs | 0 .../fabro-cli/src/commands/variable/set.rs | 0 .../fabro-cli/src/commands/version.rs | 0 .../fabro-cli/src/commands/workflow/create.rs | 0 .../fabro-cli/src/commands/workflow/list.rs | 0 .../fabro-cli/src/commands/workflow/mod.rs | 0 lib/{crates => apps}/fabro-cli/src/gh.rs | 0 lib/{crates => apps}/fabro-cli/src/landing.rs | 0 .../fabro-cli/src/local_server.rs | 0 lib/{crates => apps}/fabro-cli/src/logging.rs | 0 lib/{crates => apps}/fabro-cli/src/main.rs | 0 .../fabro-cli/src/manifest_args.rs | 0 .../fabro-cli/src/server_client.rs | 0 .../fabro-cli/src/server_runs.rs | 0 .../fabro-cli/src/shared/github.rs | 0 .../fabro-cli/src/shared/mod.rs | 0 .../fabro-cli/src/shared/provider_auth.rs | 0 .../fabro-cli/src/shared/repo.rs | 0 .../fabro-cli/src/shared/utilities.rs | 0 .../fabro-cli/src/sleep_inhibitor/dummy.rs | 0 .../src/sleep_inhibitor/iokit_bindings.rs | 0 .../fabro-cli/src/sleep_inhibitor/linux.rs | 0 .../fabro-cli/src/sleep_inhibitor/macos.rs | 0 .../fabro-cli/src/sleep_inhibitor/mod.rs | 0 .../fabro-cli/src/user_config.rs | 0 .../fabro-cli/tests/build_git_metadata.rs | 0 .../fabro-cli/tests/it/cmd/approve.rs | 0 .../fabro-cli/tests/it/cmd/archive.rs | 0 .../fabro-cli/tests/it/cmd/artifact_cp.rs | 0 .../fabro-cli/tests/it/cmd/artifact_list.rs | 0 .../fabro-cli/tests/it/cmd/attach.rs | 0 .../fabro-cli/tests/it/cmd/auth.rs | 0 .../fabro-cli/tests/it/cmd/cli_reference.rs | 0 .../fabro-cli/tests/it/cmd/config.rs | 0 .../fabro-cli/tests/it/cmd/create.rs | 0 .../fabro-cli/tests/it/cmd/deny.rs | 0 .../fabro-cli/tests/it/cmd/diff.rs | 0 .../fabro-cli/tests/it/cmd/discord.rs | 0 .../fabro-cli/tests/it/cmd/docs.rs | 0 .../fabro-cli/tests/it/cmd/doctor.rs | 0 .../fabro-cli/tests/it/cmd/dump.rs | 0 .../fabro-cli/tests/it/cmd/events.rs | 0 .../fabro-cli/tests/it/cmd/exec.rs | 0 .../fabro-cli/tests/it/cmd/fabro.rs | 0 .../fabro-cli/tests/it/cmd/fork.rs | 0 .../fabro-cli/tests/it/cmd/graph.rs | 0 .../fabro-cli/tests/it/cmd/inspect.rs | 0 .../fabro-cli/tests/it/cmd/install.rs | 0 .../fabro-cli/tests/it/cmd/json_global.rs | 0 .../fabro-cli/tests/it/cmd/logs.rs | 0 .../fabro-cli/tests/it/cmd/mcp.rs | 0 .../fabro-cli/tests/it/cmd/mod.rs | 0 .../fabro-cli/tests/it/cmd/model.rs | 0 .../fabro-cli/tests/it/cmd/model_list.rs | 0 .../fabro-cli/tests/it/cmd/model_test.rs | 0 .../fabro-cli/tests/it/cmd/parent.rs | 0 .../fabro-cli/tests/it/cmd/parse.rs | 0 .../fabro-cli/tests/it/cmd/pr.rs | 0 .../fabro-cli/tests/it/cmd/pr_close.rs | 0 .../fabro-cli/tests/it/cmd/pr_create.rs | 0 .../fabro-cli/tests/it/cmd/pr_link.rs | 0 .../fabro-cli/tests/it/cmd/pr_merge.rs | 0 .../fabro-cli/tests/it/cmd/pr_unlink.rs | 0 .../fabro-cli/tests/it/cmd/pr_view.rs | 0 .../fabro-cli/tests/it/cmd/preflight.rs | 0 .../fabro-cli/tests/it/cmd/provider.rs | 0 .../fabro-cli/tests/it/cmd/provider_login.rs | 0 .../fabro-cli/tests/it/cmd/ps.rs | 0 .../fabro-cli/tests/it/cmd/render_graph.rs | 0 .../fabro-cli/tests/it/cmd/repo.rs | 0 .../fabro-cli/tests/it/cmd/repo_deinit.rs | 0 .../fabro-cli/tests/it/cmd/repo_init.rs | 0 .../fabro-cli/tests/it/cmd/resume.rs | 0 .../fabro-cli/tests/it/cmd/rewind.rs | 0 .../fabro-cli/tests/it/cmd/rm.rs | 0 .../fabro-cli/tests/it/cmd/run.rs | 0 .../fabro-cli/tests/it/cmd/runner.rs | 0 .../fabro-cli/tests/it/cmd/sandbox_cp.rs | 0 .../fabro-cli/tests/it/cmd/sandbox_preview.rs | 0 .../fabro-cli/tests/it/cmd/sandbox_ssh.rs | 0 .../fabro-cli/tests/it/cmd/secret.rs | 0 .../fabro-cli/tests/it/cmd/secret_list.rs | 0 .../fabro-cli/tests/it/cmd/secret_rm.rs | 0 .../fabro-cli/tests/it/cmd/secret_set.rs | 0 .../fabro-cli/tests/it/cmd/send_analytics.rs | 0 .../fabro-cli/tests/it/cmd/send_panic.rs | 0 .../fabro-cli/tests/it/cmd/server_start.rs | 0 .../fabro-cli/tests/it/cmd/server_status.rs | 0 .../fabro-cli/tests/it/cmd/server_stop.rs | 0 .../fabro-cli/tests/it/cmd/start.rs | 0 .../fabro-cli/tests/it/cmd/support.rs | 0 .../fabro-cli/tests/it/cmd/system.rs | 0 .../fabro-cli/tests/it/cmd/system_df.rs | 0 .../fabro-cli/tests/it/cmd/system_events.rs | 0 .../fabro-cli/tests/it/cmd/system_info.rs | 0 .../fabro-cli/tests/it/cmd/system_prune.rs | 0 .../fabro-cli/tests/it/cmd/system_repair.rs | 0 .../fabro-cli/tests/it/cmd/test_panic.rs | 0 .../fabro-cli/tests/it/cmd/top_level.rs | 0 .../fabro-cli/tests/it/cmd/unarchive.rs | 0 .../fabro-cli/tests/it/cmd/uninstall.rs | 0 .../fabro-cli/tests/it/cmd/upgrade.rs | 0 .../fabro-cli/tests/it/cmd/validate.rs | 0 .../fabro-cli/tests/it/cmd/variable.rs | 0 .../fabro-cli/tests/it/cmd/variable_get.rs | 0 .../fabro-cli/tests/it/cmd/variable_list.rs | 0 .../fabro-cli/tests/it/cmd/variable_rm.rs | 0 .../fabro-cli/tests/it/cmd/variable_set.rs | 0 .../fabro-cli/tests/it/cmd/version.rs | 0 .../fabro-cli/tests/it/cmd/wait.rs | 0 .../fabro-cli/tests/it/cmd/worker_auth.rs | 0 .../fabro-cli/tests/it/cmd/workflow.rs | 0 .../fabro-cli/tests/it/cmd/workflow_create.rs | 0 .../fabro-cli/tests/it/cmd/workflow_list.rs | 0 .../fabro-cli/tests/it/main.rs | 0 .../fabro-cli/tests/it/scenario/archive.rs | 0 .../fabro-cli/tests/it/scenario/artifacts.rs | 0 .../fabro-cli/tests/it/scenario/auth.rs | 0 .../fabro-cli/tests/it/scenario/exec.rs | 0 .../fabro-cli/tests/it/scenario/lifecycle.rs | 0 .../fabro-cli/tests/it/scenario/mod.rs | 0 .../tests/it/scenario/server_lifecycle.rs | 0 .../fabro-cli/tests/it/scenario/smoke.rs | 0 .../tests/it/support/auth_harness.rs | 0 .../fabro-cli/tests/it/support/auth_tokens.rs | 0 .../fabro-cli/tests/it/support/mod.rs | 0 .../fabro-cli/tests/it/workflow/acp.rs | 0 .../tests/it/workflow/agent_linear.rs | 0 .../fabro-cli/tests/it/workflow/artifacts.rs | 0 .../tests/it/workflow/command_agent_mixed.rs | 0 .../tests/it/workflow/command_pipeline.rs | 0 .../tests/it/workflow/command_routing.rs | 0 .../it/workflow/conditional_branching.rs | 0 .../tests/it/workflow/dry_run_examples.rs | 0 .../it/workflow/fixtures/agent_linear.fabro | 0 .../fixtures/command_agent_mixed.fabro | 0 .../workflow/fixtures/command_pipeline.fabro | 0 .../workflow/fixtures/command_routing.fabro | 0 .../fixtures/conditional_branching.fabro | 0 .../it/workflow/fixtures/full_stack.fabro | 0 .../it/workflow/fixtures/human_gate.fabro | 0 .../fabro-cli/tests/it/workflow/full_stack.rs | 0 .../fabro-cli/tests/it/workflow/hooks.rs | 0 .../fabro-cli/tests/it/workflow/human_gate.rs | 0 .../fabro-cli/tests/it/workflow/mod.rs | 0 .../tests/manifest_path_round_trip.rs | 0 .../fabro-mcp-server/Cargo.toml | 16 ++--- .../fabro-mcp-server/src/config.rs | 0 .../fabro-mcp-server/src/lib.rs | 0 .../fabro-mcp-server/src/manifest_builder.rs | 0 .../fabro-mcp-server/src/server.rs | 0 lib/{crates => apps}/fabro-server/Cargo.toml | 62 ++++++++-------- lib/{crates => apps}/fabro-server/build.rs | 0 .../2026051801_legacy_vault_entries.rs | 0 ...01_optional_server_env_secrets_to_vault.rs | 0 .../fabro-server/src/auth/browser_shell.rs | 0 .../fabro-server/src/auth/cli_flow.rs | 0 .../fabro-server/src/auth/github_endpoints.rs | 0 .../fabro-server/src/auth/jwt.rs | 0 .../fabro-server/src/auth/keys.rs | 0 .../fabro-server/src/auth/mod.rs | 0 .../fabro-server/src/auth/translate.rs | 0 .../src/automation_materializer.rs | 0 .../fabro-server/src/canonical_host.rs | 0 .../fabro-server/src/canonical_origin.rs | 0 lib/{crates => apps}/fabro-server/src/csp.rs | 0 .../fabro-server/src/demo/mod.rs | 0 .../fabro-server/src/diagnostics.rs | 0 .../fabro-server/src/error.rs | 0 .../fabro-server/src/git_checkout.rs | 0 .../fabro-server/src/github_webhooks.rs | 0 .../fabro-server/src/install.rs | 0 .../fabro-server/src/interp.rs | 0 .../fabro-server/src/jwt_auth.rs | 0 lib/{crates => apps}/fabro-server/src/lib.rs | 0 .../fabro-server/src/manifest_validation.rs | 0 .../fabro-server/src/migrations.rs | 0 .../fabro-server/src/principal_middleware.rs | 0 .../fabro-server/src/request_id.rs | 0 .../fabro-server/src/run_files.rs | 0 .../fabro-server/src/run_files_security.rs | 0 .../fabro-server/src/run_manifest.rs | 0 .../fabro-server/src/run_selector.rs | 0 .../fabro-server/src/run_title_generation.rs | 0 .../fabro-server/src/run_tool_manifest.rs | 0 .../fabro-server/src/security_headers.rs | 0 .../fabro-server/src/serve.rs | 0 .../fabro-server/src/server.rs | 0 .../src/server/automation_scheduler.rs | 0 .../src/server/handler/artifacts.rs | 0 .../src/server/handler/automations.rs | 0 .../src/server/handler/billing.rs | 0 .../src/server/handler/completions.rs | 0 .../src/server/handler/environments.rs | 0 .../fabro-server/src/server/handler/events.rs | 0 .../fabro-server/src/server/handler/graph.rs | 0 .../src/server/handler/lifecycle.rs | 0 .../src/server/handler/llm_sse.rs | 0 .../src/server/handler/mcp_servers.rs | 0 .../fabro-server/src/server/handler/mod.rs | 0 .../fabro-server/src/server/handler/models.rs | 0 .../fabro-server/src/server/handler/pair.rs | 0 .../src/server/handler/playground.rs | 0 .../handler/prompts/playground_system.md | 0 .../src/server/handler/pull_requests.rs | 0 .../fabro-server/src/server/handler/runs.rs | 0 .../src/server/handler/sandbox.rs | 0 .../src/server/handler/sandboxes.rs | 0 .../src/server/handler/secrets.rs | 0 .../src/server/handler/sessions.rs | 0 .../fabro-server/src/server/handler/steer.rs | 0 .../fabro-server/src/server/handler/system.rs | 0 .../src/server/handler/variables.rs | 0 .../src/server/handler/worker_control.rs | 0 .../src/server/resource_sampler.rs | 0 .../src/server/session_runtime.rs | 0 .../fabro-server/src/server/tests.rs | 0 .../fabro-server/src/server_secrets.rs | 0 .../fabro-server/src/spawn_env.rs | 0 .../fabro-server/src/startup.rs | 0 .../fabro-server/src/static_files.rs | 2 +- .../fabro-server/src/test_support.rs | 0 .../fabro-server/src/web_auth.rs | 0 .../fabro-server/src/worker_control/bus.rs | 0 .../fabro-server/src/worker_control/local.rs | 0 .../fabro-server/src/worker_control/mod.rs | 0 .../fabro-server/src/worker_runtime.rs | 0 .../fabro-server/src/worker_token.rs | 0 .../tests/fixtures/spa/images/favicon.svg | 0 .../tests/fixtures/spa/index.html | 0 .../tests/it/api/auth_sessions.rs | 0 .../fabro-server/tests/it/api/automations.rs | 0 .../tests/it/api/cli_auth_token.rs | 0 .../fabro-server/tests/it/api/compression.rs | 0 .../fabro-server/tests/it/api/docs.rs | 0 .../fabro-server/tests/it/api/environments.rs | 0 .../fabro-server/tests/it/api/events.rs | 0 .../fabro-server/tests/it/api/install.rs | 0 .../tests/it/api/install_openai_compatible.rs | 0 .../fabro-server/tests/it/api/mcp_servers.rs | 0 .../fabro-server/tests/it/api/mod.rs | 0 .../fabro-server/tests/it/api/routing.rs | 0 .../fabro-server/tests/it/api/run_files.rs | 0 .../fabro-server/tests/it/api/runs.rs | 0 .../fabro-server/tests/it/api/sandbox_vnc.rs | 0 .../fabro-server/tests/it/api/sessions.rs | 0 .../fabro-server/tests/it/api/settings.rs | 0 .../fabro-server/tests/it/api/system.rs | 0 .../fabro-server/tests/it/api/tcp.rs | 0 .../fabro-server/tests/it/api/variables.rs | 0 .../fabro-server/tests/it/event_pagination.rs | 0 .../fabro-server/tests/it/helpers.rs | 2 +- .../fabro-server/tests/it/main.rs | 0 .../tests/it/openapi_conformance.rs | 0 .../fabro-server/tests/it/pagination.rs | 0 .../fabro-server/tests/it/scenario/archive.rs | 0 .../fabro-server/tests/it/scenario/dry_run.rs | 0 .../tests/it/scenario/lifecycle.rs | 0 .../fabro-server/tests/it/scenario/mod.rs | 0 .../tests/it/scenario/run_completion.rs | 0 .../fabro-server/tests/it/scenario/sse.rs | 0 .../fabro-server/tests/it/scenario/usage.rs | 0 lib/{crates => apps}/fabro-spa/Cargo.toml | 0 .../fabro-spa/assets/.gitkeep | 0 lib/{crates => apps}/fabro-spa/src/lib.rs | 0 .../fabro-acp/Cargo.toml | 4 +- .../fabro-acp/src/command.rs | 0 .../fabro-acp/src/error.rs | 0 .../fabro-acp/src/lib.rs | 0 .../fabro-acp/src/session.rs | 0 .../fabro-acp/src/test_support.rs | 0 .../fabro-acp/src/transport.rs | 0 .../fabro-acp/tests/session.rs | 0 .../fabro-agent/Cargo.toml | 14 ++-- .../fabro-agent/README.md | 0 .../fabro-agent/src/agent_profile.rs | 0 .../fabro-agent/src/apply_patch.lark | 0 .../fabro-agent/src/apply_patch.rs | 0 .../fabro-agent/src/cli.rs | 0 .../fabro-agent/src/compaction.rs | 0 .../fabro-agent/src/config.rs | 0 .../fabro-agent/src/context_window.rs | 0 .../fabro-agent/src/docker_sandbox.rs | 0 .../fabro-agent/src/error.rs | 0 .../fabro-agent/src/event.rs | 0 .../fabro-agent/src/file_tracker.rs | 0 .../fabro-agent/src/history.rs | 0 .../fabro-agent/src/lib.rs | 0 .../fabro-agent/src/local_sandbox.rs | 0 .../fabro-agent/src/loop_detection.rs | 0 .../fabro-agent/src/mcp_integration.rs | 0 .../fabro-agent/src/memory.rs | 0 .../fabro-agent/src/profiles/anthropic.rs | 0 .../fabro-agent/src/profiles/gemini.rs | 0 .../fabro-agent/src/profiles/mod.rs | 0 .../fabro-agent/src/profiles/openai.rs | 0 .../fabro-agent/src/question_tools.rs | 0 .../src/read_before_write_sandbox.rs | 0 .../fabro-agent/src/sandbox.rs | 0 .../fabro-agent/src/session.rs | 0 .../fabro-agent/src/skills.rs | 0 .../fabro-agent/src/subagent.rs | 0 .../fabro-agent/src/task_reminder.rs | 0 .../fabro-agent/src/test_support.rs | 0 .../fabro-agent/src/todo_runtime.rs | 0 .../fabro-agent/src/todo_tools.rs | 0 .../fabro-agent/src/tool_execution.rs | 0 .../fabro-agent/src/tool_permissions.rs | 0 .../fabro-agent/src/tool_registry.rs | 0 .../fabro-agent/src/tools.rs | 0 .../fabro-agent/src/truncation.rs | 0 .../fabro-agent/src/types.rs | 0 .../fabro-agent/tests/it/compaction.rs | 0 .../fabro-agent/tests/it/guardrails.rs | 0 .../fabro-agent/tests/it/main.rs | 0 .../fabro-agent/tests/it/parity_matrix.rs | 0 .../fabro-automation/Cargo.toml | 2 +- .../2026071101_file_definitions_to_sqlite.rs | 0 .../fabro-automation/src/error.rs | 0 .../fabro-automation/src/id.rs | 0 .../fabro-automation/src/lib.rs | 0 .../fabro-automation/src/migrations.rs | 0 .../fabro-automation/src/model.rs | 0 .../fabro-automation/src/store.rs | 0 .../fabro-automation/tests/store.rs | 0 .../fabro-checkpoint/Cargo.toml | 4 +- .../fabro-checkpoint/src/author.rs | 0 .../fabro-checkpoint/src/branch.rs | 0 .../fabro-checkpoint/src/error.rs | 0 .../fabro-checkpoint/src/git.rs | 0 .../fabro-checkpoint/src/lib.rs | 0 .../fabro-checkpoint/src/trailer.rs | 0 .../fabro-dump/Cargo.toml | 4 +- .../fabro-dump/src/lib.rs | 0 .../fabro-environment/Cargo.toml | 6 +- .../fabro-environment/src/error.rs | 0 .../fabro-environment/src/id.rs | 0 .../fabro-environment/src/lib.rs | 0 .../fabro-environment/src/model.rs | 0 .../fabro-environment/src/store.rs | 0 .../fabro-environment/tests/store.rs | 0 .../fabro-github/Cargo.toml | 4 +- .../fabro-github/src/lib.rs | 0 .../fabro-github/src/testdata/rsa_private.pem | 0 .../fabro-github/tests/integration.rs | 0 .../fabro-graphviz/Cargo.toml | 2 +- .../fabro-graphviz/src/condition.rs | 0 .../fabro-graphviz/src/error.rs | 0 .../fabro-graphviz/src/fidelity.rs | 0 .../fabro-graphviz/src/graph/mod.rs | 0 .../fabro-graphviz/src/graph/types.rs | 0 .../fabro-graphviz/src/lib.rs | 0 .../fabro-graphviz/src/parser/ast.rs | 0 .../fabro-graphviz/src/parser/grammar.rs | 0 .../fabro-graphviz/src/parser/lexer.rs | 0 .../fabro-graphviz/src/parser/mod.rs | 0 .../fabro-graphviz/src/parser/semantic.rs | 0 .../fabro-graphviz/src/render.rs | 0 .../fabro-graphviz/src/stylesheet.rs | 0 .../fabro-hooks/Cargo.toml | 8 +-- .../fabro-hooks/src/bridge.rs | 0 .../fabro-hooks/src/config.rs | 0 .../fabro-hooks/src/executor.rs | 0 .../fabro-hooks/src/lib.rs | 0 .../fabro-hooks/src/runner.rs | 0 .../fabro-hooks/src/types.rs | 0 .../fabro-hooks/tests/host_command_hooks.rs | 0 .../fabro-install/Cargo.toml | 10 +-- .../fabro-install/src/lib.rs | 0 .../fabro-interview/Cargo.toml | 4 +- .../fabro-interview/src/auto_approve.rs | 0 .../fabro-interview/src/callback.rs | 0 .../fabro-interview/src/console.rs | 0 .../fabro-interview/src/control.rs | 0 .../fabro-interview/src/control_protocol.rs | 0 .../fabro-interview/src/lib.rs | 0 .../fabro-interview/src/queue.rs | 0 .../fabro-interview/src/recording.rs | 0 .../fabro-interview/src/replay.rs | 0 .../fabro-llm/Cargo.toml | 10 +-- .../fabro-llm/README.md | 0 .../fabro-llm/src/adapter_registry.rs | 0 .../fabro-llm/src/attachments.rs | 0 .../fabro-llm/src/client.rs | 0 .../src/codec/anthropic_messages/decode.rs | 0 .../src/codec/anthropic_messages/encode.rs | 0 .../src/codec/anthropic_messages/mod.rs | 0 .../src/codec/anthropic_messages/stream.rs | 0 .../src/codec/anthropic_messages/wire.rs | 0 .../src/codec/bedrock_converse/decode.rs | 0 .../src/codec/bedrock_converse/encode.rs | 0 .../src/codec/bedrock_converse/mod.rs | 0 .../src/codec/bedrock_converse/stream.rs | 0 .../src/codec/gemini_generate/decode.rs | 0 .../src/codec/gemini_generate/encode.rs | 0 .../src/codec/gemini_generate/mod.rs | 0 .../src/codec/gemini_generate/stream.rs | 0 .../src/codec/gemini_generate/wire.rs | 0 .../fabro-llm/src/codec/mod.rs | 0 .../src/codec/openai_compatible/mod.rs | 0 .../src/codec/openai_compatible/request.rs | 0 .../src/codec/openai_compatible/response.rs | 0 .../src/codec/openai_compatible/stream.rs | 0 .../src/codec/openai_compatible/translate.rs | 0 .../src/codec/openai_compatible/wire.rs | 0 .../src/codec/openai_responses/decode.rs | 0 .../src/codec/openai_responses/encode.rs | 0 .../src/codec/openai_responses/mod.rs | 0 .../src/codec/openai_responses/stream.rs | 0 .../src/codec/openai_responses/wire.rs | 0 .../fabro-llm/src/cost.rs | 0 .../fabro-llm/src/error.rs | 0 .../fabro-llm/src/generate.rs | 0 .../fabro-llm/src/lib.rs | 0 .../fabro-llm/src/middleware.rs | 0 .../fabro-llm/src/model_test.rs | 0 .../fabro-llm/src/provider.rs | 0 .../fabro-llm/src/providers/anthropic.rs | 0 .../src/providers/bedrock/eventstream.rs | 0 .../fabro-llm/src/providers/bedrock/mod.rs | 0 .../fabro-llm/src/providers/bedrock/sigv4.rs | 0 .../fabro-llm/src/providers/common.rs | 0 .../fabro-llm/src/providers/fabro_server.rs | 0 .../fabro-llm/src/providers/gemini.rs | 0 .../fabro-llm/src/providers/mod.rs | 0 .../fabro-llm/src/providers/openai.rs | 0 .../src/providers/openai_compatible.rs | 0 .../fabro-llm/src/retry.rs | 0 .../fabro-llm/src/token_count.rs | 0 .../fabro-llm/src/tools.rs | 0 .../fabro-llm/src/transport.rs | 0 .../fabro-llm/src/types.rs | 0 .../fabro-llm/tests/integration.rs | 0 .../fabro-llm/tests/it/main.rs | 0 .../fabro-llm/tests/it/support.rs | 0 .../fabro-llm/tests/it/wire/anthropic.rs | 0 .../fabro-llm/tests/it/wire/gemini.rs | 0 .../fabro-llm/tests/it/wire/mod.rs | 0 .../tests/it/wire/openai_compatible.rs | 0 .../tests/it/wire/openai_responses.rs | 0 ...e__anthropic__count_tokens_wire_shape.snap | 2 +- ...c__custom_named_stream_error_identity.snap | 2 +- ...thropic__custom_named_stream_identity.snap | 2 +- ..._anthropic__custom_named_stream_route.snap | 2 +- ...hropic__decode_max_tokens_stop_reason.snap | 2 +- ...decode_thinking_and_redacted_thinking.snap | 2 +- ...nthropic__decode_tool_use_stop_reason.snap | 2 +- ...e__anthropic__encode_audio_attachment.snap | 2 +- ...ode_bad_file_path_attachments_dropped.snap | 2 +- ..._anthropic__encode_inline_attachments.snap | 2 +- ...t__wire__anthropic__encode_multi_turn.snap | 2 +- ...pic__encode_prompt_cache_with_catalog.snap | 2 +- ..._provider_options_anthropic_namespace.snap | 2 +- ..._reasoning_effort_with_levels_catalog.snap | 2 +- ...c__encode_response_format_json_object.snap | 2 +- ...c__encode_response_format_json_schema.snap | 2 +- ...re__anthropic__encode_sampling_params.snap | 2 +- ...anthropic__encode_thinking_round_trip.snap | 2 +- ...e__anthropic__encode_tool_choice_auto.snap | 2 +- ...__anthropic__encode_tool_choice_named.snap | 2 +- ...e__anthropic__encode_tool_choice_none.snap | 2 +- ...nthropic__encode_tool_choice_required.snap | 2 +- ...re__anthropic__encode_tool_round_trip.snap | 2 +- ...re__anthropic__encode_url_attachments.snap | 2 +- ...hropic__stream_error_event_mid_stream.snap | 2 +- ...hropic__stream_text_happy_path_events.snap | 2 +- ...ropic__stream_text_happy_path_request.snap | 2 +- ..._stream_thinking_with_signature_delta.snap | 2 +- ...e__anthropic__stream_tool_call_deltas.snap | 2 +- ..._without_message_stop_emits_no_finish.snap | 2 +- ...e__anthropic__system_and_tools_decode.snap | 2 +- ...e__anthropic__system_and_tools_encode.snap | 2 +- ...wire__gemini__count_tokens_wire_shape.snap | 2 +- ...ini__custom_named_http_error_identity.snap | 2 +- ..._gemini__custom_named_stream_identity.snap | 2 +- ..._function_call_with_thought_signature.snap | 2 +- ...mini__decode_max_tokens_finish_reason.snap | 5 ++ ...__gemini__decode_safety_finish_reason.snap | 5 ++ ...t__wire__gemini__decode_thought_parts.snap | 2 +- ...wire__gemini__decode_usage_arithmetic.snap | 2 +- ...wire__gemini__encode_audio_attachment.snap | 2 +- ...ode_bad_file_path_attachments_dropped.snap | 2 +- ...re__gemini__encode_inline_attachments.snap | 2 +- .../it__wire__gemini__encode_multi_turn.snap | 2 +- ..._options_can_override_safety_settings.snap | 2 +- ...ode_provider_options_gemini_namespace.snap | 2 +- ..._reasoning_effort_with_levels_catalog.snap | 2 +- ...i__encode_response_format_json_object.snap | 2 +- ...i__encode_response_format_json_schema.snap | 2 +- ..._wire__gemini__encode_sampling_params.snap | 2 +- ...e__gemini__encode_thinking_round_trip.snap | 2 +- ...wire__gemini__encode_tool_choice_auto.snap | 2 +- ...ire__gemini__encode_tool_choice_named.snap | 2 +- ...wire__gemini__encode_tool_choice_none.snap | 2 +- ...__gemini__encode_tool_choice_required.snap | 2 +- ..._wire__gemini__encode_tool_round_trip.snap | 2 +- ..._wire__gemini__encode_url_attachments.snap | 2 +- ...thesizes_finish_without_finish_reason.snap | 2 +- ...t__wire__gemini__stream_function_call.snap | 2 +- ...gemini__stream_text_happy_path_events.snap | 2 +- ...emini__stream_text_happy_path_request.snap | 2 +- ...t__wire__gemini__stream_thought_parts.snap | 2 +- ...wire__gemini__system_and_tools_decode.snap | 2 +- ...wire__gemini__system_and_tools_encode.snap | 2 +- ..._decode_reasoning_content_as_thinking.snap | 2 +- ...code_tool_calls_with_string_arguments.snap | 2 +- ...usage_openrouter_cost_and_cache_write.snap | 2 +- ...le__decode_usage_parses_token_details.snap | 2 +- ...i_compatible__encode_audio_attachment.snap | 2 +- ...ble__encode_bad_file_path_attachments.snap | 2 +- ...compatible__encode_inline_attachments.snap | 2 +- ..._openai_compatible__encode_multi_turn.snap | 2 +- ...rovider_options_keyed_by_adapter_name.snap | 2 +- ...vider_options_other_namespace_ignored.snap | 2 +- ...e__encode_response_format_json_object.snap | 2 +- ...e__encode_response_format_json_schema.snap | 2 +- ...ai_compatible__encode_sampling_params.snap | 2 +- ...nking_round_trip_as_reasoning_content.snap | 2 +- ...i_compatible__encode_tool_choice_auto.snap | 2 +- ..._compatible__encode_tool_choice_named.snap | 2 +- ...i_compatible__encode_tool_choice_none.snap | 2 +- ...mpatible__encode_tool_choice_required.snap | 2 +- ...ai_compatible__encode_tool_round_trip.snap | 2 +- ...ai_compatible__encode_url_attachments.snap | 2 +- ...i_compatible__stream_reasoning_deltas.snap | 2 +- ...atible__stream_text_happy_path_events.snap | 2 +- ...tible__stream_text_happy_path_request.snap | 2 +- ...i_compatible__stream_tool_call_deltas.snap | 2 +- ...patible__stream_usage_openrouter_cost.snap | 2 +- ...t_done_or_content_synthesizes_nothing.snap | 5 ++ ...nthesizes_finish_when_content_started.snap | 2 +- ...i_compatible__system_and_tools_decode.snap | 2 +- ...i_compatible__system_and_tools_encode.snap | 2 +- ...ai_responses__count_tokens_wire_shape.snap | 2 +- ...om_named_stream_failed_event_identity.snap | 2 +- ...code_incomplete_status_maps_to_length.snap | 2 +- ...ode_reasoning_and_function_call_items.snap | 2 +- ..._usage_subtracts_cached_and_reasoning.snap | 2 +- ...ai_responses__encode_audio_attachment.snap | 2 +- ...ode_bad_file_path_attachments_dropped.snap | 2 +- ...ode_forces_streaming_and_omits_params.snap | 2 +- ...onses__encode_dual_id_tool_round_trip.snap | 2 +- ..._responses__encode_inline_attachments.snap | 2 +- ...__openai_responses__encode_multi_turn.snap | 2 +- ...onses__encode_opaque_items_round_trip.snap | 2 +- ...ode_provider_options_openai_namespace.snap | 2 +- ..._reasoning_effort_with_levels_catalog.snap | 2 +- ...s__encode_response_format_json_object.snap | 2 +- ...s__encode_response_format_json_schema.snap | 2 +- ...nai_responses__encode_sampling_params.snap | 2 +- ...responses__encode_thinking_round_trip.snap | 2 +- ...ai_responses__encode_tool_choice_auto.snap | 2 +- ...i_responses__encode_tool_choice_named.snap | 2 +- ...ai_responses__encode_tool_choice_none.snap | 2 +- ...esponses__encode_tool_choice_required.snap | 2 +- ...nai_responses__encode_tool_round_trip.snap | 2 +- ...nai_responses__encode_url_attachments.snap | 2 +- ...es__stream_failed_event_maps_to_error.snap | 2 +- ...ses__stream_incomplete_maps_to_length.snap | 2 +- ...nses__stream_reasoning_summary_deltas.snap | 2 +- ...ponses__stream_text_happy_path_events.snap | 2 +- ...onses__stream_text_happy_path_request.snap | 2 +- ...ai_responses__stream_tool_call_deltas.snap | 2 +- ...ai_responses__system_and_tools_decode.snap | 2 +- ...ai_responses__system_and_tools_encode.snap | 2 +- .../fabro-manifest/Cargo.toml | 8 +-- .../fabro-manifest/src/lib.rs | 0 .../fabro-mcp-store/Cargo.toml | 4 +- .../fabro-mcp-store/src/error.rs | 0 .../fabro-mcp-store/src/lib.rs | 0 .../fabro-mcp-store/src/model.rs | 0 .../fabro-mcp-store/src/store.rs | 0 .../fabro-mcp-store/tests/store.rs | 0 .../fabro-mcp/Cargo.toml | 4 +- .../fabro-mcp/src/client.rs | 0 .../fabro-mcp/src/client_handler.rs | 0 .../fabro-mcp/src/config.rs | 0 .../fabro-mcp/src/connection_manager.rs | 0 .../fabro-mcp/src/http_transport.rs | 0 .../fabro-mcp/src/lib.rs | 0 .../fabro-mcp/src/sse_client.rs | 0 .../fabro-mcp/tests/stdio_integration.rs | 0 .../fabro-mcp/tests/test_mcp_server.py | 0 .../fabro-sandbox/Cargo.toml | 8 +-- .../fabro-sandbox/src/clone_source.rs | 0 .../fabro-sandbox/src/config.rs | 0 .../fabro-sandbox/src/daytona/mod.rs | 0 .../fabro-sandbox/src/details.rs | 0 .../fabro-sandbox/src/docker.rs | 0 .../fabro-sandbox/src/error.rs | 0 .../fabro-sandbox/src/from_environment.rs | 0 .../fabro-sandbox/src/glob_match.rs | 0 .../fabro-sandbox/src/lib.rs | 0 .../fabro-sandbox/src/local.rs | 0 .../fabro-sandbox/src/managed_labels.rs | 0 .../fabro-sandbox/src/provider.rs | 0 .../fabro-sandbox/src/provider/daytona.rs | 0 .../fabro-sandbox/src/provider/docker.rs | 0 .../fabro-sandbox/src/read_guard.rs | 0 .../fabro-sandbox/src/reconnect.rs | 0 .../fabro-sandbox/src/redact.rs | 0 .../fabro-sandbox/src/sandbox.rs | 0 .../fabro-sandbox/src/sandbox_spec.rs | 0 .../fabro-sandbox/src/terminal.rs | 0 .../fabro-sandbox/src/test_support.rs | 0 .../fabro-sandbox/src/worktree.rs | 0 .../tests/daytona_streaming_live.rs | 0 .../fabro-sandbox/tests/docker_streaming.rs | 0 .../fabro-sandbox/tests/error.rs | 0 .../fabro-slack/Cargo.toml | 2 +- .../fabro-slack/src/blocks.rs | 0 .../fabro-slack/src/client.rs | 0 .../fabro-slack/src/config.rs | 0 .../fabro-slack/src/connection.rs | 0 .../fabro-slack/src/dispatch.rs | 0 .../fabro-slack/src/interaction.rs | 0 .../fabro-slack/src/lib.rs | 0 .../fabro-slack/src/payload.rs | 0 .../fabro-slack/src/socket.rs | 0 .../fabro-slack/src/threads.rs | 0 .../fabro-store/Cargo.toml | 8 +-- .../fabro-store/src/artifact_store.rs | 0 .../fabro-store/src/error.rs | 0 .../fabro-store/src/keyed_mutex.rs | 0 .../fabro-store/src/keys.rs | 0 .../fabro-store/src/lib.rs | 0 .../fabro-store/src/record/codec.rs | 0 .../fabro-store/src/record/mod.rs | 0 .../fabro-store/src/record/record_id.rs | 0 .../fabro-store/src/record/repository.rs | 0 .../fabro-store/src/record/transaction.rs | 0 .../fabro-store/src/run_sessions.rs | 0 .../fabro-store/src/run_state.rs | 0 .../fabro-store/src/run_summary_store.rs | 0 .../src/serializable_projection.rs | 0 .../fabro-store/src/slate/auth_codes.rs | 0 .../fabro-store/src/slate/auth_tokens.rs | 0 .../fabro-store/src/slate/blob_store.rs | 0 .../fabro-store/src/slate/mod.rs | 0 .../fabro-store/src/slate/projection_cache.rs | 0 .../src/slate/run_catalog_index.rs | 0 .../fabro-store/src/slate/run_store.rs | 0 .../fabro-store/src/test_util.rs | 0 .../fabro-store/src/types.rs | 0 .../tests/serializable_projection.rs | 0 .../fabro-tool/Cargo.toml | 10 +-- .../fabro-tool/src/common.rs | 0 .../fabro-tool/src/create.rs | 0 .../fabro-tool/src/events.rs | 0 .../fabro-tool/src/fabro_client.rs | 0 .../fabro-tool/src/gather.rs | 0 .../fabro-tool/src/get.rs | 0 .../fabro-tool/src/interact.rs | 0 .../fabro-tool/src/lib.rs | 0 .../fabro-tool/src/manifest.rs | 0 .../fabro-tool/src/pair.rs | 0 .../fabro-tool/src/search.rs | 0 .../fabro-tracker/Cargo.toml | 0 .../src/fixtures/github-app-test-key.pem | 0 .../fabro-tracker/src/github.rs | 0 .../fabro-tracker/src/lib.rs | 0 .../fabro-tracker/src/linear.rs | 0 .../fabro-validate/Cargo.toml | 4 +- .../fabro-validate/src/lib.rs | 0 .../src/rules/all_conditional_edges.rs | 0 .../fabro-validate/src/rules/backend_valid.rs | 0 .../src/rules/condition_syntax.rs | 0 .../src/rules/direction_valid.rs | 0 .../src/rules/edge_target_exists.rs | 0 .../src/rules/exit_no_outgoing.rs | 0 .../src/rules/fidelity_valid.rs | 0 .../src/rules/freeform_edge_count.rs | 0 .../src/rules/goal_gate_has_retry.rs | 0 .../fabro-validate/src/rules/import_error.rs | 0 .../src/rules/inert_attribute.rs | 0 .../fabro-validate/src/rules/mod.rs | 0 .../fabro-validate/src/rules/model_support.rs | 0 .../src/rules/node_model_known.rs | 0 .../src/rules/orphan_custom_outcome.rs | 0 .../src/rules/parallel_branch.rs | 0 .../rules/parallel_branch_inert_attribute.rs | 0 .../src/rules/prompt_on_llm_nodes.rs | 0 .../rules/random_selection_no_conditions.rs | 0 .../fabro-validate/src/rules/reachability.rs | 0 .../src/rules/reserved_keyword_node_id.rs | 0 .../src/rules/retry_target_exists.rs | 0 .../src/rules/script_absolute_cd.rs | 0 .../src/rules/selection_valid.rs | 0 .../src/rules/start_no_incoming.rs | 0 .../fabro-validate/src/rules/start_node.rs | 0 .../src/rules/stylesheet_model_known.rs | 0 .../src/rules/stylesheet_syntax.rs | 0 .../fabro-validate/src/rules/terminal_node.rs | 0 .../fabro-validate/src/rules/test_support.rs | 0 .../rules/thread_id_requires_fidelity_full.rs | 0 .../fabro-validate/src/rules/type_known.rs | 0 .../src/rules/unresolved_file_ref.rs | 0 .../fabro-variable/Cargo.toml | 4 +- .../fabro-variable/src/lib.rs | 0 .../fabro-variable/tests/store.rs | 0 .../fabro-workflow/Cargo.toml | 22 +++--- .../fabro-workflow/README.md | 0 .../fabro-workflow/src/artifact.rs | 0 .../fabro-workflow/src/artifact_snapshot.rs | 0 .../fabro-workflow/src/artifact_upload.rs | 0 .../fabro-workflow/src/billing_rollup.rs | 0 .../fabro-workflow/src/command_log.rs | 0 .../fabro-workflow/src/condition.rs | 0 .../fabro-workflow/src/context.rs | 0 .../fabro-workflow/src/error.rs | 0 .../fabro-workflow/src/event.rs | 0 .../fabro-workflow/src/event/convert.rs | 0 .../fabro-workflow/src/event/emitter.rs | 0 .../fabro-workflow/src/event/events.rs | 0 .../fabro-workflow/src/event/names.rs | 0 .../fabro-workflow/src/event/redaction.rs | 0 .../fabro-workflow/src/event/sink.rs | 0 .../fabro-workflow/src/event/stored_fields.rs | 0 .../fabro-workflow/src/event/test_support.rs | 0 .../fabro-workflow/src/file_resolver.rs | 0 .../fabro-workflow/src/git.rs | 0 .../fabro-workflow/src/github_token_source.rs | 0 .../fabro-workflow/src/graph.rs | 0 .../fabro-workflow/src/graph/routing.rs | 0 .../fabro-workflow/src/handler/agent.rs | 0 .../fabro-workflow/src/handler/command.rs | 0 .../fabro-workflow/src/handler/conditional.rs | 0 .../fabro-workflow/src/handler/exit.rs | 0 .../fabro-workflow/src/handler/fan_in.rs | 0 .../fabro-workflow/src/handler/human.rs | 0 .../fabro-workflow/src/handler/llm/acp.rs | 0 .../src/handler/llm/activation_lease.rs | 0 .../fabro-workflow/src/handler/llm/api.rs | 0 .../src/handler/llm/changed_files.rs | 0 .../fabro-workflow/src/handler/llm/mod.rs | 0 .../src/handler/llm/preamble.rs | 0 .../fabro-workflow/src/handler/llm/router.rs | 0 .../fabro-workflow/src/handler/llm/routing.rs | 0 .../src/handler/manager_loop.rs | 0 .../fabro-workflow/src/handler/mod.rs | 0 .../fabro-workflow/src/handler/parallel.rs | 0 .../fabro-workflow/src/handler/prompt.rs | 0 .../fabro-workflow/src/handler/start.rs | 0 .../src/handler/structured_output.rs | 0 .../fabro-workflow/src/handler/wait.rs | 0 .../fabro-workflow/src/hook_context.rs | 0 .../fabro-workflow/src/interview_runtime.rs | 0 .../fabro-workflow/src/lib.rs | 0 .../fabro-workflow/src/lifecycle/artifact.rs | 0 .../src/lifecycle/auto_status.rs | 0 .../src/lifecycle/circuit_breaker.rs | 0 .../fabro-workflow/src/lifecycle/event.rs | 0 .../fabro-workflow/src/lifecycle/fidelity.rs | 0 .../fabro-workflow/src/lifecycle/git.rs | 0 .../fabro-workflow/src/lifecycle/hook.rs | 0 .../fabro-workflow/src/lifecycle/mod.rs | 0 .../fabro-workflow/src/node_handler.rs | 0 .../fabro-workflow/src/operations/archive.rs | 0 .../fabro-workflow/src/operations/create.rs | 0 .../fabro-workflow/src/operations/fork.rs | 0 .../fabro-workflow/src/operations/mod.rs | 0 .../fabro-workflow/src/operations/resume.rs | 0 .../fabro-workflow/src/operations/retry.rs | 0 .../fabro-workflow/src/operations/rewind.rs | 0 .../src/operations/run_store.rs | 0 .../fabro-workflow/src/operations/source.rs | 0 .../fabro-workflow/src/operations/start.rs | 0 .../fabro-workflow/src/operations/timeline.rs | 0 .../fabro-workflow/src/operations/validate.rs | 0 .../fabro-workflow/src/outcome.rs | 0 .../fabro-workflow/src/pipeline/execute.rs | 0 .../src/pipeline/execute/tests.rs | 0 .../fabro-workflow/src/pipeline/finalize.rs | 0 .../fabro-workflow/src/pipeline/initialize.rs | 0 .../fabro-workflow/src/pipeline/mod.rs | 0 .../fabro-workflow/src/pipeline/parse.rs | 0 .../fabro-workflow/src/pipeline/persist.rs | 0 .../src/pipeline/prompts/pr_body.md | 0 .../src/pipeline/pull_request.rs | 0 .../fabro-workflow/src/pipeline/transform.rs | 0 .../fabro-workflow/src/pipeline/types.rs | 0 .../fabro-workflow/src/pipeline/validate.rs | 0 .../fabro-workflow/src/pull_request.rs | 0 .../fabro-workflow/src/records/checkpoint.rs | 0 .../fabro-workflow/src/records/conclusion.rs | 0 .../fabro-workflow/src/records/mod.rs | 0 .../fabro-workflow/src/records/run.rs | 0 .../fabro-workflow/src/records/start.rs | 0 .../fabro-workflow/src/retry.rs | 0 .../fabro-workflow/src/run_control.rs | 0 .../fabro-workflow/src/run_dir.rs | 0 .../fabro-workflow/src/run_lookup.rs | 0 .../fabro-workflow/src/run_materialization.rs | 0 .../fabro-workflow/src/run_metadata.rs | 0 .../fabro-workflow/src/run_options.rs | 0 .../fabro-workflow/src/run_status.rs | 0 .../fabro-workflow/src/runtime_store.rs | 0 .../fabro-workflow/src/sandbox_git.rs | 0 .../fabro-workflow/src/sandbox_git_runtime.rs | 0 .../fabro-workflow/src/services.rs | 0 .../fabro-workflow/src/stage_scope.rs | 0 .../fabro-workflow/src/static_reference.rs | 0 .../fabro-workflow/src/steering_hub.rs | 0 .../fabro-workflow/src/test_support.rs | 0 .../src/transforms/file_inlining.rs | 0 .../fabro-workflow/src/transforms/import.rs | 0 .../src/transforms/importable_field.rs | 0 .../fabro-workflow/src/transforms/mod.rs | 0 .../src/transforms/model_resolution.rs | 0 .../fabro-workflow/src/transforms/preamble.rs | 0 .../src/transforms/stylesheet.rs | 0 .../src/transforms/stylesheet_application.rs | 0 .../src/transforms/variable_expansion.rs | 0 .../fabro-workflow/src/workflow_bundle.rs | 0 .../tests/it/attractor_compat.rs | 0 .../fabro-workflow/tests/it/cp_integration.rs | 0 .../tests/it/daytona_integration.rs | 0 .../tests/it/git_integration.rs | 0 .../fabro-workflow/tests/it/integration.rs | 0 .../fabro-workflow/tests/it/main.rs | 0 .../fabro-workflow/tests/materialize_run.rs | 0 ...mini__decode_max_tokens_finish_reason.snap | 5 -- ...__gemini__decode_safety_finish_reason.snap | 5 -- ...t_done_or_content_synthesizes_nothing.snap | 5 -- .../build-support/Cargo.toml | 0 .../build-support/git_metadata.rs | 0 .../fabro-api/Cargo.toml | 2 +- lib/{crates => foundation}/fabro-api/build.rs | 2 +- .../fabro-api/src/lib.rs | 0 ...gent_session_activated_props_round_trip.rs | 0 .../fabro-api/tests/automation_round_trip.rs | 0 .../tests/billed_token_counts_round_trip.rs | 0 .../tests/command_termination_round_trip.rs | 0 .../tests/completion_message_round_trip.rs | 0 .../fabro-api/tests/cost_source_round_trip.rs | 0 .../fabro-api/tests/diff_stats_round_trip.rs | 0 .../tests/diff_summary_round_trip.rs | 0 .../fabro-api/tests/environment_round_trip.rs | 0 .../tests/event_envelope_round_trip.rs | 0 .../fabro-api/tests/git_context_round_trip.rs | 0 .../tests/interview_option_round_trip.rs | 0 .../interview_question_record_round_trip.rs | 0 .../fabro-api/tests/mcp_server_round_trip.rs | 0 .../tests/mcp_transport_round_trip.rs | 0 .../fabro-api/tests/model_costs_round_trip.rs | 0 .../tests/model_features_round_trip.rs | 0 .../tests/model_limits_round_trip.rs | 0 .../fabro-api/tests/model_round_trip.rs | 0 .../tests/model_test_mode_round_trip.rs | 0 .../fabro-api/tests/pair_round_trip.rs | 0 .../pending_interview_record_round_trip.rs | 0 .../tests/pre_run_push_outcome_round_trip.rs | 0 .../fabro-api/tests/principal_round_trip.rs | 0 .../fabro-api/tests/provider_id_round_trip.rs | 0 .../fabro-api/tests/provider_round_trip.rs | 0 .../tests/pull_request_round_trip.rs | 0 .../tests/question_type_round_trip.rs | 0 .../tests/run_billing_stage_round_trip.rs | 0 .../fabro-api/tests/run_event_round_trip.rs | 0 .../fabro-api/tests/run_failure_round_trip.rs | 0 .../tests/run_integrations_round_trip.rs | 0 .../tests/run_projection_round_trip.rs | 0 .../fabro-api/tests/run_sandbox_round_trip.rs | 0 .../fabro-api/tests/run_summary_round_trip.rs | 0 .../tests/sandbox_details_round_trip.rs | 0 .../tests/sandbox_inventory_round_trip.rs | 0 .../tests/sandbox_services_round_trip.rs | 0 .../tests/secret_metadata_round_trip.rs | 0 .../fabro-api/tests/secret_type_round_trip.rs | 0 .../tests/server_settings_round_trip.rs | 0 .../tests/session_contract_round_trip.rs | 0 .../tests/stage_completion_round_trip.rs | 0 .../tests/stage_handler_round_trip.rs | 0 .../tests/stage_model_usage_round_trip.rs | 0 .../tests/stage_outcome_round_trip.rs | 0 .../tests/stage_projection_round_trip.rs | 0 .../fabro-api/tests/stage_state_round_trip.rs | 0 .../fabro-api/tests/status_round_trip.rs | 0 .../tests/submit_answer_request_round_trip.rs | 0 .../tests/system_integrations_round_trip.rs | 0 .../fabro-api/tests/variable_round_trip.rs | 0 .../tests/workflow_settings_round_trip.rs | 0 .../fabro-auth/Cargo.toml | 0 .../fabro-auth/src/context.rs | 0 .../fabro-auth/src/credential.rs | 0 .../fabro-auth/src/credential_source.rs | 0 .../fabro-auth/src/env_source.rs | 0 .../fabro-auth/src/extra_headers_source.rs | 0 .../fabro-auth/src/lib.rs | 0 .../fabro-auth/src/refresh.rs | 0 .../fabro-auth/src/resolve.rs | 0 .../fabro-auth/src/sql_vault_source.rs | 0 .../fabro-auth/src/strategies/api_key.rs | 0 .../fabro-auth/src/strategies/codex_device.rs | 0 .../fabro-auth/src/strategies/mod.rs | 0 .../fabro-auth/src/strategy.rs | 0 .../fabro-auth/src/vault_ext.rs | 0 .../fabro-auth/src/vault_source.rs | 0 .../fabro-client/Cargo.toml | 0 .../fabro-client/src/auth_store.rs | 0 .../fabro-client/src/client.rs | 0 .../fabro-client/src/credential.rs | 0 .../fabro-client/src/error.rs | 0 .../fabro-client/src/lib.rs | 0 .../fabro-client/src/session.rs | 0 .../fabro-client/src/sse.rs | 0 .../fabro-client/src/target.rs | 0 .../fabro-config/Cargo.toml | 0 ...26050101_legacy_sandbox_to_environments.rs | 0 ...1_settings_environments_to_server_files.rs | 0 .../fabro-config/src/bind.rs | 0 .../fabro-config/src/builders.rs | 0 .../fabro-config/src/daemon.rs | 0 .../fabro-config/src/defaults.rs | 0 .../fabro-config/src/defaults.toml | 0 .../fabro-config/src/envfile.rs | 0 .../fabro-config/src/error.rs | 0 .../fabro-config/src/home.rs | 0 .../fabro-config/src/input_overrides.rs | 0 .../fabro-config/src/layers/cli.rs | 0 .../fabro-config/src/layers/combine.rs | 0 .../fabro-config/src/layers/environment.rs | 0 .../fabro-config/src/layers/llm.rs | 0 .../fabro-config/src/layers/log_filter.rs | 0 .../fabro-config/src/layers/maps.rs | 0 .../fabro-config/src/layers/mod.rs | 0 .../fabro-config/src/layers/project.rs | 0 .../fabro-config/src/layers/run.rs | 0 .../fabro-config/src/layers/server.rs | 0 .../fabro-config/src/layers/settings.rs | 0 .../fabro-config/src/layers/splice_array.rs | 0 .../fabro-config/src/layers/workflow.rs | 0 .../fabro-config/src/lib.rs | 0 .../fabro-config/src/load.rs | 0 .../fabro-config/src/logging.rs | 0 .../fabro-config/src/migrations.rs | 0 .../fabro-config/src/parse.rs | 0 .../fabro-config/src/project.rs | 0 .../fabro-config/src/resolve/cli.rs | 0 .../fabro-config/src/resolve/environment.rs | 0 .../fabro-config/src/resolve/error.rs | 0 .../fabro-config/src/resolve/mod.rs | 0 .../fabro-config/src/resolve/project.rs | 0 .../fabro-config/src/resolve/run.rs | 0 .../fabro-config/src/resolve/server.rs | 0 .../fabro-config/src/resolve/workflow.rs | 0 .../fabro-config/src/run.rs | 0 .../fabro-config/src/storage.rs | 0 .../fabro-config/src/tests/combine.rs | 0 .../fabro-config/src/tests/defaults.rs | 0 .../fabro-config/src/tests/log_filter.rs | 0 .../fabro-config/src/tests/mod.rs | 0 .../fabro-config/src/tests/resolve_cli.rs | 0 .../fabro-config/src/tests/resolve_project.rs | 0 .../fabro-config/src/tests/resolve_root.rs | 0 .../fabro-config/src/tests/resolve_run.rs | 0 .../fabro-config/src/tests/resolve_server.rs | 0 .../src/tests/resolve_workflow.rs | 0 .../fabro-config/src/user.rs | 0 .../fabro-core/Cargo.toml | 0 .../fabro-core/src/context.rs | 0 .../fabro-core/src/error.rs | 0 .../fabro-core/src/executor.rs | 0 .../fabro-core/src/graph.rs | 0 .../fabro-core/src/handler.rs | 0 .../fabro-core/src/lib.rs | 0 .../fabro-core/src/lifecycle.rs | 0 .../fabro-core/src/outcome.rs | 0 .../fabro-core/src/retry.rs | 0 .../fabro-core/src/stall.rs | 0 .../fabro-core/src/state.rs | 0 .../fabro-core/src/test_fixtures.rs | 0 .../fabro-db/Cargo.toml | 0 .../migrations/2026063001_variables.sql | 0 .../migrations/2026063002_environments.sql | 0 .../migrations/2026071101_secrets.sql | 0 .../migrations/2026071102_mcp_servers.sql | 0 .../migrations/2026071103_automations.sql | 0 .../fabro-db/migrations/2026071104_runs.sql | 0 .../fabro-db/src/lib.rs | 0 .../fabro-db/tests/sqlite.rs | 0 .../fabro-dev/Cargo.toml | 0 .../fabro-dev/src/commands/bench_tests.rs | 0 .../fabro-dev/src/commands/build.rs | 0 .../fabro-dev/src/commands/docker_build.rs | 0 .../fabro-dev/src/commands/docs.rs | 0 .../src/commands/docs_cli_reference.rs | 0 .../src/commands/docs_options_reference.rs | 0 .../fabro-dev/src/commands/mod.rs | 0 .../fabro-dev/src/commands/release.rs | 0 .../fabro-dev/src/commands/spa.rs | 0 .../fabro-dev/src/commands/spa_check.rs | 4 +- .../fabro-dev/src/commands/spa_refresh.rs | 12 ++-- .../fabro-dev/src/lib.rs | 0 .../fabro-dev/src/main.rs | 0 .../fabro-dev/tests/it/docker_build.rs | 0 .../fabro-dev/tests/it/docker_entrypoint.rs | 0 .../fabro-dev/tests/it/docs.rs | 0 .../fabro-dev/tests/it/main.rs | 2 +- .../fabro-dev/tests/it/policy.rs | 24 +++---- .../fabro-dev/tests/it/release.rs | 2 +- .../fabro-dev/tests/it/spa.rs | 12 ++-- .../fabro-http/Cargo.toml | 0 .../fabro-http/src/lib.rs | 0 .../fabro-macros/Cargo.toml | 0 .../fabro-macros/src/lib.rs | 0 .../fabro-macros/src/options_metadata.rs | 0 .../fabro-macros/tests/options_metadata.rs | 0 .../fabro-model/Cargo.toml | 0 .../fabro-model/src/adapter.rs | 0 .../fabro-model/src/billing.rs | 0 .../fabro-model/src/bootstrap_catalog.rs | 0 .../fabro-model/src/catalog.rs | 0 .../src/catalog/providers/anthropic.toml | 0 .../src/catalog/providers/bedrock-openai.toml | 0 .../src/catalog/providers/bedrock.toml | 0 .../src/catalog/providers/gemini.toml | 0 .../src/catalog/providers/inception.toml | 0 .../src/catalog/providers/kimi.toml | 0 .../src/catalog/providers/litellm.toml | 0 .../src/catalog/providers/minimax.toml | 0 .../src/catalog/providers/ollama.toml | 0 .../src/catalog/providers/openai.toml | 0 .../src/catalog/providers/openrouter.toml | 0 .../src/catalog/providers/poolside.toml | 0 .../src/catalog/providers/venice.toml | 0 .../src/catalog/providers/zai.toml | 0 .../fabro-model/src/codec.rs | 0 .../fabro-model/src/ids.rs | 0 .../fabro-model/src/lib.rs | 0 .../fabro-model/src/model_ref.rs | 0 .../fabro-model/src/model_test.rs | 0 .../fabro-model/src/provider.rs | 0 .../fabro-model/src/reasoning.rs | 0 .../fabro-model/src/types.rs | 0 .../fabro-oauth/Cargo.toml | 0 .../fabro-oauth/examples/login.rs | 0 .../fabro-oauth/src/lib.rs | 0 .../fabro-options-metadata/Cargo.toml | 0 .../fabro-options-metadata/src/lib.rs | 0 .../fabro-proc/Cargo.toml | 0 .../fabro-proc/build.rs | 0 .../fabro-proc/c/capture_argv.c | 0 .../fabro-proc/src/flock.rs | 0 .../fabro-proc/src/lib.rs | 0 .../fabro-proc/src/pre_exec.rs | 0 .../fabro-proc/src/signal.rs | 0 .../fabro-proc/src/title.rs | 0 .../fabro-redact/Cargo.toml | 0 .../fabro-redact/build.rs | 0 .../fabro-redact/data/gitleaks.toml | 0 .../fabro-redact/src/entropy.rs | 0 .../fabro-redact/src/gitleaks.rs | 0 .../fabro-redact/src/jsonl.rs | 0 .../fabro-redact/src/lib.rs | 0 .../fabro-redact/src/safe_url.rs | 0 .../fabro-static/Cargo.toml | 0 .../fabro-static/src/env_vars.rs | 0 .../fabro-static/src/lib.rs | 0 .../fabro-static/src/secret_registry.rs | 0 .../fabro-telemetry/Cargo.toml | 0 .../fabro-telemetry/src/anonymous_id.rs | 0 .../fabro-telemetry/src/buffer.rs | 0 .../fabro-telemetry/src/context.rs | 0 .../fabro-telemetry/src/event.rs | 0 .../fabro-telemetry/src/git.rs | 0 .../fabro-telemetry/src/lib.rs | 0 .../fabro-telemetry/src/panic.rs | 0 .../fabro-telemetry/src/sanitize.rs | 0 .../fabro-telemetry/src/sender.rs | 0 .../fabro-telemetry/src/spawn.rs | 0 .../fabro-template/Cargo.toml | 0 .../fabro-template/src/dependency.rs | 0 .../fabro-template/src/lib.rs | 0 .../fabro-template/src/store.rs | 0 .../fabro-test/Cargo.toml | 2 +- .../fabro-test/src/http_assert.rs | 0 .../fabro-test/src/lib.rs | 0 .../fabro-types/Cargo.toml | 0 .../fabro-types/src/artifact.rs | 0 .../fabro-types/src/auth.rs | 0 .../fabro-types/src/billing.rs | 0 .../fabro-types/src/blob_ref.rs | 0 .../fabro-types/src/checkpoint.rs | 0 .../fabro-types/src/command_output.rs | 0 .../fabro-types/src/conclusion.rs | 0 .../fabro-types/src/dense.rs | 0 .../fabro-types/src/diff.rs | 0 .../fabro-types/src/event_envelope.rs | 0 .../fabro-types/src/failure_signature.rs | 0 .../fabro-types/src/graph.rs | 0 .../fabro-types/src/id.rs | 0 .../fabro-types/src/interview.rs | 0 .../fabro-types/src/lib.rs | 0 .../fabro-types/src/llm_backend.rs | 0 .../fabro-types/src/manifest_path.rs | 0 .../fabro-types/src/mcp_store.rs | 0 .../fabro-types/src/outcome.rs | 0 .../fabro-types/src/pair.rs | 0 .../fabro-types/src/principal.rs | 0 .../fabro-types/src/pull_request.rs | 0 .../fabro-types/src/repository.rs | 0 .../fabro-types/src/run.rs | 0 .../fabro-types/src/run_blob_id.rs | 0 .../fabro-types/src/run_event/agent.rs | 0 .../fabro-types/src/run_event/infra.rs | 0 .../fabro-types/src/run_event/misc.rs | 0 .../fabro-types/src/run_event/mod.rs | 0 .../fabro-types/src/run_event/run.rs | 0 .../fabro-types/src/run_event/session.rs | 0 .../fabro-types/src/run_event/stage.rs | 0 .../fabro-types/src/run_event/todo.rs | 0 .../fabro-types/src/run_failure.rs | 0 .../fabro-types/src/run_id.rs | 0 .../fabro-types/src/run_projection.rs | 0 .../fabro-types/src/run_sandbox.rs | 0 .../fabro-types/src/run_summary.rs | 0 .../fabro-types/src/run_title.rs | 0 .../fabro-types/src/sandbox_details.rs | 0 .../fabro-types/src/sandbox_inventory.rs | 0 .../fabro-types/src/sandbox_provider.rs | 0 .../fabro-types/src/sandbox_services.rs | 0 .../fabro-types/src/secret.rs | 0 .../fabro-types/src/session.rs | 0 .../fabro-types/src/settings/cli.rs | 0 .../fabro-types/src/settings/duration.rs | 0 .../fabro-types/src/settings/interp.rs | 0 .../fabro-types/src/settings/mod.rs | 0 .../fabro-types/src/settings/model_ref.rs | 0 .../fabro-types/src/settings/project.rs | 0 .../fabro-types/src/settings/public_url.rs | 0 .../fabro-types/src/settings/run.rs | 0 .../fabro-types/src/settings/server.rs | 0 .../fabro-types/src/settings/size.rs | 0 .../fabro-types/src/settings/workflow.rs | 0 .../fabro-types/src/stage_completion.rs | 0 .../fabro-types/src/stage_handler.rs | 0 .../fabro-types/src/stage_id.rs | 0 .../fabro-types/src/start.rs | 0 .../fabro-types/src/status.rs | 0 .../fabro-types/src/steering.rs | 0 .../fabro-types/src/system_integrations.rs | 0 .../fabro-types/src/test_support.rs | 0 .../fabro-types/src/timing.rs | 0 .../fabro-types/src/todo.rs | 0 .../fabro-types/src/transcript.rs | 0 .../fabro-types/src/variable.rs | 0 .../fabro-types/tests/run_event_serde.rs | 0 .../fabro-types/tests/run_failure_serde.rs | 0 .../fabro-types/tests/run_spec_methods.rs | 0 .../fabro-types/tests/run_spec_serde.rs | 0 .../tests/sandbox_inventory_serde.rs | 0 .../fabro-types/tests/sandbox_model_serde.rs | 0 .../fabro-types/tests/stage_handler.rs | 0 .../fabro-util/Cargo.toml | 0 .../fabro-util/src/backoff.rs | 0 .../fabro-util/src/browser.rs | 0 .../fabro-util/src/check_report.rs | 0 .../fabro-util/src/dev_token.rs | 0 .../fabro-util/src/env.rs | 0 .../fabro-util/src/error.rs | 0 .../fabro-util/src/exit.rs | 0 .../fabro-util/src/home.rs | 0 .../fabro-util/src/json.rs | 0 .../fabro-util/src/lib.rs | 0 .../fabro-util/src/path.rs | 0 .../fabro-util/src/printer.rs | 0 .../fabro-util/src/run_log.rs | 0 .../fabro-util/src/session_secret.rs | 0 .../fabro-util/src/shell.rs | 0 .../fabro-util/src/terminal.rs | 0 .../fabro-util/src/text.rs | 0 .../fabro-util/src/time.rs | 0 .../fabro-util/src/version.rs | 0 .../fabro-util/src/warnings.rs | 0 .../fabro-util/tests/dev_token.rs | 0 .../fabro-util/tests/error_chain.rs | 0 .../fabro-vault/Cargo.toml | 0 .../fabro-vault/src/lib.rs | 0 .../fabro-vault/src/store.rs | 0 .../fabro-vault/tests/store.rs | 0 1297 files changed, 471 insertions(+), 461 deletions(-) rename lib/{crates => apps}/fabro-cli/Cargo.toml (55%) rename lib/{crates => apps}/fabro-cli/build.rs (100%) rename lib/{crates => apps}/fabro-cli/src/args.rs (100%) rename lib/{crates => apps}/fabro-cli/src/command_context.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/artifact/cp.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/artifact/list.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/artifact/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/auth/login.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/auth/logout.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/auth/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/auth/status.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/cli_reference.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/config/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/doctor.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/dump.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/exec.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/graph.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/install.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/mcp/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/model.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/parent/link.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/parent/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/parent/unlink.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/parse.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/pr/close.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/pr/create.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/pr/link.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/pr/merge.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/pr/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/pr/unlink.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/pr/view.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/preflight.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/provider/login.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/provider/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/render_graph.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/repo/deinit.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/repo/init.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/repo/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/ask.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/attach.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/checkpoints.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/command.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/cp.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/create.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/diff.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/events.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/fork.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/logs.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/output.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/overrides.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/preview.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/resume.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/rewind.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/run_progress/event.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/run_progress/info_display.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/run_progress/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/run_progress/renderer.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/run_progress/setup_display.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/run_progress/stage_display.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/run_progress/styles.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/runner.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/ssh.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/start.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/steer.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/run/wait.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/runs/approval.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/runs/archive.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/runs/inspect.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/runs/list.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/runs/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/runs/rm.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/sandbox/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/secret/list.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/secret/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/secret/rm.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/secret/set.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/server/foreground.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/server/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/server/start.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/server/status.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/server/stop.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/system/df.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/system/events.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/system/info.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/system/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/system/prune.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/system/repair.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/uninstall.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/upgrade.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/validate.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/variable/get.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/variable/list.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/variable/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/variable/rm.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/variable/set.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/version.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/workflow/create.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/workflow/list.rs (100%) rename lib/{crates => apps}/fabro-cli/src/commands/workflow/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/gh.rs (100%) rename lib/{crates => apps}/fabro-cli/src/landing.rs (100%) rename lib/{crates => apps}/fabro-cli/src/local_server.rs (100%) rename lib/{crates => apps}/fabro-cli/src/logging.rs (100%) rename lib/{crates => apps}/fabro-cli/src/main.rs (100%) rename lib/{crates => apps}/fabro-cli/src/manifest_args.rs (100%) rename lib/{crates => apps}/fabro-cli/src/server_client.rs (100%) rename lib/{crates => apps}/fabro-cli/src/server_runs.rs (100%) rename lib/{crates => apps}/fabro-cli/src/shared/github.rs (100%) rename lib/{crates => apps}/fabro-cli/src/shared/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/shared/provider_auth.rs (100%) rename lib/{crates => apps}/fabro-cli/src/shared/repo.rs (100%) rename lib/{crates => apps}/fabro-cli/src/shared/utilities.rs (100%) rename lib/{crates => apps}/fabro-cli/src/sleep_inhibitor/dummy.rs (100%) rename lib/{crates => apps}/fabro-cli/src/sleep_inhibitor/iokit_bindings.rs (100%) rename lib/{crates => apps}/fabro-cli/src/sleep_inhibitor/linux.rs (100%) rename lib/{crates => apps}/fabro-cli/src/sleep_inhibitor/macos.rs (100%) rename lib/{crates => apps}/fabro-cli/src/sleep_inhibitor/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/src/user_config.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/build_git_metadata.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/approve.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/archive.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/artifact_cp.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/artifact_list.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/attach.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/auth.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/cli_reference.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/config.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/create.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/deny.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/diff.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/discord.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/docs.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/doctor.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/dump.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/events.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/exec.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/fabro.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/fork.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/graph.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/inspect.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/install.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/json_global.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/logs.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/mcp.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/model.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/model_list.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/model_test.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/parent.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/parse.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/pr.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/pr_close.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/pr_create.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/pr_link.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/pr_merge.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/pr_unlink.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/pr_view.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/preflight.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/provider.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/provider_login.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/ps.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/render_graph.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/repo.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/repo_deinit.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/repo_init.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/resume.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/rewind.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/rm.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/run.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/runner.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/sandbox_cp.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/sandbox_preview.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/sandbox_ssh.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/secret.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/secret_list.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/secret_rm.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/secret_set.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/send_analytics.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/send_panic.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/server_start.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/server_status.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/server_stop.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/start.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/support.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/system.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/system_df.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/system_events.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/system_info.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/system_prune.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/system_repair.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/test_panic.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/top_level.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/unarchive.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/uninstall.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/upgrade.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/validate.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/variable.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/variable_get.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/variable_list.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/variable_rm.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/variable_set.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/version.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/wait.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/worker_auth.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/workflow.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/workflow_create.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/cmd/workflow_list.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/main.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/scenario/archive.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/scenario/artifacts.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/scenario/auth.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/scenario/exec.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/scenario/lifecycle.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/scenario/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/scenario/server_lifecycle.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/scenario/smoke.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/support/auth_harness.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/support/auth_tokens.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/support/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/acp.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/agent_linear.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/artifacts.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/command_agent_mixed.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/command_pipeline.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/command_routing.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/conditional_branching.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/dry_run_examples.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/fixtures/agent_linear.fabro (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/fixtures/command_agent_mixed.fabro (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/fixtures/command_pipeline.fabro (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/fixtures/command_routing.fabro (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/fixtures/conditional_branching.fabro (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/fixtures/full_stack.fabro (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/fixtures/human_gate.fabro (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/full_stack.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/hooks.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/human_gate.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/it/workflow/mod.rs (100%) rename lib/{crates => apps}/fabro-cli/tests/manifest_path_round_trip.rs (100%) rename lib/{crates => apps}/fabro-mcp-server/Cargo.toml (59%) rename lib/{crates => apps}/fabro-mcp-server/src/config.rs (100%) rename lib/{crates => apps}/fabro-mcp-server/src/lib.rs (100%) rename lib/{crates => apps}/fabro-mcp-server/src/manifest_builder.rs (100%) rename lib/{crates => apps}/fabro-mcp-server/src/server.rs (100%) rename lib/{crates => apps}/fabro-server/Cargo.toml (56%) rename lib/{crates => apps}/fabro-server/build.rs (100%) rename lib/{crates => apps}/fabro-server/migrations/2026051801_legacy_vault_entries.rs (100%) rename lib/{crates => apps}/fabro-server/migrations/2026052501_optional_server_env_secrets_to_vault.rs (100%) rename lib/{crates => apps}/fabro-server/src/auth/browser_shell.rs (100%) rename lib/{crates => apps}/fabro-server/src/auth/cli_flow.rs (100%) rename lib/{crates => apps}/fabro-server/src/auth/github_endpoints.rs (100%) rename lib/{crates => apps}/fabro-server/src/auth/jwt.rs (100%) rename lib/{crates => apps}/fabro-server/src/auth/keys.rs (100%) rename lib/{crates => apps}/fabro-server/src/auth/mod.rs (100%) rename lib/{crates => apps}/fabro-server/src/auth/translate.rs (100%) rename lib/{crates => apps}/fabro-server/src/automation_materializer.rs (100%) rename lib/{crates => apps}/fabro-server/src/canonical_host.rs (100%) rename lib/{crates => apps}/fabro-server/src/canonical_origin.rs (100%) rename lib/{crates => apps}/fabro-server/src/csp.rs (100%) rename lib/{crates => apps}/fabro-server/src/demo/mod.rs (100%) rename lib/{crates => apps}/fabro-server/src/diagnostics.rs (100%) rename lib/{crates => apps}/fabro-server/src/error.rs (100%) rename lib/{crates => apps}/fabro-server/src/git_checkout.rs (100%) rename lib/{crates => apps}/fabro-server/src/github_webhooks.rs (100%) rename lib/{crates => apps}/fabro-server/src/install.rs (100%) rename lib/{crates => apps}/fabro-server/src/interp.rs (100%) rename lib/{crates => apps}/fabro-server/src/jwt_auth.rs (100%) rename lib/{crates => apps}/fabro-server/src/lib.rs (100%) rename lib/{crates => apps}/fabro-server/src/manifest_validation.rs (100%) rename lib/{crates => apps}/fabro-server/src/migrations.rs (100%) rename lib/{crates => apps}/fabro-server/src/principal_middleware.rs (100%) rename lib/{crates => apps}/fabro-server/src/request_id.rs (100%) rename lib/{crates => apps}/fabro-server/src/run_files.rs (100%) rename lib/{crates => apps}/fabro-server/src/run_files_security.rs (100%) rename lib/{crates => apps}/fabro-server/src/run_manifest.rs (100%) rename lib/{crates => apps}/fabro-server/src/run_selector.rs (100%) rename lib/{crates => apps}/fabro-server/src/run_title_generation.rs (100%) rename lib/{crates => apps}/fabro-server/src/run_tool_manifest.rs (100%) rename lib/{crates => apps}/fabro-server/src/security_headers.rs (100%) rename lib/{crates => apps}/fabro-server/src/serve.rs (100%) rename lib/{crates => apps}/fabro-server/src/server.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/automation_scheduler.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/artifacts.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/automations.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/billing.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/completions.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/environments.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/events.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/graph.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/lifecycle.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/llm_sse.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/mcp_servers.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/mod.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/models.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/pair.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/playground.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/prompts/playground_system.md (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/pull_requests.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/runs.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/sandbox.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/sandboxes.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/secrets.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/sessions.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/steer.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/system.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/variables.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/handler/worker_control.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/resource_sampler.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/session_runtime.rs (100%) rename lib/{crates => apps}/fabro-server/src/server/tests.rs (100%) rename lib/{crates => apps}/fabro-server/src/server_secrets.rs (100%) rename lib/{crates => apps}/fabro-server/src/spawn_env.rs (100%) rename lib/{crates => apps}/fabro-server/src/startup.rs (100%) rename lib/{crates => apps}/fabro-server/src/static_files.rs (99%) rename lib/{crates => apps}/fabro-server/src/test_support.rs (100%) rename lib/{crates => apps}/fabro-server/src/web_auth.rs (100%) rename lib/{crates => apps}/fabro-server/src/worker_control/bus.rs (100%) rename lib/{crates => apps}/fabro-server/src/worker_control/local.rs (100%) rename lib/{crates => apps}/fabro-server/src/worker_control/mod.rs (100%) rename lib/{crates => apps}/fabro-server/src/worker_runtime.rs (100%) rename lib/{crates => apps}/fabro-server/src/worker_token.rs (100%) rename lib/{crates => apps}/fabro-server/tests/fixtures/spa/images/favicon.svg (100%) rename lib/{crates => apps}/fabro-server/tests/fixtures/spa/index.html (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/auth_sessions.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/automations.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/cli_auth_token.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/compression.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/docs.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/environments.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/events.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/install.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/install_openai_compatible.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/mcp_servers.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/mod.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/routing.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/run_files.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/runs.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/sandbox_vnc.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/sessions.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/settings.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/system.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/tcp.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/api/variables.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/event_pagination.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/helpers.rs (99%) rename lib/{crates => apps}/fabro-server/tests/it/main.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/openapi_conformance.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/pagination.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/scenario/archive.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/scenario/dry_run.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/scenario/lifecycle.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/scenario/mod.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/scenario/run_completion.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/scenario/sse.rs (100%) rename lib/{crates => apps}/fabro-server/tests/it/scenario/usage.rs (100%) rename lib/{crates => apps}/fabro-spa/Cargo.toml (100%) rename lib/{crates => apps}/fabro-spa/assets/.gitkeep (100%) rename lib/{crates => apps}/fabro-spa/src/lib.rs (100%) rename lib/{crates => components}/fabro-acp/Cargo.toml (88%) rename lib/{crates => components}/fabro-acp/src/command.rs (100%) rename lib/{crates => components}/fabro-acp/src/error.rs (100%) rename lib/{crates => components}/fabro-acp/src/lib.rs (100%) rename lib/{crates => components}/fabro-acp/src/session.rs (100%) rename lib/{crates => components}/fabro-acp/src/test_support.rs (100%) rename lib/{crates => components}/fabro-acp/src/transport.rs (100%) rename lib/{crates => components}/fabro-acp/tests/session.rs (100%) rename lib/{crates => components}/fabro-agent/Cargo.toml (76%) rename lib/{crates => components}/fabro-agent/README.md (100%) rename lib/{crates => components}/fabro-agent/src/agent_profile.rs (100%) rename lib/{crates => components}/fabro-agent/src/apply_patch.lark (100%) rename lib/{crates => components}/fabro-agent/src/apply_patch.rs (100%) rename lib/{crates => components}/fabro-agent/src/cli.rs (100%) rename lib/{crates => components}/fabro-agent/src/compaction.rs (100%) rename lib/{crates => components}/fabro-agent/src/config.rs (100%) rename lib/{crates => components}/fabro-agent/src/context_window.rs (100%) rename lib/{crates => components}/fabro-agent/src/docker_sandbox.rs (100%) rename lib/{crates => components}/fabro-agent/src/error.rs (100%) rename lib/{crates => components}/fabro-agent/src/event.rs (100%) rename lib/{crates => components}/fabro-agent/src/file_tracker.rs (100%) rename lib/{crates => components}/fabro-agent/src/history.rs (100%) rename lib/{crates => components}/fabro-agent/src/lib.rs (100%) rename lib/{crates => components}/fabro-agent/src/local_sandbox.rs (100%) rename lib/{crates => components}/fabro-agent/src/loop_detection.rs (100%) rename lib/{crates => components}/fabro-agent/src/mcp_integration.rs (100%) rename lib/{crates => components}/fabro-agent/src/memory.rs (100%) rename lib/{crates => components}/fabro-agent/src/profiles/anthropic.rs (100%) rename lib/{crates => components}/fabro-agent/src/profiles/gemini.rs (100%) rename lib/{crates => components}/fabro-agent/src/profiles/mod.rs (100%) rename lib/{crates => components}/fabro-agent/src/profiles/openai.rs (100%) rename lib/{crates => components}/fabro-agent/src/question_tools.rs (100%) rename lib/{crates => components}/fabro-agent/src/read_before_write_sandbox.rs (100%) rename lib/{crates => components}/fabro-agent/src/sandbox.rs (100%) rename lib/{crates => components}/fabro-agent/src/session.rs (100%) rename lib/{crates => components}/fabro-agent/src/skills.rs (100%) rename lib/{crates => components}/fabro-agent/src/subagent.rs (100%) rename lib/{crates => components}/fabro-agent/src/task_reminder.rs (100%) rename lib/{crates => components}/fabro-agent/src/test_support.rs (100%) rename lib/{crates => components}/fabro-agent/src/todo_runtime.rs (100%) rename lib/{crates => components}/fabro-agent/src/todo_tools.rs (100%) rename lib/{crates => components}/fabro-agent/src/tool_execution.rs (100%) rename lib/{crates => components}/fabro-agent/src/tool_permissions.rs (100%) rename lib/{crates => components}/fabro-agent/src/tool_registry.rs (100%) rename lib/{crates => components}/fabro-agent/src/tools.rs (100%) rename lib/{crates => components}/fabro-agent/src/truncation.rs (100%) rename lib/{crates => components}/fabro-agent/src/types.rs (100%) rename lib/{crates => components}/fabro-agent/tests/it/compaction.rs (100%) rename lib/{crates => components}/fabro-agent/tests/it/guardrails.rs (100%) rename lib/{crates => components}/fabro-agent/tests/it/main.rs (100%) rename lib/{crates => components}/fabro-agent/tests/it/parity_matrix.rs (100%) rename lib/{crates => components}/fabro-automation/Cargo.toml (92%) rename lib/{crates => components}/fabro-automation/migrations/2026071101_file_definitions_to_sqlite.rs (100%) rename lib/{crates => components}/fabro-automation/src/error.rs (100%) rename lib/{crates => components}/fabro-automation/src/id.rs (100%) rename lib/{crates => components}/fabro-automation/src/lib.rs (100%) rename lib/{crates => components}/fabro-automation/src/migrations.rs (100%) rename lib/{crates => components}/fabro-automation/src/model.rs (100%) rename lib/{crates => components}/fabro-automation/src/store.rs (100%) rename lib/{crates => components}/fabro-automation/tests/store.rs (100%) rename lib/{crates => components}/fabro-checkpoint/Cargo.toml (82%) rename lib/{crates => components}/fabro-checkpoint/src/author.rs (100%) rename lib/{crates => components}/fabro-checkpoint/src/branch.rs (100%) rename lib/{crates => components}/fabro-checkpoint/src/error.rs (100%) rename lib/{crates => components}/fabro-checkpoint/src/git.rs (100%) rename lib/{crates => components}/fabro-checkpoint/src/lib.rs (100%) rename lib/{crates => components}/fabro-checkpoint/src/trailer.rs (100%) rename lib/{crates => components}/fabro-dump/Cargo.toml (75%) rename lib/{crates => components}/fabro-dump/src/lib.rs (100%) rename lib/{crates => components}/fabro-environment/Cargo.toml (80%) rename lib/{crates => components}/fabro-environment/src/error.rs (100%) rename lib/{crates => components}/fabro-environment/src/id.rs (100%) rename lib/{crates => components}/fabro-environment/src/lib.rs (100%) rename lib/{crates => components}/fabro-environment/src/model.rs (100%) rename lib/{crates => components}/fabro-environment/src/store.rs (100%) rename lib/{crates => components}/fabro-environment/tests/store.rs (100%) rename lib/{crates => components}/fabro-github/Cargo.toml (86%) rename lib/{crates => components}/fabro-github/src/lib.rs (100%) rename lib/{crates => components}/fabro-github/src/testdata/rsa_private.pem (100%) rename lib/{crates => components}/fabro-github/tests/integration.rs (100%) rename lib/{crates => components}/fabro-graphviz/Cargo.toml (89%) rename lib/{crates => components}/fabro-graphviz/src/condition.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/error.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/fidelity.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/graph/mod.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/graph/types.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/lib.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/parser/ast.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/parser/grammar.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/parser/lexer.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/parser/mod.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/parser/semantic.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/render.rs (100%) rename lib/{crates => components}/fabro-graphviz/src/stylesheet.rs (100%) rename lib/{crates => components}/fabro-hooks/Cargo.toml (75%) rename lib/{crates => components}/fabro-hooks/src/bridge.rs (100%) rename lib/{crates => components}/fabro-hooks/src/config.rs (100%) rename lib/{crates => components}/fabro-hooks/src/executor.rs (100%) rename lib/{crates => components}/fabro-hooks/src/lib.rs (100%) rename lib/{crates => components}/fabro-hooks/src/runner.rs (100%) rename lib/{crates => components}/fabro-hooks/src/types.rs (100%) rename lib/{crates => components}/fabro-hooks/tests/host_command_hooks.rs (100%) rename lib/{crates => components}/fabro-install/Cargo.toml (61%) rename lib/{crates => components}/fabro-install/src/lib.rs (100%) rename lib/{crates => components}/fabro-interview/Cargo.toml (82%) rename lib/{crates => components}/fabro-interview/src/auto_approve.rs (100%) rename lib/{crates => components}/fabro-interview/src/callback.rs (100%) rename lib/{crates => components}/fabro-interview/src/console.rs (100%) rename lib/{crates => components}/fabro-interview/src/control.rs (100%) rename lib/{crates => components}/fabro-interview/src/control_protocol.rs (100%) rename lib/{crates => components}/fabro-interview/src/lib.rs (100%) rename lib/{crates => components}/fabro-interview/src/queue.rs (100%) rename lib/{crates => components}/fabro-interview/src/recording.rs (100%) rename lib/{crates => components}/fabro-interview/src/replay.rs (100%) rename lib/{crates => components}/fabro-llm/Cargo.toml (82%) rename lib/{crates => components}/fabro-llm/README.md (100%) rename lib/{crates => components}/fabro-llm/src/adapter_registry.rs (100%) rename lib/{crates => components}/fabro-llm/src/attachments.rs (100%) rename lib/{crates => components}/fabro-llm/src/client.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/anthropic_messages/decode.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/anthropic_messages/encode.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/anthropic_messages/mod.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/anthropic_messages/stream.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/anthropic_messages/wire.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/bedrock_converse/decode.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/bedrock_converse/encode.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/bedrock_converse/mod.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/bedrock_converse/stream.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/gemini_generate/decode.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/gemini_generate/encode.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/gemini_generate/mod.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/gemini_generate/stream.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/gemini_generate/wire.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/mod.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_compatible/mod.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_compatible/request.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_compatible/response.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_compatible/stream.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_compatible/translate.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_compatible/wire.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_responses/decode.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_responses/encode.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_responses/mod.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_responses/stream.rs (100%) rename lib/{crates => components}/fabro-llm/src/codec/openai_responses/wire.rs (100%) rename lib/{crates => components}/fabro-llm/src/cost.rs (100%) rename lib/{crates => components}/fabro-llm/src/error.rs (100%) rename lib/{crates => components}/fabro-llm/src/generate.rs (100%) rename lib/{crates => components}/fabro-llm/src/lib.rs (100%) rename lib/{crates => components}/fabro-llm/src/middleware.rs (100%) rename lib/{crates => components}/fabro-llm/src/model_test.rs (100%) rename lib/{crates => components}/fabro-llm/src/provider.rs (100%) rename lib/{crates => components}/fabro-llm/src/providers/anthropic.rs (100%) rename lib/{crates => components}/fabro-llm/src/providers/bedrock/eventstream.rs (100%) rename lib/{crates => components}/fabro-llm/src/providers/bedrock/mod.rs (100%) rename lib/{crates => components}/fabro-llm/src/providers/bedrock/sigv4.rs (100%) rename lib/{crates => components}/fabro-llm/src/providers/common.rs (100%) rename lib/{crates => components}/fabro-llm/src/providers/fabro_server.rs (100%) rename lib/{crates => components}/fabro-llm/src/providers/gemini.rs (100%) rename lib/{crates => components}/fabro-llm/src/providers/mod.rs (100%) rename lib/{crates => components}/fabro-llm/src/providers/openai.rs (100%) rename lib/{crates => components}/fabro-llm/src/providers/openai_compatible.rs (100%) rename lib/{crates => components}/fabro-llm/src/retry.rs (100%) rename lib/{crates => components}/fabro-llm/src/token_count.rs (100%) rename lib/{crates => components}/fabro-llm/src/tools.rs (100%) rename lib/{crates => components}/fabro-llm/src/transport.rs (100%) rename lib/{crates => components}/fabro-llm/src/types.rs (100%) rename lib/{crates => components}/fabro-llm/tests/integration.rs (100%) rename lib/{crates => components}/fabro-llm/tests/it/main.rs (100%) rename lib/{crates => components}/fabro-llm/tests/it/support.rs (100%) rename lib/{crates => components}/fabro-llm/tests/it/wire/anthropic.rs (100%) rename lib/{crates => components}/fabro-llm/tests/it/wire/gemini.rs (100%) rename lib/{crates => components}/fabro-llm/tests/it/wire/mod.rs (100%) rename lib/{crates => components}/fabro-llm/tests/it/wire/openai_compatible.rs (100%) rename lib/{crates => components}/fabro-llm/tests/it/wire/openai_responses.rs (100%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap (76%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap (91%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap (86%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap (82%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap (91%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap (96%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap (82%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap (85%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap (84%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap (91%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap (86%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap (92%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap (81%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap (96%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap (91%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap (76%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap (82%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap (96%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap (81%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap (96%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap (71%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap (96%) create mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap create mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap (89%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap (86%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap (91%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap (92%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap (80%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap (87%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap (85%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap (87%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap (90%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap (88%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap (91%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap (97%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap (92%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap (96%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap (92%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap (96%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap (75%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap (78%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap (78%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap (85%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap (73%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap (69%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap (75%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap (87%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap (76%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap (84%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap (92%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap (92%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap (92%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap (78%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap (72%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap (96%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap (93%) create mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap (92%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap (74%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap (87%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap (88%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap (91%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap (90%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap (91%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap (92%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap (83%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap (85%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap (85%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap (90%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap (87%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap (91%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap (93%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap (96%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap (90%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap (73%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap (94%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap (84%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap (97%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap (95%) rename lib/{crates => components}/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap (94%) rename lib/{crates => components}/fabro-manifest/Cargo.toml (68%) rename lib/{crates => components}/fabro-manifest/src/lib.rs (100%) rename lib/{crates => components}/fabro-mcp-store/Cargo.toml (84%) rename lib/{crates => components}/fabro-mcp-store/src/error.rs (100%) rename lib/{crates => components}/fabro-mcp-store/src/lib.rs (100%) rename lib/{crates => components}/fabro-mcp-store/src/model.rs (100%) rename lib/{crates => components}/fabro-mcp-store/src/store.rs (100%) rename lib/{crates => components}/fabro-mcp-store/tests/store.rs (100%) rename lib/{crates => components}/fabro-mcp/Cargo.toml (87%) rename lib/{crates => components}/fabro-mcp/src/client.rs (100%) rename lib/{crates => components}/fabro-mcp/src/client_handler.rs (100%) rename lib/{crates => components}/fabro-mcp/src/config.rs (100%) rename lib/{crates => components}/fabro-mcp/src/connection_manager.rs (100%) rename lib/{crates => components}/fabro-mcp/src/http_transport.rs (100%) rename lib/{crates => components}/fabro-mcp/src/lib.rs (100%) rename lib/{crates => components}/fabro-mcp/src/sse_client.rs (100%) rename lib/{crates => components}/fabro-mcp/tests/stdio_integration.rs (100%) rename lib/{crates => components}/fabro-mcp/tests/test_mcp_server.py (100%) rename lib/{crates => components}/fabro-sandbox/Cargo.toml (89%) rename lib/{crates => components}/fabro-sandbox/src/clone_source.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/config.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/daytona/mod.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/details.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/docker.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/error.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/from_environment.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/glob_match.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/lib.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/local.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/managed_labels.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/provider.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/provider/daytona.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/provider/docker.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/read_guard.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/reconnect.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/redact.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/sandbox.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/sandbox_spec.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/terminal.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/test_support.rs (100%) rename lib/{crates => components}/fabro-sandbox/src/worktree.rs (100%) rename lib/{crates => components}/fabro-sandbox/tests/daytona_streaming_live.rs (100%) rename lib/{crates => components}/fabro-sandbox/tests/docker_streaming.rs (100%) rename lib/{crates => components}/fabro-sandbox/tests/error.rs (100%) rename lib/{crates => components}/fabro-slack/Cargo.toml (93%) rename lib/{crates => components}/fabro-slack/src/blocks.rs (100%) rename lib/{crates => components}/fabro-slack/src/client.rs (100%) rename lib/{crates => components}/fabro-slack/src/config.rs (100%) rename lib/{crates => components}/fabro-slack/src/connection.rs (100%) rename lib/{crates => components}/fabro-slack/src/dispatch.rs (100%) rename lib/{crates => components}/fabro-slack/src/interaction.rs (100%) rename lib/{crates => components}/fabro-slack/src/lib.rs (100%) rename lib/{crates => components}/fabro-slack/src/payload.rs (100%) rename lib/{crates => components}/fabro-slack/src/socket.rs (100%) rename lib/{crates => components}/fabro-slack/src/threads.rs (100%) rename lib/{crates => components}/fabro-store/Cargo.toml (77%) rename lib/{crates => components}/fabro-store/src/artifact_store.rs (100%) rename lib/{crates => components}/fabro-store/src/error.rs (100%) rename lib/{crates => components}/fabro-store/src/keyed_mutex.rs (100%) rename lib/{crates => components}/fabro-store/src/keys.rs (100%) rename lib/{crates => components}/fabro-store/src/lib.rs (100%) rename lib/{crates => components}/fabro-store/src/record/codec.rs (100%) rename lib/{crates => components}/fabro-store/src/record/mod.rs (100%) rename lib/{crates => components}/fabro-store/src/record/record_id.rs (100%) rename lib/{crates => components}/fabro-store/src/record/repository.rs (100%) rename lib/{crates => components}/fabro-store/src/record/transaction.rs (100%) rename lib/{crates => components}/fabro-store/src/run_sessions.rs (100%) rename lib/{crates => components}/fabro-store/src/run_state.rs (100%) rename lib/{crates => components}/fabro-store/src/run_summary_store.rs (100%) rename lib/{crates => components}/fabro-store/src/serializable_projection.rs (100%) rename lib/{crates => components}/fabro-store/src/slate/auth_codes.rs (100%) rename lib/{crates => components}/fabro-store/src/slate/auth_tokens.rs (100%) rename lib/{crates => components}/fabro-store/src/slate/blob_store.rs (100%) rename lib/{crates => components}/fabro-store/src/slate/mod.rs (100%) rename lib/{crates => components}/fabro-store/src/slate/projection_cache.rs (100%) rename lib/{crates => components}/fabro-store/src/slate/run_catalog_index.rs (100%) rename lib/{crates => components}/fabro-store/src/slate/run_store.rs (100%) rename lib/{crates => components}/fabro-store/src/test_util.rs (100%) rename lib/{crates => components}/fabro-store/src/types.rs (100%) rename lib/{crates => components}/fabro-store/tests/serializable_projection.rs (100%) rename lib/{crates => components}/fabro-tool/Cargo.toml (64%) rename lib/{crates => components}/fabro-tool/src/common.rs (100%) rename lib/{crates => components}/fabro-tool/src/create.rs (100%) rename lib/{crates => components}/fabro-tool/src/events.rs (100%) rename lib/{crates => components}/fabro-tool/src/fabro_client.rs (100%) rename lib/{crates => components}/fabro-tool/src/gather.rs (100%) rename lib/{crates => components}/fabro-tool/src/get.rs (100%) rename lib/{crates => components}/fabro-tool/src/interact.rs (100%) rename lib/{crates => components}/fabro-tool/src/lib.rs (100%) rename lib/{crates => components}/fabro-tool/src/manifest.rs (100%) rename lib/{crates => components}/fabro-tool/src/pair.rs (100%) rename lib/{crates => components}/fabro-tool/src/search.rs (100%) rename lib/{crates => components}/fabro-tracker/Cargo.toml (100%) rename lib/{crates => components}/fabro-tracker/src/fixtures/github-app-test-key.pem (100%) rename lib/{crates => components}/fabro-tracker/src/github.rs (100%) rename lib/{crates => components}/fabro-tracker/src/lib.rs (100%) rename lib/{crates => components}/fabro-tracker/src/linear.rs (100%) rename lib/{crates => components}/fabro-validate/Cargo.toml (81%) rename lib/{crates => components}/fabro-validate/src/lib.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/all_conditional_edges.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/backend_valid.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/condition_syntax.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/direction_valid.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/edge_target_exists.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/exit_no_outgoing.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/fidelity_valid.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/freeform_edge_count.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/goal_gate_has_retry.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/import_error.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/inert_attribute.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/mod.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/model_support.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/node_model_known.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/orphan_custom_outcome.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/parallel_branch.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/parallel_branch_inert_attribute.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/prompt_on_llm_nodes.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/random_selection_no_conditions.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/reachability.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/reserved_keyword_node_id.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/retry_target_exists.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/script_absolute_cd.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/selection_valid.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/start_no_incoming.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/start_node.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/stylesheet_model_known.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/stylesheet_syntax.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/terminal_node.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/test_support.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/type_known.rs (100%) rename lib/{crates => components}/fabro-validate/src/rules/unresolved_file_ref.rs (100%) rename lib/{crates => components}/fabro-variable/Cargo.toml (82%) rename lib/{crates => components}/fabro-variable/src/lib.rs (100%) rename lib/{crates => components}/fabro-variable/tests/store.rs (100%) rename lib/{crates => components}/fabro-workflow/Cargo.toml (78%) rename lib/{crates => components}/fabro-workflow/README.md (100%) rename lib/{crates => components}/fabro-workflow/src/artifact.rs (100%) rename lib/{crates => components}/fabro-workflow/src/artifact_snapshot.rs (100%) rename lib/{crates => components}/fabro-workflow/src/artifact_upload.rs (100%) rename lib/{crates => components}/fabro-workflow/src/billing_rollup.rs (100%) rename lib/{crates => components}/fabro-workflow/src/command_log.rs (100%) rename lib/{crates => components}/fabro-workflow/src/condition.rs (100%) rename lib/{crates => components}/fabro-workflow/src/context.rs (100%) rename lib/{crates => components}/fabro-workflow/src/error.rs (100%) rename lib/{crates => components}/fabro-workflow/src/event.rs (100%) rename lib/{crates => components}/fabro-workflow/src/event/convert.rs (100%) rename lib/{crates => components}/fabro-workflow/src/event/emitter.rs (100%) rename lib/{crates => components}/fabro-workflow/src/event/events.rs (100%) rename lib/{crates => components}/fabro-workflow/src/event/names.rs (100%) rename lib/{crates => components}/fabro-workflow/src/event/redaction.rs (100%) rename lib/{crates => components}/fabro-workflow/src/event/sink.rs (100%) rename lib/{crates => components}/fabro-workflow/src/event/stored_fields.rs (100%) rename lib/{crates => components}/fabro-workflow/src/event/test_support.rs (100%) rename lib/{crates => components}/fabro-workflow/src/file_resolver.rs (100%) rename lib/{crates => components}/fabro-workflow/src/git.rs (100%) rename lib/{crates => components}/fabro-workflow/src/github_token_source.rs (100%) rename lib/{crates => components}/fabro-workflow/src/graph.rs (100%) rename lib/{crates => components}/fabro-workflow/src/graph/routing.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/agent.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/command.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/conditional.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/exit.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/fan_in.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/human.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/llm/acp.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/llm/activation_lease.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/llm/api.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/llm/changed_files.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/llm/mod.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/llm/preamble.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/llm/router.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/llm/routing.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/manager_loop.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/mod.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/parallel.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/prompt.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/start.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/structured_output.rs (100%) rename lib/{crates => components}/fabro-workflow/src/handler/wait.rs (100%) rename lib/{crates => components}/fabro-workflow/src/hook_context.rs (100%) rename lib/{crates => components}/fabro-workflow/src/interview_runtime.rs (100%) rename lib/{crates => components}/fabro-workflow/src/lib.rs (100%) rename lib/{crates => components}/fabro-workflow/src/lifecycle/artifact.rs (100%) rename lib/{crates => components}/fabro-workflow/src/lifecycle/auto_status.rs (100%) rename lib/{crates => components}/fabro-workflow/src/lifecycle/circuit_breaker.rs (100%) rename lib/{crates => components}/fabro-workflow/src/lifecycle/event.rs (100%) rename lib/{crates => components}/fabro-workflow/src/lifecycle/fidelity.rs (100%) rename lib/{crates => components}/fabro-workflow/src/lifecycle/git.rs (100%) rename lib/{crates => components}/fabro-workflow/src/lifecycle/hook.rs (100%) rename lib/{crates => components}/fabro-workflow/src/lifecycle/mod.rs (100%) rename lib/{crates => components}/fabro-workflow/src/node_handler.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/archive.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/create.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/fork.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/mod.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/resume.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/retry.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/rewind.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/run_store.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/source.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/start.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/timeline.rs (100%) rename lib/{crates => components}/fabro-workflow/src/operations/validate.rs (100%) rename lib/{crates => components}/fabro-workflow/src/outcome.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/execute.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/execute/tests.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/finalize.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/initialize.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/mod.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/parse.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/persist.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/prompts/pr_body.md (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/pull_request.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/transform.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/types.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pipeline/validate.rs (100%) rename lib/{crates => components}/fabro-workflow/src/pull_request.rs (100%) rename lib/{crates => components}/fabro-workflow/src/records/checkpoint.rs (100%) rename lib/{crates => components}/fabro-workflow/src/records/conclusion.rs (100%) rename lib/{crates => components}/fabro-workflow/src/records/mod.rs (100%) rename lib/{crates => components}/fabro-workflow/src/records/run.rs (100%) rename lib/{crates => components}/fabro-workflow/src/records/start.rs (100%) rename lib/{crates => components}/fabro-workflow/src/retry.rs (100%) rename lib/{crates => components}/fabro-workflow/src/run_control.rs (100%) rename lib/{crates => components}/fabro-workflow/src/run_dir.rs (100%) rename lib/{crates => components}/fabro-workflow/src/run_lookup.rs (100%) rename lib/{crates => components}/fabro-workflow/src/run_materialization.rs (100%) rename lib/{crates => components}/fabro-workflow/src/run_metadata.rs (100%) rename lib/{crates => components}/fabro-workflow/src/run_options.rs (100%) rename lib/{crates => components}/fabro-workflow/src/run_status.rs (100%) rename lib/{crates => components}/fabro-workflow/src/runtime_store.rs (100%) rename lib/{crates => components}/fabro-workflow/src/sandbox_git.rs (100%) rename lib/{crates => components}/fabro-workflow/src/sandbox_git_runtime.rs (100%) rename lib/{crates => components}/fabro-workflow/src/services.rs (100%) rename lib/{crates => components}/fabro-workflow/src/stage_scope.rs (100%) rename lib/{crates => components}/fabro-workflow/src/static_reference.rs (100%) rename lib/{crates => components}/fabro-workflow/src/steering_hub.rs (100%) rename lib/{crates => components}/fabro-workflow/src/test_support.rs (100%) rename lib/{crates => components}/fabro-workflow/src/transforms/file_inlining.rs (100%) rename lib/{crates => components}/fabro-workflow/src/transforms/import.rs (100%) rename lib/{crates => components}/fabro-workflow/src/transforms/importable_field.rs (100%) rename lib/{crates => components}/fabro-workflow/src/transforms/mod.rs (100%) rename lib/{crates => components}/fabro-workflow/src/transforms/model_resolution.rs (100%) rename lib/{crates => components}/fabro-workflow/src/transforms/preamble.rs (100%) rename lib/{crates => components}/fabro-workflow/src/transforms/stylesheet.rs (100%) rename lib/{crates => components}/fabro-workflow/src/transforms/stylesheet_application.rs (100%) rename lib/{crates => components}/fabro-workflow/src/transforms/variable_expansion.rs (100%) rename lib/{crates => components}/fabro-workflow/src/workflow_bundle.rs (100%) rename lib/{crates => components}/fabro-workflow/tests/it/attractor_compat.rs (100%) rename lib/{crates => components}/fabro-workflow/tests/it/cp_integration.rs (100%) rename lib/{crates => components}/fabro-workflow/tests/it/daytona_integration.rs (100%) rename lib/{crates => components}/fabro-workflow/tests/it/git_integration.rs (100%) rename lib/{crates => components}/fabro-workflow/tests/it/integration.rs (100%) rename lib/{crates => components}/fabro-workflow/tests/it/main.rs (100%) rename lib/{crates => components}/fabro-workflow/tests/materialize_run.rs (100%) delete mode 100644 lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap delete mode 100644 lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap delete mode 100644 lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap rename lib/{crates => foundation}/build-support/Cargo.toml (100%) rename lib/{crates => foundation}/build-support/git_metadata.rs (100%) rename lib/{crates => foundation}/fabro-api/Cargo.toml (93%) rename lib/{crates => foundation}/fabro-api/build.rs (99%) rename lib/{crates => foundation}/fabro-api/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/agent_session_activated_props_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/automation_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/billed_token_counts_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/command_termination_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/completion_message_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/cost_source_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/diff_stats_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/diff_summary_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/environment_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/event_envelope_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/git_context_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/interview_option_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/interview_question_record_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/mcp_server_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/mcp_transport_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/model_costs_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/model_features_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/model_limits_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/model_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/model_test_mode_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/pair_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/pending_interview_record_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/pre_run_push_outcome_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/principal_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/provider_id_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/provider_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/pull_request_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/question_type_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/run_billing_stage_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/run_event_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/run_failure_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/run_integrations_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/run_projection_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/run_sandbox_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/run_summary_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/sandbox_details_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/sandbox_inventory_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/sandbox_services_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/secret_metadata_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/secret_type_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/server_settings_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/session_contract_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/stage_completion_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/stage_handler_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/stage_model_usage_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/stage_outcome_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/stage_projection_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/stage_state_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/status_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/submit_answer_request_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/system_integrations_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/variable_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-api/tests/workflow_settings_round_trip.rs (100%) rename lib/{crates => foundation}/fabro-auth/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-auth/src/context.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/credential.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/credential_source.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/env_source.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/extra_headers_source.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/refresh.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/resolve.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/sql_vault_source.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/strategies/api_key.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/strategies/codex_device.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/strategies/mod.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/strategy.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/vault_ext.rs (100%) rename lib/{crates => foundation}/fabro-auth/src/vault_source.rs (100%) rename lib/{crates => foundation}/fabro-client/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-client/src/auth_store.rs (100%) rename lib/{crates => foundation}/fabro-client/src/client.rs (100%) rename lib/{crates => foundation}/fabro-client/src/credential.rs (100%) rename lib/{crates => foundation}/fabro-client/src/error.rs (100%) rename lib/{crates => foundation}/fabro-client/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-client/src/session.rs (100%) rename lib/{crates => foundation}/fabro-client/src/sse.rs (100%) rename lib/{crates => foundation}/fabro-client/src/target.rs (100%) rename lib/{crates => foundation}/fabro-config/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-config/migrations/2026050101_legacy_sandbox_to_environments.rs (100%) rename lib/{crates => foundation}/fabro-config/migrations/2026052801_settings_environments_to_server_files.rs (100%) rename lib/{crates => foundation}/fabro-config/src/bind.rs (100%) rename lib/{crates => foundation}/fabro-config/src/builders.rs (100%) rename lib/{crates => foundation}/fabro-config/src/daemon.rs (100%) rename lib/{crates => foundation}/fabro-config/src/defaults.rs (100%) rename lib/{crates => foundation}/fabro-config/src/defaults.toml (100%) rename lib/{crates => foundation}/fabro-config/src/envfile.rs (100%) rename lib/{crates => foundation}/fabro-config/src/error.rs (100%) rename lib/{crates => foundation}/fabro-config/src/home.rs (100%) rename lib/{crates => foundation}/fabro-config/src/input_overrides.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/cli.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/combine.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/environment.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/llm.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/log_filter.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/maps.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/mod.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/project.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/run.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/server.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/settings.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/splice_array.rs (100%) rename lib/{crates => foundation}/fabro-config/src/layers/workflow.rs (100%) rename lib/{crates => foundation}/fabro-config/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-config/src/load.rs (100%) rename lib/{crates => foundation}/fabro-config/src/logging.rs (100%) rename lib/{crates => foundation}/fabro-config/src/migrations.rs (100%) rename lib/{crates => foundation}/fabro-config/src/parse.rs (100%) rename lib/{crates => foundation}/fabro-config/src/project.rs (100%) rename lib/{crates => foundation}/fabro-config/src/resolve/cli.rs (100%) rename lib/{crates => foundation}/fabro-config/src/resolve/environment.rs (100%) rename lib/{crates => foundation}/fabro-config/src/resolve/error.rs (100%) rename lib/{crates => foundation}/fabro-config/src/resolve/mod.rs (100%) rename lib/{crates => foundation}/fabro-config/src/resolve/project.rs (100%) rename lib/{crates => foundation}/fabro-config/src/resolve/run.rs (100%) rename lib/{crates => foundation}/fabro-config/src/resolve/server.rs (100%) rename lib/{crates => foundation}/fabro-config/src/resolve/workflow.rs (100%) rename lib/{crates => foundation}/fabro-config/src/run.rs (100%) rename lib/{crates => foundation}/fabro-config/src/storage.rs (100%) rename lib/{crates => foundation}/fabro-config/src/tests/combine.rs (100%) rename lib/{crates => foundation}/fabro-config/src/tests/defaults.rs (100%) rename lib/{crates => foundation}/fabro-config/src/tests/log_filter.rs (100%) rename lib/{crates => foundation}/fabro-config/src/tests/mod.rs (100%) rename lib/{crates => foundation}/fabro-config/src/tests/resolve_cli.rs (100%) rename lib/{crates => foundation}/fabro-config/src/tests/resolve_project.rs (100%) rename lib/{crates => foundation}/fabro-config/src/tests/resolve_root.rs (100%) rename lib/{crates => foundation}/fabro-config/src/tests/resolve_run.rs (100%) rename lib/{crates => foundation}/fabro-config/src/tests/resolve_server.rs (100%) rename lib/{crates => foundation}/fabro-config/src/tests/resolve_workflow.rs (100%) rename lib/{crates => foundation}/fabro-config/src/user.rs (100%) rename lib/{crates => foundation}/fabro-core/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-core/src/context.rs (100%) rename lib/{crates => foundation}/fabro-core/src/error.rs (100%) rename lib/{crates => foundation}/fabro-core/src/executor.rs (100%) rename lib/{crates => foundation}/fabro-core/src/graph.rs (100%) rename lib/{crates => foundation}/fabro-core/src/handler.rs (100%) rename lib/{crates => foundation}/fabro-core/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-core/src/lifecycle.rs (100%) rename lib/{crates => foundation}/fabro-core/src/outcome.rs (100%) rename lib/{crates => foundation}/fabro-core/src/retry.rs (100%) rename lib/{crates => foundation}/fabro-core/src/stall.rs (100%) rename lib/{crates => foundation}/fabro-core/src/state.rs (100%) rename lib/{crates => foundation}/fabro-core/src/test_fixtures.rs (100%) rename lib/{crates => foundation}/fabro-db/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-db/migrations/2026063001_variables.sql (100%) rename lib/{crates => foundation}/fabro-db/migrations/2026063002_environments.sql (100%) rename lib/{crates => foundation}/fabro-db/migrations/2026071101_secrets.sql (100%) rename lib/{crates => foundation}/fabro-db/migrations/2026071102_mcp_servers.sql (100%) rename lib/{crates => foundation}/fabro-db/migrations/2026071103_automations.sql (100%) rename lib/{crates => foundation}/fabro-db/migrations/2026071104_runs.sql (100%) rename lib/{crates => foundation}/fabro-db/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-db/tests/sqlite.rs (100%) rename lib/{crates => foundation}/fabro-dev/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-dev/src/commands/bench_tests.rs (100%) rename lib/{crates => foundation}/fabro-dev/src/commands/build.rs (100%) rename lib/{crates => foundation}/fabro-dev/src/commands/docker_build.rs (100%) rename lib/{crates => foundation}/fabro-dev/src/commands/docs.rs (100%) rename lib/{crates => foundation}/fabro-dev/src/commands/docs_cli_reference.rs (100%) rename lib/{crates => foundation}/fabro-dev/src/commands/docs_options_reference.rs (100%) rename lib/{crates => foundation}/fabro-dev/src/commands/mod.rs (100%) rename lib/{crates => foundation}/fabro-dev/src/commands/release.rs (100%) rename lib/{crates => foundation}/fabro-dev/src/commands/spa.rs (100%) rename lib/{crates => foundation}/fabro-dev/src/commands/spa_check.rs (97%) rename lib/{crates => foundation}/fabro-dev/src/commands/spa_refresh.rs (97%) rename lib/{crates => foundation}/fabro-dev/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-dev/src/main.rs (100%) rename lib/{crates => foundation}/fabro-dev/tests/it/docker_build.rs (100%) rename lib/{crates => foundation}/fabro-dev/tests/it/docker_entrypoint.rs (100%) rename lib/{crates => foundation}/fabro-dev/tests/it/docs.rs (100%) rename lib/{crates => foundation}/fabro-dev/tests/it/main.rs (98%) rename lib/{crates => foundation}/fabro-dev/tests/it/policy.rs (93%) rename lib/{crates => foundation}/fabro-dev/tests/it/release.rs (98%) rename lib/{crates => foundation}/fabro-dev/tests/it/spa.rs (93%) rename lib/{crates => foundation}/fabro-http/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-http/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-macros/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-macros/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-macros/src/options_metadata.rs (100%) rename lib/{crates => foundation}/fabro-macros/tests/options_metadata.rs (100%) rename lib/{crates => foundation}/fabro-model/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-model/src/adapter.rs (100%) rename lib/{crates => foundation}/fabro-model/src/billing.rs (100%) rename lib/{crates => foundation}/fabro-model/src/bootstrap_catalog.rs (100%) rename lib/{crates => foundation}/fabro-model/src/catalog.rs (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/anthropic.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/bedrock-openai.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/bedrock.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/gemini.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/inception.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/kimi.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/litellm.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/minimax.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/ollama.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/openai.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/openrouter.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/poolside.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/venice.toml (100%) rename lib/{crates => foundation}/fabro-model/src/catalog/providers/zai.toml (100%) rename lib/{crates => foundation}/fabro-model/src/codec.rs (100%) rename lib/{crates => foundation}/fabro-model/src/ids.rs (100%) rename lib/{crates => foundation}/fabro-model/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-model/src/model_ref.rs (100%) rename lib/{crates => foundation}/fabro-model/src/model_test.rs (100%) rename lib/{crates => foundation}/fabro-model/src/provider.rs (100%) rename lib/{crates => foundation}/fabro-model/src/reasoning.rs (100%) rename lib/{crates => foundation}/fabro-model/src/types.rs (100%) rename lib/{crates => foundation}/fabro-oauth/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-oauth/examples/login.rs (100%) rename lib/{crates => foundation}/fabro-oauth/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-options-metadata/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-options-metadata/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-proc/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-proc/build.rs (100%) rename lib/{crates => foundation}/fabro-proc/c/capture_argv.c (100%) rename lib/{crates => foundation}/fabro-proc/src/flock.rs (100%) rename lib/{crates => foundation}/fabro-proc/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-proc/src/pre_exec.rs (100%) rename lib/{crates => foundation}/fabro-proc/src/signal.rs (100%) rename lib/{crates => foundation}/fabro-proc/src/title.rs (100%) rename lib/{crates => foundation}/fabro-redact/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-redact/build.rs (100%) rename lib/{crates => foundation}/fabro-redact/data/gitleaks.toml (100%) rename lib/{crates => foundation}/fabro-redact/src/entropy.rs (100%) rename lib/{crates => foundation}/fabro-redact/src/gitleaks.rs (100%) rename lib/{crates => foundation}/fabro-redact/src/jsonl.rs (100%) rename lib/{crates => foundation}/fabro-redact/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-redact/src/safe_url.rs (100%) rename lib/{crates => foundation}/fabro-static/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-static/src/env_vars.rs (100%) rename lib/{crates => foundation}/fabro-static/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-static/src/secret_registry.rs (100%) rename lib/{crates => foundation}/fabro-telemetry/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-telemetry/src/anonymous_id.rs (100%) rename lib/{crates => foundation}/fabro-telemetry/src/buffer.rs (100%) rename lib/{crates => foundation}/fabro-telemetry/src/context.rs (100%) rename lib/{crates => foundation}/fabro-telemetry/src/event.rs (100%) rename lib/{crates => foundation}/fabro-telemetry/src/git.rs (100%) rename lib/{crates => foundation}/fabro-telemetry/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-telemetry/src/panic.rs (100%) rename lib/{crates => foundation}/fabro-telemetry/src/sanitize.rs (100%) rename lib/{crates => foundation}/fabro-telemetry/src/sender.rs (100%) rename lib/{crates => foundation}/fabro-telemetry/src/spawn.rs (100%) rename lib/{crates => foundation}/fabro-template/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-template/src/dependency.rs (100%) rename lib/{crates => foundation}/fabro-template/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-template/src/store.rs (100%) rename lib/{crates => foundation}/fabro-test/Cargo.toml (93%) rename lib/{crates => foundation}/fabro-test/src/http_assert.rs (100%) rename lib/{crates => foundation}/fabro-test/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-types/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-types/src/artifact.rs (100%) rename lib/{crates => foundation}/fabro-types/src/auth.rs (100%) rename lib/{crates => foundation}/fabro-types/src/billing.rs (100%) rename lib/{crates => foundation}/fabro-types/src/blob_ref.rs (100%) rename lib/{crates => foundation}/fabro-types/src/checkpoint.rs (100%) rename lib/{crates => foundation}/fabro-types/src/command_output.rs (100%) rename lib/{crates => foundation}/fabro-types/src/conclusion.rs (100%) rename lib/{crates => foundation}/fabro-types/src/dense.rs (100%) rename lib/{crates => foundation}/fabro-types/src/diff.rs (100%) rename lib/{crates => foundation}/fabro-types/src/event_envelope.rs (100%) rename lib/{crates => foundation}/fabro-types/src/failure_signature.rs (100%) rename lib/{crates => foundation}/fabro-types/src/graph.rs (100%) rename lib/{crates => foundation}/fabro-types/src/id.rs (100%) rename lib/{crates => foundation}/fabro-types/src/interview.rs (100%) rename lib/{crates => foundation}/fabro-types/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-types/src/llm_backend.rs (100%) rename lib/{crates => foundation}/fabro-types/src/manifest_path.rs (100%) rename lib/{crates => foundation}/fabro-types/src/mcp_store.rs (100%) rename lib/{crates => foundation}/fabro-types/src/outcome.rs (100%) rename lib/{crates => foundation}/fabro-types/src/pair.rs (100%) rename lib/{crates => foundation}/fabro-types/src/principal.rs (100%) rename lib/{crates => foundation}/fabro-types/src/pull_request.rs (100%) rename lib/{crates => foundation}/fabro-types/src/repository.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_blob_id.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_event/agent.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_event/infra.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_event/misc.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_event/mod.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_event/run.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_event/session.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_event/stage.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_event/todo.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_failure.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_id.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_projection.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_sandbox.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_summary.rs (100%) rename lib/{crates => foundation}/fabro-types/src/run_title.rs (100%) rename lib/{crates => foundation}/fabro-types/src/sandbox_details.rs (100%) rename lib/{crates => foundation}/fabro-types/src/sandbox_inventory.rs (100%) rename lib/{crates => foundation}/fabro-types/src/sandbox_provider.rs (100%) rename lib/{crates => foundation}/fabro-types/src/sandbox_services.rs (100%) rename lib/{crates => foundation}/fabro-types/src/secret.rs (100%) rename lib/{crates => foundation}/fabro-types/src/session.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/cli.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/duration.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/interp.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/mod.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/model_ref.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/project.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/public_url.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/run.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/server.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/size.rs (100%) rename lib/{crates => foundation}/fabro-types/src/settings/workflow.rs (100%) rename lib/{crates => foundation}/fabro-types/src/stage_completion.rs (100%) rename lib/{crates => foundation}/fabro-types/src/stage_handler.rs (100%) rename lib/{crates => foundation}/fabro-types/src/stage_id.rs (100%) rename lib/{crates => foundation}/fabro-types/src/start.rs (100%) rename lib/{crates => foundation}/fabro-types/src/status.rs (100%) rename lib/{crates => foundation}/fabro-types/src/steering.rs (100%) rename lib/{crates => foundation}/fabro-types/src/system_integrations.rs (100%) rename lib/{crates => foundation}/fabro-types/src/test_support.rs (100%) rename lib/{crates => foundation}/fabro-types/src/timing.rs (100%) rename lib/{crates => foundation}/fabro-types/src/todo.rs (100%) rename lib/{crates => foundation}/fabro-types/src/transcript.rs (100%) rename lib/{crates => foundation}/fabro-types/src/variable.rs (100%) rename lib/{crates => foundation}/fabro-types/tests/run_event_serde.rs (100%) rename lib/{crates => foundation}/fabro-types/tests/run_failure_serde.rs (100%) rename lib/{crates => foundation}/fabro-types/tests/run_spec_methods.rs (100%) rename lib/{crates => foundation}/fabro-types/tests/run_spec_serde.rs (100%) rename lib/{crates => foundation}/fabro-types/tests/sandbox_inventory_serde.rs (100%) rename lib/{crates => foundation}/fabro-types/tests/sandbox_model_serde.rs (100%) rename lib/{crates => foundation}/fabro-types/tests/stage_handler.rs (100%) rename lib/{crates => foundation}/fabro-util/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-util/src/backoff.rs (100%) rename lib/{crates => foundation}/fabro-util/src/browser.rs (100%) rename lib/{crates => foundation}/fabro-util/src/check_report.rs (100%) rename lib/{crates => foundation}/fabro-util/src/dev_token.rs (100%) rename lib/{crates => foundation}/fabro-util/src/env.rs (100%) rename lib/{crates => foundation}/fabro-util/src/error.rs (100%) rename lib/{crates => foundation}/fabro-util/src/exit.rs (100%) rename lib/{crates => foundation}/fabro-util/src/home.rs (100%) rename lib/{crates => foundation}/fabro-util/src/json.rs (100%) rename lib/{crates => foundation}/fabro-util/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-util/src/path.rs (100%) rename lib/{crates => foundation}/fabro-util/src/printer.rs (100%) rename lib/{crates => foundation}/fabro-util/src/run_log.rs (100%) rename lib/{crates => foundation}/fabro-util/src/session_secret.rs (100%) rename lib/{crates => foundation}/fabro-util/src/shell.rs (100%) rename lib/{crates => foundation}/fabro-util/src/terminal.rs (100%) rename lib/{crates => foundation}/fabro-util/src/text.rs (100%) rename lib/{crates => foundation}/fabro-util/src/time.rs (100%) rename lib/{crates => foundation}/fabro-util/src/version.rs (100%) rename lib/{crates => foundation}/fabro-util/src/warnings.rs (100%) rename lib/{crates => foundation}/fabro-util/tests/dev_token.rs (100%) rename lib/{crates => foundation}/fabro-util/tests/error_chain.rs (100%) rename lib/{crates => foundation}/fabro-vault/Cargo.toml (100%) rename lib/{crates => foundation}/fabro-vault/src/lib.rs (100%) rename lib/{crates => foundation}/fabro-vault/src/store.rs (100%) rename lib/{crates => foundation}/fabro-vault/tests/store.rs (100%) 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/.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.toml b/Cargo.toml index 6cdec975b..e9f63c9d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,12 @@ [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] @@ -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/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/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/fabro-event-schema-v2-concrete-shape.md b/docs/internal/fabro-event-schema-v2-concrete-shape.md index 4d8541b90..77f4fe5da 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. 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/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/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/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 4c84def39..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,16 +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-workflow = { path = "../fabro-workflow", 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" @@ -129,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 100% rename from lib/crates/fabro-cli/src/commands/model.rs rename to lib/apps/fabro-cli/src/commands/model.rs 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 100% rename from lib/crates/fabro-cli/src/commands/run/ask.rs rename to lib/apps/fabro-cli/src/commands/run/ask.rs 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 100% 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 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 100% rename from lib/crates/fabro-cli/src/shared/provider_auth.rs rename to lib/apps/fabro-cli/src/shared/provider_auth.rs 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 100% rename from lib/crates/fabro-cli/tests/it/cmd/install.rs rename to lib/apps/fabro-cli/tests/it/cmd/install.rs 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 100% rename from lib/crates/fabro-cli/tests/it/cmd/model_test.rs rename to lib/apps/fabro-cli/tests/it/cmd/model_test.rs 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 100% rename from lib/crates/fabro-cli/tests/it/support/auth_harness.rs rename to lib/apps/fabro-cli/tests/it/support/auth_harness.rs 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/crates/fabro-cli/tests/it/workflow/command_routing.rs b/lib/apps/fabro-cli/tests/it/workflow/command_routing.rs similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/command_routing.rs rename to lib/apps/fabro-cli/tests/it/workflow/command_routing.rs 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/crates/fabro-cli/tests/it/workflow/fixtures/command_routing.fabro b/lib/apps/fabro-cli/tests/it/workflow/fixtures/command_routing.fabro similarity index 100% rename from lib/crates/fabro-cli/tests/it/workflow/fixtures/command_routing.fabro rename to lib/apps/fabro-cli/tests/it/workflow/fixtures/command_routing.fabro 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 100% rename from lib/crates/fabro-cli/tests/it/workflow/mod.rs rename to lib/apps/fabro-cli/tests/it/workflow/mod.rs 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 100% rename from lib/crates/fabro-server/src/diagnostics.rs rename to lib/apps/fabro-server/src/diagnostics.rs 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 100% rename from lib/crates/fabro-server/src/install.rs rename to lib/apps/fabro-server/src/install.rs 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 100% rename from lib/crates/fabro-server/src/run_manifest.rs rename to lib/apps/fabro-server/src/run_manifest.rs 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 100% rename from lib/crates/fabro-server/src/server.rs rename to lib/apps/fabro-server/src/server.rs diff --git a/lib/crates/fabro-server/src/server/automation_scheduler.rs b/lib/apps/fabro-server/src/server/automation_scheduler.rs similarity index 100% rename from lib/crates/fabro-server/src/server/automation_scheduler.rs rename to lib/apps/fabro-server/src/server/automation_scheduler.rs 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 100% rename from lib/crates/fabro-server/src/server/handler/completions.rs rename to lib/apps/fabro-server/src/server/handler/completions.rs 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 100% rename from lib/crates/fabro-server/src/server/handler/models.rs rename to lib/apps/fabro-server/src/server/handler/models.rs diff --git a/lib/crates/fabro-server/src/server/handler/pair.rs b/lib/apps/fabro-server/src/server/handler/pair.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/pair.rs rename to lib/apps/fabro-server/src/server/handler/pair.rs diff --git a/lib/crates/fabro-server/src/server/handler/playground.rs b/lib/apps/fabro-server/src/server/handler/playground.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/playground.rs rename to lib/apps/fabro-server/src/server/handler/playground.rs 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 100% rename from lib/crates/fabro-server/src/server/handler/pull_requests.rs rename to lib/apps/fabro-server/src/server/handler/pull_requests.rs diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs similarity index 100% rename from lib/crates/fabro-server/src/server/handler/runs.rs rename to lib/apps/fabro-server/src/server/handler/runs.rs 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 100% rename from lib/crates/fabro-server/src/server/handler/sessions.rs rename to lib/apps/fabro-server/src/server/handler/sessions.rs 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 100% rename from lib/crates/fabro-server/src/server/tests.rs rename to lib/apps/fabro-server/src/server/tests.rs 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 100% rename from lib/crates/fabro-server/src/spawn_env.rs rename to lib/apps/fabro-server/src/spawn_env.rs 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 100% rename from lib/crates/fabro-server/src/test_support.rs rename to lib/apps/fabro-server/src/test_support.rs 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 100% rename from lib/crates/fabro-server/tests/it/api/automations.rs rename to lib/apps/fabro-server/tests/it/api/automations.rs 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 100% rename from lib/crates/fabro-server/tests/it/api/events.rs rename to lib/apps/fabro-server/tests/it/api/events.rs 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 100% rename from lib/crates/fabro-server/tests/it/api/sessions.rs rename to lib/apps/fabro-server/tests/it/api/sessions.rs diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/apps/fabro-server/tests/it/api/settings.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/settings.rs rename to lib/apps/fabro-server/tests/it/api/settings.rs diff --git a/lib/crates/fabro-server/tests/it/api/system.rs b/lib/apps/fabro-server/tests/it/api/system.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/system.rs rename to lib/apps/fabro-server/tests/it/api/system.rs diff --git a/lib/crates/fabro-server/tests/it/api/tcp.rs b/lib/apps/fabro-server/tests/it/api/tcp.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/tcp.rs rename to lib/apps/fabro-server/tests/it/api/tcp.rs diff --git a/lib/crates/fabro-server/tests/it/api/variables.rs b/lib/apps/fabro-server/tests/it/api/variables.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/api/variables.rs rename to lib/apps/fabro-server/tests/it/api/variables.rs diff --git a/lib/crates/fabro-server/tests/it/event_pagination.rs b/lib/apps/fabro-server/tests/it/event_pagination.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/event_pagination.rs rename to lib/apps/fabro-server/tests/it/event_pagination.rs diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/apps/fabro-server/tests/it/helpers.rs similarity index 99% rename from lib/crates/fabro-server/tests/it/helpers.rs rename to lib/apps/fabro-server/tests/it/helpers.rs index 569f7e7d0..9ecf30bb8 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/apps/fabro-server/tests/it/helpers.rs @@ -142,7 +142,7 @@ pub(crate) fn repo_root() -> 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 100% rename from lib/crates/fabro-server/tests/it/scenario/dry_run.rs rename to lib/apps/fabro-server/tests/it/scenario/dry_run.rs 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 100% rename from lib/crates/fabro-server/tests/it/scenario/run_completion.rs rename to lib/apps/fabro-server/tests/it/scenario/run_completion.rs diff --git a/lib/crates/fabro-server/tests/it/scenario/sse.rs b/lib/apps/fabro-server/tests/it/scenario/sse.rs similarity index 100% rename from lib/crates/fabro-server/tests/it/scenario/sse.rs rename to lib/apps/fabro-server/tests/it/scenario/sse.rs 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 100% rename from lib/crates/fabro-agent/README.md rename to lib/components/fabro-agent/README.md diff --git a/lib/crates/fabro-agent/src/agent_profile.rs b/lib/components/fabro-agent/src/agent_profile.rs similarity index 100% rename from lib/crates/fabro-agent/src/agent_profile.rs rename to lib/components/fabro-agent/src/agent_profile.rs 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 100% rename from lib/crates/fabro-agent/src/cli.rs rename to lib/components/fabro-agent/src/cli.rs 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 100% rename from lib/crates/fabro-agent/src/config.rs rename to lib/components/fabro-agent/src/config.rs 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 100% rename from lib/crates/fabro-agent/src/profiles/openai.rs rename to lib/components/fabro-agent/src/profiles/openai.rs 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 100% rename from lib/crates/fabro-agent/src/session.rs rename to lib/components/fabro-agent/src/session.rs 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 100% rename from lib/crates/fabro-agent/src/subagent.rs rename to lib/components/fabro-agent/src/subagent.rs 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 100% rename from lib/crates/fabro-agent/src/types.rs rename to lib/components/fabro-agent/src/types.rs diff --git a/lib/crates/fabro-agent/tests/it/compaction.rs b/lib/components/fabro-agent/tests/it/compaction.rs similarity index 100% rename from lib/crates/fabro-agent/tests/it/compaction.rs rename to lib/components/fabro-agent/tests/it/compaction.rs diff --git a/lib/crates/fabro-agent/tests/it/guardrails.rs b/lib/components/fabro-agent/tests/it/guardrails.rs similarity index 100% rename from lib/crates/fabro-agent/tests/it/guardrails.rs rename to lib/components/fabro-agent/tests/it/guardrails.rs 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 100% rename from lib/crates/fabro-agent/tests/it/parity_matrix.rs rename to lib/components/fabro-agent/tests/it/parity_matrix.rs 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 89% rename from lib/crates/fabro-graphviz/Cargo.toml rename to lib/components/fabro-graphviz/Cargo.toml index 3b33151d8..f78b3b9c2 100644 --- a/lib/crates/fabro-graphviz/Cargo.toml +++ b/lib/components/fabro-graphviz/Cargo.toml @@ -15,7 +15,7 @@ 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 } 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 100% rename from lib/crates/fabro-graphviz/src/fidelity.rs rename to lib/components/fabro-graphviz/src/fidelity.rs 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 100% rename from lib/crates/fabro-hooks/src/executor.rs rename to lib/components/fabro-hooks/src/executor.rs 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 100% rename from lib/crates/fabro-llm/src/adapter_registry.rs rename to lib/components/fabro-llm/src/adapter_registry.rs 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 100% rename from lib/crates/fabro-llm/src/client.rs rename to lib/components/fabro-llm/src/client.rs 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 100% rename from lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs rename to lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs 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 100% rename from lib/crates/fabro-llm/src/codec/anthropic_messages/wire.rs rename to lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs 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 100% rename from lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs rename to lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs 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/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 100% rename from lib/crates/fabro-llm/src/codec/mod.rs rename to lib/components/fabro-llm/src/codec/mod.rs 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 100% rename from lib/crates/fabro-llm/src/codec/openai_compatible/request.rs rename to lib/components/fabro-llm/src/codec/openai_compatible/request.rs 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 100% rename from lib/crates/fabro-llm/src/codec/openai_compatible/translate.rs rename to lib/components/fabro-llm/src/codec/openai_compatible/translate.rs 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 100% rename from lib/crates/fabro-llm/src/codec/openai_compatible/wire.rs rename to lib/components/fabro-llm/src/codec/openai_compatible/wire.rs 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 100% rename from lib/crates/fabro-llm/src/cost.rs rename to lib/components/fabro-llm/src/cost.rs 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 100% rename from lib/crates/fabro-llm/src/model_test.rs rename to lib/components/fabro-llm/src/model_test.rs 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 100% rename from lib/crates/fabro-llm/src/providers/anthropic.rs rename to lib/components/fabro-llm/src/providers/anthropic.rs 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 100% rename from lib/crates/fabro-llm/src/providers/bedrock/mod.rs rename to lib/components/fabro-llm/src/providers/bedrock/mod.rs 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 100% rename from lib/crates/fabro-llm/src/providers/common.rs rename to lib/components/fabro-llm/src/providers/common.rs 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 100% rename from lib/crates/fabro-llm/src/providers/gemini.rs rename to lib/components/fabro-llm/src/providers/gemini.rs 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 100% rename from lib/crates/fabro-llm/src/providers/openai.rs rename to lib/components/fabro-llm/src/providers/openai.rs diff --git a/lib/crates/fabro-llm/src/providers/openai_compatible.rs b/lib/components/fabro-llm/src/providers/openai_compatible.rs similarity index 100% rename from lib/crates/fabro-llm/src/providers/openai_compatible.rs rename to lib/components/fabro-llm/src/providers/openai_compatible.rs 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 100% rename from lib/crates/fabro-llm/tests/integration.rs rename to lib/components/fabro-llm/tests/integration.rs 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 100% rename from lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs rename to lib/components/fabro-llm/tests/it/wire/openai_compatible.rs 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 100% rename from lib/crates/fabro-manifest/src/lib.rs rename to lib/components/fabro-manifest/src/lib.rs 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/src/worktree.rs b/lib/components/fabro-sandbox/src/worktree.rs similarity index 100% rename from lib/crates/fabro-sandbox/src/worktree.rs rename to lib/components/fabro-sandbox/src/worktree.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 100% rename from lib/crates/fabro-store/src/run_sessions.rs rename to lib/components/fabro-store/src/run_sessions.rs diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs similarity index 100% rename from lib/crates/fabro-store/src/run_state.rs rename to lib/components/fabro-store/src/run_state.rs 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 100% rename from lib/crates/fabro-store/src/slate/mod.rs rename to lib/components/fabro-store/src/slate/mod.rs 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 100% rename from lib/crates/fabro-store/src/slate/run_store.rs rename to lib/components/fabro-store/src/slate/run_store.rs 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 100% rename from lib/crates/fabro-validate/src/rules/inert_attribute.rs rename to lib/components/fabro-validate/src/rules/inert_attribute.rs diff --git a/lib/crates/fabro-validate/src/rules/mod.rs b/lib/components/fabro-validate/src/rules/mod.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/mod.rs rename to lib/components/fabro-validate/src/rules/mod.rs diff --git a/lib/crates/fabro-validate/src/rules/model_support.rs b/lib/components/fabro-validate/src/rules/model_support.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/model_support.rs rename to lib/components/fabro-validate/src/rules/model_support.rs 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/crates/fabro-validate/src/rules/parallel_branch.rs b/lib/components/fabro-validate/src/rules/parallel_branch.rs similarity index 100% rename from lib/crates/fabro-validate/src/rules/parallel_branch.rs rename to lib/components/fabro-validate/src/rules/parallel_branch.rs 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 100% 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 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 100% 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 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 78% rename from lib/crates/fabro-workflow/Cargo.toml rename to lib/components/fabro-workflow/Cargo.toml index e719de2fd..932bb0071 100644 --- a/lib/crates/fabro-workflow/Cargo.toml +++ b/lib/components/fabro-workflow/Cargo.toml @@ -21,10 +21,10 @@ 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" } @@ -33,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 @@ -72,12 +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-workflow = { path = ".", features = ["test-support"] } -fabro-api = { path = "../fabro-api" } +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" } @@ -86,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 100% rename from lib/crates/fabro-workflow/src/artifact.rs rename to lib/components/fabro-workflow/src/artifact.rs 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 100% rename from lib/crates/fabro-workflow/src/billing_rollup.rs rename to lib/components/fabro-workflow/src/billing_rollup.rs 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 100% rename from lib/crates/fabro-workflow/src/context.rs rename to lib/components/fabro-workflow/src/context.rs diff --git a/lib/crates/fabro-workflow/src/error.rs b/lib/components/fabro-workflow/src/error.rs similarity index 100% rename from lib/crates/fabro-workflow/src/error.rs rename to lib/components/fabro-workflow/src/error.rs diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/components/fabro-workflow/src/event.rs similarity index 100% rename from lib/crates/fabro-workflow/src/event.rs rename to lib/components/fabro-workflow/src/event.rs diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs similarity index 100% rename from lib/crates/fabro-workflow/src/event/convert.rs rename to lib/components/fabro-workflow/src/event/convert.rs 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 100% rename from lib/crates/fabro-workflow/src/event/names.rs rename to lib/components/fabro-workflow/src/event/names.rs 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 100% rename from lib/crates/fabro-workflow/src/event/sink.rs rename to lib/components/fabro-workflow/src/event/sink.rs diff --git a/lib/crates/fabro-workflow/src/event/stored_fields.rs b/lib/components/fabro-workflow/src/event/stored_fields.rs similarity index 100% rename from lib/crates/fabro-workflow/src/event/stored_fields.rs rename to lib/components/fabro-workflow/src/event/stored_fields.rs 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 100% rename from lib/crates/fabro-workflow/src/handler/agent.rs rename to lib/components/fabro-workflow/src/handler/agent.rs diff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/components/fabro-workflow/src/handler/command.rs similarity index 100% rename from lib/crates/fabro-workflow/src/handler/command.rs rename to lib/components/fabro-workflow/src/handler/command.rs 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 100% rename from lib/crates/fabro-workflow/src/handler/llm/api.rs rename to lib/components/fabro-workflow/src/handler/llm/api.rs 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 100% rename from lib/crates/fabro-workflow/src/handler/llm/preamble.rs rename to lib/components/fabro-workflow/src/handler/llm/preamble.rs 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 100% rename from lib/crates/fabro-workflow/src/handler/llm/routing.rs rename to lib/components/fabro-workflow/src/handler/llm/routing.rs 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 100% rename from lib/crates/fabro-workflow/src/handler/parallel.rs rename to lib/components/fabro-workflow/src/handler/parallel.rs 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 100% rename from lib/crates/fabro-workflow/src/handler/structured_output.rs rename to lib/components/fabro-workflow/src/handler/structured_output.rs 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 100% rename from lib/crates/fabro-workflow/src/lib.rs rename to lib/components/fabro-workflow/src/lib.rs 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 100% rename from lib/crates/fabro-workflow/src/lifecycle/event.rs rename to lib/components/fabro-workflow/src/lifecycle/event.rs diff --git a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs b/lib/components/fabro-workflow/src/lifecycle/fidelity.rs similarity index 100% rename from lib/crates/fabro-workflow/src/lifecycle/fidelity.rs rename to lib/components/fabro-workflow/src/lifecycle/fidelity.rs 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 100% rename from lib/crates/fabro-workflow/src/operations/create.rs rename to lib/components/fabro-workflow/src/operations/create.rs 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 100% rename from lib/crates/fabro-workflow/src/operations/start.rs rename to lib/components/fabro-workflow/src/operations/start.rs 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 100% rename from lib/crates/fabro-workflow/src/operations/validate.rs rename to lib/components/fabro-workflow/src/operations/validate.rs diff --git a/lib/crates/fabro-workflow/src/outcome.rs b/lib/components/fabro-workflow/src/outcome.rs similarity index 100% rename from lib/crates/fabro-workflow/src/outcome.rs rename to lib/components/fabro-workflow/src/outcome.rs 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 100% rename from lib/crates/fabro-workflow/src/pipeline/execute/tests.rs rename to lib/components/fabro-workflow/src/pipeline/execute/tests.rs diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/components/fabro-workflow/src/pipeline/finalize.rs similarity index 100% rename from lib/crates/fabro-workflow/src/pipeline/finalize.rs rename to lib/components/fabro-workflow/src/pipeline/finalize.rs diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/components/fabro-workflow/src/pipeline/initialize.rs similarity index 100% rename from lib/crates/fabro-workflow/src/pipeline/initialize.rs rename to lib/components/fabro-workflow/src/pipeline/initialize.rs diff --git a/lib/crates/fabro-workflow/src/pipeline/mod.rs b/lib/components/fabro-workflow/src/pipeline/mod.rs similarity index 100% rename from lib/crates/fabro-workflow/src/pipeline/mod.rs rename to lib/components/fabro-workflow/src/pipeline/mod.rs 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 100% rename from lib/crates/fabro-workflow/src/pipeline/pull_request.rs rename to lib/components/fabro-workflow/src/pipeline/pull_request.rs diff --git a/lib/crates/fabro-workflow/src/pipeline/transform.rs b/lib/components/fabro-workflow/src/pipeline/transform.rs similarity index 100% rename from lib/crates/fabro-workflow/src/pipeline/transform.rs rename to lib/components/fabro-workflow/src/pipeline/transform.rs diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/components/fabro-workflow/src/pipeline/types.rs similarity index 100% rename from lib/crates/fabro-workflow/src/pipeline/types.rs rename to lib/components/fabro-workflow/src/pipeline/types.rs diff --git a/lib/crates/fabro-workflow/src/pipeline/validate.rs b/lib/components/fabro-workflow/src/pipeline/validate.rs similarity index 100% rename from lib/crates/fabro-workflow/src/pipeline/validate.rs rename to lib/components/fabro-workflow/src/pipeline/validate.rs 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 100% rename from lib/crates/fabro-workflow/src/run_materialization.rs rename to lib/components/fabro-workflow/src/run_materialization.rs 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 100% rename from lib/crates/fabro-workflow/src/test_support.rs rename to lib/components/fabro-workflow/src/test_support.rs 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 100% rename from lib/crates/fabro-workflow/src/transforms/model_resolution.rs rename to lib/components/fabro-workflow/src/transforms/model_resolution.rs 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 100% rename from lib/crates/fabro-workflow/tests/it/integration.rs rename to lib/components/fabro-workflow/tests/it/integration.rs 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 100% rename from lib/crates/fabro-workflow/tests/materialize_run.rs rename to lib/components/fabro-workflow/tests/materialize_run.rs 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 0a81b9845..d6ebad38c 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 100% rename from lib/crates/fabro-api/tests/model_features_round_trip.rs rename to lib/foundation/fabro-api/tests/model_features_round_trip.rs 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 100% rename from lib/crates/fabro-api/tests/model_round_trip.rs rename to lib/foundation/fabro-api/tests/model_round_trip.rs 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 100% rename from lib/crates/fabro-api/tests/provider_id_round_trip.rs rename to lib/foundation/fabro-api/tests/provider_id_round_trip.rs 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 100% rename from lib/crates/fabro-api/tests/session_contract_round_trip.rs rename to lib/foundation/fabro-api/tests/session_contract_round_trip.rs 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/crates/fabro-auth/src/extra_headers_source.rs b/lib/foundation/fabro-auth/src/extra_headers_source.rs similarity index 100% rename from lib/crates/fabro-auth/src/extra_headers_source.rs rename to lib/foundation/fabro-auth/src/extra_headers_source.rs diff --git a/lib/crates/fabro-auth/src/lib.rs b/lib/foundation/fabro-auth/src/lib.rs similarity index 100% rename from lib/crates/fabro-auth/src/lib.rs rename to lib/foundation/fabro-auth/src/lib.rs 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 100% rename from lib/crates/fabro-client/src/client.rs rename to lib/foundation/fabro-client/src/client.rs 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 100% rename from lib/crates/fabro-config/src/builders.rs rename to lib/foundation/fabro-config/src/builders.rs 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 100% rename from lib/crates/fabro-config/src/layers/llm.rs rename to lib/foundation/fabro-config/src/layers/llm.rs 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 100% rename from lib/crates/fabro-config/src/parse.rs rename to lib/foundation/fabro-config/src/parse.rs 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 100% rename from lib/crates/fabro-dev/src/commands/docs_options_reference.rs rename to lib/foundation/fabro-dev/src/commands/docs_options_reference.rs 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 100% rename from lib/crates/fabro-model/src/billing.rs rename to lib/foundation/fabro-model/src/billing.rs 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 100% rename from lib/crates/fabro-model/src/catalog.rs rename to lib/foundation/fabro-model/src/catalog.rs diff --git a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml b/lib/foundation/fabro-model/src/catalog/providers/anthropic.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/anthropic.toml rename to lib/foundation/fabro-model/src/catalog/providers/anthropic.toml 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 100% rename from lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml rename to lib/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/bedrock.toml b/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/bedrock.toml rename to lib/foundation/fabro-model/src/catalog/providers/bedrock.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/gemini.toml b/lib/foundation/fabro-model/src/catalog/providers/gemini.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/gemini.toml rename to lib/foundation/fabro-model/src/catalog/providers/gemini.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/inception.toml b/lib/foundation/fabro-model/src/catalog/providers/inception.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/inception.toml rename to lib/foundation/fabro-model/src/catalog/providers/inception.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/kimi.toml b/lib/foundation/fabro-model/src/catalog/providers/kimi.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/kimi.toml rename to lib/foundation/fabro-model/src/catalog/providers/kimi.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/litellm.toml b/lib/foundation/fabro-model/src/catalog/providers/litellm.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/litellm.toml rename to lib/foundation/fabro-model/src/catalog/providers/litellm.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/minimax.toml b/lib/foundation/fabro-model/src/catalog/providers/minimax.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/minimax.toml rename to lib/foundation/fabro-model/src/catalog/providers/minimax.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/ollama.toml b/lib/foundation/fabro-model/src/catalog/providers/ollama.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/ollama.toml rename to lib/foundation/fabro-model/src/catalog/providers/ollama.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/openai.toml b/lib/foundation/fabro-model/src/catalog/providers/openai.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/openai.toml rename to lib/foundation/fabro-model/src/catalog/providers/openai.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/openrouter.toml rename to lib/foundation/fabro-model/src/catalog/providers/openrouter.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/poolside.toml b/lib/foundation/fabro-model/src/catalog/providers/poolside.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/poolside.toml rename to lib/foundation/fabro-model/src/catalog/providers/poolside.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/venice.toml b/lib/foundation/fabro-model/src/catalog/providers/venice.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/venice.toml rename to lib/foundation/fabro-model/src/catalog/providers/venice.toml diff --git a/lib/crates/fabro-model/src/catalog/providers/zai.toml b/lib/foundation/fabro-model/src/catalog/providers/zai.toml similarity index 100% rename from lib/crates/fabro-model/src/catalog/providers/zai.toml rename to lib/foundation/fabro-model/src/catalog/providers/zai.toml 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 100% rename from lib/crates/fabro-model/src/ids.rs rename to lib/foundation/fabro-model/src/ids.rs diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/foundation/fabro-model/src/lib.rs similarity index 100% rename from lib/crates/fabro-model/src/lib.rs rename to lib/foundation/fabro-model/src/lib.rs diff --git a/lib/crates/fabro-model/src/model_ref.rs b/lib/foundation/fabro-model/src/model_ref.rs similarity index 100% rename from lib/crates/fabro-model/src/model_ref.rs rename to lib/foundation/fabro-model/src/model_ref.rs 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 100% rename from lib/crates/fabro-model/src/types.rs rename to lib/foundation/fabro-model/src/types.rs 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 100% rename from lib/crates/fabro-test/src/lib.rs rename to lib/foundation/fabro-test/src/lib.rs 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/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 100% rename from lib/crates/fabro-types/src/run_event/agent.rs rename to lib/foundation/fabro-types/src/run_event/agent.rs 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 100% rename from lib/crates/fabro-types/src/run_event/mod.rs rename to lib/foundation/fabro-types/src/run_event/mod.rs 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 100% rename from lib/crates/fabro-types/src/run_event/session.rs rename to lib/foundation/fabro-types/src/run_event/session.rs 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 100% rename from lib/crates/fabro-types/src/run_projection.rs rename to lib/foundation/fabro-types/src/run_projection.rs 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 100% rename from lib/crates/fabro-types/src/session.rs rename to lib/foundation/fabro-types/src/session.rs 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 100% rename from lib/crates/fabro-types/src/settings/model_ref.rs rename to lib/foundation/fabro-types/src/settings/model_ref.rs 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 From 5c55b0c074de554ce393c384e6e68bdafc3bbd00 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 18:05:22 -0400 Subject: [PATCH 41/45] Fix stage TODO projection ownership --- lib/crates/fabro-store/src/run_state.rs | 84 +++++++++++-------------- 1 file changed, 37 insertions(+), 47 deletions(-) diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 19c6c6a3d..1b8305f94 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -546,18 +546,27 @@ impl RunProjectionReducer for RunProjection { stage.state = StageState::from(outcome); } EventBody::TodoCreated(props) => { + if !should_project_stage_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_stage_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_stage_todo_event(stored, props.list_kind) { + return Ok(()); + } let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { return Ok(()); }; @@ -681,25 +690,21 @@ 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.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 stage sidebar represents the root stage agent, so child OpenAI plan +/// events do not belong in this projection. Anthropic task lists are +/// root-scoped (`anthropic_tasks:`) and intentionally shared +/// with subagents, so they always project. +fn should_project_stage_todo_event(stored: &RunEvent, list_kind: TodoListKind) -> bool { + let is_child_openai_plan_event = + matches!(list_kind, TodoListKind::OpenAiPlan) && stored.parent_session_id.is_some(); + !is_child_openai_plan_event +} + +fn apply_todo_created(stage: &mut StageProjection, props: &TodoCreatedProps) { if stage .todos .as_ref() @@ -4712,50 +4717,35 @@ 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.todos.is_none(), + "a child session's plan must not become the stage's root plan" + ); } #[test] From e6e6fd21dd77f0dd3733072297ab4ca19d168f34 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 18:09:25 -0400 Subject: [PATCH 42/45] test(llm): add live OpenRouter Claude prompt-caching round trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses run_multi_turn_cache_test — the same live cache verification the anthropic, openai, and gemini routes already have. OpenRouter was the one caching route with no live caller, which is exactly where the missing-breakpoints bug hid: unit and wire tests prove we now send cache_control, but only a live call proves OpenRouter forwards it to Anthropic and cache reads actually appear. Runs with: set -a && source .env && set +a && \ cargo nextest run -p fabro-llm --profile e2e --run-ignored only \ -E 'test(openrouter_claude_multi_turn_cache)' Co-Authored-By: Claude Fable 5 --- lib/crates/fabro-llm/tests/integration.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/crates/fabro-llm/tests/integration.rs b/lib/crates/fabro-llm/tests/integration.rs index 9eb558fd5..25d7e5f00 100644 --- a/lib/crates/fabro-llm/tests/integration.rs +++ b/lib/crates/fabro-llm/tests/integration.rs @@ -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; +} From d8d1c14116ec115fe63e1820627b0b92ba14fcf7 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 18:23:37 -0400 Subject: [PATCH 43/45] Clarify stage TODO ownership and plan guidance --- docs/public/api-reference/fabro-api.yaml | 6 +- lib/crates/fabro-agent/src/profiles/openai.rs | 16 +++ lib/crates/fabro-store/src/run_state.rs | 116 +++++++++++++----- lib/crates/fabro-types/src/run_projection.rs | 11 +- 4 files changed, 115 insertions(+), 34 deletions(-) diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index ca80fd423..97f58cbb4 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -10529,7 +10529,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/lib/crates/fabro-agent/src/profiles/openai.rs b/lib/crates/fabro-agent/src/profiles/openai.rs index ff0215d5c..4a624e3ef 100644 --- a/lib/crates/fabro-agent/src/profiles/openai.rs +++ b/lib/crates/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-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 1b8305f94..347291ac7 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -546,7 +546,7 @@ impl RunProjectionReducer for RunProjection { stage.state = StageState::from(outcome); } EventBody::TodoCreated(props) => { - if !should_project_stage_todo_event(stored, props.list_kind) { + 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 { @@ -555,7 +555,7 @@ impl RunProjectionReducer for RunProjection { apply_todo_created(stage, props); } EventBody::TodoUpdated(props) => { - if !should_project_stage_todo_event(stored, props.list_kind) { + 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 { @@ -564,7 +564,7 @@ impl RunProjectionReducer for RunProjection { apply_todo_updated(stage, props); } EventBody::TodoDeleted(props) => { - if !should_project_stage_todo_event(stored, props.list_kind) { + 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 { @@ -690,32 +690,37 @@ impl RunProjectionReducer for RunProjection { } } -/// Decide whether a TODO event should mutate `StageProjection.todos`. +/// 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 stage sidebar represents the root stage agent, so child OpenAI plan -/// events do not belong in this projection. Anthropic task lists are -/// root-scoped (`anthropic_tasks:`) and intentionally shared -/// with subagents, so they always project. -fn should_project_stage_todo_event(stored: &RunEvent, list_kind: TodoListKind) -> bool { - let is_child_openai_plan_event = - matches!(list_kind, TodoListKind::OpenAiPlan) && stored.parent_session_id.is_some(); - !is_child_openai_plan_event +/// 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, @@ -732,7 +737,7 @@ fn apply_todo_created(stage: &mut StageProjection, props: &TodoCreatedProps) { 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) { @@ -742,7 +747,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 { @@ -750,7 +755,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; } } @@ -4450,11 +4455,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 { @@ -4544,7 +4552,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"); @@ -4579,7 +4587,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"); } @@ -4618,9 +4626,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] @@ -4706,7 +4717,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); @@ -4743,11 +4754,56 @@ mod tests { let stage = state.stage(&stage_id).expect("stage projection present"); assert!( - stage.todos.is_none(), + 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] fn anthropic_child_session_task_events_still_project() { let mut state = initialized_projection(); @@ -4779,7 +4835,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); @@ -4845,7 +4901,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-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs index 6b67282ee..40a30b32c 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/crates/fabro-types/src/run_projection.rs @@ -351,8 +351,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")] @@ -439,7 +444,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, From cdf1dce4f8395762837835c7c54a50ffab6c2a6f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 19:36:29 -0400 Subject: [PATCH 44/45] fix(agent): remove dead execution limits --- docs/internal/events.md | 20 ---- .../fabro-event-schema-v2-concrete-shape.md | 1 - docs/public/agents/subagents.mdx | 3 +- docs/public/changelog/2026-03-23.mdx | 2 +- docs/public/reference/sdk.mdx | 7 +- lib/components/fabro-agent/README.md | 3 - lib/components/fabro-agent/src/config.rs | 11 -- lib/components/fabro-agent/src/session.rs | 107 +++++------------- lib/components/fabro-agent/src/subagent.rs | 62 +++++----- lib/components/fabro-agent/src/types.rs | 6 - .../fabro-agent/tests/it/compaction.rs | 1 - .../fabro-agent/tests/it/parity_matrix.rs | 17 +-- lib/components/fabro-workflow/src/error.rs | 20 +--- .../fabro-workflow/src/event/convert.rs | 6 - .../fabro-workflow/src/event/names.rs | 1 - .../fabro-types/src/run_event/agent.rs | 6 - .../fabro-types/src/run_event/mod.rs | 4 - 17 files changed, 66 insertions(+), 211 deletions(-) diff --git a/docs/internal/events.md b/docs/internal/events.md index 4af5ab491..7850a5411 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -1252,26 +1252,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 77f4fe5da..78134f619 100644 --- a/docs/internal/fabro-event-schema-v2-concrete-shape.md +++ b/docs/internal/fabro-event-schema-v2-concrete-shape.md @@ -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/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/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/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/lib/components/fabro-agent/README.md b/lib/components/fabro-agent/README.md index 5cac1be82..69764459e 100644 --- a/lib/components/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/components/fabro-agent/src/config.rs b/lib/components/fabro-agent/src/config.rs index c54d49f01..bc22fd75c 100644 --- a/lib/components/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/components/fabro-agent/src/session.rs b/lib/components/fabro-agent/src/session.rs index 34986a29d..759aa8884 100644 --- a/lib/components/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 @@ -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/components/fabro-agent/src/subagent.rs b/lib/components/fabro-agent/src/subagent.rs index 52cec77c6..aad7b975d 100644 --- a/lib/components/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/components/fabro-agent/src/types.rs b/lib/components/fabro-agent/src/types.rs index 94cb6808e..d77753519 100644 --- a/lib/components/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/components/fabro-agent/tests/it/compaction.rs b/lib/components/fabro-agent/tests/it/compaction.rs index 084deae6c..14cb88d5e 100644 --- a/lib/components/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/components/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs index 6b60d1cf7..1bd80ed78 100644 --- a/lib/components/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/components/fabro-workflow/src/error.rs b/lib/components/fabro-workflow/src/error.rs index 71ded36ac..75b6d317f 100644 --- a/lib/components/fabro-workflow/src/error.rs +++ b/lib/components/fabro-workflow/src/error.rs @@ -90,8 +90,6 @@ const BUDGET_EXHAUSTED_HINTS: &[&str] = &[ "context length", "budget", "quota exceeded", - "max_turns", - "max turns", "max_tokens", "max tokens", "context window exceeded", @@ -1233,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] @@ -1313,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/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs index 528371880..e5eb1f41d 100644 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -674,12 +674,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(), diff --git a/lib/components/fabro-workflow/src/event/names.rs b/lib/components/fabro-workflow/src/event/names.rs index acfee89e6..5eb339ce8 100644 --- a/lib/components/fabro-workflow/src/event/names.rs +++ b/lib/components/fabro-workflow/src/event/names.rs @@ -81,7 +81,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/foundation/fabro-types/src/run_event/agent.rs b/lib/foundation/fabro-types/src/run_event/agent.rs index 138344f9f..a38c07df2 100644 --- a/lib/foundation/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, diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs index 4aed5b3af..46d63e531 100644 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -220,8 +220,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")] @@ -499,7 +497,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", @@ -671,7 +668,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" From 7c771fc5d754caf7ef72de05667a391064b97f98 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 20:11:21 -0400 Subject: [PATCH 45/45] test(twin-openai): accept tool call histories --- test/twin/openai/src/openai/models.rs | 117 +++++++++++++++--- .../openai/tests/chat_completions_contract.rs | 38 ++++++ 2 files changed, 140 insertions(+), 15 deletions(-) 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");