diff --git a/run.json b/run.json index 0a223c0cd..9e7738fc4 100644 --- a/run.json +++ b/run.json @@ -505,7 +505,7 @@ "kind": "running" }, "status_updated_at": "2026-06-08T18:57:33.473581Z", - "last_event_at": "2026-06-08T20:57:14.538052Z", + "last_event_at": "2026-06-08T21:05:43.019700Z", "pending_control": null, "checkpoints": [ { @@ -1382,9 +1382,9 @@ } }, { - "seq": 0, + "seq": 2281, "checkpoint": { - "timestamp": "2026-06-08T21:05:29.945312Z", + "timestamp": "2026-06-08T21:05:43.019560Z", "current_node": "verify", "completed_nodes": [ "start", @@ -1398,41 +1398,77 @@ ], "node_retries": {}, "context_values": { - "thread.preflight_compile.current_node": "preflight_lint", - "internal.thread_id": "simplify_gpt", - "internal.run_id": "01KTM9H228G0Z1ATDMZ10GGS4W", - "thread.implement.current_node": "simplify_opus", - "thread.simplify_opus.current_node": "simplify_gpt", - "failure_class": "", - "internal.retry_count.simplify_opus": 0, - "internal.retry_count.preflight_compile": 0, - "command.output": "blob://sha256/e047188aabc61830dab219914b8088e6496e14ae0bc8f73e0aece5a9cdf49016", - "thread.preflight_lint.current_node": "implement", - "thread.toolchain.current_node": "preflight_compile", - "internal.retry_count.preflight_lint": 0, - "graph.rankdir": "LR", "response.implement": "Implemented the plan in the requested two commits, in order:\n\n1. `199c93c5e Remove anonymous principal actor`\n - Removed `Principal::Anonymous`.\n - Request auth now represents no actor as `None`.\n - HTTP logs use `principal_kind=\"none\"` for absent principals.\n - Updated OpenAPI/generated TS client/frontend/tests accordingly.\n\n2. `fffe671dd Make run provenance and creator total`\n - Made `Run.created_by`, `RunSpec.provenance`, `RunProvenance.subject`, and `run.created` provenance non-optional end-to-end.\n - Removed null/default/fallback provenance handling.\n - Updated create/retry/projection flows to require and propagate total provenance.\n - Added gated `fabro-types` test support helpers.\n - Added synthetic demo principal.\n - Updated OpenAPI and regenerated generated clients.\n - Updated frontend models/rendering/tests for total `created_by`.\n\nFinal repo state is clean.\n\nValidation completed:\n- `cargo build -p fabro-api`\n- TypeScript API client generation with local JRE\n- `cargo check -q --workspace --all-targets`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cargo build --workspace`\n- `cargo nextest run --workspace --no-fail-fast`\n- `cargo nextest run -p fabro-server`\n- `cd apps/fabro-web && bun run typecheck && bun test && bun run build`\n- `cd lib/packages/fabro-api-client && bun run typecheck`\n- Final grep sweep for stale anonymous/null provenance traces; remaining hits are unrelated non-run-provenance `subject` fields.\n\nManual browser/server end-to-end checks from the plan were not run in this terminal environment.", - "graph.goal": "# Plan: Make run actors and provenance total\n\n## Context\n\nThis is a greenfield app. Backward compatibility with old serialized runs, old API clients, old generated models, and old tests is not a constraint. Prefer the clean invariant and remove all traces of the placeholder shape.\n\n`Principal::Anonymous` currently represents \"no authenticated actor on this request\" inside auth middleware. That is auth state, not an actor. A `Principal` should only mean \"who acted.\"\n\nLikewise, a persisted run should always have a creator. `Run.created_by`, `RunSpec.provenance`, `RunProvenance.subject`, and `run.created` event provenance should all be total. No `Option`, no nullable OpenAPI fields, no legacy deserialization defaults, and no fallback creator in projection code.\n\nTwo commits, in order.\n\n---\n\n## Commit 1 - Remove `Principal::Anonymous`\n\nBreaking cleanup. `Principal` becomes actor-only. Missing/invalid auth is represented as absent request principal, not as an anonymous principal variant.\n\n### Rust\n\n`lib/crates/fabro-types/src/principal.rs`:\n- Drop `Anonymous`.\n- Drop `Anonymous` arms in `kind()` and `display()`.\n- Delete anonymous serialization/round-trip test coverage.\n\n`lib/crates/fabro-server/src/principal_middleware.rs`:\n- `RequestAuthContext.principal: Principal` -> `Option`.\n- `RequestAuthLogContext.principal: Principal` -> `Option`.\n- `initial()` and `rejected()` set `principal: None`.\n- `authenticated(...)`, `authenticated_worker(...)`, and `authenticated_user(...)` set `principal: Some(...)`.\n- Update `principal_without_log_unused_fields` to preserve `None` and strip user avatar data only inside `Some(Principal::User(...))`.\n- Update all gate helpers to match `Option`:\n - `require_user`\n - `require_authenticated_user`\n - `require_run_management_actor`\n - `require_worker_or_user_for_run`\n - `require_run_management_target`\n- `None` routes to the existing `auth_rejection(context.auth_status, context.auth_error_code)` behavior.\n- `Some(Principal::Worker { .. })` keeps the current forbidden-vs-auth-rejection distinctions.\n- Update tests that assert the initial/rejected principal to assert `None`.\n\n`lib/crates/fabro-server/src/server.rs` HTTP logging:\n- Keep the `principal_kind` field on every HTTP log line.\n- Compute `principal_kind` as `auth_context.principal.as_ref().map(Principal::kind).unwrap_or(\"none\")`.\n- Match `auth_context.principal` as an `Option`:\n - `Some(User(...))`, `Some(Worker { ... })`, `Some(Webhook { ... })`, `Some(Slack { ... })` keep their extra fields.\n - `None | Some(Agent { .. } | System { .. })` emits only the common HTTP fields.\n\n`docs/internal/logging-strategy.md`:\n- Replace the `anonymous` HTTP caller category guidance with `none` for requests that have no principal.\n- Keep `auth_status` as the field that distinguishes missing, invalid, expired, and authenticated auth state.\n\n### OpenAPI and generated clients\n\n`docs/public/api-reference/fabro-api.yaml`:\n- Remove `PrincipalAnonymous` from the `Principal` `oneOf`.\n- Remove `anonymous` from the `Principal` discriminator mapping.\n- Delete the `PrincipalAnonymous` schema.\n\nRegenerate:\n- `cargo build -p fabro-api`\n- `cd lib/packages/fabro-api-client && bun run generate`\n\nExpected generated cleanup:\n- `lib/packages/fabro-api-client/src/models/principal-anonymous.ts` disappears.\n- `Principal` union no longer includes `{ kind: \"anonymous\" }`.\n- `lib/packages/fabro-api-client/src/models/index.ts` no longer exports `principal-anonymous`.\n\n### Frontend\n\n`apps/fabro-web/app/lib/principal-display.tsx`:\n- Remove the `\"anonymous\"` switch case and unused icon import.\n\n`apps/fabro-web/app/components/run-summary-panel.test.tsx` and API-client exhaustiveness tests:\n- Remove anonymous principal cases.\n\n### Documentation sweep\n\nRemove anonymous-principal references from product/API docs and tests. Be careful not to touch unrelated uses of \"anonymous\" such as telemetry anonymous IDs or Git's `remote_anonymous` API.\n\nUseful sweep:\n- `rg -n \"Principal::Anonymous|PrincipalAnonymous|kind: 'anonymous'|kind: \\\"anonymous\\\"|anonymous actor|anonymous subject|principal_kind.*anonymous|\\\"anonymous\\\"\" lib/crates apps/fabro-web lib/packages/fabro-api-client docs/public docs/internal`\n\n### Verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo build --workspace`\n- `cargo nextest run --workspace`\n- `cd apps/fabro-web && bun run typecheck && bun test`\n- Manual: start `fabro server start`, hit a protected endpoint without a token, confirm 401 and an HTTP log with `principal_kind=\"none\"` and `auth_status=\"missing\"`.\n\n---\n\n## Commit 2 - Make run provenance and creator non-optional\n\nFull-chain invariant. Every persisted run has exactly one creator principal. No nullable schema fields, no legacy defaults, no projection fallbacks.\n\n### Core type changes\n\n`lib/crates/fabro-types/src/run_summary.rs`:\n- `Run.created_by: Option` -> `Principal`.\n- Drop `#[serde(default)]`.\n\n`lib/crates/fabro-types/src/run.rs`:\n- `RunProvenance.subject: Option` -> `Principal`.\n- Drop `#[serde(default, skip_serializing_if = \"Option::is_none\")]`.\n- Drop `Default` derive on `RunProvenance`.\n- `RunSpec.provenance: Option` -> `RunProvenance`.\n- Drop `#[serde(default, skip_serializing_if = \"Option::is_none\")]` on `RunSpec.provenance`.\n\n`lib/crates/fabro-types/src/run_event/run.rs`:\n- `RunCreatedProps.provenance: Option` -> `RunProvenance`.\n- Drop default/skip serialization attributes for provenance.\n\n`lib/crates/fabro-workflow/src/event/events.rs`:\n- `Event::RunCreated.provenance: Option` -> `RunProvenance`.\n- Drop default/skip serialization attributes for provenance.\n\n### Creation and retry flow\n\n`lib/crates/fabro-workflow/src/operations/create.rs`:\n- `CreateRunInput.provenance: Option` -> `RunProvenance`.\n- `PersistCreateOptions.provenance: Option` -> `RunProvenance`.\n- `RunSpec { provenance }` stores the total provenance directly.\n- `Event::RunCreated { provenance }` emits total provenance directly.\n\n`lib/crates/fabro-server/src/server/handler/runs.rs`:\n- `run_provenance(headers, subject)` returns `RunProvenance { subject: subject.clone(), ... }`.\n- Build provenance before creating `CreateRunInput`.\n\n`lib/crates/fabro-server/src/run_manifest.rs`:\n- Change `create_run_input(...)` to accept `provenance: RunProvenance` and set it directly, or stop using the helper for the final `CreateRunInput` construction. Do not create a temporary input with missing provenance.\n\n`lib/crates/fabro-workflow/src/operations/retry.rs`:\n- `RetryRunInput.provenance: Option` -> `RunProvenance`.\n- `retry_run(...)` writes the new run's `run.created` event with total provenance.\n\n`lib/crates/fabro-server/src/server/handler/lifecycle.rs`:\n- Pass `run_provenance(&headers, &actor)` directly into `RetryRunInput`.\n\n### Event conversion and projections\n\n`lib/crates/fabro-workflow/src/event/convert.rs`:\n- Convert `Event::RunCreated.provenance` into `RunCreatedProps.provenance` directly.\n- Remove `Some(...)` wrapping for run-created provenance.\n\n`lib/crates/fabro-workflow/src/event/stored_fields.rs`:\n- `Event::RunCreated { provenance, .. }` sets `actor: Some(provenance.subject.clone())`.\n\n`lib/crates/fabro-store/src/run_state.rs`:\n- `projection_from_created(...)` builds `RunSpec { provenance: props.provenance.clone(), ... }`.\n- `build_summary(...)` sets `created_by: state.spec.provenance.subject.clone()`.\n- Delete or rewrite tests that deserialize projections with `\"provenance\": null`.\n\n`lib/crates/fabro-types/src/run_projection.rs` and projection tests:\n- Replace all test `RunSpec` literals with total provenance.\n- Remove tests whose only purpose is legacy/null provenance tolerance.\n\n### OpenAPI\n\n`docs/public/api-reference/fabro-api.yaml`:\n- `Run.created_by` references `Principal` directly. Remove `oneOf [..., null]`.\n- `RunProvenance.required` includes `subject`.\n- `RunProvenance.subject` references `Principal` directly. Remove `oneOf [..., null]`.\n- `RunSpec.required` includes `provenance`.\n- `RunSpec.provenance` references `RunProvenance` directly. Remove `oneOf [..., null]`.\n- If `run.created` event properties are represented separately in the spec, make that event provenance required and non-nullable too.\n\nRegenerate:\n- `cargo build -p fabro-api`\n- `cd lib/packages/fabro-api-client && bun run generate`\n\nDo not hand-edit generated client files.\n\n### Demo mode\n\n`lib/crates/fabro-server/src/demo/mod.rs`:\n- Add a clearly synthetic demo principal using `AuthMethod::DevToken`, not GitHub:\n ```rust\n static DEMO_PRINCIPAL: LazyLock = LazyLock::new(|| {\n Principal::user(\n IdpIdentity::new(\"fabro:demo\", \"demo\").unwrap(),\n \"demo\".to_string(),\n AuthMethod::DevToken,\n )\n });\n ```\n- Replace `created_by: None` with `created_by: DEMO_PRINCIPAL.clone()`.\n- If demo creates any full `RunSpec` or `run.created` event data, give it `RunProvenance { subject: DEMO_PRINCIPAL.clone(), ... }`.\n\n### Test support\n\nDo not add fake auth helpers to `fabro_types::fixtures`; that module is run-id constants.\n\nUse the existing `fabro-types` `test-support` feature:\n- Add `#[cfg(any(test, feature = \"test-support\"))] pub mod test_support;` in `lib/crates/fabro-types/src/lib.rs` if it does not already exist.\n- Add `lib/crates/fabro-types/src/test_support.rs` with:\n - `test_principal() -> Principal`\n - `test_run_provenance() -> RunProvenance`\n- Use an obviously fake dev-token identity, e.g. issuer `fabro:test`, subject `test-user`, login `test`.\n- In crates that need the helper from integration tests or cross-crate tests, dual-list `fabro-types` in `dev-dependencies` with `features = [\"test-support\"]`, following existing repo patterns.\n\nUpdate all constructors:\n- Replace `provenance: None` in `RunSpec`, `CreateRunInput`, `RetryRunInput`, `Event::RunCreated`, and `RunCreatedProps` literals with `test_run_provenance()` or a locally meaningful provenance.\n- Replace `subject: Some(...)` with `subject: ...`.\n- Replace `subject: None` only when it is actually `RunProvenance.subject`; leave unrelated todo/commit/message `subject` fields alone.\n- Replace `created_by: None` / `created_by: null` with `test_principal()` or a frontend TS principal fixture.\n- Delete tests that assert nullable or omitted creator/provenance behavior.\n\nRepresentative Rust areas:\n- `lib/crates/fabro-store/src/run_state.rs`\n- `lib/crates/fabro-store/tests/serializable_projection.rs`\n- `lib/crates/fabro-workflow/src/operations/{create,retry,start}.rs`\n- `lib/crates/fabro-workflow/src/event/{convert,sink,stored_fields}.rs`\n- `lib/crates/fabro-workflow/src/handler/**`\n- `lib/crates/fabro-workflow/src/pipeline/**`\n- `lib/crates/fabro-workflow/src/run_{lookup,metadata}.rs`\n- `lib/crates/fabro-server/src/server/tests.rs`\n- `lib/crates/fabro-server/src/server/handler/**`\n- `lib/crates/fabro-server/tests/it/**`\n- `lib/crates/fabro-cli/tests/it/support/mod.rs`\n- `lib/crates/fabro-dump/src/lib.rs`\n- `lib/crates/fabro-tool/src/{common,create,interact,search}.rs`\n- `lib/crates/fabro-api/tests/{principal_round_trip,run_summary_round_trip,run_projection_round_trip,run_event_round_trip}.rs`\n- `lib/crates/fabro-types/tests/{run_spec_serde,run_spec_methods,run_event_serde}.rs`\n\nRepresentative TypeScript areas:\n- `apps/fabro-web/app/**` tests with `created_by: null`\n- `apps/fabro-web/app/data/runs.ts`\n- `apps/fabro-web/app/components/run-summary-panel.tsx`\n- `apps/fabro-web/app/components/runs-list/**`\n- `lib/packages/fabro-api-client/tests/principal-exhaustive.ts`\n\nUseful sweep after edits:\n- `rg -n \"Principal::Anonymous|PrincipalAnonymous|principal-anonymous|kind: ['\\\"]anonymous|created_by:\\\\s*(None|null)|provenance:\\\\s*None|subject:\\\\s*Some\\\\(|subject:\\\\s*None\" lib/crates apps/fabro-web lib/packages/fabro-api-client docs/public docs/internal`\n\nReview each hit. The only acceptable remaining matches should be unrelated uses of \"anonymous\" and unrelated non-principal `subject` fields.\n\n### Frontend\n\n`apps/fabro-web/app/components/run-summary-panel.tsx`:\n- `run?.created_by` may still be guarded by `run` loading state, but `created_by` itself is non-null once `run` exists.\n- Pass `run.created_by` directly to `principalDisplay(...)` inside loaded-run branches.\n\n`apps/fabro-web/app/data/runs.ts` and run-list components:\n- Treat `createdBy` as a total principal in UI data derived from a loaded API run.\n- Remove empty/fallback rendering that only existed for missing creator data.\n\n### Verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo build --workspace`\n- `cargo nextest run --workspace`\n- `cargo nextest run -p fabro-server`\n- `cd apps/fabro-web && bun run typecheck && bun test && bun run build`\n- Manual end-to-end:\n - `fabro server start`\n - `cd apps/fabro-web && bun run dev`\n - Authenticate and create a run through the UI.\n - Confirm `/api/v1/runs/:id` has non-null `created_by`.\n - Confirm `/api/v1/runs/:id/state` has non-null `spec.provenance.subject`.\n - Retry a failed run and confirm the retried run has the retrying user as creator.\n - Hit demo mode with `X-Fabro-Demo: 1` and confirm the run summary renders the synthetic `demo` dev-token user.\n", - "internal.work_dir": "/home/daytona/workspace/fabro", - "internal.node_visit_count": 1, - "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "last_response": "Cleanup review completed. I found and fixed three small issues:\n\n- Moved `TEST_PRINCIPAL` import in `app/lib/run-actions.test.ts` back with the other imports.\n- Removed an unnecessary explanatory comm", "thread.simplify_gpt.current_node": "verify", - "internal.retry_count.start": 0, - "response.simplify_opus": "All checks green. Summary of cleanup:\n\n**Fixed:**\n\n1. **Duplicate `TEST_PRINCIPAL` literal across 8 frontend test files** — extracted to `apps/fabro-web/app/lib/test-fixtures.ts` (typed as `Principal` from the API client). All 8 test files now import it instead of redeclaring.\n\n2. **Stale assertion + misleading test name** in `run-summary-panel.test.tsx` — removed the `expect(... \"Created by\"))).toBe(\"Ttest\")` line that no longer fit the \"shows unavailable copy\" semantics, and dropped the leftover \"optional\" wording from the test title.\n\n3. **Leftover IIFE wrapper** in `run-table-row.tsx` — hoisted `principalDisplay(run.createdBy)` to a `const createdByDisplay = …` at the top of the component and inlined the `` directly. Removed the no-longer-needed self-invoking-function shape that existed only for the old `run.createdBy && …` guard.\n\n4. **`run_created_properties` compat-shim** in `fabro-store/src/run_state.rs` — removed. The eight `test_raw_event(… \"run.created\" …)` JSON fixtures now include `\"provenance\": test_support::test_run_provenance()` explicitly, so the conditional injection of provenance no longer hides untouched fixtures.\n\n**Skipped (false positive / out-of-scope):**\n\n- The duplicate local `test_run_provenance()` in `fabro-workflow/src/test_support.rs` looked redundant, but the workflow crate exposes `pub mod test_support;` *unconditionally* while `fabro_types::test_support` is feature-gated and only enabled as a dev-dependency. Replacing the local copy broke the library build, so the duplicate stays.\n- Demo `LazyLock` — correct as-is.\n- `Option` in `principal_middleware.rs` — intentional per the plan (\"missing/invalid auth is represented as absent request principal\").\n\n**Verified:** `cargo check --workspace`, `cargo clippy --workspace --all-targets -D warnings`, `cargo fmt --check`, `cargo nextest run --workspace` (6500 pass), `bun run typecheck`, `bun test` (508 pass).", + "graph.goal": "# Plan: Make run actors and provenance total\n\n## Context\n\nThis is a greenfield app. Backward compatibility with old serialized runs, old API clients, old generated models, and old tests is not a constraint. Prefer the clean invariant and remove all traces of the placeholder shape.\n\n`Principal::Anonymous` currently represents \"no authenticated actor on this request\" inside auth middleware. That is auth state, not an actor. A `Principal` should only mean \"who acted.\"\n\nLikewise, a persisted run should always have a creator. `Run.created_by`, `RunSpec.provenance`, `RunProvenance.subject`, and `run.created` event provenance should all be total. No `Option`, no nullable OpenAPI fields, no legacy deserialization defaults, and no fallback creator in projection code.\n\nTwo commits, in order.\n\n---\n\n## Commit 1 - Remove `Principal::Anonymous`\n\nBreaking cleanup. `Principal` becomes actor-only. Missing/invalid auth is represented as absent request principal, not as an anonymous principal variant.\n\n### Rust\n\n`lib/crates/fabro-types/src/principal.rs`:\n- Drop `Anonymous`.\n- Drop `Anonymous` arms in `kind()` and `display()`.\n- Delete anonymous serialization/round-trip test coverage.\n\n`lib/crates/fabro-server/src/principal_middleware.rs`:\n- `RequestAuthContext.principal: Principal` -> `Option`.\n- `RequestAuthLogContext.principal: Principal` -> `Option`.\n- `initial()` and `rejected()` set `principal: None`.\n- `authenticated(...)`, `authenticated_worker(...)`, and `authenticated_user(...)` set `principal: Some(...)`.\n- Update `principal_without_log_unused_fields` to preserve `None` and strip user avatar data only inside `Some(Principal::User(...))`.\n- Update all gate helpers to match `Option`:\n - `require_user`\n - `require_authenticated_user`\n - `require_run_management_actor`\n - `require_worker_or_user_for_run`\n - `require_run_management_target`\n- `None` routes to the existing `auth_rejection(context.auth_status, context.auth_error_code)` behavior.\n- `Some(Principal::Worker { .. })` keeps the current forbidden-vs-auth-rejection distinctions.\n- Update tests that assert the initial/rejected principal to assert `None`.\n\n`lib/crates/fabro-server/src/server.rs` HTTP logging:\n- Keep the `principal_kind` field on every HTTP log line.\n- Compute `principal_kind` as `auth_context.principal.as_ref().map(Principal::kind).unwrap_or(\"none\")`.\n- Match `auth_context.principal` as an `Option`:\n - `Some(User(...))`, `Some(Worker { ... })`, `Some(Webhook { ... })`, `Some(Slack { ... })` keep their extra fields.\n - `None | Some(Agent { .. } | System { .. })` emits only the common HTTP fields.\n\n`docs/internal/logging-strategy.md`:\n- Replace the `anonymous` HTTP caller category guidance with `none` for requests that have no principal.\n- Keep `auth_status` as the field that distinguishes missing, invalid, expired, and authenticated auth state.\n\n### OpenAPI and generated clients\n\n`docs/public/api-reference/fabro-api.yaml`:\n- Remove `PrincipalAnonymous` from the `Principal` `oneOf`.\n- Remove `anonymous` from the `Principal` discriminator mapping.\n- Delete the `PrincipalAnonymous` schema.\n\nRegenerate:\n- `cargo build -p fabro-api`\n- `cd lib/packages/fabro-api-client && bun run generate`\n\nExpected generated cleanup:\n- `lib/packages/fabro-api-client/src/models/principal-anonymous.ts` disappears.\n- `Principal` union no longer includes `{ kind: \"anonymous\" }`.\n- `lib/packages/fabro-api-client/src/models/index.ts` no longer exports `principal-anonymous`.\n\n### Frontend\n\n`apps/fabro-web/app/lib/principal-display.tsx`:\n- Remove the `\"anonymous\"` switch case and unused icon import.\n\n`apps/fabro-web/app/components/run-summary-panel.test.tsx` and API-client exhaustiveness tests:\n- Remove anonymous principal cases.\n\n### Documentation sweep\n\nRemove anonymous-principal references from product/API docs and tests. Be careful not to touch unrelated uses of \"anonymous\" such as telemetry anonymous IDs or Git's `remote_anonymous` API.\n\nUseful sweep:\n- `rg -n \"Principal::Anonymous|PrincipalAnonymous|kind: 'anonymous'|kind: \\\"anonymous\\\"|anonymous actor|anonymous subject|principal_kind.*anonymous|\\\"anonymous\\\"\" lib/crates apps/fabro-web lib/packages/fabro-api-client docs/public docs/internal`\n\n### Verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo build --workspace`\n- `cargo nextest run --workspace`\n- `cd apps/fabro-web && bun run typecheck && bun test`\n- Manual: start `fabro server start`, hit a protected endpoint without a token, confirm 401 and an HTTP log with `principal_kind=\"none\"` and `auth_status=\"missing\"`.\n\n---\n\n## Commit 2 - Make run provenance and creator non-optional\n\nFull-chain invariant. Every persisted run has exactly one creator principal. No nullable schema fields, no legacy defaults, no projection fallbacks.\n\n### Core type changes\n\n`lib/crates/fabro-types/src/run_summary.rs`:\n- `Run.created_by: Option` -> `Principal`.\n- Drop `#[serde(default)]`.\n\n`lib/crates/fabro-types/src/run.rs`:\n- `RunProvenance.subject: Option` -> `Principal`.\n- Drop `#[serde(default, skip_serializing_if = \"Option::is_none\")]`.\n- Drop `Default` derive on `RunProvenance`.\n- `RunSpec.provenance: Option` -> `RunProvenance`.\n- Drop `#[serde(default, skip_serializing_if = \"Option::is_none\")]` on `RunSpec.provenance`.\n\n`lib/crates/fabro-types/src/run_event/run.rs`:\n- `RunCreatedProps.provenance: Option` -> `RunProvenance`.\n- Drop default/skip serialization attributes for provenance.\n\n`lib/crates/fabro-workflow/src/event/events.rs`:\n- `Event::RunCreated.provenance: Option` -> `RunProvenance`.\n- Drop default/skip serialization attributes for provenance.\n\n### Creation and retry flow\n\n`lib/crates/fabro-workflow/src/operations/create.rs`:\n- `CreateRunInput.provenance: Option` -> `RunProvenance`.\n- `PersistCreateOptions.provenance: Option` -> `RunProvenance`.\n- `RunSpec { provenance }` stores the total provenance directly.\n- `Event::RunCreated { provenance }` emits total provenance directly.\n\n`lib/crates/fabro-server/src/server/handler/runs.rs`:\n- `run_provenance(headers, subject)` returns `RunProvenance { subject: subject.clone(), ... }`.\n- Build provenance before creating `CreateRunInput`.\n\n`lib/crates/fabro-server/src/run_manifest.rs`:\n- Change `create_run_input(...)` to accept `provenance: RunProvenance` and set it directly, or stop using the helper for the final `CreateRunInput` construction. Do not create a temporary input with missing provenance.\n\n`lib/crates/fabro-workflow/src/operations/retry.rs`:\n- `RetryRunInput.provenance: Option` -> `RunProvenance`.\n- `retry_run(...)` writes the new run's `run.created` event with total provenance.\n\n`lib/crates/fabro-server/src/server/handler/lifecycle.rs`:\n- Pass `run_provenance(&headers, &actor)` directly into `RetryRunInput`.\n\n### Event conversion and projections\n\n`lib/crates/fabro-workflow/src/event/convert.rs`:\n- Convert `Event::RunCreated.provenance` into `RunCreatedProps.provenance` directly.\n- Remove `Some(...)` wrapping for run-created provenance.\n\n`lib/crates/fabro-workflow/src/event/stored_fields.rs`:\n- `Event::RunCreated { provenance, .. }` sets `actor: Some(provenance.subject.clone())`.\n\n`lib/crates/fabro-store/src/run_state.rs`:\n- `projection_from_created(...)` builds `RunSpec { provenance: props.provenance.clone(), ... }`.\n- `build_summary(...)` sets `created_by: state.spec.provenance.subject.clone()`.\n- Delete or rewrite tests that deserialize projections with `\"provenance\": null`.\n\n`lib/crates/fabro-types/src/run_projection.rs` and projection tests:\n- Replace all test `RunSpec` literals with total provenance.\n- Remove tests whose only purpose is legacy/null provenance tolerance.\n\n### OpenAPI\n\n`docs/public/api-reference/fabro-api.yaml`:\n- `Run.created_by` references `Principal` directly. Remove `oneOf [..., null]`.\n- `RunProvenance.required` includes `subject`.\n- `RunProvenance.subject` references `Principal` directly. Remove `oneOf [..., null]`.\n- `RunSpec.required` includes `provenance`.\n- `RunSpec.provenance` references `RunProvenance` directly. Remove `oneOf [..., null]`.\n- If `run.created` event properties are represented separately in the spec, make that event provenance required and non-nullable too.\n\nRegenerate:\n- `cargo build -p fabro-api`\n- `cd lib/packages/fabro-api-client && bun run generate`\n\nDo not hand-edit generated client files.\n\n### Demo mode\n\n`lib/crates/fabro-server/src/demo/mod.rs`:\n- Add a clearly synthetic demo principal using `AuthMethod::DevToken`, not GitHub:\n ```rust\n static DEMO_PRINCIPAL: LazyLock = LazyLock::new(|| {\n Principal::user(\n IdpIdentity::new(\"fabro:demo\", \"demo\").unwrap(),\n \"demo\".to_string(),\n AuthMethod::DevToken,\n )\n });\n ```\n- Replace `created_by: None` with `created_by: DEMO_PRINCIPAL.clone()`.\n- If demo creates any full `RunSpec` or `run.created` event data, give it `RunProvenance { subject: DEMO_PRINCIPAL.clone(), ... }`.\n\n### Test support\n\nDo not add fake auth helpers to `fabro_types::fixtures`; that module is run-id constants.\n\nUse the existing `fabro-types` `test-support` feature:\n- Add `#[cfg(any(test, feature = \"test-support\"))] pub mod test_support;` in `lib/crates/fabro-types/src/lib.rs` if it does not already exist.\n- Add `lib/crates/fabro-types/src/test_support.rs` with:\n - `test_principal() -> Principal`\n - `test_run_provenance() -> RunProvenance`\n- Use an obviously fake dev-token identity, e.g. issuer `fabro:test`, subject `test-user`, login `test`.\n- In crates that need the helper from integration tests or cross-crate tests, dual-list `fabro-types` in `dev-dependencies` with `features = [\"test-support\"]`, following existing repo patterns.\n\nUpdate all constructors:\n- Replace `provenance: None` in `RunSpec`, `CreateRunInput`, `RetryRunInput`, `Event::RunCreated`, and `RunCreatedProps` literals with `test_run_provenance()` or a locally meaningful provenance.\n- Replace `subject: Some(...)` with `subject: ...`.\n- Replace `subject: None` only when it is actually `RunProvenance.subject`; leave unrelated todo/commit/message `subject` fields alone.\n- Replace `created_by: None` / `created_by: null` with `test_principal()` or a frontend TS principal fixture.\n- Delete tests that assert nullable or omitted creator/provenance behavior.\n\nRepresentative Rust areas:\n- `lib/crates/fabro-store/src/run_state.rs`\n- `lib/crates/fabro-store/tests/serializable_projection.rs`\n- `lib/crates/fabro-workflow/src/operations/{create,retry,start}.rs`\n- `lib/crates/fabro-workflow/src/event/{convert,sink,stored_fields}.rs`\n- `lib/crates/fabro-workflow/src/handler/**`\n- `lib/crates/fabro-workflow/src/pipeline/**`\n- `lib/crates/fabro-workflow/src/run_{lookup,metadata}.rs`\n- `lib/crates/fabro-server/src/server/tests.rs`\n- `lib/crates/fabro-server/src/server/handler/**`\n- `lib/crates/fabro-server/tests/it/**`\n- `lib/crates/fabro-cli/tests/it/support/mod.rs`\n- `lib/crates/fabro-dump/src/lib.rs`\n- `lib/crates/fabro-tool/src/{common,create,interact,search}.rs`\n- `lib/crates/fabro-api/tests/{principal_round_trip,run_summary_round_trip,run_projection_round_trip,run_event_round_trip}.rs`\n- `lib/crates/fabro-types/tests/{run_spec_serde,run_spec_methods,run_event_serde}.rs`\n\nRepresentative TypeScript areas:\n- `apps/fabro-web/app/**` tests with `created_by: null`\n- `apps/fabro-web/app/data/runs.ts`\n- `apps/fabro-web/app/components/run-summary-panel.tsx`\n- `apps/fabro-web/app/components/runs-list/**`\n- `lib/packages/fabro-api-client/tests/principal-exhaustive.ts`\n\nUseful sweep after edits:\n- `rg -n \"Principal::Anonymous|PrincipalAnonymous|principal-anonymous|kind: ['\\\"]anonymous|created_by:\\\\s*(None|null)|provenance:\\\\s*None|subject:\\\\s*Some\\\\(|subject:\\\\s*None\" lib/crates apps/fabro-web lib/packages/fabro-api-client docs/public docs/internal`\n\nReview each hit. The only acceptable remaining matches should be unrelated uses of \"anonymous\" and unrelated non-principal `subject` fields.\n\n### Frontend\n\n`apps/fabro-web/app/components/run-summary-panel.tsx`:\n- `run?.created_by` may still be guarded by `run` loading state, but `created_by` itself is non-null once `run` exists.\n- Pass `run.created_by` directly to `principalDisplay(...)` inside loaded-run branches.\n\n`apps/fabro-web/app/data/runs.ts` and run-list components:\n- Treat `createdBy` as a total principal in UI data derived from a loaded API run.\n- Remove empty/fallback rendering that only existed for missing creator data.\n\n### Verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo build --workspace`\n- `cargo nextest run --workspace`\n- `cargo nextest run -p fabro-server`\n- `cd apps/fabro-web && bun run typecheck && bun test && bun run build`\n- Manual end-to-end:\n - `fabro server start`\n - `cd apps/fabro-web && bun run dev`\n - Authenticate and create a run through the UI.\n - Confirm `/api/v1/runs/:id` has non-null `created_by`.\n - Confirm `/api/v1/runs/:id/state` has non-null `spec.provenance.subject`.\n - Retry a failed run and confirm the retried run has the retrying user as creator.\n - Hit demo mode with `X-Fabro-Demo: 1` and confirm the run summary renders the synthetic `demo` dev-token user.\n", + "internal.retry_count.verify": 0, + "thread.simplify_opus.current_node": "simplify_gpt", "failure_signature": "", "current_node": "verify", - "internal.retry_count.verify": 0, - "internal.retry_count.simplify_gpt": 0, - "last_response": "Cleanup review completed. I found and fixed three small issues:\n\n- Moved `TEST_PRINCIPAL` import in `app/lib/run-actions.test.ts` back with the other imports.\n- Removed an unnecessary explanatory comm", - "internal.fidelity": "compact", - "thread.start.current_node": "toolchain", - "internal.retry_count.toolchain": 0, + "outcome": "succeeded", + "thread.preflight_lint.current_node": "implement", + "internal.run_id": "01KTM9H228G0Z1ATDMZ10GGS4W", "response.simplify_gpt": "Cleanup review completed. I found and fixed three small issues:\n\n- Moved `TEST_PRINCIPAL` import in `app/lib/run-actions.test.ts` back with the other imports.\n- Removed an unnecessary explanatory comment from `app/lib/test-fixtures.ts`.\n- Avoided computing `principalDisplay(run.createdBy)` when the `created_by` column is hidden by extracting a small `CreatedByCell` component.\n\nVerification run:\n\n- `cd apps/fabro-web && bun run typecheck`\n- Targeted `bun test` for the touched frontend test files: 104 passed, 0 failed.\n- `git diff --check` passed.\n\nCurrent working tree has the three cleanup modifications above.", "internal.retry_count.implement": 0, - "outcome": "succeeded", - "last_stage": "simplify_gpt" + "internal.retry_count.preflight_lint": 0, + "internal.thread_id": "simplify_gpt", + "command.output": "blob://sha256/e047188aabc61830dab219914b8088e6496e14ae0bc8f73e0aece5a9cdf49016", + "response.simplify_opus": "All checks green. Summary of cleanup:\n\n**Fixed:**\n\n1. **Duplicate `TEST_PRINCIPAL` literal across 8 frontend test files** — extracted to `apps/fabro-web/app/lib/test-fixtures.ts` (typed as `Principal` from the API client). All 8 test files now import it instead of redeclaring.\n\n2. **Stale assertion + misleading test name** in `run-summary-panel.test.tsx` — removed the `expect(... \"Created by\"))).toBe(\"Ttest\")` line that no longer fit the \"shows unavailable copy\" semantics, and dropped the leftover \"optional\" wording from the test title.\n\n3. **Leftover IIFE wrapper** in `run-table-row.tsx` — hoisted `principalDisplay(run.createdBy)` to a `const createdByDisplay = …` at the top of the component and inlined the `` directly. Removed the no-longer-needed self-invoking-function shape that existed only for the old `run.createdBy && …` guard.\n\n4. **`run_created_properties` compat-shim** in `fabro-store/src/run_state.rs` — removed. The eight `test_raw_event(… \"run.created\" …)` JSON fixtures now include `\"provenance\": test_support::test_run_provenance()` explicitly, so the conditional injection of provenance no longer hides untouched fixtures.\n\n**Skipped (false positive / out-of-scope):**\n\n- The duplicate local `test_run_provenance()` in `fabro-workflow/src/test_support.rs` looked redundant, but the workflow crate exposes `pub mod test_support;` *unconditionally* while `fabro_types::test_support` is feature-gated and only enabled as a dev-dependency. Replacing the local copy broke the library build, so the duplicate stays.\n- Demo `LazyLock` — correct as-is.\n- `Option` in `principal_middleware.rs` — intentional per the plan (\"missing/invalid auth is represented as absent request principal\").\n\n**Verified:** `cargo check --workspace`, `cargo clippy --workspace --all-targets -D warnings`, `cargo fmt --check`, `cargo nextest run --workspace` (6500 pass), `bun run typecheck`, `bun test` (508 pass).", + "thread.implement.current_node": "simplify_opus", + "thread.toolchain.current_node": "preflight_compile", + "graph.rankdir": "LR", + "internal.retry_count.preflight_compile": 0, + "internal.fidelity": "compact", + "thread.start.current_node": "toolchain", + "internal.retry_count.start": 0, + "internal.retry_count.toolchain": 0, + "internal.work_dir": "/home/daytona/workspace/fabro", + "failure_class": "", + "last_stage": "simplify_gpt", + "thread.preflight_compile.current_node": "preflight_lint", + "internal.retry_count.simplify_gpt": 0, + "internal.retry_count.simplify_opus": 0, + "internal.node_visit_count": 1, + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n " }, "node_outcomes": { + "simplify_gpt": { + "status": "succeeded", + "context_updates": { + "last_stage": "simplify_gpt", + "last_response": "Cleanup review completed. I found and fixed three small issues:\n\n- Moved `TEST_PRINCIPAL` import in `app/lib/run-actions.test.ts` back with the other imports.\n- Removed an unnecessary explanatory comm", + "response.simplify_gpt": "Cleanup review completed. I found and fixed three small issues:\n\n- Moved `TEST_PRINCIPAL` import in `app/lib/run-actions.test.ts` back with the other imports.\n- Removed an unnecessary explanatory comment from `app/lib/test-fixtures.ts`.\n- Avoided computing `principalDisplay(run.createdBy)` when the `created_by` column is hidden by extracting a small `CreatedByCell` component.\n\nVerification run:\n\n- `cd apps/fabro-web && bun run typecheck`\n- Targeted `bun test` for the touched frontend test files: 104 passed, 0 failed.\n- `git diff --check` passed.\n\nCurrent working tree has the three cleanup modifications above." + }, + "notes": "Stage completed: simplify_gpt", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 284631, + "output_tokens": 3143, + "reasoning_tokens": 944, + "cache_read_tokens": 356352, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 1723941 + }, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 137455, + "tool_time_ms": 43695, + "active_time_ms": 181150 + } + }, "verify": { "status": "succeeded", "context_updates": { @@ -1447,6 +1483,87 @@ "active_time_ms": 495385 } }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 1440, + "active_time_ms": 1440 + } + }, + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 152673, + "active_time_ms": 152673 + } + }, + "implement": { + "status": "succeeded", + "context_updates": { + "response.implement": "Implemented the plan in the requested two commits, in order:\n\n1. `199c93c5e Remove anonymous principal actor`\n - Removed `Principal::Anonymous`.\n - Request auth now represents no actor as `None`.\n - HTTP logs use `principal_kind=\"none\"` for absent principals.\n - Updated OpenAPI/generated TS client/frontend/tests accordingly.\n\n2. `fffe671dd Make run provenance and creator total`\n - Made `Run.created_by`, `RunSpec.provenance`, `RunProvenance.subject`, and `run.created` provenance non-optional end-to-end.\n - Removed null/default/fallback provenance handling.\n - Updated create/retry/projection flows to require and propagate total provenance.\n - Added gated `fabro-types` test support helpers.\n - Added synthetic demo principal.\n - Updated OpenAPI and regenerated generated clients.\n - Updated frontend models/rendering/tests for total `created_by`.\n\nFinal repo state is clean.\n\nValidation completed:\n- `cargo build -p fabro-api`\n- TypeScript API client generation with local JRE\n- `cargo check -q --workspace --all-targets`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cargo build --workspace`\n- `cargo nextest run --workspace --no-fail-fast`\n- `cargo nextest run -p fabro-server`\n- `cd apps/fabro-web && bun run typecheck && bun test && bun run build`\n- `cd lib/packages/fabro-api-client && bun run typecheck`\n- Final grep sweep for stale anonymous/null provenance traces; remaining hits are unrelated non-run-provenance `subject` fields.\n\nManual browser/server end-to-end checks from the plan were not run in this terminal environment.", + "last_response": "Implemented the plan in the requested two commits, in order:\n\n1. `199c93c5e Remove anonymous principal actor`\n - Removed `Principal::Anonymous`.\n - Request auth now represents no actor as `None`.\n", + "last_stage": "implement" + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 3764533, + "output_tokens": 47720, + "reasoning_tokens": 15703, + "cache_read_tokens": 44680704, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 43065707 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/test_support.rs" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 2723291, + "tool_time_ms": 2405987, + "active_time_ms": 5129278 + } + }, + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 161865, + "active_time_ms": 161865 + } + }, "simplify_opus": { "status": "succeeded", "context_updates": { @@ -1499,144 +1616,149 @@ "active_time_ms": 1116103 } }, - "toolchain": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" - }, - "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", - "usage": null, - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 0, - "tool_time_ms": 1440, - "active_time_ms": 1440 - } - }, - "preflight_lint": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" - }, - "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", - "usage": null, - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 0, - "tool_time_ms": 161865, - "active_time_ms": 161865 - } - }, - "simplify_gpt": { - "status": "succeeded", - "context_updates": { - "last_stage": "simplify_gpt", - "last_response": "Cleanup review completed. I found and fixed three small issues:\n\n- Moved `TEST_PRINCIPAL` import in `app/lib/run-actions.test.ts` back with the other imports.\n- Removed an unnecessary explanatory comm", - "response.simplify_gpt": "Cleanup review completed. I found and fixed three small issues:\n\n- Moved `TEST_PRINCIPAL` import in `app/lib/run-actions.test.ts` back with the other imports.\n- Removed an unnecessary explanatory comment from `app/lib/test-fixtures.ts`.\n- Avoided computing `principalDisplay(run.createdBy)` when the `created_by` column is hidden by extracting a small `CreatedByCell` component.\n\nVerification run:\n\n- `cd apps/fabro-web && bun run typecheck`\n- Targeted `bun test` for the touched frontend test files: 104 passed, 0 failed.\n- `git diff --check` passed.\n\nCurrent working tree has the three cleanup modifications above." - }, - "notes": "Stage completed: simplify_gpt", - "usage": { - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": "gpt-5.5" - }, - "tokens": { - "input_tokens": 284631, - "output_tokens": 3143, - "reasoning_tokens": 944, - "cache_read_tokens": 356352, - "cache_write_tokens": 0 - } - }, - "facts": { - "algorithm": "openai" - } - }, - "total_usd_micros": 1723941 - }, - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 137455, - "tool_time_ms": 43695, - "active_time_ms": 181150 - } - }, - "preflight_compile": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" - }, - "notes": "Script completed: cargo check -q --workspace 2>&1", - "usage": null, - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 0, - "tool_time_ms": 152673, - "active_time_ms": 152673 - } - }, "start": { "status": "succeeded", "usage": null - }, - "implement": { - "status": "succeeded", - "context_updates": { - "response.implement": "Implemented the plan in the requested two commits, in order:\n\n1. `199c93c5e Remove anonymous principal actor`\n - Removed `Principal::Anonymous`.\n - Request auth now represents no actor as `None`.\n - HTTP logs use `principal_kind=\"none\"` for absent principals.\n - Updated OpenAPI/generated TS client/frontend/tests accordingly.\n\n2. `fffe671dd Make run provenance and creator total`\n - Made `Run.created_by`, `RunSpec.provenance`, `RunProvenance.subject`, and `run.created` provenance non-optional end-to-end.\n - Removed null/default/fallback provenance handling.\n - Updated create/retry/projection flows to require and propagate total provenance.\n - Added gated `fabro-types` test support helpers.\n - Added synthetic demo principal.\n - Updated OpenAPI and regenerated generated clients.\n - Updated frontend models/rendering/tests for total `created_by`.\n\nFinal repo state is clean.\n\nValidation completed:\n- `cargo build -p fabro-api`\n- TypeScript API client generation with local JRE\n- `cargo check -q --workspace --all-targets`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cargo build --workspace`\n- `cargo nextest run --workspace --no-fail-fast`\n- `cargo nextest run -p fabro-server`\n- `cd apps/fabro-web && bun run typecheck && bun test && bun run build`\n- `cd lib/packages/fabro-api-client && bun run typecheck`\n- Final grep sweep for stale anonymous/null provenance traces; remaining hits are unrelated non-run-provenance `subject` fields.\n\nManual browser/server end-to-end checks from the plan were not run in this terminal environment.", - "last_response": "Implemented the plan in the requested two commits, in order:\n\n1. `199c93c5e Remove anonymous principal actor`\n - Removed `Principal::Anonymous`.\n - Request auth now represents no actor as `None`.\n", - "last_stage": "implement" - }, - "notes": "Stage completed: implement", - "usage": { - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": "gpt-5.5" - }, - "tokens": { - "input_tokens": 3764533, - "output_tokens": 47720, - "reasoning_tokens": 15703, - "cache_read_tokens": 44680704, - "cache_write_tokens": 0 - } - }, - "facts": { - "algorithm": "openai" - } - }, - "total_usd_micros": 43065707 - }, - "files_touched": [ - "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/test_support.rs" - ], - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 2723291, - "tool_time_ms": 2405987, - "active_time_ms": 5129278 - } } }, "next_node_id": "exit", + "git_commit_sha": "a8f2afd2489526298ea21428c6fa842b2e39a322", "node_visits": { - "simplify_opus": 1, - "simplify_gpt": 1, - "preflight_lint": 1, + "start": 1, + "verify": 1, + "toolchain": 1, "preflight_compile": 1, "implement": 1, - "toolchain": 1, - "start": 1, - "verify": 1 + "simplify_gpt": 1, + "simplify_opus": 1, + "preflight_lint": 1 } }, - "diff": {} + "diff": { + "patch": "diff --git a/lib/crates/fabro-llm/tests/it/main.rs b/lib/crates/fabro-llm/tests/it/main.rs\nnew file mode 100644\nindex 000000000..66dc027b4\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/main.rs\n@@ -0,0 +1,7 @@\n+#![allow(\n+ clippy::absolute_paths,\n+ reason = \"This test module prefers explicit type paths over extra imports.\"\n+)]\n+\n+mod support;\n+mod wire;\ndiff --git a/lib/crates/fabro-llm/tests/it/support.rs b/lib/crates/fabro-llm/tests/it/support.rs\nnew file mode 100644\nindex 000000000..c2d30c58c\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/support.rs\n@@ -0,0 +1,470 @@\n+//! Shared helpers for capturing the wire requests adapters send, plus the\n+//! canonical request corpus pinned across all four provider dialects.\n+\n+use std::sync::{Arc, Mutex};\n+\n+use fabro_llm::provider::ProviderAdapter;\n+use fabro_llm::types::{\n+ AudioData, ContentPart, DocumentData, ImageData, Message, Request, ResponseFormat, Role,\n+ ThinkingData, ToolCall, ToolChoice, ToolDefinition, ToolResult,\n+};\n+use fabro_model::Catalog;\n+use fabro_model::catalog::LlmCatalogSettings;\n+use httpmock::prelude::*;\n+\n+// ---------------------------------------------------------------------------\n+// Wire capture\n+// ---------------------------------------------------------------------------\n+\n+/// One captured wire request, normalized for snapshot stability.\n+#[derive(Debug, Clone, serde::Serialize)]\n+pub(crate) struct WireCapture {\n+ pub(crate) method: String,\n+ pub(crate) path: String,\n+ pub(crate) headers: Vec<(String, String)>,\n+ pub(crate) body: serde_json::Value,\n+}\n+\n+/// Shared slot the matcher closure writes the captured request into.\n+pub(crate) type CaptureSlot = Arc>>;\n+\n+fn capture_request(req: &HttpMockRequest) -> WireCapture {\n+ let mut headers: Vec<(String, String)> = req\n+ .headers_vec()\n+ .iter()\n+ .map(|(name, value)| {\n+ let name = name.to_ascii_lowercase();\n+ let value = match name.as_str() {\n+ // The mock server binds a random port.\n+ \"host\" => \"[host]\".to_string(),\n+ // Carries a client version that would churn snapshots.\n+ \"user-agent\" => \"[user-agent]\".to_string(),\n+ _ => value.clone(),\n+ };\n+ (name, value)\n+ })\n+ .collect();\n+ headers.sort();\n+\n+ let path = match req.uri().query() {\n+ Some(query) => format!(\"{}?{}\", req.uri().path(), query),\n+ None => req.uri().path().to_string(),\n+ };\n+\n+ WireCapture {\n+ method: req.method_str().to_string(),\n+ path,\n+ headers,\n+ body: serde_json::from_str(&req.body_string()).expect(\"request body should be JSON\"),\n+ }\n+}\n+\n+/// Mounts a mock on `path` that captures the full request into the returned\n+/// slot and responds with the JSON `response_body`.\n+pub(crate) fn mount_capture<'a>(\n+ server: &'a MockServer,\n+ path: &'static str,\n+ response_body: serde_json::Value,\n+) -> (httpmock::Mock<'a>, CaptureSlot) {\n+ let slot: CaptureSlot = Arc::new(Mutex::new(None));\n+ let writer = Arc::clone(&slot);\n+ let mock = server.mock(move |when, then| {\n+ when.method(POST)\n+ .path(path)\n+ .is_true(move |req: &HttpMockRequest| {\n+ *writer.lock().unwrap() = Some(capture_request(req));\n+ true\n+ });\n+ then.status(200)\n+ .header(\"content-type\", \"application/json\")\n+ .json_body(response_body);\n+ });\n+ (mock, slot)\n+}\n+\n+/// Like [`mount_capture`] but responds with a raw SSE transcript.\n+pub(crate) fn mount_capture_sse<'a>(\n+ server: &'a MockServer,\n+ path: &'static str,\n+ sse_body: &str,\n+) -> (httpmock::Mock<'a>, CaptureSlot) {\n+ let slot: CaptureSlot = Arc::new(Mutex::new(None));\n+ let writer = Arc::clone(&slot);\n+ let body = sse_body.to_string();\n+ let mock = server.mock(move |when, then| {\n+ when.method(POST)\n+ .path(path)\n+ .is_true(move |req: &HttpMockRequest| {\n+ *writer.lock().unwrap() = Some(capture_request(req));\n+ true\n+ });\n+ then.status(200)\n+ .header(\"content-type\", \"text/event-stream\")\n+ .body(body.clone());\n+ });\n+ (mock, slot)\n+}\n+\n+pub(crate) fn take_capture(slot: &CaptureSlot) -> WireCapture {\n+ slot.lock()\n+ .unwrap()\n+ .take()\n+ .expect(\"matcher should have captured the request\")\n+}\n+\n+/// Drives `adapter.stream(request)` to completion and returns every emitted\n+/// item as JSON: `Ok` events serialize verbatim (the public SSE wire shape);\n+/// `Err` items pin the message plus the failover/retry flags consumers key on.\n+pub(crate) async fn collect_stream_events(\n+ adapter: &dyn ProviderAdapter,\n+ request: &Request,\n+) -> Vec {\n+ use futures::StreamExt;\n+\n+ let mut stream = adapter.stream(request).await.expect(\"stream should start\");\n+ let mut events = Vec::new();\n+ while let Some(item) = stream.next().await {\n+ events.push(match item {\n+ Ok(event) => serde_json::to_value(&event).expect(\"event should serialize\"),\n+ Err(error) => serde_json::json!({\n+ \"stream_item_error\": error.to_string(),\n+ \"retryable\": error.retryable(),\n+ \"failover_eligible\": error.failover_eligible(),\n+ }),\n+ });\n+ }\n+ events\n+}\n+\n+/// Builds a catalog from inline TOML (same `LlmCatalogSettings` schema as the\n+/// shipped catalog files).\n+pub(crate) fn catalog_from_toml(source: &str) -> Arc {\n+ let settings: LlmCatalogSettings = toml::from_str(source).expect(\"catalog TOML should parse\");\n+ Arc::new(Catalog::from_settings(&settings).expect(\"catalog should build\"))\n+}\n+\n+fn is_uuid(s: &str) -> bool {\n+ s.len() == 36\n+ && s.bytes().enumerate().all(|(i, b)| match i {\n+ 8 | 13 | 18 | 23 => b == b'-',\n+ _ => b.is_ascii_hexdigit(),\n+ })\n+}\n+\n+/// Replaces UUID-shaped strings with `[UUID]` for snapshot stability — the\n+/// Gemini decoder mints synthetic `Uuid::new_v4()` tool-call ids.\n+pub(crate) fn normalize_uuids(value: &mut serde_json::Value) {\n+ match value {\n+ serde_json::Value::String(s) if is_uuid(s) => \"[UUID]\".clone_into(s),\n+ serde_json::Value::Array(items) => items.iter_mut().for_each(normalize_uuids),\n+ serde_json::Value::Object(map) => map.values_mut().for_each(normalize_uuids),\n+ _ => {}\n+ }\n+}\n+\n+/// Renders `(event, data)` pairs as an SSE transcript with `event:` lines\n+/// (the Anthropic framing).\n+pub(crate) fn sse_transcript(events: &[(&str, &str)]) -> String {\n+ use std::fmt::Write;\n+\n+ events.iter().fold(String::new(), |mut out, (event, data)| {\n+ let _ = writeln!(out, \"event: {event}\\ndata: {data}\\n\");\n+ out\n+ })\n+}\n+\n+/// Renders data-only SSE lines (the OpenAI/Gemini framing).\n+pub(crate) fn sse_data_transcript(lines: &[&str]) -> String {\n+ use std::fmt::Write;\n+\n+ lines.iter().fold(String::new(), |mut out, data| {\n+ let _ = writeln!(out, \"data: {data}\\n\");\n+ out\n+ })\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Canonical request corpus\n+//\n+// Each constructor returns one canonical `Request` shape that every dialect\n+// file pins through its own adapter. Keep these stable: editing a corpus\n+// request invalidates the pinned wire snapshots in all four dialect files.\n+// ---------------------------------------------------------------------------\n+\n+pub(crate) fn base_request(model: &str) -> Request {\n+ Request {\n+ model: model.to_string(),\n+ messages: vec![Message::user(\"Hello\")],\n+ provider: None,\n+ tools: None,\n+ tool_choice: None,\n+ response_format: None,\n+ temperature: None,\n+ top_p: None,\n+ max_tokens: Some(128),\n+ stop_sequences: None,\n+ reasoning_effort: None,\n+ speed: None,\n+ metadata: None,\n+ provider_options: None,\n+ }\n+}\n+\n+/// Multi-turn conversation: system + user/assistant/user.\n+pub(crate) fn corpus_multi_turn(model: &str) -> Request {\n+ Request {\n+ messages: vec![\n+ Message::system(\"You are a terse assistant.\"),\n+ Message::user(\"What is the capital of France?\"),\n+ Message::assistant(\"Paris.\"),\n+ Message::user(\"And of Spain?\"),\n+ ],\n+ ..base_request(model)\n+ }\n+}\n+\n+/// Two tools plus an optional tool choice.\n+pub(crate) fn corpus_tools(model: &str, tool_choice: Option) -> Request {\n+ Request {\n+ tools: Some(vec![\n+ ToolDefinition::function(\n+ \"search\",\n+ \"Search files\",\n+ serde_json::json!({\n+ \"type\": \"object\",\n+ \"properties\": {\"query\": {\"type\": \"string\"}},\n+ \"required\": [\"query\"]\n+ }),\n+ ),\n+ ToolDefinition::function(\n+ \"read_file\",\n+ \"Read a file by path\",\n+ serde_json::json!({\n+ \"type\": \"object\",\n+ \"properties\": {\"path\": {\"type\": \"string\"}}\n+ }),\n+ ),\n+ ]),\n+ tool_choice,\n+ ..base_request(model)\n+ }\n+}\n+\n+/// A full tool round trip: assistant emits two tool calls, the tool turn\n+/// returns one success carrying an image and one error result.\n+pub(crate) fn corpus_tool_round_trip(model: &str) -> Request {\n+ let mut image_result = ToolResult::success(\"call_1\", serde_json::json!({\"matches\": 2}));\n+ image_result.image_data = Some(b\"fake-screenshot-bytes\".to_vec());\n+ image_result.image_media_type = Some(\"image/png\".to_string());\n+\n+ let mut request = corpus_tools(model, None);\n+ request.messages = vec![\n+ Message::user(\"Find foo and read /tmp/x\"),\n+ Message {\n+ role: Role::Assistant,\n+ content: vec![\n+ ContentPart::text(\"Let me check.\"),\n+ ContentPart::ToolCall(ToolCall::new(\n+ \"call_1\",\n+ \"search\",\n+ serde_json::json!({\"query\": \"foo\"}),\n+ )),\n+ ContentPart::ToolCall(ToolCall::new(\n+ \"call_2\",\n+ \"read_file\",\n+ serde_json::json!({\"path\": \"/tmp/x\"}),\n+ )),\n+ ],\n+ name: None,\n+ tool_call_id: None,\n+ },\n+ Message {\n+ role: Role::Tool,\n+ content: vec![ContentPart::ToolResult(image_result)],\n+ name: None,\n+ tool_call_id: Some(\"call_1\".to_string()),\n+ },\n+ Message::tool_result(\n+ \"call_2\",\n+ serde_json::Value::String(\"file not found\".to_string()),\n+ true,\n+ ),\n+ ];\n+ request\n+}\n+\n+/// Assistant thinking block with a signature, round-tripped back as history.\n+pub(crate) fn corpus_thinking_round_trip(model: &str) -> Request {\n+ Request {\n+ messages: vec![\n+ Message::user(\"Think step by step: what is 2+2?\"),\n+ Message {\n+ role: Role::Assistant,\n+ content: vec![\n+ ContentPart::Thinking(ThinkingData {\n+ text: \"The user wants 2+2, which is 4.\".to_string(),\n+ signature: Some(\"sig_test_abc123\".to_string()),\n+ redacted: false,\n+ }),\n+ ContentPart::text(\"4.\"),\n+ ],\n+ name: None,\n+ tool_call_id: None,\n+ },\n+ Message::user(\"Now 3+3?\"),\n+ ],\n+ ..base_request(model)\n+ }\n+}\n+\n+/// Image and document attachments as inline bytes (no file I/O involved).\n+pub(crate) fn corpus_inline_attachments(model: &str) -> Request {\n+ Request {\n+ messages: vec![Message {\n+ role: Role::User,\n+ content: vec![\n+ ContentPart::text(\"Describe these attachments.\"),\n+ ContentPart::Image(ImageData {\n+ url: None,\n+ data: Some(b\"fake-png-bytes\".to_vec()),\n+ media_type: Some(\"image/png\".to_string()),\n+ detail: None,\n+ }),\n+ ContentPart::Document(DocumentData {\n+ url: None,\n+ data: Some(b\"fake-pdf-bytes\".to_vec()),\n+ media_type: Some(\"application/pdf\".to_string()),\n+ file_name: Some(\"report.pdf\".to_string()),\n+ }),\n+ ],\n+ name: None,\n+ tool_call_id: None,\n+ }],\n+ ..base_request(model)\n+ }\n+}\n+\n+/// Image and document attachments as non-file https URLs. Each dialect has\n+/// its own URL-passthrough wire shape; resolving these to inline data would\n+/// be a wire change.\n+pub(crate) fn corpus_url_attachments(model: &str) -> Request {\n+ Request {\n+ messages: vec![Message {\n+ role: Role::User,\n+ content: vec![\n+ ContentPart::text(\"Describe these attachments.\"),\n+ ContentPart::Image(ImageData {\n+ url: Some(\"https://example.com/picture.png\".to_string()),\n+ data: None,\n+ media_type: Some(\"image/png\".to_string()),\n+ detail: None,\n+ }),\n+ ContentPart::Document(DocumentData {\n+ url: Some(\"https://example.com/report.pdf\".to_string()),\n+ data: None,\n+ media_type: Some(\"application/pdf\".to_string()),\n+ file_name: Some(\"report.pdf\".to_string()),\n+ }),\n+ ],\n+ name: None,\n+ tool_call_id: None,\n+ }],\n+ ..base_request(model)\n+ }\n+}\n+\n+/// Attachments referencing file paths that do not exist. Today every adapter\n+/// silently drops the part on load failure (`Err(_) => None`) and sends the\n+/// rest of the request; these requests pin that contract.\n+pub(crate) fn corpus_bad_file_path_attachments(model: &str) -> Request {\n+ Request {\n+ messages: vec![Message {\n+ role: Role::User,\n+ content: vec![\n+ ContentPart::text(\"Describe these attachments.\"),\n+ ContentPart::Image(ImageData {\n+ url: Some(\"/nonexistent/fabro-wire-pin.png\".to_string()),\n+ data: None,\n+ media_type: Some(\"image/png\".to_string()),\n+ detail: None,\n+ }),\n+ ContentPart::Document(DocumentData {\n+ url: Some(\"/nonexistent/fabro-wire-pin.pdf\".to_string()),\n+ data: None,\n+ media_type: Some(\"application/pdf\".to_string()),\n+ file_name: Some(\"missing.pdf\".to_string()),\n+ }),\n+ ],\n+ name: None,\n+ tool_call_id: None,\n+ }],\n+ ..base_request(model)\n+ }\n+}\n+\n+/// Inline audio attachment (support differs per dialect: gemini sends it,\n+/// openai-responses falls back to text, anthropic/compat drop or warn).\n+pub(crate) fn corpus_audio_attachment(model: &str) -> Request {\n+ Request {\n+ messages: vec![Message {\n+ role: Role::User,\n+ content: vec![\n+ ContentPart::text(\"Transcribe this.\"),\n+ ContentPart::Audio(AudioData {\n+ url: None,\n+ data: Some(b\"fake-wav-bytes\".to_vec()),\n+ media_type: Some(\"audio/wav\".to_string()),\n+ }),\n+ ],\n+ name: None,\n+ tool_call_id: None,\n+ }],\n+ ..base_request(model)\n+ }\n+}\n+\n+/// Response-format request (callers pass each of the three kinds).\n+pub(crate) fn corpus_response_format(model: &str, format: ResponseFormat) -> Request {\n+ Request {\n+ response_format: Some(format),\n+ ..base_request(model)\n+ }\n+}\n+\n+/// A JSON-schema response format with `strict` set. The schema is passed raw\n+/// (no name/schema wrapper) — the shape `generate_object` produces.\n+pub(crate) fn json_schema_format() -> ResponseFormat {\n+ ResponseFormat {\n+ kind: fabro_llm::types::ResponseFormatType::JsonSchema,\n+ json_schema: Some(serde_json::json!({\n+ \"type\": \"object\",\n+ \"properties\": {\"answer\": {\"type\": \"string\"}},\n+ \"required\": [\"answer\"]\n+ })),\n+ strict: true,\n+ }\n+}\n+\n+/// Sampling parameters: temperature, top_p, stop sequences, and metadata.\n+/// Metadata deliberately holds a single key — `HashMap` iteration order would\n+/// make multi-key snapshots nondeterministic.\n+pub(crate) fn corpus_sampling_params(model: &str) -> Request {\n+ Request {\n+ temperature: Some(0.7),\n+ top_p: Some(0.9),\n+ stop_sequences: Some(vec![\"END\".to_string()]),\n+ metadata: Some(std::collections::HashMap::from([(\n+ \"trace_id\".to_string(),\n+ \"trace-123\".to_string(),\n+ )])),\n+ ..base_request(model)\n+ }\n+}\n+\n+/// Provider-options escape hatch (callers pass the dialect's namespace key).\n+pub(crate) fn corpus_provider_options(model: &str, options: serde_json::Value) -> Request {\n+ Request {\n+ provider_options: Some(options),\n+ ..base_request(model)\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/anthropic.rs b/lib/crates/fabro-llm/tests/it/wire/anthropic.rs\nnew file mode 100644\nindex 000000000..bbeec415d\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n@@ -0,0 +1,593 @@\n+//! Wire snapshots for the Anthropic Messages dialect.\n+\n+use fabro_llm::provider::ProviderAdapter;\n+use fabro_llm::providers::AnthropicAdapter;\n+use fabro_llm::types::{\n+ Message, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition,\n+};\n+use httpmock::prelude::*;\n+\n+use crate::support::{\n+ self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments,\n+ corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format,\n+ corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools,\n+ corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture,\n+};\n+\n+const MODEL: &str = \"claude-sonnet-4-20250514\";\n+\n+/// Minimal valid Messages API body for encode-side tests that only assert on\n+/// the captured request.\n+fn minimal_body() -> serde_json::Value {\n+ serde_json::json!({\n+ \"id\": \"msg_test\",\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"model\": MODEL,\n+ \"content\": [{\"type\": \"text\", \"text\": \"ok\"}],\n+ \"stop_reason\": \"end_turn\",\n+ \"stop_sequence\": null,\n+ \"usage\": {\"input_tokens\": 1, \"output_tokens\": 1}\n+ })\n+}\n+\n+/// Runs `complete()` against a capture mock and returns the captured wire\n+/// request.\n+async fn encode_capture(adapter: AnthropicAdapter, request: &Request) -> WireCapture {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(&server, \"/messages\", minimal_body());\n+ let adapter = adapter.with_base_url(server.base_url());\n+ adapter\n+ .complete(request)\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ take_capture(&slot)\n+}\n+\n+/// Runs `stream()` against an SSE transcript and returns the captured wire\n+/// request plus every emitted stream item as JSON.\n+async fn stream_capture(\n+ adapter: AnthropicAdapter,\n+ request: &Request,\n+ sse_body: &str,\n+) -> (WireCapture, Vec) {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture_sse(&server, \"/messages\", sse_body);\n+ let adapter = adapter.with_base_url(server.base_url());\n+ let events = support::collect_stream_events(&adapter, request).await;\n+ mock.assert();\n+ (take_capture(&slot), events)\n+}\n+\n+fn adapter() -> AnthropicAdapter {\n+ AnthropicAdapter::new(\"test-key\")\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Round trip (encode + decode)\n+// ---------------------------------------------------------------------------\n+\n+/// Shared setup for the system+tools round trip: runs `complete()` against a\n+/// canned response and returns both the captured request and decoded response\n+/// so the encode and decode halves can be pinned by separate tests.\n+async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(\n+ &server,\n+ \"/messages\",\n+ serde_json::json!({\n+ \"id\": \"msg_test\",\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"content\": [{\"type\": \"text\", \"text\": \"Hello back\"}],\n+ \"stop_reason\": \"end_turn\",\n+ \"stop_sequence\": null,\n+ \"usage\": {\n+ \"input_tokens\": 42,\n+ \"output_tokens\": 7,\n+ \"cache_read_input_tokens\": 10,\n+ \"cache_creation_input_tokens\": 3\n+ }\n+ }),\n+ );\n+\n+ let adapter = AnthropicAdapter::new(\"test-key\").with_base_url(server.base_url());\n+ let request = Request {\n+ messages: vec![Message::system(\"Be concise\"), Message::user(\"Hello\")],\n+ tools: Some(vec![ToolDefinition::function(\n+ \"search\",\n+ \"Search files\",\n+ serde_json::json!({\"type\": \"object\", \"properties\": {\"query\": {\"type\": \"string\"}}}),\n+ )]),\n+ temperature: Some(0.5),\n+ ..base_request(\"claude-sonnet-4-20250514\")\n+ };\n+\n+ let response = adapter\n+ .complete(&request)\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ (take_capture(&slot), response)\n+}\n+\n+#[tokio::test]\n+async fn system_and_tools_encode() {\n+ let (capture, _) = system_and_tools_roundtrip().await;\n+ fabro_test::fabro_json_snapshot!(capture);\n+}\n+\n+#[tokio::test]\n+async fn system_and_tools_decode() {\n+ let (_, response) = system_and_tools_roundtrip().await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Encode\n+// ---------------------------------------------------------------------------\n+\n+#[tokio::test]\n+async fn encode_multi_turn() {\n+ let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_auto() {\n+ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_required() {\n+ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_named() {\n+ let capture = encode_capture(\n+ adapter(),\n+ &corpus_tools(MODEL, Some(ToolChoice::named(\"search\"))),\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_none() {\n+ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_round_trip() {\n+ let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_thinking_round_trip() {\n+ let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_inline_attachments() {\n+ let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_url_attachments() {\n+ let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_bad_file_path_attachments_dropped() {\n+ let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_audio_attachment() {\n+ let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_response_format_json_object() {\n+ let format = ResponseFormat {\n+ kind: ResponseFormatType::JsonObject,\n+ json_schema: None,\n+ strict: false,\n+ };\n+ let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_response_format_json_schema() {\n+ let capture = encode_capture(\n+ adapter(),\n+ &corpus_response_format(MODEL, json_schema_format()),\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_sampling_params() {\n+ let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_provider_options_anthropic_namespace() {\n+ let capture = encode_capture(\n+ adapter(),\n+ &corpus_provider_options(MODEL, serde_json::json!({\"anthropic\": {\"top_k\": 5}})),\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_reasoning_effort_with_levels_catalog() {\n+ let catalog = support::catalog_from_toml(\n+ r#\"\n+[providers.anthropic]\n+display_name = \"Anthropic\"\n+adapter = \"anthropic\"\n+agent_profile = \"anthropic\"\n+\n+[models.\"test-claude\"]\n+provider = \"anthropic\"\n+display_name = \"Test Claude\"\n+family = \"claude\"\n+default = true\n+\n+[models.\"test-claude\".limits]\n+context_window = 200000\n+max_output = 4096\n+\n+[models.\"test-claude\".features]\n+tools = true\n+vision = true\n+reasoning = true\n+reasoning_effort = \"levels\"\n+prompt_cache = false\n+\"#,\n+ );\n+ let request = Request {\n+ reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High),\n+ ..base_request(\"test-claude\")\n+ };\n+ let capture = encode_capture(adapter().with_catalog(catalog), &request).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_prompt_cache_with_catalog() {\n+ let catalog = support::catalog_from_toml(\n+ r#\"\n+[providers.anthropic]\n+display_name = \"Anthropic\"\n+adapter = \"anthropic\"\n+agent_profile = \"anthropic\"\n+\n+[models.\"test-claude\"]\n+provider = \"anthropic\"\n+display_name = \"Test Claude\"\n+family = \"claude\"\n+default = true\n+\n+[models.\"test-claude\".limits]\n+context_window = 200000\n+max_output = 4096\n+\n+[models.\"test-claude\".features]\n+tools = true\n+vision = true\n+reasoning = true\n+prompt_cache = true\n+\"#,\n+ );\n+ let request = Request {\n+ messages: vec![\n+ Message::system(\"You are a careful reviewer.\"),\n+ Message::user(\"Review this.\"),\n+ ],\n+ ..corpus_tools(\"test-claude\", None)\n+ };\n+ // Full capture: the prompt-cache path also controls the beta header.\n+ let capture = encode_capture(adapter().with_catalog(catalog), &request).await;\n+ fabro_test::fabro_json_snapshot!(capture);\n+}\n+\n+#[tokio::test]\n+async fn count_tokens_wire_shape() {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(\n+ &server,\n+ \"/messages/count_tokens\",\n+ serde_json::json!({\"input_tokens\": 123}),\n+ );\n+\n+ let adapter = adapter().with_base_url(server.base_url());\n+ let request = Request {\n+ messages: vec![Message::system(\"Be concise\"), Message::user(\"Hello\")],\n+ ..corpus_tools(MODEL, None)\n+ };\n+ let count = adapter\n+ .count_input_tokens(&request)\n+ .await\n+ .unwrap()\n+ .expect(\"anthropic should count tokens\");\n+\n+ mock.assert();\n+ assert_eq!(count.input_tokens, 123);\n+ fabro_test::fabro_json_snapshot!(take_capture(&slot));\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Decode\n+// ---------------------------------------------------------------------------\n+\n+/// Runs `complete()` against a canned body and returns the decoded response.\n+async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response {\n+ let server = MockServer::start();\n+ let (mock, _slot) = mount_capture(&server, \"/messages\", body);\n+ let adapter = adapter().with_base_url(server.base_url());\n+ let response = adapter\n+ .complete(&base_request(MODEL))\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ response\n+}\n+\n+#[tokio::test]\n+async fn decode_tool_use_stop_reason() {\n+ let response = decode_response(serde_json::json!({\n+ \"id\": \"msg_test\",\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"model\": MODEL,\n+ \"content\": [\n+ {\"type\": \"text\", \"text\": \"Let me search.\"},\n+ {\n+ \"type\": \"tool_use\",\n+ \"id\": \"toolu_01\",\n+ \"name\": \"search\",\n+ \"input\": {\"query\": \"foo\"}\n+ }\n+ ],\n+ \"stop_reason\": \"tool_use\",\n+ \"stop_sequence\": null,\n+ \"usage\": {\"input_tokens\": 30, \"output_tokens\": 12}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+#[tokio::test]\n+async fn decode_thinking_and_redacted_thinking() {\n+ let response = decode_response(serde_json::json!({\n+ \"id\": \"msg_test\",\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"model\": MODEL,\n+ \"content\": [\n+ {\"type\": \"thinking\", \"thinking\": \"Step one.\", \"signature\": \"sig_decode_abc\"},\n+ {\"type\": \"redacted_thinking\", \"data\": \"opaque-blob\"},\n+ {\"type\": \"text\", \"text\": \"Done.\"}\n+ ],\n+ \"stop_reason\": \"end_turn\",\n+ \"stop_sequence\": null,\n+ \"usage\": {\"input_tokens\": 25, \"output_tokens\": 40}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+#[tokio::test]\n+async fn decode_max_tokens_stop_reason() {\n+ let response = decode_response(serde_json::json!({\n+ \"id\": \"msg_test\",\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"model\": MODEL,\n+ \"content\": [{\"type\": \"text\", \"text\": \"Truncated answe\"}],\n+ \"stop_reason\": \"max_tokens\",\n+ \"stop_sequence\": null,\n+ \"usage\": {\"input_tokens\": 10, \"output_tokens\": 128}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Stream\n+// ---------------------------------------------------------------------------\n+\n+/// Shared setup for the happy-path text stream; the request and event halves\n+/// are pinned by separate tests.\n+async fn stream_text_happy_path_capture() -> (WireCapture, Vec) {\n+ let sse = support::sse_transcript(&[\n+ (\n+ \"message_start\",\n+ r#\"{\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_test\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-20250514\",\"content\":[],\"usage\":{\"input_tokens\":11,\"cache_read_input_tokens\":2,\"cache_creation_input_tokens\":1,\"output_tokens\":0}}}\"#,\n+ ),\n+ (\"ping\", r#\"{\"type\":\"ping\"}\"#),\n+ (\n+ \"content_block_start\",\n+ r#\"{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\"#,\n+ ),\n+ (\n+ \"content_block_delta\",\n+ r#\"{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hel\"}}\"#,\n+ ),\n+ (\n+ \"content_block_delta\",\n+ r#\"{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"lo\"}}\"#,\n+ ),\n+ (\n+ \"content_block_stop\",\n+ r#\"{\"type\":\"content_block_stop\",\"index\":0}\"#,\n+ ),\n+ (\n+ \"message_delta\",\n+ r#\"{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":5}}\"#,\n+ ),\n+ (\"message_stop\", r#\"{\"type\":\"message_stop\"}\"#),\n+ ]);\n+ stream_capture(adapter(), &base_request(MODEL), &sse).await\n+}\n+\n+/// The captured request pins the stream flag on the wire.\n+#[tokio::test]\n+async fn stream_text_happy_path_request() {\n+ let (capture, _) = stream_text_happy_path_capture().await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn stream_text_happy_path_events() {\n+ let (_, events) = stream_text_happy_path_capture().await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+#[tokio::test]\n+async fn stream_tool_call_deltas() {\n+ let sse = support::sse_transcript(&[\n+ (\n+ \"message_start\",\n+ r#\"{\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_tool\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-20250514\",\"content\":[],\"usage\":{\"input_tokens\":20,\"output_tokens\":0}}}\"#,\n+ ),\n+ (\n+ \"content_block_start\",\n+ r#\"{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01\",\"name\":\"search\",\"input\":{}}}\"#,\n+ ),\n+ (\n+ \"content_block_delta\",\n+ r#\"{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"qu\"}}\"#,\n+ ),\n+ (\n+ \"content_block_delta\",\n+ r#\"{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ery\\\":\\\"foo\\\"}\"}}\"#,\n+ ),\n+ (\n+ \"content_block_stop\",\n+ r#\"{\"type\":\"content_block_stop\",\"index\":0}\"#,\n+ ),\n+ (\n+ \"message_delta\",\n+ r#\"{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":9}}\"#,\n+ ),\n+ (\"message_stop\", r#\"{\"type\":\"message_stop\"}\"#),\n+ ]);\n+ let (_capture, events) = stream_capture(\n+ adapter(),\n+ &corpus_tools(MODEL, Some(ToolChoice::Auto)),\n+ &sse,\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+#[tokio::test]\n+async fn stream_thinking_with_signature_delta() {\n+ let sse = support::sse_transcript(&[\n+ (\n+ \"message_start\",\n+ r#\"{\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_think\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-20250514\",\"content\":[],\"usage\":{\"input_tokens\":15,\"output_tokens\":0}}}\"#,\n+ ),\n+ (\n+ \"content_block_start\",\n+ r#\"{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\"#,\n+ ),\n+ (\n+ \"content_block_delta\",\n+ r#\"{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Let me think\"}}\"#,\n+ ),\n+ (\n+ \"content_block_delta\",\n+ r#\"{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_stream_xyz\"}}\"#,\n+ ),\n+ (\n+ \"content_block_stop\",\n+ r#\"{\"type\":\"content_block_stop\",\"index\":0}\"#,\n+ ),\n+ (\n+ \"content_block_start\",\n+ r#\"{\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\"#,\n+ ),\n+ (\n+ \"content_block_delta\",\n+ r#\"{\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"4.\"}}\"#,\n+ ),\n+ (\n+ \"content_block_stop\",\n+ r#\"{\"type\":\"content_block_stop\",\"index\":1}\"#,\n+ ),\n+ (\n+ \"message_delta\",\n+ r#\"{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":12}}\"#,\n+ ),\n+ (\"message_stop\", r#\"{\"type\":\"message_stop\"}\"#),\n+ ]);\n+ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+#[tokio::test]\n+async fn stream_error_event_mid_stream() {\n+ let sse = support::sse_transcript(&[\n+ (\n+ \"message_start\",\n+ r#\"{\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_err\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-20250514\",\"content\":[],\"usage\":{\"input_tokens\":9,\"output_tokens\":0}}}\"#,\n+ ),\n+ (\n+ \"error\",\n+ r#\"{\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\"#,\n+ ),\n+ ]);\n+ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+/// The Anthropic decoder never synthesizes a `Finish` on byte-stream end:\n+/// `message_stop` is the only finisher. A transcript that ends without it\n+/// must produce no `Finish` event.\n+#[tokio::test]\n+async fn stream_without_message_stop_emits_no_finish() {\n+ let sse = support::sse_transcript(&[\n+ (\n+ \"message_start\",\n+ r#\"{\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_cut\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-20250514\",\"content\":[],\"usage\":{\"input_tokens\":11,\"output_tokens\":0}}}\"#,\n+ ),\n+ (\n+ \"content_block_start\",\n+ r#\"{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\"#,\n+ ),\n+ (\n+ \"content_block_delta\",\n+ r#\"{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\"#,\n+ ),\n+ (\n+ \"content_block_stop\",\n+ r#\"{\"type\":\"content_block_stop\",\"index\":0}\"#,\n+ ),\n+ (\n+ \"message_delta\",\n+ r#\"{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":5}}\"#,\n+ ),\n+ ]);\n+ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/gemini.rs b/lib/crates/fabro-llm/tests/it/wire/gemini.rs\nnew file mode 100644\nindex 000000000..0471b585b\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/gemini.rs\n@@ -0,0 +1,487 @@\n+//! Wire snapshots for the Gemini `generateContent` dialect. The model is\n+//! part of the URL path, auth is the `x-goog-api-key` header, and the\n+//! decoder mints synthetic UUID tool-call ids (normalized to `[UUID]` in\n+//! these snapshots).\n+\n+use fabro_llm::provider::ProviderAdapter;\n+use fabro_llm::providers::GeminiAdapter;\n+use fabro_llm::types::{\n+ Message, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition,\n+};\n+use httpmock::prelude::*;\n+\n+use crate::support::{\n+ self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments,\n+ corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format,\n+ corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools,\n+ corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture,\n+};\n+\n+const MODEL: &str = \"gemini-test\";\n+const COMPLETE_PATH: &str = \"/models/gemini-test:generateContent\";\n+const STREAM_PATH: &str = \"/models/gemini-test:streamGenerateContent\";\n+\n+/// Minimal valid generateContent body for encode-side tests.\n+fn minimal_body() -> serde_json::Value {\n+ serde_json::json!({\n+ \"candidates\": [{\n+ \"content\": {\"role\": \"model\", \"parts\": [{\"text\": \"ok\"}]},\n+ \"finishReason\": \"STOP\"\n+ }],\n+ \"usageMetadata\": {\"promptTokenCount\": 1, \"candidatesTokenCount\": 1}\n+ })\n+}\n+\n+fn adapter() -> GeminiAdapter {\n+ GeminiAdapter::new(\"test-key\")\n+}\n+\n+/// Runs `complete()` against a capture mock and returns the captured wire\n+/// request.\n+async fn encode_capture(adapter: GeminiAdapter, request: &Request) -> WireCapture {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(&server, COMPLETE_PATH, minimal_body());\n+ let adapter = adapter.with_base_url(server.base_url());\n+ adapter\n+ .complete(request)\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ take_capture(&slot)\n+}\n+\n+/// Runs `stream()` against an SSE transcript and returns the captured wire\n+/// request plus every emitted stream item as JSON (UUIDs normalized).\n+async fn stream_capture(\n+ adapter: GeminiAdapter,\n+ request: &Request,\n+ sse_body: &str,\n+) -> (WireCapture, Vec) {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture_sse(&server, STREAM_PATH, sse_body);\n+ let adapter = adapter.with_base_url(server.base_url());\n+ let mut events = support::collect_stream_events(&adapter, request).await;\n+ mock.assert();\n+ events.iter_mut().for_each(support::normalize_uuids);\n+ (take_capture(&slot), events)\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Round trip (encode + decode)\n+// ---------------------------------------------------------------------------\n+\n+/// Shared setup for the system+tools round trip. The decoded response is\n+/// returned as a UUID-normalized JSON value (gemini mints a synthetic UUID\n+/// for the response id); the encode and decode halves are pinned separately.\n+async fn system_and_tools_roundtrip() -> (WireCapture, serde_json::Value) {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(\n+ &server,\n+ COMPLETE_PATH,\n+ serde_json::json!({\n+ \"candidates\": [{\n+ \"content\": {\"role\": \"model\", \"parts\": [{\"text\": \"Hello back\"}]},\n+ \"finishReason\": \"STOP\"\n+ }],\n+ \"usageMetadata\": {\n+ \"promptTokenCount\": 42,\n+ \"candidatesTokenCount\": 7,\n+ \"cachedContentTokenCount\": 10\n+ }\n+ }),\n+ );\n+\n+ let adapter = adapter().with_base_url(server.base_url());\n+ let request = Request {\n+ messages: vec![Message::system(\"Be concise\"), Message::user(\"Hello\")],\n+ tools: Some(vec![ToolDefinition::function(\n+ \"search\",\n+ \"Search files\",\n+ serde_json::json!({\"type\": \"object\", \"properties\": {\"query\": {\"type\": \"string\"}}}),\n+ )]),\n+ temperature: Some(0.5),\n+ ..base_request(MODEL)\n+ };\n+\n+ let response = adapter\n+ .complete(&request)\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ let mut response_value = serde_json::to_value(&response).expect(\"response should serialize\");\n+ support::normalize_uuids(&mut response_value);\n+ (take_capture(&slot), response_value)\n+}\n+\n+#[tokio::test]\n+async fn system_and_tools_encode() {\n+ let (capture, _) = system_and_tools_roundtrip().await;\n+ fabro_test::fabro_json_snapshot!(capture);\n+}\n+\n+#[tokio::test]\n+async fn system_and_tools_decode() {\n+ let (_, response) = system_and_tools_roundtrip().await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Encode\n+// ---------------------------------------------------------------------------\n+\n+#[tokio::test]\n+async fn encode_multi_turn() {\n+ let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_auto() {\n+ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_required() {\n+ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_named() {\n+ let capture = encode_capture(\n+ adapter(),\n+ &corpus_tools(MODEL, Some(ToolChoice::named(\"search\"))),\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_none() {\n+ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_round_trip() {\n+ let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_thinking_round_trip() {\n+ let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_inline_attachments() {\n+ let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_url_attachments() {\n+ let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_bad_file_path_attachments_dropped() {\n+ let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// Gemini sends inline audio (the only dialect that does).\n+#[tokio::test]\n+async fn encode_audio_attachment() {\n+ let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_response_format_json_object() {\n+ let format = ResponseFormat {\n+ kind: ResponseFormatType::JsonObject,\n+ json_schema: None,\n+ strict: false,\n+ };\n+ let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_response_format_json_schema() {\n+ let capture = encode_capture(\n+ adapter(),\n+ &corpus_response_format(MODEL, json_schema_format()),\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_sampling_params() {\n+ let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// The \"gemini\"-namespaced provider_options merge — and the default\n+/// safety_settings injection it can override.\n+#[tokio::test]\n+async fn encode_provider_options_gemini_namespace() {\n+ let capture = encode_capture(\n+ adapter(),\n+ &corpus_provider_options(\n+ MODEL,\n+ serde_json::json!({\"gemini\": {\"cached_content\": \"cachedContents/abc\"}}),\n+ ),\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_provider_options_can_override_safety_settings() {\n+ let capture = encode_capture(\n+ adapter(),\n+ &corpus_provider_options(\n+ MODEL,\n+ serde_json::json!({\"gemini\": {\"safety_settings\": []}}),\n+ ),\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_reasoning_effort_with_levels_catalog() {\n+ let catalog = support::catalog_from_toml(\n+ r#\"\n+[providers.gemini]\n+display_name = \"Gemini\"\n+adapter = \"gemini\"\n+agent_profile = \"gemini\"\n+\n+[models.\"gemini-test\"]\n+provider = \"gemini\"\n+display_name = \"Test Gemini\"\n+family = \"gemini\"\n+default = true\n+\n+[models.\"gemini-test\".limits]\n+context_window = 200000\n+max_output = 4096\n+\n+[models.\"gemini-test\".features]\n+tools = true\n+vision = true\n+reasoning = true\n+reasoning_effort = \"levels\"\n+\"#,\n+ );\n+ let request = Request {\n+ reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High),\n+ ..base_request(MODEL)\n+ };\n+ let capture = encode_capture(adapter().with_catalog(catalog), &request).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn count_tokens_wire_shape() {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(\n+ &server,\n+ \"/models/gemini-test:countTokens\",\n+ serde_json::json!({\"totalTokens\": 123}),\n+ );\n+ let adapter = adapter().with_base_url(server.base_url());\n+ let request = Request {\n+ messages: vec![Message::system(\"Be concise\"), Message::user(\"Hello\")],\n+ ..corpus_tools(MODEL, None)\n+ };\n+ let count = adapter\n+ .count_input_tokens(&request)\n+ .await\n+ .unwrap()\n+ .expect(\"gemini should count tokens\");\n+\n+ mock.assert();\n+ assert_eq!(count.input_tokens, 123);\n+ fabro_test::fabro_json_snapshot!(take_capture(&slot));\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Decode\n+// ---------------------------------------------------------------------------\n+\n+/// Runs `complete()` against a canned body and returns the decoded response\n+/// as JSON with synthetic UUIDs normalized.\n+async fn decode_response(body: serde_json::Value) -> serde_json::Value {\n+ let server = MockServer::start();\n+ let (mock, _slot) = mount_capture(&server, COMPLETE_PATH, body);\n+ let adapter = adapter().with_base_url(server.base_url());\n+ let response = adapter\n+ .complete(&base_request(MODEL))\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ let mut value = serde_json::to_value(&response).expect(\"response should serialize\");\n+ support::normalize_uuids(&mut value);\n+ value\n+}\n+\n+/// functionCall parts get synthetic UUID ids, preserve `thoughtSignature`,\n+/// and force the finish reason to ToolCalls regardless of `finishReason`.\n+#[tokio::test]\n+async fn decode_function_call_with_thought_signature() {\n+ let response = decode_response(serde_json::json!({\n+ \"candidates\": [{\n+ \"content\": {\n+ \"role\": \"model\",\n+ \"parts\": [\n+ {\"text\": \"Let me search.\"},\n+ {\n+ \"functionCall\": {\"name\": \"search\", \"args\": {\"query\": \"foo\"}},\n+ \"thoughtSignature\": \"sig_gemini_xyz\"\n+ }\n+ ]\n+ },\n+ \"finishReason\": \"STOP\"\n+ }],\n+ \"usageMetadata\": {\"promptTokenCount\": 30, \"candidatesTokenCount\": 12}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+/// The Gemini usage arithmetic: input = (prompt - cached) + tool_use_prompt;\n+/// thoughts become reasoning tokens.\n+#[tokio::test]\n+async fn decode_usage_arithmetic() {\n+ let response = decode_response(serde_json::json!({\n+ \"candidates\": [{\n+ \"content\": {\"role\": \"model\", \"parts\": [{\"text\": \"ok\"}]},\n+ \"finishReason\": \"STOP\"\n+ }],\n+ \"usageMetadata\": {\n+ \"promptTokenCount\": 100,\n+ \"candidatesTokenCount\": 50,\n+ \"thoughtsTokenCount\": 8,\n+ \"cachedContentTokenCount\": 30,\n+ \"toolUsePromptTokenCount\": 5\n+ }\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+/// `thought: true` text parts decode as Thinking content.\n+#[tokio::test]\n+async fn decode_thought_parts() {\n+ let response = decode_response(serde_json::json!({\n+ \"candidates\": [{\n+ \"content\": {\n+ \"role\": \"model\",\n+ \"parts\": [\n+ {\"text\": \"Adding the numbers.\", \"thought\": true},\n+ {\"text\": \"4.\"}\n+ ]\n+ },\n+ \"finishReason\": \"STOP\"\n+ }],\n+ \"usageMetadata\": {\"promptTokenCount\": 25, \"candidatesTokenCount\": 40}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+#[tokio::test]\n+async fn decode_max_tokens_finish_reason() {\n+ let length = decode_response(serde_json::json!({\n+ \"candidates\": [{\n+ \"content\": {\"role\": \"model\", \"parts\": [{\"text\": \"Trunc\"}]},\n+ \"finishReason\": \"MAX_TOKENS\"\n+ }],\n+ \"usageMetadata\": {\"promptTokenCount\": 10, \"candidatesTokenCount\": 128}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(length[\"finish_reason\"]);\n+}\n+\n+#[tokio::test]\n+async fn decode_safety_finish_reason() {\n+ let safety = decode_response(serde_json::json!({\n+ \"candidates\": [{\n+ \"content\": {\"role\": \"model\", \"parts\": [{\"text\": \"\"}]},\n+ \"finishReason\": \"SAFETY\"\n+ }],\n+ \"usageMetadata\": {\"promptTokenCount\": 10, \"candidatesTokenCount\": 0}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(safety[\"finish_reason\"]);\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Stream\n+// ---------------------------------------------------------------------------\n+\n+/// Shared setup for the happy-path text stream; the request and event halves\n+/// are pinned by separate tests.\n+async fn stream_text_happy_path_capture() -> (WireCapture, Vec) {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hel\"}]}}]}\"#,\n+ r#\"{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"lo\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":11,\"candidatesTokenCount\":5}}\"#,\n+ ]);\n+ stream_capture(adapter(), &base_request(MODEL), &sse).await\n+}\n+\n+/// The captured request pins model-in-URL and `?alt=sse` on the wire.\n+#[tokio::test]\n+async fn stream_text_happy_path_request() {\n+ let (capture, _) = stream_text_happy_path_capture().await;\n+ fabro_test::fabro_json_snapshot!(capture);\n+}\n+\n+#[tokio::test]\n+async fn stream_text_happy_path_events() {\n+ let (_, events) = stream_text_happy_path_capture().await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+#[tokio::test]\n+async fn stream_function_call() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"search\",\"args\":{\"query\":\"foo\"}},\"thoughtSignature\":\"sig_stream_g\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":20,\"candidatesTokenCount\":9}}\"#,\n+ ]);\n+ let (_capture, events) = stream_capture(\n+ adapter(),\n+ &corpus_tools(MODEL, Some(ToolChoice::Auto)),\n+ &sse,\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+#[tokio::test]\n+async fn stream_thought_parts() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Let me think\",\"thought\":true}]}}]}\"#,\n+ r#\"{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"4.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":12,\"thoughtsTokenCount\":6}}\"#,\n+ ]);\n+ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+/// The Gemini decoder synthesizes a `Finish` on byte-stream end\n+/// unconditionally — even when no chunk carried a `finishReason`.\n+#[tokio::test]\n+async fn stream_end_synthesizes_finish_without_finish_reason() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hello\"}]}}]}\"#,\n+ ]);\n+ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/mod.rs b/lib/crates/fabro-llm/tests/it/wire/mod.rs\nnew file mode 100644\nindex 000000000..16ad18987\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/mod.rs\n@@ -0,0 +1,22 @@\n+//! Wire snapshot tests pinning per-dialect encode/decode behavior.\n+//!\n+//! Each test points a real adapter at a local httpmock server, side-channels\n+//! the full received request (method, path, headers, body) out of an\n+//! `is_true` matcher closure, responds with a canned provider body, and\n+//! snapshots both the captured wire request (encode) and the decoded\n+//! canonical `Response` (decode). The codec extraction PRs must keep these\n+//! snapshot values identical.\n+//!\n+//! The anthropic/gemini dialects have no twin coverage, so these snapshots\n+//! are the only behavior net for those extractions.\n+//!\n+//! Snapshots are stored externally under `snapshots/` (via\n+//! `fabro_test::fabro_json_snapshot!(value)` with no inline literal) to keep\n+//! these source files small. Review and accept with `cargo insta`\n+//! (`pending-snapshots` then `accept`), per CLAUDE.md. A few tests assert two\n+//! snapshots in one function; insta names the second `-2.snap`.\n+\n+mod anthropic;\n+mod gemini;\n+mod openai_compatible;\n+mod openai_responses;\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs b/lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\nnew file mode 100644\nindex 000000000..661141eb4\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n@@ -0,0 +1,439 @@\n+//! Wire snapshots for the OpenAI Chat Completions dialect served by\n+//! `OpenAiCompatibleAdapter` (kimi, zai, minimax, venice, inception, ollama,\n+//! litellm — all config-only routes over this adapter).\n+\n+use fabro_llm::provider::ProviderAdapter;\n+use fabro_llm::providers::OpenAiCompatibleAdapter;\n+use fabro_llm::types::{\n+ Message, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition,\n+};\n+use httpmock::prelude::*;\n+\n+use crate::support::{\n+ self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments,\n+ corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format,\n+ corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools,\n+ corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture,\n+};\n+\n+const MODEL: &str = \"test-model\";\n+\n+/// Fixed `created` timestamp for canned bodies (named to satisfy clippy's\n+/// unreadable-literal lint without touching the JSON wire value).\n+const CREATED_TS: i64 = 1_700_000_000;\n+\n+/// Minimal valid Chat Completions body for encode-side tests.\n+fn minimal_body() -> serde_json::Value {\n+ serde_json::json!({\n+ \"id\": \"chatcmpl_test\",\n+ \"object\": \"chat.completion\",\n+ \"created\": CREATED_TS,\n+ \"model\": MODEL,\n+ \"choices\": [{\n+ \"index\": 0,\n+ \"message\": {\"role\": \"assistant\", \"content\": \"ok\"},\n+ \"finish_reason\": \"stop\"\n+ }],\n+ \"usage\": {\"prompt_tokens\": 1, \"completion_tokens\": 1, \"total_tokens\": 2}\n+ })\n+}\n+\n+fn adapter(server: &MockServer) -> OpenAiCompatibleAdapter {\n+ OpenAiCompatibleAdapter::new(\"test-key\", server.base_url())\n+}\n+\n+/// Runs `complete()` against a capture mock and returns the captured wire\n+/// request.\n+async fn encode_capture_with(\n+ request: &Request,\n+ configure: impl FnOnce(OpenAiCompatibleAdapter) -> OpenAiCompatibleAdapter,\n+) -> WireCapture {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(&server, \"/chat/completions\", minimal_body());\n+ let adapter = configure(adapter(&server));\n+ adapter\n+ .complete(request)\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ take_capture(&slot)\n+}\n+\n+async fn encode_capture(request: &Request) -> WireCapture {\n+ encode_capture_with(request, |adapter| adapter).await\n+}\n+\n+/// Runs `stream()` against an SSE transcript and returns the captured wire\n+/// request plus every emitted stream item as JSON.\n+async fn stream_capture(\n+ request: &Request,\n+ sse_body: &str,\n+) -> (WireCapture, Vec) {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture_sse(&server, \"/chat/completions\", sse_body);\n+ let adapter = adapter(&server);\n+ let events = support::collect_stream_events(&adapter, request).await;\n+ mock.assert();\n+ (take_capture(&slot), events)\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Round trip (encode + decode)\n+// ---------------------------------------------------------------------------\n+\n+/// Shared setup for the system+tools round trip; the encode and decode halves\n+/// are pinned by separate tests.\n+async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(\n+ &server,\n+ \"/chat/completions\",\n+ serde_json::json!({\n+ \"id\": \"chatcmpl_test\",\n+ \"object\": \"chat.completion\",\n+ \"created\": CREATED_TS,\n+ \"model\": MODEL,\n+ \"choices\": [{\n+ \"index\": 0,\n+ \"message\": {\"role\": \"assistant\", \"content\": \"Hello back\"},\n+ \"finish_reason\": \"stop\"\n+ }],\n+ \"usage\": {\"prompt_tokens\": 42, \"completion_tokens\": 7, \"total_tokens\": 49}\n+ }),\n+ );\n+\n+ let adapter = adapter(&server);\n+ let request = Request {\n+ messages: vec![Message::system(\"Be concise\"), Message::user(\"Hello\")],\n+ tools: Some(vec![ToolDefinition::function(\n+ \"search\",\n+ \"Search files\",\n+ serde_json::json!({\"type\": \"object\", \"properties\": {\"query\": {\"type\": \"string\"}}}),\n+ )]),\n+ temperature: Some(0.5),\n+ ..base_request(MODEL)\n+ };\n+\n+ let response = adapter\n+ .complete(&request)\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ (take_capture(&slot), response)\n+}\n+\n+#[tokio::test]\n+async fn system_and_tools_encode() {\n+ let (capture, _) = system_and_tools_roundtrip().await;\n+ fabro_test::fabro_json_snapshot!(capture);\n+}\n+\n+#[tokio::test]\n+async fn system_and_tools_decode() {\n+ let (_, response) = system_and_tools_roundtrip().await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Encode\n+// ---------------------------------------------------------------------------\n+\n+#[tokio::test]\n+async fn encode_multi_turn() {\n+ let capture = encode_capture(&corpus_multi_turn(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_auto() {\n+ let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::Auto))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_required() {\n+ let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::Required))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_named() {\n+ let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::named(\"search\")))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_none() {\n+ let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::None))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_round_trip() {\n+ let capture = encode_capture(&corpus_tool_round_trip(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// Assistant thinking parts echo back as `reasoning_content` (Kimi-motivated,\n+/// applies to every compat assistant message).\n+#[tokio::test]\n+async fn encode_thinking_round_trip_as_reasoning_content() {\n+ let capture = encode_capture(&corpus_thinking_round_trip(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// The compat encoder performs no attachment I/O: images are dropped\n+/// outright, documents become fallback text.\n+#[tokio::test]\n+async fn encode_inline_attachments() {\n+ let capture = encode_capture(&corpus_inline_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_url_attachments() {\n+ let capture = encode_capture(&corpus_url_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_bad_file_path_attachments() {\n+ let capture = encode_capture(&corpus_bad_file_path_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_audio_attachment() {\n+ let capture = encode_capture(&corpus_audio_attachment(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_response_format_json_object() {\n+ let format = ResponseFormat {\n+ kind: ResponseFormatType::JsonObject,\n+ json_schema: None,\n+ strict: false,\n+ };\n+ let capture = encode_capture(&corpus_response_format(MODEL, format)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_response_format_json_schema() {\n+ let capture = encode_capture(&corpus_response_format(MODEL, json_schema_format())).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_sampling_params() {\n+ let capture = encode_capture(&corpus_sampling_params(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// The provider_options namespace key is the runtime adapter NAME, not a\n+/// static \"openai_compatible\" key (pinned in-module by\n+/// `provider_options_uses_adapter_name`; this pins it from outside).\n+#[tokio::test]\n+async fn encode_provider_options_keyed_by_adapter_name() {\n+ let request = corpus_provider_options(\n+ MODEL,\n+ serde_json::json!({\"kimi\": {\"repetition_penalty\": 1.2}}),\n+ );\n+ let capture = encode_capture_with(&request, |adapter| adapter.with_name(\"kimi\")).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// Options under a key that does not match the adapter name must not merge.\n+#[tokio::test]\n+async fn encode_provider_options_other_namespace_ignored() {\n+ let request = corpus_provider_options(\n+ MODEL,\n+ serde_json::json!({\"openai\": {\"repetition_penalty\": 1.2}}),\n+ );\n+ let capture = encode_capture_with(&request, |adapter| adapter.with_name(\"kimi\")).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// The compat adapter has no count-tokens wire route.\n+#[tokio::test]\n+async fn count_input_tokens_unavailable() {\n+ let server = MockServer::start();\n+ let adapter = adapter(&server);\n+ let count = adapter\n+ .count_input_tokens(&base_request(MODEL))\n+ .await\n+ .unwrap();\n+ assert!(count.is_none());\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Decode\n+// ---------------------------------------------------------------------------\n+\n+async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response {\n+ let server = MockServer::start();\n+ let (mock, _slot) = mount_capture(&server, \"/chat/completions\", body);\n+ let adapter = adapter(&server);\n+ let response = adapter\n+ .complete(&base_request(MODEL))\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ response\n+}\n+\n+#[tokio::test]\n+async fn decode_tool_calls_with_string_arguments() {\n+ let response = decode_response(serde_json::json!({\n+ \"id\": \"chatcmpl_test\",\n+ \"object\": \"chat.completion\",\n+ \"created\": CREATED_TS,\n+ \"model\": MODEL,\n+ \"choices\": [{\n+ \"index\": 0,\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": null,\n+ \"tool_calls\": [{\n+ \"id\": \"call_abc\",\n+ \"type\": \"function\",\n+ \"function\": {\"name\": \"search\", \"arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"}\n+ }]\n+ },\n+ \"finish_reason\": \"tool_calls\"\n+ }],\n+ \"usage\": {\"prompt_tokens\": 30, \"completion_tokens\": 12, \"total_tokens\": 42}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+#[tokio::test]\n+async fn decode_reasoning_content_as_thinking() {\n+ let response = decode_response(serde_json::json!({\n+ \"id\": \"chatcmpl_test\",\n+ \"object\": \"chat.completion\",\n+ \"created\": CREATED_TS,\n+ \"model\": MODEL,\n+ \"choices\": [{\n+ \"index\": 0,\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": \"4.\",\n+ \"reasoning_content\": \"The user wants 2+2.\"\n+ },\n+ \"finish_reason\": \"stop\"\n+ }],\n+ \"usage\": {\"prompt_tokens\": 25, \"completion_tokens\": 40, \"total_tokens\": 65}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+/// Compat usage reads only prompt/completion tokens; cached-token details are\n+/// ignored today (parsing them is a 438-redo behavior change).\n+#[tokio::test]\n+async fn decode_usage_ignores_token_details() {\n+ let response = decode_response(serde_json::json!({\n+ \"id\": \"chatcmpl_test\",\n+ \"object\": \"chat.completion\",\n+ \"created\": CREATED_TS,\n+ \"model\": MODEL,\n+ \"choices\": [{\n+ \"index\": 0,\n+ \"message\": {\"role\": \"assistant\", \"content\": \"ok\"},\n+ \"finish_reason\": \"length\"\n+ }],\n+ \"usage\": {\n+ \"prompt_tokens\": 100,\n+ \"completion_tokens\": 50,\n+ \"total_tokens\": 150,\n+ \"prompt_tokens_details\": {\"cached_tokens\": 80},\n+ \"completion_tokens_details\": {\"reasoning_tokens\": 20}\n+ }\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Stream\n+// ---------------------------------------------------------------------------\n+\n+/// Shared setup for the happy-path text stream; the request and event halves\n+/// are pinned by separate tests.\n+async fn stream_text_happy_path_capture() -> (WireCapture, Vec) {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hel\"},\"finish_reason\":null}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"},\"finish_reason\":null}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[],\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5,\"total_tokens\":16}}\"#,\n+ \"[DONE]\",\n+ ]);\n+ stream_capture(&base_request(MODEL), &sse).await\n+}\n+\n+/// The captured request pins the stream flag on the wire.\n+#[tokio::test]\n+async fn stream_text_happy_path_request() {\n+ let (capture, _) = stream_text_happy_path_capture().await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn stream_text_happy_path_events() {\n+ let (_, events) = stream_text_happy_path_capture().await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+#[tokio::test]\n+async fn stream_tool_call_deltas() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_abc\",\"type\":\"function\",\"function\":{\"name\":\"search\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"qu\"}}]},\"finish_reason\":null}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ery\\\":\\\"foo\\\"}\"}}]},\"finish_reason\":null}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":9,\"total_tokens\":29}}\"#,\n+ \"[DONE]\",\n+ ]);\n+ let (_capture, events) =\n+ stream_capture(&corpus_tools(MODEL, Some(ToolChoice::Auto)), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+#[tokio::test]\n+async fn stream_reasoning_content_deltas() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\"Let me \"},\"finish_reason\":null}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"think\"},\"finish_reason\":null}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"4.\"},\"finish_reason\":null}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\"#,\n+ \"[DONE]\",\n+ ]);\n+ let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+/// Minimax tolerance: a stream that ends without `[DONE]` still synthesizes\n+/// the finish — but only because content was started.\n+#[tokio::test]\n+async fn stream_without_done_synthesizes_finish_when_content_started() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"finish_reason\":null}]}\"#,\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\"#,\n+ ]);\n+ let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+/// The other half of the minimax contract: no content started and no\n+/// `[DONE]` — nothing is synthesized.\n+#[tokio::test]\n+async fn stream_without_done_or_content_synthesizes_nothing() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"id\":\"chatcmpl_stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\"#,\n+ ]);\n+ let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/openai_responses.rs b/lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\nnew file mode 100644\nindex 000000000..d3434f018\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n@@ -0,0 +1,563 @@\n+//! Wire snapshots for the OpenAI Responses API dialect (`POST /responses`).\n+\n+use fabro_llm::provider::ProviderAdapter;\n+use fabro_llm::providers::OpenAiAdapter;\n+use fabro_llm::types::{\n+ ContentPart, Message, Request, ResponseFormat, ResponseFormatType, Role, ToolCall, ToolChoice,\n+ ToolDefinition,\n+};\n+use httpmock::prelude::*;\n+\n+use crate::support::{\n+ self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments,\n+ corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format,\n+ corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools,\n+ corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture,\n+};\n+\n+const MODEL: &str = \"gpt-test\";\n+\n+/// Minimal valid Responses API body for encode-side tests.\n+fn minimal_body() -> serde_json::Value {\n+ serde_json::json!({\n+ \"id\": \"resp_test\",\n+ \"object\": \"response\",\n+ \"model\": MODEL,\n+ \"status\": \"completed\",\n+ \"output\": [{\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_out\",\n+ \"content\": [{\"type\": \"output_text\", \"text\": \"ok\"}]\n+ }],\n+ \"usage\": {\"input_tokens\": 1, \"output_tokens\": 1}\n+ })\n+}\n+\n+fn adapter() -> OpenAiAdapter {\n+ OpenAiAdapter::new(\"test-key\")\n+}\n+\n+/// Runs `complete()` against a capture mock and returns the captured wire\n+/// request.\n+async fn encode_capture(adapter: OpenAiAdapter, request: &Request) -> WireCapture {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(&server, \"/responses\", minimal_body());\n+ let adapter = adapter.with_base_url(server.base_url());\n+ adapter\n+ .complete(request)\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ take_capture(&slot)\n+}\n+\n+/// Runs `stream()` against an SSE transcript and returns the captured wire\n+/// request plus every emitted stream item as JSON.\n+async fn stream_capture(\n+ adapter: OpenAiAdapter,\n+ request: &Request,\n+ sse_body: &str,\n+) -> (WireCapture, Vec) {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture_sse(&server, \"/responses\", sse_body);\n+ let adapter = adapter.with_base_url(server.base_url());\n+ let events = support::collect_stream_events(&adapter, request).await;\n+ mock.assert();\n+ (take_capture(&slot), events)\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Round trip (encode + decode)\n+// ---------------------------------------------------------------------------\n+\n+/// Shared setup for the system+tools round trip; the encode and decode halves\n+/// are pinned by separate tests.\n+async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(\n+ &server,\n+ \"/responses\",\n+ serde_json::json!({\n+ \"id\": \"resp_test\",\n+ \"object\": \"response\",\n+ \"model\": MODEL,\n+ \"status\": \"completed\",\n+ \"output\": [{\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_out\",\n+ \"content\": [{\"type\": \"output_text\", \"text\": \"Hello back\"}]\n+ }],\n+ \"usage\": {\n+ \"input_tokens\": 42,\n+ \"output_tokens\": 7,\n+ \"input_tokens_details\": {\"cached_tokens\": 10},\n+ \"output_tokens_details\": {\"reasoning_tokens\": 3}\n+ }\n+ }),\n+ );\n+\n+ let adapter = adapter().with_base_url(server.base_url());\n+ let request = Request {\n+ messages: vec![Message::system(\"Be concise\"), Message::user(\"Hello\")],\n+ tools: Some(vec![ToolDefinition::function(\n+ \"search\",\n+ \"Search files\",\n+ serde_json::json!({\"type\": \"object\", \"properties\": {\"query\": {\"type\": \"string\"}}}),\n+ )]),\n+ temperature: Some(0.5),\n+ ..base_request(MODEL)\n+ };\n+\n+ let response = adapter\n+ .complete(&request)\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ (take_capture(&slot), response)\n+}\n+\n+#[tokio::test]\n+async fn system_and_tools_encode() {\n+ let (capture, _) = system_and_tools_roundtrip().await;\n+ fabro_test::fabro_json_snapshot!(capture);\n+}\n+\n+#[tokio::test]\n+async fn system_and_tools_decode() {\n+ let (_, response) = system_and_tools_roundtrip().await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Encode\n+// ---------------------------------------------------------------------------\n+\n+#[tokio::test]\n+async fn encode_multi_turn() {\n+ let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_auto() {\n+ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_required() {\n+ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_named() {\n+ let capture = encode_capture(\n+ adapter(),\n+ &corpus_tools(MODEL, Some(ToolChoice::named(\"search\"))),\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_choice_none() {\n+ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_tool_round_trip() {\n+ let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// A tool call that decoded with an item-level id (`fc_…`) in\n+/// provider_metadata re-encodes with the dual ids split correctly.\n+#[tokio::test]\n+async fn encode_dual_id_tool_round_trip() {\n+ let mut tool_call = ToolCall::new(\"call_abc\", \"search\", serde_json::json!({\"query\": \"foo\"}));\n+ tool_call.provider_metadata = Some(serde_json::json!({\"id\": \"fc_123\"}));\n+ let mut request = corpus_tools(MODEL, None);\n+ request.messages = vec![\n+ Message::user(\"Find foo\"),\n+ Message {\n+ role: Role::Assistant,\n+ content: vec![ContentPart::ToolCall(tool_call)],\n+ name: None,\n+ tool_call_id: None,\n+ },\n+ Message::tool_result(\n+ \"call_abc\",\n+ serde_json::Value::String(\"2 matches\".to_string()),\n+ false,\n+ ),\n+ ];\n+ let capture = encode_capture(adapter(), &request).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// Opaque OpenAI items (reasoning / message) round-trip verbatim into the\n+/// input array.\n+#[tokio::test]\n+async fn encode_opaque_items_round_trip() {\n+ let request = Request {\n+ messages: vec![\n+ Message::user(\"Think about 2+2.\"),\n+ Message {\n+ role: Role::Assistant,\n+ content: vec![\n+ ContentPart::Other {\n+ kind: ContentPart::OPENAI_REASONING.to_string(),\n+ data: serde_json::json!({\n+ \"type\": \"reasoning\",\n+ \"id\": \"rs_1\",\n+ \"summary\": [{\"type\": \"summary_text\", \"text\": \"Adding.\"}]\n+ }),\n+ },\n+ ContentPart::Other {\n+ kind: ContentPart::OPENAI_MESSAGE.to_string(),\n+ data: serde_json::json!({\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_1\",\n+ \"content\": [{\"type\": \"output_text\", \"text\": \"4.\"}]\n+ }),\n+ },\n+ ],\n+ name: None,\n+ tool_call_id: None,\n+ },\n+ Message::user(\"Now 3+3?\"),\n+ ],\n+ ..base_request(MODEL)\n+ };\n+ let capture = encode_capture(adapter(), &request).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// Canonical Thinking parts (anthropic-style) — distinct from the opaque\n+/// reasoning round-trip above.\n+#[tokio::test]\n+async fn encode_thinking_round_trip() {\n+ let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_inline_attachments() {\n+ let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_url_attachments() {\n+ let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_bad_file_path_attachments_dropped() {\n+ let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_audio_attachment() {\n+ let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_response_format_json_object() {\n+ let format = ResponseFormat {\n+ kind: ResponseFormatType::JsonObject,\n+ json_schema: None,\n+ strict: false,\n+ };\n+ let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_response_format_json_schema() {\n+ let capture = encode_capture(\n+ adapter(),\n+ &corpus_response_format(MODEL, json_schema_format()),\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_sampling_params() {\n+ let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_provider_options_openai_namespace() {\n+ let capture = encode_capture(\n+ adapter(),\n+ &corpus_provider_options(MODEL, serde_json::json!({\"openai\": {\"seed\": 42}})),\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn encode_reasoning_effort_with_levels_catalog() {\n+ let catalog = support::catalog_from_toml(\n+ r#\"\n+[providers.openai]\n+display_name = \"OpenAI\"\n+adapter = \"openai\"\n+agent_profile = \"openai\"\n+\n+[models.\"test-gpt\"]\n+provider = \"openai\"\n+display_name = \"Test GPT\"\n+family = \"gpt\"\n+default = true\n+\n+[models.\"test-gpt\".limits]\n+context_window = 200000\n+max_output = 4096\n+\n+[models.\"test-gpt\".features]\n+tools = true\n+vision = true\n+reasoning = true\n+reasoning_effort = \"levels\"\n+\"#,\n+ );\n+ let request = Request {\n+ reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High),\n+ ..base_request(\"test-gpt\")\n+ };\n+ let capture = encode_capture(adapter().with_catalog(catalog), &request).await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+/// Codex mode forces streaming for `complete()` and omits sampling params\n+/// from the encoded body.\n+#[tokio::test]\n+async fn encode_codex_mode_forces_streaming_and_omits_params() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"type\":\"response.created\",\"response\":{\"id\":\"resp_codex\",\"model\":\"gpt-test\"}}\"#,\n+ r#\"{\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\"#,\n+ r#\"{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_codex\",\"model\":\"gpt-test\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\"#,\n+ ]);\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture_sse(&server, \"/responses\", &sse);\n+ let adapter = OpenAiAdapter::new(\"test-key\")\n+ .with_codex_mode()\n+ .with_base_url(server.base_url());\n+ let request = Request {\n+ messages: vec![Message::system(\"Be concise\"), Message::user(\"Hello\")],\n+ temperature: Some(0.5),\n+ top_p: Some(0.9),\n+ ..base_request(MODEL)\n+ };\n+ adapter.complete(&request).await.unwrap();\n+ mock.assert();\n+ fabro_test::fabro_json_snapshot!(take_capture(&slot));\n+}\n+\n+#[tokio::test]\n+async fn count_tokens_wire_shape() {\n+ let server = MockServer::start();\n+ let (mock, slot) = mount_capture(\n+ &server,\n+ \"/responses/input_tokens\",\n+ serde_json::json!({\"input_tokens\": 123, \"object\": \"response.input_tokens\"}),\n+ );\n+ let adapter = adapter().with_base_url(server.base_url());\n+ let request = Request {\n+ messages: vec![Message::system(\"Be concise\"), Message::user(\"Hello\")],\n+ ..corpus_tools(MODEL, None)\n+ };\n+ let count = adapter\n+ .count_input_tokens(&request)\n+ .await\n+ .unwrap()\n+ .expect(\"openai should count tokens\");\n+\n+ mock.assert();\n+ assert_eq!(count.input_tokens, 123);\n+ fabro_test::fabro_json_snapshot!(take_capture(&slot));\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Decode\n+// ---------------------------------------------------------------------------\n+\n+async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response {\n+ let server = MockServer::start();\n+ let (mock, _slot) = mount_capture(&server, \"/responses\", body);\n+ let adapter = adapter().with_base_url(server.base_url());\n+ let response = adapter\n+ .complete(&base_request(MODEL))\n+ .await\n+ .expect(\"complete should succeed\");\n+ mock.assert();\n+ response\n+}\n+\n+/// The Responses usage arithmetic: cached tokens are subtracted from input,\n+/// reasoning tokens from output.\n+#[tokio::test]\n+async fn decode_usage_subtracts_cached_and_reasoning() {\n+ let response = decode_response(serde_json::json!({\n+ \"id\": \"resp_test\",\n+ \"object\": \"response\",\n+ \"model\": MODEL,\n+ \"status\": \"completed\",\n+ \"output\": [{\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_out\",\n+ \"content\": [{\"type\": \"output_text\", \"text\": \"ok\"}]\n+ }],\n+ \"usage\": {\n+ \"input_tokens\": 100,\n+ \"output_tokens\": 50,\n+ \"input_tokens_details\": {\"cached_tokens\": 80},\n+ \"output_tokens_details\": {\"reasoning_tokens\": 20}\n+ }\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+/// Reasoning and function_call output items: reasoning becomes an opaque\n+/// round-trip part, function_call splits dual ids into id + metadata.\n+#[tokio::test]\n+async fn decode_reasoning_and_function_call_items() {\n+ let response = decode_response(serde_json::json!({\n+ \"id\": \"resp_test\",\n+ \"object\": \"response\",\n+ \"model\": MODEL,\n+ \"status\": \"completed\",\n+ \"output\": [\n+ {\n+ \"type\": \"reasoning\",\n+ \"id\": \"rs_1\",\n+ \"summary\": [{\"type\": \"summary_text\", \"text\": \"Searching.\"}]\n+ },\n+ {\n+ \"type\": \"function_call\",\n+ \"id\": \"fc_123\",\n+ \"call_id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ }\n+ ],\n+ \"usage\": {\"input_tokens\": 30, \"output_tokens\": 12}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+#[tokio::test]\n+async fn decode_incomplete_status_maps_to_length() {\n+ let response = decode_response(serde_json::json!({\n+ \"id\": \"resp_test\",\n+ \"object\": \"response\",\n+ \"model\": MODEL,\n+ \"status\": \"incomplete\",\n+ \"output\": [{\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_out\",\n+ \"content\": [{\"type\": \"output_text\", \"text\": \"Truncated\"}]\n+ }],\n+ \"usage\": {\"input_tokens\": 10, \"output_tokens\": 128}\n+ }))\n+ .await;\n+ fabro_test::fabro_json_snapshot!(response);\n+}\n+\n+// ---------------------------------------------------------------------------\n+// Stream\n+// ---------------------------------------------------------------------------\n+\n+/// Shared setup for the happy-path text stream; the request and event halves\n+/// are pinned by separate tests.\n+async fn stream_text_happy_path_capture() -> (WireCapture, Vec) {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"type\":\"response.created\",\"response\":{\"id\":\"resp_stream\",\"model\":\"gpt-test\"}}\"#,\n+ r#\"{\"type\":\"response.output_text.delta\",\"delta\":\"Hel\"}\"#,\n+ r#\"{\"type\":\"response.output_text.delta\",\"delta\":\"lo\"}\"#,\n+ r#\"{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_stream\",\"model\":\"gpt-test\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":11,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":2},\"output_tokens_details\":{\"reasoning_tokens\":1}}}}\"#,\n+ ]);\n+ stream_capture(adapter(), &base_request(MODEL), &sse).await\n+}\n+\n+/// The captured request pins the stream flag (and `include`) on the wire.\n+#[tokio::test]\n+async fn stream_text_happy_path_request() {\n+ let (capture, _) = stream_text_happy_path_capture().await;\n+ fabro_test::fabro_json_snapshot!(capture.body);\n+}\n+\n+#[tokio::test]\n+async fn stream_text_happy_path_events() {\n+ let (_, events) = stream_text_happy_path_capture().await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+#[tokio::test]\n+async fn stream_tool_call_deltas() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"type\":\"response.created\",\"response\":{\"id\":\"resp_stream\",\"model\":\"gpt-test\"}}\"#,\n+ r#\"{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_123\",\"call_id\":\"call_abc\",\"name\":\"search\",\"delta\":\"{\\\"qu\"}\"#,\n+ r#\"{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_123\",\"call_id\":\"call_abc\",\"delta\":\"ery\\\":\\\"foo\\\"}\"}\"#,\n+ r#\"{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_123\",\"call_id\":\"call_abc\",\"name\":\"search\",\"arguments\":\"{\\\"query\\\":\\\"foo\\\"}\"}}\"#,\n+ r#\"{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_stream\",\"model\":\"gpt-test\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":20,\"output_tokens\":9}}}\"#,\n+ ]);\n+ let (_capture, events) = stream_capture(\n+ adapter(),\n+ &corpus_tools(MODEL, Some(ToolChoice::Auto)),\n+ &sse,\n+ )\n+ .await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+#[tokio::test]\n+async fn stream_reasoning_summary_deltas() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"type\":\"response.created\",\"response\":{\"id\":\"resp_stream\",\"model\":\"gpt-test\"}}\"#,\n+ r#\"{\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"Let me \"}\"#,\n+ r#\"{\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"think\"}\"#,\n+ r#\"{\"type\":\"response.output_text.delta\",\"delta\":\"4.\"}\"#,\n+ r#\"{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_stream\",\"model\":\"gpt-test\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":15,\"output_tokens\":12,\"output_tokens_details\":{\"reasoning_tokens\":8}}}}\"#,\n+ ]);\n+ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+#[tokio::test]\n+async fn stream_failed_event_maps_to_error() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"type\":\"response.created\",\"response\":{\"id\":\"resp_stream\",\"model\":\"gpt-test\"}}\"#,\n+ r#\"{\"type\":\"response.failed\",\"response\":{\"id\":\"resp_stream\",\"error\":{\"code\":\"server_error\",\"message\":\"boom\"}}}\"#,\n+ ]);\n+ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\n+\n+/// `response.incomplete` finishes the stream with `Length`.\n+#[tokio::test]\n+async fn stream_incomplete_maps_to_length() {\n+ let sse = support::sse_data_transcript(&[\n+ r#\"{\"type\":\"response.created\",\"response\":{\"id\":\"resp_stream\",\"model\":\"gpt-test\"}}\"#,\n+ r#\"{\"type\":\"response.output_text.delta\",\"delta\":\"Trunc\"}\"#,\n+ r#\"{\"type\":\"response.incomplete\",\"response\":{\"id\":\"resp_stream\",\"model\":\"gpt-test\",\"status\":\"incomplete\",\"output\":[],\"usage\":{\"input_tokens\":10,\"output_tokens\":128}}}\"#,\n+ ]);\n+ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await;\n+ fabro_test::fabro_json_snapshot!(events);\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap\nnew file mode 100644\nindex 000000000..566a6a703\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap\n@@ -0,0 +1,78 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/messages/count_tokens\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"anthropic-version\",\n+ \"2023-06-01\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"412\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ],\n+ [\n+ \"x-api-key\",\n+ \"test-key\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"system\": \"Be concise\",\n+ \"tools\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap\nnew file mode 100644\nindex 000000000..7c2a3b747\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap\n@@ -0,0 +1,48 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"msg_test\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"provider\": \"anthropic\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Truncated answe\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"length\",\n+ \"usage\": {\n+ \"input_tokens\": 10,\n+ \"output_tokens\": 128,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"msg_test\",\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Truncated answe\"\n+ }\n+ ],\n+ \"stop_reason\": \"max_tokens\",\n+ \"stop_sequence\": null,\n+ \"usage\": {\n+ \"input_tokens\": 10,\n+ \"output_tokens\": 128\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap\nnew file mode 100644\nindex 000000000..9e3ef7bfd\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap\n@@ -0,0 +1,73 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"msg_test\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"provider\": \"anthropic\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"thinking\",\n+ \"data\": {\n+ \"text\": \"Step one.\",\n+ \"signature\": \"sig_decode_abc\",\n+ \"redacted\": false\n+ }\n+ },\n+ {\n+ \"kind\": \"redacted_thinking\",\n+ \"data\": {\n+ \"text\": \"opaque-blob\",\n+ \"signature\": null,\n+ \"redacted\": true\n+ }\n+ },\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Done.\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 25,\n+ \"output_tokens\": 40,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"msg_test\",\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"content\": [\n+ {\n+ \"type\": \"thinking\",\n+ \"thinking\": \"Step one.\",\n+ \"signature\": \"sig_decode_abc\"\n+ },\n+ {\n+ \"type\": \"redacted_thinking\",\n+ \"data\": \"opaque-blob\"\n+ },\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Done.\"\n+ }\n+ ],\n+ \"stop_reason\": \"end_turn\",\n+ \"stop_sequence\": null,\n+ \"usage\": {\n+ \"input_tokens\": 25,\n+ \"output_tokens\": 40\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap\nnew file mode 100644\nindex 000000000..dac531d55\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap\n@@ -0,0 +1,68 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"msg_test\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"provider\": \"anthropic\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Let me search.\"\n+ },\n+ {\n+ \"kind\": \"tool_call\",\n+ \"data\": {\n+ \"id\": \"toolu_01\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": null\n+ }\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 30,\n+ \"output_tokens\": 12,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"msg_test\",\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Let me search.\"\n+ },\n+ {\n+ \"type\": \"tool_use\",\n+ \"id\": \"toolu_01\",\n+ \"name\": \"search\",\n+ \"input\": {\n+ \"query\": \"foo\"\n+ }\n+ }\n+ ],\n+ \"stop_reason\": \"tool_use\",\n+ \"stop_sequence\": null,\n+ \"usage\": {\n+ \"input_tokens\": 30,\n+ \"output_tokens\": 12\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap\nnew file mode 100644\nindex 000000000..69a05af0e\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap\n@@ -0,0 +1,24 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Transcribe this.\"\n+ },\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"[Audio content not supported by this provider]\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": []\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap\nnew file mode 100644\nindex 000000000..498b4748b\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap\n@@ -0,0 +1,20 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Describe these attachments.\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": []\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap\nnew file mode 100644\nindex 000000000..443e1e221\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap\n@@ -0,0 +1,36 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Describe these attachments.\"\n+ },\n+ {\n+ \"type\": \"image\",\n+ \"source\": {\n+ \"type\": \"base64\",\n+ \"media_type\": \"image/png\",\n+ \"data\": \"ZmFrZS1wbmctYnl0ZXM=\"\n+ }\n+ },\n+ {\n+ \"type\": \"document\",\n+ \"source\": {\n+ \"type\": \"base64\",\n+ \"media_type\": \"application/pdf\",\n+ \"data\": \"ZmFrZS1wZGYtYnl0ZXM=\"\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": []\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap\nnew file mode 100644\nindex 000000000..bc576585a\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap\n@@ -0,0 +1,69 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/messages\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"anthropic-version\",\n+ \"2023-06-01\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"340\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ],\n+ [\n+ \"x-api-key\",\n+ \"test-key\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"What is the capital of France?\"\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Paris.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"And of Spain?\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"system\": \"You are a terse assistant.\",\n+ \"stop_sequences\": []\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap\nnew file mode 100644\nindex 000000000..f29eca145\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap\n@@ -0,0 +1,95 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/messages\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"anthropic-beta\",\n+ \"prompt-caching-2024-07-31\"\n+ ],\n+ [\n+ \"anthropic-version\",\n+ \"2023-06-01\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"559\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ],\n+ [\n+ \"x-api-key\",\n+ \"test-key\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"model\": \"test-claude\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Review this.\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"system\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"You are a careful reviewer.\",\n+ \"cache_control\": {\n+ \"type\": \"ephemeral\"\n+ }\n+ }\n+ ],\n+ \"stop_sequences\": [],\n+ \"tools\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ },\n+ \"cache_control\": {\n+ \"type\": \"ephemeral\"\n+ }\n+ }\n+ ]\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap\nnew file mode 100644\nindex 000000000..1475f5820\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap\n@@ -0,0 +1,21 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": [],\n+ \"top_k\": 5\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap\nnew file mode 100644\nindex 000000000..92b9f5904\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap\n@@ -0,0 +1,23 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-claude\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": [],\n+ \"output_config\": {\n+ \"effort\": \"high\"\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap\nnew file mode 100644\nindex 000000000..fe60412fb\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap\n@@ -0,0 +1,21 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"system\": \"You must respond with valid JSON only, no other text.\",\n+ \"stop_sequences\": []\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap\nnew file mode 100644\nindex 000000000..e757af0a9\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap\n@@ -0,0 +1,41 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": [],\n+ \"tools\": [\n+ {\n+ \"name\": \"json_output\",\n+ \"description\": \"Output the requested structured data\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"answer\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"answer\"\n+ ]\n+ }\n+ }\n+ ],\n+ \"tool_choice\": {\n+ \"type\": \"tool\",\n+ \"name\": \"json_output\"\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap\nnew file mode 100644\nindex 000000000..b50a93df6\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap\n@@ -0,0 +1,27 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"temperature\": 0.7,\n+ \"top_p\": 0.9,\n+ \"stop_sequences\": [\n+ \"END\"\n+ ],\n+ \"metadata\": {\n+ \"trace_id\": \"trace-123\"\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap\nnew file mode 100644\nindex 000000000..5fedc6ff1\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap\n@@ -0,0 +1,43 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Think step by step: what is 2+2?\"\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"type\": \"thinking\",\n+ \"thinking\": \"The user wants 2+2, which is 4.\",\n+ \"signature\": \"sig_test_abc123\"\n+ },\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"4.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Now 3+3?\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": []\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap\nnew file mode 100644\nindex 000000000..d181d4796\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap\n@@ -0,0 +1,52 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": [],\n+ \"tools\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": {\n+ \"type\": \"auto\"\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap\nnew file mode 100644\nindex 000000000..5d7d5214b\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap\n@@ -0,0 +1,53 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": [],\n+ \"tools\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": {\n+ \"type\": \"tool\",\n+ \"name\": \"search\"\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap\nnew file mode 100644\nindex 000000000..4fbbbaa5f\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap\n@@ -0,0 +1,20 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": []\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap\nnew file mode 100644\nindex 000000000..b114a2048\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap\n@@ -0,0 +1,52 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": [],\n+ \"tools\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": {\n+ \"type\": \"any\"\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap\nnew file mode 100644\nindex 000000000..f744b1d64\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap\n@@ -0,0 +1,91 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Find foo and read /tmp/x\"\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Let me check.\"\n+ },\n+ {\n+ \"type\": \"tool_use\",\n+ \"id\": \"call_1\",\n+ \"name\": \"search\",\n+ \"input\": {\n+ \"query\": \"foo\"\n+ }\n+ },\n+ {\n+ \"type\": \"tool_use\",\n+ \"id\": \"call_2\",\n+ \"name\": \"read_file\",\n+ \"input\": {\n+ \"path\": \"/tmp/x\"\n+ }\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"tool_result\",\n+ \"tool_use_id\": \"call_1\",\n+ \"content\": \"{\\\"matches\\\":2}\",\n+ \"is_error\": false\n+ },\n+ {\n+ \"type\": \"tool_result\",\n+ \"tool_use_id\": \"call_2\",\n+ \"content\": \"file not found\",\n+ \"is_error\": true\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": [],\n+ \"tools\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap\nnew file mode 100644\nindex 000000000..f4abc02be\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap\n@@ -0,0 +1,34 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Describe these attachments.\"\n+ },\n+ {\n+ \"type\": \"image\",\n+ \"source\": {\n+ \"type\": \"url\",\n+ \"url\": \"https://example.com/picture.png\"\n+ }\n+ },\n+ {\n+ \"type\": \"document\",\n+ \"source\": {\n+ \"type\": \"url\",\n+ \"url\": \"https://example.com/report.pdf\"\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": []\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap\nnew file mode 100644\nindex 000000000..d2b78a3a2\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap\n@@ -0,0 +1,14 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"stream_item_error\": \"Server error from anthropic: Overloaded\",\n+ \"retryable\": true,\n+ \"failover_eligible\": true\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap\nnew file mode 100644\nindex 000000000..a8ad9d9d1\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap\n@@ -0,0 +1,65 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": \"block_0\"\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"Hel\",\n+ \"text_id\": \"block_0\"\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"lo\",\n+ \"text_id\": \"block_0\"\n+ },\n+ {\n+ \"type\": \"text_end\",\n+ \"text_id\": \"block_0\"\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 11,\n+ \"output_tokens\": 5,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 2,\n+ \"cache_write_tokens\": 1\n+ },\n+ \"response\": {\n+ \"id\": \"msg_stream_test\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"provider\": \"anthropic\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Hello\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 11,\n+ \"output_tokens\": 5,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 2,\n+ \"cache_write_tokens\": 1\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap\nnew file mode 100644\nindex 000000000..8b641b028\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap\n@@ -0,0 +1,21 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stop_sequences\": [],\n+ \"stream\": true\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap\nnew file mode 100644\nindex 000000000..2dc7b67fb\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap\n@@ -0,0 +1,78 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"reasoning_start\"\n+ },\n+ {\n+ \"type\": \"reasoning_delta\",\n+ \"delta\": \"Let me think\"\n+ },\n+ {\n+ \"type\": \"reasoning_end\"\n+ },\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": \"block_1\"\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"4.\",\n+ \"text_id\": \"block_1\"\n+ },\n+ {\n+ \"type\": \"text_end\",\n+ \"text_id\": \"block_1\"\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 15,\n+ \"output_tokens\": 12,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"msg_stream_think\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"provider\": \"anthropic\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"thinking\",\n+ \"data\": {\n+ \"text\": \"Let me think\",\n+ \"signature\": \"sig_stream_xyz\",\n+ \"redacted\": false\n+ }\n+ },\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"4.\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 15,\n+ \"output_tokens\": 12,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap\nnew file mode 100644\nindex 000000000..d2dc68988\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap\n@@ -0,0 +1,97 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"tool_call_start\",\n+ \"tool_call\": {\n+ \"id\": \"toolu_01\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {},\n+ \"raw_arguments\": null\n+ }\n+ },\n+ {\n+ \"type\": \"tool_call_delta\",\n+ \"tool_call\": {\n+ \"id\": \"toolu_01\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": \"{\\\"qu\",\n+ \"raw_arguments\": null\n+ }\n+ },\n+ {\n+ \"type\": \"tool_call_delta\",\n+ \"tool_call\": {\n+ \"id\": \"toolu_01\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": \"ery\\\":\\\"foo\\\"}\",\n+ \"raw_arguments\": null\n+ }\n+ },\n+ {\n+ \"type\": \"tool_call_end\",\n+ \"tool_call\": {\n+ \"id\": \"toolu_01\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ }\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 20,\n+ \"output_tokens\": 9,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"msg_stream_tool\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"provider\": \"anthropic\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"tool_call\",\n+ \"data\": {\n+ \"id\": \"toolu_01\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ }\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 20,\n+ \"output_tokens\": 9,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap\nnew file mode 100644\nindex 000000000..a12eec3d6\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap\n@@ -0,0 +1,22 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": \"block_0\"\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"Hello\",\n+ \"text_id\": \"block_0\"\n+ },\n+ {\n+ \"type\": \"text_end\",\n+ \"text_id\": \"block_0\"\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap\nnew file mode 100644\nindex 000000000..44e29f38e\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap\n@@ -0,0 +1,50 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"msg_test\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"provider\": \"anthropic\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Hello back\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 42,\n+ \"output_tokens\": 7,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 10,\n+ \"cache_write_tokens\": 3\n+ },\n+ \"raw\": {\n+ \"id\": \"msg_test\",\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello back\"\n+ }\n+ ],\n+ \"stop_reason\": \"end_turn\",\n+ \"stop_sequence\": null,\n+ \"usage\": {\n+ \"input_tokens\": 42,\n+ \"output_tokens\": 7,\n+ \"cache_read_input_tokens\": 10,\n+ \"cache_creation_input_tokens\": 3\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap\nnew file mode 100644\nindex 000000000..63678178e\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap\n@@ -0,0 +1,66 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/messages\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"anthropic-version\",\n+ \"2023-06-01\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"316\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ],\n+ [\n+ \"x-api-key\",\n+ \"test-key\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"model\": \"claude-sonnet-4-20250514\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"system\": \"Be concise\",\n+ \"temperature\": 0.5,\n+ \"stop_sequences\": [],\n+ \"tools\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"input_schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap\nnew file mode 100644\nindex 000000000..4056e3979\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap\n@@ -0,0 +1,93 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/models/gemini-test:countTokens\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"583\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ],\n+ [\n+ \"x-goog-api-key\",\n+ \"test-key\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"generateContentRequest\": {\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"systemInstruction\": {\n+ \"parts\": [\n+ {\n+ \"text\": \"Be concise\"\n+ }\n+ ]\n+ },\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"tools\": [\n+ {\n+ \"functionDeclarations\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+ }\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap\nnew file mode 100644\nindex 000000000..bbe899144\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap\n@@ -0,0 +1,73 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"[UUID]\",\n+ \"model\": \"gemini-test\",\n+ \"provider\": \"gemini\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Let me search.\"\n+ },\n+ {\n+ \"kind\": \"tool_call\",\n+ \"data\": {\n+ \"id\": \"[UUID]\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": null,\n+ \"provider_metadata\": {\n+ \"thoughtSignature\": \"sig_gemini_xyz\"\n+ }\n+ }\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 30,\n+ \"output_tokens\": 12,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"candidates\": [\n+ {\n+ \"content\": {\n+ \"role\": \"model\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Let me search.\"\n+ },\n+ {\n+ \"functionCall\": {\n+ \"name\": \"search\",\n+ \"args\": {\n+ \"query\": \"foo\"\n+ }\n+ },\n+ \"thoughtSignature\": \"sig_gemini_xyz\"\n+ }\n+ ]\n+ },\n+ \"finishReason\": \"STOP\"\n+ }\n+ ],\n+ \"usageMetadata\": {\n+ \"promptTokenCount\": 30,\n+ \"candidatesTokenCount\": 12\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --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\nnew file mode 100644\nindex 000000000..f80ff0a94\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap\n@@ -0,0 +1,5 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+\"length\"\ndiff --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\nnew file mode 100644\nindex 000000000..aedfd4696\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap\n@@ -0,0 +1,5 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+\"content_filter\"\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap\nnew file mode 100644\nindex 000000000..2eeb7b0af\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap\n@@ -0,0 +1,61 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"[UUID]\",\n+ \"model\": \"gemini-test\",\n+ \"provider\": \"gemini\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"thinking\",\n+ \"data\": {\n+ \"text\": \"Adding the numbers.\",\n+ \"signature\": null,\n+ \"redacted\": false\n+ }\n+ },\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"4.\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 25,\n+ \"output_tokens\": 40,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"candidates\": [\n+ {\n+ \"content\": {\n+ \"role\": \"model\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Adding the numbers.\",\n+ \"thought\": true\n+ },\n+ {\n+ \"text\": \"4.\"\n+ }\n+ ]\n+ },\n+ \"finishReason\": \"STOP\"\n+ }\n+ ],\n+ \"usageMetadata\": {\n+ \"promptTokenCount\": 25,\n+ \"candidatesTokenCount\": 40\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap\nnew file mode 100644\nindex 000000000..f332140ab\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap\n@@ -0,0 +1,52 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"[UUID]\",\n+ \"model\": \"gemini-test\",\n+ \"provider\": \"gemini\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"ok\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 75,\n+ \"output_tokens\": 50,\n+ \"reasoning_tokens\": 8,\n+ \"cache_read_tokens\": 30,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"candidates\": [\n+ {\n+ \"content\": {\n+ \"role\": \"model\",\n+ \"parts\": [\n+ {\n+ \"text\": \"ok\"\n+ }\n+ ]\n+ },\n+ \"finishReason\": \"STOP\"\n+ }\n+ ],\n+ \"usageMetadata\": {\n+ \"promptTokenCount\": 100,\n+ \"candidatesTokenCount\": 50,\n+ \"thoughtsTokenCount\": 8,\n+ \"cachedContentTokenCount\": 30,\n+ \"toolUsePromptTokenCount\": 5\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap\nnew file mode 100644\nindex 000000000..d3f6c4a7f\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap\n@@ -0,0 +1,31 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Transcribe this.\"\n+ },\n+ {\n+ \"inlineData\": {\n+ \"mimeType\": \"audio/wav\",\n+ \"data\": \"ZmFrZS13YXYtYnl0ZXM=\"\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap\nnew file mode 100644\nindex 000000000..b4605fdbb\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap\n@@ -0,0 +1,25 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Describe these attachments.\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap\nnew file mode 100644\nindex 000000000..130c517ee\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap\n@@ -0,0 +1,37 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Describe these attachments.\"\n+ },\n+ {\n+ \"inlineData\": {\n+ \"mimeType\": \"image/png\",\n+ \"data\": \"ZmFrZS1wbmctYnl0ZXM=\"\n+ }\n+ },\n+ {\n+ \"inlineData\": {\n+ \"mimeType\": \"application/pdf\",\n+ \"data\": \"ZmFrZS1wZGYtYnl0ZXM=\"\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap\nnew file mode 100644\nindex 000000000..af95759cb\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap\n@@ -0,0 +1,48 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"What is the capital of France?\"\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"model\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Paris.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"And of Spain?\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"systemInstruction\": {\n+ \"parts\": [\n+ {\n+ \"text\": \"You are a terse assistant.\"\n+ }\n+ ]\n+ },\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap\nnew file mode 100644\nindex 000000000..105e76111\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap\n@@ -0,0 +1,20 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"safety_settings\": []\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap\nnew file mode 100644\nindex 000000000..e6b616613\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap\n@@ -0,0 +1,26 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"cached_content\": \"cachedContents/abc\",\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap\nnew file mode 100644\nindex 000000000..83f70e81f\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap\n@@ -0,0 +1,25 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap\nnew file mode 100644\nindex 000000000..d16ceda4c\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap\n@@ -0,0 +1,26 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128,\n+ \"responseMimeType\": \"application/json\"\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap\nnew file mode 100644\nindex 000000000..7a6a39742\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap\n@@ -0,0 +1,37 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128,\n+ \"responseMimeType\": \"application/json\",\n+ \"responseSchema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"answer\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"answer\"\n+ ]\n+ }\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap\nnew file mode 100644\nindex 000000000..1e9350fcd\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap\n@@ -0,0 +1,30 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"temperature\": 0.7,\n+ \"maxOutputTokens\": 128,\n+ \"topP\": 0.9,\n+ \"stopSequences\": [\n+ \"END\"\n+ ]\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap\nnew file mode 100644\nindex 000000000..c0cdbd040\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap\n@@ -0,0 +1,41 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Think step by step: what is 2+2?\"\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"model\",\n+ \"parts\": [\n+ {\n+ \"text\": \"4.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Now 3+3?\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap\nnew file mode 100644\nindex 000000000..186a9afce\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap\n@@ -0,0 +1,63 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"tools\": [\n+ {\n+ \"functionDeclarations\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"toolConfig\": {\n+ \"functionCallingConfig\": {\n+ \"mode\": \"AUTO\"\n+ }\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap\nnew file mode 100644\nindex 000000000..d31af7c7d\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap\n@@ -0,0 +1,66 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"tools\": [\n+ {\n+ \"functionDeclarations\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"toolConfig\": {\n+ \"functionCallingConfig\": {\n+ \"mode\": \"ANY\",\n+ \"allowedFunctionNames\": [\n+ \"search\"\n+ ]\n+ }\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap\nnew file mode 100644\nindex 000000000..7bb3b0bfa\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap\n@@ -0,0 +1,63 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"tools\": [\n+ {\n+ \"functionDeclarations\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"toolConfig\": {\n+ \"functionCallingConfig\": {\n+ \"mode\": \"NONE\"\n+ }\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap\nnew file mode 100644\nindex 000000000..7832bc141\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap\n@@ -0,0 +1,63 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"tools\": [\n+ {\n+ \"functionDeclarations\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"toolConfig\": {\n+ \"functionCallingConfig\": {\n+ \"mode\": \"ANY\"\n+ }\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap\nnew file mode 100644\nindex 000000000..d0fdee6ce\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap\n@@ -0,0 +1,108 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Find foo and read /tmp/x\"\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"model\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Let me check.\"\n+ },\n+ {\n+ \"functionCall\": {\n+ \"name\": \"search\",\n+ \"args\": {\n+ \"query\": \"foo\"\n+ }\n+ }\n+ },\n+ {\n+ \"functionCall\": {\n+ \"name\": \"read_file\",\n+ \"args\": {\n+ \"path\": \"/tmp/x\"\n+ }\n+ }\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"functionResponse\": {\n+ \"name\": \"search\",\n+ \"response\": {\n+ \"matches\": 2\n+ }\n+ }\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"functionResponse\": {\n+ \"name\": \"read_file\",\n+ \"response\": {\n+ \"result\": \"file not found\"\n+ }\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"tools\": [\n+ {\n+ \"functionDeclarations\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap\nnew file mode 100644\nindex 000000000..dcf7ef4e2\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap\n@@ -0,0 +1,37 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Describe these attachments.\"\n+ },\n+ {\n+ \"fileData\": {\n+ \"mimeType\": \"image/png\",\n+ \"fileUri\": \"https://example.com/picture.png\"\n+ }\n+ },\n+ {\n+ \"fileData\": {\n+ \"mimeType\": \"application/pdf\",\n+ \"fileUri\": \"https://example.com/report.pdf\"\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap\nnew file mode 100644\nindex 000000000..4fd8569ca\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap\n@@ -0,0 +1,56 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": \"[UUID]\"\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"Hello\",\n+ \"text_id\": \"[UUID]\"\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 0,\n+ \"output_tokens\": 0,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"[UUID]\",\n+ \"model\": \"gemini-test\",\n+ \"provider\": \"gemini\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Hello\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 0,\n+ \"output_tokens\": 0,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap\nnew file mode 100644\nindex 000000000..d006bcbf7\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap\n@@ -0,0 +1,88 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"tool_call_start\",\n+ \"tool_call\": {\n+ \"id\": \"[UUID]\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": null,\n+ \"provider_metadata\": {\n+ \"thoughtSignature\": \"sig_stream_g\"\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"tool_call_end\",\n+ \"tool_call\": {\n+ \"id\": \"[UUID]\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": null,\n+ \"provider_metadata\": {\n+ \"thoughtSignature\": \"sig_stream_g\"\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 20,\n+ \"output_tokens\": 9,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"[UUID]\",\n+ \"model\": \"gemini-test\",\n+ \"provider\": \"gemini\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"tool_call\",\n+ \"data\": {\n+ \"id\": \"[UUID]\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": null,\n+ \"provider_metadata\": {\n+ \"thoughtSignature\": \"sig_stream_g\"\n+ }\n+ }\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 20,\n+ \"output_tokens\": 9,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap\nnew file mode 100644\nindex 000000000..2518f784a\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap\n@@ -0,0 +1,65 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": \"[UUID]\"\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"Hel\",\n+ \"text_id\": \"[UUID]\"\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"lo\",\n+ \"text_id\": \"[UUID]\"\n+ },\n+ {\n+ \"type\": \"text_end\",\n+ \"text_id\": \"[UUID]\"\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 11,\n+ \"output_tokens\": 5,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"[UUID]\",\n+ \"model\": \"gemini-test\",\n+ \"provider\": \"gemini\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Hello\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 11,\n+ \"output_tokens\": 5,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap\nnew file mode 100644\nindex 000000000..cb853aaf9\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap\n@@ -0,0 +1,51 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/models/gemini-test:streamGenerateContent?alt=sse\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"197\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ],\n+ [\n+ \"x-goog-api-key\",\n+ \"test-key\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"generationConfig\": {\n+ \"maxOutputTokens\": 128\n+ },\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap\nnew file mode 100644\nindex 000000000..6067c0cba\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap\n@@ -0,0 +1,78 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"reasoning_start\"\n+ },\n+ {\n+ \"type\": \"reasoning_delta\",\n+ \"delta\": \"Let me think\"\n+ },\n+ {\n+ \"type\": \"reasoning_end\"\n+ },\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": \"[UUID]\"\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"4.\",\n+ \"text_id\": \"[UUID]\"\n+ },\n+ {\n+ \"type\": \"text_end\",\n+ \"text_id\": \"[UUID]\"\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 15,\n+ \"output_tokens\": 12,\n+ \"reasoning_tokens\": 6,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"[UUID]\",\n+ \"model\": \"gemini-test\",\n+ \"provider\": \"gemini\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"thinking\",\n+ \"data\": {\n+ \"text\": \"Let me think\",\n+ \"signature\": null,\n+ \"redacted\": false\n+ }\n+ },\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"4.\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 15,\n+ \"output_tokens\": 12,\n+ \"reasoning_tokens\": 6,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap\nnew file mode 100644\nindex 000000000..0ed032bbc\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap\n@@ -0,0 +1,50 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"[UUID]\",\n+ \"model\": \"gemini-test\",\n+ \"provider\": \"gemini\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Hello back\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 32,\n+ \"output_tokens\": 7,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 10,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"candidates\": [\n+ {\n+ \"content\": {\n+ \"role\": \"model\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello back\"\n+ }\n+ ]\n+ },\n+ \"finishReason\": \"STOP\"\n+ }\n+ ],\n+ \"usageMetadata\": {\n+ \"promptTokenCount\": 42,\n+ \"candidatesTokenCount\": 7,\n+ \"cachedContentTokenCount\": 10\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap\nnew file mode 100644\nindex 000000000..c6d3c6a48\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap\n@@ -0,0 +1,77 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/gemini.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/models/gemini-test:generateContent\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"425\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ],\n+ [\n+ \"x-goog-api-key\",\n+ \"test-key\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"contents\": [\n+ {\n+ \"role\": \"user\",\n+ \"parts\": [\n+ {\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"systemInstruction\": {\n+ \"parts\": [\n+ {\n+ \"text\": \"Be concise\"\n+ }\n+ ]\n+ },\n+ \"generationConfig\": {\n+ \"temperature\": 0.5,\n+ \"maxOutputTokens\": 128\n+ },\n+ \"tools\": [\n+ {\n+ \"functionDeclarations\": [\n+ {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+ ],\n+ \"safety_settings\": [\n+ {\n+ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n+ \"threshold\": \"BLOCK_ONLY_HIGH\"\n+ }\n+ ]\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap\nnew file mode 100644\nindex 000000000..5ed2b7de1\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap\n@@ -0,0 +1,60 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"chatcmpl_test\",\n+ \"model\": \"test-model\",\n+ \"provider\": \"openai-compatible\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"thinking\",\n+ \"data\": {\n+ \"text\": \"The user wants 2+2.\",\n+ \"signature\": null,\n+ \"redacted\": false\n+ }\n+ },\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"4.\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 25,\n+ \"output_tokens\": 40,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"chatcmpl_test\",\n+ \"object\": \"chat.completion\",\n+ \"created\": 1700000000,\n+ \"model\": \"test-model\",\n+ \"choices\": [\n+ {\n+ \"index\": 0,\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": \"4.\",\n+ \"reasoning_content\": \"The user wants 2+2.\"\n+ },\n+ \"finish_reason\": \"stop\"\n+ }\n+ ],\n+ \"usage\": {\n+ \"prompt_tokens\": 25,\n+ \"completion_tokens\": 40,\n+ \"total_tokens\": 65\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap\nnew file mode 100644\nindex 000000000..768e9aaa8\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap\n@@ -0,0 +1,69 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"chatcmpl_test\",\n+ \"model\": \"test-model\",\n+ \"provider\": \"openai-compatible\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"tool_call\",\n+ \"data\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ }\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 30,\n+ \"output_tokens\": 12,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"chatcmpl_test\",\n+ \"object\": \"chat.completion\",\n+ \"created\": 1700000000,\n+ \"model\": \"test-model\",\n+ \"choices\": [\n+ {\n+ \"index\": 0,\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": null,\n+ \"tool_calls\": [\n+ {\n+ \"id\": \"call_abc\",\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"search\",\n+ \"arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ }\n+ }\n+ ]\n+ },\n+ \"finish_reason\": \"tool_calls\"\n+ }\n+ ],\n+ \"usage\": {\n+ \"prompt_tokens\": 30,\n+ \"completion_tokens\": 12,\n+ \"total_tokens\": 42\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_ignores_token_details.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_ignores_token_details.snap\nnew file mode 100644\nindex 000000000..35cbb5f1e\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_ignores_token_details.snap\n@@ -0,0 +1,57 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"chatcmpl_test\",\n+ \"model\": \"test-model\",\n+ \"provider\": \"openai-compatible\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"ok\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"length\",\n+ \"usage\": {\n+ \"input_tokens\": 100,\n+ \"output_tokens\": 50,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"chatcmpl_test\",\n+ \"object\": \"chat.completion\",\n+ \"created\": 1700000000,\n+ \"model\": \"test-model\",\n+ \"choices\": [\n+ {\n+ \"index\": 0,\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": \"ok\"\n+ },\n+ \"finish_reason\": \"length\"\n+ }\n+ ],\n+ \"usage\": {\n+ \"prompt_tokens\": 100,\n+ \"completion_tokens\": 50,\n+ \"total_tokens\": 150,\n+ \"prompt_tokens_details\": {\n+ \"cached_tokens\": 80\n+ },\n+ \"completion_tokens_details\": {\n+ \"reasoning_tokens\": 20\n+ }\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap\nnew file mode 100644\nindex 000000000..f647c24c0\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap\n@@ -0,0 +1,14 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Transcribe this.[Audio content not supported by this provider]\"\n+ }\n+ ],\n+ \"max_tokens\": 128\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap\nnew file mode 100644\nindex 000000000..7b3833469\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap\n@@ -0,0 +1,14 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Describe these attachments.[Document 'missing.pdf': content type not supported by this provider]\"\n+ }\n+ ],\n+ \"max_tokens\": 128\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap\nnew file mode 100644\nindex 000000000..fcc58e5cf\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap\n@@ -0,0 +1,14 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Describe these attachments.[Document 'report.pdf': content type not supported by this provider]\"\n+ }\n+ ],\n+ \"max_tokens\": 128\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap\nnew file mode 100644\nindex 000000000..6e4c35fa0\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap\n@@ -0,0 +1,26 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"system\",\n+ \"content\": \"You are a terse assistant.\"\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"What is the capital of France?\"\n+ },\n+ {\n+ \"role\": \"assistant\",\n+ \"content\": \"Paris.\"\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"And of Spain?\"\n+ }\n+ ],\n+ \"max_tokens\": 128\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap\nnew file mode 100644\nindex 000000000..2b0718398\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap\n@@ -0,0 +1,15 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"repetition_penalty\": 1.2\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap\nnew file mode 100644\nindex 000000000..bb8c27f58\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap\n@@ -0,0 +1,14 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"max_tokens\": 128\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap\nnew file mode 100644\nindex 000000000..84f716422\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap\n@@ -0,0 +1,17 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"response_format\": {\n+ \"type\": \"json_object\"\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap\nnew file mode 100644\nindex 000000000..13961444e\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap\n@@ -0,0 +1,32 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"response_format\": {\n+ \"type\": \"json_schema\",\n+ \"json_schema\": {\n+ \"name\": \"response\",\n+ \"strict\": true,\n+ \"schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"answer\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"answer\"\n+ ]\n+ }\n+ }\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap\nnew file mode 100644\nindex 000000000..6a345a9bf\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap\n@@ -0,0 +1,19 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"temperature\": 0.7,\n+ \"max_tokens\": 128,\n+ \"top_p\": 0.9,\n+ \"stop\": [\n+ \"END\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap\nnew file mode 100644\nindex 000000000..210eaa446\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap\n@@ -0,0 +1,23 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Think step by step: what is 2+2?\"\n+ },\n+ {\n+ \"role\": \"assistant\",\n+ \"content\": \"4.\",\n+ \"reasoning_content\": \"The user wants 2+2, which is 4.\"\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Now 3+3?\"\n+ }\n+ ],\n+ \"max_tokens\": 128\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap\nnew file mode 100644\nindex 000000000..7b9bdab82\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap\n@@ -0,0 +1,50 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": \"auto\"\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap\nnew file mode 100644\nindex 000000000..5da2b548a\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap\n@@ -0,0 +1,55 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"search\"\n+ }\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap\nnew file mode 100644\nindex 000000000..a7ace0225\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap\n@@ -0,0 +1,50 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": \"none\"\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap\nnew file mode 100644\nindex 000000000..d77d00bef\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap\n@@ -0,0 +1,50 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": \"required\"\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap\nnew file mode 100644\nindex 000000000..43e81924f\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap\n@@ -0,0 +1,81 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Find foo and read /tmp/x\"\n+ },\n+ {\n+ \"role\": \"assistant\",\n+ \"content\": \"Let me check.\",\n+ \"tool_calls\": [\n+ {\n+ \"id\": \"call_1\",\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"search\",\n+ \"arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ }\n+ },\n+ {\n+ \"id\": \"call_2\",\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"read_file\",\n+ \"arguments\": \"{\\\"path\\\":\\\"/tmp/x\\\"}\"\n+ }\n+ }\n+ ]\n+ },\n+ {\n+ \"role\": \"tool\",\n+ \"content\": \"{\\\"matches\\\":2}\",\n+ \"tool_call_id\": \"call_1\"\n+ },\n+ {\n+ \"role\": \"tool\",\n+ \"content\": \"file not found\",\n+ \"tool_call_id\": \"call_2\"\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ }\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap\nnew file mode 100644\nindex 000000000..fcc58e5cf\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap\n@@ -0,0 +1,14 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Describe these attachments.[Document 'report.pdf': content type not supported by this provider]\"\n+ }\n+ ],\n+ \"max_tokens\": 128\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_content_deltas.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_content_deltas.snap\nnew file mode 100644\nindex 000000000..bd4c6dcf3\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_content_deltas.snap\n@@ -0,0 +1,65 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"4.\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_end\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 0,\n+ \"output_tokens\": 0,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"chatcmpl_stream\",\n+ \"model\": \"test-model\",\n+ \"provider\": \"openai-compatible\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"thinking\",\n+ \"data\": {\n+ \"text\": \"Let me think\",\n+ \"signature\": null,\n+ \"redacted\": false\n+ }\n+ },\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"4.\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 0,\n+ \"output_tokens\": 0,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap\nnew file mode 100644\nindex 000000000..e7b975533\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap\n@@ -0,0 +1,62 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"Hel\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"lo\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_end\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 11,\n+ \"output_tokens\": 5,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"chatcmpl_stream\",\n+ \"model\": \"test-model\",\n+ \"provider\": \"openai-compatible\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Hello\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 11,\n+ \"output_tokens\": 5,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap\nnew file mode 100644\nindex 000000000..502647610\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap\n@@ -0,0 +1,15 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"max_tokens\": 128,\n+ \"stream\": true\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap\nnew file mode 100644\nindex 000000000..6ab800ec8\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap\n@@ -0,0 +1,94 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"tool_call_start\",\n+ \"tool_call\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": null,\n+ \"raw_arguments\": null\n+ }\n+ },\n+ {\n+ \"type\": \"tool_call_delta\",\n+ \"tool_call\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": null,\n+ \"raw_arguments\": null\n+ }\n+ },\n+ {\n+ \"type\": \"tool_call_delta\",\n+ \"tool_call\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": null,\n+ \"raw_arguments\": null\n+ }\n+ },\n+ {\n+ \"type\": \"tool_call_end\",\n+ \"tool_call\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ }\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 20,\n+ \"output_tokens\": 9,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"chatcmpl_stream\",\n+ \"model\": \"test-model\",\n+ \"provider\": \"openai-compatible\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"tool_call\",\n+ \"data\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ }\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 20,\n+ \"output_tokens\": 9,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --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\nnew file mode 100644\nindex 000000000..4c107d93b\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap\n@@ -0,0 +1,5 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+[]\ndiff --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/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap\nnew file mode 100644\nindex 000000000..858a68b66\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap\n@@ -0,0 +1,57 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"Hello\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_end\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 0,\n+ \"output_tokens\": 0,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"chatcmpl_stream\",\n+ \"model\": \"test-model\",\n+ \"provider\": \"openai-compatible\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Hello\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 0,\n+ \"output_tokens\": 0,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": null,\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap\nnew file mode 100644\nindex 000000000..fd4a0a4d6\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap\n@@ -0,0 +1,51 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"chatcmpl_test\",\n+ \"model\": \"test-model\",\n+ \"provider\": \"openai-compatible\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Hello back\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 42,\n+ \"output_tokens\": 7,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"chatcmpl_test\",\n+ \"object\": \"chat.completion\",\n+ \"created\": 1700000000,\n+ \"model\": \"test-model\",\n+ \"choices\": [\n+ {\n+ \"index\": 0,\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": \"Hello back\"\n+ },\n+ \"finish_reason\": \"stop\"\n+ }\n+ ],\n+ \"usage\": {\n+ \"prompt_tokens\": 42,\n+ \"completion_tokens\": 7,\n+ \"total_tokens\": 49\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap\nnew file mode 100644\nindex 000000000..a732b5d44\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap\n@@ -0,0 +1,62 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/chat/completions\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"authorization\",\n+ \"Bearer test-key\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"305\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"model\": \"test-model\",\n+ \"messages\": [\n+ {\n+ \"role\": \"system\",\n+ \"content\": \"Be concise\"\n+ },\n+ {\n+ \"role\": \"user\",\n+ \"content\": \"Hello\"\n+ }\n+ ],\n+ \"temperature\": 0.5,\n+ \"max_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"function\": {\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap\nnew file mode 100644\nindex 000000000..4d98746a8\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap\n@@ -0,0 +1,77 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/responses/input_tokens\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"authorization\",\n+ \"Bearer test-key\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"454\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"instructions\": \"Be concise\",\n+ \"model\": \"gpt-test\",\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ]\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap\nnew file mode 100644\nindex 000000000..070663013\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap\n@@ -0,0 +1,67 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"resp_test\",\n+ \"model\": \"gpt-test\",\n+ \"provider\": \"openai\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"openai_message\",\n+ \"data\": {\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_out\",\n+ \"content\": [\n+ {\n+ \"type\": \"output_text\",\n+ \"text\": \"Truncated\"\n+ }\n+ ]\n+ }\n+ },\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Truncated\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"length\",\n+ \"usage\": {\n+ \"input_tokens\": 10,\n+ \"output_tokens\": 128,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"resp_test\",\n+ \"object\": \"response\",\n+ \"model\": \"gpt-test\",\n+ \"status\": \"incomplete\",\n+ \"output\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_out\",\n+ \"content\": [\n+ {\n+ \"type\": \"output_text\",\n+ \"text\": \"Truncated\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"usage\": {\n+ \"input_tokens\": 10,\n+ \"output_tokens\": 128\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap\nnew file mode 100644\nindex 000000000..a453f0698\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap\n@@ -0,0 +1,83 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"resp_test\",\n+ \"model\": \"gpt-test\",\n+ \"provider\": \"openai\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"openai_reasoning\",\n+ \"data\": {\n+ \"type\": \"reasoning\",\n+ \"id\": \"rs_1\",\n+ \"summary\": [\n+ {\n+ \"type\": \"summary_text\",\n+ \"text\": \"Searching.\"\n+ }\n+ ]\n+ }\n+ },\n+ {\n+ \"kind\": \"tool_call\",\n+ \"data\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": \"{\\\"query\\\":\\\"foo\\\"}\",\n+ \"provider_metadata\": {\n+ \"id\": \"fc_123\"\n+ }\n+ }\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 30,\n+ \"output_tokens\": 12,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"resp_test\",\n+ \"object\": \"response\",\n+ \"model\": \"gpt-test\",\n+ \"status\": \"completed\",\n+ \"output\": [\n+ {\n+ \"type\": \"reasoning\",\n+ \"id\": \"rs_1\",\n+ \"summary\": [\n+ {\n+ \"type\": \"summary_text\",\n+ \"text\": \"Searching.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"function_call\",\n+ \"id\": \"fc_123\",\n+ \"call_id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ }\n+ ],\n+ \"usage\": {\n+ \"input_tokens\": 30,\n+ \"output_tokens\": 12\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap\nnew file mode 100644\nindex 000000000..846589d95\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap\n@@ -0,0 +1,73 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"resp_test\",\n+ \"model\": \"gpt-test\",\n+ \"provider\": \"openai\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"openai_message\",\n+ \"data\": {\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_out\",\n+ \"content\": [\n+ {\n+ \"type\": \"output_text\",\n+ \"text\": \"ok\"\n+ }\n+ ]\n+ }\n+ },\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"ok\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 20,\n+ \"output_tokens\": 30,\n+ \"reasoning_tokens\": 20,\n+ \"cache_read_tokens\": 80,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"resp_test\",\n+ \"object\": \"response\",\n+ \"model\": \"gpt-test\",\n+ \"status\": \"completed\",\n+ \"output\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_out\",\n+ \"content\": [\n+ {\n+ \"type\": \"output_text\",\n+ \"text\": \"ok\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"usage\": {\n+ \"input_tokens\": 100,\n+ \"output_tokens\": 50,\n+ \"input_tokens_details\": {\n+ \"cached_tokens\": 80\n+ },\n+ \"output_tokens_details\": {\n+ \"reasoning_tokens\": 20\n+ }\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap\nnew file mode 100644\nindex 000000000..5e12b469e\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap\n@@ -0,0 +1,28 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Transcribe this.\"\n+ },\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"[Audio content not supported by this provider]\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap\nnew file mode 100644\nindex 000000000..93b5f16a3\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap\n@@ -0,0 +1,28 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Describe these attachments.\"\n+ },\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"[Document 'missing.pdf': content type not supported by this provider]\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --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/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap\nnew file mode 100644\nindex 000000000..655b2e204\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap\n@@ -0,0 +1,51 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/responses\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"authorization\",\n+ \"Bearer test-key\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"210\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"instructions\": \"Be concise\",\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ],\n+ \"stream\": true\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap\nnew file mode 100644\nindex 000000000..990bf91a8\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap\n@@ -0,0 +1,67 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Find foo\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"function_call\",\n+ \"id\": \"fc_123\",\n+ \"call_id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ },\n+ {\n+ \"type\": \"function_call_output\",\n+ \"call_id\": \"call_abc\",\n+ \"output\": \"2 matches\"\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap\nnew file mode 100644\nindex 000000000..3d74e1cdd\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap\n@@ -0,0 +1,32 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Describe these attachments.\"\n+ },\n+ {\n+ \"type\": \"input_image\",\n+ \"image_url\": \"data:image/png;base64,ZmFrZS1wbmctYnl0ZXM=\"\n+ },\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"[Document 'report.pdf': content type not supported by this provider]\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap\nnew file mode 100644\nindex 000000000..3e049c7fe\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap\n@@ -0,0 +1,45 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"What is the capital of France?\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"type\": \"output_text\",\n+ \"text\": \"Paris.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"And of Spain?\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"instructions\": \"You are a terse assistant.\",\n+ \"max_output_tokens\": 128,\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap\nnew file mode 100644\nindex 000000000..acfb705af\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap\n@@ -0,0 +1,55 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Think about 2+2.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"reasoning\",\n+ \"id\": \"rs_1\",\n+ \"summary\": [\n+ {\n+ \"type\": \"summary_text\",\n+ \"text\": \"Adding.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_1\",\n+ \"content\": [\n+ {\n+ \"type\": \"output_text\",\n+ \"text\": \"4.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Now 3+3?\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap\nnew file mode 100644\nindex 000000000..cd69aff6a\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap\n@@ -0,0 +1,25 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ],\n+ \"seed\": 42\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap\nnew file mode 100644\nindex 000000000..c9efcd60f\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap\n@@ -0,0 +1,27 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"test-gpt\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"reasoning\": {\n+ \"effort\": \"high\"\n+ },\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap\nnew file mode 100644\nindex 000000000..f4ccc269d\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap\n@@ -0,0 +1,29 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"text\": {\n+ \"format\": {\n+ \"type\": \"json_object\"\n+ }\n+ },\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap\nnew file mode 100644\nindex 000000000..3fa0b83e2\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap\n@@ -0,0 +1,42 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"text\": {\n+ \"format\": {\n+ \"type\": \"json_schema\",\n+ \"name\": \"response\",\n+ \"strict\": true,\n+ \"schema\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"answer\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"answer\"\n+ ]\n+ }\n+ }\n+ },\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap\nnew file mode 100644\nindex 000000000..3634e5fb3\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap\n@@ -0,0 +1,32 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"temperature\": 0.7,\n+ \"max_output_tokens\": 128,\n+ \"top_p\": 0.9,\n+ \"stop\": [\n+ \"END\"\n+ ],\n+ \"metadata\": {\n+ \"trace_id\": \"trace-123\"\n+ },\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap\nnew file mode 100644\nindex 000000000..e4b3593b0\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap\n@@ -0,0 +1,44 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Think step by step: what is 2+2?\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"type\": \"output_text\",\n+ \"text\": \"4.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Now 3+3?\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap\nnew file mode 100644\nindex 000000000..af2dfe6a9\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap\n@@ -0,0 +1,56 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": \"auto\",\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap\nnew file mode 100644\nindex 000000000..cc00b7891\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap\n@@ -0,0 +1,59 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": {\n+ \"type\": \"function\",\n+ \"name\": \"search\"\n+ },\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap\nnew file mode 100644\nindex 000000000..fda379759\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap\n@@ -0,0 +1,56 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": \"none\",\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap\nnew file mode 100644\nindex 000000000..b9e9d82e0\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap\n@@ -0,0 +1,56 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"tool_choice\": \"required\",\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap\nnew file mode 100644\nindex 000000000..97c1654d9\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap\n@@ -0,0 +1,90 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Find foo and read /tmp/x\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"type\": \"output_text\",\n+ \"text\": \"Let me check.\"\n+ }\n+ ]\n+ },\n+ {\n+ \"type\": \"function_call\",\n+ \"id\": \"call_1\",\n+ \"call_id\": \"call_1\",\n+ \"name\": \"search\",\n+ \"arguments\": \"{\\\"query\\\":\\\"foo\\\"}\"\n+ },\n+ {\n+ \"type\": \"function_call\",\n+ \"id\": \"call_2\",\n+ \"call_id\": \"call_2\",\n+ \"name\": \"read_file\",\n+ \"arguments\": \"{\\\"path\\\":\\\"/tmp/x\\\"}\"\n+ },\n+ {\n+ \"type\": \"function_call_output\",\n+ \"call_id\": \"call_1\",\n+ \"output\": \"{\\\"matches\\\":2}\"\n+ },\n+ {\n+ \"type\": \"function_call_output\",\n+ \"call_id\": \"call_2\",\n+ \"output\": \"file not found\",\n+ \"status\": \"incomplete\"\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ },\n+ \"required\": [\n+ \"query\"\n+ ]\n+ }\n+ },\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"read_file\",\n+ \"description\": \"Read a file by path\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"path\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap\nnew file mode 100644\nindex 000000000..9b67b8abc\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap\n@@ -0,0 +1,32 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Describe these attachments.\"\n+ },\n+ {\n+ \"type\": \"input_image\",\n+ \"image_url\": \"https://example.com/picture.png\"\n+ },\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"[Document 'report.pdf': content type not supported by this provider]\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap\nnew file mode 100644\nindex 000000000..68057a6b7\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap\n@@ -0,0 +1,14 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"stream_item_error\": \"Server error from openai: boom\",\n+ \"retryable\": true,\n+ \"failover_eligible\": true\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap\nnew file mode 100644\nindex 000000000..787a6f4df\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap\n@@ -0,0 +1,65 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"Trunc\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"length\",\n+ \"usage\": {\n+ \"input_tokens\": 10,\n+ \"output_tokens\": 128,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"resp_stream\",\n+ \"model\": \"gpt-test\",\n+ \"provider\": \"openai\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Trunc\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"length\",\n+ \"usage\": {\n+ \"input_tokens\": 10,\n+ \"output_tokens\": 128,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"resp_stream\",\n+ \"model\": \"gpt-test\",\n+ \"status\": \"incomplete\",\n+ \"output\": [],\n+ \"usage\": {\n+ \"input_tokens\": 10,\n+ \"output_tokens\": 128\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap\nnew file mode 100644\nindex 000000000..c38adf4e4\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap\n@@ -0,0 +1,79 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"reasoning_start\"\n+ },\n+ {\n+ \"type\": \"reasoning_delta\",\n+ \"delta\": \"Let me \"\n+ },\n+ {\n+ \"type\": \"reasoning_delta\",\n+ \"delta\": \"think\"\n+ },\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"4.\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 15,\n+ \"output_tokens\": 4,\n+ \"reasoning_tokens\": 8,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"resp_stream\",\n+ \"model\": \"gpt-test\",\n+ \"provider\": \"openai\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"4.\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 15,\n+ \"output_tokens\": 4,\n+ \"reasoning_tokens\": 8,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"resp_stream\",\n+ \"model\": \"gpt-test\",\n+ \"status\": \"completed\",\n+ \"output\": [],\n+ \"usage\": {\n+ \"input_tokens\": 15,\n+ \"output_tokens\": 12,\n+ \"output_tokens_details\": {\n+ \"reasoning_tokens\": 8\n+ }\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap\nnew file mode 100644\nindex 000000000..c0b41b21e\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap\n@@ -0,0 +1,76 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"text_start\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"Hel\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"text_delta\",\n+ \"delta\": \"lo\",\n+ \"text_id\": null\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 9,\n+ \"output_tokens\": 4,\n+ \"reasoning_tokens\": 1,\n+ \"cache_read_tokens\": 2,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"resp_stream\",\n+ \"model\": \"gpt-test\",\n+ \"provider\": \"openai\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Hello\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 9,\n+ \"output_tokens\": 4,\n+ \"reasoning_tokens\": 1,\n+ \"cache_read_tokens\": 2,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"resp_stream\",\n+ \"model\": \"gpt-test\",\n+ \"status\": \"completed\",\n+ \"output\": [],\n+ \"usage\": {\n+ \"input_tokens\": 11,\n+ \"output_tokens\": 5,\n+ \"input_tokens_details\": {\n+ \"cached_tokens\": 2\n+ },\n+ \"output_tokens_details\": {\n+ \"reasoning_tokens\": 1\n+ }\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap\nnew file mode 100644\nindex 000000000..c5f2d85eb\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap\n@@ -0,0 +1,25 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"max_output_tokens\": 128,\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ],\n+ \"stream\": true\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap\nnew file mode 100644\nindex 000000000..66c3b5d91\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap\n@@ -0,0 +1,121 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+[\n+ {\n+ \"type\": \"stream_start\"\n+ },\n+ {\n+ \"type\": \"tool_call_start\",\n+ \"tool_call\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {},\n+ \"raw_arguments\": \"{\\\"qu\",\n+ \"provider_metadata\": {\n+ \"id\": \"fc_123\"\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"tool_call_delta\",\n+ \"tool_call\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {},\n+ \"raw_arguments\": \"{\\\"qu\",\n+ \"provider_metadata\": {\n+ \"id\": \"fc_123\"\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"tool_call_delta\",\n+ \"tool_call\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {},\n+ \"raw_arguments\": \"ery\\\":\\\"foo\\\"}\",\n+ \"provider_metadata\": {\n+ \"id\": \"fc_123\"\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"tool_call_end\",\n+ \"tool_call\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": \"{\\\"query\\\":\\\"foo\\\"}\",\n+ \"provider_metadata\": {\n+ \"id\": \"fc_123\"\n+ }\n+ }\n+ },\n+ {\n+ \"type\": \"finish\",\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 20,\n+ \"output_tokens\": 9,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"response\": {\n+ \"id\": \"resp_stream\",\n+ \"model\": \"gpt-test\",\n+ \"provider\": \"openai\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"tool_call\",\n+ \"data\": {\n+ \"id\": \"call_abc\",\n+ \"name\": \"search\",\n+ \"type\": \"function\",\n+ \"arguments\": {\n+ \"query\": \"foo\"\n+ },\n+ \"raw_arguments\": \"{\\\"query\\\":\\\"foo\\\"}\",\n+ \"provider_metadata\": {\n+ \"id\": \"fc_123\"\n+ }\n+ }\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"tool_calls\",\n+ \"usage\": {\n+ \"input_tokens\": 20,\n+ \"output_tokens\": 9,\n+ \"reasoning_tokens\": 0,\n+ \"cache_read_tokens\": 0,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"resp_stream\",\n+ \"model\": \"gpt-test\",\n+ \"status\": \"completed\",\n+ \"output\": [],\n+ \"usage\": {\n+ \"input_tokens\": 20,\n+ \"output_tokens\": 9\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+ }\n+ }\n+]\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap\nnew file mode 100644\nindex 000000000..1ce06379d\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap\n@@ -0,0 +1,73 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"id\": \"resp_test\",\n+ \"model\": \"gpt-test\",\n+ \"provider\": \"openai\",\n+ \"message\": {\n+ \"role\": \"assistant\",\n+ \"content\": [\n+ {\n+ \"kind\": \"openai_message\",\n+ \"data\": {\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_out\",\n+ \"content\": [\n+ {\n+ \"type\": \"output_text\",\n+ \"text\": \"Hello back\"\n+ }\n+ ]\n+ }\n+ },\n+ {\n+ \"kind\": \"text\",\n+ \"data\": \"Hello back\"\n+ }\n+ ],\n+ \"name\": null,\n+ \"tool_call_id\": null\n+ },\n+ \"finish_reason\": \"stop\",\n+ \"usage\": {\n+ \"input_tokens\": 32,\n+ \"output_tokens\": 4,\n+ \"reasoning_tokens\": 3,\n+ \"cache_read_tokens\": 10,\n+ \"cache_write_tokens\": 0\n+ },\n+ \"raw\": {\n+ \"id\": \"resp_test\",\n+ \"object\": \"response\",\n+ \"model\": \"gpt-test\",\n+ \"status\": \"completed\",\n+ \"output\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"assistant\",\n+ \"id\": \"msg_out\",\n+ \"content\": [\n+ {\n+ \"type\": \"output_text\",\n+ \"text\": \"Hello back\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"usage\": {\n+ \"input_tokens\": 42,\n+ \"output_tokens\": 7,\n+ \"input_tokens_details\": {\n+ \"cached_tokens\": 10\n+ },\n+ \"output_tokens_details\": {\n+ \"reasoning_tokens\": 3\n+ }\n+ }\n+ },\n+ \"warnings\": [],\n+ \"rate_limit\": null\n+}\ndiff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap\nnew file mode 100644\nindex 000000000..5c7aca225\n--- /dev/null\n+++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap\n@@ -0,0 +1,67 @@\n+---\n+source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs\n+expression: rendered\n+---\n+{\n+ \"method\": \"POST\",\n+ \"path\": \"/responses\",\n+ \"headers\": [\n+ [\n+ \"accept\",\n+ \"*/*\"\n+ ],\n+ [\n+ \"authorization\",\n+ \"Bearer test-key\"\n+ ],\n+ [\n+ \"content-length\",\n+ \"385\"\n+ ],\n+ [\n+ \"content-type\",\n+ \"application/json\"\n+ ],\n+ [\n+ \"host\",\n+ \"[host]\"\n+ ]\n+ ],\n+ \"body\": {\n+ \"model\": \"gpt-test\",\n+ \"input\": [\n+ {\n+ \"type\": \"message\",\n+ \"role\": \"user\",\n+ \"content\": [\n+ {\n+ \"type\": \"input_text\",\n+ \"text\": \"Hello\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"instructions\": \"Be concise\",\n+ \"temperature\": 0.5,\n+ \"max_output_tokens\": 128,\n+ \"tools\": [\n+ {\n+ \"type\": \"function\",\n+ \"name\": \"search\",\n+ \"description\": \"Search files\",\n+ \"parameters\": {\n+ \"type\": \"object\",\n+ \"properties\": {\n+ \"query\": {\n+ \"type\": \"string\"\n+ }\n+ }\n+ }\n+ }\n+ ],\n+ \"store\": false,\n+ \"include\": [\n+ \"reasoning.encrypted_content\"\n+ ]\n+ }\n+}\ndiff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs\nindex 8db94500c..486ebdf2f 100644\n--- a/lib/crates/fabro-test/src/lib.rs\n+++ b/lib/crates/fabro-test/src/lib.rs\n@@ -2004,6 +2004,32 @@ macro_rules! fabro_json_snapshot {\n insta::assert_snapshot!(rendered, @$snapshot);\n });\n }};\n+ // External-snapshot forms (no inline `@\"...\"`): insta writes the snapshot\n+ // to a `.snap` file under the test's `snapshots/` directory. Use these to\n+ // keep large snapshots out of the `.rs` source.\n+ ($filter_source:expr, $value:expr $(,)?) => {{\n+ let filters =\n+ $crate::json_snapshot_filters($crate::snapshot_filters_from(&$filter_source));\n+ let filters: Vec<(&str, &str)> = filters\n+ .iter()\n+ .map(|(pattern, replacement)| (pattern.as_str(), replacement.as_str()))\n+ .collect();\n+ let rendered = serde_json::to_string_pretty(&$value).unwrap();\n+ insta::with_settings!({ filters => filters }, {\n+ insta::assert_snapshot!(rendered);\n+ });\n+ }};\n+ ($value:expr $(,)?) => {{\n+ let filters = $crate::json_snapshot_filters($crate::TestContext::default_filters());\n+ let filters: Vec<(&str, &str)> = filters\n+ .iter()\n+ .map(|(pattern, replacement)| (pattern.as_str(), replacement.as_str()))\n+ .collect();\n+ let rendered = serde_json::to_string_pretty(&$value).unwrap();\n+ insta::with_settings!({ filters => filters }, {\n+ insta::assert_snapshot!(rendered);\n+ });\n+ }};\n }\n \n impl TestContext {\n", + "summary": { + "files_changed": 223, + "additions": 8616, + "deletions": 326 + } + } } ], - "conclusion": null, + "conclusion": { + "timestamp": "2026-06-08T21:05:43.084902Z", + "status": "succeeded", + "timing": { + "wall_time_ms": 7689492, + "inference_time_ms": 3339546, + "tool_time_ms": 3898348, + "active_time_ms": 7237894 + }, + "final_git_commit_sha": "a8f2afd2489526298ea21428c6fa842b2e39a322", + "stages": [ + { + "stage_id": "start", + "stage_label": "start", + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "toolchain", + "stage_label": "toolchain", + "timing": { + "wall_time_ms": 1446, + "inference_time_ms": 0, + "tool_time_ms": 1440, + "active_time_ms": 1440 + }, + "retries": 0 + }, + { + "stage_id": "preflight_compile", + "stage_label": "preflight_compile", + "timing": { + "wall_time_ms": 152679, + "inference_time_ms": 0, + "tool_time_ms": 152673, + "active_time_ms": 152673 + }, + "retries": 0 + }, + { + "stage_id": "preflight_lint", + "stage_label": "preflight_lint", + "timing": { + "wall_time_ms": 161871, + "inference_time_ms": 0, + "tool_time_ms": 161865, + "active_time_ms": 161865 + }, + "retries": 0 + }, + { + "stage_id": "implement", + "stage_label": "implement", + "timing": { + "wall_time_ms": 5436597, + "inference_time_ms": 2723291, + "tool_time_ms": 2405987, + "active_time_ms": 5129278 + }, + "billing_usd_micros": 43065707, + "retries": 0 + }, + { + "stage_id": "simplify_opus", + "stage_label": "simplify_opus", + "timing": { + "wall_time_ms": 1138333, + "inference_time_ms": 478800, + "tool_time_ms": 637303, + "active_time_ms": 1116103 + }, + "billing_usd_micros": 7654954, + "retries": 0 + }, + { + "stage_id": "simplify_gpt", + "stage_label": "simplify_gpt", + "timing": { + "wall_time_ms": 197329, + "inference_time_ms": 137455, + "tool_time_ms": 43695, + "active_time_ms": 181150 + }, + "billing_usd_micros": 1723941, + "retries": 0 + }, + { + "stage_id": "verify", + "stage_label": "verify", + "timing": { + "wall_time_ms": 495400, + "inference_time_ms": 0, + "tool_time_ms": 495385, + "active_time_ms": 495385 + }, + "retries": 0 + } + ], + "billing": { + "input_tokens": 4137094, + "output_tokens": 79549, + "total_tokens": 54978691, + "reasoning_tokens": 16647, + "cache_read_tokens": 50111665, + "cache_write_tokens": 633736, + "total_usd_micros": 52444602 + }, + "total_retries": 0, + "diff": {} + }, "sandbox": { "kind": "ready", "plan": { @@ -1758,23 +1880,30 @@ }, "state": "succeeded" }, - "verify@1": { - "first_event_seq": 2274, + "exit@1": { + "first_event_seq": 2284, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": null, + "failure_reason": null, + "timestamp": "2026-06-08T21:05:43.019700Z" + }, "provider_used": null, "diff": null, - "script_invocation": { - "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", - "command": "exec 2>&1\ngit 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", - "language": "shell" - }, + "script_invocation": null, "script_timing": null, "parallel_results": null, "output": null, - "started_at": "2026-06-08T20:57:14.537668Z", - "handler": "command", + "started_at": "2026-06-08T21:05:43.019679Z", + "handler": "exit", + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, "usage": { "input_tokens": 0, "output_tokens": 0, @@ -1783,7 +1912,55 @@ "cache_read_tokens": 0, "cache_write_tokens": 0 }, - "state": "running" + "state": "succeeded" + }, + "verify@1": { + "first_event_seq": 2274, + "prompt": null, + "response": null, + "completion": { + "outcome": "succeeded", + "notes": "Script completed: 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", + "failure_reason": null, + "timestamp": "2026-06-08T21:05:29.944463Z" + }, + "provider_used": null, + "diff": null, + "script_invocation": { + "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", + "command": "exec 2>&1\ngit 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", + "language": "shell" + }, + "script_timing": { + "output": "blob://sha256/e047188aabc61830dab219914b8088e6496e14ae0bc8f73e0aece5a9cdf49016", + "exit_code": 0, + "duration_ms": 495385, + "termination": "exited", + "output_bytes": 101665, + "live_streaming": true + }, + "parallel_results": null, + "output": null, + "output_bytes": 101665, + "live_streaming": true, + "termination": "exited", + "started_at": "2026-06-08T20:57:14.537668Z", + "handler": "command", + "timing": { + "wall_time_ms": 495400, + "inference_time_ms": 0, + "tool_time_ms": 495385, + "active_time_ms": 495385 + }, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "reasoning_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "state": "succeeded" }, "simplify_gpt@1": { "first_event_seq": 2085, diff --git a/stages/008-verify@1/diff.patch b/stages/008-verify@1/diff.patch new file mode 100644 index 000000000..506dedbcf --- /dev/null +++ b/stages/008-verify@1/diff.patch @@ -0,0 +1,9023 @@ +diff --git a/lib/crates/fabro-llm/tests/it/main.rs b/lib/crates/fabro-llm/tests/it/main.rs +new file mode 100644 +index 000000000..66dc027b4 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/main.rs +@@ -0,0 +1,7 @@ ++#![allow( ++ clippy::absolute_paths, ++ reason = "This test module prefers explicit type paths over extra imports." ++)] ++ ++mod support; ++mod wire; +diff --git a/lib/crates/fabro-llm/tests/it/support.rs b/lib/crates/fabro-llm/tests/it/support.rs +new file mode 100644 +index 000000000..c2d30c58c +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/support.rs +@@ -0,0 +1,470 @@ ++//! Shared helpers for capturing the wire requests adapters send, plus the ++//! canonical request corpus pinned across all four provider dialects. ++ ++use std::sync::{Arc, Mutex}; ++ ++use fabro_llm::provider::ProviderAdapter; ++use fabro_llm::types::{ ++ AudioData, ContentPart, DocumentData, ImageData, Message, Request, ResponseFormat, Role, ++ ThinkingData, ToolCall, ToolChoice, ToolDefinition, ToolResult, ++}; ++use fabro_model::Catalog; ++use fabro_model::catalog::LlmCatalogSettings; ++use httpmock::prelude::*; ++ ++// --------------------------------------------------------------------------- ++// Wire capture ++// --------------------------------------------------------------------------- ++ ++/// One captured wire request, normalized for snapshot stability. ++#[derive(Debug, Clone, serde::Serialize)] ++pub(crate) struct WireCapture { ++ pub(crate) method: String, ++ pub(crate) path: String, ++ pub(crate) headers: Vec<(String, String)>, ++ pub(crate) body: serde_json::Value, ++} ++ ++/// Shared slot the matcher closure writes the captured request into. ++pub(crate) type CaptureSlot = Arc>>; ++ ++fn capture_request(req: &HttpMockRequest) -> WireCapture { ++ let mut headers: Vec<(String, String)> = req ++ .headers_vec() ++ .iter() ++ .map(|(name, value)| { ++ let name = name.to_ascii_lowercase(); ++ let value = match name.as_str() { ++ // The mock server binds a random port. ++ "host" => "[host]".to_string(), ++ // Carries a client version that would churn snapshots. ++ "user-agent" => "[user-agent]".to_string(), ++ _ => value.clone(), ++ }; ++ (name, value) ++ }) ++ .collect(); ++ headers.sort(); ++ ++ let path = match req.uri().query() { ++ Some(query) => format!("{}?{}", req.uri().path(), query), ++ None => req.uri().path().to_string(), ++ }; ++ ++ WireCapture { ++ method: req.method_str().to_string(), ++ path, ++ headers, ++ body: serde_json::from_str(&req.body_string()).expect("request body should be JSON"), ++ } ++} ++ ++/// Mounts a mock on `path` that captures the full request into the returned ++/// slot and responds with the JSON `response_body`. ++pub(crate) fn mount_capture<'a>( ++ server: &'a MockServer, ++ path: &'static str, ++ response_body: serde_json::Value, ++) -> (httpmock::Mock<'a>, CaptureSlot) { ++ let slot: CaptureSlot = Arc::new(Mutex::new(None)); ++ let writer = Arc::clone(&slot); ++ let mock = server.mock(move |when, then| { ++ when.method(POST) ++ .path(path) ++ .is_true(move |req: &HttpMockRequest| { ++ *writer.lock().unwrap() = Some(capture_request(req)); ++ true ++ }); ++ then.status(200) ++ .header("content-type", "application/json") ++ .json_body(response_body); ++ }); ++ (mock, slot) ++} ++ ++/// Like [`mount_capture`] but responds with a raw SSE transcript. ++pub(crate) fn mount_capture_sse<'a>( ++ server: &'a MockServer, ++ path: &'static str, ++ sse_body: &str, ++) -> (httpmock::Mock<'a>, CaptureSlot) { ++ let slot: CaptureSlot = Arc::new(Mutex::new(None)); ++ let writer = Arc::clone(&slot); ++ let body = sse_body.to_string(); ++ let mock = server.mock(move |when, then| { ++ when.method(POST) ++ .path(path) ++ .is_true(move |req: &HttpMockRequest| { ++ *writer.lock().unwrap() = Some(capture_request(req)); ++ true ++ }); ++ then.status(200) ++ .header("content-type", "text/event-stream") ++ .body(body.clone()); ++ }); ++ (mock, slot) ++} ++ ++pub(crate) fn take_capture(slot: &CaptureSlot) -> WireCapture { ++ slot.lock() ++ .unwrap() ++ .take() ++ .expect("matcher should have captured the request") ++} ++ ++/// Drives `adapter.stream(request)` to completion and returns every emitted ++/// item as JSON: `Ok` events serialize verbatim (the public SSE wire shape); ++/// `Err` items pin the message plus the failover/retry flags consumers key on. ++pub(crate) async fn collect_stream_events( ++ adapter: &dyn ProviderAdapter, ++ request: &Request, ++) -> Vec { ++ use futures::StreamExt; ++ ++ let mut stream = adapter.stream(request).await.expect("stream should start"); ++ let mut events = Vec::new(); ++ while let Some(item) = stream.next().await { ++ events.push(match item { ++ Ok(event) => serde_json::to_value(&event).expect("event should serialize"), ++ Err(error) => serde_json::json!({ ++ "stream_item_error": error.to_string(), ++ "retryable": error.retryable(), ++ "failover_eligible": error.failover_eligible(), ++ }), ++ }); ++ } ++ events ++} ++ ++/// Builds a catalog from inline TOML (same `LlmCatalogSettings` schema as the ++/// shipped catalog files). ++pub(crate) fn catalog_from_toml(source: &str) -> Arc { ++ let settings: LlmCatalogSettings = toml::from_str(source).expect("catalog TOML should parse"); ++ Arc::new(Catalog::from_settings(&settings).expect("catalog should build")) ++} ++ ++fn is_uuid(s: &str) -> bool { ++ s.len() == 36 ++ && s.bytes().enumerate().all(|(i, b)| match i { ++ 8 | 13 | 18 | 23 => b == b'-', ++ _ => b.is_ascii_hexdigit(), ++ }) ++} ++ ++/// Replaces UUID-shaped strings with `[UUID]` for snapshot stability — the ++/// Gemini decoder mints synthetic `Uuid::new_v4()` tool-call ids. ++pub(crate) fn normalize_uuids(value: &mut serde_json::Value) { ++ match value { ++ serde_json::Value::String(s) if is_uuid(s) => "[UUID]".clone_into(s), ++ serde_json::Value::Array(items) => items.iter_mut().for_each(normalize_uuids), ++ serde_json::Value::Object(map) => map.values_mut().for_each(normalize_uuids), ++ _ => {} ++ } ++} ++ ++/// Renders `(event, data)` pairs as an SSE transcript with `event:` lines ++/// (the Anthropic framing). ++pub(crate) fn sse_transcript(events: &[(&str, &str)]) -> String { ++ use std::fmt::Write; ++ ++ events.iter().fold(String::new(), |mut out, (event, data)| { ++ let _ = writeln!(out, "event: {event}\ndata: {data}\n"); ++ out ++ }) ++} ++ ++/// Renders data-only SSE lines (the OpenAI/Gemini framing). ++pub(crate) fn sse_data_transcript(lines: &[&str]) -> String { ++ use std::fmt::Write; ++ ++ lines.iter().fold(String::new(), |mut out, data| { ++ let _ = writeln!(out, "data: {data}\n"); ++ out ++ }) ++} ++ ++// --------------------------------------------------------------------------- ++// Canonical request corpus ++// ++// Each constructor returns one canonical `Request` shape that every dialect ++// file pins through its own adapter. Keep these stable: editing a corpus ++// request invalidates the pinned wire snapshots in all four dialect files. ++// --------------------------------------------------------------------------- ++ ++pub(crate) fn base_request(model: &str) -> Request { ++ Request { ++ model: model.to_string(), ++ messages: vec![Message::user("Hello")], ++ provider: None, ++ tools: None, ++ tool_choice: None, ++ response_format: None, ++ temperature: None, ++ top_p: None, ++ max_tokens: Some(128), ++ stop_sequences: None, ++ reasoning_effort: None, ++ speed: None, ++ metadata: None, ++ provider_options: None, ++ } ++} ++ ++/// Multi-turn conversation: system + user/assistant/user. ++pub(crate) fn corpus_multi_turn(model: &str) -> Request { ++ Request { ++ messages: vec![ ++ Message::system("You are a terse assistant."), ++ Message::user("What is the capital of France?"), ++ Message::assistant("Paris."), ++ Message::user("And of Spain?"), ++ ], ++ ..base_request(model) ++ } ++} ++ ++/// Two tools plus an optional tool choice. ++pub(crate) fn corpus_tools(model: &str, tool_choice: Option) -> Request { ++ Request { ++ tools: Some(vec![ ++ ToolDefinition::function( ++ "search", ++ "Search files", ++ serde_json::json!({ ++ "type": "object", ++ "properties": {"query": {"type": "string"}}, ++ "required": ["query"] ++ }), ++ ), ++ ToolDefinition::function( ++ "read_file", ++ "Read a file by path", ++ serde_json::json!({ ++ "type": "object", ++ "properties": {"path": {"type": "string"}} ++ }), ++ ), ++ ]), ++ tool_choice, ++ ..base_request(model) ++ } ++} ++ ++/// A full tool round trip: assistant emits two tool calls, the tool turn ++/// returns one success carrying an image and one error result. ++pub(crate) fn corpus_tool_round_trip(model: &str) -> Request { ++ let mut image_result = ToolResult::success("call_1", serde_json::json!({"matches": 2})); ++ image_result.image_data = Some(b"fake-screenshot-bytes".to_vec()); ++ image_result.image_media_type = Some("image/png".to_string()); ++ ++ let mut request = corpus_tools(model, None); ++ request.messages = vec![ ++ Message::user("Find foo and read /tmp/x"), ++ Message { ++ role: Role::Assistant, ++ content: vec![ ++ ContentPart::text("Let me check."), ++ ContentPart::ToolCall(ToolCall::new( ++ "call_1", ++ "search", ++ serde_json::json!({"query": "foo"}), ++ )), ++ ContentPart::ToolCall(ToolCall::new( ++ "call_2", ++ "read_file", ++ serde_json::json!({"path": "/tmp/x"}), ++ )), ++ ], ++ name: None, ++ tool_call_id: None, ++ }, ++ Message { ++ role: Role::Tool, ++ content: vec![ContentPart::ToolResult(image_result)], ++ name: None, ++ tool_call_id: Some("call_1".to_string()), ++ }, ++ Message::tool_result( ++ "call_2", ++ serde_json::Value::String("file not found".to_string()), ++ true, ++ ), ++ ]; ++ request ++} ++ ++/// Assistant thinking block with a signature, round-tripped back as history. ++pub(crate) fn corpus_thinking_round_trip(model: &str) -> Request { ++ Request { ++ messages: vec![ ++ Message::user("Think step by step: what is 2+2?"), ++ Message { ++ role: Role::Assistant, ++ content: vec![ ++ ContentPart::Thinking(ThinkingData { ++ text: "The user wants 2+2, which is 4.".to_string(), ++ signature: Some("sig_test_abc123".to_string()), ++ redacted: false, ++ }), ++ ContentPart::text("4."), ++ ], ++ name: None, ++ tool_call_id: None, ++ }, ++ Message::user("Now 3+3?"), ++ ], ++ ..base_request(model) ++ } ++} ++ ++/// Image and document attachments as inline bytes (no file I/O involved). ++pub(crate) fn corpus_inline_attachments(model: &str) -> Request { ++ Request { ++ messages: vec![Message { ++ role: Role::User, ++ content: vec![ ++ ContentPart::text("Describe these attachments."), ++ ContentPart::Image(ImageData { ++ url: None, ++ data: Some(b"fake-png-bytes".to_vec()), ++ media_type: Some("image/png".to_string()), ++ detail: None, ++ }), ++ ContentPart::Document(DocumentData { ++ url: None, ++ data: Some(b"fake-pdf-bytes".to_vec()), ++ media_type: Some("application/pdf".to_string()), ++ file_name: Some("report.pdf".to_string()), ++ }), ++ ], ++ name: None, ++ tool_call_id: None, ++ }], ++ ..base_request(model) ++ } ++} ++ ++/// Image and document attachments as non-file https URLs. Each dialect has ++/// its own URL-passthrough wire shape; resolving these to inline data would ++/// be a wire change. ++pub(crate) fn corpus_url_attachments(model: &str) -> Request { ++ Request { ++ messages: vec![Message { ++ role: Role::User, ++ content: vec![ ++ ContentPart::text("Describe these attachments."), ++ ContentPart::Image(ImageData { ++ url: Some("https://example.com/picture.png".to_string()), ++ data: None, ++ media_type: Some("image/png".to_string()), ++ detail: None, ++ }), ++ ContentPart::Document(DocumentData { ++ url: Some("https://example.com/report.pdf".to_string()), ++ data: None, ++ media_type: Some("application/pdf".to_string()), ++ file_name: Some("report.pdf".to_string()), ++ }), ++ ], ++ name: None, ++ tool_call_id: None, ++ }], ++ ..base_request(model) ++ } ++} ++ ++/// Attachments referencing file paths that do not exist. Today every adapter ++/// silently drops the part on load failure (`Err(_) => None`) and sends the ++/// rest of the request; these requests pin that contract. ++pub(crate) fn corpus_bad_file_path_attachments(model: &str) -> Request { ++ Request { ++ messages: vec![Message { ++ role: Role::User, ++ content: vec![ ++ ContentPart::text("Describe these attachments."), ++ ContentPart::Image(ImageData { ++ url: Some("/nonexistent/fabro-wire-pin.png".to_string()), ++ data: None, ++ media_type: Some("image/png".to_string()), ++ detail: None, ++ }), ++ ContentPart::Document(DocumentData { ++ url: Some("/nonexistent/fabro-wire-pin.pdf".to_string()), ++ data: None, ++ media_type: Some("application/pdf".to_string()), ++ file_name: Some("missing.pdf".to_string()), ++ }), ++ ], ++ name: None, ++ tool_call_id: None, ++ }], ++ ..base_request(model) ++ } ++} ++ ++/// Inline audio attachment (support differs per dialect: gemini sends it, ++/// openai-responses falls back to text, anthropic/compat drop or warn). ++pub(crate) fn corpus_audio_attachment(model: &str) -> Request { ++ Request { ++ messages: vec![Message { ++ role: Role::User, ++ content: vec![ ++ ContentPart::text("Transcribe this."), ++ ContentPart::Audio(AudioData { ++ url: None, ++ data: Some(b"fake-wav-bytes".to_vec()), ++ media_type: Some("audio/wav".to_string()), ++ }), ++ ], ++ name: None, ++ tool_call_id: None, ++ }], ++ ..base_request(model) ++ } ++} ++ ++/// Response-format request (callers pass each of the three kinds). ++pub(crate) fn corpus_response_format(model: &str, format: ResponseFormat) -> Request { ++ Request { ++ response_format: Some(format), ++ ..base_request(model) ++ } ++} ++ ++/// A JSON-schema response format with `strict` set. The schema is passed raw ++/// (no name/schema wrapper) — the shape `generate_object` produces. ++pub(crate) fn json_schema_format() -> ResponseFormat { ++ ResponseFormat { ++ kind: fabro_llm::types::ResponseFormatType::JsonSchema, ++ json_schema: Some(serde_json::json!({ ++ "type": "object", ++ "properties": {"answer": {"type": "string"}}, ++ "required": ["answer"] ++ })), ++ strict: true, ++ } ++} ++ ++/// Sampling parameters: temperature, top_p, stop sequences, and metadata. ++/// Metadata deliberately holds a single key — `HashMap` iteration order would ++/// make multi-key snapshots nondeterministic. ++pub(crate) fn corpus_sampling_params(model: &str) -> Request { ++ Request { ++ temperature: Some(0.7), ++ top_p: Some(0.9), ++ stop_sequences: Some(vec!["END".to_string()]), ++ metadata: Some(std::collections::HashMap::from([( ++ "trace_id".to_string(), ++ "trace-123".to_string(), ++ )])), ++ ..base_request(model) ++ } ++} ++ ++/// Provider-options escape hatch (callers pass the dialect's namespace key). ++pub(crate) fn corpus_provider_options(model: &str, options: serde_json::Value) -> Request { ++ Request { ++ provider_options: Some(options), ++ ..base_request(model) ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/anthropic.rs b/lib/crates/fabro-llm/tests/it/wire/anthropic.rs +new file mode 100644 +index 000000000..bbeec415d +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/anthropic.rs +@@ -0,0 +1,593 @@ ++//! Wire snapshots for the Anthropic Messages dialect. ++ ++use fabro_llm::provider::ProviderAdapter; ++use fabro_llm::providers::AnthropicAdapter; ++use fabro_llm::types::{ ++ Message, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition, ++}; ++use httpmock::prelude::*; ++ ++use crate::support::{ ++ self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, ++ corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, ++ corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, ++ corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, ++}; ++ ++const MODEL: &str = "claude-sonnet-4-20250514"; ++ ++/// Minimal valid Messages API body for encode-side tests that only assert on ++/// the captured request. ++fn minimal_body() -> serde_json::Value { ++ serde_json::json!({ ++ "id": "msg_test", ++ "type": "message", ++ "role": "assistant", ++ "model": MODEL, ++ "content": [{"type": "text", "text": "ok"}], ++ "stop_reason": "end_turn", ++ "stop_sequence": null, ++ "usage": {"input_tokens": 1, "output_tokens": 1} ++ }) ++} ++ ++/// Runs `complete()` against a capture mock and returns the captured wire ++/// request. ++async fn encode_capture(adapter: AnthropicAdapter, request: &Request) -> WireCapture { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture(&server, "/messages", minimal_body()); ++ let adapter = adapter.with_base_url(server.base_url()); ++ adapter ++ .complete(request) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ take_capture(&slot) ++} ++ ++/// Runs `stream()` against an SSE transcript and returns the captured wire ++/// request plus every emitted stream item as JSON. ++async fn stream_capture( ++ adapter: AnthropicAdapter, ++ request: &Request, ++ sse_body: &str, ++) -> (WireCapture, Vec) { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture_sse(&server, "/messages", sse_body); ++ let adapter = adapter.with_base_url(server.base_url()); ++ let events = support::collect_stream_events(&adapter, request).await; ++ mock.assert(); ++ (take_capture(&slot), events) ++} ++ ++fn adapter() -> AnthropicAdapter { ++ AnthropicAdapter::new("test-key") ++} ++ ++// --------------------------------------------------------------------------- ++// Round trip (encode + decode) ++// --------------------------------------------------------------------------- ++ ++/// Shared setup for the system+tools round trip: runs `complete()` against a ++/// canned response and returns both the captured request and decoded response ++/// so the encode and decode halves can be pinned by separate tests. ++async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture( ++ &server, ++ "/messages", ++ serde_json::json!({ ++ "id": "msg_test", ++ "type": "message", ++ "role": "assistant", ++ "model": "claude-sonnet-4-20250514", ++ "content": [{"type": "text", "text": "Hello back"}], ++ "stop_reason": "end_turn", ++ "stop_sequence": null, ++ "usage": { ++ "input_tokens": 42, ++ "output_tokens": 7, ++ "cache_read_input_tokens": 10, ++ "cache_creation_input_tokens": 3 ++ } ++ }), ++ ); ++ ++ let adapter = AnthropicAdapter::new("test-key").with_base_url(server.base_url()); ++ let request = Request { ++ messages: vec![Message::system("Be concise"), Message::user("Hello")], ++ tools: Some(vec![ToolDefinition::function( ++ "search", ++ "Search files", ++ serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), ++ )]), ++ temperature: Some(0.5), ++ ..base_request("claude-sonnet-4-20250514") ++ }; ++ ++ let response = adapter ++ .complete(&request) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ (take_capture(&slot), response) ++} ++ ++#[tokio::test] ++async fn system_and_tools_encode() { ++ let (capture, _) = system_and_tools_roundtrip().await; ++ fabro_test::fabro_json_snapshot!(capture); ++} ++ ++#[tokio::test] ++async fn system_and_tools_decode() { ++ let (_, response) = system_and_tools_roundtrip().await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++// --------------------------------------------------------------------------- ++// Encode ++// --------------------------------------------------------------------------- ++ ++#[tokio::test] ++async fn encode_multi_turn() { ++ let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_auto() { ++ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_required() { ++ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_named() { ++ let capture = encode_capture( ++ adapter(), ++ &corpus_tools(MODEL, Some(ToolChoice::named("search"))), ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_none() { ++ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_round_trip() { ++ let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_thinking_round_trip() { ++ let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_inline_attachments() { ++ let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_url_attachments() { ++ let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_bad_file_path_attachments_dropped() { ++ let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_audio_attachment() { ++ let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_response_format_json_object() { ++ let format = ResponseFormat { ++ kind: ResponseFormatType::JsonObject, ++ json_schema: None, ++ strict: false, ++ }; ++ let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_response_format_json_schema() { ++ let capture = encode_capture( ++ adapter(), ++ &corpus_response_format(MODEL, json_schema_format()), ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_sampling_params() { ++ let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_provider_options_anthropic_namespace() { ++ let capture = encode_capture( ++ adapter(), ++ &corpus_provider_options(MODEL, serde_json::json!({"anthropic": {"top_k": 5}})), ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_reasoning_effort_with_levels_catalog() { ++ let catalog = support::catalog_from_toml( ++ r#" ++[providers.anthropic] ++display_name = "Anthropic" ++adapter = "anthropic" ++agent_profile = "anthropic" ++ ++[models."test-claude"] ++provider = "anthropic" ++display_name = "Test Claude" ++family = "claude" ++default = true ++ ++[models."test-claude".limits] ++context_window = 200000 ++max_output = 4096 ++ ++[models."test-claude".features] ++tools = true ++vision = true ++reasoning = true ++reasoning_effort = "levels" ++prompt_cache = false ++"#, ++ ); ++ let request = Request { ++ reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High), ++ ..base_request("test-claude") ++ }; ++ let capture = encode_capture(adapter().with_catalog(catalog), &request).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_prompt_cache_with_catalog() { ++ let catalog = support::catalog_from_toml( ++ r#" ++[providers.anthropic] ++display_name = "Anthropic" ++adapter = "anthropic" ++agent_profile = "anthropic" ++ ++[models."test-claude"] ++provider = "anthropic" ++display_name = "Test Claude" ++family = "claude" ++default = true ++ ++[models."test-claude".limits] ++context_window = 200000 ++max_output = 4096 ++ ++[models."test-claude".features] ++tools = true ++vision = true ++reasoning = true ++prompt_cache = true ++"#, ++ ); ++ let request = Request { ++ messages: vec![ ++ Message::system("You are a careful reviewer."), ++ Message::user("Review this."), ++ ], ++ ..corpus_tools("test-claude", None) ++ }; ++ // Full capture: the prompt-cache path also controls the beta header. ++ let capture = encode_capture(adapter().with_catalog(catalog), &request).await; ++ fabro_test::fabro_json_snapshot!(capture); ++} ++ ++#[tokio::test] ++async fn count_tokens_wire_shape() { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture( ++ &server, ++ "/messages/count_tokens", ++ serde_json::json!({"input_tokens": 123}), ++ ); ++ ++ let adapter = adapter().with_base_url(server.base_url()); ++ let request = Request { ++ messages: vec![Message::system("Be concise"), Message::user("Hello")], ++ ..corpus_tools(MODEL, None) ++ }; ++ let count = adapter ++ .count_input_tokens(&request) ++ .await ++ .unwrap() ++ .expect("anthropic should count tokens"); ++ ++ mock.assert(); ++ assert_eq!(count.input_tokens, 123); ++ fabro_test::fabro_json_snapshot!(take_capture(&slot)); ++} ++ ++// --------------------------------------------------------------------------- ++// Decode ++// --------------------------------------------------------------------------- ++ ++/// Runs `complete()` against a canned body and returns the decoded response. ++async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response { ++ let server = MockServer::start(); ++ let (mock, _slot) = mount_capture(&server, "/messages", body); ++ let adapter = adapter().with_base_url(server.base_url()); ++ let response = adapter ++ .complete(&base_request(MODEL)) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ response ++} ++ ++#[tokio::test] ++async fn decode_tool_use_stop_reason() { ++ let response = decode_response(serde_json::json!({ ++ "id": "msg_test", ++ "type": "message", ++ "role": "assistant", ++ "model": MODEL, ++ "content": [ ++ {"type": "text", "text": "Let me search."}, ++ { ++ "type": "tool_use", ++ "id": "toolu_01", ++ "name": "search", ++ "input": {"query": "foo"} ++ } ++ ], ++ "stop_reason": "tool_use", ++ "stop_sequence": null, ++ "usage": {"input_tokens": 30, "output_tokens": 12} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++#[tokio::test] ++async fn decode_thinking_and_redacted_thinking() { ++ let response = decode_response(serde_json::json!({ ++ "id": "msg_test", ++ "type": "message", ++ "role": "assistant", ++ "model": MODEL, ++ "content": [ ++ {"type": "thinking", "thinking": "Step one.", "signature": "sig_decode_abc"}, ++ {"type": "redacted_thinking", "data": "opaque-blob"}, ++ {"type": "text", "text": "Done."} ++ ], ++ "stop_reason": "end_turn", ++ "stop_sequence": null, ++ "usage": {"input_tokens": 25, "output_tokens": 40} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++#[tokio::test] ++async fn decode_max_tokens_stop_reason() { ++ let response = decode_response(serde_json::json!({ ++ "id": "msg_test", ++ "type": "message", ++ "role": "assistant", ++ "model": MODEL, ++ "content": [{"type": "text", "text": "Truncated answe"}], ++ "stop_reason": "max_tokens", ++ "stop_sequence": null, ++ "usage": {"input_tokens": 10, "output_tokens": 128} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++// --------------------------------------------------------------------------- ++// Stream ++// --------------------------------------------------------------------------- ++ ++/// Shared setup for the happy-path text stream; the request and event halves ++/// are pinned by separate tests. ++async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { ++ let sse = support::sse_transcript(&[ ++ ( ++ "message_start", ++ r#"{"type":"message_start","message":{"id":"msg_stream_test","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":11,"cache_read_input_tokens":2,"cache_creation_input_tokens":1,"output_tokens":0}}}"#, ++ ), ++ ("ping", r#"{"type":"ping"}"#), ++ ( ++ "content_block_start", ++ r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, ++ ), ++ ( ++ "content_block_delta", ++ r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}"#, ++ ), ++ ( ++ "content_block_delta", ++ r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}"#, ++ ), ++ ( ++ "content_block_stop", ++ r#"{"type":"content_block_stop","index":0}"#, ++ ), ++ ( ++ "message_delta", ++ r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}}"#, ++ ), ++ ("message_stop", r#"{"type":"message_stop"}"#), ++ ]); ++ stream_capture(adapter(), &base_request(MODEL), &sse).await ++} ++ ++/// The captured request pins the stream flag on the wire. ++#[tokio::test] ++async fn stream_text_happy_path_request() { ++ let (capture, _) = stream_text_happy_path_capture().await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn stream_text_happy_path_events() { ++ let (_, events) = stream_text_happy_path_capture().await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++#[tokio::test] ++async fn stream_tool_call_deltas() { ++ let sse = support::sse_transcript(&[ ++ ( ++ "message_start", ++ r#"{"type":"message_start","message":{"id":"msg_stream_tool","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":20,"output_tokens":0}}}"#, ++ ), ++ ( ++ "content_block_start", ++ r#"{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01","name":"search","input":{}}}"#, ++ ), ++ ( ++ "content_block_delta", ++ r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"qu"}}"#, ++ ), ++ ( ++ "content_block_delta", ++ r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ery\":\"foo\"}"}}"#, ++ ), ++ ( ++ "content_block_stop", ++ r#"{"type":"content_block_stop","index":0}"#, ++ ), ++ ( ++ "message_delta", ++ r#"{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":9}}"#, ++ ), ++ ("message_stop", r#"{"type":"message_stop"}"#), ++ ]); ++ let (_capture, events) = stream_capture( ++ adapter(), ++ &corpus_tools(MODEL, Some(ToolChoice::Auto)), ++ &sse, ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++#[tokio::test] ++async fn stream_thinking_with_signature_delta() { ++ let sse = support::sse_transcript(&[ ++ ( ++ "message_start", ++ r#"{"type":"message_start","message":{"id":"msg_stream_think","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":15,"output_tokens":0}}}"#, ++ ), ++ ( ++ "content_block_start", ++ r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, ++ ), ++ ( ++ "content_block_delta", ++ r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think"}}"#, ++ ), ++ ( ++ "content_block_delta", ++ r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_stream_xyz"}}"#, ++ ), ++ ( ++ "content_block_stop", ++ r#"{"type":"content_block_stop","index":0}"#, ++ ), ++ ( ++ "content_block_start", ++ r#"{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#, ++ ), ++ ( ++ "content_block_delta", ++ r#"{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"4."}}"#, ++ ), ++ ( ++ "content_block_stop", ++ r#"{"type":"content_block_stop","index":1}"#, ++ ), ++ ( ++ "message_delta", ++ r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}"#, ++ ), ++ ("message_stop", r#"{"type":"message_stop"}"#), ++ ]); ++ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++#[tokio::test] ++async fn stream_error_event_mid_stream() { ++ let sse = support::sse_transcript(&[ ++ ( ++ "message_start", ++ r#"{"type":"message_start","message":{"id":"msg_stream_err","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":9,"output_tokens":0}}}"#, ++ ), ++ ( ++ "error", ++ r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, ++ ), ++ ]); ++ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++/// The Anthropic decoder never synthesizes a `Finish` on byte-stream end: ++/// `message_stop` is the only finisher. A transcript that ends without it ++/// must produce no `Finish` event. ++#[tokio::test] ++async fn stream_without_message_stop_emits_no_finish() { ++ let sse = support::sse_transcript(&[ ++ ( ++ "message_start", ++ r#"{"type":"message_start","message":{"id":"msg_stream_cut","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":11,"output_tokens":0}}}"#, ++ ), ++ ( ++ "content_block_start", ++ r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, ++ ), ++ ( ++ "content_block_delta", ++ r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#, ++ ), ++ ( ++ "content_block_stop", ++ r#"{"type":"content_block_stop","index":0}"#, ++ ), ++ ( ++ "message_delta", ++ r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}}"#, ++ ), ++ ]); ++ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/gemini.rs b/lib/crates/fabro-llm/tests/it/wire/gemini.rs +new file mode 100644 +index 000000000..0471b585b +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/gemini.rs +@@ -0,0 +1,487 @@ ++//! Wire snapshots for the Gemini `generateContent` dialect. The model is ++//! part of the URL path, auth is the `x-goog-api-key` header, and the ++//! decoder mints synthetic UUID tool-call ids (normalized to `[UUID]` in ++//! these snapshots). ++ ++use fabro_llm::provider::ProviderAdapter; ++use fabro_llm::providers::GeminiAdapter; ++use fabro_llm::types::{ ++ Message, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition, ++}; ++use httpmock::prelude::*; ++ ++use crate::support::{ ++ self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, ++ corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, ++ corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, ++ corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, ++}; ++ ++const MODEL: &str = "gemini-test"; ++const COMPLETE_PATH: &str = "/models/gemini-test:generateContent"; ++const STREAM_PATH: &str = "/models/gemini-test:streamGenerateContent"; ++ ++/// Minimal valid generateContent body for encode-side tests. ++fn minimal_body() -> serde_json::Value { ++ serde_json::json!({ ++ "candidates": [{ ++ "content": {"role": "model", "parts": [{"text": "ok"}]}, ++ "finishReason": "STOP" ++ }], ++ "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1} ++ }) ++} ++ ++fn adapter() -> GeminiAdapter { ++ GeminiAdapter::new("test-key") ++} ++ ++/// Runs `complete()` against a capture mock and returns the captured wire ++/// request. ++async fn encode_capture(adapter: GeminiAdapter, request: &Request) -> WireCapture { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture(&server, COMPLETE_PATH, minimal_body()); ++ let adapter = adapter.with_base_url(server.base_url()); ++ adapter ++ .complete(request) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ take_capture(&slot) ++} ++ ++/// Runs `stream()` against an SSE transcript and returns the captured wire ++/// request plus every emitted stream item as JSON (UUIDs normalized). ++async fn stream_capture( ++ adapter: GeminiAdapter, ++ request: &Request, ++ sse_body: &str, ++) -> (WireCapture, Vec) { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture_sse(&server, STREAM_PATH, sse_body); ++ let adapter = adapter.with_base_url(server.base_url()); ++ let mut events = support::collect_stream_events(&adapter, request).await; ++ mock.assert(); ++ events.iter_mut().for_each(support::normalize_uuids); ++ (take_capture(&slot), events) ++} ++ ++// --------------------------------------------------------------------------- ++// Round trip (encode + decode) ++// --------------------------------------------------------------------------- ++ ++/// Shared setup for the system+tools round trip. The decoded response is ++/// returned as a UUID-normalized JSON value (gemini mints a synthetic UUID ++/// for the response id); the encode and decode halves are pinned separately. ++async fn system_and_tools_roundtrip() -> (WireCapture, serde_json::Value) { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture( ++ &server, ++ COMPLETE_PATH, ++ serde_json::json!({ ++ "candidates": [{ ++ "content": {"role": "model", "parts": [{"text": "Hello back"}]}, ++ "finishReason": "STOP" ++ }], ++ "usageMetadata": { ++ "promptTokenCount": 42, ++ "candidatesTokenCount": 7, ++ "cachedContentTokenCount": 10 ++ } ++ }), ++ ); ++ ++ let adapter = adapter().with_base_url(server.base_url()); ++ let request = Request { ++ messages: vec![Message::system("Be concise"), Message::user("Hello")], ++ tools: Some(vec![ToolDefinition::function( ++ "search", ++ "Search files", ++ serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), ++ )]), ++ temperature: Some(0.5), ++ ..base_request(MODEL) ++ }; ++ ++ let response = adapter ++ .complete(&request) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ let mut response_value = serde_json::to_value(&response).expect("response should serialize"); ++ support::normalize_uuids(&mut response_value); ++ (take_capture(&slot), response_value) ++} ++ ++#[tokio::test] ++async fn system_and_tools_encode() { ++ let (capture, _) = system_and_tools_roundtrip().await; ++ fabro_test::fabro_json_snapshot!(capture); ++} ++ ++#[tokio::test] ++async fn system_and_tools_decode() { ++ let (_, response) = system_and_tools_roundtrip().await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++// --------------------------------------------------------------------------- ++// Encode ++// --------------------------------------------------------------------------- ++ ++#[tokio::test] ++async fn encode_multi_turn() { ++ let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_auto() { ++ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_required() { ++ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_named() { ++ let capture = encode_capture( ++ adapter(), ++ &corpus_tools(MODEL, Some(ToolChoice::named("search"))), ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_none() { ++ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_round_trip() { ++ let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_thinking_round_trip() { ++ let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_inline_attachments() { ++ let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_url_attachments() { ++ let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_bad_file_path_attachments_dropped() { ++ let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// Gemini sends inline audio (the only dialect that does). ++#[tokio::test] ++async fn encode_audio_attachment() { ++ let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_response_format_json_object() { ++ let format = ResponseFormat { ++ kind: ResponseFormatType::JsonObject, ++ json_schema: None, ++ strict: false, ++ }; ++ let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_response_format_json_schema() { ++ let capture = encode_capture( ++ adapter(), ++ &corpus_response_format(MODEL, json_schema_format()), ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_sampling_params() { ++ let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// The "gemini"-namespaced provider_options merge — and the default ++/// safety_settings injection it can override. ++#[tokio::test] ++async fn encode_provider_options_gemini_namespace() { ++ let capture = encode_capture( ++ adapter(), ++ &corpus_provider_options( ++ MODEL, ++ serde_json::json!({"gemini": {"cached_content": "cachedContents/abc"}}), ++ ), ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_provider_options_can_override_safety_settings() { ++ let capture = encode_capture( ++ adapter(), ++ &corpus_provider_options( ++ MODEL, ++ serde_json::json!({"gemini": {"safety_settings": []}}), ++ ), ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_reasoning_effort_with_levels_catalog() { ++ let catalog = support::catalog_from_toml( ++ r#" ++[providers.gemini] ++display_name = "Gemini" ++adapter = "gemini" ++agent_profile = "gemini" ++ ++[models."gemini-test"] ++provider = "gemini" ++display_name = "Test Gemini" ++family = "gemini" ++default = true ++ ++[models."gemini-test".limits] ++context_window = 200000 ++max_output = 4096 ++ ++[models."gemini-test".features] ++tools = true ++vision = true ++reasoning = true ++reasoning_effort = "levels" ++"#, ++ ); ++ let request = Request { ++ reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High), ++ ..base_request(MODEL) ++ }; ++ let capture = encode_capture(adapter().with_catalog(catalog), &request).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn count_tokens_wire_shape() { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture( ++ &server, ++ "/models/gemini-test:countTokens", ++ serde_json::json!({"totalTokens": 123}), ++ ); ++ let adapter = adapter().with_base_url(server.base_url()); ++ let request = Request { ++ messages: vec![Message::system("Be concise"), Message::user("Hello")], ++ ..corpus_tools(MODEL, None) ++ }; ++ let count = adapter ++ .count_input_tokens(&request) ++ .await ++ .unwrap() ++ .expect("gemini should count tokens"); ++ ++ mock.assert(); ++ assert_eq!(count.input_tokens, 123); ++ fabro_test::fabro_json_snapshot!(take_capture(&slot)); ++} ++ ++// --------------------------------------------------------------------------- ++// Decode ++// --------------------------------------------------------------------------- ++ ++/// Runs `complete()` against a canned body and returns the decoded response ++/// as JSON with synthetic UUIDs normalized. ++async fn decode_response(body: serde_json::Value) -> serde_json::Value { ++ let server = MockServer::start(); ++ let (mock, _slot) = mount_capture(&server, COMPLETE_PATH, body); ++ let adapter = adapter().with_base_url(server.base_url()); ++ let response = adapter ++ .complete(&base_request(MODEL)) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ let mut value = serde_json::to_value(&response).expect("response should serialize"); ++ support::normalize_uuids(&mut value); ++ value ++} ++ ++/// functionCall parts get synthetic UUID ids, preserve `thoughtSignature`, ++/// and force the finish reason to ToolCalls regardless of `finishReason`. ++#[tokio::test] ++async fn decode_function_call_with_thought_signature() { ++ let response = decode_response(serde_json::json!({ ++ "candidates": [{ ++ "content": { ++ "role": "model", ++ "parts": [ ++ {"text": "Let me search."}, ++ { ++ "functionCall": {"name": "search", "args": {"query": "foo"}}, ++ "thoughtSignature": "sig_gemini_xyz" ++ } ++ ] ++ }, ++ "finishReason": "STOP" ++ }], ++ "usageMetadata": {"promptTokenCount": 30, "candidatesTokenCount": 12} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++/// The Gemini usage arithmetic: input = (prompt - cached) + tool_use_prompt; ++/// thoughts become reasoning tokens. ++#[tokio::test] ++async fn decode_usage_arithmetic() { ++ let response = decode_response(serde_json::json!({ ++ "candidates": [{ ++ "content": {"role": "model", "parts": [{"text": "ok"}]}, ++ "finishReason": "STOP" ++ }], ++ "usageMetadata": { ++ "promptTokenCount": 100, ++ "candidatesTokenCount": 50, ++ "thoughtsTokenCount": 8, ++ "cachedContentTokenCount": 30, ++ "toolUsePromptTokenCount": 5 ++ } ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++/// `thought: true` text parts decode as Thinking content. ++#[tokio::test] ++async fn decode_thought_parts() { ++ let response = decode_response(serde_json::json!({ ++ "candidates": [{ ++ "content": { ++ "role": "model", ++ "parts": [ ++ {"text": "Adding the numbers.", "thought": true}, ++ {"text": "4."} ++ ] ++ }, ++ "finishReason": "STOP" ++ }], ++ "usageMetadata": {"promptTokenCount": 25, "candidatesTokenCount": 40} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++#[tokio::test] ++async fn decode_max_tokens_finish_reason() { ++ let length = decode_response(serde_json::json!({ ++ "candidates": [{ ++ "content": {"role": "model", "parts": [{"text": "Trunc"}]}, ++ "finishReason": "MAX_TOKENS" ++ }], ++ "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 128} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(length["finish_reason"]); ++} ++ ++#[tokio::test] ++async fn decode_safety_finish_reason() { ++ let safety = decode_response(serde_json::json!({ ++ "candidates": [{ ++ "content": {"role": "model", "parts": [{"text": ""}]}, ++ "finishReason": "SAFETY" ++ }], ++ "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 0} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(safety["finish_reason"]); ++} ++ ++// --------------------------------------------------------------------------- ++// Stream ++// --------------------------------------------------------------------------- ++ ++/// Shared setup for the happy-path text stream; the request and event halves ++/// are pinned by separate tests. ++async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hel"}]}}]}"#, ++ r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"lo"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":5}}"#, ++ ]); ++ stream_capture(adapter(), &base_request(MODEL), &sse).await ++} ++ ++/// The captured request pins model-in-URL and `?alt=sse` on the wire. ++#[tokio::test] ++async fn stream_text_happy_path_request() { ++ let (capture, _) = stream_text_happy_path_capture().await; ++ fabro_test::fabro_json_snapshot!(capture); ++} ++ ++#[tokio::test] ++async fn stream_text_happy_path_events() { ++ let (_, events) = stream_text_happy_path_capture().await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++#[tokio::test] ++async fn stream_function_call() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"search","args":{"query":"foo"}},"thoughtSignature":"sig_stream_g"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":9}}"#, ++ ]); ++ let (_capture, events) = stream_capture( ++ adapter(), ++ &corpus_tools(MODEL, Some(ToolChoice::Auto)), ++ &sse, ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++#[tokio::test] ++async fn stream_thought_parts() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Let me think","thought":true}]}}]}"#, ++ r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"4."}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":15,"candidatesTokenCount":12,"thoughtsTokenCount":6}}"#, ++ ]); ++ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++/// The Gemini decoder synthesizes a `Finish` on byte-stream end ++/// unconditionally — even when no chunk carried a `finishReason`. ++#[tokio::test] ++async fn stream_end_synthesizes_finish_without_finish_reason() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello"}]}}]}"#, ++ ]); ++ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/mod.rs b/lib/crates/fabro-llm/tests/it/wire/mod.rs +new file mode 100644 +index 000000000..16ad18987 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/mod.rs +@@ -0,0 +1,22 @@ ++//! Wire snapshot tests pinning per-dialect encode/decode behavior. ++//! ++//! Each test points a real adapter at a local httpmock server, side-channels ++//! the full received request (method, path, headers, body) out of an ++//! `is_true` matcher closure, responds with a canned provider body, and ++//! snapshots both the captured wire request (encode) and the decoded ++//! canonical `Response` (decode). The codec extraction PRs must keep these ++//! snapshot values identical. ++//! ++//! The anthropic/gemini dialects have no twin coverage, so these snapshots ++//! are the only behavior net for those extractions. ++//! ++//! Snapshots are stored externally under `snapshots/` (via ++//! `fabro_test::fabro_json_snapshot!(value)` with no inline literal) to keep ++//! these source files small. Review and accept with `cargo insta` ++//! (`pending-snapshots` then `accept`), per CLAUDE.md. A few tests assert two ++//! snapshots in one function; insta names the second `-2.snap`. ++ ++mod anthropic; ++mod gemini; ++mod openai_compatible; ++mod openai_responses; +diff --git a/lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs b/lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +new file mode 100644 +index 000000000..661141eb4 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs +@@ -0,0 +1,439 @@ ++//! Wire snapshots for the OpenAI Chat Completions dialect served by ++//! `OpenAiCompatibleAdapter` (kimi, zai, minimax, venice, inception, ollama, ++//! litellm — all config-only routes over this adapter). ++ ++use fabro_llm::provider::ProviderAdapter; ++use fabro_llm::providers::OpenAiCompatibleAdapter; ++use fabro_llm::types::{ ++ Message, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition, ++}; ++use httpmock::prelude::*; ++ ++use crate::support::{ ++ self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, ++ corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, ++ corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, ++ corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, ++}; ++ ++const MODEL: &str = "test-model"; ++ ++/// Fixed `created` timestamp for canned bodies (named to satisfy clippy's ++/// unreadable-literal lint without touching the JSON wire value). ++const CREATED_TS: i64 = 1_700_000_000; ++ ++/// Minimal valid Chat Completions body for encode-side tests. ++fn minimal_body() -> serde_json::Value { ++ serde_json::json!({ ++ "id": "chatcmpl_test", ++ "object": "chat.completion", ++ "created": CREATED_TS, ++ "model": MODEL, ++ "choices": [{ ++ "index": 0, ++ "message": {"role": "assistant", "content": "ok"}, ++ "finish_reason": "stop" ++ }], ++ "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} ++ }) ++} ++ ++fn adapter(server: &MockServer) -> OpenAiCompatibleAdapter { ++ OpenAiCompatibleAdapter::new("test-key", server.base_url()) ++} ++ ++/// Runs `complete()` against a capture mock and returns the captured wire ++/// request. ++async fn encode_capture_with( ++ request: &Request, ++ configure: impl FnOnce(OpenAiCompatibleAdapter) -> OpenAiCompatibleAdapter, ++) -> WireCapture { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture(&server, "/chat/completions", minimal_body()); ++ let adapter = configure(adapter(&server)); ++ adapter ++ .complete(request) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ take_capture(&slot) ++} ++ ++async fn encode_capture(request: &Request) -> WireCapture { ++ encode_capture_with(request, |adapter| adapter).await ++} ++ ++/// Runs `stream()` against an SSE transcript and returns the captured wire ++/// request plus every emitted stream item as JSON. ++async fn stream_capture( ++ request: &Request, ++ sse_body: &str, ++) -> (WireCapture, Vec) { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture_sse(&server, "/chat/completions", sse_body); ++ let adapter = adapter(&server); ++ let events = support::collect_stream_events(&adapter, request).await; ++ mock.assert(); ++ (take_capture(&slot), events) ++} ++ ++// --------------------------------------------------------------------------- ++// Round trip (encode + decode) ++// --------------------------------------------------------------------------- ++ ++/// Shared setup for the system+tools round trip; the encode and decode halves ++/// are pinned by separate tests. ++async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture( ++ &server, ++ "/chat/completions", ++ serde_json::json!({ ++ "id": "chatcmpl_test", ++ "object": "chat.completion", ++ "created": CREATED_TS, ++ "model": MODEL, ++ "choices": [{ ++ "index": 0, ++ "message": {"role": "assistant", "content": "Hello back"}, ++ "finish_reason": "stop" ++ }], ++ "usage": {"prompt_tokens": 42, "completion_tokens": 7, "total_tokens": 49} ++ }), ++ ); ++ ++ let adapter = adapter(&server); ++ let request = Request { ++ messages: vec![Message::system("Be concise"), Message::user("Hello")], ++ tools: Some(vec![ToolDefinition::function( ++ "search", ++ "Search files", ++ serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), ++ )]), ++ temperature: Some(0.5), ++ ..base_request(MODEL) ++ }; ++ ++ let response = adapter ++ .complete(&request) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ (take_capture(&slot), response) ++} ++ ++#[tokio::test] ++async fn system_and_tools_encode() { ++ let (capture, _) = system_and_tools_roundtrip().await; ++ fabro_test::fabro_json_snapshot!(capture); ++} ++ ++#[tokio::test] ++async fn system_and_tools_decode() { ++ let (_, response) = system_and_tools_roundtrip().await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++// --------------------------------------------------------------------------- ++// Encode ++// --------------------------------------------------------------------------- ++ ++#[tokio::test] ++async fn encode_multi_turn() { ++ let capture = encode_capture(&corpus_multi_turn(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_auto() { ++ let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::Auto))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_required() { ++ let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::Required))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_named() { ++ let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::named("search")))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_none() { ++ let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::None))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_round_trip() { ++ let capture = encode_capture(&corpus_tool_round_trip(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// Assistant thinking parts echo back as `reasoning_content` (Kimi-motivated, ++/// applies to every compat assistant message). ++#[tokio::test] ++async fn encode_thinking_round_trip_as_reasoning_content() { ++ let capture = encode_capture(&corpus_thinking_round_trip(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// The compat encoder performs no attachment I/O: images are dropped ++/// outright, documents become fallback text. ++#[tokio::test] ++async fn encode_inline_attachments() { ++ let capture = encode_capture(&corpus_inline_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_url_attachments() { ++ let capture = encode_capture(&corpus_url_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_bad_file_path_attachments() { ++ let capture = encode_capture(&corpus_bad_file_path_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_audio_attachment() { ++ let capture = encode_capture(&corpus_audio_attachment(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_response_format_json_object() { ++ let format = ResponseFormat { ++ kind: ResponseFormatType::JsonObject, ++ json_schema: None, ++ strict: false, ++ }; ++ let capture = encode_capture(&corpus_response_format(MODEL, format)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_response_format_json_schema() { ++ let capture = encode_capture(&corpus_response_format(MODEL, json_schema_format())).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_sampling_params() { ++ let capture = encode_capture(&corpus_sampling_params(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// 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). ++#[tokio::test] ++async fn encode_provider_options_keyed_by_adapter_name() { ++ let request = corpus_provider_options( ++ MODEL, ++ serde_json::json!({"kimi": {"repetition_penalty": 1.2}}), ++ ); ++ let capture = encode_capture_with(&request, |adapter| adapter.with_name("kimi")).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// Options under a key that does not match the adapter name must not merge. ++#[tokio::test] ++async fn encode_provider_options_other_namespace_ignored() { ++ let request = corpus_provider_options( ++ MODEL, ++ serde_json::json!({"openai": {"repetition_penalty": 1.2}}), ++ ); ++ let capture = encode_capture_with(&request, |adapter| adapter.with_name("kimi")).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// The compat adapter has no count-tokens wire route. ++#[tokio::test] ++async fn count_input_tokens_unavailable() { ++ let server = MockServer::start(); ++ let adapter = adapter(&server); ++ let count = adapter ++ .count_input_tokens(&base_request(MODEL)) ++ .await ++ .unwrap(); ++ assert!(count.is_none()); ++} ++ ++// --------------------------------------------------------------------------- ++// Decode ++// --------------------------------------------------------------------------- ++ ++async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response { ++ let server = MockServer::start(); ++ let (mock, _slot) = mount_capture(&server, "/chat/completions", body); ++ let adapter = adapter(&server); ++ let response = adapter ++ .complete(&base_request(MODEL)) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ response ++} ++ ++#[tokio::test] ++async fn decode_tool_calls_with_string_arguments() { ++ let response = decode_response(serde_json::json!({ ++ "id": "chatcmpl_test", ++ "object": "chat.completion", ++ "created": CREATED_TS, ++ "model": MODEL, ++ "choices": [{ ++ "index": 0, ++ "message": { ++ "role": "assistant", ++ "content": null, ++ "tool_calls": [{ ++ "id": "call_abc", ++ "type": "function", ++ "function": {"name": "search", "arguments": "{\"query\":\"foo\"}"} ++ }] ++ }, ++ "finish_reason": "tool_calls" ++ }], ++ "usage": {"prompt_tokens": 30, "completion_tokens": 12, "total_tokens": 42} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++#[tokio::test] ++async fn decode_reasoning_content_as_thinking() { ++ let response = decode_response(serde_json::json!({ ++ "id": "chatcmpl_test", ++ "object": "chat.completion", ++ "created": CREATED_TS, ++ "model": MODEL, ++ "choices": [{ ++ "index": 0, ++ "message": { ++ "role": "assistant", ++ "content": "4.", ++ "reasoning_content": "The user wants 2+2." ++ }, ++ "finish_reason": "stop" ++ }], ++ "usage": {"prompt_tokens": 25, "completion_tokens": 40, "total_tokens": 65} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++/// Compat usage reads only prompt/completion tokens; cached-token details are ++/// ignored today (parsing them is a 438-redo behavior change). ++#[tokio::test] ++async fn decode_usage_ignores_token_details() { ++ let response = decode_response(serde_json::json!({ ++ "id": "chatcmpl_test", ++ "object": "chat.completion", ++ "created": CREATED_TS, ++ "model": MODEL, ++ "choices": [{ ++ "index": 0, ++ "message": {"role": "assistant", "content": "ok"}, ++ "finish_reason": "length" ++ }], ++ "usage": { ++ "prompt_tokens": 100, ++ "completion_tokens": 50, ++ "total_tokens": 150, ++ "prompt_tokens_details": {"cached_tokens": 80}, ++ "completion_tokens_details": {"reasoning_tokens": 20} ++ } ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++// --------------------------------------------------------------------------- ++// Stream ++// --------------------------------------------------------------------------- ++ ++/// Shared setup for the happy-path text stream; the request and event halves ++/// are pinned by separate tests. ++async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"},"finish_reason":null}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"lo"},"finish_reason":null}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":11,"completion_tokens":5,"total_tokens":16}}"#, ++ "[DONE]", ++ ]); ++ stream_capture(&base_request(MODEL), &sse).await ++} ++ ++/// The captured request pins the stream flag on the wire. ++#[tokio::test] ++async fn stream_text_happy_path_request() { ++ let (capture, _) = stream_text_happy_path_capture().await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn stream_text_happy_path_events() { ++ let (_, events) = stream_text_happy_path_capture().await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++#[tokio::test] ++async fn stream_tool_call_deltas() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"search","arguments":""}}]},"finish_reason":null}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"qu"}}]},"finish_reason":null}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ery\":\"foo\"}"}}]},"finish_reason":null}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":20,"completion_tokens":9,"total_tokens":29}}"#, ++ "[DONE]", ++ ]); ++ let (_capture, events) = ++ stream_capture(&corpus_tools(MODEL, Some(ToolChoice::Auto)), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++#[tokio::test] ++async fn stream_reasoning_content_deltas() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Let me "},"finish_reason":null}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"reasoning_content":"think"},"finish_reason":null}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"4."},"finish_reason":null}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, ++ "[DONE]", ++ ]); ++ let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++/// Minimax tolerance: a stream that ends without `[DONE]` still synthesizes ++/// the finish — but only because content was started. ++#[tokio::test] ++async fn stream_without_done_synthesizes_finish_when_content_started() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}"#, ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, ++ ]); ++ let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++/// The other half of the minimax contract: no content started and no ++/// `[DONE]` — nothing is synthesized. ++#[tokio::test] ++async fn stream_without_done_or_content_synthesizes_nothing() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}"#, ++ ]); ++ let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/openai_responses.rs b/lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +new file mode 100644 +index 000000000..d3434f018 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/openai_responses.rs +@@ -0,0 +1,563 @@ ++//! Wire snapshots for the OpenAI Responses API dialect (`POST /responses`). ++ ++use fabro_llm::provider::ProviderAdapter; ++use fabro_llm::providers::OpenAiAdapter; ++use fabro_llm::types::{ ++ ContentPart, Message, Request, ResponseFormat, ResponseFormatType, Role, ToolCall, ToolChoice, ++ ToolDefinition, ++}; ++use httpmock::prelude::*; ++ ++use crate::support::{ ++ self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, ++ corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, ++ corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, ++ corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, ++}; ++ ++const MODEL: &str = "gpt-test"; ++ ++/// Minimal valid Responses API body for encode-side tests. ++fn minimal_body() -> serde_json::Value { ++ serde_json::json!({ ++ "id": "resp_test", ++ "object": "response", ++ "model": MODEL, ++ "status": "completed", ++ "output": [{ ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_out", ++ "content": [{"type": "output_text", "text": "ok"}] ++ }], ++ "usage": {"input_tokens": 1, "output_tokens": 1} ++ }) ++} ++ ++fn adapter() -> OpenAiAdapter { ++ OpenAiAdapter::new("test-key") ++} ++ ++/// Runs `complete()` against a capture mock and returns the captured wire ++/// request. ++async fn encode_capture(adapter: OpenAiAdapter, request: &Request) -> WireCapture { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture(&server, "/responses", minimal_body()); ++ let adapter = adapter.with_base_url(server.base_url()); ++ adapter ++ .complete(request) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ take_capture(&slot) ++} ++ ++/// Runs `stream()` against an SSE transcript and returns the captured wire ++/// request plus every emitted stream item as JSON. ++async fn stream_capture( ++ adapter: OpenAiAdapter, ++ request: &Request, ++ sse_body: &str, ++) -> (WireCapture, Vec) { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture_sse(&server, "/responses", sse_body); ++ let adapter = adapter.with_base_url(server.base_url()); ++ let events = support::collect_stream_events(&adapter, request).await; ++ mock.assert(); ++ (take_capture(&slot), events) ++} ++ ++// --------------------------------------------------------------------------- ++// Round trip (encode + decode) ++// --------------------------------------------------------------------------- ++ ++/// Shared setup for the system+tools round trip; the encode and decode halves ++/// are pinned by separate tests. ++async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture( ++ &server, ++ "/responses", ++ serde_json::json!({ ++ "id": "resp_test", ++ "object": "response", ++ "model": MODEL, ++ "status": "completed", ++ "output": [{ ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_out", ++ "content": [{"type": "output_text", "text": "Hello back"}] ++ }], ++ "usage": { ++ "input_tokens": 42, ++ "output_tokens": 7, ++ "input_tokens_details": {"cached_tokens": 10}, ++ "output_tokens_details": {"reasoning_tokens": 3} ++ } ++ }), ++ ); ++ ++ let adapter = adapter().with_base_url(server.base_url()); ++ let request = Request { ++ messages: vec![Message::system("Be concise"), Message::user("Hello")], ++ tools: Some(vec![ToolDefinition::function( ++ "search", ++ "Search files", ++ serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), ++ )]), ++ temperature: Some(0.5), ++ ..base_request(MODEL) ++ }; ++ ++ let response = adapter ++ .complete(&request) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ (take_capture(&slot), response) ++} ++ ++#[tokio::test] ++async fn system_and_tools_encode() { ++ let (capture, _) = system_and_tools_roundtrip().await; ++ fabro_test::fabro_json_snapshot!(capture); ++} ++ ++#[tokio::test] ++async fn system_and_tools_decode() { ++ let (_, response) = system_and_tools_roundtrip().await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++// --------------------------------------------------------------------------- ++// Encode ++// --------------------------------------------------------------------------- ++ ++#[tokio::test] ++async fn encode_multi_turn() { ++ let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_auto() { ++ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_required() { ++ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_named() { ++ let capture = encode_capture( ++ adapter(), ++ &corpus_tools(MODEL, Some(ToolChoice::named("search"))), ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_choice_none() { ++ let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_tool_round_trip() { ++ let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// A tool call that decoded with an item-level id (`fc_…`) in ++/// provider_metadata re-encodes with the dual ids split correctly. ++#[tokio::test] ++async fn encode_dual_id_tool_round_trip() { ++ let mut tool_call = ToolCall::new("call_abc", "search", serde_json::json!({"query": "foo"})); ++ tool_call.provider_metadata = Some(serde_json::json!({"id": "fc_123"})); ++ let mut request = corpus_tools(MODEL, None); ++ request.messages = vec![ ++ Message::user("Find foo"), ++ Message { ++ role: Role::Assistant, ++ content: vec![ContentPart::ToolCall(tool_call)], ++ name: None, ++ tool_call_id: None, ++ }, ++ Message::tool_result( ++ "call_abc", ++ serde_json::Value::String("2 matches".to_string()), ++ false, ++ ), ++ ]; ++ let capture = encode_capture(adapter(), &request).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// Opaque OpenAI items (reasoning / message) round-trip verbatim into the ++/// input array. ++#[tokio::test] ++async fn encode_opaque_items_round_trip() { ++ let request = Request { ++ messages: vec![ ++ Message::user("Think about 2+2."), ++ Message { ++ role: Role::Assistant, ++ content: vec![ ++ ContentPart::Other { ++ kind: ContentPart::OPENAI_REASONING.to_string(), ++ data: serde_json::json!({ ++ "type": "reasoning", ++ "id": "rs_1", ++ "summary": [{"type": "summary_text", "text": "Adding."}] ++ }), ++ }, ++ ContentPart::Other { ++ kind: ContentPart::OPENAI_MESSAGE.to_string(), ++ data: serde_json::json!({ ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_1", ++ "content": [{"type": "output_text", "text": "4."}] ++ }), ++ }, ++ ], ++ name: None, ++ tool_call_id: None, ++ }, ++ Message::user("Now 3+3?"), ++ ], ++ ..base_request(MODEL) ++ }; ++ let capture = encode_capture(adapter(), &request).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// Canonical Thinking parts (anthropic-style) — distinct from the opaque ++/// reasoning round-trip above. ++#[tokio::test] ++async fn encode_thinking_round_trip() { ++ let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_inline_attachments() { ++ let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_url_attachments() { ++ let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_bad_file_path_attachments_dropped() { ++ let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_audio_attachment() { ++ let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_response_format_json_object() { ++ let format = ResponseFormat { ++ kind: ResponseFormatType::JsonObject, ++ json_schema: None, ++ strict: false, ++ }; ++ let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_response_format_json_schema() { ++ let capture = encode_capture( ++ adapter(), ++ &corpus_response_format(MODEL, json_schema_format()), ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_sampling_params() { ++ let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_provider_options_openai_namespace() { ++ let capture = encode_capture( ++ adapter(), ++ &corpus_provider_options(MODEL, serde_json::json!({"openai": {"seed": 42}})), ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn encode_reasoning_effort_with_levels_catalog() { ++ let catalog = support::catalog_from_toml( ++ r#" ++[providers.openai] ++display_name = "OpenAI" ++adapter = "openai" ++agent_profile = "openai" ++ ++[models."test-gpt"] ++provider = "openai" ++display_name = "Test GPT" ++family = "gpt" ++default = true ++ ++[models."test-gpt".limits] ++context_window = 200000 ++max_output = 4096 ++ ++[models."test-gpt".features] ++tools = true ++vision = true ++reasoning = true ++reasoning_effort = "levels" ++"#, ++ ); ++ let request = Request { ++ reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High), ++ ..base_request("test-gpt") ++ }; ++ let capture = encode_capture(adapter().with_catalog(catalog), &request).await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++/// Codex mode forces streaming for `complete()` and omits sampling params ++/// from the encoded body. ++#[tokio::test] ++async fn encode_codex_mode_forces_streaming_and_omits_params() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"type":"response.created","response":{"id":"resp_codex","model":"gpt-test"}}"#, ++ r#"{"type":"response.output_text.delta","delta":"ok"}"#, ++ r#"{"type":"response.completed","response":{"id":"resp_codex","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":5,"output_tokens":2}}}"#, ++ ]); ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture_sse(&server, "/responses", &sse); ++ let adapter = OpenAiAdapter::new("test-key") ++ .with_codex_mode() ++ .with_base_url(server.base_url()); ++ let request = Request { ++ messages: vec![Message::system("Be concise"), Message::user("Hello")], ++ temperature: Some(0.5), ++ top_p: Some(0.9), ++ ..base_request(MODEL) ++ }; ++ adapter.complete(&request).await.unwrap(); ++ mock.assert(); ++ fabro_test::fabro_json_snapshot!(take_capture(&slot)); ++} ++ ++#[tokio::test] ++async fn count_tokens_wire_shape() { ++ let server = MockServer::start(); ++ let (mock, slot) = mount_capture( ++ &server, ++ "/responses/input_tokens", ++ serde_json::json!({"input_tokens": 123, "object": "response.input_tokens"}), ++ ); ++ let adapter = adapter().with_base_url(server.base_url()); ++ let request = Request { ++ messages: vec![Message::system("Be concise"), Message::user("Hello")], ++ ..corpus_tools(MODEL, None) ++ }; ++ let count = adapter ++ .count_input_tokens(&request) ++ .await ++ .unwrap() ++ .expect("openai should count tokens"); ++ ++ mock.assert(); ++ assert_eq!(count.input_tokens, 123); ++ fabro_test::fabro_json_snapshot!(take_capture(&slot)); ++} ++ ++// --------------------------------------------------------------------------- ++// Decode ++// --------------------------------------------------------------------------- ++ ++async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response { ++ let server = MockServer::start(); ++ let (mock, _slot) = mount_capture(&server, "/responses", body); ++ let adapter = adapter().with_base_url(server.base_url()); ++ let response = adapter ++ .complete(&base_request(MODEL)) ++ .await ++ .expect("complete should succeed"); ++ mock.assert(); ++ response ++} ++ ++/// The Responses usage arithmetic: cached tokens are subtracted from input, ++/// reasoning tokens from output. ++#[tokio::test] ++async fn decode_usage_subtracts_cached_and_reasoning() { ++ let response = decode_response(serde_json::json!({ ++ "id": "resp_test", ++ "object": "response", ++ "model": MODEL, ++ "status": "completed", ++ "output": [{ ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_out", ++ "content": [{"type": "output_text", "text": "ok"}] ++ }], ++ "usage": { ++ "input_tokens": 100, ++ "output_tokens": 50, ++ "input_tokens_details": {"cached_tokens": 80}, ++ "output_tokens_details": {"reasoning_tokens": 20} ++ } ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++/// Reasoning and function_call output items: reasoning becomes an opaque ++/// round-trip part, function_call splits dual ids into id + metadata. ++#[tokio::test] ++async fn decode_reasoning_and_function_call_items() { ++ let response = decode_response(serde_json::json!({ ++ "id": "resp_test", ++ "object": "response", ++ "model": MODEL, ++ "status": "completed", ++ "output": [ ++ { ++ "type": "reasoning", ++ "id": "rs_1", ++ "summary": [{"type": "summary_text", "text": "Searching."}] ++ }, ++ { ++ "type": "function_call", ++ "id": "fc_123", ++ "call_id": "call_abc", ++ "name": "search", ++ "arguments": "{\"query\":\"foo\"}" ++ } ++ ], ++ "usage": {"input_tokens": 30, "output_tokens": 12} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++#[tokio::test] ++async fn decode_incomplete_status_maps_to_length() { ++ let response = decode_response(serde_json::json!({ ++ "id": "resp_test", ++ "object": "response", ++ "model": MODEL, ++ "status": "incomplete", ++ "output": [{ ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_out", ++ "content": [{"type": "output_text", "text": "Truncated"}] ++ }], ++ "usage": {"input_tokens": 10, "output_tokens": 128} ++ })) ++ .await; ++ fabro_test::fabro_json_snapshot!(response); ++} ++ ++// --------------------------------------------------------------------------- ++// Stream ++// --------------------------------------------------------------------------- ++ ++/// Shared setup for the happy-path text stream; the request and event halves ++/// are pinned by separate tests. ++async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, ++ r#"{"type":"response.output_text.delta","delta":"Hel"}"#, ++ r#"{"type":"response.output_text.delta","delta":"lo"}"#, ++ r#"{"type":"response.completed","response":{"id":"resp_stream","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":11,"output_tokens":5,"input_tokens_details":{"cached_tokens":2},"output_tokens_details":{"reasoning_tokens":1}}}}"#, ++ ]); ++ stream_capture(adapter(), &base_request(MODEL), &sse).await ++} ++ ++/// The captured request pins the stream flag (and `include`) on the wire. ++#[tokio::test] ++async fn stream_text_happy_path_request() { ++ let (capture, _) = stream_text_happy_path_capture().await; ++ fabro_test::fabro_json_snapshot!(capture.body); ++} ++ ++#[tokio::test] ++async fn stream_text_happy_path_events() { ++ let (_, events) = stream_text_happy_path_capture().await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++#[tokio::test] ++async fn stream_tool_call_deltas() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, ++ r#"{"type":"response.function_call_arguments.delta","item_id":"fc_123","call_id":"call_abc","name":"search","delta":"{\"qu"}"#, ++ r#"{"type":"response.function_call_arguments.delta","item_id":"fc_123","call_id":"call_abc","delta":"ery\":\"foo\"}"}"#, ++ r#"{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_123","call_id":"call_abc","name":"search","arguments":"{\"query\":\"foo\"}"}}"#, ++ r#"{"type":"response.completed","response":{"id":"resp_stream","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":20,"output_tokens":9}}}"#, ++ ]); ++ let (_capture, events) = stream_capture( ++ adapter(), ++ &corpus_tools(MODEL, Some(ToolChoice::Auto)), ++ &sse, ++ ) ++ .await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++#[tokio::test] ++async fn stream_reasoning_summary_deltas() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, ++ r#"{"type":"response.reasoning_summary_text.delta","delta":"Let me "}"#, ++ r#"{"type":"response.reasoning_summary_text.delta","delta":"think"}"#, ++ r#"{"type":"response.output_text.delta","delta":"4."}"#, ++ r#"{"type":"response.completed","response":{"id":"resp_stream","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":15,"output_tokens":12,"output_tokens_details":{"reasoning_tokens":8}}}}"#, ++ ]); ++ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++#[tokio::test] ++async fn stream_failed_event_maps_to_error() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, ++ r#"{"type":"response.failed","response":{"id":"resp_stream","error":{"code":"server_error","message":"boom"}}}"#, ++ ]); ++ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} ++ ++/// `response.incomplete` finishes the stream with `Length`. ++#[tokio::test] ++async fn stream_incomplete_maps_to_length() { ++ let sse = support::sse_data_transcript(&[ ++ r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, ++ r#"{"type":"response.output_text.delta","delta":"Trunc"}"#, ++ r#"{"type":"response.incomplete","response":{"id":"resp_stream","model":"gpt-test","status":"incomplete","output":[],"usage":{"input_tokens":10,"output_tokens":128}}}"#, ++ ]); ++ let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; ++ fabro_test::fabro_json_snapshot!(events); ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap +new file mode 100644 +index 000000000..566a6a703 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap +@@ -0,0 +1,78 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/messages/count_tokens", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "anthropic-version", ++ "2023-06-01" ++ ], ++ [ ++ "content-length", ++ "412" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ], ++ [ ++ "x-api-key", ++ "test-key" ++ ] ++ ], ++ "body": { ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "system": "Be concise", ++ "tools": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap +new file mode 100644 +index 000000000..7c2a3b747 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap +@@ -0,0 +1,48 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "id": "msg_test", ++ "model": "claude-sonnet-4-20250514", ++ "provider": "anthropic", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Truncated answe" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "length", ++ "usage": { ++ "input_tokens": 10, ++ "output_tokens": 128, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "msg_test", ++ "type": "message", ++ "role": "assistant", ++ "model": "claude-sonnet-4-20250514", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Truncated answe" ++ } ++ ], ++ "stop_reason": "max_tokens", ++ "stop_sequence": null, ++ "usage": { ++ "input_tokens": 10, ++ "output_tokens": 128 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap +new file mode 100644 +index 000000000..9e3ef7bfd +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap +@@ -0,0 +1,73 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "id": "msg_test", ++ "model": "claude-sonnet-4-20250514", ++ "provider": "anthropic", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "thinking", ++ "data": { ++ "text": "Step one.", ++ "signature": "sig_decode_abc", ++ "redacted": false ++ } ++ }, ++ { ++ "kind": "redacted_thinking", ++ "data": { ++ "text": "opaque-blob", ++ "signature": null, ++ "redacted": true ++ } ++ }, ++ { ++ "kind": "text", ++ "data": "Done." ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 25, ++ "output_tokens": 40, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "msg_test", ++ "type": "message", ++ "role": "assistant", ++ "model": "claude-sonnet-4-20250514", ++ "content": [ ++ { ++ "type": "thinking", ++ "thinking": "Step one.", ++ "signature": "sig_decode_abc" ++ }, ++ { ++ "type": "redacted_thinking", ++ "data": "opaque-blob" ++ }, ++ { ++ "type": "text", ++ "text": "Done." ++ } ++ ], ++ "stop_reason": "end_turn", ++ "stop_sequence": null, ++ "usage": { ++ "input_tokens": 25, ++ "output_tokens": 40 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap +new file mode 100644 +index 000000000..dac531d55 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap +@@ -0,0 +1,68 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "id": "msg_test", ++ "model": "claude-sonnet-4-20250514", ++ "provider": "anthropic", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Let me search." ++ }, ++ { ++ "kind": "tool_call", ++ "data": { ++ "id": "toolu_01", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": null ++ } ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 30, ++ "output_tokens": 12, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "msg_test", ++ "type": "message", ++ "role": "assistant", ++ "model": "claude-sonnet-4-20250514", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Let me search." ++ }, ++ { ++ "type": "tool_use", ++ "id": "toolu_01", ++ "name": "search", ++ "input": { ++ "query": "foo" ++ } ++ } ++ ], ++ "stop_reason": "tool_use", ++ "stop_sequence": null, ++ "usage": { ++ "input_tokens": 30, ++ "output_tokens": 12 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap +new file mode 100644 +index 000000000..69a05af0e +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap +@@ -0,0 +1,24 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Transcribe this." ++ }, ++ { ++ "type": "text", ++ "text": "[Audio content not supported by this provider]" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap +new file mode 100644 +index 000000000..498b4748b +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap +@@ -0,0 +1,20 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Describe these attachments." ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap +new file mode 100644 +index 000000000..443e1e221 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap +@@ -0,0 +1,36 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Describe these attachments." ++ }, ++ { ++ "type": "image", ++ "source": { ++ "type": "base64", ++ "media_type": "image/png", ++ "data": "ZmFrZS1wbmctYnl0ZXM=" ++ } ++ }, ++ { ++ "type": "document", ++ "source": { ++ "type": "base64", ++ "media_type": "application/pdf", ++ "data": "ZmFrZS1wZGYtYnl0ZXM=" ++ } ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap +new file mode 100644 +index 000000000..bc576585a +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap +@@ -0,0 +1,69 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/messages", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "anthropic-version", ++ "2023-06-01" ++ ], ++ [ ++ "content-length", ++ "340" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ], ++ [ ++ "x-api-key", ++ "test-key" ++ ] ++ ], ++ "body": { ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "What is the capital of France?" ++ } ++ ] ++ }, ++ { ++ "role": "assistant", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Paris." ++ } ++ ] ++ }, ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "And of Spain?" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "system": "You are a terse assistant.", ++ "stop_sequences": [] ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap +new file mode 100644 +index 000000000..f29eca145 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap +@@ -0,0 +1,95 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/messages", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "anthropic-beta", ++ "prompt-caching-2024-07-31" ++ ], ++ [ ++ "anthropic-version", ++ "2023-06-01" ++ ], ++ [ ++ "content-length", ++ "559" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ], ++ [ ++ "x-api-key", ++ "test-key" ++ ] ++ ], ++ "body": { ++ "model": "test-claude", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Review this." ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "system": [ ++ { ++ "type": "text", ++ "text": "You are a careful reviewer.", ++ "cache_control": { ++ "type": "ephemeral" ++ } ++ } ++ ], ++ "stop_sequences": [], ++ "tools": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ }, ++ "cache_control": { ++ "type": "ephemeral" ++ } ++ } ++ ] ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap +new file mode 100644 +index 000000000..1475f5820 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap +@@ -0,0 +1,21 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [], ++ "top_k": 5 ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap +new file mode 100644 +index 000000000..92b9f5904 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap +@@ -0,0 +1,23 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "test-claude", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [], ++ "output_config": { ++ "effort": "high" ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap +new file mode 100644 +index 000000000..fe60412fb +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap +@@ -0,0 +1,21 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "system": "You must respond with valid JSON only, no other text.", ++ "stop_sequences": [] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap +new file mode 100644 +index 000000000..e757af0a9 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap +@@ -0,0 +1,41 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [], ++ "tools": [ ++ { ++ "name": "json_output", ++ "description": "Output the requested structured data", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "answer": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "answer" ++ ] ++ } ++ } ++ ], ++ "tool_choice": { ++ "type": "tool", ++ "name": "json_output" ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap +new file mode 100644 +index 000000000..b50a93df6 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap +@@ -0,0 +1,27 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "temperature": 0.7, ++ "top_p": 0.9, ++ "stop_sequences": [ ++ "END" ++ ], ++ "metadata": { ++ "trace_id": "trace-123" ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap +new file mode 100644 +index 000000000..5fedc6ff1 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap +@@ -0,0 +1,43 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Think step by step: what is 2+2?" ++ } ++ ] ++ }, ++ { ++ "role": "assistant", ++ "content": [ ++ { ++ "type": "thinking", ++ "thinking": "The user wants 2+2, which is 4.", ++ "signature": "sig_test_abc123" ++ }, ++ { ++ "type": "text", ++ "text": "4." ++ } ++ ] ++ }, ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Now 3+3?" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap +new file mode 100644 +index 000000000..d181d4796 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap +@@ -0,0 +1,52 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [], ++ "tools": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": { ++ "type": "auto" ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap +new file mode 100644 +index 000000000..5d7d5214b +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap +@@ -0,0 +1,53 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [], ++ "tools": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": { ++ "type": "tool", ++ "name": "search" ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap +new file mode 100644 +index 000000000..4fbbbaa5f +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap +@@ -0,0 +1,20 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap +new file mode 100644 +index 000000000..b114a2048 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap +@@ -0,0 +1,52 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [], ++ "tools": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": { ++ "type": "any" ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap +new file mode 100644 +index 000000000..f744b1d64 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap +@@ -0,0 +1,91 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Find foo and read /tmp/x" ++ } ++ ] ++ }, ++ { ++ "role": "assistant", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Let me check." ++ }, ++ { ++ "type": "tool_use", ++ "id": "call_1", ++ "name": "search", ++ "input": { ++ "query": "foo" ++ } ++ }, ++ { ++ "type": "tool_use", ++ "id": "call_2", ++ "name": "read_file", ++ "input": { ++ "path": "/tmp/x" ++ } ++ } ++ ] ++ }, ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "tool_result", ++ "tool_use_id": "call_1", ++ "content": "{\"matches\":2}", ++ "is_error": false ++ }, ++ { ++ "type": "tool_result", ++ "tool_use_id": "call_2", ++ "content": "file not found", ++ "is_error": true ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [], ++ "tools": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap +new file mode 100644 +index 000000000..f4abc02be +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap +@@ -0,0 +1,34 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Describe these attachments." ++ }, ++ { ++ "type": "image", ++ "source": { ++ "type": "url", ++ "url": "https://example.com/picture.png" ++ } ++ }, ++ { ++ "type": "document", ++ "source": { ++ "type": "url", ++ "url": "https://example.com/report.pdf" ++ } ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap +new file mode 100644 +index 000000000..d2b78a3a2 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap +@@ -0,0 +1,14 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "stream_item_error": "Server error from anthropic: Overloaded", ++ "retryable": true, ++ "failover_eligible": true ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap +new file mode 100644 +index 000000000..a8ad9d9d1 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap +@@ -0,0 +1,65 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "text_start", ++ "text_id": "block_0" ++ }, ++ { ++ "type": "text_delta", ++ "delta": "Hel", ++ "text_id": "block_0" ++ }, ++ { ++ "type": "text_delta", ++ "delta": "lo", ++ "text_id": "block_0" ++ }, ++ { ++ "type": "text_end", ++ "text_id": "block_0" ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 11, ++ "output_tokens": 5, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 2, ++ "cache_write_tokens": 1 ++ }, ++ "response": { ++ "id": "msg_stream_test", ++ "model": "claude-sonnet-4-20250514", ++ "provider": "anthropic", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Hello" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 11, ++ "output_tokens": 5, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 2, ++ "cache_write_tokens": 1 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap +new file mode 100644 +index 000000000..8b641b028 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap +@@ -0,0 +1,21 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "stop_sequences": [], ++ "stream": true ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap +new file mode 100644 +index 000000000..2dc7b67fb +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap +@@ -0,0 +1,78 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "reasoning_start" ++ }, ++ { ++ "type": "reasoning_delta", ++ "delta": "Let me think" ++ }, ++ { ++ "type": "reasoning_end" ++ }, ++ { ++ "type": "text_start", ++ "text_id": "block_1" ++ }, ++ { ++ "type": "text_delta", ++ "delta": "4.", ++ "text_id": "block_1" ++ }, ++ { ++ "type": "text_end", ++ "text_id": "block_1" ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 15, ++ "output_tokens": 12, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "msg_stream_think", ++ "model": "claude-sonnet-4-20250514", ++ "provider": "anthropic", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "thinking", ++ "data": { ++ "text": "Let me think", ++ "signature": "sig_stream_xyz", ++ "redacted": false ++ } ++ }, ++ { ++ "kind": "text", ++ "data": "4." ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 15, ++ "output_tokens": 12, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap +new file mode 100644 +index 000000000..d2dc68988 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap +@@ -0,0 +1,97 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "tool_call_start", ++ "tool_call": { ++ "id": "toolu_01", ++ "name": "search", ++ "type": "function", ++ "arguments": {}, ++ "raw_arguments": null ++ } ++ }, ++ { ++ "type": "tool_call_delta", ++ "tool_call": { ++ "id": "toolu_01", ++ "name": "search", ++ "type": "function", ++ "arguments": "{\"qu", ++ "raw_arguments": null ++ } ++ }, ++ { ++ "type": "tool_call_delta", ++ "tool_call": { ++ "id": "toolu_01", ++ "name": "search", ++ "type": "function", ++ "arguments": "ery\":\"foo\"}", ++ "raw_arguments": null ++ } ++ }, ++ { ++ "type": "tool_call_end", ++ "tool_call": { ++ "id": "toolu_01", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": "{\"query\":\"foo\"}" ++ } ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 20, ++ "output_tokens": 9, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "msg_stream_tool", ++ "model": "claude-sonnet-4-20250514", ++ "provider": "anthropic", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "tool_call", ++ "data": { ++ "id": "toolu_01", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": "{\"query\":\"foo\"}" ++ } ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 20, ++ "output_tokens": 9, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap +new file mode 100644 +index 000000000..a12eec3d6 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap +@@ -0,0 +1,22 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "text_start", ++ "text_id": "block_0" ++ }, ++ { ++ "type": "text_delta", ++ "delta": "Hello", ++ "text_id": "block_0" ++ }, ++ { ++ "type": "text_end", ++ "text_id": "block_0" ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap +new file mode 100644 +index 000000000..44e29f38e +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap +@@ -0,0 +1,50 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "id": "msg_test", ++ "model": "claude-sonnet-4-20250514", ++ "provider": "anthropic", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Hello back" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 42, ++ "output_tokens": 7, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 10, ++ "cache_write_tokens": 3 ++ }, ++ "raw": { ++ "id": "msg_test", ++ "type": "message", ++ "role": "assistant", ++ "model": "claude-sonnet-4-20250514", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello back" ++ } ++ ], ++ "stop_reason": "end_turn", ++ "stop_sequence": null, ++ "usage": { ++ "input_tokens": 42, ++ "output_tokens": 7, ++ "cache_read_input_tokens": 10, ++ "cache_creation_input_tokens": 3 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap +new file mode 100644 +index 000000000..63678178e +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap +@@ -0,0 +1,66 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/anthropic.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/messages", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "anthropic-version", ++ "2023-06-01" ++ ], ++ [ ++ "content-length", ++ "316" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ], ++ [ ++ "x-api-key", ++ "test-key" ++ ] ++ ], ++ "body": { ++ "model": "claude-sonnet-4-20250514", ++ "messages": [ ++ { ++ "role": "user", ++ "content": [ ++ { ++ "type": "text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_tokens": 128, ++ "system": "Be concise", ++ "temperature": 0.5, ++ "stop_sequences": [], ++ "tools": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "input_schema": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap +new file mode 100644 +index 000000000..4056e3979 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap +@@ -0,0 +1,93 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/models/gemini-test:countTokens", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "content-length", ++ "583" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ], ++ [ ++ "x-goog-api-key", ++ "test-key" ++ ] ++ ], ++ "body": { ++ "generateContentRequest": { ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "systemInstruction": { ++ "parts": [ ++ { ++ "text": "Be concise" ++ } ++ ] ++ }, ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "tools": [ ++ { ++ "functionDeclarations": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++ } ++ ], ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++ } ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap +new file mode 100644 +index 000000000..bbe899144 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap +@@ -0,0 +1,73 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "id": "[UUID]", ++ "model": "gemini-test", ++ "provider": "gemini", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Let me search." ++ }, ++ { ++ "kind": "tool_call", ++ "data": { ++ "id": "[UUID]", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": null, ++ "provider_metadata": { ++ "thoughtSignature": "sig_gemini_xyz" ++ } ++ } ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 30, ++ "output_tokens": 12, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "candidates": [ ++ { ++ "content": { ++ "role": "model", ++ "parts": [ ++ { ++ "text": "Let me search." ++ }, ++ { ++ "functionCall": { ++ "name": "search", ++ "args": { ++ "query": "foo" ++ } ++ }, ++ "thoughtSignature": "sig_gemini_xyz" ++ } ++ ] ++ }, ++ "finishReason": "STOP" ++ } ++ ], ++ "usageMetadata": { ++ "promptTokenCount": 30, ++ "candidatesTokenCount": 12 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +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 +new file mode 100644 +index 000000000..f80ff0a94 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap +@@ -0,0 +1,5 @@ ++--- ++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 +new file mode 100644 +index 000000000..aedfd4696 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap +@@ -0,0 +1,5 @@ ++--- ++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__gemini__decode_thought_parts.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap +new file mode 100644 +index 000000000..2eeb7b0af +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap +@@ -0,0 +1,61 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "id": "[UUID]", ++ "model": "gemini-test", ++ "provider": "gemini", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "thinking", ++ "data": { ++ "text": "Adding the numbers.", ++ "signature": null, ++ "redacted": false ++ } ++ }, ++ { ++ "kind": "text", ++ "data": "4." ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 25, ++ "output_tokens": 40, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "candidates": [ ++ { ++ "content": { ++ "role": "model", ++ "parts": [ ++ { ++ "text": "Adding the numbers.", ++ "thought": true ++ }, ++ { ++ "text": "4." ++ } ++ ] ++ }, ++ "finishReason": "STOP" ++ } ++ ], ++ "usageMetadata": { ++ "promptTokenCount": 25, ++ "candidatesTokenCount": 40 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap +new file mode 100644 +index 000000000..f332140ab +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap +@@ -0,0 +1,52 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "id": "[UUID]", ++ "model": "gemini-test", ++ "provider": "gemini", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "ok" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 75, ++ "output_tokens": 50, ++ "reasoning_tokens": 8, ++ "cache_read_tokens": 30, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "candidates": [ ++ { ++ "content": { ++ "role": "model", ++ "parts": [ ++ { ++ "text": "ok" ++ } ++ ] ++ }, ++ "finishReason": "STOP" ++ } ++ ], ++ "usageMetadata": { ++ "promptTokenCount": 100, ++ "candidatesTokenCount": 50, ++ "thoughtsTokenCount": 8, ++ "cachedContentTokenCount": 30, ++ "toolUsePromptTokenCount": 5 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap +new file mode 100644 +index 000000000..d3f6c4a7f +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap +@@ -0,0 +1,31 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Transcribe this." ++ }, ++ { ++ "inlineData": { ++ "mimeType": "audio/wav", ++ "data": "ZmFrZS13YXYtYnl0ZXM=" ++ } ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap +new file mode 100644 +index 000000000..b4605fdbb +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap +@@ -0,0 +1,25 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Describe these attachments." ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap +new file mode 100644 +index 000000000..130c517ee +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap +@@ -0,0 +1,37 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Describe these attachments." ++ }, ++ { ++ "inlineData": { ++ "mimeType": "image/png", ++ "data": "ZmFrZS1wbmctYnl0ZXM=" ++ } ++ }, ++ { ++ "inlineData": { ++ "mimeType": "application/pdf", ++ "data": "ZmFrZS1wZGYtYnl0ZXM=" ++ } ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap +new file mode 100644 +index 000000000..af95759cb +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap +@@ -0,0 +1,48 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "What is the capital of France?" ++ } ++ ] ++ }, ++ { ++ "role": "model", ++ "parts": [ ++ { ++ "text": "Paris." ++ } ++ ] ++ }, ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "And of Spain?" ++ } ++ ] ++ } ++ ], ++ "systemInstruction": { ++ "parts": [ ++ { ++ "text": "You are a terse assistant." ++ } ++ ] ++ }, ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap +new file mode 100644 +index 000000000..105e76111 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap +@@ -0,0 +1,20 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "safety_settings": [] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap +new file mode 100644 +index 000000000..e6b616613 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap +@@ -0,0 +1,26 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "cached_content": "cachedContents/abc", ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap +new file mode 100644 +index 000000000..83f70e81f +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap +@@ -0,0 +1,25 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap +new file mode 100644 +index 000000000..d16ceda4c +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap +@@ -0,0 +1,26 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128, ++ "responseMimeType": "application/json" ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap +new file mode 100644 +index 000000000..7a6a39742 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap +@@ -0,0 +1,37 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128, ++ "responseMimeType": "application/json", ++ "responseSchema": { ++ "type": "object", ++ "properties": { ++ "answer": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "answer" ++ ] ++ } ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap +new file mode 100644 +index 000000000..1e9350fcd +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap +@@ -0,0 +1,30 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "temperature": 0.7, ++ "maxOutputTokens": 128, ++ "topP": 0.9, ++ "stopSequences": [ ++ "END" ++ ] ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap +new file mode 100644 +index 000000000..c0cdbd040 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap +@@ -0,0 +1,41 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Think step by step: what is 2+2?" ++ } ++ ] ++ }, ++ { ++ "role": "model", ++ "parts": [ ++ { ++ "text": "4." ++ } ++ ] ++ }, ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Now 3+3?" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap +new file mode 100644 +index 000000000..186a9afce +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap +@@ -0,0 +1,63 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "tools": [ ++ { ++ "functionDeclarations": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++ } ++ ], ++ "toolConfig": { ++ "functionCallingConfig": { ++ "mode": "AUTO" ++ } ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap +new file mode 100644 +index 000000000..d31af7c7d +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap +@@ -0,0 +1,66 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "tools": [ ++ { ++ "functionDeclarations": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++ } ++ ], ++ "toolConfig": { ++ "functionCallingConfig": { ++ "mode": "ANY", ++ "allowedFunctionNames": [ ++ "search" ++ ] ++ } ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap +new file mode 100644 +index 000000000..7bb3b0bfa +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap +@@ -0,0 +1,63 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "tools": [ ++ { ++ "functionDeclarations": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++ } ++ ], ++ "toolConfig": { ++ "functionCallingConfig": { ++ "mode": "NONE" ++ } ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap +new file mode 100644 +index 000000000..7832bc141 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap +@@ -0,0 +1,63 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "tools": [ ++ { ++ "functionDeclarations": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++ } ++ ], ++ "toolConfig": { ++ "functionCallingConfig": { ++ "mode": "ANY" ++ } ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap +new file mode 100644 +index 000000000..d0fdee6ce +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap +@@ -0,0 +1,108 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Find foo and read /tmp/x" ++ } ++ ] ++ }, ++ { ++ "role": "model", ++ "parts": [ ++ { ++ "text": "Let me check." ++ }, ++ { ++ "functionCall": { ++ "name": "search", ++ "args": { ++ "query": "foo" ++ } ++ } ++ }, ++ { ++ "functionCall": { ++ "name": "read_file", ++ "args": { ++ "path": "/tmp/x" ++ } ++ } ++ } ++ ] ++ }, ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "functionResponse": { ++ "name": "search", ++ "response": { ++ "matches": 2 ++ } ++ } ++ } ++ ] ++ }, ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "functionResponse": { ++ "name": "read_file", ++ "response": { ++ "result": "file not found" ++ } ++ } ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "tools": [ ++ { ++ "functionDeclarations": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++ } ++ ], ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap +new file mode 100644 +index 000000000..dcf7ef4e2 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap +@@ -0,0 +1,37 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Describe these attachments." ++ }, ++ { ++ "fileData": { ++ "mimeType": "image/png", ++ "fileUri": "https://example.com/picture.png" ++ } ++ }, ++ { ++ "fileData": { ++ "mimeType": "application/pdf", ++ "fileUri": "https://example.com/report.pdf" ++ } ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap +new file mode 100644 +index 000000000..4fd8569ca +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap +@@ -0,0 +1,56 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "text_start", ++ "text_id": "[UUID]" ++ }, ++ { ++ "type": "text_delta", ++ "delta": "Hello", ++ "text_id": "[UUID]" ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 0, ++ "output_tokens": 0, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "[UUID]", ++ "model": "gemini-test", ++ "provider": "gemini", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Hello" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 0, ++ "output_tokens": 0, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap +new file mode 100644 +index 000000000..d006bcbf7 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap +@@ -0,0 +1,88 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "tool_call_start", ++ "tool_call": { ++ "id": "[UUID]", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": null, ++ "provider_metadata": { ++ "thoughtSignature": "sig_stream_g" ++ } ++ } ++ }, ++ { ++ "type": "tool_call_end", ++ "tool_call": { ++ "id": "[UUID]", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": null, ++ "provider_metadata": { ++ "thoughtSignature": "sig_stream_g" ++ } ++ } ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 20, ++ "output_tokens": 9, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "[UUID]", ++ "model": "gemini-test", ++ "provider": "gemini", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "tool_call", ++ "data": { ++ "id": "[UUID]", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": null, ++ "provider_metadata": { ++ "thoughtSignature": "sig_stream_g" ++ } ++ } ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 20, ++ "output_tokens": 9, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap +new file mode 100644 +index 000000000..2518f784a +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap +@@ -0,0 +1,65 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "text_start", ++ "text_id": "[UUID]" ++ }, ++ { ++ "type": "text_delta", ++ "delta": "Hel", ++ "text_id": "[UUID]" ++ }, ++ { ++ "type": "text_delta", ++ "delta": "lo", ++ "text_id": "[UUID]" ++ }, ++ { ++ "type": "text_end", ++ "text_id": "[UUID]" ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 11, ++ "output_tokens": 5, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "[UUID]", ++ "model": "gemini-test", ++ "provider": "gemini", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Hello" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 11, ++ "output_tokens": 5, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap +new file mode 100644 +index 000000000..cb853aaf9 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap +@@ -0,0 +1,51 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/models/gemini-test:streamGenerateContent?alt=sse", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "content-length", ++ "197" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ], ++ [ ++ "x-goog-api-key", ++ "test-key" ++ ] ++ ], ++ "body": { ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "generationConfig": { ++ "maxOutputTokens": 128 ++ }, ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap +new file mode 100644 +index 000000000..6067c0cba +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap +@@ -0,0 +1,78 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "reasoning_start" ++ }, ++ { ++ "type": "reasoning_delta", ++ "delta": "Let me think" ++ }, ++ { ++ "type": "reasoning_end" ++ }, ++ { ++ "type": "text_start", ++ "text_id": "[UUID]" ++ }, ++ { ++ "type": "text_delta", ++ "delta": "4.", ++ "text_id": "[UUID]" ++ }, ++ { ++ "type": "text_end", ++ "text_id": "[UUID]" ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 15, ++ "output_tokens": 12, ++ "reasoning_tokens": 6, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "[UUID]", ++ "model": "gemini-test", ++ "provider": "gemini", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "thinking", ++ "data": { ++ "text": "Let me think", ++ "signature": null, ++ "redacted": false ++ } ++ }, ++ { ++ "kind": "text", ++ "data": "4." ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 15, ++ "output_tokens": 12, ++ "reasoning_tokens": 6, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap +new file mode 100644 +index 000000000..0ed032bbc +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap +@@ -0,0 +1,50 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "id": "[UUID]", ++ "model": "gemini-test", ++ "provider": "gemini", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Hello back" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 32, ++ "output_tokens": 7, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 10, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "candidates": [ ++ { ++ "content": { ++ "role": "model", ++ "parts": [ ++ { ++ "text": "Hello back" ++ } ++ ] ++ }, ++ "finishReason": "STOP" ++ } ++ ], ++ "usageMetadata": { ++ "promptTokenCount": 42, ++ "candidatesTokenCount": 7, ++ "cachedContentTokenCount": 10 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap +new file mode 100644 +index 000000000..c6d3c6a48 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap +@@ -0,0 +1,77 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/gemini.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/models/gemini-test:generateContent", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "content-length", ++ "425" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ], ++ [ ++ "x-goog-api-key", ++ "test-key" ++ ] ++ ], ++ "body": { ++ "contents": [ ++ { ++ "role": "user", ++ "parts": [ ++ { ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "systemInstruction": { ++ "parts": [ ++ { ++ "text": "Be concise" ++ } ++ ] ++ }, ++ "generationConfig": { ++ "temperature": 0.5, ++ "maxOutputTokens": 128 ++ }, ++ "tools": [ ++ { ++ "functionDeclarations": [ ++ { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++ } ++ ], ++ "safety_settings": [ ++ { ++ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", ++ "threshold": "BLOCK_ONLY_HIGH" ++ } ++ ] ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap +new file mode 100644 +index 000000000..5ed2b7de1 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap +@@ -0,0 +1,60 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "id": "chatcmpl_test", ++ "model": "test-model", ++ "provider": "openai-compatible", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "thinking", ++ "data": { ++ "text": "The user wants 2+2.", ++ "signature": null, ++ "redacted": false ++ } ++ }, ++ { ++ "kind": "text", ++ "data": "4." ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 25, ++ "output_tokens": 40, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "chatcmpl_test", ++ "object": "chat.completion", ++ "created": 1700000000, ++ "model": "test-model", ++ "choices": [ ++ { ++ "index": 0, ++ "message": { ++ "role": "assistant", ++ "content": "4.", ++ "reasoning_content": "The user wants 2+2." ++ }, ++ "finish_reason": "stop" ++ } ++ ], ++ "usage": { ++ "prompt_tokens": 25, ++ "completion_tokens": 40, ++ "total_tokens": 65 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap +new file mode 100644 +index 000000000..768e9aaa8 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap +@@ -0,0 +1,69 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "id": "chatcmpl_test", ++ "model": "test-model", ++ "provider": "openai-compatible", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "tool_call", ++ "data": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": "{\"query\":\"foo\"}" ++ } ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 30, ++ "output_tokens": 12, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "chatcmpl_test", ++ "object": "chat.completion", ++ "created": 1700000000, ++ "model": "test-model", ++ "choices": [ ++ { ++ "index": 0, ++ "message": { ++ "role": "assistant", ++ "content": null, ++ "tool_calls": [ ++ { ++ "id": "call_abc", ++ "type": "function", ++ "function": { ++ "name": "search", ++ "arguments": "{\"query\":\"foo\"}" ++ } ++ } ++ ] ++ }, ++ "finish_reason": "tool_calls" ++ } ++ ], ++ "usage": { ++ "prompt_tokens": 30, ++ "completion_tokens": 12, ++ "total_tokens": 42 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_ignores_token_details.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_ignores_token_details.snap +new file mode 100644 +index 000000000..35cbb5f1e +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_ignores_token_details.snap +@@ -0,0 +1,57 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "id": "chatcmpl_test", ++ "model": "test-model", ++ "provider": "openai-compatible", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "ok" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "length", ++ "usage": { ++ "input_tokens": 100, ++ "output_tokens": 50, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "chatcmpl_test", ++ "object": "chat.completion", ++ "created": 1700000000, ++ "model": "test-model", ++ "choices": [ ++ { ++ "index": 0, ++ "message": { ++ "role": "assistant", ++ "content": "ok" ++ }, ++ "finish_reason": "length" ++ } ++ ], ++ "usage": { ++ "prompt_tokens": 100, ++ "completion_tokens": 50, ++ "total_tokens": 150, ++ "prompt_tokens_details": { ++ "cached_tokens": 80 ++ }, ++ "completion_tokens_details": { ++ "reasoning_tokens": 20 ++ } ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap +new file mode 100644 +index 000000000..f647c24c0 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap +@@ -0,0 +1,14 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Transcribe this.[Audio content not supported by this provider]" ++ } ++ ], ++ "max_tokens": 128 ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap +new file mode 100644 +index 000000000..7b3833469 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap +@@ -0,0 +1,14 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Describe these attachments.[Document 'missing.pdf': content type not supported by this provider]" ++ } ++ ], ++ "max_tokens": 128 ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap +new file mode 100644 +index 000000000..fcc58e5cf +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap +@@ -0,0 +1,14 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Describe these attachments.[Document 'report.pdf': content type not supported by this provider]" ++ } ++ ], ++ "max_tokens": 128 ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap +new file mode 100644 +index 000000000..6e4c35fa0 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap +@@ -0,0 +1,26 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "system", ++ "content": "You are a terse assistant." ++ }, ++ { ++ "role": "user", ++ "content": "What is the capital of France?" ++ }, ++ { ++ "role": "assistant", ++ "content": "Paris." ++ }, ++ { ++ "role": "user", ++ "content": "And of Spain?" ++ } ++ ], ++ "max_tokens": 128 ++} +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/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap +new file mode 100644 +index 000000000..2b0718398 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap +@@ -0,0 +1,15 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "max_tokens": 128, ++ "repetition_penalty": 1.2 ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap +new file mode 100644 +index 000000000..bb8c27f58 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap +@@ -0,0 +1,14 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "max_tokens": 128 ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap +new file mode 100644 +index 000000000..84f716422 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap +@@ -0,0 +1,17 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "max_tokens": 128, ++ "response_format": { ++ "type": "json_object" ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap +new file mode 100644 +index 000000000..13961444e +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap +@@ -0,0 +1,32 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "max_tokens": 128, ++ "response_format": { ++ "type": "json_schema", ++ "json_schema": { ++ "name": "response", ++ "strict": true, ++ "schema": { ++ "type": "object", ++ "properties": { ++ "answer": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "answer" ++ ] ++ } ++ } ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap +new file mode 100644 +index 000000000..6a345a9bf +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap +@@ -0,0 +1,19 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "temperature": 0.7, ++ "max_tokens": 128, ++ "top_p": 0.9, ++ "stop": [ ++ "END" ++ ] ++} +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/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap +new file mode 100644 +index 000000000..210eaa446 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap +@@ -0,0 +1,23 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Think step by step: what is 2+2?" ++ }, ++ { ++ "role": "assistant", ++ "content": "4.", ++ "reasoning_content": "The user wants 2+2, which is 4." ++ }, ++ { ++ "role": "user", ++ "content": "Now 3+3?" ++ } ++ ], ++ "max_tokens": 128 ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap +new file mode 100644 +index 000000000..7b9bdab82 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap +@@ -0,0 +1,50 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "max_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "function": { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ } ++ }, ++ { ++ "type": "function", ++ "function": { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": "auto" ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap +new file mode 100644 +index 000000000..5da2b548a +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap +@@ -0,0 +1,55 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "max_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "function": { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ } ++ }, ++ { ++ "type": "function", ++ "function": { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": { ++ "type": "function", ++ "function": { ++ "name": "search" ++ } ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap +new file mode 100644 +index 000000000..a7ace0225 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap +@@ -0,0 +1,50 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "max_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "function": { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ } ++ }, ++ { ++ "type": "function", ++ "function": { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": "none" ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap +new file mode 100644 +index 000000000..d77d00bef +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap +@@ -0,0 +1,50 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "max_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "function": { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ } ++ }, ++ { ++ "type": "function", ++ "function": { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": "required" ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap +new file mode 100644 +index 000000000..43e81924f +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap +@@ -0,0 +1,81 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Find foo and read /tmp/x" ++ }, ++ { ++ "role": "assistant", ++ "content": "Let me check.", ++ "tool_calls": [ ++ { ++ "id": "call_1", ++ "type": "function", ++ "function": { ++ "name": "search", ++ "arguments": "{\"query\":\"foo\"}" ++ } ++ }, ++ { ++ "id": "call_2", ++ "type": "function", ++ "function": { ++ "name": "read_file", ++ "arguments": "{\"path\":\"/tmp/x\"}" ++ } ++ } ++ ] ++ }, ++ { ++ "role": "tool", ++ "content": "{\"matches\":2}", ++ "tool_call_id": "call_1" ++ }, ++ { ++ "role": "tool", ++ "content": "file not found", ++ "tool_call_id": "call_2" ++ } ++ ], ++ "max_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "function": { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ } ++ }, ++ { ++ "type": "function", ++ "function": { ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ } ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap +new file mode 100644 +index 000000000..fcc58e5cf +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap +@@ -0,0 +1,14 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Describe these attachments.[Document 'report.pdf': content type not supported by this provider]" ++ } ++ ], ++ "max_tokens": 128 ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_content_deltas.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_content_deltas.snap +new file mode 100644 +index 000000000..bd4c6dcf3 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_content_deltas.snap +@@ -0,0 +1,65 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "text_start", ++ "text_id": null ++ }, ++ { ++ "type": "text_delta", ++ "delta": "4.", ++ "text_id": null ++ }, ++ { ++ "type": "text_end", ++ "text_id": null ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 0, ++ "output_tokens": 0, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "chatcmpl_stream", ++ "model": "test-model", ++ "provider": "openai-compatible", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "thinking", ++ "data": { ++ "text": "Let me think", ++ "signature": null, ++ "redacted": false ++ } ++ }, ++ { ++ "kind": "text", ++ "data": "4." ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 0, ++ "output_tokens": 0, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap +new file mode 100644 +index 000000000..e7b975533 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap +@@ -0,0 +1,62 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "text_start", ++ "text_id": null ++ }, ++ { ++ "type": "text_delta", ++ "delta": "Hel", ++ "text_id": null ++ }, ++ { ++ "type": "text_delta", ++ "delta": "lo", ++ "text_id": null ++ }, ++ { ++ "type": "text_end", ++ "text_id": null ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 11, ++ "output_tokens": 5, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "chatcmpl_stream", ++ "model": "test-model", ++ "provider": "openai-compatible", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Hello" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 11, ++ "output_tokens": 5, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap +new file mode 100644 +index 000000000..502647610 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap +@@ -0,0 +1,15 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "max_tokens": 128, ++ "stream": true ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap +new file mode 100644 +index 000000000..6ab800ec8 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap +@@ -0,0 +1,94 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "tool_call_start", ++ "tool_call": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": null, ++ "raw_arguments": null ++ } ++ }, ++ { ++ "type": "tool_call_delta", ++ "tool_call": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": null, ++ "raw_arguments": null ++ } ++ }, ++ { ++ "type": "tool_call_delta", ++ "tool_call": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": null, ++ "raw_arguments": null ++ } ++ }, ++ { ++ "type": "tool_call_end", ++ "tool_call": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": "{\"query\":\"foo\"}" ++ } ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 20, ++ "output_tokens": 9, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "chatcmpl_stream", ++ "model": "test-model", ++ "provider": "openai-compatible", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "tool_call", ++ "data": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": "{\"query\":\"foo\"}" ++ } ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 20, ++ "output_tokens": 9, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +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 +new file mode 100644 +index 000000000..4c107d93b +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap +@@ -0,0 +1,5 @@ ++--- ++source: lib/crates/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/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap +new file mode 100644 +index 000000000..858a68b66 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap +@@ -0,0 +1,57 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "text_start", ++ "text_id": null ++ }, ++ { ++ "type": "text_delta", ++ "delta": "Hello", ++ "text_id": null ++ }, ++ { ++ "type": "text_end", ++ "text_id": null ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 0, ++ "output_tokens": 0, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "chatcmpl_stream", ++ "model": "test-model", ++ "provider": "openai-compatible", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Hello" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 0, ++ "output_tokens": 0, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": null, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap +new file mode 100644 +index 000000000..fd4a0a4d6 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap +@@ -0,0 +1,51 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "id": "chatcmpl_test", ++ "model": "test-model", ++ "provider": "openai-compatible", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Hello back" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 42, ++ "output_tokens": 7, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "chatcmpl_test", ++ "object": "chat.completion", ++ "created": 1700000000, ++ "model": "test-model", ++ "choices": [ ++ { ++ "index": 0, ++ "message": { ++ "role": "assistant", ++ "content": "Hello back" ++ }, ++ "finish_reason": "stop" ++ } ++ ], ++ "usage": { ++ "prompt_tokens": 42, ++ "completion_tokens": 7, ++ "total_tokens": 49 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap +new file mode 100644 +index 000000000..a732b5d44 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap +@@ -0,0 +1,62 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_compatible.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/chat/completions", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "authorization", ++ "Bearer test-key" ++ ], ++ [ ++ "content-length", ++ "305" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ] ++ ], ++ "body": { ++ "model": "test-model", ++ "messages": [ ++ { ++ "role": "system", ++ "content": "Be concise" ++ }, ++ { ++ "role": "user", ++ "content": "Hello" ++ } ++ ], ++ "temperature": 0.5, ++ "max_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "function": { ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ } ++ ] ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap +new file mode 100644 +index 000000000..4d98746a8 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap +@@ -0,0 +1,77 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/responses/input_tokens", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "authorization", ++ "Bearer test-key" ++ ], ++ [ ++ "content-length", ++ "454" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ] ++ ], ++ "body": { ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "instructions": "Be concise", ++ "model": "gpt-test", ++ "tools": [ ++ { ++ "type": "function", ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "type": "function", ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ] ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap +new file mode 100644 +index 000000000..070663013 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap +@@ -0,0 +1,67 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "id": "resp_test", ++ "model": "gpt-test", ++ "provider": "openai", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "openai_message", ++ "data": { ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_out", ++ "content": [ ++ { ++ "type": "output_text", ++ "text": "Truncated" ++ } ++ ] ++ } ++ }, ++ { ++ "kind": "text", ++ "data": "Truncated" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "length", ++ "usage": { ++ "input_tokens": 10, ++ "output_tokens": 128, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "resp_test", ++ "object": "response", ++ "model": "gpt-test", ++ "status": "incomplete", ++ "output": [ ++ { ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_out", ++ "content": [ ++ { ++ "type": "output_text", ++ "text": "Truncated" ++ } ++ ] ++ } ++ ], ++ "usage": { ++ "input_tokens": 10, ++ "output_tokens": 128 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap +new file mode 100644 +index 000000000..a453f0698 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap +@@ -0,0 +1,83 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "id": "resp_test", ++ "model": "gpt-test", ++ "provider": "openai", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "openai_reasoning", ++ "data": { ++ "type": "reasoning", ++ "id": "rs_1", ++ "summary": [ ++ { ++ "type": "summary_text", ++ "text": "Searching." ++ } ++ ] ++ } ++ }, ++ { ++ "kind": "tool_call", ++ "data": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": "{\"query\":\"foo\"}", ++ "provider_metadata": { ++ "id": "fc_123" ++ } ++ } ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 30, ++ "output_tokens": 12, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "resp_test", ++ "object": "response", ++ "model": "gpt-test", ++ "status": "completed", ++ "output": [ ++ { ++ "type": "reasoning", ++ "id": "rs_1", ++ "summary": [ ++ { ++ "type": "summary_text", ++ "text": "Searching." ++ } ++ ] ++ }, ++ { ++ "type": "function_call", ++ "id": "fc_123", ++ "call_id": "call_abc", ++ "name": "search", ++ "arguments": "{\"query\":\"foo\"}" ++ } ++ ], ++ "usage": { ++ "input_tokens": 30, ++ "output_tokens": 12 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap +new file mode 100644 +index 000000000..846589d95 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap +@@ -0,0 +1,73 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "id": "resp_test", ++ "model": "gpt-test", ++ "provider": "openai", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "openai_message", ++ "data": { ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_out", ++ "content": [ ++ { ++ "type": "output_text", ++ "text": "ok" ++ } ++ ] ++ } ++ }, ++ { ++ "kind": "text", ++ "data": "ok" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 20, ++ "output_tokens": 30, ++ "reasoning_tokens": 20, ++ "cache_read_tokens": 80, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "resp_test", ++ "object": "response", ++ "model": "gpt-test", ++ "status": "completed", ++ "output": [ ++ { ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_out", ++ "content": [ ++ { ++ "type": "output_text", ++ "text": "ok" ++ } ++ ] ++ } ++ ], ++ "usage": { ++ "input_tokens": 100, ++ "output_tokens": 50, ++ "input_tokens_details": { ++ "cached_tokens": 80 ++ }, ++ "output_tokens_details": { ++ "reasoning_tokens": 20 ++ } ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap +new file mode 100644 +index 000000000..5e12b469e +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap +@@ -0,0 +1,28 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Transcribe this." ++ }, ++ { ++ "type": "input_text", ++ "text": "[Audio content not supported by this provider]" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap +new file mode 100644 +index 000000000..93b5f16a3 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap +@@ -0,0 +1,28 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Describe these attachments." ++ }, ++ { ++ "type": "input_text", ++ "text": "[Document 'missing.pdf': content type not supported by this provider]" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +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/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap +new file mode 100644 +index 000000000..655b2e204 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap +@@ -0,0 +1,51 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/responses", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "authorization", ++ "Bearer test-key" ++ ], ++ [ ++ "content-length", ++ "210" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ] ++ ], ++ "body": { ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "instructions": "Be concise", ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ], ++ "stream": true ++ } ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap +new file mode 100644 +index 000000000..990bf91a8 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap +@@ -0,0 +1,67 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Find foo" ++ } ++ ] ++ }, ++ { ++ "type": "function_call", ++ "id": "fc_123", ++ "call_id": "call_abc", ++ "name": "search", ++ "arguments": "{\"query\":\"foo\"}" ++ }, ++ { ++ "type": "function_call_output", ++ "call_id": "call_abc", ++ "output": "2 matches" ++ } ++ ], ++ "max_output_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "type": "function", ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ], ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap +new file mode 100644 +index 000000000..3d74e1cdd +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap +@@ -0,0 +1,32 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Describe these attachments." ++ }, ++ { ++ "type": "input_image", ++ "image_url": "data:image/png;base64,ZmFrZS1wbmctYnl0ZXM=" ++ }, ++ { ++ "type": "input_text", ++ "text": "[Document 'report.pdf': content type not supported by this provider]" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap +new file mode 100644 +index 000000000..3e049c7fe +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap +@@ -0,0 +1,45 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "What is the capital of France?" ++ } ++ ] ++ }, ++ { ++ "type": "message", ++ "role": "assistant", ++ "content": [ ++ { ++ "type": "output_text", ++ "text": "Paris." ++ } ++ ] ++ }, ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "And of Spain?" ++ } ++ ] ++ } ++ ], ++ "instructions": "You are a terse assistant.", ++ "max_output_tokens": 128, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap +new file mode 100644 +index 000000000..acfb705af +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap +@@ -0,0 +1,55 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Think about 2+2." ++ } ++ ] ++ }, ++ { ++ "type": "reasoning", ++ "id": "rs_1", ++ "summary": [ ++ { ++ "type": "summary_text", ++ "text": "Adding." ++ } ++ ] ++ }, ++ { ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_1", ++ "content": [ ++ { ++ "type": "output_text", ++ "text": "4." ++ } ++ ] ++ }, ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Now 3+3?" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap +new file mode 100644 +index 000000000..cd69aff6a +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap +@@ -0,0 +1,25 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ], ++ "seed": 42 ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap +new file mode 100644 +index 000000000..c9efcd60f +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap +@@ -0,0 +1,27 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "test-gpt", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "reasoning": { ++ "effort": "high" ++ }, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap +new file mode 100644 +index 000000000..f4ccc269d +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap +@@ -0,0 +1,29 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "text": { ++ "format": { ++ "type": "json_object" ++ } ++ }, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap +new file mode 100644 +index 000000000..3fa0b83e2 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap +@@ -0,0 +1,42 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "text": { ++ "format": { ++ "type": "json_schema", ++ "name": "response", ++ "strict": true, ++ "schema": { ++ "type": "object", ++ "properties": { ++ "answer": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "answer" ++ ] ++ } ++ } ++ }, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap +new file mode 100644 +index 000000000..3634e5fb3 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap +@@ -0,0 +1,32 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "temperature": 0.7, ++ "max_output_tokens": 128, ++ "top_p": 0.9, ++ "stop": [ ++ "END" ++ ], ++ "metadata": { ++ "trace_id": "trace-123" ++ }, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap +new file mode 100644 +index 000000000..e4b3593b0 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap +@@ -0,0 +1,44 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Think step by step: what is 2+2?" ++ } ++ ] ++ }, ++ { ++ "type": "message", ++ "role": "assistant", ++ "content": [ ++ { ++ "type": "output_text", ++ "text": "4." ++ } ++ ] ++ }, ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Now 3+3?" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap +new file mode 100644 +index 000000000..af2dfe6a9 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap +@@ -0,0 +1,56 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "type": "function", ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": "auto", ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap +new file mode 100644 +index 000000000..cc00b7891 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap +@@ -0,0 +1,59 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "type": "function", ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": { ++ "type": "function", ++ "name": "search" ++ }, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap +new file mode 100644 +index 000000000..fda379759 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap +@@ -0,0 +1,56 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "type": "function", ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": "none", ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap +new file mode 100644 +index 000000000..b9e9d82e0 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap +@@ -0,0 +1,56 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "type": "function", ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ], ++ "tool_choice": "required", ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap +new file mode 100644 +index 000000000..97c1654d9 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap +@@ -0,0 +1,90 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Find foo and read /tmp/x" ++ } ++ ] ++ }, ++ { ++ "type": "message", ++ "role": "assistant", ++ "content": [ ++ { ++ "type": "output_text", ++ "text": "Let me check." ++ } ++ ] ++ }, ++ { ++ "type": "function_call", ++ "id": "call_1", ++ "call_id": "call_1", ++ "name": "search", ++ "arguments": "{\"query\":\"foo\"}" ++ }, ++ { ++ "type": "function_call", ++ "id": "call_2", ++ "call_id": "call_2", ++ "name": "read_file", ++ "arguments": "{\"path\":\"/tmp/x\"}" ++ }, ++ { ++ "type": "function_call_output", ++ "call_id": "call_1", ++ "output": "{\"matches\":2}" ++ }, ++ { ++ "type": "function_call_output", ++ "call_id": "call_2", ++ "output": "file not found", ++ "status": "incomplete" ++ } ++ ], ++ "max_output_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ }, ++ "required": [ ++ "query" ++ ] ++ } ++ }, ++ { ++ "type": "function", ++ "name": "read_file", ++ "description": "Read a file by path", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "path": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ], ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap +new file mode 100644 +index 000000000..9b67b8abc +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap +@@ -0,0 +1,32 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Describe these attachments." ++ }, ++ { ++ "type": "input_image", ++ "image_url": "https://example.com/picture.png" ++ }, ++ { ++ "type": "input_text", ++ "text": "[Document 'report.pdf': content type not supported by this provider]" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap +new file mode 100644 +index 000000000..68057a6b7 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap +@@ -0,0 +1,14 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "stream_item_error": "Server error from openai: boom", ++ "retryable": true, ++ "failover_eligible": true ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap +new file mode 100644 +index 000000000..787a6f4df +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap +@@ -0,0 +1,65 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "text_start", ++ "text_id": null ++ }, ++ { ++ "type": "text_delta", ++ "delta": "Trunc", ++ "text_id": null ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "length", ++ "usage": { ++ "input_tokens": 10, ++ "output_tokens": 128, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "resp_stream", ++ "model": "gpt-test", ++ "provider": "openai", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Trunc" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "length", ++ "usage": { ++ "input_tokens": 10, ++ "output_tokens": 128, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "resp_stream", ++ "model": "gpt-test", ++ "status": "incomplete", ++ "output": [], ++ "usage": { ++ "input_tokens": 10, ++ "output_tokens": 128 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap +new file mode 100644 +index 000000000..c38adf4e4 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap +@@ -0,0 +1,79 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "reasoning_start" ++ }, ++ { ++ "type": "reasoning_delta", ++ "delta": "Let me " ++ }, ++ { ++ "type": "reasoning_delta", ++ "delta": "think" ++ }, ++ { ++ "type": "text_start", ++ "text_id": null ++ }, ++ { ++ "type": "text_delta", ++ "delta": "4.", ++ "text_id": null ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 15, ++ "output_tokens": 4, ++ "reasoning_tokens": 8, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "resp_stream", ++ "model": "gpt-test", ++ "provider": "openai", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "4." ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 15, ++ "output_tokens": 4, ++ "reasoning_tokens": 8, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "resp_stream", ++ "model": "gpt-test", ++ "status": "completed", ++ "output": [], ++ "usage": { ++ "input_tokens": 15, ++ "output_tokens": 12, ++ "output_tokens_details": { ++ "reasoning_tokens": 8 ++ } ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap +new file mode 100644 +index 000000000..c0b41b21e +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap +@@ -0,0 +1,76 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "text_start", ++ "text_id": null ++ }, ++ { ++ "type": "text_delta", ++ "delta": "Hel", ++ "text_id": null ++ }, ++ { ++ "type": "text_delta", ++ "delta": "lo", ++ "text_id": null ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 9, ++ "output_tokens": 4, ++ "reasoning_tokens": 1, ++ "cache_read_tokens": 2, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "resp_stream", ++ "model": "gpt-test", ++ "provider": "openai", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "text", ++ "data": "Hello" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 9, ++ "output_tokens": 4, ++ "reasoning_tokens": 1, ++ "cache_read_tokens": 2, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "resp_stream", ++ "model": "gpt-test", ++ "status": "completed", ++ "output": [], ++ "usage": { ++ "input_tokens": 11, ++ "output_tokens": 5, ++ "input_tokens_details": { ++ "cached_tokens": 2 ++ }, ++ "output_tokens_details": { ++ "reasoning_tokens": 1 ++ } ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap +new file mode 100644 +index 000000000..c5f2d85eb +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap +@@ -0,0 +1,25 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "max_output_tokens": 128, ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ], ++ "stream": true ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap +new file mode 100644 +index 000000000..66c3b5d91 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap +@@ -0,0 +1,121 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++[ ++ { ++ "type": "stream_start" ++ }, ++ { ++ "type": "tool_call_start", ++ "tool_call": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": {}, ++ "raw_arguments": "{\"qu", ++ "provider_metadata": { ++ "id": "fc_123" ++ } ++ } ++ }, ++ { ++ "type": "tool_call_delta", ++ "tool_call": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": {}, ++ "raw_arguments": "{\"qu", ++ "provider_metadata": { ++ "id": "fc_123" ++ } ++ } ++ }, ++ { ++ "type": "tool_call_delta", ++ "tool_call": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": {}, ++ "raw_arguments": "ery\":\"foo\"}", ++ "provider_metadata": { ++ "id": "fc_123" ++ } ++ } ++ }, ++ { ++ "type": "tool_call_end", ++ "tool_call": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": "{\"query\":\"foo\"}", ++ "provider_metadata": { ++ "id": "fc_123" ++ } ++ } ++ }, ++ { ++ "type": "finish", ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 20, ++ "output_tokens": 9, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "response": { ++ "id": "resp_stream", ++ "model": "gpt-test", ++ "provider": "openai", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "tool_call", ++ "data": { ++ "id": "call_abc", ++ "name": "search", ++ "type": "function", ++ "arguments": { ++ "query": "foo" ++ }, ++ "raw_arguments": "{\"query\":\"foo\"}", ++ "provider_metadata": { ++ "id": "fc_123" ++ } ++ } ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "tool_calls", ++ "usage": { ++ "input_tokens": 20, ++ "output_tokens": 9, ++ "reasoning_tokens": 0, ++ "cache_read_tokens": 0, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "resp_stream", ++ "model": "gpt-test", ++ "status": "completed", ++ "output": [], ++ "usage": { ++ "input_tokens": 20, ++ "output_tokens": 9 ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++ } ++ } ++] +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap +new file mode 100644 +index 000000000..1ce06379d +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap +@@ -0,0 +1,73 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "id": "resp_test", ++ "model": "gpt-test", ++ "provider": "openai", ++ "message": { ++ "role": "assistant", ++ "content": [ ++ { ++ "kind": "openai_message", ++ "data": { ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_out", ++ "content": [ ++ { ++ "type": "output_text", ++ "text": "Hello back" ++ } ++ ] ++ } ++ }, ++ { ++ "kind": "text", ++ "data": "Hello back" ++ } ++ ], ++ "name": null, ++ "tool_call_id": null ++ }, ++ "finish_reason": "stop", ++ "usage": { ++ "input_tokens": 32, ++ "output_tokens": 4, ++ "reasoning_tokens": 3, ++ "cache_read_tokens": 10, ++ "cache_write_tokens": 0 ++ }, ++ "raw": { ++ "id": "resp_test", ++ "object": "response", ++ "model": "gpt-test", ++ "status": "completed", ++ "output": [ ++ { ++ "type": "message", ++ "role": "assistant", ++ "id": "msg_out", ++ "content": [ ++ { ++ "type": "output_text", ++ "text": "Hello back" ++ } ++ ] ++ } ++ ], ++ "usage": { ++ "input_tokens": 42, ++ "output_tokens": 7, ++ "input_tokens_details": { ++ "cached_tokens": 10 ++ }, ++ "output_tokens_details": { ++ "reasoning_tokens": 3 ++ } ++ } ++ }, ++ "warnings": [], ++ "rate_limit": null ++} +diff --git a/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap +new file mode 100644 +index 000000000..5c7aca225 +--- /dev/null ++++ b/lib/crates/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap +@@ -0,0 +1,67 @@ ++--- ++source: lib/crates/fabro-llm/tests/it/wire/openai_responses.rs ++expression: rendered ++--- ++{ ++ "method": "POST", ++ "path": "/responses", ++ "headers": [ ++ [ ++ "accept", ++ "*/*" ++ ], ++ [ ++ "authorization", ++ "Bearer test-key" ++ ], ++ [ ++ "content-length", ++ "385" ++ ], ++ [ ++ "content-type", ++ "application/json" ++ ], ++ [ ++ "host", ++ "[host]" ++ ] ++ ], ++ "body": { ++ "model": "gpt-test", ++ "input": [ ++ { ++ "type": "message", ++ "role": "user", ++ "content": [ ++ { ++ "type": "input_text", ++ "text": "Hello" ++ } ++ ] ++ } ++ ], ++ "instructions": "Be concise", ++ "temperature": 0.5, ++ "max_output_tokens": 128, ++ "tools": [ ++ { ++ "type": "function", ++ "name": "search", ++ "description": "Search files", ++ "parameters": { ++ "type": "object", ++ "properties": { ++ "query": { ++ "type": "string" ++ } ++ } ++ } ++ } ++ ], ++ "store": false, ++ "include": [ ++ "reasoning.encrypted_content" ++ ] ++ } ++} +diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs +index 8db94500c..486ebdf2f 100644 +--- a/lib/crates/fabro-test/src/lib.rs ++++ b/lib/crates/fabro-test/src/lib.rs +@@ -2004,6 +2004,32 @@ macro_rules! fabro_json_snapshot { + insta::assert_snapshot!(rendered, @$snapshot); + }); + }}; ++ // External-snapshot forms (no inline `@"..."`): insta writes the snapshot ++ // to a `.snap` file under the test's `snapshots/` directory. Use these to ++ // keep large snapshots out of the `.rs` source. ++ ($filter_source:expr, $value:expr $(,)?) => {{ ++ let filters = ++ $crate::json_snapshot_filters($crate::snapshot_filters_from(&$filter_source)); ++ let filters: Vec<(&str, &str)> = filters ++ .iter() ++ .map(|(pattern, replacement)| (pattern.as_str(), replacement.as_str())) ++ .collect(); ++ let rendered = serde_json::to_string_pretty(&$value).unwrap(); ++ insta::with_settings!({ filters => filters }, { ++ insta::assert_snapshot!(rendered); ++ }); ++ }}; ++ ($value:expr $(,)?) => {{ ++ let filters = $crate::json_snapshot_filters($crate::TestContext::default_filters()); ++ let filters: Vec<(&str, &str)> = filters ++ .iter() ++ .map(|(pattern, replacement)| (pattern.as_str(), replacement.as_str())) ++ .collect(); ++ let rendered = serde_json::to_string_pretty(&$value).unwrap(); ++ insta::with_settings!({ filters => filters }, { ++ insta::assert_snapshot!(rendered); ++ }); ++ }}; + } + + impl TestContext { diff --git a/stages/008-verify@1/output.log b/stages/008-verify@1/output.log new file mode 100644 index 000000000..23021cafb --- /dev/null +++ b/stages/008-verify@1/output.log @@ -0,0 +1 @@ +blob://sha256/e047188aabc61830dab219914b8088e6496e14ae0bc8f73e0aece5a9cdf49016 \ No newline at end of file diff --git a/stages/008-verify@1/script_timing.json b/stages/008-verify@1/script_timing.json new file mode 100644 index 000000000..1e1c08f0c --- /dev/null +++ b/stages/008-verify@1/script_timing.json @@ -0,0 +1,8 @@ +{ + "output": "blob://sha256/e047188aabc61830dab219914b8088e6496e14ae0bc8f73e0aece5a9cdf49016", + "exit_code": 0, + "duration_ms": 495385, + "termination": "exited", + "output_bytes": 101665, + "live_streaming": true +} \ No newline at end of file diff --git a/stages/008-verify@1/status.json b/stages/008-verify@1/status.json new file mode 100644 index 000000000..3d4bc1bad --- /dev/null +++ b/stages/008-verify@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Script completed: 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", + "failure_reason": null, + "timestamp": "2026-06-08T21:05:29.944463Z" +} \ No newline at end of file diff --git a/stages/009-exit@1/status.json b/stages/009-exit@1/status.json new file mode 100644 index 000000000..7920be3a9 --- /dev/null +++ b/stages/009-exit@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": null, + "failure_reason": null, + "timestamp": "2026-06-08T21:05:43.019700Z" +} \ No newline at end of file