Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
89 KiB
JSON
1 line
89 KiB
JSON
{"repo": "alfin-efendy/ryuzi", "files": {"crates/core/src/plugins/wasm_provider_conformance.rs": {"346f6411f4c1385f3d01f9683f716d0f213a3eb8": {"t": 1786067836.1098037, "n": 87776, "h": "a7e7b9c0593a0b57", "c": "//! Reusable provider-conformance harness (plan Task 16, Step 1).\n//!\n//! A provider component reaches the LLM router through the generic\n//! [`crate::plugins::wasm_provider`] seam. Before a real provider is migrated\n//! onto that seam, it must prove — against MOCKED, allowlisted HTTP — that it\n//! behaves like a provider: it lists models, completes in order, never leaks a\n//! host/forged credential onto the wire, maps upstream HTTP errors onto the\n//! right `provider-error`, and lets the host budget catch a stalled upstream\n//! instead of hanging.\n//!\n//! This module is that proof harness, and it is DECOUPLED from any one\n//! fixture's wire format or expected values: [`ProviderConformance`] is\n//! parameterized by a [`ConformanceFixture`] — a compiled component artifact\n//! + provider id, the mock upstream's per-endpoint response bodies (whatever\n//! bytes THIS component's own `list-models`/`complete` parses; the harness\n//! never parses them itself), and a [`ProviderExpectations`] struct describing\n//! what the six checks should observe. Each check stands up a mock HTTP\n//! upstream ([`MockUpstream`]) on the fixture's own endpoint paths and seeded\n//! with its wire bodies, points a real [`WasmProviderTransport`] at it (via\n//! [`crate::plugins::wasm_provider::build_test_transport_with_grants`],\n//! granting the `ryuzi:http`/`ryuzi:storage` capabilities — plus\n//! `ryuzi:provider-auth` and a stored user credential for a fixture that\n//! declares one — and seeding the mock's base URL into the component's own\n//! storage slice under the fixture's override key, the generic \"endpoint\n//! override\" channel a real provider component would read too), and drives the\n//! actual host seam.\n//!\n//! Every fixture below shares the SAME six checks: the synthetic\n//! `component-provider-http` fixture (plain `ryuzi:http`, tab-separated wire\n//! format), plus one per real OpenAI-chat provider component (`plugins/openai`,\n//! `plugins/groq`, … — host-mediated `ryuzi:provider-auth`, OpenAI JSON), built\n//! through [`OpenAiFormatFixture`]. A later per-provider slice adds one more\n//! [`ConformanceFixture`] — never another copy of the checks.\n//!\n//! Everything here is `#[cfg(test)]` lib-test code (the integration-test build\n//! OOMs on the dev box); the module is gated behind `#[cfg(test)]` in\n//! `plugins/mod.rs`.\n\nuse std::path::{Path, PathBuf};\nuse std::sync::{Arc, Mutex};\nuse std::time::{Duration, Instant};\n\nuse axum::http::{HeaderMap, StatusCode};\nuse axum::routing::{get, post};\nuse axum::Router;\n\nuse crate::plugins::wasm_provider::{\n build_test_transport_with_grants, TestTransportGrants, WasmCompletionRequest, WasmModelInfo,\n WasmProviderRuntime, WasmProviderTransport, WasmTokenUsage,\n};\n\n/// The single loopback host every mock upstream binds to and every\n/// conformance run allowlists (a bare host: the allowlist matches on host,\n/// not port).\nconst LOOPBACK_HOST: &str = \"127.0.0.1\";\n\n/// How the mock `/complete` endpoint should respond, per conformance\n/// scenario. `Body`/`Status` bodies are caller-supplied (see\n/// [`ConformanceFixture::wire`] / [`ProviderExpectations::http_error_cases`])\n/// — the harness never hardcodes wire-level content.\n#[derive(Clone)]\nenum CompleteBehavior {\n /// `200 OK` with this body.\n Body(String),\n /// This HTTP status with a short error body (drives error mapping).\n Status(u16),\n /// Accept the request then stall far past any test budget (drives the\n /// host timeout budget catching a slow upstream).\n Stall,\n}\n\n/// A mock HTTP upstream the conformance harness points a provider transport\n/// at. Serves the fixture's model-list path (a caller-supplied success body)\n/// and its completion path (per [`CompleteBehavior`]), and records every value\n/// it receives for the fixture's own CREDENTIAL header\n/// ([`ConformanceFixture::credential_header`] — `authorization` for a\n/// bearer-scheme provider, `x-api-key` for an Anthropic-scheme one) so the auth\n/// check can assert on exactly what reached the wire. Wire-agnostic: it serves\n/// whatever bytes the caller hands it, on whatever paths, and never parses them.\nstruct MockUpstream {\n base_url: String,\n seen_credential: Arc<Mutex<Vec<String>>>,\n}\n\nimpl MockUpstream {\n /// Bind a fresh loopback listener, serve the fixture's model-list path\n /// (always `wire.models_body`) and its completion path (per `complete`),\n /// and return the running upstream. The paths come from the fixture\n /// because they are this provider's REAL endpoint paths relative to its\n /// base URL (`/models` + `/chat/completions` for an OpenAI-format\n /// provider, `/models` + `/messages` for Anthropic) — the component builds\n /// its own URLs, so the mock has to meet it where it actually knocks.\n ///\n /// `credential_header` is the header name whose values this upstream\n /// records for the auth-absence check: the credential scheme is descriptor\n /// DATA, so a bearer provider records `authorization` and an `x-api-key`\n /// provider records `x-api-key`, without the checks below caring which.\n async fn start(\n wire: &MockWireBodies,\n complete: CompleteBehavior,\n credential_header: &str,\n ) -> Self {\n let models_body = wire.models_body.clone();\n let seen_credential: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));\n\n let models_seen = seen_credential.clone();\n let models_header = credential_header.to_string();\n let models_route = get(move |headers: HeaderMap| {\n let seen = models_seen.clone();\n let name = models_header.clone();\n let body = models_body.clone();\n async move {\n record_credential(&headers, &name, &seen);\n (StatusCode::OK, body)\n }\n });\n\n let complete_seen = seen_credential.clone();\n let complete_header = credential_header.to_string();\n let complete_route = post(move |headers: HeaderMap| {\n let seen = complete_seen.clone();\n let name = complete_header.clone();\n let behavior = complete.clone();\n async move {\n record_credential(&headers, &name, &seen);\n match behavior {\n CompleteBehavior::Body(body) => (StatusCode::OK, body),\n CompleteBehavior::Status(status) => (\n StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),\n format!(\"upstream error {status}\"),\n ),\n CompleteBehavior::Stall => {\n // Far longer than any conformance budget: the host must\n // catch this, not wait it out.\n tokio::time::sleep(Duration::from_secs(30)).await;\n (StatusCode::OK, \"too late\".to_string())\n }\n }\n }\n });\n\n let app = Router::new()\n .route(&wire.models_path, models_route)\n .route(&wire.complete_path, complete_route);\n\n let listener = tokio::net::TcpListener::bind(\"127.0.0.1:0\")\n .await\n .expect(\"loopback listener should bind\");\n let port = listener.local_addr().unwrap().port();\n tokio::spawn(async move {\n axum::serve(listener, app).await.unwrap();\n });\n\n MockUpstream {\n base_url: format!(\"http://{LOOPBACK_HOST}:{port}\"),\n seen_credential,\n }\n }\n\n /// Every value this upstream has received for the fixture's credential\n /// header so far.\n fn credential_headers_seen(&self) -> Vec<String> {\n self.seen_credential.lock().unwrap().clone()\n }\n}\n\n/// Record any occurrence of the credential header `name` (case-insensitive) on\n/// an incoming mock request.\nfn record_credential(headers: &HeaderMap, name: &str, seen: &Arc<Mutex<Vec<String>>>) {\n if let Some(value) = headers.get(name) {\n seen.lock()\n .unwrap()\n .push(value.to_str().unwrap_or(\"<non-utf8>\").to_string());\n }\n}\n\n/// The mock upstream's wire surface for one fixture: the endpoint paths THIS\n/// provider component actually requests, and the response bodies in whatever\n/// format its own `list-models`/`complete` parses. The harness serves these\n/// bytes verbatim over HTTP and never interprets them — a real provider's\n/// config carries that provider's actual paths and JSON bodies instead of the\n/// synthetic fixture's `/complete` + tab-separated tables.\npub(crate) struct MockWireBodies {\n /// Path the component GETs its model list from, relative to the base URL.\n pub models_path: String,\n /// Body the mock's model-list route serves on success.\n pub models_body: String,\n /// Path the component POSTs completions to, relative to the base URL.\n pub complete_path: String,\n /// Body the mock's completion route serves on success.\n pub complete_success_body: String,\n}\n\n/// One HTTP-error scenario the conformance battery drives: an upstream status\n/// this provider component's own error-mapping logic must turn into a\n/// `provider-error` whose rendered `Err(String)` contains `expected_substring`.\npub(crate) struct HttpErrorCase {\n pub status: u16,\n pub expected_substring: &'static str,\n}\n\n/// What the six-point battery expects THIS provider component to produce over\n/// the same mocked host seam. One instance is one fixture's worth of expected\n/// values; the checks themselves stay fixture-agnostic.\npub(crate) struct ProviderExpectations {\n /// Expected `list-models` result, in order.\n pub models: Vec<WasmModelInfo>,\n /// Expected `complete` chunk texts, in the EXACT order the upstream\n /// served them. Anti-tautology: a fixture's wire body should serve these\n /// NOT already in alphabetical/sorted order, so a harness that only\n /// checked set membership (rather than order) would still fail.\n pub chunk_texts: Vec<&'static str>,\n /// Expected concatenation of `chunk_texts` — the final completion text.\n pub final_text: &'static str,\n /// Expected usage carried by the terminal (last) chunk, if any.\n pub terminal_usage: Option<WasmTokenUsage>,\n /// HTTP-error scenarios to drive in turn (status -> expected substring),\n /// exercising this component's own upstream-status -> `provider-error`\n /// mapping.\n pub http_error_cases: Vec<HttpErrorCase>,\n /// Substrings acceptable in the mapped error when the upstream stalls\n /// past the host's timeout budget (providers may phrase this\n /// differently — rate-limited/unavailable/failed/timeout wording all\n /// count as \"caught it, didn't hang\").\n pub timeout_error_substrings: Vec<&'static str>,\n /// A literal the auth-absence check additionally asserts never leaks into\n /// the completion output, IF this fixture's guest forges one onto its\n /// requests. The substantive assertion — what the mock is allowed to see\n /// on the wire, per [`Self::expected_authorization`] — always runs\n /// regardless of this field.\n pub guest_forged_secret: Option<&'static str>,\n /// The ONLY value the mock upstream may observe for the fixture's\n /// credential header ([`ConformanceFixture::credential_header`] —\n /// `authorization` for a bearer provider, `x-api-key` for an\n /// Anthropic-scheme one).\n ///\n /// `None` — the component authenticates through plain `ryuzi:http`, so no\n /// credential header may reach the upstream at all: the host strips whatever\n /// the guest sets, and the check asserts the mock saw none.\n ///\n /// `Some(value)` — the component authenticates through\n /// `ryuzi:provider-auth`, so the HOST puts the user's stored credential on\n /// the wire (a bearer provider's value is `Bearer <key>`; an `x-api-key`\n /// provider's is the bare key). The check then asserts every observed value\n /// equals exactly this host-injected one, which is the same guarantee\n /// stated the other way round: nothing a guest could contribute ever\n /// appears.\n pub expected_authorization: Option<&'static str>,\n}\n\n/// How an OAuth provider fixture authenticates: the `[[oauth]]` profile the\n/// bundle declares (== the router provider id and the id the guest passes to\n/// `authorized-request`) and the access token seeded for it. The host injects\n/// that token as `Authorization: Bearer <token>`; the component never sees it.\n/// `None` on a fixture that reaches its upstream through plain `ryuzi:http` or\n/// host-mediated `ryuzi:provider-auth` instead.\npub(crate) struct OAuthProfileSeed {\n pub profile_id: &'static str,\n pub access_token: &'static str,\n}\n\n/// Everything [`ProviderConformance`] needs for one battery run: the compiled\n/// component + provider id under test, the mock's wire-level response\n/// bodies, and the expected outputs the six checks assert against. The\n/// synthetic `component-provider-http` fixture\n/// ([`synthetic_fixture_conformance`]) is exactly one instance of this; a\n/// later slice builds one per REAL provider component instead.\npub(crate) struct ConformanceFixture {\n pub artifact: PathBuf,\n pub provider_id: String,\n /// The model id put on every `complete` request (the mock ignores it —\n /// scenario selection is driven by [`CompleteBehavior`] — but a real\n /// provider component may route on it, so it stays a genuine field).\n pub request_model: String,\n /// Key in the component's own `ryuzi:storage` slice that the harness\n /// seeds with the mock's base URL. Fixture-owned because it is the\n /// component's OWN endpoint-override contract (the synthetic fixture reads\n /// `conformance-base-url`; the openai component reads `base-url`, its real\n /// product-level proxy override).\n pub base_url_storage_key: String,\n /// A user API key to store for [`Self::provider_id`], which also declares\n /// that id in the test bundle's manifest and so grants\n /// `ryuzi:provider-auth`. `Some` for a component that authenticates\n /// host-mediated (an API-key provider); `None` for one that reaches the\n /// upstream through plain `ryuzi:http`.\n pub stored_api_key: Option<&'static str>,\n /// The request header the mock upstream watches for this component's\n /// host-injected credential — `authorization` for a bearer-scheme provider,\n /// `x-api-key` for an Anthropic-scheme one. Derived from the descriptor's\n /// `AuthScheme`, the same DATA the host keys its injection off, so the\n /// auth-absence check need not know which provider it is running.\n pub credential_header: String,\n /// For an OAuth provider: the `[[oauth]]` profile + seeded token that grants\n /// `ryuzi:oauth` and lets `authorized-request` inject a host-managed bearer.\n /// `None` for an API-key/http provider (whose auth is `stored_api_key` /\n /// plain http instead). Exactly one of `stored_api_key`/`oauth_profile` is\n /// `Some` for any credentialed fixture.\n pub oauth_profile: Option<OAuthProfileSeed>,\n pub wire: MockWireBodies,\n pub expect: ProviderExpectations,\n}\n\n/// The reusable provider conformance battery: parameterized by a\n/// [`ConformanceFixture`]. Later per-provider slices construct one per real\n/// provider component and call [`Self::run_full_battery`] (or an individual\n/// check) against it.\npub(crate) struct ProviderConformance {\n fixture: ConformanceFixture,\n}\n\nimpl ProviderConformance {\n pub(crate) fn new(fixture: ConformanceFixture) -> Self {\n Self { fixture }\n }\n\n /// Build a real [`WasmProviderTransport`] over the component under test,\n /// granting `ryuzi:http` + `ryuzi:storage` (plus `ryuzi:provider-auth` and\n /// a stored user credential when the fixture declares one), allowlisting\n /// the loopback mock, seeding the mock base URL into the component's\n /// storage slice under the fixture's own override key, and bounding every\n /// call (and the host's own HTTP budget) by `timeout`. Delegates the\n /// actual bundle/context/policy wiring to\n /// [`build_test_transport_with_grants`] — the same builder\n /// `wasm_provider`'s own tests use — so that ~80 lines of boilerplate\n /// exists exactly once.\n async fn transport(\n &self,\n mock: &MockUpstream,\n timeout: Duration,\n ) -> (Arc<WasmProviderTransport>, tempfile::NamedTempFile) {\n let provider_auth = self\n .fixture\n .stored_api_key\n .map(|api_key| (self.fixture.provider_id.clone(), api_key.to_string()));\n // An OAuth fixture grants `ryuzi:oauth` + a seeded profile token instead\n // of `ryuzi:provider-auth` + a stored API key. The two are mutually\n // exclusive (a fixture is one or the other), so this reuses the SAME\n // builder and the SAME six checks — only the grant + seeded credential\n // differ.\n let (oauth_profile_ids, oauth_tokens) = match &self.fixture.oauth_profile {\n Some(seed) => (\n vec![seed.profile_id.to_string()],\n vec![(seed.profile_id.to_string(), seed.access_token.to_string())],\n ),\n None => (vec![], vec![]),\n };\n build_test_transport_with_grants(\n self.fixture.artifact.clone(),\n &self.fixture.provider_id,\n timeout,\n TestTransportGrants {\n network_allowlist: vec![LOOPBACK_HOST.to_string()],\n allow_storage: true,\n storage_seed: vec![(\n self.fixture.base_url_storage_key.clone(),\n mock.base_url.as_bytes().to_vec(),\n )],\n provider_ids: provider_auth\n .iter()\n .map(|(provider, _)| provider.clone())\n .collect(),\n provider_credentials: provider_auth.into_iter().collect(),\n oauth_profile_ids,\n oauth_tokens,\n },\n )\n .await\n }\n\n /// The request every check drives `complete` with (a real prompt; the\n /// fixture reads its upstream from storage, not from the prompt, so this\n /// stays a genuine provider request regardless of which fixture is under\n /// test).\n fn completion_request(&self) -> WasmCompletionRequest {\n WasmCompletionRequest {\n model: self.fixture.request_model.clone(),\n prompt: \"hello\".to_string(),\n max_tokens: Some(64),\n temperature: Some(0.2),\n }\n }\n\n /// Run the whole six-point battery in sequence.\n pub(crate) async fn run_full_battery(&self) {\n self.assert_lists_models().await;\n self.assert_completes_in_order().await;\n self.assert_strips_guest_authorization().await;\n self.assert_maps_http_errors().await;\n self.assert_maps_timeouts().await;\n }\n\n /// (1) Model listing: `list-models` returns exactly the models the mock\n /// `/models` endpoint served, in order.\n pub(crate) async fn assert_lists_models(&self) {\n let mock = MockUpstream::start(\n &self.fixture.wire,\n CompleteBehavior::Body(self.fixture.wire.complete_success_body.clone()),\n &self.fixture.credential_header,\n )\n .await;\n let (transport, _tmp) = self.transport(&mock, Duration::from_secs(10)).await;\n\n let models = transport\n .list_models()\n .await\n .expect(\"list-models over mocked HTTP must succeed\");\n\n assert_eq!(\n models, self.fixture.expect.models,\n \"list-models must return the models the mock /models endpoint served\",\n );\n }\n\n /// (2) Non-stream completion + (3) stream ordering: `complete` returns the\n /// served chunks in their exact served order, the concatenation is the\n /// expected final text, and the terminal chunk carries `finished` + usage.\n pub(crate) async fn assert_completes_in_order(&self) {\n let mock = MockUpstream::start(\n &self.fixture.wire,\n CompleteBehavior::Body(self.fixture.wire.complete_success_body.clone()),\n &self.fixture.credential_header,\n )\n .await;\n let (transport, _tmp) = self.transport(&mock, Duration::from_secs(10)).await;\n\n let chunks = transport\n .complete(self.completion_request())\n .await\n .expect(\"complete over mocked HTTP must succeed\");\n\n let texts: Vec<&str> = chunks.iter().map(|chunk| chunk.text.as_str()).collect();\n assert_eq!(\n texts, self.fixture.expect.chunk_texts,\n \"chunk order must be preserved exactly as the upstream served it\",\n );\n\n let final_text: String = chunks.iter().map(|chunk| chunk.text.as_str()).collect();\n assert_eq!(\n final_text.as_str(),\n self.fixture.expect.final_text,\n \"the concatenated completion must be the expected final text\",\n );\n\n let (last, rest) = chunks\n .split_last()\n .expect(\"a conformance fixture must serve at least one chunk\");\n assert!(\n rest.iter().all(|chunk| !chunk.finished),\n \"only the terminal chunk may be finished\",\n );\n assert!(last.finished, \"the terminal chunk must be finished\");\n assert_eq!(\n last.usage, self.fixture.expect.terminal_usage,\n \"the terminal chunk must carry the expected token usage\",\n );\n }\n\n /// (4) Auth absence: nothing a GUEST contributes can reach the wire as a\n /// credential. For a plain-`ryuzi:http` component that means the mock\n /// upstream sees no credential header at all (the host strips the one the\n /// guest forges); for a `ryuzi:provider-auth` component it means the only\n /// value the mock ever sees for the fixture's credential header is the\n /// host-injected one ([`ProviderExpectations::expected_authorization`]).\n /// Either way the guest must never surface a credential in its output.\n pub(crate) async fn assert_strips_guest_authorization(&self) {\n let mock = MockUpstream::start(\n &self.fixture.wire,\n CompleteBehavior::Body(self.fixture.wire.complete_success_body.clone()),\n &self.fixture.credential_header,\n )\n .await;\n let (transport, _tmp) = self.transport(&mock, Duration::from_secs(10)).await;\n\n let chunks = transport\n .complete(self.completion_request())\n .await\n .expect(\"complete over mocked HTTP must succeed\");\n\n let seen = mock.credential_headers_seen();\n match self.fixture.expect.expected_authorization {\n None => assert!(\n seen.is_empty(),\n \"the guest's credential header must be stripped before it reaches \\\n the upstream, but the mock saw: {seen:?}\",\n ),\n Some(expected) => {\n assert!(\n !seen.is_empty(),\n \"the host must have injected the stored credential, but the \\\n mock saw no credential header at all\",\n );\n assert!(\n seen.iter().all(|value| value == expected),\n \"only the HOST-injected credential may reach the upstream \\\n (expected every value to be {expected:?}), but the mock saw: {seen:?}\",\n );\n }\n }\n\n if let Some(secret) = self.fixture.expect.guest_forged_secret {\n assert!(\n !seen.iter().any(|value| value.contains(secret)),\n \"a guest-forged credential must never reach the upstream, but \\\n the mock saw: {seen:?}\",\n );\n let final_text: String = chunks.iter().map(|chunk| chunk.text.as_str()).collect();\n assert!(\n !final_text.contains(secret),\n \"the guest must not surface a forged/host secret in its output\",\n );\n }\n }\n\n /// (5) HTTP error mapping: each configured upstream status maps to a\n /// `provider-error` surfaced as `Err(String)` containing the expected\n /// substring (e.g. a `429` to rate-limited, a `5xx` to unavailable).\n pub(crate) async fn assert_maps_http_errors(&self) {\n for case in &self.fixture.expect.http_error_cases {\n let mock = MockUpstream::start(\n &self.fixture.wire,\n CompleteBehavior::Status(case.status),\n &self.fixture.credential_header,\n )\n .await;\n let (transport, _tmp) = self.transport(&mock, Duration::from_secs(10)).await;\n let error = transport\n .complete(self.completion_request())\n .await\n .expect_err(&format!(\n \"a {} upstream must surface as Err, not a chunk list\",\n case.status\n ));\n assert!(\n error.contains(case.expected_substring),\n \"a {} upstream must map to an error containing {:?}, got: {error}\",\n case.status,\n case.expected_substring,\n );\n }\n }\n\n /// (6) Timeout mapping: a stalled upstream is caught by the host's per-call\n /// budget (its HTTP timeout, and the epoch budget behind it) and surfaces\n /// promptly as `Err` — never a hang or panic.\n pub(crate) async fn assert_maps_timeouts(&self) {\n let mock = MockUpstream::start(\n &self.fixture.wire,\n CompleteBehavior::Stall,\n &self.fixture.credential_header,\n )\n .await;\n let budget = Duration::from_millis(600);\n let (transport, _tmp) = self.transport(&mock, budget).await;\n\n let started = Instant::now();\n let error = transport\n .complete(self.completion_request())\n .await\n .expect_err(\"a stalled upstream must be caught by the host budget, not hang\");\n let elapsed = started.elapsed();\n\n assert!(\n elapsed < Duration::from_secs(10),\n \"the host budget must catch the stall promptly (well under the mock's \\\n 30s stall), but the call took {elapsed:?}\",\n );\n assert!(\n self.fixture\n .expect\n .timeout_error_substrings\n .iter()\n .any(|substring| error.contains(substring)),\n \"a caught stall must read as a timeout/failure provider-error, got: {error}\",\n );\n }\n}\n\n/// The model table the synthetic fixture's mock `/models` endpoint serves\n/// (`id\\tdisplay\\tcontext` per line). Two models in a fixed order so listing +\n/// order are both checked. Fixture-specific data — see\n/// [`synthetic_fixture_conformance`], NOT the generic harness above.\nconst MODELS_BODY: &str = \"fixture-model\\tFixture Model\\t8192\\nfixture-mini\\tFixture Mini\\t4096\";\n\n/// The completion table the synthetic fixture's mock `/complete` endpoint\n/// serves in the success scenario (`text\\tfinished[\\tinput\\toutput]` per\n/// line). Deliberately NOT in alphabetical order, so a harness that only\n/// checked set membership (rather than order) would still pass — the order\n/// assertion must pin `Zeta, Alpha, Mu` exactly. Fixture-specific data.\nconst OK_CHUNKS_BODY: &str = \"Zeta\\tfalse\\nAlpha\\tfalse\\nMu\\ttrue\\t11\\t3\";\n\n/// The `Authorization` value the synthetic fixture forges onto every request\n/// (see the fixture's own `src/lib.rs`); the host must strip it, so it must\n/// NEVER reach the upstream, and the completion output must never contain it.\nconst FORBIDDEN_AUTHORIZATION: &str = \"Bearer guest-forbidden-secret\";\n\n/// The prebuilt `component-provider-http` fixture artifact (built on demand by\n/// [`crate::plugins::build_fixture_components_once`]).\nfn provider_http_fixture_artifact() -> PathBuf {\n Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"tests/fixtures/component-provider-http/target/wasm32-wasip2/release\")\n .join(\"ryuzi_component_provider_http_fixture.wasm\")\n}\n\n/// The synthetic `component-provider-http` fixture's own conformance config:\n/// exactly what its tab-separated wire format and hand-written guest logic\n/// produce. A later slice builds a [`ConformanceFixture`] per REAL provider\n/// component instead — same six checks, different wire bodies + expectations.\nfn synthetic_fixture_conformance() -> ConformanceFixture {\n ConformanceFixture {\n artifact: provider_http_fixture_artifact(),\n provider_id: \"wasm-prov-conformance\".to_string(),\n request_model: \"fixture-model\".to_string(),\n base_url_storage_key: \"conformance-base-url\".to_string(),\n // Plain `ryuzi:http` egress: no provider-auth grant, no stored key.\n stored_api_key: None,\n // No credential is injected at all, so the header watched here is\n // immaterial — `authorization` matches what a guest would forge.\n credential_header: \"authorization\".to_string(),\n // Plain `ryuzi:http`, not OAuth.\n oauth_profile: None,\n wire: MockWireBodies {\n models_path: \"/models\".to_string(),\n models_body: MODELS_BODY.to_string(),\n complete_path: \"/complete\".to_string(),\n complete_success_body: OK_CHUNKS_BODY.to_string(),\n },\n expect: ProviderExpectations {\n models: vec![\n WasmModelInfo {\n id: \"fixture-model\".to_string(),\n display_name: \"Fixture Model\".to_string(),\n context_window: 8192,\n },\n WasmModelInfo {\n id: \"fixture-mini\".to_string(),\n display_name: \"Fixture Mini\".to_string(),\n context_window: 4096,\n },\n ],\n chunk_texts: vec![\"Zeta\", \"Alpha\", \"Mu\"],\n final_text: \"ZetaAlphaMu\",\n terminal_usage: Some(WasmTokenUsage {\n input: 11,\n output: 3,\n }),\n http_error_cases: vec![\n HttpErrorCase {\n status: 429,\n expected_substring: \"rate limited\",\n },\n HttpErrorCase {\n status: 503,\n expected_substring: \"unavailable\",\n },\n ],\n timeout_error_substrings: vec![\n \"failed\",\n \"timeout\",\n \"timed out\",\n \"budget\",\n \"unavailable\",\n ],\n guest_forged_secret: Some(FORBIDDEN_AUTHORIZATION),\n // Plain `ryuzi:http`: the host strips the guest's forged header\n // and injects nothing, so the upstream must see NO Authorization.\n expected_authorization: None,\n },\n }\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn provider_component_passes_the_full_conformance_battery() {\n crate::plugins::build_fixture_components_once();\n let harness = ProviderConformance::new(synthetic_fixture_conformance());\n harness.run_full_battery().await;\n}\n\n// ---------------------------------------------------------------------------\n// The REAL OpenAI-CHAT provider components (plan Task 16, Steps 2 + 3)\n//\n// Same six checks, real components, and OpenAI's actual wire format. Nothing\n// below touches the harness itself — each provider is one more\n// [`ConformanceFixture`], which is the point of the Step 1 parameterization.\n//\n// Every component here is built on the shared `ryuzi-openai-format` crate, so\n// their endpoint paths, storage override key and error mapping are properties\n// of the FORMAT rather than of any one provider. [`OpenAiFormatFixture`]\n// therefore carries only what genuinely differs — the provider id, the model\n// it is asked for, the stored credential, and the literal wire bodies its mock\n// upstream serves — and fills the format-level fields in itself.\n// ---------------------------------------------------------------------------\n\n/// Model-discovery path every OpenAI-format component GETs (its descriptor's\n/// `has_models_endpoint` is `true` and none of them override the default).\nconst OPENAI_FORMAT_MODELS_PATH: &str = \"/models\";\n\n/// Chat-generation path every OpenAI-format component POSTs to (every\n/// descriptor here leaves `chat_path` at `None`).\nconst OPENAI_FORMAT_CHAT_PATH: &str = \"/chat/completions\";\n\n/// The base-URL override key these components read from their own storage\n/// slice (`ryuzi_openai_format::BASE_URL_STORAGE_KEY`) — the real product-level\n/// proxy override, which the harness reuses to aim them at its mock.\nconst OPENAI_FORMAT_BASE_URL_KEY: &str = \"base-url\";\n\n/// One real OpenAI-format provider component's conformance data.\n///\n/// Deliberately NOT a generator of its own expectations: the wire bodies are\n/// literal JSON and the expected models/text/usage are literal values, so the\n/// battery still compares what the COMPONENT parsed against what a human wrote\n/// down, not one derivation against another.\nstruct OpenAiFormatFixture {\n /// Router provider id — also the `plugins/<dir>` name, the manifest\n /// `provider-ids` entry, and (via `ryuzi_plugin_<id>`) the built artifact's\n /// stem. A drift in any of those surfaces as a load or `denied` failure.\n provider_id: &'static str,\n /// The model id put on every `complete` request.\n request_model: &'static str,\n /// The user API key this run stores through the real `provider_connections`\n /// path. `ryuzi:provider-auth` resolves it host-side and injects it as\n /// `Authorization: Bearer …` (every descriptor here declares\n /// `AuthScheme::Bearer`), so the component itself never sees this value.\n stored_api_key: &'static str,\n /// Exactly what the mock upstream must therefore observe — and nothing else.\n expected_authorization: &'static str,\n /// Literal `GET /models` body the mock serves.\n models_body: &'static str,\n /// `(id, context_window)` per model, in served order. The display name is\n /// asserted to equal the id, which is a property of the format: an\n /// OpenAI-shaped `/models` response carries no display name.\n expected_models: &'static [(&'static str, u32)],\n /// Literal `POST /chat/completions` (non-stream) body the mock serves.\n completion_body: &'static str,\n /// The whole completion — the flat ABI collapses the response to ONE\n /// terminal chunk, so this is both the only chunk's text and the final text.\n expected_text: &'static str,\n /// Usage the terminal chunk must carry, from `completion_body`'s `usage`.\n expected_usage: WasmTokenUsage,\n}\n\nimpl OpenAiFormatFixture {\n /// The built component artifact. Each `plugins/<id>` is a standalone\n /// workspace crate (not a `tests/fixtures/*` fixture), and cargo names its\n /// output after the crate with `-` replaced by `_`.\n fn artifact(&self) -> PathBuf {\n Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"../../plugins\")\n .join(self.provider_id)\n .join(\"target/wasm32-wasip2/release\")\n .join(format!(\n \"ryuzi_plugin_{}.wasm\",\n self.provider_id.replace('-', \"_\")\n ))\n }\n\n /// Expand into a full [`ConformanceFixture`], supplying the format-level\n /// fields (paths, storage key, error mapping) every OpenAI-format component\n /// shares by construction.\n fn into_conformance(self) -> ConformanceFixture {\n ConformanceFixture {\n artifact: self.artifact(),\n provider_id: self.provider_id.to_string(),\n request_model: self.request_model.to_string(),\n base_url_storage_key: OPENAI_FORMAT_BASE_URL_KEY.to_string(),\n stored_api_key: Some(self.stored_api_key),\n // Every OpenAI-format descriptor here declares `AuthScheme::Bearer`,\n // so the host injects the credential as `Authorization: Bearer …`.\n credential_header: \"authorization\".to_string(),\n // API-key providers, host-mediated via `ryuzi:provider-auth`.\n oauth_profile: None,\n wire: MockWireBodies {\n models_path: OPENAI_FORMAT_MODELS_PATH.to_string(),\n models_body: self.models_body.to_string(),\n complete_path: OPENAI_FORMAT_CHAT_PATH.to_string(),\n complete_success_body: self.completion_body.to_string(),\n },\n expect: ProviderExpectations {\n models: self\n .expected_models\n .iter()\n .map(|(id, context_window)| WasmModelInfo {\n id: (*id).to_string(),\n display_name: (*id).to_string(),\n context_window: *context_window,\n })\n .collect(),\n // Flat-text ABI + a buffered upstream: one terminal chunk.\n chunk_texts: vec![self.expected_text],\n final_text: self.expected_text,\n terminal_usage: Some(self.expected_usage),\n http_error_cases: vec![\n HttpErrorCase {\n status: 429,\n expected_substring: \"rate limited\",\n },\n HttpErrorCase {\n status: 503,\n expected_substring: \"unavailable\",\n },\n // A 4xx that is NOT a model-not-found stays an\n // invalid-request carrying only the status — never the\n // upstream message.\n HttpErrorCase {\n status: 400,\n expected_substring: \"HTTP 400\",\n },\n ],\n timeout_error_substrings: vec![\n \"failed\",\n \"timeout\",\n \"timed out\",\n \"budget\",\n \"unavailable\",\n ],\n // These components set no credential header of their own — they\n // have no `ryuzi:http` import to set one with — so there is no\n // forged secret to look for.\n guest_forged_secret: None,\n // ...and the ONLY credential on the wire is the host-injected one.\n expected_authorization: Some(self.expected_authorization),\n },\n }\n }\n}\n\n/// Build the component under test, then run the whole six-point battery against\n/// it. One line per provider below.\nasync fn run_openai_format_battery(fixture: OpenAiFormatFixture) {\n crate::plugins::build_provider_component_once(fixture.provider_id);\n ProviderConformance::new(fixture.into_conformance())\n .run_full_battery()\n .await;\n}\n\n/// A two-model `/models` body for a provider whose descriptor seeds NO model\n/// list. The ids are synthetic on purpose — inventing real-looking ones would\n/// assert a catalog this repo has no source for — and the component treats a\n/// `/models` id opaquely anyway. They are served in NON-alphabetical order so\n/// the battery's order assertion is not satisfied by an accidental sort, and\n/// they embed the provider id so a cross-wired fixture (running provider A's\n/// component against provider B's expectations) fails loudly.\nmacro_rules! synthetic_models_body {\n ($id:literal) => {\n concat!(\n r#\"{\"object\":\"list\",\"data\":[{\"id\":\"\"#,\n $id,\n r#\"-zeta\",\"object\":\"model\"},{\"id\":\"\"#,\n $id,\n r#\"-alpha\",\"object\":\"model\"}]}\"#\n )\n };\n}\n\n/// The matching expectations for [`synthetic_models_body!`]. Both windows are\n/// the shared conservative default (`ryuzi_openai_format::DEFAULT_CONTEXT_WINDOW`):\n/// these providers ship an EMPTY static context-window table, because their\n/// `/models` responses carry no context length and their descriptors pin no\n/// per-model values. That the two windows coincide here is therefore the\n/// behaviour under test, not the blind spot M1 fixed for `openai` — `openai` is\n/// the one component with a real table, and its fixture below still asserts two\n/// DIFFERENT windows.\nmacro_rules! synthetic_models_expected {\n ($id:literal) => {\n &[\n (concat!($id, \"-zeta\"), 128_000),\n (concat!($id, \"-alpha\"), 128_000),\n ]\n };\n}\n\n/// A non-stream chat-completion body. `text` is the whole completion (the flat\n/// ABI collapses the response to one terminal chunk) and the usage counts are\n/// per-provider so a cross-wired fixture cannot pass.\nmacro_rules! completion_body {\n ($text:literal, $input:literal, $output:literal) => {\n concat!(\n r#\"{\"id\":\"chatcmpl-conformance\",\"object\":\"chat.completion\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"\"#,\n $text,\n r#\"\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":\"#,\n $input,\n r#\",\"completion_tokens\":\"#,\n $output,\n r#\"}}\"#\n )\n };\n}\n\n/// The real `openai` component. The ONLY fixture here with a populated static\n/// context-window table, so its two expected windows deliberately DIFFER:\n/// `gpt-5.2` is unknown to the table and takes the conservative default,\n/// `gpt-3.5-turbo` is a genuine table hit at its published 16_385. A pair that\n/// coincided could not tell a lookup from a fallback.\nconst OPENAI_FIXTURE: OpenAiFormatFixture = OpenAiFormatFixture {\n provider_id: \"openai\",\n request_model: \"gpt-5.2\",\n stored_api_key: \"sk-conformance-openai-key\",\n expected_authorization: \"Bearer sk-conformance-openai-key\",\n models_body: r#\"{\"object\":\"list\",\"data\":[\n {\"id\":\"gpt-5.2\",\"object\":\"model\",\"created\":1,\"owned_by\":\"openai\"},\n {\"id\":\"gpt-3.5-turbo\",\"object\":\"model\",\"created\":2,\"owned_by\":\"openai\"}\n ]}\"#,\n expected_models: &[(\"gpt-5.2\", 128_000), (\"gpt-3.5-turbo\", 16_385)],\n completion_body: completion_body!(\"Zeta Alpha Mu\", 11, 3),\n expected_text: \"Zeta Alpha Mu\",\n expected_usage: WasmTokenUsage {\n input: 11,\n output: 3,\n },\n};\n\nconst OPENROUTER_FIXTURE: OpenAiFormatFixture = OpenAiFormatFixture {\n provider_id: \"openrouter\",\n request_model: \"openrouter-zeta\",\n stored_api_key: \"sk-or-conformance-key\",\n expected_authorization: \"Bearer sk-or-conformance-key\",\n models_body: synthetic_models_body!(\"openrouter\"),\n expected_models: synthetic_models_expected!(\"openrouter\"),\n completion_body: completion_body!(\"routed reply\", 21, 5),\n expected_text: \"routed reply\",\n expected_usage: WasmTokenUsage {\n input: 21,\n output: 5,\n },\n};\n\nconst GROQ_FIXTURE: OpenAiFormatFixture = OpenAiFormatFixture {\n provider_id: \"groq\",\n request_model: \"groq-zeta\",\n stored_api_key: \"gsk-conformance-key\",\n expected_authorization: \"Bearer gsk-conformance-key\",\n models_body: synthetic_models_body!(\"groq\"),\n expected_models: synthetic_models_expected!(\"groq\"),\n completion_body: completion_body!(\"fast reply\", 13, 7),\n expected_text: \"fast reply\",\n expected_usage: WasmTokenUsage {\n input: 13,\n output: 7,\n },\n};\n\n/// `deepseek` is the one non-`openai` descriptor here that SEEDS a model list\n/// (`[\"deepseek-chat\", \"deepseek-reasoner\"]`), so its fixture uses those real\n/// ids instead of synthetic ones — served reasoner-first, i.e. not sorted.\nconst DEEPSEEK_FIXTURE: OpenAiFormatFixture = OpenAiFormatFixture {\n provider_id: \"deepseek\",\n request_model: \"deepseek-chat\",\n stored_api_key: \"sk-ds-conformance-key\",\n expected_authorization: \"Bearer sk-ds-conformance-key\",\n models_body: r#\"{\"object\":\"list\",\"data\":[\n {\"id\":\"deepseek-reasoner\",\"object\":\"model\"},\n {\"id\":\"deepseek-chat\",\"object\":\"model\"}\n ]}\"#,\n expected_models: &[(\"deepseek-reasoner\", 128_000), (\"deepseek-chat\", 128_000)],\n completion_body: completion_body!(\"reasoned reply\", 17, 9),\n expected_text: \"reasoned reply\",\n expected_usage: WasmTokenUsage {\n input: 17,\n output: 9,\n },\n};\n\nconst MISTRAL_FIXTURE: OpenAiFormatFixture = OpenAiFormatFixture {\n provider_id: \"mistral\",\n request_model: \"mistral-zeta\",\n stored_api_key: \"mi-conformance-key\",\n expected_authorization: \"Bearer mi-conformance-key\",\n models_body: synthetic_models_body!(\"mistral\"),\n expected_models: synthetic_models_expected!(\"mistral\"),\n completion_body: completion_body!(\"le reply\", 23, 11),\n expected_text: \"le reply\",\n expected_usage: WasmTokenUsage {\n input: 23,\n output: 11,\n },\n};\n\nconst XAI_FIXTURE: OpenAiFormatFixture = OpenAiFormatFixture {\n provider_id: \"xai\",\n request_model: \"xai-zeta\",\n stored_api_key: \"xai-conformance-key\",\n expected_authorization: \"Bearer xai-conformance-key\",\n models_body: synthetic_models_body!(\"xai\"),\n expected_models: synthetic_models_expected!(\"xai\"),\n completion_body: completion_body!(\"witty reply\", 29, 13),\n expected_text: \"witty reply\",\n expected_usage: WasmTokenUsage {\n input: 29,\n output: 13,\n },\n};\n\nconst NVIDIA_FIXTURE: OpenAiFormatFixture = OpenAiFormatFixture {\n provider_id: \"nvidia\",\n request_model: \"nvidia-zeta\",\n stored_api_key: \"nvapi-conformance-key\",\n expected_authorization: \"Bearer nvapi-conformance-key\",\n models_body: synthetic_models_body!(\"nvidia\"),\n expected_models: synthetic_models_expected!(\"nvidia\"),\n completion_body: completion_body!(\"accelerated reply\", 31, 17),\n expected_text: \"accelerated reply\",\n expected_usage: WasmTokenUsage {\n input: 31,\n output: 17,\n },\n};\n\n/// `google` points at Gemini's OpenAI-COMPATIBILITY endpoint, and its\n/// descriptor seeds a model list (`[\"gemini-3.0-pro\", \"gemini-3.0-flash\"]`), so\n/// its fixture uses those real ids instead of synthetic ones — served\n/// flash-first, i.e. not in the descriptor's own order.\nconst GOOGLE_FIXTURE: OpenAiFormatFixture = OpenAiFormatFixture {\n provider_id: \"google\",\n request_model: \"gemini-3.0-pro\",\n stored_api_key: \"AIza-conformance-key\",\n expected_authorization: \"Bearer AIza-conformance-key\",\n models_body: r#\"{\"object\":\"list\",\"data\":[\n {\"id\":\"gemini-3.0-flash\",\"object\":\"model\"},\n {\"id\":\"gemini-3.0-pro\",\"object\":\"model\"}\n ]}\"#,\n expected_models: &[(\"gemini-3.0-flash\", 128_000), (\"gemini-3.0-pro\", 128_000)],\n completion_body: completion_body!(\"grounded reply\", 41, 23),\n expected_text: \"grounded reply\",\n expected_usage: WasmTokenUsage {\n input: 41,\n output: 23,\n },\n};\n\nconst HUGGINGFACE_FIXTURE: OpenAiFormatFixture = OpenAiFormatFixture {\n provider_id: \"huggingface\",\n request_model: \"huggingface-zeta\",\n stored_api_key: \"hf-conformance-key\",\n expected_authorization: \"Bearer hf-conformance-key\",\n models_body: synthetic_models_body!(\"huggingface\"),\n expected_models: synthetic_models_expected!(\"huggingface\"),\n completion_body: completion_body!(\"routed hub reply\", 37, 19),\n expected_text: \"routed hub reply\",\n expected_usage: WasmTokenUsage {\n input: 37,\n output: 19,\n },\n};\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn openai_component_passes_the_full_conformance_battery() {\n run_openai_format_battery(OPENAI_FIXTURE).await;\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn openrouter_component_passes_the_full_conformance_battery() {\n run_openai_format_battery(OPENROUTER_FIXTURE).await;\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn groq_component_passes_the_full_conformance_battery() {\n run_openai_format_battery(GROQ_FIXTURE).await;\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn deepseek_component_passes_the_full_conformance_battery() {\n run_openai_format_battery(DEEPSEEK_FIXTURE).await;\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn mistral_component_passes_the_full_conformance_battery() {\n run_openai_format_battery(MISTRAL_FIXTURE).await;\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn xai_component_passes_the_full_conformance_battery() {\n run_openai_format_battery(XAI_FIXTURE).await;\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn nvidia_component_passes_the_full_conformance_battery() {\n run_openai_format_battery(NVIDIA_FIXTURE).await;\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn huggingface_component_passes_the_full_conformance_battery() {\n run_openai_format_battery(HUGGINGFACE_FIXTURE).await;\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn google_component_passes_the_full_conformance_battery() {\n run_openai_format_battery(GOOGLE_FIXTURE).await;\n}\n\n/// Every OpenAI-format provider bundle ported so far, for the manifest audit\n/// below. Kept next to the fixtures so a new provider is added in one place.\n///\n/// HAND-MAINTAINED, and that is its one blind spot: the audit iterates this\n/// list, not `plugins/`, so a bundle added under `plugins/<id>/` WITHOUT a\n/// fixture here is audited by nothing — its manifest, allowlist and base URL go\n/// unchecked. Adding a provider means adding it here in the same change.\nconst OPENAI_FORMAT_FIXTURES: &[&OpenAiFormatFixture] = &[\n &OPENAI_FIXTURE,\n &OPENROUTER_FIXTURE,\n &GROQ_FIXTURE,\n &DEEPSEEK_FIXTURE,\n &MISTRAL_FIXTURE,\n &XAI_FIXTURE,\n &NVIDIA_FIXTURE,\n &HUGGINGFACE_FIXTURE,\n &GOOGLE_FIXTURE,\n];\n\n/// The conformance battery proves each component BEHAVES like its provider.\n/// This proves each bundle is DECLARED like one — which is what decides whether\n/// the host will hand it the user's credential at all, and is invisible to a\n/// battery that grants capabilities itself.\n///\n/// For every ported provider, the committed `ryuzi-plugin.toml` must:\n/// - parse and validate as a `PluginBundleManifest`;\n/// - declare `provider-ids` EXPLICITLY (the `[id]` fallback does not authorize\n/// `ryuzi:provider-auth`) and name exactly this provider;\n/// - allowlist exactly one host, and that host must be the host of the\n/// provider's OWN `ProviderDescriptor::base_url` — so the user's key can only\n/// travel to the endpoint the router itself would have used.\n///\n/// ...and the component's own `src/logic.rs` must carry the descriptor's FULL\n/// `base_url`, PATH included. The allowlist check above matches on host only,\n/// and the conformance battery always overrides the base URL through storage to\n/// reach its mock, so a mistranscribed path (dropping groq's `/openai` segment,\n/// say) would otherwise satisfy every other gate in this file and only fail\n/// against the live upstream.\n///\n/// It also re-checks the descriptor facts this whole slice assumes: an API-key\n/// provider, with the expected auth scheme, and a live `/models` endpoint. A\n/// descriptor that drifts away from those makes its component wrong, and this\n/// fails first.\nfn assert_bundle_is_declared_like_its_provider(\n id: &str,\n expected_auth: crate::llm_router::registry::AuthScheme,\n) {\n use crate::llm_router::registry::{self, ProviderCategory};\n use ryuzi_plugin_sdk::PluginBundleManifest;\n\n let descriptor =\n registry::descriptor(id).unwrap_or_else(|| panic!(\"{id} must exist in the router catalog\"));\n let base_url = descriptor\n .base_url\n .unwrap_or_else(|| panic!(\"{id} must declare a base_url to allowlist\"));\n let expected_host = url::Url::parse(base_url)\n .unwrap_or_else(|e| panic!(\"{id} base_url {base_url}: {e}\"))\n .host_str()\n .unwrap_or_else(|| panic!(\"{id} base_url {base_url} has no host\"))\n .to_string();\n\n // The descriptor facts this component was built from.\n assert_eq!(\n descriptor.category,\n ProviderCategory::ApiKey,\n \"{id} category\"\n );\n assert_eq!(descriptor.auth, expected_auth, \"{id} auth scheme\");\n assert!(descriptor.has_models_endpoint, \"{id} has_models_endpoint\");\n assert_eq!(descriptor.chat_path, None, \"{id} chat_path\");\n\n let bundle_dir = Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"../../plugins\")\n .join(id);\n let path = bundle_dir.join(\"ryuzi-plugin.toml\");\n let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!(\"{}: {e}\", path.display()));\n let manifest: PluginBundleManifest =\n toml::from_str(&text).unwrap_or_else(|e| panic!(\"{id} manifest: {e}\"));\n manifest\n .validate()\n .unwrap_or_else(|e| panic!(\"{id} manifest is invalid: {e}\"));\n\n assert_eq!(manifest.id, id, \"bundle id\");\n assert_eq!(\n manifest.provider_ids,\n vec![id.to_string()],\n \"{id} must declare provider-ids EXPLICITLY — the [id] fallback does \\\n not grant ryuzi:provider-auth\",\n );\n let hosts: Vec<&str> = manifest\n .permissions\n .network\n .iter()\n .map(|host| host.0.as_str())\n .collect();\n assert_eq!(\n hosts,\n vec![expected_host.as_str()],\n \"{id} must allowlist exactly the host of its descriptor's base_url \\\n ({base_url}) — a wider allowlist widens where the user's key may go\",\n );\n\n // The full base URL — scheme, host AND path — as the component transcribed\n // it. Read out of the source text rather than through the config constant\n // because each `plugins/<id>` is a separate workspace this crate cannot\n // link. Deliberately the assigned VALUE of `default_base_url`, not merely\n // an occurrence somewhere in the file: every one of these logic modules\n // also quotes its base URL in a doc comment, so a `contains` check would\n // pass on a component whose real config had drifted.\n let logic_path = bundle_dir.join(\"src/logic.rs\");\n let logic = std::fs::read_to_string(&logic_path)\n .unwrap_or_else(|e| panic!(\"{}: {e}\", logic_path.display()));\n let configured = configured_default_base_url(&logic).unwrap_or_else(|| {\n panic!(\n \"{} must assign a string literal to `default_base_url`\",\n logic_path.display()\n )\n });\n assert_eq!(\n configured,\n base_url,\n \"{} configures base URL {configured:?} but the {id} descriptor says \\\n {base_url:?} — the allowlist check matches on HOST only and the \\\n conformance battery overrides the base URL through storage, so a wrong \\\n PATH escapes every other gate in this file\",\n logic_path.display(),\n );\n}\n\n/// The string literal a provider component's `logic.rs` assigns to\n/// `default_base_url`, if any — the base URL that component will really send\n/// to when the user has set no override. Source-text extraction, because these\n/// bundles are separate workspaces the engine crate cannot link against.\n///\n/// Matches the ASSIGNMENT — a `default_base_url:` directly followed by a string\n/// literal — not the struct FIELD DEFINITION (`default_base_url: &'static str`),\n/// which the Anthropic component declares inline in the same file. The\n/// assignment is the first `default_base_url:` whose next non-space character\n/// opens a quoted string.\nfn configured_default_base_url(logic: &str) -> Option<&str> {\n logic\n .split(\"default_base_url:\")\n .skip(1)\n .find_map(|segment| {\n let literal = segment.trim_start().strip_prefix('\"')?;\n let (value, _) = literal.split_once('\"')?;\n Some(value)\n })\n}\n\n#[test]\nfn every_ported_provider_bundle_declares_provider_auth_and_only_its_own_host() {\n use crate::llm_router::registry::AuthScheme;\n\n for fixture in OPENAI_FORMAT_FIXTURES {\n // Every OpenAI-format descriptor ported so far declares Bearer auth.\n assert_bundle_is_declared_like_its_provider(fixture.provider_id, AuthScheme::Bearer);\n }\n\n // Anthropic speaks a different wire format (`ApiFormat::Anthropic`) but is\n // held to the identical bundle-DECLARATION contract — explicit\n // `provider-ids`, a one-host allowlist matching its descriptor base URL, and\n // that full base URL (path included) in its `logic.rs`. Its only difference\n // here is the auth scheme: `x-api-key`, not bearer.\n assert_bundle_is_declared_like_its_provider(\"anthropic\", AuthScheme::XApiKey);\n}\n\n// ---------------------------------------------------------------------------\n// The `anthropic` provider component (plan Task 16c4, Step C)\n//\n// The SAME six checks, driven through the SAME `ProviderConformance` harness —\n// but Anthropic's `/messages` wire format, not OpenAI-chat. That is the point\n// of the Step 1 parameterization: a different format is a different\n// `ConformanceFixture` (different wire bodies, `/messages` path, `x-api-key`\n// credential header), never another copy of the checks.\n// ---------------------------------------------------------------------------\n\n/// The user API key this run stores for `anthropic`. `ryuzi:provider-auth`\n/// resolves it host-side and injects it as `x-api-key` (the `anthropic`\n/// descriptor declares `AuthScheme::XApiKey`), so the component itself never\n/// sees this value — and, unlike a bearer provider, the header the mock watches\n/// carries the BARE key, not a `Bearer …` wrapper.\nconst ANTHROPIC_STORED_API_KEY: &str = \"sk-ant-conformance-key\";\n\n/// The literal `GET /models` body Anthropic's endpoint serves: typed `model`\n/// entries with a `display_name` the component must PREFER over the id (the\n/// OpenAI-format listing has no such field). Served opus-first, i.e. not sorted,\n/// so the battery's order assertion is not satisfied by an accidental sort.\nconst ANTHROPIC_MODELS_BODY: &str = r#\"{\"data\":[\n {\"type\":\"model\",\"id\":\"claude-opus-4-5\",\"display_name\":\"Claude Opus 4.5\",\"created_at\":\"2025-01-01T00:00:00Z\"},\n {\"type\":\"model\",\"id\":\"claude-haiku-4-5\",\"display_name\":\"Claude Haiku 4.5\",\"created_at\":\"2025-01-01T00:00:00Z\"}\n],\"has_more\":false}\"#;\n\n/// The literal `POST /messages` (non-stream) body Anthropic serves: a `content`\n/// array of typed blocks (NOT `choices[]`) and `usage.input_tokens`/\n/// `output_tokens` (NOT `prompt_`/`completion_tokens`). A leading `thinking`\n/// block is included to prove the component skips it and never surfaces private\n/// reasoning as the completion.\nconst ANTHROPIC_MESSAGE_BODY: &str = r#\"{\n \"id\":\"msg_conformance\",\n \"type\":\"message\",\n \"role\":\"assistant\",\n \"model\":\"claude-opus-4-5\",\n \"content\":[\n {\"type\":\"thinking\",\"thinking\":\"private chain of thought\"},\n {\"type\":\"text\",\"text\":\"considered reply\"}\n ],\n \"stop_reason\":\"end_turn\",\n \"usage\":{\"input_tokens\":43,\"output_tokens\":29}\n}\"#;\n\n/// The `anthropic` component's built artifact. Like every `plugins/<id>`, a\n/// standalone workspace crate whose cargo output is the crate name with `-`\n/// replaced by `_`.\nfn anthropic_artifact() -> PathBuf {\n Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"../../plugins/anthropic/target/wasm32-wasip2/release\")\n .join(\"ryuzi_plugin_anthropic.wasm\")\n}\n\n/// The `anthropic` conformance fixture: Anthropic-Messages wire bodies, the\n/// `/models` + `/messages` endpoint paths, the shared base-URL override key, and\n/// the `x-api-key` credential header the host injects for this scheme.\nfn anthropic_conformance() -> ConformanceFixture {\n ConformanceFixture {\n artifact: anthropic_artifact(),\n provider_id: \"anthropic\".to_string(),\n request_model: \"claude-opus-4-5\".to_string(),\n base_url_storage_key: OPENAI_FORMAT_BASE_URL_KEY.to_string(),\n stored_api_key: Some(ANTHROPIC_STORED_API_KEY),\n // `AuthScheme::XApiKey`: the host injects the bare key as `x-api-key`,\n // so that — not `authorization` — is the header the mock watches.\n credential_header: \"x-api-key\".to_string(),\n // The x-api-key sibling authenticates via `ryuzi:provider-auth`, not OAuth.\n oauth_profile: None,\n wire: MockWireBodies {\n models_path: \"/models\".to_string(),\n models_body: ANTHROPIC_MODELS_BODY.to_string(),\n // ApiFormat::Anthropic generates at `/messages`, not\n // `/chat/completions`.\n complete_path: \"/messages\".to_string(),\n complete_success_body: ANTHROPIC_MESSAGE_BODY.to_string(),\n },\n expect: ProviderExpectations {\n // Display names come from the `/models` body, NOT the id — the one\n // place Anthropic's listing carries more than the OpenAI shape.\n models: vec![\n WasmModelInfo {\n id: \"claude-opus-4-5\".to_string(),\n display_name: \"Claude Opus 4.5\".to_string(),\n context_window: 128_000,\n },\n WasmModelInfo {\n id: \"claude-haiku-4-5\".to_string(),\n display_name: \"Claude Haiku 4.5\".to_string(),\n context_window: 128_000,\n },\n ],\n // Flat-text ABI + a buffered upstream: one terminal chunk, carrying\n // only the first text block (the thinking block is dropped).\n chunk_texts: vec![\"considered reply\"],\n final_text: \"considered reply\",\n terminal_usage: Some(WasmTokenUsage {\n input: 43,\n output: 29,\n }),\n http_error_cases: vec![\n HttpErrorCase {\n status: 429,\n expected_substring: \"rate limited\",\n },\n HttpErrorCase {\n status: 503,\n expected_substring: \"unavailable\",\n },\n // A 4xx that is NOT a not-found stays an invalid-request\n // carrying only the status — never the upstream message.\n HttpErrorCase {\n status: 400,\n expected_substring: \"HTTP 400\",\n },\n ],\n timeout_error_substrings: vec![\n \"failed\",\n \"timeout\",\n \"timed out\",\n \"budget\",\n \"unavailable\",\n ],\n // The component sets no credential header of its own — it has no\n // `ryuzi:http` import to set one with — so there is no forged secret\n // to look for.\n guest_forged_secret: None,\n // ...and the ONLY value the mock may see for `x-api-key` is the\n // host-injected bare key.\n expected_authorization: Some(ANTHROPIC_STORED_API_KEY),\n },\n }\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn anthropic_component_passes_the_full_conformance_battery() {\n crate::plugins::build_provider_component_once(\"anthropic\");\n ProviderConformance::new(anthropic_conformance())\n .run_full_battery()\n .await;\n}\n\n// ---------------------------------------------------------------------------\n// The `anthropic-oauth` provider component (plan Task 16c5, Step C)\n//\n// The SAME six checks, driven through the SAME `ProviderConformance` harness —\n// but the OAUTH egress path, not `ryuzi:provider-auth`. That is the point of\n// the Step 1 parameterization AND the Step-C harness extension: an OAuth\n// provider is a `ConformanceFixture` whose `oauth_profile` is `Some` (granting\n// `ryuzi:oauth` + a seeded profile token) instead of `stored_api_key`, never\n// another copy of the checks. Anthropic-Messages wire format, shared with the\n// x-api-key sibling; the ONE behavioural difference the harness observes is who\n// puts the bearer on the wire — the HOST, from the seeded OAuth token, never\n// the guest.\n// ---------------------------------------------------------------------------\n\n/// The OAuth access token this run seeds for the `anthropic-oauth` profile.\n/// `ryuzi:oauth` resolves it host-side and injects it as `Authorization: Bearer\n/// <token>`, so the component itself never sees this value — and the auth-absence\n/// check asserts the mock upstream saw EXACTLY this, i.e. nothing the guest\n/// forged (the guest has no `ryuzi:http` to forge one with anyway).\nconst ANTHROPIC_OAUTH_SEEDED_TOKEN: &str = \"oauth-access-token-conformance\";\n\n/// The literal `GET /models` body the OAuth endpoint serves. Uses model ids and\n/// display names distinct from the x-api-key sibling's body so a cross-wired\n/// fixture (running one component against the other's expectations) fails loudly.\n/// Served opus-first — i.e. NOT alphabetical (`haiku` would sort first) — so the\n/// battery's order assertion is not satisfied by an accidental sort.\nconst ANTHROPIC_OAUTH_MODELS_BODY: &str = r#\"{\"data\":[\n {\"type\":\"model\",\"id\":\"claude-opus-4-8\",\"display_name\":\"Claude Opus 4.8\",\"created_at\":\"2026-01-01T00:00:00Z\"},\n {\"type\":\"model\",\"id\":\"claude-haiku-4-5-20251001\",\"display_name\":\"Claude Haiku 4.5\",\"created_at\":\"2025-10-01T00:00:00Z\"}\n],\"has_more\":false}\"#;\n\n/// The literal `POST /messages` (non-stream) body the OAuth endpoint serves: a\n/// `content` array of typed blocks with a leading `thinking` block the component\n/// must skip, and `usage.input_tokens`/`output_tokens` distinct from the\n/// sibling's so a cross-wired fixture cannot pass.\nconst ANTHROPIC_OAUTH_MESSAGE_BODY: &str = r#\"{\n \"id\":\"msg_oauth_conformance\",\n \"type\":\"message\",\n \"role\":\"assistant\",\n \"model\":\"claude-opus-4-8\",\n \"content\":[\n {\"type\":\"thinking\",\"thinking\":\"private chain of thought\"},\n {\"type\":\"text\",\"text\":\"subscription reply\"}\n ],\n \"stop_reason\":\"end_turn\",\n \"usage\":{\"input_tokens\":51,\"output_tokens\":37}\n}\"#;\n\n/// The `anthropic-oauth` component's built artifact — a standalone workspace\n/// crate whose cargo output is the crate name with `-` replaced by `_`.\nfn anthropic_oauth_artifact() -> PathBuf {\n Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"../../plugins/anthropic-oauth/target/wasm32-wasip2/release\")\n .join(\"ryuzi_plugin_anthropic_oauth.wasm\")\n}\n\n/// The `anthropic-oauth` conformance fixture: Anthropic-Messages wire bodies on\n/// the `/models` + `/messages` paths, the shared base-URL override key, and —\n/// the Step-C addition — an `oauth_profile` that grants `ryuzi:oauth` and seeds\n/// a profile token, so the host injects `Authorization: Bearer <token>` and the\n/// component never touches a credential.\nfn anthropic_oauth_conformance() -> ConformanceFixture {\n ConformanceFixture {\n artifact: anthropic_oauth_artifact(),\n provider_id: \"anthropic-oauth\".to_string(),\n request_model: \"claude-opus-4-8\".to_string(),\n base_url_storage_key: OPENAI_FORMAT_BASE_URL_KEY.to_string(),\n // OAuth, not `ryuzi:provider-auth`: no stored API key.\n stored_api_key: None,\n // The descriptor declares `AuthScheme::Bearer`, and the host injects the\n // OAuth token as `Authorization: Bearer …`, so `authorization` — not\n // `x-api-key` — is the header the mock watches.\n credential_header: \"authorization\".to_string(),\n oauth_profile: Some(OAuthProfileSeed {\n // Matches the manifest `[[oauth]]` id, the router provider id, and\n // `logic::OAUTH_PROFILE` — the string the guest passes to\n // `authorized-request`. A drift is a hard `denied`.\n profile_id: \"anthropic-oauth\",\n access_token: ANTHROPIC_OAUTH_SEEDED_TOKEN,\n }),\n wire: MockWireBodies {\n models_path: \"/models\".to_string(),\n models_body: ANTHROPIC_OAUTH_MODELS_BODY.to_string(),\n // ApiFormat::Anthropic generates at `/messages`; the OAuth guest\n // appends `?beta=true`, which axum ignores for path routing so this\n // `/messages` route still matches.\n complete_path: \"/messages\".to_string(),\n complete_success_body: ANTHROPIC_OAUTH_MESSAGE_BODY.to_string(),\n },\n expect: ProviderExpectations {\n models: vec![\n WasmModelInfo {\n id: \"claude-opus-4-8\".to_string(),\n display_name: \"Claude Opus 4.8\".to_string(),\n context_window: 128_000,\n },\n WasmModelInfo {\n id: \"claude-haiku-4-5-20251001\".to_string(),\n display_name: \"Claude Haiku 4.5\".to_string(),\n context_window: 128_000,\n },\n ],\n // Flat-text ABI + buffered upstream: one terminal chunk carrying only\n // the first text block (the thinking block is dropped).\n chunk_texts: vec![\"subscription reply\"],\n final_text: \"subscription reply\",\n terminal_usage: Some(WasmTokenUsage {\n input: 51,\n output: 37,\n }),\n http_error_cases: vec![\n HttpErrorCase {\n status: 429,\n expected_substring: \"rate limited\",\n },\n HttpErrorCase {\n status: 503,\n expected_substring: \"unavailable\",\n },\n // A 4xx that is NOT a not-found stays an invalid-request carrying\n // only the status — never the upstream message. The host passes a\n // non-2xx upstream status straight through `authorized-request`,\n // so this exercises the SAME `classify_error` mapping as the\n // x-api-key path.\n HttpErrorCase {\n status: 400,\n expected_substring: \"HTTP 400\",\n },\n ],\n timeout_error_substrings: vec![\n \"failed\",\n \"timeout\",\n \"timed out\",\n \"budget\",\n \"unavailable\",\n ],\n // The component sets no credential header of its own — no\n // `ryuzi:http`, no `ryuzi:provider-auth`, only host-mediated\n // `ryuzi:oauth` — so there is no forged secret to look for.\n guest_forged_secret: None,\n // ...and the ONLY value the mock may see for `authorization` is the\n // HOST-injected bearer built from the seeded OAuth token. This is the\n // auth-absence guarantee for the OAuth path: the guest never sets or\n // sees the bearer; the mock receives only what the host injected.\n expected_authorization: Some(\"Bearer oauth-access-token-conformance\"),\n },\n }\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn anthropic_oauth_component_passes_the_full_conformance_battery() {\n crate::plugins::build_provider_component_once(\"anthropic-oauth\");\n ProviderConformance::new(anthropic_oauth_conformance())\n .run_full_battery()\n .await;\n}\n\n/// The battery proves the `anthropic-oauth` component BEHAVES like its provider.\n/// This proves its bundle is DECLARED like one — the OAuth analogue of\n/// [`assert_bundle_is_declared_like_its_provider`], which only covers API-key\n/// (`ryuzi:provider-auth`) bundles. What decides whether the host will inject\n/// the Claude-subscription bearer at all is invisible to a battery that grants\n/// `ryuzi:oauth` itself, so it is checked here against the committed manifest.\n#[test]\nfn anthropic_oauth_bundle_is_declared_like_its_oauth_provider() {\n use crate::llm_router::registry::{self, ProviderCategory};\n use ryuzi_plugin_sdk::PluginBundleManifest;\n\n let id = \"anthropic-oauth\";\n let descriptor =\n registry::descriptor(id).unwrap_or_else(|| panic!(\"{id} must exist in the router catalog\"));\n\n // The descriptor facts this component was built from: an OAuth provider,\n // Anthropic wire format, with a live `/models` endpoint.\n assert_eq!(\n descriptor.category,\n ProviderCategory::OAuth,\n \"{id} category\"\n );\n assert_eq!(\n descriptor.format,\n registry::ApiFormat::Anthropic,\n \"{id} wire format\"\n );\n assert!(descriptor.has_models_endpoint, \"{id} has_models_endpoint\");\n let oauth = descriptor\n .oauth\n .as_ref()\n .unwrap_or_else(|| panic!(\"{id} must declare an OAuthConfig\"));\n\n // The hosts the manifest may allowlist: the API base host plus the OAuth\n // authorize/token hosts the profile authenticates against — nothing wider.\n let host_of = |raw: &str| {\n url::Url::parse(raw)\n .unwrap_or_else(|e| panic!(\"{id} url {raw}: {e}\"))\n .host_str()\n .unwrap_or_else(|| panic!(\"{id} url {raw} has no host\"))\n .to_string()\n };\n let base_url = descriptor\n .base_url\n .unwrap_or_else(|| panic!(\"{id} must declare a base_url to allowlist\"));\n let mut expected_hosts = std::collections::BTreeSet::new();\n expected_hosts.insert(host_of(base_url));\n expected_hosts.insert(host_of(oauth.authorize_url));\n expected_hosts.insert(host_of(oauth.token_url));\n\n let bundle_dir = Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"../../plugins\")\n .join(id);\n let path = bundle_dir.join(\"ryuzi-plugin.toml\");\n let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!(\"{}: {e}\", path.display()));\n let manifest: PluginBundleManifest =\n toml::from_str(&text).unwrap_or_else(|e| panic!(\"{id} manifest: {e}\"));\n manifest\n .validate()\n .unwrap_or_else(|e| panic!(\"{id} manifest is invalid: {e}\"));\n\n assert_eq!(manifest.id, id, \"bundle id\");\n assert_eq!(\n manifest.provider_ids,\n vec![id.to_string()],\n \"{id} must declare provider-ids EXPLICITLY so the router registers its \\\n transport under that id\",\n );\n // Exactly one `[[oauth]]` profile, whose id equals the string the component\n // passes to `authorized-request` (and the router provider id).\n assert_eq!(manifest.oauth.len(), 1, \"{id} declares one oauth profile\");\n assert_eq!(\n manifest.oauth[0].id, id,\n \"{id} oauth profile id must equal the provider id the guest requests\",\n );\n let hosts: std::collections::BTreeSet<String> = manifest\n .permissions\n .network\n .iter()\n .map(|host| host.0.clone())\n .collect();\n assert_eq!(\n hosts, expected_hosts,\n \"{id} must allowlist exactly the API host plus the OAuth authorize/token \\\n hosts its profile authenticates against ({expected_hosts:?}) — a wider \\\n allowlist widens where a request may travel\",\n );\n\n // The full base URL — scheme, host AND path — as the component transcribed\n // it into its own `logic.rs`, read from source text because the bundle is a\n // separate workspace this crate cannot link. The allowlist check matches on\n // HOST only and the battery overrides the base URL through storage, so a\n // wrong PATH escapes every other gate here.\n let logic_path = bundle_dir.join(\"src/logic.rs\");\n let logic = std::fs::read_to_string(&logic_path)\n .unwrap_or_else(|e| panic!(\"{}: {e}\", logic_path.display()));\n let configured = configured_default_base_url(&logic).unwrap_or_else(|| {\n panic!(\n \"{} must assign a string literal to `default_base_url`\",\n logic_path.display()\n )\n });\n assert_eq!(\n configured,\n base_url,\n \"{} configures base URL {configured:?} but the {id} descriptor says \\\n {base_url:?}\",\n logic_path.display(),\n );\n}\n\n// ---------------------------------------------------------------------------\n// The `qwen` provider component (plan Task 16c6, Step C)\n//\n// The SAME six checks, driven through the SAME `ProviderConformance` harness —\n// the OAUTH egress path (like `anthropic-oauth`), but OpenAI-chat wire (like the\n// API-key components), and a SEEDED model list rather than a `/models` fetch\n// (`qwen`'s descriptor sets `has_models_endpoint: false`). Nothing below touches\n// the harness itself — qwen is one more `ConformanceFixture` whose `oauth_profile`\n// is `Some`, which is the point of the Step 1 parameterization + the 16c5 OAuth\n// extension.\n// ---------------------------------------------------------------------------\n\n/// The OAuth access token this run seeds for the `qwen` profile. `ryuzi:oauth`\n/// resolves it host-side and injects it as `Authorization: Bearer <token>`, so\n/// the component itself never sees this value — and the auth-absence check\n/// asserts the mock upstream saw EXACTLY this (the guest has no `ryuzi:http` to\n/// forge one with anyway).\nconst QWEN_OAUTH_SEEDED_TOKEN: &str = \"qwen-oauth-access-token-conformance\";\n\n/// A DECOY `/models` body: `qwen` seeds its model list (`has_models_endpoint:\n/// false`), so `list-models` must NOT fetch this. If the component wrongly\n/// fetched, its result would be these ids and the seeded-models assertion below\n/// would fail loudly — that is the point of serving a decoy rather than the real\n/// seed here.\nconst QWEN_DECOY_MODELS_BODY: &str =\n r#\"{\"object\":\"list\",\"data\":[{\"id\":\"decoy-should-not-be-fetched\",\"object\":\"model\"}]}\"#;\n\n/// The `qwen` component's built artifact — a standalone workspace crate whose\n/// cargo output is the crate name with `-` replaced by `_`.\nfn qwen_artifact() -> PathBuf {\n Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"../../plugins/qwen/target/wasm32-wasip2/release\")\n .join(\"ryuzi_plugin_qwen.wasm\")\n}\n\n/// The `qwen` conformance fixture: OpenAI-chat wire bodies on the\n/// `/chat/completions` path, the shared base-URL override key, an `oauth_profile`\n/// that grants `ryuzi:oauth` + a seeded token, and a SEEDED model expectation\n/// (the descriptor's four models, each at the conservative default window) that\n/// the component returns WITHOUT fetching the decoy `/models` route.\nfn qwen_conformance() -> ConformanceFixture {\n ConformanceFixture {\n artifact: qwen_artifact(),\n provider_id: \"qwen\".to_string(),\n request_model: \"qwen3-coder-plus\".to_string(),\n base_url_storage_key: OPENAI_FORMAT_BASE_URL_KEY.to_string(),\n // OAuth, not `ryuzi:provider-auth`: no stored API key.\n stored_api_key: None,\n // The descriptor declares `AuthScheme::Bearer`, and the host injects the\n // OAuth token as `Authorization: Bearer …`, so `authorization` is the\n // header the mock watches.\n credential_header: \"authorization\".to_string(),\n oauth_profile: Some(OAuthProfileSeed {\n // Matches the manifest `[[oauth]]` id, the router provider id, and\n // `logic::OAUTH_PROFILE`. A drift is a hard `denied`.\n profile_id: \"qwen\",\n access_token: QWEN_OAUTH_SEEDED_TOKEN,\n }),\n wire: MockWireBodies {\n // Served but never fetched — qwen seeds. The decoy proves it.\n models_path: \"/models\".to_string(),\n models_body: QWEN_DECOY_MODELS_BODY.to_string(),\n // ApiFormat::OpenAi, chat_path None -> `/chat/completions`, the same\n // path the native `oauth_upstream_request` qwen arm POSTs to.\n complete_path: \"/chat/completions\".to_string(),\n complete_success_body: completion_body!(\"qwen coded reply\", 19, 13).to_string(),\n },\n expect: ProviderExpectations {\n // The SEEDED list, from the descriptor's `models`, in order — each at\n // the conservative default window (qwen pins no per-model windows),\n // id doubling as display name. NOT the decoy `/models` body.\n models: vec![\n WasmModelInfo {\n id: \"qwen3-coder-plus\".to_string(),\n display_name: \"qwen3-coder-plus\".to_string(),\n context_window: 128_000,\n },\n WasmModelInfo {\n id: \"qwen3-coder-flash\".to_string(),\n display_name: \"qwen3-coder-flash\".to_string(),\n context_window: 128_000,\n },\n WasmModelInfo {\n id: \"vision-model\".to_string(),\n display_name: \"vision-model\".to_string(),\n context_window: 128_000,\n },\n WasmModelInfo {\n id: \"coder-model\".to_string(),\n display_name: \"coder-model\".to_string(),\n context_window: 128_000,\n },\n ],\n // Flat-text ABI + a buffered upstream: one terminal chunk.\n chunk_texts: vec![\"qwen coded reply\"],\n final_text: \"qwen coded reply\",\n terminal_usage: Some(WasmTokenUsage {\n input: 19,\n output: 13,\n }),\n http_error_cases: vec![\n HttpErrorCase {\n status: 429,\n expected_substring: \"rate limited\",\n },\n HttpErrorCase {\n status: 503,\n expected_substring: \"unavailable\",\n },\n // A 4xx that is NOT a model-not-found stays an invalid-request\n // carrying only the status — never the upstream message. The host\n // passes a non-2xx upstream status straight through\n // `authorized-request`, so this exercises the SAME\n // `classify_error` mapping as the API-key path.\n HttpErrorCase {\n status: 400,\n expected_substring: \"HTTP 400\",\n },\n ],\n timeout_error_substrings: vec![\n \"failed\",\n \"timeout\",\n \"timed out\",\n \"budget\",\n \"unavailable\",\n ],\n // No `ryuzi:http`, no `ryuzi:provider-auth`, only host-mediated\n // `ryuzi:oauth` — so there is no forged secret to look for.\n guest_forged_secret: None,\n // ...and the ONLY value the mock may see for `authorization` is the\n // HOST-injected bearer built from the seeded OAuth token.\n expected_authorization: Some(\"Bearer qwen-oauth-access-token-conformance\"),\n },\n }\n}\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn qwen_component_passes_the_full_conformance_battery() {\n crate::plugins::build_provider_component_once(\"qwen\");\n ProviderConformance::new(qwen_conformance())\n .run_full_battery()\n .await;\n}\n\n/// The battery proves the `qwen` component BEHAVES like its provider. This\n/// proves its bundle is DECLARED like one — the device-grant analogue of\n/// [`anthropic_oauth_bundle_is_declared_like_its_oauth_provider`]. `qwen` is an\n/// OAuth provider whose credential comes from an RFC 8628 device-authorization\n/// GRANT (`device_grant: QWEN_DEVICE_GRANT`), not a redirect+PKCE `OAuthConfig`,\n/// so the hosts its profile authenticates against are the grant's device-code +\n/// token endpoints, and it advertises a SEEDED model list\n/// (`has_models_endpoint: false`).\n#[test]\nfn qwen_bundle_is_declared_like_its_device_grant_oauth_provider() {\n use crate::llm_router::registry::{self, ApiFormat, ProviderCategory};\n use ryuzi_plugin_sdk::PluginBundleManifest;\n\n let id = \"qwen\";\n let descriptor =\n registry::descriptor(id).unwrap_or_else(|| panic!(\"{id} must exist in the router catalog\"));\n\n // The descriptor facts this component was built from: an OAuth provider,\n // OpenAI-chat wire, with NO live `/models` endpoint (so a seeded list).\n assert_eq!(\n descriptor.category,\n ProviderCategory::OAuth,\n \"{id} category\"\n );\n assert_eq!(descriptor.format, ApiFormat::OpenAi, \"{id} wire format\");\n assert!(\n !descriptor.has_models_endpoint,\n \"{id} has no live /models endpoint — its component advertises a seeded list\",\n );\n assert_eq!(descriptor.chat_path, None, \"{id} chat_path\");\n\n // A device-authorization grant, not a redirect+PKCE OAuthConfig.\n let grant = registry::device_grant_config(id)\n .unwrap_or_else(|| panic!(\"{id} must declare a DeviceGrantConfig\"));\n\n // The hosts the manifest may allowlist: the API base host plus the grant's\n // device-code + token hosts the profile authenticates against — nothing wider.\n let host_of = |raw: &str| {\n url::Url::parse(raw)\n .unwrap_or_else(|e| panic!(\"{id} url {raw}: {e}\"))\n .host_str()\n .unwrap_or_else(|| panic!(\"{id} url {raw} has no host\"))\n .to_string()\n };\n let base_url = descriptor\n .base_url\n .unwrap_or_else(|| panic!(\"{id} must declare a base_url to allowlist\"));\n let mut expected_hosts = std::collections::BTreeSet::new();\n expected_hosts.insert(host_of(base_url));\n expected_hosts.insert(host_of(grant.device_code_url));\n expected_hosts.insert(host_of(grant.token_url));\n\n let bundle_dir = Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"../../plugins\")\n .join(id);\n let path = bundle_dir.join(\"ryuzi-plugin.toml\");\n let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!(\"{}: {e}\", path.display()));\n let manifest: PluginBundleManifest =\n toml::from_str(&text).unwrap_or_else(|e| panic!(\"{id} manifest: {e}\"));\n manifest\n .validate()\n .unwrap_or_else(|e| panic!(\"{id} manifest is invalid: {e}\"));\n\n assert_eq!(manifest.id, id, \"bundle id\");\n assert_eq!(\n manifest.provider_ids,\n vec![id.to_string()],\n \"{id} must declare provider-ids EXPLICITLY so the router registers its \\\n transport under that id\",\n );\n // Exactly one `[[oauth]]` profile, whose id equals the string the component\n // passes to `authorized-request` (and the router provider id).\n assert_eq!(manifest.oauth.len(), 1, \"{id} declares one oauth profile\");\n assert_eq!(\n manifest.oauth[0].id, id,\n \"{id} oauth profile id must equal the provider id the guest requests\",\n );\n // The profile's token-url + scopes are transcribed from the device grant.\n assert_eq!(\n manifest.oauth[0].token_url.as_deref(),\n Some(grant.token_url),\n \"{id} oauth token-url must be the device grant's token endpoint\",\n );\n let expected_scopes: Vec<String> = grant.scope.split(' ').map(str::to_string).collect();\n assert_eq!(\n manifest.oauth[0].scopes, expected_scopes,\n \"{id} oauth scopes must be the device grant's scope, space-split\",\n );\n let hosts: std::collections::BTreeSet<String> = manifest\n .permissions\n .network\n .iter()\n .map(|host| host.0.clone())\n .collect();\n assert_eq!(\n hosts, expected_hosts,\n \"{id} must allowlist exactly the API host plus the device grant's \\\n device-code/token hosts ({expected_hosts:?}) — a wider allowlist widens \\\n where a request may travel\",\n );\n\n // The full base URL — scheme, host AND path — as the component transcribed it\n // into its own `logic.rs`, read from source text because the bundle is a\n // separate workspace this crate cannot link. The allowlist check matches on\n // HOST only and the battery overrides the base URL through storage, so a wrong\n // PATH escapes every other gate here.\n let logic_path = bundle_dir.join(\"src/logic.rs\");\n let logic = std::fs::read_to_string(&logic_path)\n .unwrap_or_else(|e| panic!(\"{}: {e}\", logic_path.display()));\n let configured = configured_default_base_url(&logic).unwrap_or_else(|| {\n panic!(\n \"{} must assign a string literal to `default_base_url`\",\n logic_path.display()\n )\n });\n assert_eq!(\n configured,\n base_url,\n \"{} configures base URL {configured:?} but the {id} descriptor says \\\n {base_url:?}\",\n logic_path.display(),\n );\n}\n"}}}} |