{"repo": "bitrouter/bitrouter", "files": {"apps/bitrouter/src/commands.rs": {"5536fe525829ec51d373ac611ae89d1fed449dfc": {"t": 1785596341.3203242, "n": 62553, "h": "0e6e6cbbc3916f4c", "c": "//! Management command logic — the implementations behind the CLI subcommands.\n//! Kept in the lib so it is testable and so a future desktop front-end can\n//! reuse it without dragging in the binary.\n\nuse anyhow::{Context, Result};\n\nuse bitrouter_sdk::caller::CallerContext;\nuse bitrouter_sdk::config::{Config, ConfigRoutingTable};\nuse bitrouter_sdk::language_model::{RoutingPrefs, RoutingTable};\n\nuse crate::auth::{NewApiKey, db as auth_db, generate};\nuse crate::daemon::RouteHop;\nuse crate::output::reports::skills::{\n FailedSkill, SkillAddReport, SkillEntry, SkillHit, SkillInitReport, SkillRemoveReport,\n SkillsFindReport, SkillsListReport, SkillsUpdateReport, SkippedSkill, UpdatedSkill,\n};\n\n/// The starter `bitrouter.yaml` written by `bitrouter init`. Mirrors\n/// the zero-config in-memory default so a user who runs `init` and\n/// then deletes nothing gets the same behaviour as running bitrouter\n/// with no config at all — only the file is here to be edited.\npub const STARTER_CONFIG: &str = r#\"# bitrouter.yaml — generated by `bitrouter init`.\n#\n# This file mirrors the zero-config in-memory default. Delete any\n# section you don't want to override, or add custom providers /\n# guardrails / policies / MCP servers as needed.\n\nserver:\n # Bind localhost only by default — combined with `skip_auth: true`,\n # exposing `0.0.0.0` would let any host on the network use bitrouter\n # without a credential check. Flip to `0.0.0.0:4356` only when you've\n # also set `skip_auth: false` and minted virtual keys with\n # `bitrouter key sign`.\n listen: \"127.0.0.1:4356\"\n # skip_auth=true admits credential-less local requests (cc-switch /\n # litellm style). Set to false and create keys with `bitrouter key\n # sign` for a multi-tenant deployment.\n skip_auth: true\n\ndatabase:\n # Relative paths are interpreted against the bitrouter home (the\n # directory containing this file) — the daemon chdirs there on\n # startup, so `./bitrouter.db` always lands alongside the config.\n url: \"sqlite://./bitrouter.db\"\n\n# Routing policies are opt-in and live in a sibling policy-lock.yaml.\n# `bitrouter policy init --preset --strong \n# --economy ` creates the lock and preset binding. With no binding,\n# model routing behaves exactly as it does in zero-config mode.\n\n# Built-in providers auto-enable when their env var is set. An entry\n# with `inherit_defaults: true` (the workspace default) plus an empty\n# body is enough — `api_base`, `api_protocol`, `api_key`, and the\n# routable models all flow from the built-in catalog. Comment a line\n# out (or set `active: false`) to disable a provider you don't want.\nproviders:\n openai: {} # uses OPENAI_API_KEY\n anthropic: {} # uses ANTHROPIC_API_KEY\n google: {} # uses GEMINI_API_KEY\n openrouter: {} # uses OPENROUTER_API_KEY\n # github-copilot needs `bitrouter providers login github-copilot` first:\n # github-copilot: {}\n # A provider can hold multiple accounts — e.g. two subscriptions to\n # the same upstream. `account_strategy` is `failover` (try the first,\n # drop to the next on a retryable / out-of-credits error — default)\n # or `balance` (spread load evenly across accounts):\n # opencode-go: { account_strategy: failover, accounts: [{ api_key: \"${OPENCODE_GO_KEY_A}\", label: primary }, { api_key: \"${OPENCODE_GO_KEY_B}\", label: backup }] }\n\n# Upstream MCP (Model Context Protocol) servers — `bitrouter tools\n# list/status` reads this, and `POST /mcp/` proxies JSON-RPC\n# against the entry. Discover servers from the official MCP registry\n# with `bitrouter mcp search ` and print a paste-ready stub with\n# `bitrouter mcp add `. Spec:\n# https://modelcontextprotocol.io/specification/2025-06-18\n# mcp_servers:\n# ctx7:\n# name: ctx7\n# transport:\n# type: http\n# url: https://mcp.context7.com/mcp\n# headers:\n# Authorization: \"Bearer ${CTX7_TOKEN}\"\n# git:\n# name: git\n# transport:\n# type: stdio\n# command: uvx\n# args: [\"mcp-server-git\"]\n\n# Upstream ACP (Agent Client Protocol) agents — manage with\n# `bitrouter agents list` / `check` / `install`. Spec:\n# https://agentclientprotocol.com/protocol/schema\n# agents:\n# claude:\n# name: claude\n# transport:\n# type: stdio\n# command: npx\n# args: [\"-y\", \"@zed-industries/claude-code-acp@latest\"]\n# codex:\n# name: codex\n# transport:\n# type: stdio\n# command: npx\n# args: [\"-y\", \"@zed-industries/codex-acp@latest\"]\n\ninherit_defaults: true\n\"#;\n\n/// Outcome of a [`write_starter_config`] scaffold write.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum ScaffoldOutcome {\n /// The starter `bitrouter.yaml` was written.\n Wrote,\n /// A file already existed and `force` was not set — left untouched.\n Skipped,\n}\n\n/// `bitrouter init` — write the starter `bitrouter.yaml` to `path`. Without\n/// `force`, an existing file is left untouched (returns\n/// [`ScaffoldOutcome::Skipped`]) so the onboarding wizard's `--yes` run never\n/// clobbers a config the user already tuned; with `force` it overwrites. This\n/// is the single sanctioned `bitrouter.yaml` write — nothing else in the tree\n/// serializes a `Config`.\npub async fn write_starter_config(path: &std::path::Path, force: bool) -> Result {\n if !force && tokio::fs::try_exists(path).await.unwrap_or(false) {\n return Ok(ScaffoldOutcome::Skipped);\n }\n tokio::fs::write(path, STARTER_CONFIG)\n .await\n .with_context(|| format!(\"writing {}\", path.display()))?;\n Ok(ScaffoldOutcome::Wrote)\n}\n\n/// The outcome of `bitrouter key generate` — the plaintext secret is shown to\n/// the user **once** and never persisted (only its SHA-256 hash is stored).\npub struct GeneratedKey {\n /// The api key id.\n pub id: String,\n /// The plaintext `brvk_…` secret — display once, then discard.\n pub secret: String,\n}\n\n/// `bitrouter key sign` — mint a `brvk_` virtual key for `user_id`, persist only\n/// its hash, and return the plaintext once. v1 does **not** sign a JWT (004\n/// §3.0); the command keeps the `sign` name but creates a DB-backed virtual key.\npub async fn key_sign(\n db_url: &str,\n user_id: &str,\n policy_id: Option<&str>,\n) -> Result {\n let db = crate::db::connect(db_url)\n .await\n .with_context(|| format!(\"connecting to database {db_url}\"))?;\n crate::db::run_migrations(&db)\n .await\n .context(\"running database migrations\")?;\n auth_db::upsert_user(&db, user_id)\n .await\n .context(\"creating user row\")?;\n\n let key = generate();\n let id = format!(\"brvk_id_{}\", &key.hash[..16]);\n auth_db::insert_api_key(\n &db,\n &NewApiKey {\n id: id.clone(),\n key_hash: key.hash.clone(),\n user_id: user_id.to_string(),\n spend_limit_micro_usd: None,\n rpm_limit: None,\n policy_id: policy_id.map(|s| s.to_string()),\n },\n )\n .await\n .context(\"inserting api key\")?;\n\n Ok(GeneratedKey {\n id,\n secret: key.secret,\n })\n}\n\n/// `bitrouter models [--provider ]` — list routable models, optionally\n/// filtered to those a given provider declares.\npub async fn list_models(\n config: &Config,\n provider_filter: Option<&str>,\n) -> Result)>> {\n let mut resolved = config.clone();\n bitrouter_providers::apply_builtin_defaults(&mut resolved);\n // Best-effort `/models` discovery for any provider with\n // `auto_discover: true`. Zero-config built-ins set the flag, so\n // this turns `bitrouter models` from \"empty\" into a useful catalog\n // when the user has the API key set. Failures log and leave the\n // provider with no models — no error bubbles to the user.\n bitrouter_sdk::config::discover_models(&mut resolved).await;\n let table = ConfigRoutingTable::from_config(resolved);\n Ok(table\n .list_models()\n .into_iter()\n .filter(|m| match provider_filter {\n Some(p) => m.providers.iter().any(|x| x == p),\n None => true,\n })\n .map(|m| (m.id, m.providers))\n .collect())\n}\n\n/// `bitrouter route ` — resolve a model name through the routing table,\n/// **standalone** (no running daemon needed). Returns the fallback chain.\npub async fn resolve_route(config: &Config, model: &str) -> Result> {\n let mut resolved = config.clone();\n bitrouter_providers::apply_builtin_defaults(&mut resolved);\n let table = ConfigRoutingTable::from_config(resolved);\n let chain = table\n .route_chain(model, &RoutingPrefs::default(), &CallerContext::local())\n .await\n .with_context(|| format!(\"resolving model '{model}'\"))?;\n Ok(chain\n .into_iter()\n .map(|t| RouteHop {\n provider: t.provider_name,\n service_id: t.service_id,\n api_protocol: format!(\"{:?}\", t.api_protocol).to_lowercase(),\n })\n .collect())\n}\n\n/// One provider entry for `bitrouter providers list`.\npub struct ProviderListing {\n /// Provider id.\n pub id: String,\n /// Upstream API base.\n pub api_base: String,\n /// Whether the provider is active / routable.\n pub active: bool,\n /// Number of declared models.\n pub model_count: usize,\n}\n\n/// `bitrouter providers list` — list every configured provider.\n/// Applies built-in catalog defaults first so a zero-config or\n/// minimally-declared provider (`openai: {}`) shows its real `api_base`\n/// and `active` state instead of empty placeholders.\npub fn list_providers(config: &Config) -> Vec {\n let mut resolved = config.clone();\n bitrouter_providers::apply_builtin_defaults(&mut resolved);\n let mut providers: Vec = resolved\n .providers\n .iter()\n .map(|(id, p)| ProviderListing {\n id: id.clone(),\n api_base: p.api_base.clone(),\n active: p.active,\n model_count: p.models.len(),\n })\n .collect();\n providers.sort_by(|a, b| a.id.cmp(&b.id));\n providers\n}\n\n/// Reject a policy id that would escape its directory or hide as a dotfile.\n/// Allowed: ASCII letters / digits / `_` / `-` / `.` (not as the leading char).\nfn validate_policy_id(id: &str) -> Result<()> {\n if id.is_empty() {\n anyhow::bail!(\"policy id must not be empty\");\n }\n if id.starts_with('.') {\n anyhow::bail!(\"policy id must not start with '.' (no dotfiles / `..`)\");\n }\n if id.chars().any(|c| {\n c == '/'\n || c == '\\\\'\n || c == '\\0'\n || c.is_whitespace()\n || !(c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')\n }) {\n anyhow::bail!(\n \"policy id '{id}' contains invalid characters (allowed: letters, digits, `_`, `-`, `.`)\"\n );\n }\n Ok(())\n}\n\n/// `bitrouter policy create` — write a starter policy file into `policy_dir`.\n/// Refuses to overwrite an existing policy of the same id. The `id` must be a\n/// plain filename (no path separators, no `..`, no leading dot) so the joined\n/// path cannot escape `policy_dir`.\npub async fn create_policy(policy_dir: &std::path::Path, id: &str) -> Result {\n validate_policy_id(id)?;\n tokio::fs::create_dir_all(policy_dir)\n .await\n .with_context(|| format!(\"creating policy dir {}\", policy_dir.display()))?;\n let path = policy_dir.join(format!(\"{id}.yaml\"));\n if tokio::fs::try_exists(&path).await.unwrap_or(false) {\n anyhow::bail!(\"policy '{id}' already exists at {}\", path.display());\n }\n let body = format!(\n \"# policy '{id}' — edit to taste, then bind it to a key:\\n\\\n # bitrouter key sign --user --policy {id}\\n\\\n id: {id}\\n\\\n # allowed_models: [gpt-5, claude-sonnet-4-6]\\n\\\n # denied_models: []\\n\\\n # max_spend_micro_usd: 1000000\\n\\\n # max_requests_per_minute: 60\\n\\\n # allowed_tools: [search]\\n\"\n );\n tokio::fs::write(&path, body)\n .await\n .with_context(|| format!(\"writing {}\", path.display()))?;\n Ok(path)\n}\n\n/// One auth method offered for a provider — derived from the catalog +\n/// the PKCE registry. The CLI shows users only the methods the provider\n/// actually supports.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum AuthMethod {\n /// Use the user's existing Claude Code session: read its OAuth credential\n /// live from `~/.claude` (Keychain / `.credentials.json`), or sign in via\n /// the `claude` CLI when not yet logged in. The preferred path for\n /// `anthropic` — no second token copy, so it can't refresh-rotate Claude\n /// Code out (RFC 6749 §6).\n ClaudeCodeSession,\n /// Browser-based PKCE Authorization Code flow — Anthropic Claude\n /// Pro/Max, OpenAI Codex / ChatGPT.\n PkceSubscription,\n /// Adopt an existing session from the provider's own vendor CLI (Claude\n /// Code, Codex) by reading its on-disk / Keychain OAuth token — no browser.\n ImportFromCli,\n /// RFC 8628 Device Authorization Grant — GitHub Copilot's flow.\n DeviceCode,\n /// User pastes a static API key (sk-…, sk-ant-…).\n ApiKey,\n}\n\nimpl AuthMethod {\n fn label(self) -> &'static str {\n match self {\n AuthMethod::ClaudeCodeSession => {\n \"Use your Claude Code session (read ~/.claude, or sign in via the claude CLI)\"\n }\n AuthMethod::PkceSubscription => \"Subscription (browser sign-in)\",\n AuthMethod::ImportFromCli => \"Import an existing session from the vendor CLI\",\n AuthMethod::DeviceCode => \"Device-code OAuth (show a code in browser)\",\n AuthMethod::ApiKey => \"API key (paste a static credential)\",\n }\n }\n}\n\n/// The vendor CLI bitrouter can adopt an existing OAuth session from, for\n/// providers that ship one — `Codex` for `openai-codex`. `None` for every\n/// other provider.\n///\n/// `anthropic` is **not** listed: it is now the platform / pay-as-you-go\n/// provider (`x-api-key` only). Adopting a Claude Code session is the\n/// `claude-code` subscription provider's job, handled by\n/// [`AuthMethod::ClaudeCodeSession`] (a *live* session read, not a one-shot\n/// token copy), so there is no vendor-CLI import for `anthropic`.\nfn import_cli_for(provider_id: &str) -> Option<&'static str> {\n match provider_id {\n bitrouter_providers::codex::PROVIDER_ID => Some(\"Codex\"),\n bitrouter_providers::supergrok::PROVIDER_ID => Some(\"Grok\"),\n bitrouter_providers::antigravity::PROVIDER_ID => Some(\"Antigravity (agy)\"),\n _ => None,\n }\n}\n\n/// Decide which auth methods are wired for `entry`. Order matters — the\n/// first entry is what runs when the user hits enter on the prompt.\nfn available_methods(entry: &bitrouter_providers::ProviderEntry) -> Vec {\n use bitrouter_providers::AuthScheme;\n let mut methods = Vec::new();\n // The Claude subscription's preferred (and only) path: read the user's live\n // Claude Code session and let the `claude` CLI own login. It belongs to the\n // dedicated `claude-code` subscription provider — `anthropic` is now the\n // platform / pay-as-you-go provider and offers the API-key path only.\n // Listed first so it's the default on / non-interactive runs.\n let is_claude_code = entry.id == bitrouter_providers::claude_code::PROVIDER_ID;\n if is_claude_code {\n methods.push(AuthMethod::ClaudeCodeSession);\n }\n // Providers with a sibling vendor CLI can adopt its existing session as a\n // one-shot copy — currently only Codex (`openai-codex`). List that before\n // PKCE so the default path reuses an already-authenticated local CLI\n // session when one exists.\n if import_cli_for(&entry.id).is_some() {\n methods.push(AuthMethod::ImportFromCli);\n }\n // `anthropic` is platform / pay-as-you-go: offer the API-key path only. The\n // PKCE registry still carries an `anthropic` entry (the Claude subscription\n // browser flow), but that subscription path now belongs to the dedicated\n // `claude-code` provider, so it must not be offered under `anthropic`.\n let is_anthropic = entry.id == bitrouter_providers::anthropic::PROVIDER_ID;\n let has_pkce = !is_anthropic && bitrouter_providers::oauth::registry::has_pkce_flow(&entry.id);\n if has_pkce {\n methods.push(AuthMethod::PkceSubscription);\n }\n match &entry.auth {\n AuthScheme::Bearer { .. } | AuthScheme::Header { .. } => {\n methods.push(AuthMethod::ApiKey);\n }\n AuthScheme::Oauth { params, .. } => {\n // Device-code is only viable when the TOML carries the\n // device authorization endpoint — PKCE-only providers\n // (`openai-codex`) skip this even though their auth scheme\n // is `oauth`.\n let has_device = params\n .get(\"device_authorization_endpoint\")\n .and_then(|v| v.as_str())\n .is_some();\n if has_device {\n methods.push(AuthMethod::DeviceCode);\n }\n // OAuth-only providers don't accept pasted API keys (the\n // Codex backend literally rejects sk-… credentials), so\n // there's no API-key fallback to offer.\n }\n AuthScheme::Native { .. } => {}\n }\n methods\n}\n\n/// Prompt for an integer choice between `[1, options.len()]`. Empty\n/// input (or just ) picks option 1. Invalid input (non-number,\n/// out-of-range) prints a one-line error and re-prompts; a true EOF on\n/// stdin (read_line returns 0 bytes) bails so the caller doesn't loop\n/// forever in non-interactive contexts.\nfn prompt_method_choice(provider: &str, options: &[AuthMethod]) -> Result {\n use std::io::BufRead;\n eprintln!();\n eprintln!(\"How would you like to authenticate to {provider}?\");\n for (i, m) in options.iter().enumerate() {\n eprintln!(\" {}) {}\", i + 1, m.label());\n }\n let stdin = std::io::stdin();\n let mut handle = stdin.lock();\n loop {\n eprint!(\"Choose [1]: \");\n let mut line = String::new();\n let n_bytes = handle\n .read_line(&mut line)\n .context(\"reading method choice from stdin\")?;\n if n_bytes == 0 {\n anyhow::bail!(\"stdin closed before a choice was made\");\n }\n let trimmed = line.trim();\n if trimmed.is_empty() {\n return Ok(options[0]);\n }\n match trimmed.parse::() {\n Ok(n) if (1..=options.len()).contains(&n) => return Ok(options[n - 1]),\n Ok(n) => eprintln!(\" choice must be between 1 and {}, got {n}\", options.len()),\n Err(_) => eprintln!(\" '{trimmed}' is not a number\"),\n }\n }\n}\n\n/// `bitrouter providers login [--label ]` — interactive\n/// credential setup.\n///\n/// Resolves which auth methods are wired for `provider_id` (subscription\n/// PKCE / device-code / API key paste), prompts the user to pick when\n/// there's more than one, runs the chosen flow, and persists the\n/// resulting [`bitrouter_providers::oauth::credential_store::Credential`]\n/// under `(provider_id, label)`.\n/// What `login_provider` accomplished — surfaced as the CLI's JSON result while\n/// the interactive prompts and confirmation go to stderr.\npub struct LoginOutcome {\n pub provider: String,\n pub label: String,\n pub method: String,\n pub path: String,\n}\n\n/// Non-interactive provider-login controls.\n#[derive(Debug, Clone, Default)]\npub struct ProviderLoginOptions {\n /// Require importing an already-authenticated vendor CLI session.\n pub import_existing: bool,\n /// Refuse browser-based PKCE flows.\n pub no_browser: bool,\n /// Seed the credential directly from this API key, skipping the interactive\n /// method choice and the stdin paste entirely. Set by `providers login\n /// --api-key` / `--key-stdin` and the onboarding wizard's per-provider\n /// `--provider-api-key`. The provider must offer the API-key method.\n pub api_key: Option,\n}\n\npub async fn login_provider(provider_id: &str, label: &str) -> Result {\n login_provider_with_options(provider_id, label, ProviderLoginOptions::default()).await\n}\n\npub async fn login_provider_with_options(\n provider_id: &str,\n label: &str,\n options: ProviderLoginOptions,\n) -> Result {\n use bitrouter_providers::builtin;\n\n // The `bitrouter` cloud gateway is compiled in; every other provider's\n // auth shape (handler + public OAuth params) comes from the fetched-or-\n // cached registry — the same mapper the built-ins once used.\n let entry: bitrouter_providers::ProviderEntry = match builtin::find(provider_id) {\n Some(e) => e.clone(),\n None => {\n let data = bitrouter_providers::registry::apply::load_or_cached(\n &bitrouter_sdk::config::RegistryConfig::default(),\n )\n .await\n .ok_or_else(|| {\n anyhow::anyhow!(\n \"could not load the provider registry (offline, and nothing cached) \\\n to resolve '{provider_id}'\"\n )\n })?;\n let provider = data\n .providers\n .iter()\n .find(|p| p.name == provider_id)\n .ok_or_else(|| {\n anyhow::anyhow!(\n \"unknown provider '{provider_id}' — try `bitrouter providers list` \\\n for the catalog\"\n )\n })?;\n builtin::entry_from_registry(provider)\n .with_context(|| format!(\"resolving the auth shape for '{provider_id}'\"))?\n }\n };\n let methods = available_methods(&entry);\n if methods.is_empty() {\n anyhow::bail!(\n \"provider '{provider_id}' has no interactive login path \\\n (auth scheme: {:?})\",\n std::mem::discriminant(&entry.auth)\n );\n }\n let chosen = choose_auth_method(&entry, &methods, &options)?;\n eprintln!();\n eprintln!(\n \" Logging in to {} via {}\",\n entry.display_name,\n chosen.label()\n );\n\n let credential = match chosen {\n AuthMethod::ClaudeCodeSession => run_claude_code_session().await?,\n AuthMethod::PkceSubscription => run_pkce_subscription(provider_id).await?,\n AuthMethod::ImportFromCli => run_cli_import(provider_id)?,\n AuthMethod::DeviceCode => run_device_code(provider_id, &entry).await?,\n // A flag-supplied key (`--api-key` / `--key-stdin` / the wizard) skips\n // the interactive stdin paste; otherwise prompt for it.\n AuthMethod::ApiKey => match options.api_key.as_deref() {\n Some(key) => {\n let key = key.trim();\n if key.is_empty() {\n anyhow::bail!(\"the supplied API key is empty — aborting login\");\n }\n bitrouter_providers::oauth::credential_store::Credential::api_key(key)\n }\n None => run_api_key_paste(&entry.display_name)?,\n },\n };\n\n let mut store = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()\n .context(\"opening credential store\")?;\n let kind = credential.kind_label();\n store\n .set(provider_id, label, credential)\n .with_context(|| format!(\"persisting credential for {provider_id} ({label})\"))?;\n eprintln!(\n \" ✓ Saved {kind} credential for {provider_id} (label: {label}) at {}\",\n store.path().display()\n );\n eprintln!();\n Ok(LoginOutcome {\n provider: provider_id.to_string(),\n label: label.to_string(),\n method: kind.to_string(),\n path: store.path().display().to_string(),\n })\n}\n\nfn choose_auth_method(\n entry: &bitrouter_providers::ProviderEntry,\n methods: &[AuthMethod],\n options: &ProviderLoginOptions,\n) -> Result {\n // A supplied API key is the most explicit signal — take the API-key method\n // directly, or fail clearly if the provider doesn't offer one (OAuth-only\n // backends reject pasted keys).\n if options.api_key.is_some() {\n if methods.contains(&AuthMethod::ApiKey) {\n return Ok(AuthMethod::ApiKey);\n }\n anyhow::bail!(\n \"provider '{}' does not accept a pasted API key — it uses an \\\n interactive OAuth flow; drop --api-key/--key-stdin\",\n entry.id\n );\n }\n\n if options.import_existing {\n if methods.contains(&AuthMethod::ImportFromCli) {\n return Ok(AuthMethod::ImportFromCli);\n }\n anyhow::bail!(\n \"provider '{}' cannot import an existing vendor CLI session\",\n entry.id\n );\n }\n\n let selectable: Vec = if options.no_browser {\n methods\n .iter()\n .copied()\n .filter(|m| *m != AuthMethod::PkceSubscription)\n .collect()\n } else {\n methods.to_vec()\n };\n if selectable.is_empty() {\n anyhow::bail!(\n \"provider '{}' has no non-browser login path; rerun without --no-browser\",\n entry.id\n );\n }\n if selectable.len() == 1 || options.no_browser {\n Ok(selectable[0])\n } else {\n prompt_method_choice(&entry.display_name, &selectable)\n }\n}\n\n/// Adopt the user's existing Claude Code session as the live credential source\n/// for the `claude-code` subscription provider.\n///\n/// Resolution order, mirroring how Claude Code users actually authenticate:\n/// 1. If a Claude Code session already exists in `~/.claude` (macOS Keychain /\n/// `.credentials.json`), use it as-is — the daemon reads it live and\n/// refreshes in place.\n/// 2. Otherwise sign the user in with the `claude` CLI itself (`claude auth\n/// login --claudeai`), installing it first (the same native installer\n/// `bitrouter spawn` uses) when it's missing, then re-read.\n///\n/// Returns a [`Credential::ClaudeCodeCli`] marker — no token is copied into\n/// bitrouter's own store, so bitrouter and Claude Code share one credential and\n/// can't refresh-rotate each other out (RFC 6749 §6). `login_provider` stores\n/// this marker under the `provider_id` it was invoked with, so\n/// `bitrouter providers login claude-code` lands it under `claude-code`.\nasync fn run_claude_code_session()\n-> Result {\n use std::io::IsTerminal;\n\n use bitrouter_providers::import::claude_code::ClaudeCodeStore;\n use bitrouter_providers::oauth::credential_store::Credential;\n\n // Adopt a legacy #590 marker stored under `anthropic` before checking the\n // live session, so a returning user's existing sign-in is recognised under\n // the new `claude-code` provider.\n crate::claude_code::migrate_legacy_anthropic_marker_default();\n\n let store = ClaudeCodeStore::system().ok_or_else(|| {\n anyhow::anyhow!(\n \"could not resolve your home directory (set HOME) to find the Claude Code session\"\n )\n })?;\n\n // 1. Already signed in to Claude Code?\n if let Some(live) = store\n .read()\n .context(\"reading your existing Claude Code session\")?\n {\n eprintln!(\n \" Found your Claude Code session ({}) — bitrouter will read it live.\",\n live.source\n );\n return Ok(Credential::ClaudeCodeCli);\n }\n\n // 2. Not signed in — drive the `claude` CLI's own login.\n if !std::io::stdin().is_terminal() {\n anyhow::bail!(\n \"not signed in to Claude Code. Run `claude auth login` (install the CLI first with \\\n `curl -fsSL https://claude.ai/install.sh | bash`), then re-run \\\n `bitrouter providers login claude-code`.\"\n );\n }\n\n let claude = crate::spawn::ensure_agent_installed(crate::spawn::SpawnAgent::Claude, false)\n .await\n .context(\"locating the claude CLI to sign you in\")?;\n\n eprintln!(\" You're not signed in to Claude Code yet — launching `claude auth login`.\");\n let status = tokio::process::Command::new(&claude)\n .arg(\"auth\")\n .arg(\"login\")\n .arg(\"--claudeai\")\n .status()\n .await\n .context(\"running `claude auth login`\")?;\n if !status.success() {\n anyhow::bail!(\n \"`claude auth login` didn't complete — sign in, then re-run \\\n `bitrouter providers login claude-code`.\"\n );\n }\n\n match store\n .read()\n .context(\"re-reading your Claude Code session after sign-in\")?\n {\n Some(live) => {\n eprintln!(\n \" Signed in — bitrouter will read your session live from {}.\",\n live.source\n );\n Ok(Credential::ClaudeCodeCli)\n }\n None => anyhow::bail!(\n \"still no Claude Code session after `claude auth login` — run `claude auth status` \\\n to check, then retry.\"\n ),\n }\n}\n\n/// Run the browser-based PKCE Authorization Code flow against the\n/// PKCE-registered provider. Errors when the provider isn't in\n/// `bitrouter_providers::oauth::registry`.\nasync fn run_pkce_subscription(\n provider_id: &str,\n) -> Result {\n let registry = bitrouter_providers::oauth::registry::find(provider_id).ok_or_else(|| {\n anyhow::anyhow!(\n \"provider '{provider_id}' has no PKCE login registration — \\\n this should have been caught by available_methods()\"\n )\n })?;\n let client = reqwest::Client::builder()\n .user_agent(concat!(\"bitrouter/\", env!(\"CARGO_PKG_VERSION\")))\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .context(\"building HTTP client for PKCE login\")?;\n let ux = StderrLoginUx;\n let outcome = bitrouter_providers::oauth::login::run_login(\n &client,\n ®istry,\n &ux,\n bitrouter_providers::oauth::auth_code::MANUAL_PASTE_TIMEOUT,\n )\n .await\n .with_context(|| format!(\"PKCE login for {provider_id}\"))?;\n if outcome.manual_fallback_used {\n eprintln!(\" (used manual paste fallback)\");\n }\n Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(outcome.token))\n}\n\n/// Run the RFC 8628 Device Authorization Grant — the existing\n/// `github-copilot` flow, lifted out of the old `login_provider`.\nasync fn run_device_code(\n provider_id: &str,\n entry: &bitrouter_providers::ProviderEntry,\n) -> Result {\n use bitrouter_providers::AuthScheme;\n let params = match &entry.auth {\n AuthScheme::Oauth { params, .. } => params,\n _ => anyhow::bail!(\"provider '{provider_id}' is not OAuth — refusing device flow\"),\n };\n let client_id = params\n .get(\"client_id\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| anyhow::anyhow!(\"'{provider_id}': auth.params.client_id is missing\"))?\n .to_string();\n let scope = params\n .get(\"scope\")\n .and_then(|v| v.as_str())\n .map(str::to_string);\n let device_url = params\n .get(\"device_authorization_endpoint\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n anyhow::anyhow!(\"'{provider_id}': auth.params.device_authorization_endpoint is missing\")\n })?\n .to_string();\n let token_url = params\n .get(\"token_endpoint\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| anyhow::anyhow!(\"'{provider_id}': auth.params.token_endpoint is missing\"))?\n .to_string();\n\n let flow = bitrouter_providers::oauth::DeviceCodeFlow::new(\n bitrouter_providers::oauth::DeviceCodeParams {\n client_id,\n scope,\n device_authorization_endpoint: device_url,\n token_endpoint: token_url,\n },\n )\n .with_context(|| format!(\"building OAuth flow for {provider_id}\"))?;\n\n let device_code = flow\n .request_device_code()\n .await\n .with_context(|| format!(\"requesting OAuth device code for {provider_id}\"))?;\n\n eprintln!();\n eprintln!(\" OAuth device authorization for {}\", entry.display_name);\n eprintln!(\" ──────────────────────────────────────────\");\n if let Some(complete) = &device_code.verification_uri_complete {\n eprintln!(\" Open: {complete}\");\n } else {\n eprintln!(\" Open: {}\", device_code.verification_uri);\n }\n eprintln!(\" Code: {}\", device_code.user_code);\n eprintln!();\n eprintln!(\" Waiting for authorization…\");\n\n let mut interval = std::time::Duration::from_secs(device_code.interval.max(1));\n let token = loop {\n tokio::time::sleep(interval).await;\n match flow.poll_once(&device_code.device_code).await? {\n bitrouter_providers::oauth::device_code::PollOutcome::Token(t) => break t,\n bitrouter_providers::oauth::device_code::PollOutcome::Pending => continue,\n bitrouter_providers::oauth::device_code::PollOutcome::SlowDown => {\n // RFC 8628 §3.5: server requested back-off — add 5s.\n interval += std::time::Duration::from_secs(5);\n }\n }\n };\n Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(token))\n}\n\n/// Adopt an existing OAuth session from the provider's sibling vendor CLI\n/// (Codex for `openai-codex`) by reading its on-disk / Keychain credential.\n/// Errors when no such session is found.\n///\n/// `anthropic` is intentionally absent: it is the platform / pay-as-you-go\n/// provider now, and the Claude Code session is adopted *live* by the\n/// `claude-code` provider via [`run_claude_code_session`] — not copied in here\n/// (the one-shot copy is what risked the RFC 6749 §6 family-revoke).\nfn run_cli_import(\n provider_id: &str,\n) -> Result {\n let imported = match provider_id {\n bitrouter_providers::codex::PROVIDER_ID => bitrouter_providers::import::codex::import(),\n bitrouter_providers::supergrok::PROVIDER_ID => bitrouter_providers::import::grok::import(),\n bitrouter_providers::antigravity::PROVIDER_ID => {\n bitrouter_providers::import::antigravity::import()\n }\n other => anyhow::bail!(\"no vendor-CLI import is available for provider '{other}'\"),\n }\n .with_context(|| format!(\"importing a CLI credential for {provider_id}\"))?;\n let imported = imported.ok_or_else(|| {\n anyhow::anyhow!(\n \"no existing CLI session found for {provider_id} — sign in to the \\\n vendor CLI first, or choose the browser sign-in instead\"\n )\n })?;\n eprintln!(\" Imported existing session from {}\", imported.source);\n Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(imported.token))\n}\n\n/// Read a static API key from stdin. Input is **not** masked — the user\n/// sees what they're pasting, which is the simplest correct UX and\n/// matches `gh auth login --with-token`. Future work could wire\n/// `rpassword` for a hidden prompt.\nfn run_api_key_paste(\n display_name: &str,\n) -> Result {\n use std::io::BufRead;\n eprintln!();\n eprintln!(\" Paste your {display_name} API key, then press enter:\");\n eprint!(\" > \");\n let stdin = std::io::stdin();\n let mut line = String::new();\n stdin\n .lock()\n .read_line(&mut line)\n .context(\"reading API key from stdin\")?;\n let key = line.trim();\n if key.is_empty() {\n anyhow::bail!(\"no API key entered — aborting login\");\n }\n Ok(bitrouter_providers::oauth::credential_store::Credential::api_key(key))\n}\n\n/// `LoginUx` implementation that drives stderr + stdin. Used by the\n/// `bitrouter providers login` CLI; library tests provide their own scripted\n/// UX.\nstruct StderrLoginUx;\n\n#[async_trait::async_trait]\nimpl bitrouter_providers::oauth::login::LoginUx for StderrLoginUx {\n async fn show_authorize_url(&self, url: &str, hint: &str) {\n eprintln!();\n eprintln!(\" Open this URL in your browser to continue:\");\n eprintln!();\n eprintln!(\" {url}\");\n eprintln!();\n eprintln!(\" {hint}\");\n }\n\n async fn prompt_pasted_redirect(\n &self,\n ) -> std::result::Result {\n // The login flow races this future against the loopback\n // listener. If the listener wins we never read stdin, so a\n // blocking read here would normally pin a thread for the whole\n // OAuth round trip. Run the read on a blocking thread so\n // cancellation (the `tokio::select!` in `run_login` dropping\n // this future when the listener finishes) actually frees it.\n let pasted = tokio::task::spawn_blocking(|| {\n use std::io::BufRead;\n eprint!(\" Paste redirect URL (or just the code): \");\n let stdin = std::io::stdin();\n let mut line = String::new();\n stdin.lock().read_line(&mut line).map_err(|e| {\n bitrouter_providers::oauth::login::LoginError::UserIo(format!(\n \"reading pasted redirect: {e}\"\n ))\n })?;\n Ok::<_, bitrouter_providers::oauth::login::LoginError>(line)\n })\n .await\n .map_err(|e| {\n bitrouter_providers::oauth::login::LoginError::UserIo(format!(\n \"join error reading pasted redirect: {e}\"\n ))\n })??;\n Ok(pasted)\n }\n}\n\n/// `bitrouter providers logout ` — drop every stored credential for\n/// the provider (subscription OAuth or pasted API key), if any.\npub async fn logout_provider(provider_id: &str) -> Result {\n let mut store = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()\n .context(\"opening credential store\")?;\n let removed = store\n .remove_all_for(provider_id)\n .with_context(|| format!(\"removing stored credentials for {provider_id}\"))?;\n match removed {\n 0 => eprintln!(\" No stored credentials for {provider_id}; nothing to remove.\"),\n n => eprintln!(\" ✓ Removed {n} stored credential(s) for {provider_id}.\"),\n }\n Ok(removed)\n}\n\n// ===== skills (client installer) =====\n\n/// Default BitRouter registry queried when `bitrouter skills add ` is\n/// given a bare skill name rather than a git source.\nconst DEFAULT_SKILLS_REGISTRY: &str = \"https://api.bitrouter.ai\";\n\n/// Build the HTTP client used to talk to a skills registry. Mirrors the\n/// 30-second-timeout, user-agent-tagged client the cloud SDK uses.\nfn skills_http_client() -> Result {\n reqwest::Client::builder()\n .user_agent(concat!(\"bitrouter-skills/\", env!(\"CARGO_PKG_VERSION\")))\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .context(\"building skills HTTP client\")\n}\n\n/// Resolve the install target for the `--global` flag.\nfn skills_target(global: bool) -> Result {\n use bitrouter_skills::install::InstallTarget;\n if global {\n Ok(InstallTarget::Global)\n } else {\n let cwd = std::env::current_dir().context(\"resolving the current directory\")?;\n Ok(InstallTarget::Project { project_root: cwd })\n }\n}\n\n/// Whether `source` is a bare registry skill name (no path/scheme), as opposed\n/// to a git source.\nfn is_bare_skill_name(source: &str) -> bool {\n !source.contains('/') && !source.contains(\"://\") && !source.starts_with(\"git@\")\n}\n\n/// Resolve a registry name to its [`bitrouter_skills::source::SkillSource`] via\n/// the namespace's marketplace hub.\nasync fn resolve_registry_source(\n name: &str,\n registry: Option<&str>,\n namespace: &str,\n) -> Result {\n use bitrouter_skills::marketplace;\n use bitrouter_skills::source::SkillSource;\n let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);\n let client = skills_http_client()?;\n let market = marketplace::fetch_marketplace(&client, base, namespace).await?;\n let entry = market\n .find(name)\n .ok_or_else(|| anyhow::anyhow!(\"skill {name:?} not found in registry {base}\"))?;\n SkillSource::try_from(&entry.source)\n .with_context(|| format!(\"resolving registry source for {name:?}\"))\n}\n\n/// `bitrouter skills add` — install a skill from a git source or registry name.\npub async fn skills_add(\n source: &str,\n select: Option<&str>,\n global: bool,\n overwrite: bool,\n registry: Option<&str>,\n namespace: Option<&str>,\n) -> Result {\n use bitrouter_skills::{install, source as skill_source};\n\n if source.trim().is_empty() {\n anyhow::bail!(\n \"a skill source is required (owner/repo, a git URL, a local path, or a registry name)\"\n );\n }\n\n let target = skills_target(global)?;\n let parsed = if is_bare_skill_name(source) {\n let nsid = namespace\n .ok_or_else(|| anyhow::anyhow!(\"--namespace is required to query a registry\"))?;\n resolve_registry_source(source, registry, nsid).await?\n } else {\n skill_source::parse_source(source)?\n };\n let outcome = install::install(&parsed, &target, overwrite, select, None).await?;\n Ok(SkillAddReport {\n action: if outcome.was_updated {\n \"updated\"\n } else {\n \"installed\"\n },\n name: outcome.name,\n dest: outcome.dest.display().to_string(),\n })\n}\n\n/// `bitrouter skills list` — show installed skills.\npub fn skills_list(global: bool) -> Result {\n use bitrouter_skills::install;\n let target = skills_target(global)?;\n let skills = install::list_installed(&target)?\n .into_iter()\n .map(|(name, path)| SkillEntry {\n name,\n path: path.display().to_string(),\n })\n .collect();\n Ok(SkillsListReport { skills })\n}\n\n/// `bitrouter skills remove` — uninstall a skill by name.\npub fn skills_remove(name: &str, global: bool) -> Result {\n use bitrouter_skills::install;\n let target = skills_target(global)?;\n let dir = install::remove(name, &target)?;\n Ok(SkillRemoveReport {\n name: name.to_string(),\n path: dir.display().to_string(),\n removed: true,\n })\n}\n\n/// `bitrouter skills find` — search a registry.\npub async fn skills_find(\n query: &str,\n registry: Option<&str>,\n namespace: Option<&str>,\n) -> Result {\n use bitrouter_skills::marketplace;\n let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);\n let nsid = namespace\n .ok_or_else(|| anyhow::anyhow!(\"--namespace is required to query a registry\"))?;\n let client = skills_http_client()?;\n let market = marketplace::fetch_marketplace(&client, base, nsid).await?;\n let results = market\n .search(query)\n .into_iter()\n .map(|entry| SkillHit {\n name: entry.name.clone(),\n version: entry.version.clone().unwrap_or_else(|| \"-\".to_string()),\n description: entry.description.clone().unwrap_or_default(),\n })\n .collect();\n Ok(SkillsFindReport {\n query: query.to_string(),\n registry: base.to_string(),\n results,\n })\n}\n\n/// `bitrouter skills init` — scaffold a SKILL.md.\npub fn skills_init(name: &str, output: &std::path::Path) -> Result {\n bitrouter_skills::install::validate_skill_name(name)?;\n if output.exists() {\n anyhow::bail!(\"{} already exists; refusing to overwrite\", output.display());\n }\n let body = format!(\n \"---\\nname: {name}\\ndescription: TODO — one line on what this skill does and when to use it.\\n---\\n\\n# {name}\\n\\nTODO: instructions for the agent.\\n\\n## When to use\\n\\n## Steps\\n\"\n );\n std::fs::write(output, body).with_context(|| format!(\"writing {}\", output.display()))?;\n Ok(SkillInitReport {\n path: output.display().to_string(),\n created: true,\n })\n}\n\n/// `bitrouter skills update` — re-install installed skills from the registry.\npub async fn skills_update(\n name: Option<&str>,\n global: bool,\n registry: Option<&str>,\n namespace: Option<&str>,\n) -> Result {\n use bitrouter_skills::install;\n use bitrouter_skills::marketplace;\n use bitrouter_skills::source::SkillSource;\n\n let target = skills_target(global)?;\n let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);\n\n // `update` only re-installs skills that are actually installed; an explicit\n // name must already be present (it does not double as an installer).\n let installed: Vec = install::list_installed(&target)?\n .into_iter()\n .map(|(n, _)| n)\n .collect();\n let names: Vec = match name {\n Some(n) => {\n if !installed.iter().any(|i| i == n) {\n anyhow::bail!(\"skill {n:?} is not installed; use `bitrouter skills add` first\");\n }\n vec![n.to_string()]\n }\n None => installed,\n };\n if names.is_empty() {\n return Ok(SkillsUpdateReport {\n updated: Vec::new(),\n skipped: Vec::new(),\n failed: Vec::new(),\n });\n }\n\n let nsid = namespace\n .ok_or_else(|| anyhow::anyhow!(\"--namespace is required to query a registry\"))?;\n let client = skills_http_client()?;\n let market = marketplace::fetch_marketplace(&client, base, nsid).await?;\n\n // Update each skill independently: one failure must not abort the rest.\n // Re-install into the same directory so a skill stays put even if its\n // upstream frontmatter name has drifted. Per-skill outcomes are collected\n // into the report, which exits non-zero when any skill failed.\n let mut report = SkillsUpdateReport {\n updated: Vec::new(),\n skipped: Vec::new(),\n failed: Vec::new(),\n };\n for skill_name in names {\n match market.find(&skill_name) {\n Some(entry) => {\n let result = match SkillSource::try_from(&entry.source) {\n Ok(parsed) => {\n install::install(&parsed, &target, true, None, Some(&skill_name)).await\n }\n Err(e) => Err(e),\n };\n match result {\n Ok(outcome) => report.updated.push(UpdatedSkill {\n name: outcome.name,\n dest: outcome.dest.display().to_string(),\n }),\n Err(e) => report.failed.push(FailedSkill {\n name: skill_name,\n error: e.to_string(),\n }),\n }\n }\n None => report.skipped.push(SkippedSkill {\n name: skill_name,\n reason: format!(\"not in registry {base}\"),\n }),\n }\n }\n Ok(report)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn init_writes_skip_auth_true_config() {\n let dir = std::env::temp_dir().join(format!(\n \"brinit-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n tokio::fs::create_dir_all(&dir).await.unwrap();\n let path = dir.join(\"bitrouter.yaml\");\n assert_eq!(\n write_starter_config(&path, false).await.unwrap(),\n ScaffoldOutcome::Wrote\n );\n let written = tokio::fs::read_to_string(&path).await.unwrap();\n assert!(written.contains(\"skip_auth: true\"));\n // and it parses back into a valid Config\n let cfg = bitrouter_sdk::config::parse_with(&written, |_| Some(\"x\".to_string())).unwrap();\n assert!(cfg.server.skip_auth);\n // Without --force, an existing file is left untouched (Skipped) — the\n // wizard's `--yes` run must never clobber a tuned config.\n tokio::fs::write(&path, \"# hand-edited\\n\").await.unwrap();\n assert_eq!(\n write_starter_config(&path, false).await.unwrap(),\n ScaffoldOutcome::Skipped\n );\n assert_eq!(\n tokio::fs::read_to_string(&path).await.unwrap(),\n \"# hand-edited\\n\"\n );\n // With --force it overwrites.\n assert_eq!(\n write_starter_config(&path, true).await.unwrap(),\n ScaffoldOutcome::Wrote\n );\n assert!(\n tokio::fs::read_to_string(&path)\n .await\n .unwrap()\n .contains(\"skip_auth: true\")\n );\n let _ = tokio::fs::remove_dir_all(&dir).await;\n }\n\n #[tokio::test]\n async fn key_sign_produces_brvk_secret_and_persists_only_hash() {\n let key = key_sign(\"sqlite::memory:\", \"u1\", Some(\"p1\")).await.unwrap();\n assert!(key.secret.starts_with(\"brvk_\"));\n assert!(key.id.starts_with(\"brvk_id_\"));\n }\n\n /// Regression for the out-of-the-box `bitrouter init` → `start` failure:\n /// the starter config ships a *commented* `opencode-go` example that\n /// references `${OPENCODE_GO_KEY_A/B}`. The loader expands `${VAR}` over the\n /// raw text before YAML parsing, so it used to error on those unset vars\n /// even though they live in a comment. With comment-aware substitution the\n /// starter config must load with **no** environment variables set.\n #[test]\n fn starter_config_loads_with_all_env_vars_unset() {\n let cfg = bitrouter_sdk::config::parse_with(STARTER_CONFIG, |_| None)\n .expect(\"starter config must load with no env vars set\");\n assert!(cfg.server.skip_auth);\n }\n\n /// Regression: the `mcp_servers:` and `agents:` blocks in\n /// `STARTER_CONFIG` are commented out by default, but if a user\n /// uncomments them the result must still parse against the current\n /// `Config` schema. This test strips the leading `# ` from those\n /// blocks and reparses; future schema drift fails CI here rather\n /// than fails the next user's `bitrouter init`.\n #[test]\n fn starter_config_parses_with_mcp_and_agents_examples_uncommented() {\n let uncommented: String = STARTER_CONFIG\n .lines()\n .map(|line| {\n // Uncomment any line inside the mcp_servers: / agents:\n // blocks. Those blocks are characterised by lines that\n // start with \"# \" followed by either the block-header\n // keyword or a key-value pair shaped like YAML.\n let trimmed = line.trim_start();\n if trimmed.starts_with(\"# mcp_servers:\")\n || trimmed.starts_with(\"# agents:\")\n || trimmed.starts_with(\"# \")\n || trimmed.starts_with(\"# \")\n || trimmed.starts_with(\"# \")\n {\n let leading_spaces = line.len() - trimmed.len();\n let stripped = trimmed\n .strip_prefix(\"# \")\n .or_else(|| trimmed.strip_prefix(\"#\"))\n .unwrap_or(trimmed);\n format!(\"{}{}\", \" \".repeat(leading_spaces), stripped)\n } else {\n line.to_string()\n }\n })\n .collect::>()\n .join(\"\\n\");\n let cfg = bitrouter_sdk::config::parse_with(&uncommented, |_| Some(\"x\".to_string()))\n .expect(\"uncommented STARTER_CONFIG must parse against current schema\");\n assert!(\n !cfg.mcp_servers.is_empty(),\n \"mcp_servers block should land as parsed entries; got: {:?}\",\n cfg.mcp_servers.keys().collect::>()\n );\n assert!(\n !cfg.agents.is_empty(),\n \"agents block should land as parsed entries; got: {:?}\",\n cfg.agents.keys().collect::>()\n );\n }\n\n /// Build a [`ProviderEntry`] from a registry-provider fixture — anthropic\n /// and openai-codex are no longer compiled-in built-ins, so `login` resolves\n /// their auth shape from the fetched registry via `entry_from_registry`.\n fn entry_for(json: serde_json::Value) -> bitrouter_providers::ProviderEntry {\n let p: bitrouter_providers::registry::types::RegistryProvider =\n serde_json::from_value(json).expect(\"valid registry-provider fixture\");\n bitrouter_providers::builtin::entry_from_registry(&p).expect(\"maps to an entry\")\n }\n\n #[test]\n fn claude_code_prefers_claude_code_session_first() {\n // The `claude-code` subscription provider — the live Claude Code session\n // is its default (and the marker lands under `claude-code`).\n let entry = entry_for(serde_json::json!({\n \"name\": \"claude-code\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"claude-code\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n assert_eq!(\n methods.first(),\n Some(&AuthMethod::ClaudeCodeSession),\n \"the live Claude Code session must be the default for claude-code\"\n );\n assert!(\n !methods.contains(&AuthMethod::ImportFromCli),\n \"claude-code uses the live session, not the one-shot copy that can family-revoke\"\n );\n }\n\n #[test]\n fn anthropic_offers_api_key_only() {\n // `anthropic` is now platform / pay-as-you-go: API-key paste only — no\n // Claude Code session, no subscription PKCE, no vendor-CLI import. The\n // subscription path moved to the `claude-code` provider.\n let entry = entry_for(serde_json::json!({\n \"name\": \"anthropic\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"header\", \"header\": \"x-api-key\", \"env\": \"ANTHROPIC_API_KEY\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n assert_eq!(\n methods,\n vec![AuthMethod::ApiKey],\n \"anthropic must offer the API-key path only\"\n );\n assert!(!methods.contains(&AuthMethod::ClaudeCodeSession));\n assert!(!methods.contains(&AuthMethod::PkceSubscription));\n assert!(!methods.contains(&AuthMethod::ImportFromCli));\n }\n\n #[test]\n fn codex_keeps_oneshot_import_and_offers_no_claude_code_session() {\n let entry = entry_for(serde_json::json!({\n \"name\": \"openai-codex\",\n \"api_base\": \"https://chatgpt.com/backend-api/codex\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"openai-codex\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n assert_eq!(\n methods.first(),\n Some(&AuthMethod::ImportFromCli),\n \"openai-codex onboarding should first reuse an existing Codex CLI session\"\n );\n assert!(methods.contains(&AuthMethod::ImportFromCli));\n assert!(methods.contains(&AuthMethod::PkceSubscription));\n assert!(!methods.contains(&AuthMethod::ClaudeCodeSession));\n }\n\n #[test]\n fn import_existing_selects_vendor_cli_import_without_prompting() {\n let entry = entry_for(serde_json::json!({\n \"name\": \"openai-codex\",\n \"api_base\": \"https://chatgpt.com/backend-api/codex\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"openai-codex\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let chosen = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: true,\n no_browser: true,\n api_key: None,\n },\n )\n .unwrap();\n assert_eq!(chosen, AuthMethod::ImportFromCli);\n }\n\n #[test]\n fn api_key_option_selects_api_key_method_without_prompting() {\n // A `--api-key`/`--key-stdin`-supplied key drives the non-interactive\n // BYOK path: `anthropic` offers the API-key method, so it's chosen.\n let entry = entry_for(serde_json::json!({\n \"name\": \"anthropic\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"header\", \"header\": \"x-api-key\", \"env\": \"ANTHROPIC_API_KEY\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let chosen = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: false,\n no_browser: false,\n api_key: Some(\"sk-ant-test\".to_string()),\n },\n )\n .unwrap();\n assert_eq!(chosen, AuthMethod::ApiKey);\n }\n\n #[test]\n fn api_key_option_rejects_oauth_only_providers() {\n // `openai-codex` is OAuth-only (no API-key method); a supplied key is a\n // clear error rather than a silently-ignored flag.\n let entry = entry_for(serde_json::json!({\n \"name\": \"openai-codex\",\n \"api_base\": \"https://chatgpt.com/backend-api/codex\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"openai-codex\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let err = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: false,\n no_browser: false,\n api_key: Some(\"sk-whatever\".to_string()),\n },\n )\n .unwrap_err();\n assert!(format!(\"{err:#}\").contains(\"does not accept a pasted API key\"));\n }\n\n #[test]\n fn import_existing_rejects_providers_without_vendor_cli_import() {\n let entry = entry_for(serde_json::json!({\n \"name\": \"anthropic\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"header\", \"header\": \"x-api-key\", \"env\": \"ANTHROPIC_API_KEY\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let err = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: true,\n no_browser: true,\n api_key: None,\n },\n )\n .unwrap_err();\n assert!(format!(\"{err:#}\").contains(\"cannot import\"));\n }\n\n fn sample_config() -> Config {\n let yaml = r#\"\nproviders:\n openai:\n api_base: https://api.openai.com/v1\n api_key: k1\n models: [{ id: gpt-5 }, { id: shared }]\n anthropic:\n api_base: https://api.anthropic.com/v1\n api_key: k2\n models: [{ id: shared }]\n\"#;\n bitrouter_sdk::config::parse_with(yaml, |_| None).unwrap()\n }\n\n #[tokio::test]\n async fn list_models_filters_by_provider() {\n let cfg = sample_config();\n let all = list_models(&cfg, None).await.unwrap();\n assert_eq!(all.len(), 2); // gpt-5, shared\n let openai_only = list_models(&cfg, Some(\"openai\")).await.unwrap();\n assert_eq!(openai_only.len(), 2); // gpt-5 + shared (openai declares both)\n let anthropic_only = list_models(&cfg, Some(\"anthropic\")).await.unwrap();\n assert_eq!(anthropic_only.len(), 1); // only `shared`\n assert_eq!(anthropic_only[0].0, \"shared\");\n }\n\n #[tokio::test]\n async fn resolve_route_returns_the_cascade_chain() {\n let cfg = sample_config();\n // `shared` is declared by both providers → a 2-hop alphabetical chain\n let chain = resolve_route(&cfg, \"shared\").await.unwrap();\n assert_eq!(chain.len(), 2);\n assert_eq!(chain[0].provider, \"anthropic\");\n assert_eq!(chain[1].provider, \"openai\");\n // an unknown model is a clean error (no DEFAULT_PROVIDER fallback)\n assert!(resolve_route(&cfg, \"no-such-model\").await.is_err());\n }\n\n #[test]\n fn list_providers_sorts_and_summarises() {\n let providers = list_providers(&sample_config());\n assert_eq!(providers.len(), 2);\n assert_eq!(providers[0].id, \"anthropic\");\n assert_eq!(providers[1].id, \"openai\");\n assert_eq!(providers[1].model_count, 2);\n assert!(providers[1].active);\n }\n\n #[tokio::test]\n async fn create_policy_rejects_path_traversal_ids() {\n let dir = std::env::temp_dir().join(format!(\n \"brpoltrav-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n // Path separators, parent refs, dotfiles, control chars — all rejected\n // before any file is written.\n for bad in [\n \"../etc/passwd\",\n \"foo/bar\",\n \"..\",\n \".hidden\",\n \"\",\n \"a b\",\n \"x\\0y\",\n ] {\n let err = create_policy(&dir, bad).await.unwrap_err();\n let msg = format!(\"{err}\");\n assert!(\n msg.contains(\"invalid characters\")\n || msg.contains(\"must not\")\n || msg.contains(\"empty\"),\n \"bad id '{bad}' should have been rejected; got: {msg}\"\n );\n }\n // The dir was never created (because every call bailed before touching fs).\n // (We don't assert this strongly — a parallel test could create it — just clean up.)\n let _ = tokio::fs::remove_dir_all(&dir).await;\n }\n\n #[tokio::test]\n async fn create_policy_writes_a_starter_file() {\n let dir = std::env::temp_dir().join(format!(\n \"brpol-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n let path = create_policy(&dir, \"team-default\").await.unwrap();\n let body = tokio::fs::read_to_string(&path).await.unwrap();\n assert!(body.contains(\"id: team-default\"));\n // a re-create refuses to overwrite\n assert!(create_policy(&dir, \"team-default\").await.is_err());\n let _ = tokio::fs::remove_dir_all(&dir).await;\n }\n}\n"}, "122de07d23f09f3fedf4d7034c615a1add6f6bf6": {"t": 1785674696.6502438, "n": 62553, "h": "0e6e6cbbc3916f4c", "c": "//! Management command logic — the implementations behind the CLI subcommands.\n//! Kept in the lib so it is testable and so a future desktop front-end can\n//! reuse it without dragging in the binary.\n\nuse anyhow::{Context, Result};\n\nuse bitrouter_sdk::caller::CallerContext;\nuse bitrouter_sdk::config::{Config, ConfigRoutingTable};\nuse bitrouter_sdk::language_model::{RoutingPrefs, RoutingTable};\n\nuse crate::auth::{NewApiKey, db as auth_db, generate};\nuse crate::daemon::RouteHop;\nuse crate::output::reports::skills::{\n FailedSkill, SkillAddReport, SkillEntry, SkillHit, SkillInitReport, SkillRemoveReport,\n SkillsFindReport, SkillsListReport, SkillsUpdateReport, SkippedSkill, UpdatedSkill,\n};\n\n/// The starter `bitrouter.yaml` written by `bitrouter init`. Mirrors\n/// the zero-config in-memory default so a user who runs `init` and\n/// then deletes nothing gets the same behaviour as running bitrouter\n/// with no config at all — only the file is here to be edited.\npub const STARTER_CONFIG: &str = r#\"# bitrouter.yaml — generated by `bitrouter init`.\n#\n# This file mirrors the zero-config in-memory default. Delete any\n# section you don't want to override, or add custom providers /\n# guardrails / policies / MCP servers as needed.\n\nserver:\n # Bind localhost only by default — combined with `skip_auth: true`,\n # exposing `0.0.0.0` would let any host on the network use bitrouter\n # without a credential check. Flip to `0.0.0.0:4356` only when you've\n # also set `skip_auth: false` and minted virtual keys with\n # `bitrouter key sign`.\n listen: \"127.0.0.1:4356\"\n # skip_auth=true admits credential-less local requests (cc-switch /\n # litellm style). Set to false and create keys with `bitrouter key\n # sign` for a multi-tenant deployment.\n skip_auth: true\n\ndatabase:\n # Relative paths are interpreted against the bitrouter home (the\n # directory containing this file) — the daemon chdirs there on\n # startup, so `./bitrouter.db` always lands alongside the config.\n url: \"sqlite://./bitrouter.db\"\n\n# Routing policies are opt-in and live in a sibling policy-lock.yaml.\n# `bitrouter policy init --preset --strong \n# --economy ` creates the lock and preset binding. With no binding,\n# model routing behaves exactly as it does in zero-config mode.\n\n# Built-in providers auto-enable when their env var is set. An entry\n# with `inherit_defaults: true` (the workspace default) plus an empty\n# body is enough — `api_base`, `api_protocol`, `api_key`, and the\n# routable models all flow from the built-in catalog. Comment a line\n# out (or set `active: false`) to disable a provider you don't want.\nproviders:\n openai: {} # uses OPENAI_API_KEY\n anthropic: {} # uses ANTHROPIC_API_KEY\n google: {} # uses GEMINI_API_KEY\n openrouter: {} # uses OPENROUTER_API_KEY\n # github-copilot needs `bitrouter providers login github-copilot` first:\n # github-copilot: {}\n # A provider can hold multiple accounts — e.g. two subscriptions to\n # the same upstream. `account_strategy` is `failover` (try the first,\n # drop to the next on a retryable / out-of-credits error — default)\n # or `balance` (spread load evenly across accounts):\n # opencode-go: { account_strategy: failover, accounts: [{ api_key: \"${OPENCODE_GO_KEY_A}\", label: primary }, { api_key: \"${OPENCODE_GO_KEY_B}\", label: backup }] }\n\n# Upstream MCP (Model Context Protocol) servers — `bitrouter tools\n# list/status` reads this, and `POST /mcp/` proxies JSON-RPC\n# against the entry. Discover servers from the official MCP registry\n# with `bitrouter mcp search ` and print a paste-ready stub with\n# `bitrouter mcp add `. Spec:\n# https://modelcontextprotocol.io/specification/2025-06-18\n# mcp_servers:\n# ctx7:\n# name: ctx7\n# transport:\n# type: http\n# url: https://mcp.context7.com/mcp\n# headers:\n# Authorization: \"Bearer ${CTX7_TOKEN}\"\n# git:\n# name: git\n# transport:\n# type: stdio\n# command: uvx\n# args: [\"mcp-server-git\"]\n\n# Upstream ACP (Agent Client Protocol) agents — manage with\n# `bitrouter agents list` / `check` / `install`. Spec:\n# https://agentclientprotocol.com/protocol/schema\n# agents:\n# claude:\n# name: claude\n# transport:\n# type: stdio\n# command: npx\n# args: [\"-y\", \"@zed-industries/claude-code-acp@latest\"]\n# codex:\n# name: codex\n# transport:\n# type: stdio\n# command: npx\n# args: [\"-y\", \"@zed-industries/codex-acp@latest\"]\n\ninherit_defaults: true\n\"#;\n\n/// Outcome of a [`write_starter_config`] scaffold write.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum ScaffoldOutcome {\n /// The starter `bitrouter.yaml` was written.\n Wrote,\n /// A file already existed and `force` was not set — left untouched.\n Skipped,\n}\n\n/// `bitrouter init` — write the starter `bitrouter.yaml` to `path`. Without\n/// `force`, an existing file is left untouched (returns\n/// [`ScaffoldOutcome::Skipped`]) so the onboarding wizard's `--yes` run never\n/// clobbers a config the user already tuned; with `force` it overwrites. This\n/// is the single sanctioned `bitrouter.yaml` write — nothing else in the tree\n/// serializes a `Config`.\npub async fn write_starter_config(path: &std::path::Path, force: bool) -> Result {\n if !force && tokio::fs::try_exists(path).await.unwrap_or(false) {\n return Ok(ScaffoldOutcome::Skipped);\n }\n tokio::fs::write(path, STARTER_CONFIG)\n .await\n .with_context(|| format!(\"writing {}\", path.display()))?;\n Ok(ScaffoldOutcome::Wrote)\n}\n\n/// The outcome of `bitrouter key generate` — the plaintext secret is shown to\n/// the user **once** and never persisted (only its SHA-256 hash is stored).\npub struct GeneratedKey {\n /// The api key id.\n pub id: String,\n /// The plaintext `brvk_…` secret — display once, then discard.\n pub secret: String,\n}\n\n/// `bitrouter key sign` — mint a `brvk_` virtual key for `user_id`, persist only\n/// its hash, and return the plaintext once. v1 does **not** sign a JWT (004\n/// §3.0); the command keeps the `sign` name but creates a DB-backed virtual key.\npub async fn key_sign(\n db_url: &str,\n user_id: &str,\n policy_id: Option<&str>,\n) -> Result {\n let db = crate::db::connect(db_url)\n .await\n .with_context(|| format!(\"connecting to database {db_url}\"))?;\n crate::db::run_migrations(&db)\n .await\n .context(\"running database migrations\")?;\n auth_db::upsert_user(&db, user_id)\n .await\n .context(\"creating user row\")?;\n\n let key = generate();\n let id = format!(\"brvk_id_{}\", &key.hash[..16]);\n auth_db::insert_api_key(\n &db,\n &NewApiKey {\n id: id.clone(),\n key_hash: key.hash.clone(),\n user_id: user_id.to_string(),\n spend_limit_micro_usd: None,\n rpm_limit: None,\n policy_id: policy_id.map(|s| s.to_string()),\n },\n )\n .await\n .context(\"inserting api key\")?;\n\n Ok(GeneratedKey {\n id,\n secret: key.secret,\n })\n}\n\n/// `bitrouter models [--provider ]` — list routable models, optionally\n/// filtered to those a given provider declares.\npub async fn list_models(\n config: &Config,\n provider_filter: Option<&str>,\n) -> Result)>> {\n let mut resolved = config.clone();\n bitrouter_providers::apply_builtin_defaults(&mut resolved);\n // Best-effort `/models` discovery for any provider with\n // `auto_discover: true`. Zero-config built-ins set the flag, so\n // this turns `bitrouter models` from \"empty\" into a useful catalog\n // when the user has the API key set. Failures log and leave the\n // provider with no models — no error bubbles to the user.\n bitrouter_sdk::config::discover_models(&mut resolved).await;\n let table = ConfigRoutingTable::from_config(resolved);\n Ok(table\n .list_models()\n .into_iter()\n .filter(|m| match provider_filter {\n Some(p) => m.providers.iter().any(|x| x == p),\n None => true,\n })\n .map(|m| (m.id, m.providers))\n .collect())\n}\n\n/// `bitrouter route ` — resolve a model name through the routing table,\n/// **standalone** (no running daemon needed). Returns the fallback chain.\npub async fn resolve_route(config: &Config, model: &str) -> Result> {\n let mut resolved = config.clone();\n bitrouter_providers::apply_builtin_defaults(&mut resolved);\n let table = ConfigRoutingTable::from_config(resolved);\n let chain = table\n .route_chain(model, &RoutingPrefs::default(), &CallerContext::local())\n .await\n .with_context(|| format!(\"resolving model '{model}'\"))?;\n Ok(chain\n .into_iter()\n .map(|t| RouteHop {\n provider: t.provider_name,\n service_id: t.service_id,\n api_protocol: format!(\"{:?}\", t.api_protocol).to_lowercase(),\n })\n .collect())\n}\n\n/// One provider entry for `bitrouter providers list`.\npub struct ProviderListing {\n /// Provider id.\n pub id: String,\n /// Upstream API base.\n pub api_base: String,\n /// Whether the provider is active / routable.\n pub active: bool,\n /// Number of declared models.\n pub model_count: usize,\n}\n\n/// `bitrouter providers list` — list every configured provider.\n/// Applies built-in catalog defaults first so a zero-config or\n/// minimally-declared provider (`openai: {}`) shows its real `api_base`\n/// and `active` state instead of empty placeholders.\npub fn list_providers(config: &Config) -> Vec {\n let mut resolved = config.clone();\n bitrouter_providers::apply_builtin_defaults(&mut resolved);\n let mut providers: Vec = resolved\n .providers\n .iter()\n .map(|(id, p)| ProviderListing {\n id: id.clone(),\n api_base: p.api_base.clone(),\n active: p.active,\n model_count: p.models.len(),\n })\n .collect();\n providers.sort_by(|a, b| a.id.cmp(&b.id));\n providers\n}\n\n/// Reject a policy id that would escape its directory or hide as a dotfile.\n/// Allowed: ASCII letters / digits / `_` / `-` / `.` (not as the leading char).\nfn validate_policy_id(id: &str) -> Result<()> {\n if id.is_empty() {\n anyhow::bail!(\"policy id must not be empty\");\n }\n if id.starts_with('.') {\n anyhow::bail!(\"policy id must not start with '.' (no dotfiles / `..`)\");\n }\n if id.chars().any(|c| {\n c == '/'\n || c == '\\\\'\n || c == '\\0'\n || c.is_whitespace()\n || !(c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')\n }) {\n anyhow::bail!(\n \"policy id '{id}' contains invalid characters (allowed: letters, digits, `_`, `-`, `.`)\"\n );\n }\n Ok(())\n}\n\n/// `bitrouter policy create` — write a starter policy file into `policy_dir`.\n/// Refuses to overwrite an existing policy of the same id. The `id` must be a\n/// plain filename (no path separators, no `..`, no leading dot) so the joined\n/// path cannot escape `policy_dir`.\npub async fn create_policy(policy_dir: &std::path::Path, id: &str) -> Result {\n validate_policy_id(id)?;\n tokio::fs::create_dir_all(policy_dir)\n .await\n .with_context(|| format!(\"creating policy dir {}\", policy_dir.display()))?;\n let path = policy_dir.join(format!(\"{id}.yaml\"));\n if tokio::fs::try_exists(&path).await.unwrap_or(false) {\n anyhow::bail!(\"policy '{id}' already exists at {}\", path.display());\n }\n let body = format!(\n \"# policy '{id}' — edit to taste, then bind it to a key:\\n\\\n # bitrouter key sign --user --policy {id}\\n\\\n id: {id}\\n\\\n # allowed_models: [gpt-5, claude-sonnet-4-6]\\n\\\n # denied_models: []\\n\\\n # max_spend_micro_usd: 1000000\\n\\\n # max_requests_per_minute: 60\\n\\\n # allowed_tools: [search]\\n\"\n );\n tokio::fs::write(&path, body)\n .await\n .with_context(|| format!(\"writing {}\", path.display()))?;\n Ok(path)\n}\n\n/// One auth method offered for a provider — derived from the catalog +\n/// the PKCE registry. The CLI shows users only the methods the provider\n/// actually supports.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum AuthMethod {\n /// Use the user's existing Claude Code session: read its OAuth credential\n /// live from `~/.claude` (Keychain / `.credentials.json`), or sign in via\n /// the `claude` CLI when not yet logged in. The preferred path for\n /// `anthropic` — no second token copy, so it can't refresh-rotate Claude\n /// Code out (RFC 6749 §6).\n ClaudeCodeSession,\n /// Browser-based PKCE Authorization Code flow — Anthropic Claude\n /// Pro/Max, OpenAI Codex / ChatGPT.\n PkceSubscription,\n /// Adopt an existing session from the provider's own vendor CLI (Claude\n /// Code, Codex) by reading its on-disk / Keychain OAuth token — no browser.\n ImportFromCli,\n /// RFC 8628 Device Authorization Grant — GitHub Copilot's flow.\n DeviceCode,\n /// User pastes a static API key (sk-…, sk-ant-…).\n ApiKey,\n}\n\nimpl AuthMethod {\n fn label(self) -> &'static str {\n match self {\n AuthMethod::ClaudeCodeSession => {\n \"Use your Claude Code session (read ~/.claude, or sign in via the claude CLI)\"\n }\n AuthMethod::PkceSubscription => \"Subscription (browser sign-in)\",\n AuthMethod::ImportFromCli => \"Import an existing session from the vendor CLI\",\n AuthMethod::DeviceCode => \"Device-code OAuth (show a code in browser)\",\n AuthMethod::ApiKey => \"API key (paste a static credential)\",\n }\n }\n}\n\n/// The vendor CLI bitrouter can adopt an existing OAuth session from, for\n/// providers that ship one — `Codex` for `openai-codex`. `None` for every\n/// other provider.\n///\n/// `anthropic` is **not** listed: it is now the platform / pay-as-you-go\n/// provider (`x-api-key` only). Adopting a Claude Code session is the\n/// `claude-code` subscription provider's job, handled by\n/// [`AuthMethod::ClaudeCodeSession`] (a *live* session read, not a one-shot\n/// token copy), so there is no vendor-CLI import for `anthropic`.\nfn import_cli_for(provider_id: &str) -> Option<&'static str> {\n match provider_id {\n bitrouter_providers::codex::PROVIDER_ID => Some(\"Codex\"),\n bitrouter_providers::supergrok::PROVIDER_ID => Some(\"Grok\"),\n bitrouter_providers::antigravity::PROVIDER_ID => Some(\"Antigravity (agy)\"),\n _ => None,\n }\n}\n\n/// Decide which auth methods are wired for `entry`. Order matters — the\n/// first entry is what runs when the user hits enter on the prompt.\nfn available_methods(entry: &bitrouter_providers::ProviderEntry) -> Vec {\n use bitrouter_providers::AuthScheme;\n let mut methods = Vec::new();\n // The Claude subscription's preferred (and only) path: read the user's live\n // Claude Code session and let the `claude` CLI own login. It belongs to the\n // dedicated `claude-code` subscription provider — `anthropic` is now the\n // platform / pay-as-you-go provider and offers the API-key path only.\n // Listed first so it's the default on / non-interactive runs.\n let is_claude_code = entry.id == bitrouter_providers::claude_code::PROVIDER_ID;\n if is_claude_code {\n methods.push(AuthMethod::ClaudeCodeSession);\n }\n // Providers with a sibling vendor CLI can adopt its existing session as a\n // one-shot copy — currently only Codex (`openai-codex`). List that before\n // PKCE so the default path reuses an already-authenticated local CLI\n // session when one exists.\n if import_cli_for(&entry.id).is_some() {\n methods.push(AuthMethod::ImportFromCli);\n }\n // `anthropic` is platform / pay-as-you-go: offer the API-key path only. The\n // PKCE registry still carries an `anthropic` entry (the Claude subscription\n // browser flow), but that subscription path now belongs to the dedicated\n // `claude-code` provider, so it must not be offered under `anthropic`.\n let is_anthropic = entry.id == bitrouter_providers::anthropic::PROVIDER_ID;\n let has_pkce = !is_anthropic && bitrouter_providers::oauth::registry::has_pkce_flow(&entry.id);\n if has_pkce {\n methods.push(AuthMethod::PkceSubscription);\n }\n match &entry.auth {\n AuthScheme::Bearer { .. } | AuthScheme::Header { .. } => {\n methods.push(AuthMethod::ApiKey);\n }\n AuthScheme::Oauth { params, .. } => {\n // Device-code is only viable when the TOML carries the\n // device authorization endpoint — PKCE-only providers\n // (`openai-codex`) skip this even though their auth scheme\n // is `oauth`.\n let has_device = params\n .get(\"device_authorization_endpoint\")\n .and_then(|v| v.as_str())\n .is_some();\n if has_device {\n methods.push(AuthMethod::DeviceCode);\n }\n // OAuth-only providers don't accept pasted API keys (the\n // Codex backend literally rejects sk-… credentials), so\n // there's no API-key fallback to offer.\n }\n AuthScheme::Native { .. } => {}\n }\n methods\n}\n\n/// Prompt for an integer choice between `[1, options.len()]`. Empty\n/// input (or just ) picks option 1. Invalid input (non-number,\n/// out-of-range) prints a one-line error and re-prompts; a true EOF on\n/// stdin (read_line returns 0 bytes) bails so the caller doesn't loop\n/// forever in non-interactive contexts.\nfn prompt_method_choice(provider: &str, options: &[AuthMethod]) -> Result {\n use std::io::BufRead;\n eprintln!();\n eprintln!(\"How would you like to authenticate to {provider}?\");\n for (i, m) in options.iter().enumerate() {\n eprintln!(\" {}) {}\", i + 1, m.label());\n }\n let stdin = std::io::stdin();\n let mut handle = stdin.lock();\n loop {\n eprint!(\"Choose [1]: \");\n let mut line = String::new();\n let n_bytes = handle\n .read_line(&mut line)\n .context(\"reading method choice from stdin\")?;\n if n_bytes == 0 {\n anyhow::bail!(\"stdin closed before a choice was made\");\n }\n let trimmed = line.trim();\n if trimmed.is_empty() {\n return Ok(options[0]);\n }\n match trimmed.parse::() {\n Ok(n) if (1..=options.len()).contains(&n) => return Ok(options[n - 1]),\n Ok(n) => eprintln!(\" choice must be between 1 and {}, got {n}\", options.len()),\n Err(_) => eprintln!(\" '{trimmed}' is not a number\"),\n }\n }\n}\n\n/// `bitrouter providers login [--label ]` — interactive\n/// credential setup.\n///\n/// Resolves which auth methods are wired for `provider_id` (subscription\n/// PKCE / device-code / API key paste), prompts the user to pick when\n/// there's more than one, runs the chosen flow, and persists the\n/// resulting [`bitrouter_providers::oauth::credential_store::Credential`]\n/// under `(provider_id, label)`.\n/// What `login_provider` accomplished — surfaced as the CLI's JSON result while\n/// the interactive prompts and confirmation go to stderr.\npub struct LoginOutcome {\n pub provider: String,\n pub label: String,\n pub method: String,\n pub path: String,\n}\n\n/// Non-interactive provider-login controls.\n#[derive(Debug, Clone, Default)]\npub struct ProviderLoginOptions {\n /// Require importing an already-authenticated vendor CLI session.\n pub import_existing: bool,\n /// Refuse browser-based PKCE flows.\n pub no_browser: bool,\n /// Seed the credential directly from this API key, skipping the interactive\n /// method choice and the stdin paste entirely. Set by `providers login\n /// --api-key` / `--key-stdin` and the onboarding wizard's per-provider\n /// `--provider-api-key`. The provider must offer the API-key method.\n pub api_key: Option,\n}\n\npub async fn login_provider(provider_id: &str, label: &str) -> Result {\n login_provider_with_options(provider_id, label, ProviderLoginOptions::default()).await\n}\n\npub async fn login_provider_with_options(\n provider_id: &str,\n label: &str,\n options: ProviderLoginOptions,\n) -> Result {\n use bitrouter_providers::builtin;\n\n // The `bitrouter` cloud gateway is compiled in; every other provider's\n // auth shape (handler + public OAuth params) comes from the fetched-or-\n // cached registry — the same mapper the built-ins once used.\n let entry: bitrouter_providers::ProviderEntry = match builtin::find(provider_id) {\n Some(e) => e.clone(),\n None => {\n let data = bitrouter_providers::registry::apply::load_or_cached(\n &bitrouter_sdk::config::RegistryConfig::default(),\n )\n .await\n .ok_or_else(|| {\n anyhow::anyhow!(\n \"could not load the provider registry (offline, and nothing cached) \\\n to resolve '{provider_id}'\"\n )\n })?;\n let provider = data\n .providers\n .iter()\n .find(|p| p.name == provider_id)\n .ok_or_else(|| {\n anyhow::anyhow!(\n \"unknown provider '{provider_id}' — try `bitrouter providers list` \\\n for the catalog\"\n )\n })?;\n builtin::entry_from_registry(provider)\n .with_context(|| format!(\"resolving the auth shape for '{provider_id}'\"))?\n }\n };\n let methods = available_methods(&entry);\n if methods.is_empty() {\n anyhow::bail!(\n \"provider '{provider_id}' has no interactive login path \\\n (auth scheme: {:?})\",\n std::mem::discriminant(&entry.auth)\n );\n }\n let chosen = choose_auth_method(&entry, &methods, &options)?;\n eprintln!();\n eprintln!(\n \" Logging in to {} via {}\",\n entry.display_name,\n chosen.label()\n );\n\n let credential = match chosen {\n AuthMethod::ClaudeCodeSession => run_claude_code_session().await?,\n AuthMethod::PkceSubscription => run_pkce_subscription(provider_id).await?,\n AuthMethod::ImportFromCli => run_cli_import(provider_id)?,\n AuthMethod::DeviceCode => run_device_code(provider_id, &entry).await?,\n // A flag-supplied key (`--api-key` / `--key-stdin` / the wizard) skips\n // the interactive stdin paste; otherwise prompt for it.\n AuthMethod::ApiKey => match options.api_key.as_deref() {\n Some(key) => {\n let key = key.trim();\n if key.is_empty() {\n anyhow::bail!(\"the supplied API key is empty — aborting login\");\n }\n bitrouter_providers::oauth::credential_store::Credential::api_key(key)\n }\n None => run_api_key_paste(&entry.display_name)?,\n },\n };\n\n let mut store = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()\n .context(\"opening credential store\")?;\n let kind = credential.kind_label();\n store\n .set(provider_id, label, credential)\n .with_context(|| format!(\"persisting credential for {provider_id} ({label})\"))?;\n eprintln!(\n \" ✓ Saved {kind} credential for {provider_id} (label: {label}) at {}\",\n store.path().display()\n );\n eprintln!();\n Ok(LoginOutcome {\n provider: provider_id.to_string(),\n label: label.to_string(),\n method: kind.to_string(),\n path: store.path().display().to_string(),\n })\n}\n\nfn choose_auth_method(\n entry: &bitrouter_providers::ProviderEntry,\n methods: &[AuthMethod],\n options: &ProviderLoginOptions,\n) -> Result {\n // A supplied API key is the most explicit signal — take the API-key method\n // directly, or fail clearly if the provider doesn't offer one (OAuth-only\n // backends reject pasted keys).\n if options.api_key.is_some() {\n if methods.contains(&AuthMethod::ApiKey) {\n return Ok(AuthMethod::ApiKey);\n }\n anyhow::bail!(\n \"provider '{}' does not accept a pasted API key — it uses an \\\n interactive OAuth flow; drop --api-key/--key-stdin\",\n entry.id\n );\n }\n\n if options.import_existing {\n if methods.contains(&AuthMethod::ImportFromCli) {\n return Ok(AuthMethod::ImportFromCli);\n }\n anyhow::bail!(\n \"provider '{}' cannot import an existing vendor CLI session\",\n entry.id\n );\n }\n\n let selectable: Vec = if options.no_browser {\n methods\n .iter()\n .copied()\n .filter(|m| *m != AuthMethod::PkceSubscription)\n .collect()\n } else {\n methods.to_vec()\n };\n if selectable.is_empty() {\n anyhow::bail!(\n \"provider '{}' has no non-browser login path; rerun without --no-browser\",\n entry.id\n );\n }\n if selectable.len() == 1 || options.no_browser {\n Ok(selectable[0])\n } else {\n prompt_method_choice(&entry.display_name, &selectable)\n }\n}\n\n/// Adopt the user's existing Claude Code session as the live credential source\n/// for the `claude-code` subscription provider.\n///\n/// Resolution order, mirroring how Claude Code users actually authenticate:\n/// 1. If a Claude Code session already exists in `~/.claude` (macOS Keychain /\n/// `.credentials.json`), use it as-is — the daemon reads it live and\n/// refreshes in place.\n/// 2. Otherwise sign the user in with the `claude` CLI itself (`claude auth\n/// login --claudeai`), installing it first (the same native installer\n/// `bitrouter spawn` uses) when it's missing, then re-read.\n///\n/// Returns a [`Credential::ClaudeCodeCli`] marker — no token is copied into\n/// bitrouter's own store, so bitrouter and Claude Code share one credential and\n/// can't refresh-rotate each other out (RFC 6749 §6). `login_provider` stores\n/// this marker under the `provider_id` it was invoked with, so\n/// `bitrouter providers login claude-code` lands it under `claude-code`.\nasync fn run_claude_code_session()\n-> Result {\n use std::io::IsTerminal;\n\n use bitrouter_providers::import::claude_code::ClaudeCodeStore;\n use bitrouter_providers::oauth::credential_store::Credential;\n\n // Adopt a legacy #590 marker stored under `anthropic` before checking the\n // live session, so a returning user's existing sign-in is recognised under\n // the new `claude-code` provider.\n crate::claude_code::migrate_legacy_anthropic_marker_default();\n\n let store = ClaudeCodeStore::system().ok_or_else(|| {\n anyhow::anyhow!(\n \"could not resolve your home directory (set HOME) to find the Claude Code session\"\n )\n })?;\n\n // 1. Already signed in to Claude Code?\n if let Some(live) = store\n .read()\n .context(\"reading your existing Claude Code session\")?\n {\n eprintln!(\n \" Found your Claude Code session ({}) — bitrouter will read it live.\",\n live.source\n );\n return Ok(Credential::ClaudeCodeCli);\n }\n\n // 2. Not signed in — drive the `claude` CLI's own login.\n if !std::io::stdin().is_terminal() {\n anyhow::bail!(\n \"not signed in to Claude Code. Run `claude auth login` (install the CLI first with \\\n `curl -fsSL https://claude.ai/install.sh | bash`), then re-run \\\n `bitrouter providers login claude-code`.\"\n );\n }\n\n let claude = crate::spawn::ensure_agent_installed(crate::spawn::SpawnAgent::Claude, false)\n .await\n .context(\"locating the claude CLI to sign you in\")?;\n\n eprintln!(\" You're not signed in to Claude Code yet — launching `claude auth login`.\");\n let status = tokio::process::Command::new(&claude)\n .arg(\"auth\")\n .arg(\"login\")\n .arg(\"--claudeai\")\n .status()\n .await\n .context(\"running `claude auth login`\")?;\n if !status.success() {\n anyhow::bail!(\n \"`claude auth login` didn't complete — sign in, then re-run \\\n `bitrouter providers login claude-code`.\"\n );\n }\n\n match store\n .read()\n .context(\"re-reading your Claude Code session after sign-in\")?\n {\n Some(live) => {\n eprintln!(\n \" Signed in — bitrouter will read your session live from {}.\",\n live.source\n );\n Ok(Credential::ClaudeCodeCli)\n }\n None => anyhow::bail!(\n \"still no Claude Code session after `claude auth login` — run `claude auth status` \\\n to check, then retry.\"\n ),\n }\n}\n\n/// Run the browser-based PKCE Authorization Code flow against the\n/// PKCE-registered provider. Errors when the provider isn't in\n/// `bitrouter_providers::oauth::registry`.\nasync fn run_pkce_subscription(\n provider_id: &str,\n) -> Result {\n let registry = bitrouter_providers::oauth::registry::find(provider_id).ok_or_else(|| {\n anyhow::anyhow!(\n \"provider '{provider_id}' has no PKCE login registration — \\\n this should have been caught by available_methods()\"\n )\n })?;\n let client = reqwest::Client::builder()\n .user_agent(concat!(\"bitrouter/\", env!(\"CARGO_PKG_VERSION\")))\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .context(\"building HTTP client for PKCE login\")?;\n let ux = StderrLoginUx;\n let outcome = bitrouter_providers::oauth::login::run_login(\n &client,\n ®istry,\n &ux,\n bitrouter_providers::oauth::auth_code::MANUAL_PASTE_TIMEOUT,\n )\n .await\n .with_context(|| format!(\"PKCE login for {provider_id}\"))?;\n if outcome.manual_fallback_used {\n eprintln!(\" (used manual paste fallback)\");\n }\n Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(outcome.token))\n}\n\n/// Run the RFC 8628 Device Authorization Grant — the existing\n/// `github-copilot` flow, lifted out of the old `login_provider`.\nasync fn run_device_code(\n provider_id: &str,\n entry: &bitrouter_providers::ProviderEntry,\n) -> Result {\n use bitrouter_providers::AuthScheme;\n let params = match &entry.auth {\n AuthScheme::Oauth { params, .. } => params,\n _ => anyhow::bail!(\"provider '{provider_id}' is not OAuth — refusing device flow\"),\n };\n let client_id = params\n .get(\"client_id\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| anyhow::anyhow!(\"'{provider_id}': auth.params.client_id is missing\"))?\n .to_string();\n let scope = params\n .get(\"scope\")\n .and_then(|v| v.as_str())\n .map(str::to_string);\n let device_url = params\n .get(\"device_authorization_endpoint\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n anyhow::anyhow!(\"'{provider_id}': auth.params.device_authorization_endpoint is missing\")\n })?\n .to_string();\n let token_url = params\n .get(\"token_endpoint\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| anyhow::anyhow!(\"'{provider_id}': auth.params.token_endpoint is missing\"))?\n .to_string();\n\n let flow = bitrouter_providers::oauth::DeviceCodeFlow::new(\n bitrouter_providers::oauth::DeviceCodeParams {\n client_id,\n scope,\n device_authorization_endpoint: device_url,\n token_endpoint: token_url,\n },\n )\n .with_context(|| format!(\"building OAuth flow for {provider_id}\"))?;\n\n let device_code = flow\n .request_device_code()\n .await\n .with_context(|| format!(\"requesting OAuth device code for {provider_id}\"))?;\n\n eprintln!();\n eprintln!(\" OAuth device authorization for {}\", entry.display_name);\n eprintln!(\" ──────────────────────────────────────────\");\n if let Some(complete) = &device_code.verification_uri_complete {\n eprintln!(\" Open: {complete}\");\n } else {\n eprintln!(\" Open: {}\", device_code.verification_uri);\n }\n eprintln!(\" Code: {}\", device_code.user_code);\n eprintln!();\n eprintln!(\" Waiting for authorization…\");\n\n let mut interval = std::time::Duration::from_secs(device_code.interval.max(1));\n let token = loop {\n tokio::time::sleep(interval).await;\n match flow.poll_once(&device_code.device_code).await? {\n bitrouter_providers::oauth::device_code::PollOutcome::Token(t) => break t,\n bitrouter_providers::oauth::device_code::PollOutcome::Pending => continue,\n bitrouter_providers::oauth::device_code::PollOutcome::SlowDown => {\n // RFC 8628 §3.5: server requested back-off — add 5s.\n interval += std::time::Duration::from_secs(5);\n }\n }\n };\n Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(token))\n}\n\n/// Adopt an existing OAuth session from the provider's sibling vendor CLI\n/// (Codex for `openai-codex`) by reading its on-disk / Keychain credential.\n/// Errors when no such session is found.\n///\n/// `anthropic` is intentionally absent: it is the platform / pay-as-you-go\n/// provider now, and the Claude Code session is adopted *live* by the\n/// `claude-code` provider via [`run_claude_code_session`] — not copied in here\n/// (the one-shot copy is what risked the RFC 6749 §6 family-revoke).\nfn run_cli_import(\n provider_id: &str,\n) -> Result {\n let imported = match provider_id {\n bitrouter_providers::codex::PROVIDER_ID => bitrouter_providers::import::codex::import(),\n bitrouter_providers::supergrok::PROVIDER_ID => bitrouter_providers::import::grok::import(),\n bitrouter_providers::antigravity::PROVIDER_ID => {\n bitrouter_providers::import::antigravity::import()\n }\n other => anyhow::bail!(\"no vendor-CLI import is available for provider '{other}'\"),\n }\n .with_context(|| format!(\"importing a CLI credential for {provider_id}\"))?;\n let imported = imported.ok_or_else(|| {\n anyhow::anyhow!(\n \"no existing CLI session found for {provider_id} — sign in to the \\\n vendor CLI first, or choose the browser sign-in instead\"\n )\n })?;\n eprintln!(\" Imported existing session from {}\", imported.source);\n Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(imported.token))\n}\n\n/// Read a static API key from stdin. Input is **not** masked — the user\n/// sees what they're pasting, which is the simplest correct UX and\n/// matches `gh auth login --with-token`. Future work could wire\n/// `rpassword` for a hidden prompt.\nfn run_api_key_paste(\n display_name: &str,\n) -> Result {\n use std::io::BufRead;\n eprintln!();\n eprintln!(\" Paste your {display_name} API key, then press enter:\");\n eprint!(\" > \");\n let stdin = std::io::stdin();\n let mut line = String::new();\n stdin\n .lock()\n .read_line(&mut line)\n .context(\"reading API key from stdin\")?;\n let key = line.trim();\n if key.is_empty() {\n anyhow::bail!(\"no API key entered — aborting login\");\n }\n Ok(bitrouter_providers::oauth::credential_store::Credential::api_key(key))\n}\n\n/// `LoginUx` implementation that drives stderr + stdin. Used by the\n/// `bitrouter providers login` CLI; library tests provide their own scripted\n/// UX.\nstruct StderrLoginUx;\n\n#[async_trait::async_trait]\nimpl bitrouter_providers::oauth::login::LoginUx for StderrLoginUx {\n async fn show_authorize_url(&self, url: &str, hint: &str) {\n eprintln!();\n eprintln!(\" Open this URL in your browser to continue:\");\n eprintln!();\n eprintln!(\" {url}\");\n eprintln!();\n eprintln!(\" {hint}\");\n }\n\n async fn prompt_pasted_redirect(\n &self,\n ) -> std::result::Result {\n // The login flow races this future against the loopback\n // listener. If the listener wins we never read stdin, so a\n // blocking read here would normally pin a thread for the whole\n // OAuth round trip. Run the read on a blocking thread so\n // cancellation (the `tokio::select!` in `run_login` dropping\n // this future when the listener finishes) actually frees it.\n let pasted = tokio::task::spawn_blocking(|| {\n use std::io::BufRead;\n eprint!(\" Paste redirect URL (or just the code): \");\n let stdin = std::io::stdin();\n let mut line = String::new();\n stdin.lock().read_line(&mut line).map_err(|e| {\n bitrouter_providers::oauth::login::LoginError::UserIo(format!(\n \"reading pasted redirect: {e}\"\n ))\n })?;\n Ok::<_, bitrouter_providers::oauth::login::LoginError>(line)\n })\n .await\n .map_err(|e| {\n bitrouter_providers::oauth::login::LoginError::UserIo(format!(\n \"join error reading pasted redirect: {e}\"\n ))\n })??;\n Ok(pasted)\n }\n}\n\n/// `bitrouter providers logout ` — drop every stored credential for\n/// the provider (subscription OAuth or pasted API key), if any.\npub async fn logout_provider(provider_id: &str) -> Result {\n let mut store = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()\n .context(\"opening credential store\")?;\n let removed = store\n .remove_all_for(provider_id)\n .with_context(|| format!(\"removing stored credentials for {provider_id}\"))?;\n match removed {\n 0 => eprintln!(\" No stored credentials for {provider_id}; nothing to remove.\"),\n n => eprintln!(\" ✓ Removed {n} stored credential(s) for {provider_id}.\"),\n }\n Ok(removed)\n}\n\n// ===== skills (client installer) =====\n\n/// Default BitRouter registry queried when `bitrouter skills add ` is\n/// given a bare skill name rather than a git source.\nconst DEFAULT_SKILLS_REGISTRY: &str = \"https://api.bitrouter.ai\";\n\n/// Build the HTTP client used to talk to a skills registry. Mirrors the\n/// 30-second-timeout, user-agent-tagged client the cloud SDK uses.\nfn skills_http_client() -> Result {\n reqwest::Client::builder()\n .user_agent(concat!(\"bitrouter-skills/\", env!(\"CARGO_PKG_VERSION\")))\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .context(\"building skills HTTP client\")\n}\n\n/// Resolve the install target for the `--global` flag.\nfn skills_target(global: bool) -> Result {\n use bitrouter_skills::install::InstallTarget;\n if global {\n Ok(InstallTarget::Global)\n } else {\n let cwd = std::env::current_dir().context(\"resolving the current directory\")?;\n Ok(InstallTarget::Project { project_root: cwd })\n }\n}\n\n/// Whether `source` is a bare registry skill name (no path/scheme), as opposed\n/// to a git source.\nfn is_bare_skill_name(source: &str) -> bool {\n !source.contains('/') && !source.contains(\"://\") && !source.starts_with(\"git@\")\n}\n\n/// Resolve a registry name to its [`bitrouter_skills::source::SkillSource`] via\n/// the namespace's marketplace hub.\nasync fn resolve_registry_source(\n name: &str,\n registry: Option<&str>,\n namespace: &str,\n) -> Result {\n use bitrouter_skills::marketplace;\n use bitrouter_skills::source::SkillSource;\n let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);\n let client = skills_http_client()?;\n let market = marketplace::fetch_marketplace(&client, base, namespace).await?;\n let entry = market\n .find(name)\n .ok_or_else(|| anyhow::anyhow!(\"skill {name:?} not found in registry {base}\"))?;\n SkillSource::try_from(&entry.source)\n .with_context(|| format!(\"resolving registry source for {name:?}\"))\n}\n\n/// `bitrouter skills add` — install a skill from a git source or registry name.\npub async fn skills_add(\n source: &str,\n select: Option<&str>,\n global: bool,\n overwrite: bool,\n registry: Option<&str>,\n namespace: Option<&str>,\n) -> Result {\n use bitrouter_skills::{install, source as skill_source};\n\n if source.trim().is_empty() {\n anyhow::bail!(\n \"a skill source is required (owner/repo, a git URL, a local path, or a registry name)\"\n );\n }\n\n let target = skills_target(global)?;\n let parsed = if is_bare_skill_name(source) {\n let nsid = namespace\n .ok_or_else(|| anyhow::anyhow!(\"--namespace is required to query a registry\"))?;\n resolve_registry_source(source, registry, nsid).await?\n } else {\n skill_source::parse_source(source)?\n };\n let outcome = install::install(&parsed, &target, overwrite, select, None).await?;\n Ok(SkillAddReport {\n action: if outcome.was_updated {\n \"updated\"\n } else {\n \"installed\"\n },\n name: outcome.name,\n dest: outcome.dest.display().to_string(),\n })\n}\n\n/// `bitrouter skills list` — show installed skills.\npub fn skills_list(global: bool) -> Result {\n use bitrouter_skills::install;\n let target = skills_target(global)?;\n let skills = install::list_installed(&target)?\n .into_iter()\n .map(|(name, path)| SkillEntry {\n name,\n path: path.display().to_string(),\n })\n .collect();\n Ok(SkillsListReport { skills })\n}\n\n/// `bitrouter skills remove` — uninstall a skill by name.\npub fn skills_remove(name: &str, global: bool) -> Result {\n use bitrouter_skills::install;\n let target = skills_target(global)?;\n let dir = install::remove(name, &target)?;\n Ok(SkillRemoveReport {\n name: name.to_string(),\n path: dir.display().to_string(),\n removed: true,\n })\n}\n\n/// `bitrouter skills find` — search a registry.\npub async fn skills_find(\n query: &str,\n registry: Option<&str>,\n namespace: Option<&str>,\n) -> Result {\n use bitrouter_skills::marketplace;\n let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);\n let nsid = namespace\n .ok_or_else(|| anyhow::anyhow!(\"--namespace is required to query a registry\"))?;\n let client = skills_http_client()?;\n let market = marketplace::fetch_marketplace(&client, base, nsid).await?;\n let results = market\n .search(query)\n .into_iter()\n .map(|entry| SkillHit {\n name: entry.name.clone(),\n version: entry.version.clone().unwrap_or_else(|| \"-\".to_string()),\n description: entry.description.clone().unwrap_or_default(),\n })\n .collect();\n Ok(SkillsFindReport {\n query: query.to_string(),\n registry: base.to_string(),\n results,\n })\n}\n\n/// `bitrouter skills init` — scaffold a SKILL.md.\npub fn skills_init(name: &str, output: &std::path::Path) -> Result {\n bitrouter_skills::install::validate_skill_name(name)?;\n if output.exists() {\n anyhow::bail!(\"{} already exists; refusing to overwrite\", output.display());\n }\n let body = format!(\n \"---\\nname: {name}\\ndescription: TODO — one line on what this skill does and when to use it.\\n---\\n\\n# {name}\\n\\nTODO: instructions for the agent.\\n\\n## When to use\\n\\n## Steps\\n\"\n );\n std::fs::write(output, body).with_context(|| format!(\"writing {}\", output.display()))?;\n Ok(SkillInitReport {\n path: output.display().to_string(),\n created: true,\n })\n}\n\n/// `bitrouter skills update` — re-install installed skills from the registry.\npub async fn skills_update(\n name: Option<&str>,\n global: bool,\n registry: Option<&str>,\n namespace: Option<&str>,\n) -> Result {\n use bitrouter_skills::install;\n use bitrouter_skills::marketplace;\n use bitrouter_skills::source::SkillSource;\n\n let target = skills_target(global)?;\n let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);\n\n // `update` only re-installs skills that are actually installed; an explicit\n // name must already be present (it does not double as an installer).\n let installed: Vec = install::list_installed(&target)?\n .into_iter()\n .map(|(n, _)| n)\n .collect();\n let names: Vec = match name {\n Some(n) => {\n if !installed.iter().any(|i| i == n) {\n anyhow::bail!(\"skill {n:?} is not installed; use `bitrouter skills add` first\");\n }\n vec![n.to_string()]\n }\n None => installed,\n };\n if names.is_empty() {\n return Ok(SkillsUpdateReport {\n updated: Vec::new(),\n skipped: Vec::new(),\n failed: Vec::new(),\n });\n }\n\n let nsid = namespace\n .ok_or_else(|| anyhow::anyhow!(\"--namespace is required to query a registry\"))?;\n let client = skills_http_client()?;\n let market = marketplace::fetch_marketplace(&client, base, nsid).await?;\n\n // Update each skill independently: one failure must not abort the rest.\n // Re-install into the same directory so a skill stays put even if its\n // upstream frontmatter name has drifted. Per-skill outcomes are collected\n // into the report, which exits non-zero when any skill failed.\n let mut report = SkillsUpdateReport {\n updated: Vec::new(),\n skipped: Vec::new(),\n failed: Vec::new(),\n };\n for skill_name in names {\n match market.find(&skill_name) {\n Some(entry) => {\n let result = match SkillSource::try_from(&entry.source) {\n Ok(parsed) => {\n install::install(&parsed, &target, true, None, Some(&skill_name)).await\n }\n Err(e) => Err(e),\n };\n match result {\n Ok(outcome) => report.updated.push(UpdatedSkill {\n name: outcome.name,\n dest: outcome.dest.display().to_string(),\n }),\n Err(e) => report.failed.push(FailedSkill {\n name: skill_name,\n error: e.to_string(),\n }),\n }\n }\n None => report.skipped.push(SkippedSkill {\n name: skill_name,\n reason: format!(\"not in registry {base}\"),\n }),\n }\n }\n Ok(report)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn init_writes_skip_auth_true_config() {\n let dir = std::env::temp_dir().join(format!(\n \"brinit-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n tokio::fs::create_dir_all(&dir).await.unwrap();\n let path = dir.join(\"bitrouter.yaml\");\n assert_eq!(\n write_starter_config(&path, false).await.unwrap(),\n ScaffoldOutcome::Wrote\n );\n let written = tokio::fs::read_to_string(&path).await.unwrap();\n assert!(written.contains(\"skip_auth: true\"));\n // and it parses back into a valid Config\n let cfg = bitrouter_sdk::config::parse_with(&written, |_| Some(\"x\".to_string())).unwrap();\n assert!(cfg.server.skip_auth);\n // Without --force, an existing file is left untouched (Skipped) — the\n // wizard's `--yes` run must never clobber a tuned config.\n tokio::fs::write(&path, \"# hand-edited\\n\").await.unwrap();\n assert_eq!(\n write_starter_config(&path, false).await.unwrap(),\n ScaffoldOutcome::Skipped\n );\n assert_eq!(\n tokio::fs::read_to_string(&path).await.unwrap(),\n \"# hand-edited\\n\"\n );\n // With --force it overwrites.\n assert_eq!(\n write_starter_config(&path, true).await.unwrap(),\n ScaffoldOutcome::Wrote\n );\n assert!(\n tokio::fs::read_to_string(&path)\n .await\n .unwrap()\n .contains(\"skip_auth: true\")\n );\n let _ = tokio::fs::remove_dir_all(&dir).await;\n }\n\n #[tokio::test]\n async fn key_sign_produces_brvk_secret_and_persists_only_hash() {\n let key = key_sign(\"sqlite::memory:\", \"u1\", Some(\"p1\")).await.unwrap();\n assert!(key.secret.starts_with(\"brvk_\"));\n assert!(key.id.starts_with(\"brvk_id_\"));\n }\n\n /// Regression for the out-of-the-box `bitrouter init` → `start` failure:\n /// the starter config ships a *commented* `opencode-go` example that\n /// references `${OPENCODE_GO_KEY_A/B}`. The loader expands `${VAR}` over the\n /// raw text before YAML parsing, so it used to error on those unset vars\n /// even though they live in a comment. With comment-aware substitution the\n /// starter config must load with **no** environment variables set.\n #[test]\n fn starter_config_loads_with_all_env_vars_unset() {\n let cfg = bitrouter_sdk::config::parse_with(STARTER_CONFIG, |_| None)\n .expect(\"starter config must load with no env vars set\");\n assert!(cfg.server.skip_auth);\n }\n\n /// Regression: the `mcp_servers:` and `agents:` blocks in\n /// `STARTER_CONFIG` are commented out by default, but if a user\n /// uncomments them the result must still parse against the current\n /// `Config` schema. This test strips the leading `# ` from those\n /// blocks and reparses; future schema drift fails CI here rather\n /// than fails the next user's `bitrouter init`.\n #[test]\n fn starter_config_parses_with_mcp_and_agents_examples_uncommented() {\n let uncommented: String = STARTER_CONFIG\n .lines()\n .map(|line| {\n // Uncomment any line inside the mcp_servers: / agents:\n // blocks. Those blocks are characterised by lines that\n // start with \"# \" followed by either the block-header\n // keyword or a key-value pair shaped like YAML.\n let trimmed = line.trim_start();\n if trimmed.starts_with(\"# mcp_servers:\")\n || trimmed.starts_with(\"# agents:\")\n || trimmed.starts_with(\"# \")\n || trimmed.starts_with(\"# \")\n || trimmed.starts_with(\"# \")\n {\n let leading_spaces = line.len() - trimmed.len();\n let stripped = trimmed\n .strip_prefix(\"# \")\n .or_else(|| trimmed.strip_prefix(\"#\"))\n .unwrap_or(trimmed);\n format!(\"{}{}\", \" \".repeat(leading_spaces), stripped)\n } else {\n line.to_string()\n }\n })\n .collect::>()\n .join(\"\\n\");\n let cfg = bitrouter_sdk::config::parse_with(&uncommented, |_| Some(\"x\".to_string()))\n .expect(\"uncommented STARTER_CONFIG must parse against current schema\");\n assert!(\n !cfg.mcp_servers.is_empty(),\n \"mcp_servers block should land as parsed entries; got: {:?}\",\n cfg.mcp_servers.keys().collect::>()\n );\n assert!(\n !cfg.agents.is_empty(),\n \"agents block should land as parsed entries; got: {:?}\",\n cfg.agents.keys().collect::>()\n );\n }\n\n /// Build a [`ProviderEntry`] from a registry-provider fixture — anthropic\n /// and openai-codex are no longer compiled-in built-ins, so `login` resolves\n /// their auth shape from the fetched registry via `entry_from_registry`.\n fn entry_for(json: serde_json::Value) -> bitrouter_providers::ProviderEntry {\n let p: bitrouter_providers::registry::types::RegistryProvider =\n serde_json::from_value(json).expect(\"valid registry-provider fixture\");\n bitrouter_providers::builtin::entry_from_registry(&p).expect(\"maps to an entry\")\n }\n\n #[test]\n fn claude_code_prefers_claude_code_session_first() {\n // The `claude-code` subscription provider — the live Claude Code session\n // is its default (and the marker lands under `claude-code`).\n let entry = entry_for(serde_json::json!({\n \"name\": \"claude-code\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"claude-code\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n assert_eq!(\n methods.first(),\n Some(&AuthMethod::ClaudeCodeSession),\n \"the live Claude Code session must be the default for claude-code\"\n );\n assert!(\n !methods.contains(&AuthMethod::ImportFromCli),\n \"claude-code uses the live session, not the one-shot copy that can family-revoke\"\n );\n }\n\n #[test]\n fn anthropic_offers_api_key_only() {\n // `anthropic` is now platform / pay-as-you-go: API-key paste only — no\n // Claude Code session, no subscription PKCE, no vendor-CLI import. The\n // subscription path moved to the `claude-code` provider.\n let entry = entry_for(serde_json::json!({\n \"name\": \"anthropic\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"header\", \"header\": \"x-api-key\", \"env\": \"ANTHROPIC_API_KEY\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n assert_eq!(\n methods,\n vec![AuthMethod::ApiKey],\n \"anthropic must offer the API-key path only\"\n );\n assert!(!methods.contains(&AuthMethod::ClaudeCodeSession));\n assert!(!methods.contains(&AuthMethod::PkceSubscription));\n assert!(!methods.contains(&AuthMethod::ImportFromCli));\n }\n\n #[test]\n fn codex_keeps_oneshot_import_and_offers_no_claude_code_session() {\n let entry = entry_for(serde_json::json!({\n \"name\": \"openai-codex\",\n \"api_base\": \"https://chatgpt.com/backend-api/codex\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"openai-codex\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n assert_eq!(\n methods.first(),\n Some(&AuthMethod::ImportFromCli),\n \"openai-codex onboarding should first reuse an existing Codex CLI session\"\n );\n assert!(methods.contains(&AuthMethod::ImportFromCli));\n assert!(methods.contains(&AuthMethod::PkceSubscription));\n assert!(!methods.contains(&AuthMethod::ClaudeCodeSession));\n }\n\n #[test]\n fn import_existing_selects_vendor_cli_import_without_prompting() {\n let entry = entry_for(serde_json::json!({\n \"name\": \"openai-codex\",\n \"api_base\": \"https://chatgpt.com/backend-api/codex\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"openai-codex\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let chosen = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: true,\n no_browser: true,\n api_key: None,\n },\n )\n .unwrap();\n assert_eq!(chosen, AuthMethod::ImportFromCli);\n }\n\n #[test]\n fn api_key_option_selects_api_key_method_without_prompting() {\n // A `--api-key`/`--key-stdin`-supplied key drives the non-interactive\n // BYOK path: `anthropic` offers the API-key method, so it's chosen.\n let entry = entry_for(serde_json::json!({\n \"name\": \"anthropic\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"header\", \"header\": \"x-api-key\", \"env\": \"ANTHROPIC_API_KEY\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let chosen = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: false,\n no_browser: false,\n api_key: Some(\"sk-ant-test\".to_string()),\n },\n )\n .unwrap();\n assert_eq!(chosen, AuthMethod::ApiKey);\n }\n\n #[test]\n fn api_key_option_rejects_oauth_only_providers() {\n // `openai-codex` is OAuth-only (no API-key method); a supplied key is a\n // clear error rather than a silently-ignored flag.\n let entry = entry_for(serde_json::json!({\n \"name\": \"openai-codex\",\n \"api_base\": \"https://chatgpt.com/backend-api/codex\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"openai-codex\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let err = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: false,\n no_browser: false,\n api_key: Some(\"sk-whatever\".to_string()),\n },\n )\n .unwrap_err();\n assert!(format!(\"{err:#}\").contains(\"does not accept a pasted API key\"));\n }\n\n #[test]\n fn import_existing_rejects_providers_without_vendor_cli_import() {\n let entry = entry_for(serde_json::json!({\n \"name\": \"anthropic\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"header\", \"header\": \"x-api-key\", \"env\": \"ANTHROPIC_API_KEY\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let err = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: true,\n no_browser: true,\n api_key: None,\n },\n )\n .unwrap_err();\n assert!(format!(\"{err:#}\").contains(\"cannot import\"));\n }\n\n fn sample_config() -> Config {\n let yaml = r#\"\nproviders:\n openai:\n api_base: https://api.openai.com/v1\n api_key: k1\n models: [{ id: gpt-5 }, { id: shared }]\n anthropic:\n api_base: https://api.anthropic.com/v1\n api_key: k2\n models: [{ id: shared }]\n\"#;\n bitrouter_sdk::config::parse_with(yaml, |_| None).unwrap()\n }\n\n #[tokio::test]\n async fn list_models_filters_by_provider() {\n let cfg = sample_config();\n let all = list_models(&cfg, None).await.unwrap();\n assert_eq!(all.len(), 2); // gpt-5, shared\n let openai_only = list_models(&cfg, Some(\"openai\")).await.unwrap();\n assert_eq!(openai_only.len(), 2); // gpt-5 + shared (openai declares both)\n let anthropic_only = list_models(&cfg, Some(\"anthropic\")).await.unwrap();\n assert_eq!(anthropic_only.len(), 1); // only `shared`\n assert_eq!(anthropic_only[0].0, \"shared\");\n }\n\n #[tokio::test]\n async fn resolve_route_returns_the_cascade_chain() {\n let cfg = sample_config();\n // `shared` is declared by both providers → a 2-hop alphabetical chain\n let chain = resolve_route(&cfg, \"shared\").await.unwrap();\n assert_eq!(chain.len(), 2);\n assert_eq!(chain[0].provider, \"anthropic\");\n assert_eq!(chain[1].provider, \"openai\");\n // an unknown model is a clean error (no DEFAULT_PROVIDER fallback)\n assert!(resolve_route(&cfg, \"no-such-model\").await.is_err());\n }\n\n #[test]\n fn list_providers_sorts_and_summarises() {\n let providers = list_providers(&sample_config());\n assert_eq!(providers.len(), 2);\n assert_eq!(providers[0].id, \"anthropic\");\n assert_eq!(providers[1].id, \"openai\");\n assert_eq!(providers[1].model_count, 2);\n assert!(providers[1].active);\n }\n\n #[tokio::test]\n async fn create_policy_rejects_path_traversal_ids() {\n let dir = std::env::temp_dir().join(format!(\n \"brpoltrav-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n // Path separators, parent refs, dotfiles, control chars — all rejected\n // before any file is written.\n for bad in [\n \"../etc/passwd\",\n \"foo/bar\",\n \"..\",\n \".hidden\",\n \"\",\n \"a b\",\n \"x\\0y\",\n ] {\n let err = create_policy(&dir, bad).await.unwrap_err();\n let msg = format!(\"{err}\");\n assert!(\n msg.contains(\"invalid characters\")\n || msg.contains(\"must not\")\n || msg.contains(\"empty\"),\n \"bad id '{bad}' should have been rejected; got: {msg}\"\n );\n }\n // The dir was never created (because every call bailed before touching fs).\n // (We don't assert this strongly — a parallel test could create it — just clean up.)\n let _ = tokio::fs::remove_dir_all(&dir).await;\n }\n\n #[tokio::test]\n async fn create_policy_writes_a_starter_file() {\n let dir = std::env::temp_dir().join(format!(\n \"brpol-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n let path = create_policy(&dir, \"team-default\").await.unwrap();\n let body = tokio::fs::read_to_string(&path).await.unwrap();\n assert!(body.contains(\"id: team-default\"));\n // a re-create refuses to overwrite\n assert!(create_policy(&dir, \"team-default\").await.is_err());\n let _ = tokio::fs::remove_dir_all(&dir).await;\n }\n}\n"}, "004ca3550a8594f59e272d7e405642be4dd572d5": {"t": 1785846948.4192727, "n": 62867, "h": "49ff4f52fce8dc04", "c": "//! Management command logic — the implementations behind the CLI subcommands.\n//! Kept in the lib so it is testable and so a future desktop front-end can\n//! reuse it without dragging in the binary.\n\nuse anyhow::{Context, Result};\n\nuse bitrouter_sdk::caller::CallerContext;\nuse bitrouter_sdk::config::{Config, ConfigRoutingTable};\nuse bitrouter_sdk::language_model::{RoutingPrefs, RoutingTable};\n\nuse crate::auth::{NewApiKey, db as auth_db, generate};\nuse crate::daemon::RouteHop;\nuse crate::output::reports::skills::{\n FailedSkill, SkillAddReport, SkillEntry, SkillHit, SkillInitReport, SkillRemoveReport,\n SkillsFindReport, SkillsListReport, SkillsUpdateReport, SkippedSkill, UpdatedSkill,\n};\n\n/// The starter `bitrouter.yaml` written by `bitrouter init`. Mirrors\n/// the zero-config in-memory default so a user who runs `init` and\n/// then deletes nothing gets the same behaviour as running bitrouter\n/// with no config at all — only the file is here to be edited.\npub const STARTER_CONFIG: &str = r#\"# bitrouter.yaml — generated by `bitrouter init`.\n#\n# This file mirrors the zero-config in-memory default. Delete any\n# section you don't want to override, or add custom providers /\n# guardrails / policies / MCP servers as needed.\n\nserver:\n # Bind localhost only by default — combined with `skip_auth: true`,\n # exposing `0.0.0.0` would let any host on the network use bitrouter\n # without a credential check. Flip to `0.0.0.0:4356` only when you've\n # also set `skip_auth: false` and minted virtual keys with\n # `bitrouter key sign`.\n listen: \"127.0.0.1:4356\"\n # skip_auth=true admits credential-less local requests (cc-switch /\n # litellm style). Set to false and create keys with `bitrouter key\n # sign` for a multi-tenant deployment.\n skip_auth: true\n\n# Retryable fallback is immediate by default. For providers that can return a\n# short overload burst, opt into a bounded delay schedule. Values apply before\n# the 2nd, 3rd, ... route candidate; the final value repeats for longer chains.\n# upstream:\n# fallback_backoff_ms: [1000, 2000, 4000, 8000, 16000, 30000]\n\ndatabase:\n # Relative paths are interpreted against the bitrouter home (the\n # directory containing this file) — the daemon chdirs there on\n # startup, so `./bitrouter.db` always lands alongside the config.\n url: \"sqlite://./bitrouter.db\"\n\n# Routing policies are opt-in and live in a sibling policy-lock.yaml.\n# `bitrouter policy init --preset --strong \n# --economy ` creates the lock and preset binding. With no binding,\n# model routing behaves exactly as it does in zero-config mode.\n\n# Built-in providers auto-enable when their env var is set. An entry\n# with `inherit_defaults: true` (the workspace default) plus an empty\n# body is enough — `api_base`, `api_protocol`, `api_key`, and the\n# routable models all flow from the built-in catalog. Comment a line\n# out (or set `active: false`) to disable a provider you don't want.\nproviders:\n openai: {} # uses OPENAI_API_KEY\n anthropic: {} # uses ANTHROPIC_API_KEY\n google: {} # uses GEMINI_API_KEY\n openrouter: {} # uses OPENROUTER_API_KEY\n # github-copilot needs `bitrouter providers login github-copilot` first:\n # github-copilot: {}\n # A provider can hold multiple accounts — e.g. two subscriptions to\n # the same upstream. `account_strategy` is `failover` (try the first,\n # drop to the next on a retryable / out-of-credits error — default)\n # or `balance` (spread load evenly across accounts):\n # opencode-go: { account_strategy: failover, accounts: [{ api_key: \"${OPENCODE_GO_KEY_A}\", label: primary }, { api_key: \"${OPENCODE_GO_KEY_B}\", label: backup }] }\n\n# Upstream MCP (Model Context Protocol) servers — `bitrouter tools\n# list/status` reads this, and `POST /mcp/` proxies JSON-RPC\n# against the entry. Discover servers from the official MCP registry\n# with `bitrouter mcp search ` and print a paste-ready stub with\n# `bitrouter mcp add `. Spec:\n# https://modelcontextprotocol.io/specification/2025-06-18\n# mcp_servers:\n# ctx7:\n# name: ctx7\n# transport:\n# type: http\n# url: https://mcp.context7.com/mcp\n# headers:\n# Authorization: \"Bearer ${CTX7_TOKEN}\"\n# git:\n# name: git\n# transport:\n# type: stdio\n# command: uvx\n# args: [\"mcp-server-git\"]\n\n# Upstream ACP (Agent Client Protocol) agents — manage with\n# `bitrouter agents list` / `check` / `install`. Spec:\n# https://agentclientprotocol.com/protocol/schema\n# agents:\n# claude:\n# name: claude\n# transport:\n# type: stdio\n# command: npx\n# args: [\"-y\", \"@zed-industries/claude-code-acp@latest\"]\n# codex:\n# name: codex\n# transport:\n# type: stdio\n# command: npx\n# args: [\"-y\", \"@zed-industries/codex-acp@latest\"]\n\ninherit_defaults: true\n\"#;\n\n/// Outcome of a [`write_starter_config`] scaffold write.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum ScaffoldOutcome {\n /// The starter `bitrouter.yaml` was written.\n Wrote,\n /// A file already existed and `force` was not set — left untouched.\n Skipped,\n}\n\n/// `bitrouter init` — write the starter `bitrouter.yaml` to `path`. Without\n/// `force`, an existing file is left untouched (returns\n/// [`ScaffoldOutcome::Skipped`]) so the onboarding wizard's `--yes` run never\n/// clobbers a config the user already tuned; with `force` it overwrites. This\n/// is the single sanctioned `bitrouter.yaml` write — nothing else in the tree\n/// serializes a `Config`.\npub async fn write_starter_config(path: &std::path::Path, force: bool) -> Result {\n if !force && tokio::fs::try_exists(path).await.unwrap_or(false) {\n return Ok(ScaffoldOutcome::Skipped);\n }\n tokio::fs::write(path, STARTER_CONFIG)\n .await\n .with_context(|| format!(\"writing {}\", path.display()))?;\n Ok(ScaffoldOutcome::Wrote)\n}\n\n/// The outcome of `bitrouter key generate` — the plaintext secret is shown to\n/// the user **once** and never persisted (only its SHA-256 hash is stored).\npub struct GeneratedKey {\n /// The api key id.\n pub id: String,\n /// The plaintext `brvk_…` secret — display once, then discard.\n pub secret: String,\n}\n\n/// `bitrouter key sign` — mint a `brvk_` virtual key for `user_id`, persist only\n/// its hash, and return the plaintext once. v1 does **not** sign a JWT (004\n/// §3.0); the command keeps the `sign` name but creates a DB-backed virtual key.\npub async fn key_sign(\n db_url: &str,\n user_id: &str,\n policy_id: Option<&str>,\n) -> Result {\n let db = crate::db::connect(db_url)\n .await\n .with_context(|| format!(\"connecting to database {db_url}\"))?;\n crate::db::run_migrations(&db)\n .await\n .context(\"running database migrations\")?;\n auth_db::upsert_user(&db, user_id)\n .await\n .context(\"creating user row\")?;\n\n let key = generate();\n let id = format!(\"brvk_id_{}\", &key.hash[..16]);\n auth_db::insert_api_key(\n &db,\n &NewApiKey {\n id: id.clone(),\n key_hash: key.hash.clone(),\n user_id: user_id.to_string(),\n spend_limit_micro_usd: None,\n rpm_limit: None,\n policy_id: policy_id.map(|s| s.to_string()),\n },\n )\n .await\n .context(\"inserting api key\")?;\n\n Ok(GeneratedKey {\n id,\n secret: key.secret,\n })\n}\n\n/// `bitrouter models [--provider ]` — list routable models, optionally\n/// filtered to those a given provider declares.\npub async fn list_models(\n config: &Config,\n provider_filter: Option<&str>,\n) -> Result)>> {\n let mut resolved = config.clone();\n bitrouter_providers::apply_builtin_defaults(&mut resolved);\n // Best-effort `/models` discovery for any provider with\n // `auto_discover: true`. Zero-config built-ins set the flag, so\n // this turns `bitrouter models` from \"empty\" into a useful catalog\n // when the user has the API key set. Failures log and leave the\n // provider with no models — no error bubbles to the user.\n bitrouter_sdk::config::discover_models(&mut resolved).await;\n let table = ConfigRoutingTable::from_config(resolved);\n Ok(table\n .list_models()\n .into_iter()\n .filter(|m| match provider_filter {\n Some(p) => m.providers.iter().any(|x| x == p),\n None => true,\n })\n .map(|m| (m.id, m.providers))\n .collect())\n}\n\n/// `bitrouter route ` — resolve a model name through the routing table,\n/// **standalone** (no running daemon needed). Returns the fallback chain.\npub async fn resolve_route(config: &Config, model: &str) -> Result> {\n let mut resolved = config.clone();\n bitrouter_providers::apply_builtin_defaults(&mut resolved);\n let table = ConfigRoutingTable::from_config(resolved);\n let chain = table\n .route_chain(model, &RoutingPrefs::default(), &CallerContext::local())\n .await\n .with_context(|| format!(\"resolving model '{model}'\"))?;\n Ok(chain\n .into_iter()\n .map(|t| RouteHop {\n provider: t.provider_name,\n service_id: t.service_id,\n api_protocol: format!(\"{:?}\", t.api_protocol).to_lowercase(),\n })\n .collect())\n}\n\n/// One provider entry for `bitrouter providers list`.\npub struct ProviderListing {\n /// Provider id.\n pub id: String,\n /// Upstream API base.\n pub api_base: String,\n /// Whether the provider is active / routable.\n pub active: bool,\n /// Number of declared models.\n pub model_count: usize,\n}\n\n/// `bitrouter providers list` — list every configured provider.\n/// Applies built-in catalog defaults first so a zero-config or\n/// minimally-declared provider (`openai: {}`) shows its real `api_base`\n/// and `active` state instead of empty placeholders.\npub fn list_providers(config: &Config) -> Vec {\n let mut resolved = config.clone();\n bitrouter_providers::apply_builtin_defaults(&mut resolved);\n let mut providers: Vec = resolved\n .providers\n .iter()\n .map(|(id, p)| ProviderListing {\n id: id.clone(),\n api_base: p.api_base.clone(),\n active: p.active,\n model_count: p.models.len(),\n })\n .collect();\n providers.sort_by(|a, b| a.id.cmp(&b.id));\n providers\n}\n\n/// Reject a policy id that would escape its directory or hide as a dotfile.\n/// Allowed: ASCII letters / digits / `_` / `-` / `.` (not as the leading char).\nfn validate_policy_id(id: &str) -> Result<()> {\n if id.is_empty() {\n anyhow::bail!(\"policy id must not be empty\");\n }\n if id.starts_with('.') {\n anyhow::bail!(\"policy id must not start with '.' (no dotfiles / `..`)\");\n }\n if id.chars().any(|c| {\n c == '/'\n || c == '\\\\'\n || c == '\\0'\n || c.is_whitespace()\n || !(c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')\n }) {\n anyhow::bail!(\n \"policy id '{id}' contains invalid characters (allowed: letters, digits, `_`, `-`, `.`)\"\n );\n }\n Ok(())\n}\n\n/// `bitrouter policy create` — write a starter policy file into `policy_dir`.\n/// Refuses to overwrite an existing policy of the same id. The `id` must be a\n/// plain filename (no path separators, no `..`, no leading dot) so the joined\n/// path cannot escape `policy_dir`.\npub async fn create_policy(policy_dir: &std::path::Path, id: &str) -> Result {\n validate_policy_id(id)?;\n tokio::fs::create_dir_all(policy_dir)\n .await\n .with_context(|| format!(\"creating policy dir {}\", policy_dir.display()))?;\n let path = policy_dir.join(format!(\"{id}.yaml\"));\n if tokio::fs::try_exists(&path).await.unwrap_or(false) {\n anyhow::bail!(\"policy '{id}' already exists at {}\", path.display());\n }\n let body = format!(\n \"# policy '{id}' — edit to taste, then bind it to a key:\\n\\\n # bitrouter key sign --user --policy {id}\\n\\\n id: {id}\\n\\\n # allowed_models: [gpt-5, claude-sonnet-4-6]\\n\\\n # denied_models: []\\n\\\n # max_spend_micro_usd: 1000000\\n\\\n # max_requests_per_minute: 60\\n\\\n # allowed_tools: [search]\\n\"\n );\n tokio::fs::write(&path, body)\n .await\n .with_context(|| format!(\"writing {}\", path.display()))?;\n Ok(path)\n}\n\n/// One auth method offered for a provider — derived from the catalog +\n/// the PKCE registry. The CLI shows users only the methods the provider\n/// actually supports.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum AuthMethod {\n /// Use the user's existing Claude Code session: read its OAuth credential\n /// live from `~/.claude` (Keychain / `.credentials.json`), or sign in via\n /// the `claude` CLI when not yet logged in. The preferred path for\n /// `anthropic` — no second token copy, so it can't refresh-rotate Claude\n /// Code out (RFC 6749 §6).\n ClaudeCodeSession,\n /// Browser-based PKCE Authorization Code flow — Anthropic Claude\n /// Pro/Max, OpenAI Codex / ChatGPT.\n PkceSubscription,\n /// Adopt an existing session from the provider's own vendor CLI (Claude\n /// Code, Codex) by reading its on-disk / Keychain OAuth token — no browser.\n ImportFromCli,\n /// RFC 8628 Device Authorization Grant — GitHub Copilot's flow.\n DeviceCode,\n /// User pastes a static API key (sk-…, sk-ant-…).\n ApiKey,\n}\n\nimpl AuthMethod {\n fn label(self) -> &'static str {\n match self {\n AuthMethod::ClaudeCodeSession => {\n \"Use your Claude Code session (read ~/.claude, or sign in via the claude CLI)\"\n }\n AuthMethod::PkceSubscription => \"Subscription (browser sign-in)\",\n AuthMethod::ImportFromCli => \"Import an existing session from the vendor CLI\",\n AuthMethod::DeviceCode => \"Device-code OAuth (show a code in browser)\",\n AuthMethod::ApiKey => \"API key (paste a static credential)\",\n }\n }\n}\n\n/// The vendor CLI bitrouter can adopt an existing OAuth session from, for\n/// providers that ship one — `Codex` for `openai-codex`. `None` for every\n/// other provider.\n///\n/// `anthropic` is **not** listed: it is now the platform / pay-as-you-go\n/// provider (`x-api-key` only). Adopting a Claude Code session is the\n/// `claude-code` subscription provider's job, handled by\n/// [`AuthMethod::ClaudeCodeSession`] (a *live* session read, not a one-shot\n/// token copy), so there is no vendor-CLI import for `anthropic`.\nfn import_cli_for(provider_id: &str) -> Option<&'static str> {\n match provider_id {\n bitrouter_providers::codex::PROVIDER_ID => Some(\"Codex\"),\n bitrouter_providers::supergrok::PROVIDER_ID => Some(\"Grok\"),\n bitrouter_providers::antigravity::PROVIDER_ID => Some(\"Antigravity (agy)\"),\n _ => None,\n }\n}\n\n/// Decide which auth methods are wired for `entry`. Order matters — the\n/// first entry is what runs when the user hits enter on the prompt.\nfn available_methods(entry: &bitrouter_providers::ProviderEntry) -> Vec {\n use bitrouter_providers::AuthScheme;\n let mut methods = Vec::new();\n // The Claude subscription's preferred (and only) path: read the user's live\n // Claude Code session and let the `claude` CLI own login. It belongs to the\n // dedicated `claude-code` subscription provider — `anthropic` is now the\n // platform / pay-as-you-go provider and offers the API-key path only.\n // Listed first so it's the default on / non-interactive runs.\n let is_claude_code = entry.id == bitrouter_providers::claude_code::PROVIDER_ID;\n if is_claude_code {\n methods.push(AuthMethod::ClaudeCodeSession);\n }\n // Providers with a sibling vendor CLI can adopt its existing session as a\n // one-shot copy — currently only Codex (`openai-codex`). List that before\n // PKCE so the default path reuses an already-authenticated local CLI\n // session when one exists.\n if import_cli_for(&entry.id).is_some() {\n methods.push(AuthMethod::ImportFromCli);\n }\n // `anthropic` is platform / pay-as-you-go: offer the API-key path only. The\n // PKCE registry still carries an `anthropic` entry (the Claude subscription\n // browser flow), but that subscription path now belongs to the dedicated\n // `claude-code` provider, so it must not be offered under `anthropic`.\n let is_anthropic = entry.id == bitrouter_providers::anthropic::PROVIDER_ID;\n let has_pkce = !is_anthropic && bitrouter_providers::oauth::registry::has_pkce_flow(&entry.id);\n if has_pkce {\n methods.push(AuthMethod::PkceSubscription);\n }\n match &entry.auth {\n AuthScheme::Bearer { .. } | AuthScheme::Header { .. } => {\n methods.push(AuthMethod::ApiKey);\n }\n AuthScheme::Oauth { params, .. } => {\n // Device-code is only viable when the TOML carries the\n // device authorization endpoint — PKCE-only providers\n // (`openai-codex`) skip this even though their auth scheme\n // is `oauth`.\n let has_device = params\n .get(\"device_authorization_endpoint\")\n .and_then(|v| v.as_str())\n .is_some();\n if has_device {\n methods.push(AuthMethod::DeviceCode);\n }\n // OAuth-only providers don't accept pasted API keys (the\n // Codex backend literally rejects sk-… credentials), so\n // there's no API-key fallback to offer.\n }\n AuthScheme::Native { .. } => {}\n }\n methods\n}\n\n/// Prompt for an integer choice between `[1, options.len()]`. Empty\n/// input (or just ) picks option 1. Invalid input (non-number,\n/// out-of-range) prints a one-line error and re-prompts; a true EOF on\n/// stdin (read_line returns 0 bytes) bails so the caller doesn't loop\n/// forever in non-interactive contexts.\nfn prompt_method_choice(provider: &str, options: &[AuthMethod]) -> Result {\n use std::io::BufRead;\n eprintln!();\n eprintln!(\"How would you like to authenticate to {provider}?\");\n for (i, m) in options.iter().enumerate() {\n eprintln!(\" {}) {}\", i + 1, m.label());\n }\n let stdin = std::io::stdin();\n let mut handle = stdin.lock();\n loop {\n eprint!(\"Choose [1]: \");\n let mut line = String::new();\n let n_bytes = handle\n .read_line(&mut line)\n .context(\"reading method choice from stdin\")?;\n if n_bytes == 0 {\n anyhow::bail!(\"stdin closed before a choice was made\");\n }\n let trimmed = line.trim();\n if trimmed.is_empty() {\n return Ok(options[0]);\n }\n match trimmed.parse::() {\n Ok(n) if (1..=options.len()).contains(&n) => return Ok(options[n - 1]),\n Ok(n) => eprintln!(\" choice must be between 1 and {}, got {n}\", options.len()),\n Err(_) => eprintln!(\" '{trimmed}' is not a number\"),\n }\n }\n}\n\n/// `bitrouter providers login [--label ]` — interactive\n/// credential setup.\n///\n/// Resolves which auth methods are wired for `provider_id` (subscription\n/// PKCE / device-code / API key paste), prompts the user to pick when\n/// there's more than one, runs the chosen flow, and persists the\n/// resulting [`bitrouter_providers::oauth::credential_store::Credential`]\n/// under `(provider_id, label)`.\n/// What `login_provider` accomplished — surfaced as the CLI's JSON result while\n/// the interactive prompts and confirmation go to stderr.\npub struct LoginOutcome {\n pub provider: String,\n pub label: String,\n pub method: String,\n pub path: String,\n}\n\n/// Non-interactive provider-login controls.\n#[derive(Debug, Clone, Default)]\npub struct ProviderLoginOptions {\n /// Require importing an already-authenticated vendor CLI session.\n pub import_existing: bool,\n /// Refuse browser-based PKCE flows.\n pub no_browser: bool,\n /// Seed the credential directly from this API key, skipping the interactive\n /// method choice and the stdin paste entirely. Set by `providers login\n /// --api-key` / `--key-stdin` and the onboarding wizard's per-provider\n /// `--provider-api-key`. The provider must offer the API-key method.\n pub api_key: Option,\n}\n\npub async fn login_provider(provider_id: &str, label: &str) -> Result {\n login_provider_with_options(provider_id, label, ProviderLoginOptions::default()).await\n}\n\npub async fn login_provider_with_options(\n provider_id: &str,\n label: &str,\n options: ProviderLoginOptions,\n) -> Result {\n use bitrouter_providers::builtin;\n\n // The `bitrouter` cloud gateway is compiled in; every other provider's\n // auth shape (handler + public OAuth params) comes from the fetched-or-\n // cached registry — the same mapper the built-ins once used.\n let entry: bitrouter_providers::ProviderEntry = match builtin::find(provider_id) {\n Some(e) => e.clone(),\n None => {\n let data = bitrouter_providers::registry::apply::load_or_cached(\n &bitrouter_sdk::config::RegistryConfig::default(),\n )\n .await\n .ok_or_else(|| {\n anyhow::anyhow!(\n \"could not load the provider registry (offline, and nothing cached) \\\n to resolve '{provider_id}'\"\n )\n })?;\n let provider = data\n .providers\n .iter()\n .find(|p| p.name == provider_id)\n .ok_or_else(|| {\n anyhow::anyhow!(\n \"unknown provider '{provider_id}' — try `bitrouter providers list` \\\n for the catalog\"\n )\n })?;\n builtin::entry_from_registry(provider)\n .with_context(|| format!(\"resolving the auth shape for '{provider_id}'\"))?\n }\n };\n let methods = available_methods(&entry);\n if methods.is_empty() {\n anyhow::bail!(\n \"provider '{provider_id}' has no interactive login path \\\n (auth scheme: {:?})\",\n std::mem::discriminant(&entry.auth)\n );\n }\n let chosen = choose_auth_method(&entry, &methods, &options)?;\n eprintln!();\n eprintln!(\n \" Logging in to {} via {}\",\n entry.display_name,\n chosen.label()\n );\n\n let credential = match chosen {\n AuthMethod::ClaudeCodeSession => run_claude_code_session().await?,\n AuthMethod::PkceSubscription => run_pkce_subscription(provider_id).await?,\n AuthMethod::ImportFromCli => run_cli_import(provider_id)?,\n AuthMethod::DeviceCode => run_device_code(provider_id, &entry).await?,\n // A flag-supplied key (`--api-key` / `--key-stdin` / the wizard) skips\n // the interactive stdin paste; otherwise prompt for it.\n AuthMethod::ApiKey => match options.api_key.as_deref() {\n Some(key) => {\n let key = key.trim();\n if key.is_empty() {\n anyhow::bail!(\"the supplied API key is empty — aborting login\");\n }\n bitrouter_providers::oauth::credential_store::Credential::api_key(key)\n }\n None => run_api_key_paste(&entry.display_name)?,\n },\n };\n\n let mut store = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()\n .context(\"opening credential store\")?;\n let kind = credential.kind_label();\n store\n .set(provider_id, label, credential)\n .with_context(|| format!(\"persisting credential for {provider_id} ({label})\"))?;\n eprintln!(\n \" ✓ Saved {kind} credential for {provider_id} (label: {label}) at {}\",\n store.path().display()\n );\n eprintln!();\n Ok(LoginOutcome {\n provider: provider_id.to_string(),\n label: label.to_string(),\n method: kind.to_string(),\n path: store.path().display().to_string(),\n })\n}\n\nfn choose_auth_method(\n entry: &bitrouter_providers::ProviderEntry,\n methods: &[AuthMethod],\n options: &ProviderLoginOptions,\n) -> Result {\n // A supplied API key is the most explicit signal — take the API-key method\n // directly, or fail clearly if the provider doesn't offer one (OAuth-only\n // backends reject pasted keys).\n if options.api_key.is_some() {\n if methods.contains(&AuthMethod::ApiKey) {\n return Ok(AuthMethod::ApiKey);\n }\n anyhow::bail!(\n \"provider '{}' does not accept a pasted API key — it uses an \\\n interactive OAuth flow; drop --api-key/--key-stdin\",\n entry.id\n );\n }\n\n if options.import_existing {\n if methods.contains(&AuthMethod::ImportFromCli) {\n return Ok(AuthMethod::ImportFromCli);\n }\n anyhow::bail!(\n \"provider '{}' cannot import an existing vendor CLI session\",\n entry.id\n );\n }\n\n let selectable: Vec = if options.no_browser {\n methods\n .iter()\n .copied()\n .filter(|m| *m != AuthMethod::PkceSubscription)\n .collect()\n } else {\n methods.to_vec()\n };\n if selectable.is_empty() {\n anyhow::bail!(\n \"provider '{}' has no non-browser login path; rerun without --no-browser\",\n entry.id\n );\n }\n if selectable.len() == 1 || options.no_browser {\n Ok(selectable[0])\n } else {\n prompt_method_choice(&entry.display_name, &selectable)\n }\n}\n\n/// Adopt the user's existing Claude Code session as the live credential source\n/// for the `claude-code` subscription provider.\n///\n/// Resolution order, mirroring how Claude Code users actually authenticate:\n/// 1. If a Claude Code session already exists in `~/.claude` (macOS Keychain /\n/// `.credentials.json`), use it as-is — the daemon reads it live and\n/// refreshes in place.\n/// 2. Otherwise sign the user in with the `claude` CLI itself (`claude auth\n/// login --claudeai`), installing it first (the same native installer\n/// `bitrouter spawn` uses) when it's missing, then re-read.\n///\n/// Returns a [`Credential::ClaudeCodeCli`] marker — no token is copied into\n/// bitrouter's own store, so bitrouter and Claude Code share one credential and\n/// can't refresh-rotate each other out (RFC 6749 §6). `login_provider` stores\n/// this marker under the `provider_id` it was invoked with, so\n/// `bitrouter providers login claude-code` lands it under `claude-code`.\nasync fn run_claude_code_session()\n-> Result {\n use std::io::IsTerminal;\n\n use bitrouter_providers::import::claude_code::ClaudeCodeStore;\n use bitrouter_providers::oauth::credential_store::Credential;\n\n // Adopt a legacy #590 marker stored under `anthropic` before checking the\n // live session, so a returning user's existing sign-in is recognised under\n // the new `claude-code` provider.\n crate::claude_code::migrate_legacy_anthropic_marker_default();\n\n let store = ClaudeCodeStore::system().ok_or_else(|| {\n anyhow::anyhow!(\n \"could not resolve your home directory (set HOME) to find the Claude Code session\"\n )\n })?;\n\n // 1. Already signed in to Claude Code?\n if let Some(live) = store\n .read()\n .context(\"reading your existing Claude Code session\")?\n {\n eprintln!(\n \" Found your Claude Code session ({}) — bitrouter will read it live.\",\n live.source\n );\n return Ok(Credential::ClaudeCodeCli);\n }\n\n // 2. Not signed in — drive the `claude` CLI's own login.\n if !std::io::stdin().is_terminal() {\n anyhow::bail!(\n \"not signed in to Claude Code. Run `claude auth login` (install the CLI first with \\\n `curl -fsSL https://claude.ai/install.sh | bash`), then re-run \\\n `bitrouter providers login claude-code`.\"\n );\n }\n\n let claude = crate::spawn::ensure_agent_installed(crate::spawn::SpawnAgent::Claude, false)\n .await\n .context(\"locating the claude CLI to sign you in\")?;\n\n eprintln!(\" You're not signed in to Claude Code yet — launching `claude auth login`.\");\n let status = tokio::process::Command::new(&claude)\n .arg(\"auth\")\n .arg(\"login\")\n .arg(\"--claudeai\")\n .status()\n .await\n .context(\"running `claude auth login`\")?;\n if !status.success() {\n anyhow::bail!(\n \"`claude auth login` didn't complete — sign in, then re-run \\\n `bitrouter providers login claude-code`.\"\n );\n }\n\n match store\n .read()\n .context(\"re-reading your Claude Code session after sign-in\")?\n {\n Some(live) => {\n eprintln!(\n \" Signed in — bitrouter will read your session live from {}.\",\n live.source\n );\n Ok(Credential::ClaudeCodeCli)\n }\n None => anyhow::bail!(\n \"still no Claude Code session after `claude auth login` — run `claude auth status` \\\n to check, then retry.\"\n ),\n }\n}\n\n/// Run the browser-based PKCE Authorization Code flow against the\n/// PKCE-registered provider. Errors when the provider isn't in\n/// `bitrouter_providers::oauth::registry`.\nasync fn run_pkce_subscription(\n provider_id: &str,\n) -> Result {\n let registry = bitrouter_providers::oauth::registry::find(provider_id).ok_or_else(|| {\n anyhow::anyhow!(\n \"provider '{provider_id}' has no PKCE login registration — \\\n this should have been caught by available_methods()\"\n )\n })?;\n let client = reqwest::Client::builder()\n .user_agent(concat!(\"bitrouter/\", env!(\"CARGO_PKG_VERSION\")))\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .context(\"building HTTP client for PKCE login\")?;\n let ux = StderrLoginUx;\n let outcome = bitrouter_providers::oauth::login::run_login(\n &client,\n ®istry,\n &ux,\n bitrouter_providers::oauth::auth_code::MANUAL_PASTE_TIMEOUT,\n )\n .await\n .with_context(|| format!(\"PKCE login for {provider_id}\"))?;\n if outcome.manual_fallback_used {\n eprintln!(\" (used manual paste fallback)\");\n }\n Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(outcome.token))\n}\n\n/// Run the RFC 8628 Device Authorization Grant — the existing\n/// `github-copilot` flow, lifted out of the old `login_provider`.\nasync fn run_device_code(\n provider_id: &str,\n entry: &bitrouter_providers::ProviderEntry,\n) -> Result {\n use bitrouter_providers::AuthScheme;\n let params = match &entry.auth {\n AuthScheme::Oauth { params, .. } => params,\n _ => anyhow::bail!(\"provider '{provider_id}' is not OAuth — refusing device flow\"),\n };\n let client_id = params\n .get(\"client_id\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| anyhow::anyhow!(\"'{provider_id}': auth.params.client_id is missing\"))?\n .to_string();\n let scope = params\n .get(\"scope\")\n .and_then(|v| v.as_str())\n .map(str::to_string);\n let device_url = params\n .get(\"device_authorization_endpoint\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n anyhow::anyhow!(\"'{provider_id}': auth.params.device_authorization_endpoint is missing\")\n })?\n .to_string();\n let token_url = params\n .get(\"token_endpoint\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| anyhow::anyhow!(\"'{provider_id}': auth.params.token_endpoint is missing\"))?\n .to_string();\n\n let flow = bitrouter_providers::oauth::DeviceCodeFlow::new(\n bitrouter_providers::oauth::DeviceCodeParams {\n client_id,\n scope,\n device_authorization_endpoint: device_url,\n token_endpoint: token_url,\n },\n )\n .with_context(|| format!(\"building OAuth flow for {provider_id}\"))?;\n\n let device_code = flow\n .request_device_code()\n .await\n .with_context(|| format!(\"requesting OAuth device code for {provider_id}\"))?;\n\n eprintln!();\n eprintln!(\" OAuth device authorization for {}\", entry.display_name);\n eprintln!(\" ──────────────────────────────────────────\");\n if let Some(complete) = &device_code.verification_uri_complete {\n eprintln!(\" Open: {complete}\");\n } else {\n eprintln!(\" Open: {}\", device_code.verification_uri);\n }\n eprintln!(\" Code: {}\", device_code.user_code);\n eprintln!();\n eprintln!(\" Waiting for authorization…\");\n\n let mut interval = std::time::Duration::from_secs(device_code.interval.max(1));\n let token = loop {\n tokio::time::sleep(interval).await;\n match flow.poll_once(&device_code.device_code).await? {\n bitrouter_providers::oauth::device_code::PollOutcome::Token(t) => break t,\n bitrouter_providers::oauth::device_code::PollOutcome::Pending => continue,\n bitrouter_providers::oauth::device_code::PollOutcome::SlowDown => {\n // RFC 8628 §3.5: server requested back-off — add 5s.\n interval += std::time::Duration::from_secs(5);\n }\n }\n };\n Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(token))\n}\n\n/// Adopt an existing OAuth session from the provider's sibling vendor CLI\n/// (Codex for `openai-codex`) by reading its on-disk / Keychain credential.\n/// Errors when no such session is found.\n///\n/// `anthropic` is intentionally absent: it is the platform / pay-as-you-go\n/// provider now, and the Claude Code session is adopted *live* by the\n/// `claude-code` provider via [`run_claude_code_session`] — not copied in here\n/// (the one-shot copy is what risked the RFC 6749 §6 family-revoke).\nfn run_cli_import(\n provider_id: &str,\n) -> Result {\n let imported = match provider_id {\n bitrouter_providers::codex::PROVIDER_ID => bitrouter_providers::import::codex::import(),\n bitrouter_providers::supergrok::PROVIDER_ID => bitrouter_providers::import::grok::import(),\n bitrouter_providers::antigravity::PROVIDER_ID => {\n bitrouter_providers::import::antigravity::import()\n }\n other => anyhow::bail!(\"no vendor-CLI import is available for provider '{other}'\"),\n }\n .with_context(|| format!(\"importing a CLI credential for {provider_id}\"))?;\n let imported = imported.ok_or_else(|| {\n anyhow::anyhow!(\n \"no existing CLI session found for {provider_id} — sign in to the \\\n vendor CLI first, or choose the browser sign-in instead\"\n )\n })?;\n eprintln!(\" Imported existing session from {}\", imported.source);\n Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(imported.token))\n}\n\n/// Read a static API key from stdin. Input is **not** masked — the user\n/// sees what they're pasting, which is the simplest correct UX and\n/// matches `gh auth login --with-token`. Future work could wire\n/// `rpassword` for a hidden prompt.\nfn run_api_key_paste(\n display_name: &str,\n) -> Result {\n use std::io::BufRead;\n eprintln!();\n eprintln!(\" Paste your {display_name} API key, then press enter:\");\n eprint!(\" > \");\n let stdin = std::io::stdin();\n let mut line = String::new();\n stdin\n .lock()\n .read_line(&mut line)\n .context(\"reading API key from stdin\")?;\n let key = line.trim();\n if key.is_empty() {\n anyhow::bail!(\"no API key entered — aborting login\");\n }\n Ok(bitrouter_providers::oauth::credential_store::Credential::api_key(key))\n}\n\n/// `LoginUx` implementation that drives stderr + stdin. Used by the\n/// `bitrouter providers login` CLI; library tests provide their own scripted\n/// UX.\nstruct StderrLoginUx;\n\n#[async_trait::async_trait]\nimpl bitrouter_providers::oauth::login::LoginUx for StderrLoginUx {\n async fn show_authorize_url(&self, url: &str, hint: &str) {\n eprintln!();\n eprintln!(\" Open this URL in your browser to continue:\");\n eprintln!();\n eprintln!(\" {url}\");\n eprintln!();\n eprintln!(\" {hint}\");\n }\n\n async fn prompt_pasted_redirect(\n &self,\n ) -> std::result::Result {\n // The login flow races this future against the loopback\n // listener. If the listener wins we never read stdin, so a\n // blocking read here would normally pin a thread for the whole\n // OAuth round trip. Run the read on a blocking thread so\n // cancellation (the `tokio::select!` in `run_login` dropping\n // this future when the listener finishes) actually frees it.\n let pasted = tokio::task::spawn_blocking(|| {\n use std::io::BufRead;\n eprint!(\" Paste redirect URL (or just the code): \");\n let stdin = std::io::stdin();\n let mut line = String::new();\n stdin.lock().read_line(&mut line).map_err(|e| {\n bitrouter_providers::oauth::login::LoginError::UserIo(format!(\n \"reading pasted redirect: {e}\"\n ))\n })?;\n Ok::<_, bitrouter_providers::oauth::login::LoginError>(line)\n })\n .await\n .map_err(|e| {\n bitrouter_providers::oauth::login::LoginError::UserIo(format!(\n \"join error reading pasted redirect: {e}\"\n ))\n })??;\n Ok(pasted)\n }\n}\n\n/// `bitrouter providers logout ` — drop every stored credential for\n/// the provider (subscription OAuth or pasted API key), if any.\npub async fn logout_provider(provider_id: &str) -> Result {\n let mut store = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()\n .context(\"opening credential store\")?;\n let removed = store\n .remove_all_for(provider_id)\n .with_context(|| format!(\"removing stored credentials for {provider_id}\"))?;\n match removed {\n 0 => eprintln!(\" No stored credentials for {provider_id}; nothing to remove.\"),\n n => eprintln!(\" ✓ Removed {n} stored credential(s) for {provider_id}.\"),\n }\n Ok(removed)\n}\n\n// ===== skills (client installer) =====\n\n/// Default BitRouter registry queried when `bitrouter skills add ` is\n/// given a bare skill name rather than a git source.\nconst DEFAULT_SKILLS_REGISTRY: &str = \"https://api.bitrouter.ai\";\n\n/// Build the HTTP client used to talk to a skills registry. Mirrors the\n/// 30-second-timeout, user-agent-tagged client the cloud SDK uses.\nfn skills_http_client() -> Result {\n reqwest::Client::builder()\n .user_agent(concat!(\"bitrouter-skills/\", env!(\"CARGO_PKG_VERSION\")))\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .context(\"building skills HTTP client\")\n}\n\n/// Resolve the install target for the `--global` flag.\nfn skills_target(global: bool) -> Result {\n use bitrouter_skills::install::InstallTarget;\n if global {\n Ok(InstallTarget::Global)\n } else {\n let cwd = std::env::current_dir().context(\"resolving the current directory\")?;\n Ok(InstallTarget::Project { project_root: cwd })\n }\n}\n\n/// Whether `source` is a bare registry skill name (no path/scheme), as opposed\n/// to a git source.\nfn is_bare_skill_name(source: &str) -> bool {\n !source.contains('/') && !source.contains(\"://\") && !source.starts_with(\"git@\")\n}\n\n/// Resolve a registry name to its [`bitrouter_skills::source::SkillSource`] via\n/// the namespace's marketplace hub.\nasync fn resolve_registry_source(\n name: &str,\n registry: Option<&str>,\n namespace: &str,\n) -> Result {\n use bitrouter_skills::marketplace;\n use bitrouter_skills::source::SkillSource;\n let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);\n let client = skills_http_client()?;\n let market = marketplace::fetch_marketplace(&client, base, namespace).await?;\n let entry = market\n .find(name)\n .ok_or_else(|| anyhow::anyhow!(\"skill {name:?} not found in registry {base}\"))?;\n SkillSource::try_from(&entry.source)\n .with_context(|| format!(\"resolving registry source for {name:?}\"))\n}\n\n/// `bitrouter skills add` — install a skill from a git source or registry name.\npub async fn skills_add(\n source: &str,\n select: Option<&str>,\n global: bool,\n overwrite: bool,\n registry: Option<&str>,\n namespace: Option<&str>,\n) -> Result {\n use bitrouter_skills::{install, source as skill_source};\n\n if source.trim().is_empty() {\n anyhow::bail!(\n \"a skill source is required (owner/repo, a git URL, a local path, or a registry name)\"\n );\n }\n\n let target = skills_target(global)?;\n let parsed = if is_bare_skill_name(source) {\n let nsid = namespace\n .ok_or_else(|| anyhow::anyhow!(\"--namespace is required to query a registry\"))?;\n resolve_registry_source(source, registry, nsid).await?\n } else {\n skill_source::parse_source(source)?\n };\n let outcome = install::install(&parsed, &target, overwrite, select, None).await?;\n Ok(SkillAddReport {\n action: if outcome.was_updated {\n \"updated\"\n } else {\n \"installed\"\n },\n name: outcome.name,\n dest: outcome.dest.display().to_string(),\n })\n}\n\n/// `bitrouter skills list` — show installed skills.\npub fn skills_list(global: bool) -> Result {\n use bitrouter_skills::install;\n let target = skills_target(global)?;\n let skills = install::list_installed(&target)?\n .into_iter()\n .map(|(name, path)| SkillEntry {\n name,\n path: path.display().to_string(),\n })\n .collect();\n Ok(SkillsListReport { skills })\n}\n\n/// `bitrouter skills remove` — uninstall a skill by name.\npub fn skills_remove(name: &str, global: bool) -> Result {\n use bitrouter_skills::install;\n let target = skills_target(global)?;\n let dir = install::remove(name, &target)?;\n Ok(SkillRemoveReport {\n name: name.to_string(),\n path: dir.display().to_string(),\n removed: true,\n })\n}\n\n/// `bitrouter skills find` — search a registry.\npub async fn skills_find(\n query: &str,\n registry: Option<&str>,\n namespace: Option<&str>,\n) -> Result {\n use bitrouter_skills::marketplace;\n let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);\n let nsid = namespace\n .ok_or_else(|| anyhow::anyhow!(\"--namespace is required to query a registry\"))?;\n let client = skills_http_client()?;\n let market = marketplace::fetch_marketplace(&client, base, nsid).await?;\n let results = market\n .search(query)\n .into_iter()\n .map(|entry| SkillHit {\n name: entry.name.clone(),\n version: entry.version.clone().unwrap_or_else(|| \"-\".to_string()),\n description: entry.description.clone().unwrap_or_default(),\n })\n .collect();\n Ok(SkillsFindReport {\n query: query.to_string(),\n registry: base.to_string(),\n results,\n })\n}\n\n/// `bitrouter skills init` — scaffold a SKILL.md.\npub fn skills_init(name: &str, output: &std::path::Path) -> Result {\n bitrouter_skills::install::validate_skill_name(name)?;\n if output.exists() {\n anyhow::bail!(\"{} already exists; refusing to overwrite\", output.display());\n }\n let body = format!(\n \"---\\nname: {name}\\ndescription: TODO — one line on what this skill does and when to use it.\\n---\\n\\n# {name}\\n\\nTODO: instructions for the agent.\\n\\n## When to use\\n\\n## Steps\\n\"\n );\n std::fs::write(output, body).with_context(|| format!(\"writing {}\", output.display()))?;\n Ok(SkillInitReport {\n path: output.display().to_string(),\n created: true,\n })\n}\n\n/// `bitrouter skills update` — re-install installed skills from the registry.\npub async fn skills_update(\n name: Option<&str>,\n global: bool,\n registry: Option<&str>,\n namespace: Option<&str>,\n) -> Result {\n use bitrouter_skills::install;\n use bitrouter_skills::marketplace;\n use bitrouter_skills::source::SkillSource;\n\n let target = skills_target(global)?;\n let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);\n\n // `update` only re-installs skills that are actually installed; an explicit\n // name must already be present (it does not double as an installer).\n let installed: Vec = install::list_installed(&target)?\n .into_iter()\n .map(|(n, _)| n)\n .collect();\n let names: Vec = match name {\n Some(n) => {\n if !installed.iter().any(|i| i == n) {\n anyhow::bail!(\"skill {n:?} is not installed; use `bitrouter skills add` first\");\n }\n vec![n.to_string()]\n }\n None => installed,\n };\n if names.is_empty() {\n return Ok(SkillsUpdateReport {\n updated: Vec::new(),\n skipped: Vec::new(),\n failed: Vec::new(),\n });\n }\n\n let nsid = namespace\n .ok_or_else(|| anyhow::anyhow!(\"--namespace is required to query a registry\"))?;\n let client = skills_http_client()?;\n let market = marketplace::fetch_marketplace(&client, base, nsid).await?;\n\n // Update each skill independently: one failure must not abort the rest.\n // Re-install into the same directory so a skill stays put even if its\n // upstream frontmatter name has drifted. Per-skill outcomes are collected\n // into the report, which exits non-zero when any skill failed.\n let mut report = SkillsUpdateReport {\n updated: Vec::new(),\n skipped: Vec::new(),\n failed: Vec::new(),\n };\n for skill_name in names {\n match market.find(&skill_name) {\n Some(entry) => {\n let result = match SkillSource::try_from(&entry.source) {\n Ok(parsed) => {\n install::install(&parsed, &target, true, None, Some(&skill_name)).await\n }\n Err(e) => Err(e),\n };\n match result {\n Ok(outcome) => report.updated.push(UpdatedSkill {\n name: outcome.name,\n dest: outcome.dest.display().to_string(),\n }),\n Err(e) => report.failed.push(FailedSkill {\n name: skill_name,\n error: e.to_string(),\n }),\n }\n }\n None => report.skipped.push(SkippedSkill {\n name: skill_name,\n reason: format!(\"not in registry {base}\"),\n }),\n }\n }\n Ok(report)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn init_writes_skip_auth_true_config() {\n let dir = std::env::temp_dir().join(format!(\n \"brinit-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n tokio::fs::create_dir_all(&dir).await.unwrap();\n let path = dir.join(\"bitrouter.yaml\");\n assert_eq!(\n write_starter_config(&path, false).await.unwrap(),\n ScaffoldOutcome::Wrote\n );\n let written = tokio::fs::read_to_string(&path).await.unwrap();\n assert!(written.contains(\"skip_auth: true\"));\n // and it parses back into a valid Config\n let cfg = bitrouter_sdk::config::parse_with(&written, |_| Some(\"x\".to_string())).unwrap();\n assert!(cfg.server.skip_auth);\n // Without --force, an existing file is left untouched (Skipped) — the\n // wizard's `--yes` run must never clobber a tuned config.\n tokio::fs::write(&path, \"# hand-edited\\n\").await.unwrap();\n assert_eq!(\n write_starter_config(&path, false).await.unwrap(),\n ScaffoldOutcome::Skipped\n );\n assert_eq!(\n tokio::fs::read_to_string(&path).await.unwrap(),\n \"# hand-edited\\n\"\n );\n // With --force it overwrites.\n assert_eq!(\n write_starter_config(&path, true).await.unwrap(),\n ScaffoldOutcome::Wrote\n );\n assert!(\n tokio::fs::read_to_string(&path)\n .await\n .unwrap()\n .contains(\"skip_auth: true\")\n );\n let _ = tokio::fs::remove_dir_all(&dir).await;\n }\n\n #[tokio::test]\n async fn key_sign_produces_brvk_secret_and_persists_only_hash() {\n let key = key_sign(\"sqlite::memory:\", \"u1\", Some(\"p1\")).await.unwrap();\n assert!(key.secret.starts_with(\"brvk_\"));\n assert!(key.id.starts_with(\"brvk_id_\"));\n }\n\n /// Regression for the out-of-the-box `bitrouter init` → `start` failure:\n /// the starter config ships a *commented* `opencode-go` example that\n /// references `${OPENCODE_GO_KEY_A/B}`. The loader expands `${VAR}` over the\n /// raw text before YAML parsing, so it used to error on those unset vars\n /// even though they live in a comment. With comment-aware substitution the\n /// starter config must load with **no** environment variables set.\n #[test]\n fn starter_config_loads_with_all_env_vars_unset() {\n let cfg = bitrouter_sdk::config::parse_with(STARTER_CONFIG, |_| None)\n .expect(\"starter config must load with no env vars set\");\n assert!(cfg.server.skip_auth);\n }\n\n /// Regression: the `mcp_servers:` and `agents:` blocks in\n /// `STARTER_CONFIG` are commented out by default, but if a user\n /// uncomments them the result must still parse against the current\n /// `Config` schema. This test strips the leading `# ` from those\n /// blocks and reparses; future schema drift fails CI here rather\n /// than fails the next user's `bitrouter init`.\n #[test]\n fn starter_config_parses_with_mcp_and_agents_examples_uncommented() {\n let uncommented: String = STARTER_CONFIG\n .lines()\n .map(|line| {\n // Uncomment any line inside the mcp_servers: / agents:\n // blocks. Those blocks are characterised by lines that\n // start with \"# \" followed by either the block-header\n // keyword or a key-value pair shaped like YAML.\n let trimmed = line.trim_start();\n if trimmed.starts_with(\"# mcp_servers:\")\n || trimmed.starts_with(\"# agents:\")\n || trimmed.starts_with(\"# \")\n || trimmed.starts_with(\"# \")\n || trimmed.starts_with(\"# \")\n {\n let leading_spaces = line.len() - trimmed.len();\n let stripped = trimmed\n .strip_prefix(\"# \")\n .or_else(|| trimmed.strip_prefix(\"#\"))\n .unwrap_or(trimmed);\n format!(\"{}{}\", \" \".repeat(leading_spaces), stripped)\n } else {\n line.to_string()\n }\n })\n .collect::>()\n .join(\"\\n\");\n let cfg = bitrouter_sdk::config::parse_with(&uncommented, |_| Some(\"x\".to_string()))\n .expect(\"uncommented STARTER_CONFIG must parse against current schema\");\n assert!(\n !cfg.mcp_servers.is_empty(),\n \"mcp_servers block should land as parsed entries; got: {:?}\",\n cfg.mcp_servers.keys().collect::>()\n );\n assert!(\n !cfg.agents.is_empty(),\n \"agents block should land as parsed entries; got: {:?}\",\n cfg.agents.keys().collect::>()\n );\n }\n\n /// Build a [`ProviderEntry`] from a registry-provider fixture — anthropic\n /// and openai-codex are no longer compiled-in built-ins, so `login` resolves\n /// their auth shape from the fetched registry via `entry_from_registry`.\n fn entry_for(json: serde_json::Value) -> bitrouter_providers::ProviderEntry {\n let p: bitrouter_providers::registry::types::RegistryProvider =\n serde_json::from_value(json).expect(\"valid registry-provider fixture\");\n bitrouter_providers::builtin::entry_from_registry(&p).expect(\"maps to an entry\")\n }\n\n #[test]\n fn claude_code_prefers_claude_code_session_first() {\n // The `claude-code` subscription provider — the live Claude Code session\n // is its default (and the marker lands under `claude-code`).\n let entry = entry_for(serde_json::json!({\n \"name\": \"claude-code\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"claude-code\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n assert_eq!(\n methods.first(),\n Some(&AuthMethod::ClaudeCodeSession),\n \"the live Claude Code session must be the default for claude-code\"\n );\n assert!(\n !methods.contains(&AuthMethod::ImportFromCli),\n \"claude-code uses the live session, not the one-shot copy that can family-revoke\"\n );\n }\n\n #[test]\n fn anthropic_offers_api_key_only() {\n // `anthropic` is now platform / pay-as-you-go: API-key paste only — no\n // Claude Code session, no subscription PKCE, no vendor-CLI import. The\n // subscription path moved to the `claude-code` provider.\n let entry = entry_for(serde_json::json!({\n \"name\": \"anthropic\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"header\", \"header\": \"x-api-key\", \"env\": \"ANTHROPIC_API_KEY\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n assert_eq!(\n methods,\n vec![AuthMethod::ApiKey],\n \"anthropic must offer the API-key path only\"\n );\n assert!(!methods.contains(&AuthMethod::ClaudeCodeSession));\n assert!(!methods.contains(&AuthMethod::PkceSubscription));\n assert!(!methods.contains(&AuthMethod::ImportFromCli));\n }\n\n #[test]\n fn codex_keeps_oneshot_import_and_offers_no_claude_code_session() {\n let entry = entry_for(serde_json::json!({\n \"name\": \"openai-codex\",\n \"api_base\": \"https://chatgpt.com/backend-api/codex\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"openai-codex\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n assert_eq!(\n methods.first(),\n Some(&AuthMethod::ImportFromCli),\n \"openai-codex onboarding should first reuse an existing Codex CLI session\"\n );\n assert!(methods.contains(&AuthMethod::ImportFromCli));\n assert!(methods.contains(&AuthMethod::PkceSubscription));\n assert!(!methods.contains(&AuthMethod::ClaudeCodeSession));\n }\n\n #[test]\n fn import_existing_selects_vendor_cli_import_without_prompting() {\n let entry = entry_for(serde_json::json!({\n \"name\": \"openai-codex\",\n \"api_base\": \"https://chatgpt.com/backend-api/codex\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"openai-codex\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let chosen = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: true,\n no_browser: true,\n api_key: None,\n },\n )\n .unwrap();\n assert_eq!(chosen, AuthMethod::ImportFromCli);\n }\n\n #[test]\n fn api_key_option_selects_api_key_method_without_prompting() {\n // A `--api-key`/`--key-stdin`-supplied key drives the non-interactive\n // BYOK path: `anthropic` offers the API-key method, so it's chosen.\n let entry = entry_for(serde_json::json!({\n \"name\": \"anthropic\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"header\", \"header\": \"x-api-key\", \"env\": \"ANTHROPIC_API_KEY\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let chosen = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: false,\n no_browser: false,\n api_key: Some(\"sk-ant-test\".to_string()),\n },\n )\n .unwrap();\n assert_eq!(chosen, AuthMethod::ApiKey);\n }\n\n #[test]\n fn api_key_option_rejects_oauth_only_providers() {\n // `openai-codex` is OAuth-only (no API-key method); a supplied key is a\n // clear error rather than a silently-ignored flag.\n let entry = entry_for(serde_json::json!({\n \"name\": \"openai-codex\",\n \"api_base\": \"https://chatgpt.com/backend-api/codex\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"oauth\", \"handler\": \"openai-codex\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let err = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: false,\n no_browser: false,\n api_key: Some(\"sk-whatever\".to_string()),\n },\n )\n .unwrap_err();\n assert!(format!(\"{err:#}\").contains(\"does not accept a pasted API key\"));\n }\n\n #[test]\n fn import_existing_rejects_providers_without_vendor_cli_import() {\n let entry = entry_for(serde_json::json!({\n \"name\": \"anthropic\",\n \"api_base\": \"https://api.anthropic.com/v1\",\n \"status\": \"active\",\n \"auth\": { \"kind\": \"header\", \"header\": \"x-api-key\", \"env\": \"ANTHROPIC_API_KEY\" },\n \"models\": []\n }));\n let methods = available_methods(&entry);\n let err = choose_auth_method(\n &entry,\n &methods,\n &ProviderLoginOptions {\n import_existing: true,\n no_browser: true,\n api_key: None,\n },\n )\n .unwrap_err();\n assert!(format!(\"{err:#}\").contains(\"cannot import\"));\n }\n\n fn sample_config() -> Config {\n let yaml = r#\"\nproviders:\n openai:\n api_base: https://api.openai.com/v1\n api_key: k1\n models: [{ id: gpt-5 }, { id: shared }]\n anthropic:\n api_base: https://api.anthropic.com/v1\n api_key: k2\n models: [{ id: shared }]\n\"#;\n bitrouter_sdk::config::parse_with(yaml, |_| None).unwrap()\n }\n\n #[tokio::test]\n async fn list_models_filters_by_provider() {\n let cfg = sample_config();\n let all = list_models(&cfg, None).await.unwrap();\n assert_eq!(all.len(), 2); // gpt-5, shared\n let openai_only = list_models(&cfg, Some(\"openai\")).await.unwrap();\n assert_eq!(openai_only.len(), 2); // gpt-5 + shared (openai declares both)\n let anthropic_only = list_models(&cfg, Some(\"anthropic\")).await.unwrap();\n assert_eq!(anthropic_only.len(), 1); // only `shared`\n assert_eq!(anthropic_only[0].0, \"shared\");\n }\n\n #[tokio::test]\n async fn resolve_route_returns_the_cascade_chain() {\n let cfg = sample_config();\n // `shared` is declared by both providers → a 2-hop alphabetical chain\n let chain = resolve_route(&cfg, \"shared\").await.unwrap();\n assert_eq!(chain.len(), 2);\n assert_eq!(chain[0].provider, \"anthropic\");\n assert_eq!(chain[1].provider, \"openai\");\n // an unknown model is a clean error (no DEFAULT_PROVIDER fallback)\n assert!(resolve_route(&cfg, \"no-such-model\").await.is_err());\n }\n\n #[test]\n fn list_providers_sorts_and_summarises() {\n let providers = list_providers(&sample_config());\n assert_eq!(providers.len(), 2);\n assert_eq!(providers[0].id, \"anthropic\");\n assert_eq!(providers[1].id, \"openai\");\n assert_eq!(providers[1].model_count, 2);\n assert!(providers[1].active);\n }\n\n #[tokio::test]\n async fn create_policy_rejects_path_traversal_ids() {\n let dir = std::env::temp_dir().join(format!(\n \"brpoltrav-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n // Path separators, parent refs, dotfiles, control chars — all rejected\n // before any file is written.\n for bad in [\n \"../etc/passwd\",\n \"foo/bar\",\n \"..\",\n \".hidden\",\n \"\",\n \"a b\",\n \"x\\0y\",\n ] {\n let err = create_policy(&dir, bad).await.unwrap_err();\n let msg = format!(\"{err}\");\n assert!(\n msg.contains(\"invalid characters\")\n || msg.contains(\"must not\")\n || msg.contains(\"empty\"),\n \"bad id '{bad}' should have been rejected; got: {msg}\"\n );\n }\n // The dir was never created (because every call bailed before touching fs).\n // (We don't assert this strongly — a parallel test could create it — just clean up.)\n let _ = tokio::fs::remove_dir_all(&dir).await;\n }\n\n #[tokio::test]\n async fn create_policy_writes_a_starter_file() {\n let dir = std::env::temp_dir().join(format!(\n \"brpol-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n let path = create_policy(&dir, \"team-default\").await.unwrap();\n let body = tokio::fs::read_to_string(&path).await.unwrap();\n assert!(body.contains(\"id: team-default\"));\n // a re-create refuses to overwrite\n assert!(create_policy(&dir, \"team-default\").await.is_err());\n let _ = tokio::fs::remove_dir_all(&dir).await;\n }\n}\n"}}, "skills/bitrouter/references/providers.md": {"5536fe525829ec51d373ac611ae89d1fed449dfc": {"t": 1785596341.491726, "n": 22063, "h": "d795c664b5079153", "c": "# Providers & routing\n\nHow to configure `providers:`, `models:`, `mcp_servers:`, and `agents:` in `bitrouter.yaml`. Reflects the v1 schema in `crates/bitrouter-sdk/src/config/mod.rs` — fields and strategies not listed here do not exist.\n\n## Known providers\n\nbitrouter already knows how to talk to these providers — their `api_base`,\n`api_protocol`, and credential env var come from the **fetched provider\nregistry** (below), so listing one with an empty body in `bitrouter.yaml`\nenables it and the registry fills the rest. The definitions are **not** vendored\ninto the binary; they are fetched at startup and disk-cached (see the registry\nsection). The one in-binary exception is the hosted `bitrouter` cloud gateway.\n\n| Provider id | Env var | Auth | Notes |\n|---|---|---|---|\n| `openai` | `OPENAI_API_KEY` | Bearer | Chat Completions default; Responses API also live |\n| `anthropic` | `ANTHROPIC_API_KEY` | Header `x-api-key` | Messages API |\n| `google` | `GEMINI_API_KEY` | Header `x-goog-api-key` | Generative Language API (Gemini API key) — **not** `GOOGLE_API_KEY` |\n| `aws-bedrock` | `AWS_BEARER_TOKEN_BEDROCK` | Bearer | Bedrock `bedrock-mantle` OpenAI-compatible endpoints; region via `AWS_REGION` (default `us-east-1`). Native Converse features (cross-region profiles, Guardrails) not served |\n| `azure` | `AZURE_OPENAI_API_KEY` | Bearer | Azure OpenAI `/openai/v1` surface; set `AZURE_OPENAI_RESOURCE` to your resource name (unset ⇒ provider inactive). Entra ID tokens also work as the bearer |\n| `vertex` | `VERTEX_EXPRESS_API_KEY` | Header `x-goog-api-key` | Vertex AI, currently **Express Mode only** → **Gemini models only** (no Claude/Llama/Mistral), global endpoint (region ignored). Partner models are commented out in the registry entry pending service-account OAuth support |\n| `openrouter` | `OPENROUTER_API_KEY` | Bearer | Forwards every OpenRouter model |\n| `claude-code` | `CLAUDE_CODE_OAUTH_TOKEN` or local OAuth | Claude Code subscription | `bitrouter providers login claude-code`, or a long-lived `claude setup-token` value for headless use; distinct from `anthropic` API-key billing |\n| `github-copilot` | — (local OAuth) | Device flow | `bitrouter providers login github-copilot`; per-model protocol map (Claude → Anthropic, gpt-5.x-codex → Responses, rest → Chat) |\n| `openai-codex` | — (local PKCE) | ChatGPT subscription | `bitrouter providers login openai-codex` |\n| `supergrok` | — (local OAuth) | SuperGrok subscription | `bitrouter providers login supergrok`; imports the Grok CLI session (`~/.grok/auth.json`), distinct from `xai` API-key billing |\n| `google-ai` | — (local OAuth) | Google AI (Antigravity) subscription | `bitrouter providers login google-ai`; imports the `agy` CLI keyring session, custom cloudcode-pa protocol, distinct from `google` API-key billing. Unofficial — uses your own Google account |\n| `opencode-zen` | `OPENCODE_ZEN_API_KEY` | Bearer | Per-family protocol routing |\n| `opencode-go` | `OPENCODE_ZEN_API_KEY` (shared) | Bearer | Low-cost subscription tier — same credential as Zen |\n\nZero-config mode auto-enables every API-key provider whose env var is present;\nan API-key provider without its credential gets `active: false` and falls out of\nthe routing table. Local-OAuth/PKCE providers (`claude-code`, `github-copilot`,\n`openai-codex`, `supergrok`, `google-ai`) are enabled by `bitrouter providers login`, not an env var. **First run with no network\nand no cache**: the registry is empty, so only fully-specified local providers\nand the in-binary `bitrouter` cloud gateway are available — the known-provider\nshorthand needs one prior successful fetch. Startup still succeeds.\n\n`CLAUDE_CODE_OAUTH_TOKEN` is the local-OAuth exception: a non-empty value\nauto-enables `claude-code` and is captured as a process-local, non-refreshable\nBearer when the daemon constructs the provider. Set it before `start` (or use a\nreload path that reconstructs provider auth), keep it out of YAML and command\narguments, and route standard Anthropic clients explicitly to\n`claude-code:`. BitRouter adds the subscription upstream's required\nOAuth/Claude-Code headers and prepends the current Claude Agent SDK identity\nsystem block without dropping the downstream client's system instructions. It\nalso unwraps LiteLLM `extra_body` extensions and drops its harness-only\n`session_id`, which remains available through BitRouter's workflow headers.\nBare Claude models still do not auto-cascade onto a subscription provider.\n\n## Provider registry (catalog + priority)\n\nBitRouter fetches the public provider registry from\n`https://github.com/bitrouter/bitrouter/tree/main/registry` at startup and on\nreload: a deterministic catalog of public providers (their transport + auth),\nthe models, and which providers serve them. It is fetched from the generated\n`dist/registry/` artifacts, disk-cached under\n`$XDG_CACHE_HOME/bitrouter/registry.json` (24h TTL, stale-fallback on a\nnetwork outage), and merged into the routing table. If a fetch fails the cache\nis reused; with no cache (first run, offline) the registry is empty and only\nlocally-configured providers route. The merge routes a model id\n(e.g. `anthropic/claude-sonnet-4.6`) to a provider that serves it, translating\nto that provider's own upstream id. Providers may serve models beyond the\ncurated `registry/models` catalog (BYOK / BYO-subscription extras); those route\nthe same way — the curated catalog is the blessed default set, not a routing\ngate.\n\nRules:\n\n- **Public providers only.** Every public registry provider is merged; only\n `private` ones (invite-only entries, no public registration) are skipped. The\n registry classifies each provider by how a caller obtains\n access: `api_key` (a portable key), `local_oauth` / `local_pkce` (a local\n interactive login — e.g. `github-copilot`, `openai-codex`), or `private`.\n- **Credential-gated.** An `api_key` provider becomes routable only when its key\n is present, read from the convention `${NAME}_API_KEY` (uppercased, hyphens →\n underscores — e.g. `DEEPSEEK_API_KEY`, `ZAI_CODING_PLAN_API_KEY`) or from the\n provider's declared env var. No key ⇒ not enabled. Declare the provider\n explicitly with `api_key: ${MY_VAR}` to override the env-var name. A\n `local_oauth` / `local_pkce` provider is not env-gated — it activates after\n `bitrouter providers login `.\n- **Full catalog via the sync channel.** A provider may declare an `auto_sync`\n feed (the channel the registry itself syncs from). BitRouter reads the same\n channel to pull the provider's **full** catalog beyond the registry seed\n subset: a `v1_models` feed (the gateways) is probed at `GET {api_base}/models`\n on startup; a `models_dev` feed pulls the provider's models from models.dev.\n The registry seed models keep the highest route priority.\n- **BitRouter Cloud is a normal public provider.** The public registry entry is\n still named `bitrouter`, but OSS treats it as BitRouter Cloud and discovers\n its cloud-owned model list from `/models`.\n\n```yaml\nregistry:\n enabled: true # default; set false (or inherit_defaults: false) to disable the merge\n url: \"https://raw.githubusercontent.com/bitrouter/bitrouter/main/dist/registry\"\n provider_priority: # default ladder, highest first\n - first-party-subscription\n - gateway-subscription\n - first-party-api\n - bitrouter-cloud\n - third-party-api\n```\n\nWhen several active providers serve the same canonical model, the auto-cascade\norders them by this `provider_priority` ladder (a provider's class comes from\nthe registry or explicit provider config). Override per provider with `class:` or a numeric\n`priority:` (lower = preferred) under `providers.`.\n\n## Minimal config\n\n```yaml\n# bitrouter.yaml — the smallest file that does useful work\nserver:\n listen: \"127.0.0.1:4356\"\n skip_auth: true\n\nproviders:\n openai: {} # uses OPENAI_API_KEY\n anthropic: {} # uses ANTHROPIC_API_KEY\n google: {} # uses GEMINI_API_KEY\n\ninherit_defaults: true\n```\n\n`inherit_defaults: true` (the workspace default) is what makes `openai: {}` work — it fills `api_base`, `api_protocol`, the model catalog, and the env-resolved `api_key` from the registry entry. Set it to `false` only when you want to override everything explicitly.\n\n## Custom OpenAI-compatible providers\n\nAnything that speaks OpenAI Chat Completions on a known base URL fits here. No registry entry needed.\n\n```yaml\nproviders:\n ollama:\n api_base: \"http://localhost:11434/v1\"\n api_protocol: { \"*\": openai } # default; can be omitted\n models:\n - { id: \"llama3.1:70b\" }\n - { id: \"codellama:34b\" }\n\n groq:\n api_base: \"https://api.groq.com/openai/v1\"\n api_key: \"${GROQ_API_KEY}\"\n auto_discover: true # pull /v1/models at startup + reload\n\n vllm:\n api_base: \"https://llm.internal.example.com/v1\"\n api_key: \"${VLLM_API_KEY}\"\n models:\n - { id: \"my-org/mixtral-8x22b\" }\n```\n\n> Azure OpenAI and Amazon Bedrock are registry providers now (see\n> *Known providers*) — you do not hand-write them here. Set their env vars (and\n> `AZURE_OPENAI_RESOURCE` / `AWS_REGION`) and they self-enable. Google `vertex`\n> is registry-backed **in Express Mode only** (Gemini-only, static `VERTEX_EXPRESS_API_KEY`);\n> the full Vertex catalog (Claude/Llama on regional endpoints) is commented out in\n> the registry entry, pending service-account OAuth support.\n\n`api_protocol` accepts a glob-prefix pattern map: `{ \"claude-*\": anthropic, \"gpt-5.5-codex\": responses, \"*\": openai }` is valid and matches most-specific-first.\n\n## Multi-account (failover or balance)\n\nWhen you hold two subscriptions to the same upstream, expand the provider into accounts:\n\n```yaml\nproviders:\n opencode-go:\n account_strategy: failover # or \"balance\"\n accounts:\n - { api_key: \"${OPENCODE_GO_KEY_A}\", label: primary }\n - { api_key: \"${OPENCODE_GO_KEY_B}\", label: backup }\n```\n\n- `failover` (default): try `primary` first; drop to `backup` on retryable failure (5xx / 429 / timeout / credit-exhaustion).\n- `balance`: process-random rotation so each account roughly shares load; the remaining accounts still act as failover targets for that request.\n\nPer-account `api_base` is allowed for multi-region setups — empty inherits the provider's.\n\n## Rate limits\n\n```yaml\nproviders:\n openai:\n rate_limits:\n \"gpt-4*\": { requests_per_minute: 60, tokens_per_minute: 90000 }\n \"gpt-4o-mini\": { requests_per_minute: 200, tokens_per_minute: 200000 }\n```\n\nGlob-prefix patterns, same precedence as `api_protocol`. Each `(provider, pattern)` bucket gets an independent window.\n\n## Upstream timeouts\n\nGlobal defaults for the outbound client that calls providers live under the\ntop-level `upstream.timeouts` block; any provider overrides them under\n`providers..timeouts`. All fields are seconds and optional — an unset\nprovider field inherits the resolved global value, and an unset global inherits\nthe built-in default.\n\n```yaml\nupstream:\n timeouts:\n connect_secs: 10 # TCP connect (default 10)\n read_secs: 120 # idle/per-read: fires when the upstream goes\n # silent this long, INCLUDING mid-stream\n # (the stream-idle guard). Default 120.\n pool_idle_secs: 90 # idle pooled-connection eviction (default 90)\n tcp_keepalive_secs: 60 # TCP keepalive probe interval (default 60)\n # total_secs: # overall wall-clock cap for the whole\n # request/stream. Opt-in, OFF by default —\n # setting it bounds total stream duration, so\n # keep it generous for reasoning/agentic models.\nproviders:\n slow-reasoner:\n timeouts:\n read_secs: 300 # this provider tolerates longer silences\n total_secs: 1800 # and gets its own 30-min wall-clock cap\n```\n\nNotes:\n- `read_secs` is a per-read (idle) timeout — it resets on every chunk, so it is\n what catches an upstream that stalls mid-SSE-stream. A mid-stream fire maps to\n a `504` upstream-timeout (and triggers fallback only if it happens before the\n first byte; once bytes are streaming the request can't fall back).\n- `total_secs` is unset by default on purpose: an overall cap would kill\n legitimately long agentic/reasoning streams.\n- Timeouts are **not** inherited via a provider's `derives:` chain, only from\n the built-in global default.\n\n## Tags & routing\n\nRouting prefs filter providers by tag — `require_tags: [cheap]` keeps only tagged providers in scope:\n\n```yaml\nproviders:\n openai:\n tags: [cloud, paid]\n ollama:\n tags: [local, free, cheap]\n```\n\n## Derives (provider inheritance)\n\n```yaml\nproviders:\n openai:\n api_key: \"${OPENAI_API_KEY}\"\n openai-dev:\n derives: openai\n api_key: \"${OPENAI_DEV_KEY}\" # only the credential differs\n```\n\n`derives` flows `api_protocol`, `rate_limits`, `models`, `tags`, and `auto_discover` from the named provider into empty fields of this one.\n\n## Virtual models\n\nThe top-level `models:` block defines named models that don't map 1:1 to a single provider. The full schema is broader than this skill covers — see `crates/bitrouter-sdk/src/config/mod.rs::VirtualModel`. The pattern that 90% of users want is a simple alias:\n\n```yaml\nmodels:\n fast:\n # routes to whichever provider declares this id first\n upstream_id: \"claude-haiku-4-5\"\n cheap-coder:\n upstream_id: \"opencode-go/glm-5.1\"\n```\n\nFor richer routing (priority chains, splits), check the v1 docs at — the schema is richer than what was previously documented in the agent-skills repo.\n\n## MCP servers\n\n```yaml\nmcp_servers:\n ctx7:\n name: ctx7\n transport:\n type: http\n url: https://mcp.context7.com/mcp\n headers:\n Authorization: \"Bearer ${CTX7_TOKEN}\"\n\n git:\n name: git\n transport:\n type: stdio\n command: uvx\n args: [\"mcp-server-git\"]\n```\n\nOnce configured, `POST /mcp/` proxies JSON-RPC through. Inspect with `bitrouter tools list` / `bitrouter tools status` / `bitrouter tools discover `. To find servers without hand-writing the block: `bitrouter mcp search ` / `bitrouter mcp list` browse the official MCP registry (with an install-support column), and `bitrouter mcp add ` prints a reviewed stub to paste under `mcp_servers:`.\n\n## Server tools (router-executed)\n\nAttach an MCP server's tools to LLM requests: BitRouter advertises them to the model, executes the model's calls to them itself, and loops until the model stops calling them — all inside one client response. The named servers must also be declared under `mcp_servers:` above.\n\n```yaml\nserver_tools:\n mcp_servers: [ctx7, git] # ids from mcp_servers: above\n max_iterations: 10 # optional; max tool rounds per request (default 10)\n```\n\nTool names are prefixed (`__`, or the server's `tool_prefix`) so they can't collide with the caller's own tools. Empty/unset leaves the pipeline single-shot. This is the inverse of `bitrouter mcp serve` (which makes BitRouter an MCP *server*): here BitRouter is an MCP *client* consuming those tools inside the request loop.\n\n### Model-backed server tools (advisor / sub-agent / fusion)\n\nBitRouter can also expose server tools backed by *nested model completions*. Each is advertised only when the caller declares it (a provider-defined tool in the request `tools`, e.g. `{\"type\":\"bitrouter:advisor\"}`) and runs on a loop-less sub-pipeline metered to the caller.\n\n```yaml\nserver_tools:\n advisor: true # bitrouter:advisor — consult a stronger model mid-task\n subagent: true # bitrouter:subagent — delegate a task to a worker model\n fusion: # bitrouter:fusion — multi-model panel + judge deliberation\n panel: [anthropic/claude-opus-4.8, openai/gpt-latest, google/gemini-pro]\n judge: anthropic/claude-opus-4.8\n # optional: outer_model, alias (default bitrouter/fusion), synthesizer, web_tools\n```\n\nSetting `fusion:` also enables the `bitrouter/fusion` model alias, which expands a request to the configured panel + judge. The judge *compares* the panel's answers (consensus / contradictions / partial coverage / unique insights / blind spots); the calling model writes the final answer from that analysis.\n\n### Built-in web search (BYOK)\n\nThe `web_search` server tool gives *any* model routed through BitRouter a web search, served by a search backend you bring keys for. Advertised only when the caller declares `{\"type\":\"bitrouter:web_search\"}` (optionally with `backend` / `max_results` overrides). The model calls `web_search` with a `query`; BitRouter runs it and returns `{backend, answer?, results:[{url,title?,snippet?,content?,published?,score?}]}` — `answer` is present only for the answer-engine `native` backend.\n\n```yaml\nserver_tools:\n web_search:\n max_results: 5 # optional default cap (caller may lower it)\n backends: # preference + failover order; first that resolves a key is default\n - kind: parallel # HTTP, key from api_key or PARALLEL_API_KEY\n - kind: exa # HTTP, key from api_key or EXA_API_KEY\n - kind: firecrawl # HTTP, key from api_key or FIRECRAWL_API_KEY\n - kind: tavily # HTTP, key from api_key or TAVILY_API_KEY\n - kind: native # reuse a provider's NATIVE search for every model\n name: native # backend id a caller pins (default \"native\")\n model: anthropic/claude-opus-4.8\n tool: { type: \"anthropic:web_search_20250305\" }\n```\n\nHTTP backends (`parallel` / `exa` / `firecrawl` / `tavily`) take an optional `api_key` (supports `${VAR}`) and `api_base`; a backend with no resolvable key is skipped. The `native` backend runs a nested completion (so it needs a routable model) — it forwards a provider's own search tool, making one provider's native web search usable from models that lack it.\n\n### Built-in web fetch (BYOK)\n\nThe `web_fetch` server tool gives any model routed through BitRouter the ability to fetch and read a specific URL's full content, served by a BYOK extraction backend. Advertised only when the caller declares `{\"type\":\"bitrouter:web_fetch\"}` (optionally with `backend` / `max_content_tokens` overrides). The model calls `web_fetch` with a `url`; BitRouter fetches it and returns `{status, backend, url, title?, content, published?}`.\n\n```yaml\nserver_tools:\n web_fetch:\n max_content_tokens: 25000 # default per-fetch content cap (caller may lower)\n backends: # preference / failover order\n - kind: exa # POST /contents, key from api_key or EXA_API_KEY\n - kind: firecrawl # POST /v2/scrape, key from api_key or FIRECRAWL_API_KEY\n - kind: tavily # POST /extract, key from api_key or TAVILY_API_KEY\n```\n\nBYOK extraction happens on the provider's infrastructure (BitRouter does not dereference the URL itself), so the backends own the fetch safety surface.\n\n## ACP agents\n\n```yaml\nagents:\n claude:\n name: claude\n transport:\n type: stdio\n command: npx\n args: [\"-y\", \"@zed-industries/claude-code-acp@latest\"]\n\n codex:\n name: codex\n transport:\n type: stdio\n command: npx\n args: [\"-y\", \"@zed-industries/codex-acp@latest\"]\n\n pi-acp:\n name: pi-acp\n transport:\n type: stdio\n command: npx\n args: [\"-y\", \"pi-acp@latest\"] # spawns `pi --mode rpc`; needs `pi` on PATH\n```\n\nThe bundled catalog ids are `claude-acp`, `codex-acp`, `gemini-cli`, `opencode`, `pi-acp`, `hermes-acp` (Nous Hermes Agent — native `hermes acp`), and `openclaw` (OpenClaw's gateway ACP bridge `openclaw acp`; auto-starts the profile's gateway), plus the interactive-only `grok` and `antigravity` (no ACP adapter — orchestrator/`launch` surfaces only, own auth).\n`pi-acp` wraps the [`pi`](https://github.com/earendil-works/pi) coding agent — install\nit (`npm i -g @earendil-works/pi-coding-agent`) and point pi at BitRouter with the\n`@bitrouter/pi` provider so pi's own model calls route back through the daemon.\n\n`bitrouter agents list` shows the bundled catalog; `--remote` also lists the official ACP agent registry (50+ agents). `bitrouter agents install ` prints a paste-ready stub — catalog first, then registry (`npx`/`uvx` entries, version-pinned; binary-only entries need manual install). `bitrouter agents check` verifies each configured agent answers `initialize`.\n\nAgents declared here are referenced by `--agent ` when launching a per-session substrate. Managers (GUI, AI agents, editors) spawn one process per session:\n\n```bash\nbitrouter acp serve --agent claude [--worktree ] [--config PATH]\n```\n\nEach spawned process is one session, exposes vanilla ACP over stdio, and exits when the manager disconnects. For headless one-shot use, `bitrouter acp prompt --agent claude ` streams NDJSON to stdout. See `references/sessions.md` for the full substrate model.\n\n## Apply changes\n\n```bash\nbitrouter reload # hot-reload running daemon\n# SIGHUP also reloads daemon-side config, but does not forward new shell env vars\n# or `bitrouter restart` for a clean cycle\n```\n\n`reload` also re-pushes the current shell's provider env vars into the daemon, so an `export OPENAI_API_KEY=new...; bitrouter reload` sequence rotates the key live.\n\n## What's not supported (don't suggest these)\n\nOlder versions of this skill mentioned features that **do not exist** in the v1 schema:\n\n- `cost_limits` (daily/monthly USD caps per provider) — not parsed.\n- `strategy: conditional` with `prompt_tokens` rules — not parsed.\n- `strategy: least_cost` — not parsed.\n- `safety_settings`, `features.computer_use`, `features.json_mode` provider blocks — not parsed.\n- `bitrouter providers add/remove/use/test/stats/export/import` subcommands — use `providers list`, `providers login`, and `providers logout`.\n- `bitrouter config reload/show` — `config validate` exists for CI-safe validation; use `bitrouter reload` for the daemon.\n"}, "122de07d23f09f3fedf4d7034c615a1add6f6bf6": {"t": 1785674698.4368534, "n": 22063, "h": "d795c664b5079153", "c": "# Providers & routing\n\nHow to configure `providers:`, `models:`, `mcp_servers:`, and `agents:` in `bitrouter.yaml`. Reflects the v1 schema in `crates/bitrouter-sdk/src/config/mod.rs` — fields and strategies not listed here do not exist.\n\n## Known providers\n\nbitrouter already knows how to talk to these providers — their `api_base`,\n`api_protocol`, and credential env var come from the **fetched provider\nregistry** (below), so listing one with an empty body in `bitrouter.yaml`\nenables it and the registry fills the rest. The definitions are **not** vendored\ninto the binary; they are fetched at startup and disk-cached (see the registry\nsection). The one in-binary exception is the hosted `bitrouter` cloud gateway.\n\n| Provider id | Env var | Auth | Notes |\n|---|---|---|---|\n| `openai` | `OPENAI_API_KEY` | Bearer | Chat Completions default; Responses API also live |\n| `anthropic` | `ANTHROPIC_API_KEY` | Header `x-api-key` | Messages API |\n| `google` | `GEMINI_API_KEY` | Header `x-goog-api-key` | Generative Language API (Gemini API key) — **not** `GOOGLE_API_KEY` |\n| `aws-bedrock` | `AWS_BEARER_TOKEN_BEDROCK` | Bearer | Bedrock `bedrock-mantle` OpenAI-compatible endpoints; region via `AWS_REGION` (default `us-east-1`). Native Converse features (cross-region profiles, Guardrails) not served |\n| `azure` | `AZURE_OPENAI_API_KEY` | Bearer | Azure OpenAI `/openai/v1` surface; set `AZURE_OPENAI_RESOURCE` to your resource name (unset ⇒ provider inactive). Entra ID tokens also work as the bearer |\n| `vertex` | `VERTEX_EXPRESS_API_KEY` | Header `x-goog-api-key` | Vertex AI, currently **Express Mode only** → **Gemini models only** (no Claude/Llama/Mistral), global endpoint (region ignored). Partner models are commented out in the registry entry pending service-account OAuth support |\n| `openrouter` | `OPENROUTER_API_KEY` | Bearer | Forwards every OpenRouter model |\n| `claude-code` | `CLAUDE_CODE_OAUTH_TOKEN` or local OAuth | Claude Code subscription | `bitrouter providers login claude-code`, or a long-lived `claude setup-token` value for headless use; distinct from `anthropic` API-key billing |\n| `github-copilot` | — (local OAuth) | Device flow | `bitrouter providers login github-copilot`; per-model protocol map (Claude → Anthropic, gpt-5.x-codex → Responses, rest → Chat) |\n| `openai-codex` | — (local PKCE) | ChatGPT subscription | `bitrouter providers login openai-codex` |\n| `supergrok` | — (local OAuth) | SuperGrok subscription | `bitrouter providers login supergrok`; imports the Grok CLI session (`~/.grok/auth.json`), distinct from `xai` API-key billing |\n| `google-ai` | — (local OAuth) | Google AI (Antigravity) subscription | `bitrouter providers login google-ai`; imports the `agy` CLI keyring session, custom cloudcode-pa protocol, distinct from `google` API-key billing. Unofficial — uses your own Google account |\n| `opencode-zen` | `OPENCODE_ZEN_API_KEY` | Bearer | Per-family protocol routing |\n| `opencode-go` | `OPENCODE_ZEN_API_KEY` (shared) | Bearer | Low-cost subscription tier — same credential as Zen |\n\nZero-config mode auto-enables every API-key provider whose env var is present;\nan API-key provider without its credential gets `active: false` and falls out of\nthe routing table. Local-OAuth/PKCE providers (`claude-code`, `github-copilot`,\n`openai-codex`, `supergrok`, `google-ai`) are enabled by `bitrouter providers login`, not an env var. **First run with no network\nand no cache**: the registry is empty, so only fully-specified local providers\nand the in-binary `bitrouter` cloud gateway are available — the known-provider\nshorthand needs one prior successful fetch. Startup still succeeds.\n\n`CLAUDE_CODE_OAUTH_TOKEN` is the local-OAuth exception: a non-empty value\nauto-enables `claude-code` and is captured as a process-local, non-refreshable\nBearer when the daemon constructs the provider. Set it before `start` (or use a\nreload path that reconstructs provider auth), keep it out of YAML and command\narguments, and route standard Anthropic clients explicitly to\n`claude-code:`. BitRouter adds the subscription upstream's required\nOAuth/Claude-Code headers and prepends the current Claude Agent SDK identity\nsystem block without dropping the downstream client's system instructions. It\nalso unwraps LiteLLM `extra_body` extensions and drops its harness-only\n`session_id`, which remains available through BitRouter's workflow headers.\nBare Claude models still do not auto-cascade onto a subscription provider.\n\n## Provider registry (catalog + priority)\n\nBitRouter fetches the public provider registry from\n`https://github.com/bitrouter/bitrouter/tree/main/registry` at startup and on\nreload: a deterministic catalog of public providers (their transport + auth),\nthe models, and which providers serve them. It is fetched from the generated\n`dist/registry/` artifacts, disk-cached under\n`$XDG_CACHE_HOME/bitrouter/registry.json` (24h TTL, stale-fallback on a\nnetwork outage), and merged into the routing table. If a fetch fails the cache\nis reused; with no cache (first run, offline) the registry is empty and only\nlocally-configured providers route. The merge routes a model id\n(e.g. `anthropic/claude-sonnet-4.6`) to a provider that serves it, translating\nto that provider's own upstream id. Providers may serve models beyond the\ncurated `registry/models` catalog (BYOK / BYO-subscription extras); those route\nthe same way — the curated catalog is the blessed default set, not a routing\ngate.\n\nRules:\n\n- **Public providers only.** Every public registry provider is merged; only\n `private` ones (invite-only entries, no public registration) are skipped. The\n registry classifies each provider by how a caller obtains\n access: `api_key` (a portable key), `local_oauth` / `local_pkce` (a local\n interactive login — e.g. `github-copilot`, `openai-codex`), or `private`.\n- **Credential-gated.** An `api_key` provider becomes routable only when its key\n is present, read from the convention `${NAME}_API_KEY` (uppercased, hyphens →\n underscores — e.g. `DEEPSEEK_API_KEY`, `ZAI_CODING_PLAN_API_KEY`) or from the\n provider's declared env var. No key ⇒ not enabled. Declare the provider\n explicitly with `api_key: ${MY_VAR}` to override the env-var name. A\n `local_oauth` / `local_pkce` provider is not env-gated — it activates after\n `bitrouter providers login `.\n- **Full catalog via the sync channel.** A provider may declare an `auto_sync`\n feed (the channel the registry itself syncs from). BitRouter reads the same\n channel to pull the provider's **full** catalog beyond the registry seed\n subset: a `v1_models` feed (the gateways) is probed at `GET {api_base}/models`\n on startup; a `models_dev` feed pulls the provider's models from models.dev.\n The registry seed models keep the highest route priority.\n- **BitRouter Cloud is a normal public provider.** The public registry entry is\n still named `bitrouter`, but OSS treats it as BitRouter Cloud and discovers\n its cloud-owned model list from `/models`.\n\n```yaml\nregistry:\n enabled: true # default; set false (or inherit_defaults: false) to disable the merge\n url: \"https://raw.githubusercontent.com/bitrouter/bitrouter/main/dist/registry\"\n provider_priority: # default ladder, highest first\n - first-party-subscription\n - gateway-subscription\n - first-party-api\n - bitrouter-cloud\n - third-party-api\n```\n\nWhen several active providers serve the same canonical model, the auto-cascade\norders them by this `provider_priority` ladder (a provider's class comes from\nthe registry or explicit provider config). Override per provider with `class:` or a numeric\n`priority:` (lower = preferred) under `providers.`.\n\n## Minimal config\n\n```yaml\n# bitrouter.yaml — the smallest file that does useful work\nserver:\n listen: \"127.0.0.1:4356\"\n skip_auth: true\n\nproviders:\n openai: {} # uses OPENAI_API_KEY\n anthropic: {} # uses ANTHROPIC_API_KEY\n google: {} # uses GEMINI_API_KEY\n\ninherit_defaults: true\n```\n\n`inherit_defaults: true` (the workspace default) is what makes `openai: {}` work — it fills `api_base`, `api_protocol`, the model catalog, and the env-resolved `api_key` from the registry entry. Set it to `false` only when you want to override everything explicitly.\n\n## Custom OpenAI-compatible providers\n\nAnything that speaks OpenAI Chat Completions on a known base URL fits here. No registry entry needed.\n\n```yaml\nproviders:\n ollama:\n api_base: \"http://localhost:11434/v1\"\n api_protocol: { \"*\": openai } # default; can be omitted\n models:\n - { id: \"llama3.1:70b\" }\n - { id: \"codellama:34b\" }\n\n groq:\n api_base: \"https://api.groq.com/openai/v1\"\n api_key: \"${GROQ_API_KEY}\"\n auto_discover: true # pull /v1/models at startup + reload\n\n vllm:\n api_base: \"https://llm.internal.example.com/v1\"\n api_key: \"${VLLM_API_KEY}\"\n models:\n - { id: \"my-org/mixtral-8x22b\" }\n```\n\n> Azure OpenAI and Amazon Bedrock are registry providers now (see\n> *Known providers*) — you do not hand-write them here. Set their env vars (and\n> `AZURE_OPENAI_RESOURCE` / `AWS_REGION`) and they self-enable. Google `vertex`\n> is registry-backed **in Express Mode only** (Gemini-only, static `VERTEX_EXPRESS_API_KEY`);\n> the full Vertex catalog (Claude/Llama on regional endpoints) is commented out in\n> the registry entry, pending service-account OAuth support.\n\n`api_protocol` accepts a glob-prefix pattern map: `{ \"claude-*\": anthropic, \"gpt-5.5-codex\": responses, \"*\": openai }` is valid and matches most-specific-first.\n\n## Multi-account (failover or balance)\n\nWhen you hold two subscriptions to the same upstream, expand the provider into accounts:\n\n```yaml\nproviders:\n opencode-go:\n account_strategy: failover # or \"balance\"\n accounts:\n - { api_key: \"${OPENCODE_GO_KEY_A}\", label: primary }\n - { api_key: \"${OPENCODE_GO_KEY_B}\", label: backup }\n```\n\n- `failover` (default): try `primary` first; drop to `backup` on retryable failure (5xx / 429 / timeout / credit-exhaustion).\n- `balance`: process-random rotation so each account roughly shares load; the remaining accounts still act as failover targets for that request.\n\nPer-account `api_base` is allowed for multi-region setups — empty inherits the provider's.\n\n## Rate limits\n\n```yaml\nproviders:\n openai:\n rate_limits:\n \"gpt-4*\": { requests_per_minute: 60, tokens_per_minute: 90000 }\n \"gpt-4o-mini\": { requests_per_minute: 200, tokens_per_minute: 200000 }\n```\n\nGlob-prefix patterns, same precedence as `api_protocol`. Each `(provider, pattern)` bucket gets an independent window.\n\n## Upstream timeouts\n\nGlobal defaults for the outbound client that calls providers live under the\ntop-level `upstream.timeouts` block; any provider overrides them under\n`providers..timeouts`. All fields are seconds and optional — an unset\nprovider field inherits the resolved global value, and an unset global inherits\nthe built-in default.\n\n```yaml\nupstream:\n timeouts:\n connect_secs: 10 # TCP connect (default 10)\n read_secs: 120 # idle/per-read: fires when the upstream goes\n # silent this long, INCLUDING mid-stream\n # (the stream-idle guard). Default 120.\n pool_idle_secs: 90 # idle pooled-connection eviction (default 90)\n tcp_keepalive_secs: 60 # TCP keepalive probe interval (default 60)\n # total_secs: # overall wall-clock cap for the whole\n # request/stream. Opt-in, OFF by default —\n # setting it bounds total stream duration, so\n # keep it generous for reasoning/agentic models.\nproviders:\n slow-reasoner:\n timeouts:\n read_secs: 300 # this provider tolerates longer silences\n total_secs: 1800 # and gets its own 30-min wall-clock cap\n```\n\nNotes:\n- `read_secs` is a per-read (idle) timeout — it resets on every chunk, so it is\n what catches an upstream that stalls mid-SSE-stream. A mid-stream fire maps to\n a `504` upstream-timeout (and triggers fallback only if it happens before the\n first byte; once bytes are streaming the request can't fall back).\n- `total_secs` is unset by default on purpose: an overall cap would kill\n legitimately long agentic/reasoning streams.\n- Timeouts are **not** inherited via a provider's `derives:` chain, only from\n the built-in global default.\n\n## Tags & routing\n\nRouting prefs filter providers by tag — `require_tags: [cheap]` keeps only tagged providers in scope:\n\n```yaml\nproviders:\n openai:\n tags: [cloud, paid]\n ollama:\n tags: [local, free, cheap]\n```\n\n## Derives (provider inheritance)\n\n```yaml\nproviders:\n openai:\n api_key: \"${OPENAI_API_KEY}\"\n openai-dev:\n derives: openai\n api_key: \"${OPENAI_DEV_KEY}\" # only the credential differs\n```\n\n`derives` flows `api_protocol`, `rate_limits`, `models`, `tags`, and `auto_discover` from the named provider into empty fields of this one.\n\n## Virtual models\n\nThe top-level `models:` block defines named models that don't map 1:1 to a single provider. The full schema is broader than this skill covers — see `crates/bitrouter-sdk/src/config/mod.rs::VirtualModel`. The pattern that 90% of users want is a simple alias:\n\n```yaml\nmodels:\n fast:\n # routes to whichever provider declares this id first\n upstream_id: \"claude-haiku-4-5\"\n cheap-coder:\n upstream_id: \"opencode-go/glm-5.1\"\n```\n\nFor richer routing (priority chains, splits), check the v1 docs at — the schema is richer than what was previously documented in the agent-skills repo.\n\n## MCP servers\n\n```yaml\nmcp_servers:\n ctx7:\n name: ctx7\n transport:\n type: http\n url: https://mcp.context7.com/mcp\n headers:\n Authorization: \"Bearer ${CTX7_TOKEN}\"\n\n git:\n name: git\n transport:\n type: stdio\n command: uvx\n args: [\"mcp-server-git\"]\n```\n\nOnce configured, `POST /mcp/` proxies JSON-RPC through. Inspect with `bitrouter tools list` / `bitrouter tools status` / `bitrouter tools discover `. To find servers without hand-writing the block: `bitrouter mcp search ` / `bitrouter mcp list` browse the official MCP registry (with an install-support column), and `bitrouter mcp add ` prints a reviewed stub to paste under `mcp_servers:`.\n\n## Server tools (router-executed)\n\nAttach an MCP server's tools to LLM requests: BitRouter advertises them to the model, executes the model's calls to them itself, and loops until the model stops calling them — all inside one client response. The named servers must also be declared under `mcp_servers:` above.\n\n```yaml\nserver_tools:\n mcp_servers: [ctx7, git] # ids from mcp_servers: above\n max_iterations: 10 # optional; max tool rounds per request (default 10)\n```\n\nTool names are prefixed (`__`, or the server's `tool_prefix`) so they can't collide with the caller's own tools. Empty/unset leaves the pipeline single-shot. This is the inverse of `bitrouter mcp serve` (which makes BitRouter an MCP *server*): here BitRouter is an MCP *client* consuming those tools inside the request loop.\n\n### Model-backed server tools (advisor / sub-agent / fusion)\n\nBitRouter can also expose server tools backed by *nested model completions*. Each is advertised only when the caller declares it (a provider-defined tool in the request `tools`, e.g. `{\"type\":\"bitrouter:advisor\"}`) and runs on a loop-less sub-pipeline metered to the caller.\n\n```yaml\nserver_tools:\n advisor: true # bitrouter:advisor — consult a stronger model mid-task\n subagent: true # bitrouter:subagent — delegate a task to a worker model\n fusion: # bitrouter:fusion — multi-model panel + judge deliberation\n panel: [anthropic/claude-opus-4.8, openai/gpt-latest, google/gemini-pro]\n judge: anthropic/claude-opus-4.8\n # optional: outer_model, alias (default bitrouter/fusion), synthesizer, web_tools\n```\n\nSetting `fusion:` also enables the `bitrouter/fusion` model alias, which expands a request to the configured panel + judge. The judge *compares* the panel's answers (consensus / contradictions / partial coverage / unique insights / blind spots); the calling model writes the final answer from that analysis.\n\n### Built-in web search (BYOK)\n\nThe `web_search` server tool gives *any* model routed through BitRouter a web search, served by a search backend you bring keys for. Advertised only when the caller declares `{\"type\":\"bitrouter:web_search\"}` (optionally with `backend` / `max_results` overrides). The model calls `web_search` with a `query`; BitRouter runs it and returns `{backend, answer?, results:[{url,title?,snippet?,content?,published?,score?}]}` — `answer` is present only for the answer-engine `native` backend.\n\n```yaml\nserver_tools:\n web_search:\n max_results: 5 # optional default cap (caller may lower it)\n backends: # preference + failover order; first that resolves a key is default\n - kind: parallel # HTTP, key from api_key or PARALLEL_API_KEY\n - kind: exa # HTTP, key from api_key or EXA_API_KEY\n - kind: firecrawl # HTTP, key from api_key or FIRECRAWL_API_KEY\n - kind: tavily # HTTP, key from api_key or TAVILY_API_KEY\n - kind: native # reuse a provider's NATIVE search for every model\n name: native # backend id a caller pins (default \"native\")\n model: anthropic/claude-opus-4.8\n tool: { type: \"anthropic:web_search_20250305\" }\n```\n\nHTTP backends (`parallel` / `exa` / `firecrawl` / `tavily`) take an optional `api_key` (supports `${VAR}`) and `api_base`; a backend with no resolvable key is skipped. The `native` backend runs a nested completion (so it needs a routable model) — it forwards a provider's own search tool, making one provider's native web search usable from models that lack it.\n\n### Built-in web fetch (BYOK)\n\nThe `web_fetch` server tool gives any model routed through BitRouter the ability to fetch and read a specific URL's full content, served by a BYOK extraction backend. Advertised only when the caller declares `{\"type\":\"bitrouter:web_fetch\"}` (optionally with `backend` / `max_content_tokens` overrides). The model calls `web_fetch` with a `url`; BitRouter fetches it and returns `{status, backend, url, title?, content, published?}`.\n\n```yaml\nserver_tools:\n web_fetch:\n max_content_tokens: 25000 # default per-fetch content cap (caller may lower)\n backends: # preference / failover order\n - kind: exa # POST /contents, key from api_key or EXA_API_KEY\n - kind: firecrawl # POST /v2/scrape, key from api_key or FIRECRAWL_API_KEY\n - kind: tavily # POST /extract, key from api_key or TAVILY_API_KEY\n```\n\nBYOK extraction happens on the provider's infrastructure (BitRouter does not dereference the URL itself), so the backends own the fetch safety surface.\n\n## ACP agents\n\n```yaml\nagents:\n claude:\n name: claude\n transport:\n type: stdio\n command: npx\n args: [\"-y\", \"@zed-industries/claude-code-acp@latest\"]\n\n codex:\n name: codex\n transport:\n type: stdio\n command: npx\n args: [\"-y\", \"@zed-industries/codex-acp@latest\"]\n\n pi-acp:\n name: pi-acp\n transport:\n type: stdio\n command: npx\n args: [\"-y\", \"pi-acp@latest\"] # spawns `pi --mode rpc`; needs `pi` on PATH\n```\n\nThe bundled catalog ids are `claude-acp`, `codex-acp`, `gemini-cli`, `opencode`, `pi-acp`, `hermes-acp` (Nous Hermes Agent — native `hermes acp`), and `openclaw` (OpenClaw's gateway ACP bridge `openclaw acp`; auto-starts the profile's gateway), plus the interactive-only `grok` and `antigravity` (no ACP adapter — orchestrator/`launch` surfaces only, own auth).\n`pi-acp` wraps the [`pi`](https://github.com/earendil-works/pi) coding agent — install\nit (`npm i -g @earendil-works/pi-coding-agent`) and point pi at BitRouter with the\n`@bitrouter/pi` provider so pi's own model calls route back through the daemon.\n\n`bitrouter agents list` shows the bundled catalog; `--remote` also lists the official ACP agent registry (50+ agents). `bitrouter agents install ` prints a paste-ready stub — catalog first, then registry (`npx`/`uvx` entries, version-pinned; binary-only entries need manual install). `bitrouter agents check` verifies each configured agent answers `initialize`.\n\nAgents declared here are referenced by `--agent ` when launching a per-session substrate. Managers (GUI, AI agents, editors) spawn one process per session:\n\n```bash\nbitrouter acp serve --agent claude [--worktree ] [--config PATH]\n```\n\nEach spawned process is one session, exposes vanilla ACP over stdio, and exits when the manager disconnects. For headless one-shot use, `bitrouter acp prompt --agent claude ` streams NDJSON to stdout. See `references/sessions.md` for the full substrate model.\n\n## Apply changes\n\n```bash\nbitrouter reload # hot-reload running daemon\n# SIGHUP also reloads daemon-side config, but does not forward new shell env vars\n# or `bitrouter restart` for a clean cycle\n```\n\n`reload` also re-pushes the current shell's provider env vars into the daemon, so an `export OPENAI_API_KEY=new...; bitrouter reload` sequence rotates the key live.\n\n## What's not supported (don't suggest these)\n\nOlder versions of this skill mentioned features that **do not exist** in the v1 schema:\n\n- `cost_limits` (daily/monthly USD caps per provider) — not parsed.\n- `strategy: conditional` with `prompt_tokens` rules — not parsed.\n- `strategy: least_cost` — not parsed.\n- `safety_settings`, `features.computer_use`, `features.json_mode` provider blocks — not parsed.\n- `bitrouter providers add/remove/use/test/stats/export/import` subcommands — use `providers list`, `providers login`, and `providers logout`.\n- `bitrouter config reload/show` — `config validate` exists for CI-safe validation; use `bitrouter reload` for the daemon.\n"}, "004ca3550a8594f59e272d7e405642be4dd572d5": {"t": 1785846946.9575205, "n": 22063, "h": "d795c664b5079153", "c": "# Providers & routing\n\nHow to configure `providers:`, `models:`, `mcp_servers:`, and `agents:` in `bitrouter.yaml`. Reflects the v1 schema in `crates/bitrouter-sdk/src/config/mod.rs` — fields and strategies not listed here do not exist.\n\n## Known providers\n\nbitrouter already knows how to talk to these providers — their `api_base`,\n`api_protocol`, and credential env var come from the **fetched provider\nregistry** (below), so listing one with an empty body in `bitrouter.yaml`\nenables it and the registry fills the rest. The definitions are **not** vendored\ninto the binary; they are fetched at startup and disk-cached (see the registry\nsection). The one in-binary exception is the hosted `bitrouter` cloud gateway.\n\n| Provider id | Env var | Auth | Notes |\n|---|---|---|---|\n| `openai` | `OPENAI_API_KEY` | Bearer | Chat Completions default; Responses API also live |\n| `anthropic` | `ANTHROPIC_API_KEY` | Header `x-api-key` | Messages API |\n| `google` | `GEMINI_API_KEY` | Header `x-goog-api-key` | Generative Language API (Gemini API key) — **not** `GOOGLE_API_KEY` |\n| `aws-bedrock` | `AWS_BEARER_TOKEN_BEDROCK` | Bearer | Bedrock `bedrock-mantle` OpenAI-compatible endpoints; region via `AWS_REGION` (default `us-east-1`). Native Converse features (cross-region profiles, Guardrails) not served |\n| `azure` | `AZURE_OPENAI_API_KEY` | Bearer | Azure OpenAI `/openai/v1` surface; set `AZURE_OPENAI_RESOURCE` to your resource name (unset ⇒ provider inactive). Entra ID tokens also work as the bearer |\n| `vertex` | `VERTEX_EXPRESS_API_KEY` | Header `x-goog-api-key` | Vertex AI, currently **Express Mode only** → **Gemini models only** (no Claude/Llama/Mistral), global endpoint (region ignored). Partner models are commented out in the registry entry pending service-account OAuth support |\n| `openrouter` | `OPENROUTER_API_KEY` | Bearer | Forwards every OpenRouter model |\n| `claude-code` | `CLAUDE_CODE_OAUTH_TOKEN` or local OAuth | Claude Code subscription | `bitrouter providers login claude-code`, or a long-lived `claude setup-token` value for headless use; distinct from `anthropic` API-key billing |\n| `github-copilot` | — (local OAuth) | Device flow | `bitrouter providers login github-copilot`; per-model protocol map (Claude → Anthropic, gpt-5.x-codex → Responses, rest → Chat) |\n| `openai-codex` | — (local PKCE) | ChatGPT subscription | `bitrouter providers login openai-codex` |\n| `supergrok` | — (local OAuth) | SuperGrok subscription | `bitrouter providers login supergrok`; imports the Grok CLI session (`~/.grok/auth.json`), distinct from `xai` API-key billing |\n| `google-ai` | — (local OAuth) | Google AI (Antigravity) subscription | `bitrouter providers login google-ai`; imports the `agy` CLI keyring session, custom cloudcode-pa protocol, distinct from `google` API-key billing. Unofficial — uses your own Google account |\n| `opencode-zen` | `OPENCODE_ZEN_API_KEY` | Bearer | Per-family protocol routing |\n| `opencode-go` | `OPENCODE_ZEN_API_KEY` (shared) | Bearer | Low-cost subscription tier — same credential as Zen |\n\nZero-config mode auto-enables every API-key provider whose env var is present;\nan API-key provider without its credential gets `active: false` and falls out of\nthe routing table. Local-OAuth/PKCE providers (`claude-code`, `github-copilot`,\n`openai-codex`, `supergrok`, `google-ai`) are enabled by `bitrouter providers login`, not an env var. **First run with no network\nand no cache**: the registry is empty, so only fully-specified local providers\nand the in-binary `bitrouter` cloud gateway are available — the known-provider\nshorthand needs one prior successful fetch. Startup still succeeds.\n\n`CLAUDE_CODE_OAUTH_TOKEN` is the local-OAuth exception: a non-empty value\nauto-enables `claude-code` and is captured as a process-local, non-refreshable\nBearer when the daemon constructs the provider. Set it before `start` (or use a\nreload path that reconstructs provider auth), keep it out of YAML and command\narguments, and route standard Anthropic clients explicitly to\n`claude-code:`. BitRouter adds the subscription upstream's required\nOAuth/Claude-Code headers and prepends the current Claude Agent SDK identity\nsystem block without dropping the downstream client's system instructions. It\nalso unwraps LiteLLM `extra_body` extensions and drops its harness-only\n`session_id`, which remains available through BitRouter's workflow headers.\nBare Claude models still do not auto-cascade onto a subscription provider.\n\n## Provider registry (catalog + priority)\n\nBitRouter fetches the public provider registry from\n`https://github.com/bitrouter/bitrouter/tree/main/registry` at startup and on\nreload: a deterministic catalog of public providers (their transport + auth),\nthe models, and which providers serve them. It is fetched from the generated\n`dist/registry/` artifacts, disk-cached under\n`$XDG_CACHE_HOME/bitrouter/registry.json` (24h TTL, stale-fallback on a\nnetwork outage), and merged into the routing table. If a fetch fails the cache\nis reused; with no cache (first run, offline) the registry is empty and only\nlocally-configured providers route. The merge routes a model id\n(e.g. `anthropic/claude-sonnet-4.6`) to a provider that serves it, translating\nto that provider's own upstream id. Providers may serve models beyond the\ncurated `registry/models` catalog (BYOK / BYO-subscription extras); those route\nthe same way — the curated catalog is the blessed default set, not a routing\ngate.\n\nRules:\n\n- **Public providers only.** Every public registry provider is merged; only\n `private` ones (invite-only entries, no public registration) are skipped. The\n registry classifies each provider by how a caller obtains\n access: `api_key` (a portable key), `local_oauth` / `local_pkce` (a local\n interactive login — e.g. `github-copilot`, `openai-codex`), or `private`.\n- **Credential-gated.** An `api_key` provider becomes routable only when its key\n is present, read from the convention `${NAME}_API_KEY` (uppercased, hyphens →\n underscores — e.g. `DEEPSEEK_API_KEY`, `ZAI_CODING_PLAN_API_KEY`) or from the\n provider's declared env var. No key ⇒ not enabled. Declare the provider\n explicitly with `api_key: ${MY_VAR}` to override the env-var name. A\n `local_oauth` / `local_pkce` provider is not env-gated — it activates after\n `bitrouter providers login `.\n- **Full catalog via the sync channel.** A provider may declare an `auto_sync`\n feed (the channel the registry itself syncs from). BitRouter reads the same\n channel to pull the provider's **full** catalog beyond the registry seed\n subset: a `v1_models` feed (the gateways) is probed at `GET {api_base}/models`\n on startup; a `models_dev` feed pulls the provider's models from models.dev.\n The registry seed models keep the highest route priority.\n- **BitRouter Cloud is a normal public provider.** The public registry entry is\n still named `bitrouter`, but OSS treats it as BitRouter Cloud and discovers\n its cloud-owned model list from `/models`.\n\n```yaml\nregistry:\n enabled: true # default; set false (or inherit_defaults: false) to disable the merge\n url: \"https://raw.githubusercontent.com/bitrouter/bitrouter/main/dist/registry\"\n provider_priority: # default ladder, highest first\n - first-party-subscription\n - gateway-subscription\n - first-party-api\n - bitrouter-cloud\n - third-party-api\n```\n\nWhen several active providers serve the same canonical model, the auto-cascade\norders them by this `provider_priority` ladder (a provider's class comes from\nthe registry or explicit provider config). Override per provider with `class:` or a numeric\n`priority:` (lower = preferred) under `providers.`.\n\n## Minimal config\n\n```yaml\n# bitrouter.yaml — the smallest file that does useful work\nserver:\n listen: \"127.0.0.1:4356\"\n skip_auth: true\n\nproviders:\n openai: {} # uses OPENAI_API_KEY\n anthropic: {} # uses ANTHROPIC_API_KEY\n google: {} # uses GEMINI_API_KEY\n\ninherit_defaults: true\n```\n\n`inherit_defaults: true` (the workspace default) is what makes `openai: {}` work — it fills `api_base`, `api_protocol`, the model catalog, and the env-resolved `api_key` from the registry entry. Set it to `false` only when you want to override everything explicitly.\n\n## Custom OpenAI-compatible providers\n\nAnything that speaks OpenAI Chat Completions on a known base URL fits here. No registry entry needed.\n\n```yaml\nproviders:\n ollama:\n api_base: \"http://localhost:11434/v1\"\n api_protocol: { \"*\": openai } # default; can be omitted\n models:\n - { id: \"llama3.1:70b\" }\n - { id: \"codellama:34b\" }\n\n groq:\n api_base: \"https://api.groq.com/openai/v1\"\n api_key: \"${GROQ_API_KEY}\"\n auto_discover: true # pull /v1/models at startup + reload\n\n vllm:\n api_base: \"https://llm.internal.example.com/v1\"\n api_key: \"${VLLM_API_KEY}\"\n models:\n - { id: \"my-org/mixtral-8x22b\" }\n```\n\n> Azure OpenAI and Amazon Bedrock are registry providers now (see\n> *Known providers*) — you do not hand-write them here. Set their env vars (and\n> `AZURE_OPENAI_RESOURCE` / `AWS_REGION`) and they self-enable. Google `vertex`\n> is registry-backed **in Express Mode only** (Gemini-only, static `VERTEX_EXPRESS_API_KEY`);\n> the full Vertex catalog (Claude/Llama on regional endpoints) is commented out in\n> the registry entry, pending service-account OAuth support.\n\n`api_protocol` accepts a glob-prefix pattern map: `{ \"claude-*\": anthropic, \"gpt-5.5-codex\": responses, \"*\": openai }` is valid and matches most-specific-first.\n\n## Multi-account (failover or balance)\n\nWhen you hold two subscriptions to the same upstream, expand the provider into accounts:\n\n```yaml\nproviders:\n opencode-go:\n account_strategy: failover # or \"balance\"\n accounts:\n - { api_key: \"${OPENCODE_GO_KEY_A}\", label: primary }\n - { api_key: \"${OPENCODE_GO_KEY_B}\", label: backup }\n```\n\n- `failover` (default): try `primary` first; drop to `backup` on retryable failure (5xx / 429 / timeout / credit-exhaustion).\n- `balance`: process-random rotation so each account roughly shares load; the remaining accounts still act as failover targets for that request.\n\nPer-account `api_base` is allowed for multi-region setups — empty inherits the provider's.\n\n## Rate limits\n\n```yaml\nproviders:\n openai:\n rate_limits:\n \"gpt-4*\": { requests_per_minute: 60, tokens_per_minute: 90000 }\n \"gpt-4o-mini\": { requests_per_minute: 200, tokens_per_minute: 200000 }\n```\n\nGlob-prefix patterns, same precedence as `api_protocol`. Each `(provider, pattern)` bucket gets an independent window.\n\n## Upstream timeouts\n\nGlobal defaults for the outbound client that calls providers live under the\ntop-level `upstream.timeouts` block; any provider overrides them under\n`providers..timeouts`. All fields are seconds and optional — an unset\nprovider field inherits the resolved global value, and an unset global inherits\nthe built-in default.\n\n```yaml\nupstream:\n timeouts:\n connect_secs: 10 # TCP connect (default 10)\n read_secs: 120 # idle/per-read: fires when the upstream goes\n # silent this long, INCLUDING mid-stream\n # (the stream-idle guard). Default 120.\n pool_idle_secs: 90 # idle pooled-connection eviction (default 90)\n tcp_keepalive_secs: 60 # TCP keepalive probe interval (default 60)\n # total_secs: # overall wall-clock cap for the whole\n # request/stream. Opt-in, OFF by default —\n # setting it bounds total stream duration, so\n # keep it generous for reasoning/agentic models.\nproviders:\n slow-reasoner:\n timeouts:\n read_secs: 300 # this provider tolerates longer silences\n total_secs: 1800 # and gets its own 30-min wall-clock cap\n```\n\nNotes:\n- `read_secs` is a per-read (idle) timeout — it resets on every chunk, so it is\n what catches an upstream that stalls mid-SSE-stream. A mid-stream fire maps to\n a `504` upstream-timeout (and triggers fallback only if it happens before the\n first byte; once bytes are streaming the request can't fall back).\n- `total_secs` is unset by default on purpose: an overall cap would kill\n legitimately long agentic/reasoning streams.\n- Timeouts are **not** inherited via a provider's `derives:` chain, only from\n the built-in global default.\n\n## Tags & routing\n\nRouting prefs filter providers by tag — `require_tags: [cheap]` keeps only tagged providers in scope:\n\n```yaml\nproviders:\n openai:\n tags: [cloud, paid]\n ollama:\n tags: [local, free, cheap]\n```\n\n## Derives (provider inheritance)\n\n```yaml\nproviders:\n openai:\n api_key: \"${OPENAI_API_KEY}\"\n openai-dev:\n derives: openai\n api_key: \"${OPENAI_DEV_KEY}\" # only the credential differs\n```\n\n`derives` flows `api_protocol`, `rate_limits`, `models`, `tags`, and `auto_discover` from the named provider into empty fields of this one.\n\n## Virtual models\n\nThe top-level `models:` block defines named models that don't map 1:1 to a single provider. The full schema is broader than this skill covers — see `crates/bitrouter-sdk/src/config/mod.rs::VirtualModel`. The pattern that 90% of users want is a simple alias:\n\n```yaml\nmodels:\n fast:\n # routes to whichever provider declares this id first\n upstream_id: \"claude-haiku-4-5\"\n cheap-coder:\n upstream_id: \"opencode-go/glm-5.1\"\n```\n\nFor richer routing (priority chains, splits), check the v1 docs at — the schema is richer than what was previously documented in the agent-skills repo.\n\n## MCP servers\n\n```yaml\nmcp_servers:\n ctx7:\n name: ctx7\n transport:\n type: http\n url: https://mcp.context7.com/mcp\n headers:\n Authorization: \"Bearer ${CTX7_TOKEN}\"\n\n git:\n name: git\n transport:\n type: stdio\n command: uvx\n args: [\"mcp-server-git\"]\n```\n\nOnce configured, `POST /mcp/` proxies JSON-RPC through. Inspect with `bitrouter tools list` / `bitrouter tools status` / `bitrouter tools discover `. To find servers without hand-writing the block: `bitrouter mcp search ` / `bitrouter mcp list` browse the official MCP registry (with an install-support column), and `bitrouter mcp add ` prints a reviewed stub to paste under `mcp_servers:`.\n\n## Server tools (router-executed)\n\nAttach an MCP server's tools to LLM requests: BitRouter advertises them to the model, executes the model's calls to them itself, and loops until the model stops calling them — all inside one client response. The named servers must also be declared under `mcp_servers:` above.\n\n```yaml\nserver_tools:\n mcp_servers: [ctx7, git] # ids from mcp_servers: above\n max_iterations: 10 # optional; max tool rounds per request (default 10)\n```\n\nTool names are prefixed (`__`, or the server's `tool_prefix`) so they can't collide with the caller's own tools. Empty/unset leaves the pipeline single-shot. This is the inverse of `bitrouter mcp serve` (which makes BitRouter an MCP *server*): here BitRouter is an MCP *client* consuming those tools inside the request loop.\n\n### Model-backed server tools (advisor / sub-agent / fusion)\n\nBitRouter can also expose server tools backed by *nested model completions*. Each is advertised only when the caller declares it (a provider-defined tool in the request `tools`, e.g. `{\"type\":\"bitrouter:advisor\"}`) and runs on a loop-less sub-pipeline metered to the caller.\n\n```yaml\nserver_tools:\n advisor: true # bitrouter:advisor — consult a stronger model mid-task\n subagent: true # bitrouter:subagent — delegate a task to a worker model\n fusion: # bitrouter:fusion — multi-model panel + judge deliberation\n panel: [anthropic/claude-opus-4.8, openai/gpt-latest, google/gemini-pro]\n judge: anthropic/claude-opus-4.8\n # optional: outer_model, alias (default bitrouter/fusion), synthesizer, web_tools\n```\n\nSetting `fusion:` also enables the `bitrouter/fusion` model alias, which expands a request to the configured panel + judge. The judge *compares* the panel's answers (consensus / contradictions / partial coverage / unique insights / blind spots); the calling model writes the final answer from that analysis.\n\n### Built-in web search (BYOK)\n\nThe `web_search` server tool gives *any* model routed through BitRouter a web search, served by a search backend you bring keys for. Advertised only when the caller declares `{\"type\":\"bitrouter:web_search\"}` (optionally with `backend` / `max_results` overrides). The model calls `web_search` with a `query`; BitRouter runs it and returns `{backend, answer?, results:[{url,title?,snippet?,content?,published?,score?}]}` — `answer` is present only for the answer-engine `native` backend.\n\n```yaml\nserver_tools:\n web_search:\n max_results: 5 # optional default cap (caller may lower it)\n backends: # preference + failover order; first that resolves a key is default\n - kind: parallel # HTTP, key from api_key or PARALLEL_API_KEY\n - kind: exa # HTTP, key from api_key or EXA_API_KEY\n - kind: firecrawl # HTTP, key from api_key or FIRECRAWL_API_KEY\n - kind: tavily # HTTP, key from api_key or TAVILY_API_KEY\n - kind: native # reuse a provider's NATIVE search for every model\n name: native # backend id a caller pins (default \"native\")\n model: anthropic/claude-opus-4.8\n tool: { type: \"anthropic:web_search_20250305\" }\n```\n\nHTTP backends (`parallel` / `exa` / `firecrawl` / `tavily`) take an optional `api_key` (supports `${VAR}`) and `api_base`; a backend with no resolvable key is skipped. The `native` backend runs a nested completion (so it needs a routable model) — it forwards a provider's own search tool, making one provider's native web search usable from models that lack it.\n\n### Built-in web fetch (BYOK)\n\nThe `web_fetch` server tool gives any model routed through BitRouter the ability to fetch and read a specific URL's full content, served by a BYOK extraction backend. Advertised only when the caller declares `{\"type\":\"bitrouter:web_fetch\"}` (optionally with `backend` / `max_content_tokens` overrides). The model calls `web_fetch` with a `url`; BitRouter fetches it and returns `{status, backend, url, title?, content, published?}`.\n\n```yaml\nserver_tools:\n web_fetch:\n max_content_tokens: 25000 # default per-fetch content cap (caller may lower)\n backends: # preference / failover order\n - kind: exa # POST /contents, key from api_key or EXA_API_KEY\n - kind: firecrawl # POST /v2/scrape, key from api_key or FIRECRAWL_API_KEY\n - kind: tavily # POST /extract, key from api_key or TAVILY_API_KEY\n```\n\nBYOK extraction happens on the provider's infrastructure (BitRouter does not dereference the URL itself), so the backends own the fetch safety surface.\n\n## ACP agents\n\n```yaml\nagents:\n claude:\n name: claude\n transport:\n type: stdio\n command: npx\n args: [\"-y\", \"@zed-industries/claude-code-acp@latest\"]\n\n codex:\n name: codex\n transport:\n type: stdio\n command: npx\n args: [\"-y\", \"@zed-industries/codex-acp@latest\"]\n\n pi-acp:\n name: pi-acp\n transport:\n type: stdio\n command: npx\n args: [\"-y\", \"pi-acp@latest\"] # spawns `pi --mode rpc`; needs `pi` on PATH\n```\n\nThe bundled catalog ids are `claude-acp`, `codex-acp`, `gemini-cli`, `opencode`, `pi-acp`, `hermes-acp` (Nous Hermes Agent — native `hermes acp`), and `openclaw` (OpenClaw's gateway ACP bridge `openclaw acp`; auto-starts the profile's gateway), plus the interactive-only `grok` and `antigravity` (no ACP adapter — orchestrator/`launch` surfaces only, own auth).\n`pi-acp` wraps the [`pi`](https://github.com/earendil-works/pi) coding agent — install\nit (`npm i -g @earendil-works/pi-coding-agent`) and point pi at BitRouter with the\n`@bitrouter/pi` provider so pi's own model calls route back through the daemon.\n\n`bitrouter agents list` shows the bundled catalog; `--remote` also lists the official ACP agent registry (50+ agents). `bitrouter agents install ` prints a paste-ready stub — catalog first, then registry (`npx`/`uvx` entries, version-pinned; binary-only entries need manual install). `bitrouter agents check` verifies each configured agent answers `initialize`.\n\nAgents declared here are referenced by `--agent ` when launching a per-session substrate. Managers (GUI, AI agents, editors) spawn one process per session:\n\n```bash\nbitrouter acp serve --agent claude [--worktree ] [--config PATH]\n```\n\nEach spawned process is one session, exposes vanilla ACP over stdio, and exits when the manager disconnects. For headless one-shot use, `bitrouter acp prompt --agent claude ` streams NDJSON to stdout. See `references/sessions.md` for the full substrate model.\n\n## Apply changes\n\n```bash\nbitrouter reload # hot-reload running daemon\n# SIGHUP also reloads daemon-side config, but does not forward new shell env vars\n# or `bitrouter restart` for a clean cycle\n```\n\n`reload` also re-pushes the current shell's provider env vars into the daemon, so an `export OPENAI_API_KEY=new...; bitrouter reload` sequence rotates the key live.\n\n## What's not supported (don't suggest these)\n\nOlder versions of this skill mentioned features that **do not exist** in the v1 schema:\n\n- `cost_limits` (daily/monthly USD caps per provider) — not parsed.\n- `strategy: conditional` with `prompt_tokens` rules — not parsed.\n- `strategy: least_cost` — not parsed.\n- `safety_settings`, `features.computer_use`, `features.json_mode` provider blocks — not parsed.\n- `bitrouter providers add/remove/use/test/stats/export/import` subcommands — use `providers list`, `providers login`, and `providers logout`.\n- `bitrouter config reload/show` — `config validate` exists for CI-safe validation; use `bitrouter reload` for the daemon.\n"}}, "crates/bitrouter-providers/src/registry/cache.rs": {"122de07d23f09f3fedf4d7034c615a1add6f6bf6": {"t": 1785746248.9225721, "n": 8987, "h": "23a2888502c2b3d4", "c": "//! File-based on-disk cache for the public registry distribution.\n//!\n//! Layout: a single JSON file `{ \"fetched_at\": , \"data\": }`.\n//! Default path: `$XDG_CACHE_HOME/bitrouter/registry.json` (falling\n//! back to `~/.cache/bitrouter/…` on Unix or\n//! `%LOCALAPPDATA%\\bitrouter\\cache\\…` on Windows), per the XDG Base Directory\n//! spec ().\n//!\n//! TTL: 24h. After expiry the cache is \"stale\" — callers should re-fetch, but\n//! [`DiskCache::read_any`] still returns the stale data so a network outage\n//! doesn't blank the routable provider set.\n\nuse std::fs;\nuse std::io;\nuse std::path::PathBuf;\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::registry::types::RegistryData;\n\n/// Freshness window — 24 hours. After this the cached payload is \"stale\".\npub const TTL: Duration = Duration::from_secs(24 * 60 * 60);\n\n/// Default filename inside the bitrouter cache directory.\npub const DEFAULT_FILENAME: &str = \"registry.json\";\n\n/// Errors raised by the disk cache.\n#[derive(Debug, thiserror::Error)]\npub enum CacheError {\n /// File I/O failure (open / write / mkdir / rename).\n #[error(\"registry cache I/O error at {path}: {source}\")]\n Io {\n /// The path that failed.\n path: PathBuf,\n /// The underlying I/O error.\n #[source]\n source: io::Error,\n },\n /// JSON parse / serialise failure.\n #[error(\"registry cache JSON error: {0}\")]\n Json(#[from] serde_json::Error),\n /// No home / cache directory could be resolved.\n #[error(\"could not resolve a cache directory\")]\n NoCacheDir,\n}\n\n/// One on-disk cache slot for the registry.\npub struct DiskCache {\n path: PathBuf,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct CachedPayload {\n /// Unix seconds when the registry was fetched.\n fetched_at: u64,\n data: RegistryData,\n}\n\nimpl DiskCache {\n /// Use an explicit cache path. Tests use this; the binary calls\n /// [`Self::default_path`].\n pub fn at(path: impl Into) -> Self {\n Self { path: path.into() }\n }\n\n /// Resolve the default cache file under `$XDG_CACHE_HOME/bitrouter/`.\n pub fn default_path() -> Result {\n let dir = default_cache_dir()?;\n Ok(Self::at(dir.join(DEFAULT_FILENAME)))\n }\n\n /// Read the cached data if it exists AND is within the TTL window. Returns\n /// `Ok(None)` for missing or stale entries — callers that don't care about\n /// freshness should call [`Self::read_any`].\n pub fn read_fresh(&self) -> Result, CacheError> {\n let Some((payload, age)) = self.read_with_age()? else {\n return Ok(None);\n };\n if age <= TTL {\n Ok(Some(payload.data))\n } else {\n Ok(None)\n }\n }\n\n /// Read the cached data whether or not it is fresh. Returns `Ok(None)` only\n /// when the file is absent or unreadable. Use after a fetch fails so a\n /// transient network outage still serves the provider set.\n pub fn read_any(&self) -> Result, CacheError> {\n Ok(self.read_with_age()?.map(|(p, _)| p.data))\n }\n\n /// Write freshly-fetched registry data, stamping the current time.\n pub fn write(&self, data: &RegistryData) -> Result<(), CacheError> {\n let parent = self.path.parent().ok_or(CacheError::NoCacheDir)?;\n fs::create_dir_all(parent).map_err(|source| CacheError::Io {\n path: parent.to_path_buf(),\n source,\n })?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let payload = CachedPayload {\n fetched_at: now,\n data: data.clone(),\n };\n let bytes = serde_json::to_vec_pretty(&payload)?;\n let tmp = self.path.with_extension(\"json.tmp\");\n // atomic-replace: write to a sibling then rename, so a crash mid-write\n // never leaves a half-truncated cache.\n fs::write(&tmp, &bytes).map_err(|source| CacheError::Io {\n path: tmp.clone(),\n source,\n })?;\n fs::rename(&tmp, &self.path).map_err(|source| CacheError::Io {\n path: self.path.clone(),\n source,\n })?;\n Ok(())\n }\n\n fn read_with_age(&self) -> Result, CacheError> {\n let bytes = match fs::read(&self.path) {\n Ok(b) => b,\n Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),\n Err(source) => {\n return Err(CacheError::Io {\n path: self.path.clone(),\n source,\n });\n }\n };\n let payload: CachedPayload = serde_json::from_slice(&bytes)?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let age = Duration::from_secs(now.saturating_sub(payload.fetched_at));\n Ok(Some((payload, age)))\n }\n}\n\n/// Resolve the bitrouter cache directory. Follows the XDG Base Directory spec\n/// on Unix; uses `%LOCALAPPDATA%` on Windows.\nfn default_cache_dir() -> Result {\n if let Some(dir) = std::env::var_os(\"XDG_CACHE_HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\"));\n }\n #[cfg(windows)]\n if let Some(dir) = std::env::var_os(\"LOCALAPPDATA\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\").join(\"cache\"));\n }\n if let Some(home) = std::env::var_os(\"HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(home).join(\".cache\").join(\"bitrouter\"));\n }\n Err(CacheError::NoCacheDir)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::registry::types::{CanonicalModel, RegistryAccess, RegistryProvider};\n\n fn sample_data() -> RegistryData {\n RegistryData {\n providers: vec![RegistryProvider {\n name: \"deepseek\".into(),\n display_name: None,\n api_base: Some(\"https://api.deepseek.com/v1\".into()),\n api_protocol: Vec::new(),\n protocol_endpoints: None,\n models: Vec::new(),\n status: \"active\".into(),\n kind: None,\n auth: None,\n required_config: Vec::new(),\n doc_url: None,\n community: false,\n access: Some(RegistryAccess::ApiKey),\n byok: Some(true),\n billing: Default::default(),\n }],\n canonical: vec![CanonicalModel {\n id: \"deepseek/deepseek-v3.2\".into(),\n }],\n }\n }\n\n /// Unique tmp dir per call — tests run in parallel and a shared directory\n /// races with itself.\n fn tmp_dir() -> PathBuf {\n use std::sync::atomic::{AtomicU64, Ordering};\n static COUNTER: AtomicU64 = AtomicU64::new(0);\n let id = COUNTER.fetch_add(1, Ordering::Relaxed);\n let dir = std::env::temp_dir().join(format!(\n \"bitrouter-registry-cache-{}-{id}\",\n std::process::id()\n ));\n let _ = fs::remove_dir_all(&dir);\n fs::create_dir_all(&dir).expect(\"create tmp dir\");\n dir\n }\n\n #[test]\n fn writes_and_reads_fresh() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"reg.json\"));\n cache.write(&sample_data()).expect(\"write\");\n let got = cache.read_fresh().expect(\"read\").expect(\"fresh\");\n assert_eq!(got.providers.len(), 1);\n assert_eq!(got.canonical[0].id, \"deepseek/deepseek-v3.2\");\n }\n\n #[test]\n fn missing_file_returns_none() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"absent.json\"));\n assert!(cache.read_fresh().expect(\"read_fresh\").is_none());\n assert!(cache.read_any().expect(\"read_any\").is_none());\n }\n\n #[test]\n fn stale_payload_only_visible_via_read_any() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n let stale = CachedPayload {\n fetched_at: 0,\n data: sample_data(),\n };\n fs::write(&path, serde_json::to_vec_pretty(&stale).expect(\"ser\")).expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(\n cache.read_fresh().expect(\"read_fresh\").is_none(),\n \"TTL must reject stale\"\n );\n assert!(\n cache.read_any().expect(\"read_any\").is_some(),\n \"read_any must still surface stale data\"\n );\n }\n\n #[test]\n fn rejects_corrupt_payload() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n fs::write(&path, b\"not json\").expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(matches!(cache.read_any(), Err(CacheError::Json(_))));\n }\n}\n"}, "733bf7f576607dd5e47ca0853e241df531e3468e": {"t": 1785761073.7778666, "n": 8987, "h": "23a2888502c2b3d4", "c": "//! File-based on-disk cache for the public registry distribution.\n//!\n//! Layout: a single JSON file `{ \"fetched_at\": , \"data\": }`.\n//! Default path: `$XDG_CACHE_HOME/bitrouter/registry.json` (falling\n//! back to `~/.cache/bitrouter/…` on Unix or\n//! `%LOCALAPPDATA%\\bitrouter\\cache\\…` on Windows), per the XDG Base Directory\n//! spec ().\n//!\n//! TTL: 24h. After expiry the cache is \"stale\" — callers should re-fetch, but\n//! [`DiskCache::read_any`] still returns the stale data so a network outage\n//! doesn't blank the routable provider set.\n\nuse std::fs;\nuse std::io;\nuse std::path::PathBuf;\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::registry::types::RegistryData;\n\n/// Freshness window — 24 hours. After this the cached payload is \"stale\".\npub const TTL: Duration = Duration::from_secs(24 * 60 * 60);\n\n/// Default filename inside the bitrouter cache directory.\npub const DEFAULT_FILENAME: &str = \"registry.json\";\n\n/// Errors raised by the disk cache.\n#[derive(Debug, thiserror::Error)]\npub enum CacheError {\n /// File I/O failure (open / write / mkdir / rename).\n #[error(\"registry cache I/O error at {path}: {source}\")]\n Io {\n /// The path that failed.\n path: PathBuf,\n /// The underlying I/O error.\n #[source]\n source: io::Error,\n },\n /// JSON parse / serialise failure.\n #[error(\"registry cache JSON error: {0}\")]\n Json(#[from] serde_json::Error),\n /// No home / cache directory could be resolved.\n #[error(\"could not resolve a cache directory\")]\n NoCacheDir,\n}\n\n/// One on-disk cache slot for the registry.\npub struct DiskCache {\n path: PathBuf,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct CachedPayload {\n /// Unix seconds when the registry was fetched.\n fetched_at: u64,\n data: RegistryData,\n}\n\nimpl DiskCache {\n /// Use an explicit cache path. Tests use this; the binary calls\n /// [`Self::default_path`].\n pub fn at(path: impl Into) -> Self {\n Self { path: path.into() }\n }\n\n /// Resolve the default cache file under `$XDG_CACHE_HOME/bitrouter/`.\n pub fn default_path() -> Result {\n let dir = default_cache_dir()?;\n Ok(Self::at(dir.join(DEFAULT_FILENAME)))\n }\n\n /// Read the cached data if it exists AND is within the TTL window. Returns\n /// `Ok(None)` for missing or stale entries — callers that don't care about\n /// freshness should call [`Self::read_any`].\n pub fn read_fresh(&self) -> Result, CacheError> {\n let Some((payload, age)) = self.read_with_age()? else {\n return Ok(None);\n };\n if age <= TTL {\n Ok(Some(payload.data))\n } else {\n Ok(None)\n }\n }\n\n /// Read the cached data whether or not it is fresh. Returns `Ok(None)` only\n /// when the file is absent or unreadable. Use after a fetch fails so a\n /// transient network outage still serves the provider set.\n pub fn read_any(&self) -> Result, CacheError> {\n Ok(self.read_with_age()?.map(|(p, _)| p.data))\n }\n\n /// Write freshly-fetched registry data, stamping the current time.\n pub fn write(&self, data: &RegistryData) -> Result<(), CacheError> {\n let parent = self.path.parent().ok_or(CacheError::NoCacheDir)?;\n fs::create_dir_all(parent).map_err(|source| CacheError::Io {\n path: parent.to_path_buf(),\n source,\n })?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let payload = CachedPayload {\n fetched_at: now,\n data: data.clone(),\n };\n let bytes = serde_json::to_vec_pretty(&payload)?;\n let tmp = self.path.with_extension(\"json.tmp\");\n // atomic-replace: write to a sibling then rename, so a crash mid-write\n // never leaves a half-truncated cache.\n fs::write(&tmp, &bytes).map_err(|source| CacheError::Io {\n path: tmp.clone(),\n source,\n })?;\n fs::rename(&tmp, &self.path).map_err(|source| CacheError::Io {\n path: self.path.clone(),\n source,\n })?;\n Ok(())\n }\n\n fn read_with_age(&self) -> Result, CacheError> {\n let bytes = match fs::read(&self.path) {\n Ok(b) => b,\n Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),\n Err(source) => {\n return Err(CacheError::Io {\n path: self.path.clone(),\n source,\n });\n }\n };\n let payload: CachedPayload = serde_json::from_slice(&bytes)?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let age = Duration::from_secs(now.saturating_sub(payload.fetched_at));\n Ok(Some((payload, age)))\n }\n}\n\n/// Resolve the bitrouter cache directory. Follows the XDG Base Directory spec\n/// on Unix; uses `%LOCALAPPDATA%` on Windows.\nfn default_cache_dir() -> Result {\n if let Some(dir) = std::env::var_os(\"XDG_CACHE_HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\"));\n }\n #[cfg(windows)]\n if let Some(dir) = std::env::var_os(\"LOCALAPPDATA\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\").join(\"cache\"));\n }\n if let Some(home) = std::env::var_os(\"HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(home).join(\".cache\").join(\"bitrouter\"));\n }\n Err(CacheError::NoCacheDir)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::registry::types::{CanonicalModel, RegistryAccess, RegistryProvider};\n\n fn sample_data() -> RegistryData {\n RegistryData {\n providers: vec![RegistryProvider {\n name: \"deepseek\".into(),\n display_name: None,\n api_base: Some(\"https://api.deepseek.com/v1\".into()),\n api_protocol: Vec::new(),\n protocol_endpoints: None,\n models: Vec::new(),\n status: \"active\".into(),\n kind: None,\n auth: None,\n required_config: Vec::new(),\n doc_url: None,\n community: false,\n access: Some(RegistryAccess::ApiKey),\n byok: Some(true),\n billing: Default::default(),\n }],\n canonical: vec![CanonicalModel {\n id: \"deepseek/deepseek-v3.2\".into(),\n }],\n }\n }\n\n /// Unique tmp dir per call — tests run in parallel and a shared directory\n /// races with itself.\n fn tmp_dir() -> PathBuf {\n use std::sync::atomic::{AtomicU64, Ordering};\n static COUNTER: AtomicU64 = AtomicU64::new(0);\n let id = COUNTER.fetch_add(1, Ordering::Relaxed);\n let dir = std::env::temp_dir().join(format!(\n \"bitrouter-registry-cache-{}-{id}\",\n std::process::id()\n ));\n let _ = fs::remove_dir_all(&dir);\n fs::create_dir_all(&dir).expect(\"create tmp dir\");\n dir\n }\n\n #[test]\n fn writes_and_reads_fresh() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"reg.json\"));\n cache.write(&sample_data()).expect(\"write\");\n let got = cache.read_fresh().expect(\"read\").expect(\"fresh\");\n assert_eq!(got.providers.len(), 1);\n assert_eq!(got.canonical[0].id, \"deepseek/deepseek-v3.2\");\n }\n\n #[test]\n fn missing_file_returns_none() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"absent.json\"));\n assert!(cache.read_fresh().expect(\"read_fresh\").is_none());\n assert!(cache.read_any().expect(\"read_any\").is_none());\n }\n\n #[test]\n fn stale_payload_only_visible_via_read_any() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n let stale = CachedPayload {\n fetched_at: 0,\n data: sample_data(),\n };\n fs::write(&path, serde_json::to_vec_pretty(&stale).expect(\"ser\")).expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(\n cache.read_fresh().expect(\"read_fresh\").is_none(),\n \"TTL must reject stale\"\n );\n assert!(\n cache.read_any().expect(\"read_any\").is_some(),\n \"read_any must still surface stale data\"\n );\n }\n\n #[test]\n fn rejects_corrupt_payload() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n fs::write(&path, b\"not json\").expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(matches!(cache.read_any(), Err(CacheError::Json(_))));\n }\n}\n"}, "04362417b9821b715d9c858789083534cf302a3b": {"t": 1785761075.035772, "n": 9020, "h": "ae71e68790ebc702", "c": "//! File-based on-disk cache for the public registry distribution.\n//!\n//! Layout: a single JSON file `{ \"fetched_at\": , \"data\": }`.\n//! Default path: `$XDG_CACHE_HOME/bitrouter/registry.json` (falling\n//! back to `~/.cache/bitrouter/…` on Unix or\n//! `%LOCALAPPDATA%\\bitrouter\\cache\\…` on Windows), per the XDG Base Directory\n//! spec ().\n//!\n//! TTL: 24h. After expiry the cache is \"stale\" — callers should re-fetch, but\n//! [`DiskCache::read_any`] still returns the stale data so a network outage\n//! doesn't blank the routable provider set.\n\nuse std::fs;\nuse std::io;\nuse std::path::PathBuf;\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::registry::types::RegistryData;\n\n/// Freshness window — 24 hours. After this the cached payload is \"stale\".\npub const TTL: Duration = Duration::from_secs(24 * 60 * 60);\n\n/// Default filename inside the bitrouter cache directory.\npub const DEFAULT_FILENAME: &str = \"registry.json\";\n\n/// Errors raised by the disk cache.\n#[derive(Debug, thiserror::Error)]\npub enum CacheError {\n /// File I/O failure (open / write / mkdir / rename).\n #[error(\"registry cache I/O error at {path}: {source}\")]\n Io {\n /// The path that failed.\n path: PathBuf,\n /// The underlying I/O error.\n #[source]\n source: io::Error,\n },\n /// JSON parse / serialise failure.\n #[error(\"registry cache JSON error: {0}\")]\n Json(#[from] serde_json::Error),\n /// No home / cache directory could be resolved.\n #[error(\"could not resolve a cache directory\")]\n NoCacheDir,\n}\n\n/// One on-disk cache slot for the registry.\npub struct DiskCache {\n path: PathBuf,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct CachedPayload {\n /// Unix seconds when the registry was fetched.\n fetched_at: u64,\n data: RegistryData,\n}\n\nimpl DiskCache {\n /// Use an explicit cache path. Tests use this; the binary calls\n /// [`Self::default_path`].\n pub fn at(path: impl Into) -> Self {\n Self { path: path.into() }\n }\n\n /// Resolve the default cache file under `$XDG_CACHE_HOME/bitrouter/`.\n pub fn default_path() -> Result {\n let dir = default_cache_dir()?;\n Ok(Self::at(dir.join(DEFAULT_FILENAME)))\n }\n\n /// Read the cached data if it exists AND is within the TTL window. Returns\n /// `Ok(None)` for missing or stale entries — callers that don't care about\n /// freshness should call [`Self::read_any`].\n pub fn read_fresh(&self) -> Result, CacheError> {\n let Some((payload, age)) = self.read_with_age()? else {\n return Ok(None);\n };\n if age <= TTL {\n Ok(Some(payload.data))\n } else {\n Ok(None)\n }\n }\n\n /// Read the cached data whether or not it is fresh. Returns `Ok(None)` only\n /// when the file is absent or unreadable. Use after a fetch fails so a\n /// transient network outage still serves the provider set.\n pub fn read_any(&self) -> Result, CacheError> {\n Ok(self.read_with_age()?.map(|(p, _)| p.data))\n }\n\n /// Write freshly-fetched registry data, stamping the current time.\n pub fn write(&self, data: &RegistryData) -> Result<(), CacheError> {\n let parent = self.path.parent().ok_or(CacheError::NoCacheDir)?;\n fs::create_dir_all(parent).map_err(|source| CacheError::Io {\n path: parent.to_path_buf(),\n source,\n })?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let payload = CachedPayload {\n fetched_at: now,\n data: data.clone(),\n };\n let bytes = serde_json::to_vec_pretty(&payload)?;\n let tmp = self.path.with_extension(\"json.tmp\");\n // atomic-replace: write to a sibling then rename, so a crash mid-write\n // never leaves a half-truncated cache.\n fs::write(&tmp, &bytes).map_err(|source| CacheError::Io {\n path: tmp.clone(),\n source,\n })?;\n fs::rename(&tmp, &self.path).map_err(|source| CacheError::Io {\n path: self.path.clone(),\n source,\n })?;\n Ok(())\n }\n\n fn read_with_age(&self) -> Result, CacheError> {\n let bytes = match fs::read(&self.path) {\n Ok(b) => b,\n Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),\n Err(source) => {\n return Err(CacheError::Io {\n path: self.path.clone(),\n source,\n });\n }\n };\n let payload: CachedPayload = serde_json::from_slice(&bytes)?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let age = Duration::from_secs(now.saturating_sub(payload.fetched_at));\n Ok(Some((payload, age)))\n }\n}\n\n/// Resolve the bitrouter cache directory. Follows the XDG Base Directory spec\n/// on Unix; uses `%LOCALAPPDATA%` on Windows.\nfn default_cache_dir() -> Result {\n if let Some(dir) = std::env::var_os(\"XDG_CACHE_HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\"));\n }\n #[cfg(windows)]\n if let Some(dir) = std::env::var_os(\"LOCALAPPDATA\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\").join(\"cache\"));\n }\n if let Some(home) = std::env::var_os(\"HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(home).join(\".cache\").join(\"bitrouter\"));\n }\n Err(CacheError::NoCacheDir)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::registry::types::{CanonicalModel, RegistryAccess, RegistryProvider};\n\n fn sample_data() -> RegistryData {\n RegistryData {\n providers: vec![RegistryProvider {\n name: \"deepseek\".into(),\n display_name: None,\n api_base: Some(\"https://api.deepseek.com/v1\".into()),\n api_protocol: Vec::new(),\n protocol_endpoints: None,\n models: Vec::new(),\n status: \"active\".into(),\n kind: None,\n auth: None,\n required_config: Vec::new(),\n doc_url: None,\n community: false,\n access: Some(RegistryAccess::ApiKey),\n byok: Some(true),\n auto_sync: None,\n billing: Default::default(),\n }],\n canonical: vec![CanonicalModel {\n id: \"deepseek/deepseek-v3.2\".into(),\n }],\n }\n }\n\n /// Unique tmp dir per call — tests run in parallel and a shared directory\n /// races with itself.\n fn tmp_dir() -> PathBuf {\n use std::sync::atomic::{AtomicU64, Ordering};\n static COUNTER: AtomicU64 = AtomicU64::new(0);\n let id = COUNTER.fetch_add(1, Ordering::Relaxed);\n let dir = std::env::temp_dir().join(format!(\n \"bitrouter-registry-cache-{}-{id}\",\n std::process::id()\n ));\n let _ = fs::remove_dir_all(&dir);\n fs::create_dir_all(&dir).expect(\"create tmp dir\");\n dir\n }\n\n #[test]\n fn writes_and_reads_fresh() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"reg.json\"));\n cache.write(&sample_data()).expect(\"write\");\n let got = cache.read_fresh().expect(\"read\").expect(\"fresh\");\n assert_eq!(got.providers.len(), 1);\n assert_eq!(got.canonical[0].id, \"deepseek/deepseek-v3.2\");\n }\n\n #[test]\n fn missing_file_returns_none() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"absent.json\"));\n assert!(cache.read_fresh().expect(\"read_fresh\").is_none());\n assert!(cache.read_any().expect(\"read_any\").is_none());\n }\n\n #[test]\n fn stale_payload_only_visible_via_read_any() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n let stale = CachedPayload {\n fetched_at: 0,\n data: sample_data(),\n };\n fs::write(&path, serde_json::to_vec_pretty(&stale).expect(\"ser\")).expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(\n cache.read_fresh().expect(\"read_fresh\").is_none(),\n \"TTL must reject stale\"\n );\n assert!(\n cache.read_any().expect(\"read_any\").is_some(),\n \"read_any must still surface stale data\"\n );\n }\n\n #[test]\n fn rejects_corrupt_payload() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n fs::write(&path, b\"not json\").expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(matches!(cache.read_any(), Err(CacheError::Json(_))));\n }\n}\n"}, "afdb4b7f68723456efa804e1d2760ed103a59a84": {"t": 1785761076.8474286, "n": 8969, "h": "cf889a51f559ab56", "c": "//! File-based on-disk cache for the public registry distribution.\n//!\n//! Layout: a single JSON file `{ \"fetched_at\": , \"data\": }`.\n//! Default path: `$XDG_CACHE_HOME/bitrouter/registry.json` (falling\n//! back to `~/.cache/bitrouter/…` on Unix or\n//! `%LOCALAPPDATA%\\bitrouter\\cache\\…` on Windows), per the XDG Base Directory\n//! spec ().\n//!\n//! TTL: 24h. After expiry the cache is \"stale\" — callers should re-fetch, but\n//! [`DiskCache::read_any`] still returns the stale data so a network outage\n//! doesn't blank the routable provider set.\n\nuse std::fs;\nuse std::io;\nuse std::path::PathBuf;\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::registry::types::RegistryData;\n\n/// Freshness window — 24 hours. After this the cached payload is \"stale\".\npub const TTL: Duration = Duration::from_secs(24 * 60 * 60);\n\n/// Default filename inside the bitrouter cache directory.\npub const DEFAULT_FILENAME: &str = \"registry.json\";\n\n/// Errors raised by the disk cache.\n#[derive(Debug, thiserror::Error)]\npub enum CacheError {\n /// File I/O failure (open / write / mkdir / rename).\n #[error(\"registry cache I/O error at {path}: {source}\")]\n Io {\n /// The path that failed.\n path: PathBuf,\n /// The underlying I/O error.\n #[source]\n source: io::Error,\n },\n /// JSON parse / serialise failure.\n #[error(\"registry cache JSON error: {0}\")]\n Json(#[from] serde_json::Error),\n /// No home / cache directory could be resolved.\n #[error(\"could not resolve a cache directory\")]\n NoCacheDir,\n}\n\n/// One on-disk cache slot for the registry.\npub struct DiskCache {\n path: PathBuf,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct CachedPayload {\n /// Unix seconds when the registry was fetched.\n fetched_at: u64,\n data: RegistryData,\n}\n\nimpl DiskCache {\n /// Use an explicit cache path. Tests use this; the binary calls\n /// [`Self::default_path`].\n pub fn at(path: impl Into) -> Self {\n Self { path: path.into() }\n }\n\n /// Resolve the default cache file under `$XDG_CACHE_HOME/bitrouter/`.\n pub fn default_path() -> Result {\n let dir = default_cache_dir()?;\n Ok(Self::at(dir.join(DEFAULT_FILENAME)))\n }\n\n /// Read the cached data if it exists AND is within the TTL window. Returns\n /// `Ok(None)` for missing or stale entries — callers that don't care about\n /// freshness should call [`Self::read_any`].\n pub fn read_fresh(&self) -> Result, CacheError> {\n let Some((payload, age)) = self.read_with_age()? else {\n return Ok(None);\n };\n if age <= TTL {\n Ok(Some(payload.data))\n } else {\n Ok(None)\n }\n }\n\n /// Read the cached data whether or not it is fresh. Returns `Ok(None)` only\n /// when the file is absent or unreadable. Use after a fetch fails so a\n /// transient network outage still serves the provider set.\n pub fn read_any(&self) -> Result, CacheError> {\n Ok(self.read_with_age()?.map(|(p, _)| p.data))\n }\n\n /// Write freshly-fetched registry data, stamping the current time.\n pub fn write(&self, data: &RegistryData) -> Result<(), CacheError> {\n let parent = self.path.parent().ok_or(CacheError::NoCacheDir)?;\n fs::create_dir_all(parent).map_err(|source| CacheError::Io {\n path: parent.to_path_buf(),\n source,\n })?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let payload = CachedPayload {\n fetched_at: now,\n data: data.clone(),\n };\n let bytes = serde_json::to_vec_pretty(&payload)?;\n let tmp = self.path.with_extension(\"json.tmp\");\n // atomic-replace: write to a sibling then rename, so a crash mid-write\n // never leaves a half-truncated cache.\n fs::write(&tmp, &bytes).map_err(|source| CacheError::Io {\n path: tmp.clone(),\n source,\n })?;\n fs::rename(&tmp, &self.path).map_err(|source| CacheError::Io {\n path: self.path.clone(),\n source,\n })?;\n Ok(())\n }\n\n fn read_with_age(&self) -> Result, CacheError> {\n let bytes = match fs::read(&self.path) {\n Ok(b) => b,\n Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),\n Err(source) => {\n return Err(CacheError::Io {\n path: self.path.clone(),\n source,\n });\n }\n };\n let payload: CachedPayload = serde_json::from_slice(&bytes)?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let age = Duration::from_secs(now.saturating_sub(payload.fetched_at));\n Ok(Some((payload, age)))\n }\n}\n\n/// Resolve the bitrouter cache directory. Follows the XDG Base Directory spec\n/// on Unix; uses `%LOCALAPPDATA%` on Windows.\nfn default_cache_dir() -> Result {\n if let Some(dir) = std::env::var_os(\"XDG_CACHE_HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\"));\n }\n #[cfg(windows)]\n if let Some(dir) = std::env::var_os(\"LOCALAPPDATA\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\").join(\"cache\"));\n }\n if let Some(home) = std::env::var_os(\"HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(home).join(\".cache\").join(\"bitrouter\"));\n }\n Err(CacheError::NoCacheDir)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::registry::types::{CanonicalModel, RegistryAccess, RegistryProvider};\n\n fn sample_data() -> RegistryData {\n RegistryData {\n providers: vec![RegistryProvider {\n name: \"deepseek\".into(),\n display_name: None,\n api_base: \"https://api.deepseek.com/v1\".into(),\n api_protocol: Vec::new(),\n protocol_endpoints: None,\n models: Vec::new(),\n status: \"active\".into(),\n kind: None,\n auth: None,\n doc_url: None,\n community: false,\n access: Some(RegistryAccess::ApiKey),\n byok: Some(true),\n auto_sync: None,\n billing: Default::default(),\n }],\n canonical: vec![CanonicalModel {\n id: \"deepseek/deepseek-v3.2\".into(),\n }],\n }\n }\n\n /// Unique tmp dir per call — tests run in parallel and a shared directory\n /// races with itself.\n fn tmp_dir() -> PathBuf {\n use std::sync::atomic::{AtomicU64, Ordering};\n static COUNTER: AtomicU64 = AtomicU64::new(0);\n let id = COUNTER.fetch_add(1, Ordering::Relaxed);\n let dir = std::env::temp_dir().join(format!(\n \"bitrouter-registry-cache-{}-{id}\",\n std::process::id()\n ));\n let _ = fs::remove_dir_all(&dir);\n fs::create_dir_all(&dir).expect(\"create tmp dir\");\n dir\n }\n\n #[test]\n fn writes_and_reads_fresh() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"reg.json\"));\n cache.write(&sample_data()).expect(\"write\");\n let got = cache.read_fresh().expect(\"read\").expect(\"fresh\");\n assert_eq!(got.providers.len(), 1);\n assert_eq!(got.canonical[0].id, \"deepseek/deepseek-v3.2\");\n }\n\n #[test]\n fn missing_file_returns_none() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"absent.json\"));\n assert!(cache.read_fresh().expect(\"read_fresh\").is_none());\n assert!(cache.read_any().expect(\"read_any\").is_none());\n }\n\n #[test]\n fn stale_payload_only_visible_via_read_any() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n let stale = CachedPayload {\n fetched_at: 0,\n data: sample_data(),\n };\n fs::write(&path, serde_json::to_vec_pretty(&stale).expect(\"ser\")).expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(\n cache.read_fresh().expect(\"read_fresh\").is_none(),\n \"TTL must reject stale\"\n );\n assert!(\n cache.read_any().expect(\"read_any\").is_some(),\n \"read_any must still surface stale data\"\n );\n }\n\n #[test]\n fn rejects_corrupt_payload() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n fs::write(&path, b\"not json\").expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(matches!(cache.read_any(), Err(CacheError::Json(_))));\n }\n}\n"}, "e58c62332d74e402553ebba78939634dc5212f9b": {"t": 1785761078.1233377, "n": 8989, "h": "69ac3b8af22832d8", "c": "//! File-based on-disk cache for the provider-registry distribution.\n//!\n//! Layout: a single JSON file `{ \"fetched_at\": , \"data\": }`.\n//! Default path: `$XDG_CACHE_HOME/bitrouter/provider-registry.json` (falling\n//! back to `~/.cache/bitrouter/…` on Unix or\n//! `%LOCALAPPDATA%\\bitrouter\\cache\\…` on Windows), per the XDG Base Directory\n//! spec ().\n//!\n//! TTL: 24h. After expiry the cache is \"stale\" — callers should re-fetch, but\n//! [`DiskCache::read_any`] still returns the stale data so a network outage\n//! doesn't blank the routable provider set.\n\nuse std::fs;\nuse std::io;\nuse std::path::PathBuf;\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::registry::types::RegistryData;\n\n/// Freshness window — 24 hours. After this the cached payload is \"stale\".\npub const TTL: Duration = Duration::from_secs(24 * 60 * 60);\n\n/// Default filename inside the bitrouter cache directory.\npub const DEFAULT_FILENAME: &str = \"provider-registry.json\";\n\n/// Errors raised by the disk cache.\n#[derive(Debug, thiserror::Error)]\npub enum CacheError {\n /// File I/O failure (open / write / mkdir / rename).\n #[error(\"registry cache I/O error at {path}: {source}\")]\n Io {\n /// The path that failed.\n path: PathBuf,\n /// The underlying I/O error.\n #[source]\n source: io::Error,\n },\n /// JSON parse / serialise failure.\n #[error(\"registry cache JSON error: {0}\")]\n Json(#[from] serde_json::Error),\n /// No home / cache directory could be resolved.\n #[error(\"could not resolve a cache directory\")]\n NoCacheDir,\n}\n\n/// One on-disk cache slot for the registry.\npub struct DiskCache {\n path: PathBuf,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct CachedPayload {\n /// Unix seconds when the registry was fetched.\n fetched_at: u64,\n data: RegistryData,\n}\n\nimpl DiskCache {\n /// Use an explicit cache path. Tests use this; the binary calls\n /// [`Self::default_path`].\n pub fn at(path: impl Into) -> Self {\n Self { path: path.into() }\n }\n\n /// Resolve the default cache file under `$XDG_CACHE_HOME/bitrouter/`.\n pub fn default_path() -> Result {\n let dir = default_cache_dir()?;\n Ok(Self::at(dir.join(DEFAULT_FILENAME)))\n }\n\n /// Read the cached data if it exists AND is within the TTL window. Returns\n /// `Ok(None)` for missing or stale entries — callers that don't care about\n /// freshness should call [`Self::read_any`].\n pub fn read_fresh(&self) -> Result, CacheError> {\n let Some((payload, age)) = self.read_with_age()? else {\n return Ok(None);\n };\n if age <= TTL {\n Ok(Some(payload.data))\n } else {\n Ok(None)\n }\n }\n\n /// Read the cached data whether or not it is fresh. Returns `Ok(None)` only\n /// when the file is absent or unreadable. Use after a fetch fails so a\n /// transient network outage still serves the provider set.\n pub fn read_any(&self) -> Result, CacheError> {\n Ok(self.read_with_age()?.map(|(p, _)| p.data))\n }\n\n /// Write freshly-fetched registry data, stamping the current time.\n pub fn write(&self, data: &RegistryData) -> Result<(), CacheError> {\n let parent = self.path.parent().ok_or(CacheError::NoCacheDir)?;\n fs::create_dir_all(parent).map_err(|source| CacheError::Io {\n path: parent.to_path_buf(),\n source,\n })?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let payload = CachedPayload {\n fetched_at: now,\n data: data.clone(),\n };\n let bytes = serde_json::to_vec_pretty(&payload)?;\n let tmp = self.path.with_extension(\"json.tmp\");\n // atomic-replace: write to a sibling then rename, so a crash mid-write\n // never leaves a half-truncated cache.\n fs::write(&tmp, &bytes).map_err(|source| CacheError::Io {\n path: tmp.clone(),\n source,\n })?;\n fs::rename(&tmp, &self.path).map_err(|source| CacheError::Io {\n path: self.path.clone(),\n source,\n })?;\n Ok(())\n }\n\n fn read_with_age(&self) -> Result, CacheError> {\n let bytes = match fs::read(&self.path) {\n Ok(b) => b,\n Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),\n Err(source) => {\n return Err(CacheError::Io {\n path: self.path.clone(),\n source,\n });\n }\n };\n let payload: CachedPayload = serde_json::from_slice(&bytes)?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let age = Duration::from_secs(now.saturating_sub(payload.fetched_at));\n Ok(Some((payload, age)))\n }\n}\n\n/// Resolve the bitrouter cache directory. Follows the XDG Base Directory spec\n/// on Unix; uses `%LOCALAPPDATA%` on Windows.\nfn default_cache_dir() -> Result {\n if let Some(dir) = std::env::var_os(\"XDG_CACHE_HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\"));\n }\n #[cfg(windows)]\n if let Some(dir) = std::env::var_os(\"LOCALAPPDATA\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\").join(\"cache\"));\n }\n if let Some(home) = std::env::var_os(\"HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(home).join(\".cache\").join(\"bitrouter\"));\n }\n Err(CacheError::NoCacheDir)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::registry::types::{CanonicalModel, RegistryAccess, RegistryProvider};\n\n fn sample_data() -> RegistryData {\n RegistryData {\n providers: vec![RegistryProvider {\n name: \"deepseek\".into(),\n display_name: None,\n api_base: \"https://api.deepseek.com/v1\".into(),\n api_protocol: Vec::new(),\n protocol_endpoints: None,\n models: Vec::new(),\n status: \"active\".into(),\n kind: None,\n auth: None,\n doc_url: None,\n community: false,\n access: Some(RegistryAccess::ApiKey),\n byok: Some(true),\n auto_sync: None,\n billing: Default::default(),\n }],\n canonical: vec![CanonicalModel {\n id: \"deepseek/deepseek-v3.2\".into(),\n }],\n }\n }\n\n /// Unique tmp dir per call — tests run in parallel and a shared directory\n /// races with itself.\n fn tmp_dir() -> PathBuf {\n use std::sync::atomic::{AtomicU64, Ordering};\n static COUNTER: AtomicU64 = AtomicU64::new(0);\n let id = COUNTER.fetch_add(1, Ordering::Relaxed);\n let dir = std::env::temp_dir().join(format!(\n \"bitrouter-registry-cache-{}-{id}\",\n std::process::id()\n ));\n let _ = fs::remove_dir_all(&dir);\n fs::create_dir_all(&dir).expect(\"create tmp dir\");\n dir\n }\n\n #[test]\n fn writes_and_reads_fresh() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"reg.json\"));\n cache.write(&sample_data()).expect(\"write\");\n let got = cache.read_fresh().expect(\"read\").expect(\"fresh\");\n assert_eq!(got.providers.len(), 1);\n assert_eq!(got.canonical[0].id, \"deepseek/deepseek-v3.2\");\n }\n\n #[test]\n fn missing_file_returns_none() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"absent.json\"));\n assert!(cache.read_fresh().expect(\"read_fresh\").is_none());\n assert!(cache.read_any().expect(\"read_any\").is_none());\n }\n\n #[test]\n fn stale_payload_only_visible_via_read_any() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n let stale = CachedPayload {\n fetched_at: 0,\n data: sample_data(),\n };\n fs::write(&path, serde_json::to_vec_pretty(&stale).expect(\"ser\")).expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(\n cache.read_fresh().expect(\"read_fresh\").is_none(),\n \"TTL must reject stale\"\n );\n assert!(\n cache.read_any().expect(\"read_any\").is_some(),\n \"read_any must still surface stale data\"\n );\n }\n\n #[test]\n fn rejects_corrupt_payload() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n fs::write(&path, b\"not json\").expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(matches!(cache.read_any(), Err(CacheError::Json(_))));\n }\n}\n"}, "fa4083ec5789cb970a5e291786609ef9f8005e00": {"t": 1785761079.4275928, "n": 8673, "h": "fef5bb76ccbc87a0", "c": "//! File-based on-disk cache for the provider-registry distribution.\n//!\n//! Layout: a single JSON file `{ \"fetched_at\": , \"data\": }`.\n//! Default path: `$XDG_CACHE_HOME/bitrouter/provider-registry.json` (falling\n//! back to `~/.cache/bitrouter/…` on Unix or\n//! `%LOCALAPPDATA%\\bitrouter\\cache\\…` on Windows), per the XDG Base Directory\n//! spec ().\n//!\n//! TTL: 24h. After expiry the cache is \"stale\" — callers should re-fetch, but\n//! [`DiskCache::read_any`] still returns the stale data so a network outage\n//! doesn't blank the routable provider set.\n\nuse std::fs;\nuse std::io;\nuse std::path::PathBuf;\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::registry::types::RegistryData;\n\n/// Freshness window — 24 hours. After this the cached payload is \"stale\".\npub const TTL: Duration = Duration::from_secs(24 * 60 * 60);\n\n/// Default filename inside the bitrouter cache directory.\npub const DEFAULT_FILENAME: &str = \"provider-registry.json\";\n\n/// Errors raised by the disk cache.\n#[derive(Debug, thiserror::Error)]\npub enum CacheError {\n /// File I/O failure (open / write / mkdir / rename).\n #[error(\"registry cache I/O error at {path}: {source}\")]\n Io {\n /// The path that failed.\n path: PathBuf,\n /// The underlying I/O error.\n #[source]\n source: io::Error,\n },\n /// JSON parse / serialise failure.\n #[error(\"registry cache JSON error: {0}\")]\n Json(#[from] serde_json::Error),\n /// No home / cache directory could be resolved.\n #[error(\"could not resolve a cache directory\")]\n NoCacheDir,\n}\n\n/// One on-disk cache slot for the registry.\npub struct DiskCache {\n path: PathBuf,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct CachedPayload {\n /// Unix seconds when the registry was fetched.\n fetched_at: u64,\n data: RegistryData,\n}\n\nimpl DiskCache {\n /// Use an explicit cache path. Tests use this; the binary calls\n /// [`Self::default_path`].\n pub fn at(path: impl Into) -> Self {\n Self { path: path.into() }\n }\n\n /// Resolve the default cache file under `$XDG_CACHE_HOME/bitrouter/`.\n pub fn default_path() -> Result {\n let dir = default_cache_dir()?;\n Ok(Self::at(dir.join(DEFAULT_FILENAME)))\n }\n\n /// Read the cached data if it exists AND is within the TTL window. Returns\n /// `Ok(None)` for missing or stale entries — callers that don't care about\n /// freshness should call [`Self::read_any`].\n pub fn read_fresh(&self) -> Result, CacheError> {\n let Some((payload, age)) = self.read_with_age()? else {\n return Ok(None);\n };\n if age <= TTL {\n Ok(Some(payload.data))\n } else {\n Ok(None)\n }\n }\n\n /// Read the cached data whether or not it is fresh. Returns `Ok(None)` only\n /// when the file is absent or unreadable. Use after a fetch fails so a\n /// transient network outage still serves the provider set.\n pub fn read_any(&self) -> Result, CacheError> {\n Ok(self.read_with_age()?.map(|(p, _)| p.data))\n }\n\n /// Write freshly-fetched registry data, stamping the current time.\n pub fn write(&self, data: &RegistryData) -> Result<(), CacheError> {\n let parent = self.path.parent().ok_or(CacheError::NoCacheDir)?;\n fs::create_dir_all(parent).map_err(|source| CacheError::Io {\n path: parent.to_path_buf(),\n source,\n })?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let payload = CachedPayload {\n fetched_at: now,\n data: data.clone(),\n };\n let bytes = serde_json::to_vec_pretty(&payload)?;\n let tmp = self.path.with_extension(\"json.tmp\");\n // atomic-replace: write to a sibling then rename, so a crash mid-write\n // never leaves a half-truncated cache.\n fs::write(&tmp, &bytes).map_err(|source| CacheError::Io {\n path: tmp.clone(),\n source,\n })?;\n fs::rename(&tmp, &self.path).map_err(|source| CacheError::Io {\n path: self.path.clone(),\n source,\n })?;\n Ok(())\n }\n\n fn read_with_age(&self) -> Result, CacheError> {\n let bytes = match fs::read(&self.path) {\n Ok(b) => b,\n Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),\n Err(source) => {\n return Err(CacheError::Io {\n path: self.path.clone(),\n source,\n });\n }\n };\n let payload: CachedPayload = serde_json::from_slice(&bytes)?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let age = Duration::from_secs(now.saturating_sub(payload.fetched_at));\n Ok(Some((payload, age)))\n }\n}\n\n/// Resolve the bitrouter cache directory. Follows the XDG Base Directory spec\n/// on Unix; uses `%LOCALAPPDATA%` on Windows.\nfn default_cache_dir() -> Result {\n if let Some(dir) = std::env::var_os(\"XDG_CACHE_HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\"));\n }\n #[cfg(windows)]\n if let Some(dir) = std::env::var_os(\"LOCALAPPDATA\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\").join(\"cache\"));\n }\n if let Some(home) = std::env::var_os(\"HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(home).join(\".cache\").join(\"bitrouter\"));\n }\n Err(CacheError::NoCacheDir)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::registry::types::{CanonicalModel, RegistryProvider};\n\n fn sample_data() -> RegistryData {\n RegistryData {\n providers: vec![RegistryProvider {\n name: \"deepseek\".into(),\n api_base: \"https://api.deepseek.com/v1\".into(),\n models: Vec::new(),\n status: \"active\".into(),\n community: false,\n byok: true,\n billing: Default::default(),\n }],\n canonical: vec![CanonicalModel {\n id: \"deepseek/deepseek-v3.2\".into(),\n }],\n }\n }\n\n /// Unique tmp dir per call — tests run in parallel and a shared directory\n /// races with itself.\n fn tmp_dir() -> PathBuf {\n use std::sync::atomic::{AtomicU64, Ordering};\n static COUNTER: AtomicU64 = AtomicU64::new(0);\n let id = COUNTER.fetch_add(1, Ordering::Relaxed);\n let dir = std::env::temp_dir().join(format!(\n \"bitrouter-registry-cache-{}-{id}\",\n std::process::id()\n ));\n let _ = fs::remove_dir_all(&dir);\n fs::create_dir_all(&dir).expect(\"create tmp dir\");\n dir\n }\n\n #[test]\n fn writes_and_reads_fresh() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"reg.json\"));\n cache.write(&sample_data()).expect(\"write\");\n let got = cache.read_fresh().expect(\"read\").expect(\"fresh\");\n assert_eq!(got.providers.len(), 1);\n assert_eq!(got.canonical[0].id, \"deepseek/deepseek-v3.2\");\n }\n\n #[test]\n fn missing_file_returns_none() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"absent.json\"));\n assert!(cache.read_fresh().expect(\"read_fresh\").is_none());\n assert!(cache.read_any().expect(\"read_any\").is_none());\n }\n\n #[test]\n fn stale_payload_only_visible_via_read_any() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n let stale = CachedPayload {\n fetched_at: 0,\n data: sample_data(),\n };\n fs::write(&path, serde_json::to_vec_pretty(&stale).expect(\"ser\")).expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(\n cache.read_fresh().expect(\"read_fresh\").is_none(),\n \"TTL must reject stale\"\n );\n assert!(\n cache.read_any().expect(\"read_any\").is_some(),\n \"read_any must still surface stale data\"\n );\n }\n\n #[test]\n fn rejects_corrupt_payload() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n fs::write(&path, b\"not json\").expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(matches!(cache.read_any(), Err(CacheError::Json(_))));\n }\n}\n"}, "25c832260981e0149867f08144ab4fb19075520a": {"t": 1786067652.4066439, "n": 8987, "h": "23a2888502c2b3d4", "c": "//! File-based on-disk cache for the public registry distribution.\n//!\n//! Layout: a single JSON file `{ \"fetched_at\": , \"data\": }`.\n//! Default path: `$XDG_CACHE_HOME/bitrouter/registry.json` (falling\n//! back to `~/.cache/bitrouter/…` on Unix or\n//! `%LOCALAPPDATA%\\bitrouter\\cache\\…` on Windows), per the XDG Base Directory\n//! spec ().\n//!\n//! TTL: 24h. After expiry the cache is \"stale\" — callers should re-fetch, but\n//! [`DiskCache::read_any`] still returns the stale data so a network outage\n//! doesn't blank the routable provider set.\n\nuse std::fs;\nuse std::io;\nuse std::path::PathBuf;\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::registry::types::RegistryData;\n\n/// Freshness window — 24 hours. After this the cached payload is \"stale\".\npub const TTL: Duration = Duration::from_secs(24 * 60 * 60);\n\n/// Default filename inside the bitrouter cache directory.\npub const DEFAULT_FILENAME: &str = \"registry.json\";\n\n/// Errors raised by the disk cache.\n#[derive(Debug, thiserror::Error)]\npub enum CacheError {\n /// File I/O failure (open / write / mkdir / rename).\n #[error(\"registry cache I/O error at {path}: {source}\")]\n Io {\n /// The path that failed.\n path: PathBuf,\n /// The underlying I/O error.\n #[source]\n source: io::Error,\n },\n /// JSON parse / serialise failure.\n #[error(\"registry cache JSON error: {0}\")]\n Json(#[from] serde_json::Error),\n /// No home / cache directory could be resolved.\n #[error(\"could not resolve a cache directory\")]\n NoCacheDir,\n}\n\n/// One on-disk cache slot for the registry.\npub struct DiskCache {\n path: PathBuf,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct CachedPayload {\n /// Unix seconds when the registry was fetched.\n fetched_at: u64,\n data: RegistryData,\n}\n\nimpl DiskCache {\n /// Use an explicit cache path. Tests use this; the binary calls\n /// [`Self::default_path`].\n pub fn at(path: impl Into) -> Self {\n Self { path: path.into() }\n }\n\n /// Resolve the default cache file under `$XDG_CACHE_HOME/bitrouter/`.\n pub fn default_path() -> Result {\n let dir = default_cache_dir()?;\n Ok(Self::at(dir.join(DEFAULT_FILENAME)))\n }\n\n /// Read the cached data if it exists AND is within the TTL window. Returns\n /// `Ok(None)` for missing or stale entries — callers that don't care about\n /// freshness should call [`Self::read_any`].\n pub fn read_fresh(&self) -> Result, CacheError> {\n let Some((payload, age)) = self.read_with_age()? else {\n return Ok(None);\n };\n if age <= TTL {\n Ok(Some(payload.data))\n } else {\n Ok(None)\n }\n }\n\n /// Read the cached data whether or not it is fresh. Returns `Ok(None)` only\n /// when the file is absent or unreadable. Use after a fetch fails so a\n /// transient network outage still serves the provider set.\n pub fn read_any(&self) -> Result, CacheError> {\n Ok(self.read_with_age()?.map(|(p, _)| p.data))\n }\n\n /// Write freshly-fetched registry data, stamping the current time.\n pub fn write(&self, data: &RegistryData) -> Result<(), CacheError> {\n let parent = self.path.parent().ok_or(CacheError::NoCacheDir)?;\n fs::create_dir_all(parent).map_err(|source| CacheError::Io {\n path: parent.to_path_buf(),\n source,\n })?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let payload = CachedPayload {\n fetched_at: now,\n data: data.clone(),\n };\n let bytes = serde_json::to_vec_pretty(&payload)?;\n let tmp = self.path.with_extension(\"json.tmp\");\n // atomic-replace: write to a sibling then rename, so a crash mid-write\n // never leaves a half-truncated cache.\n fs::write(&tmp, &bytes).map_err(|source| CacheError::Io {\n path: tmp.clone(),\n source,\n })?;\n fs::rename(&tmp, &self.path).map_err(|source| CacheError::Io {\n path: self.path.clone(),\n source,\n })?;\n Ok(())\n }\n\n fn read_with_age(&self) -> Result, CacheError> {\n let bytes = match fs::read(&self.path) {\n Ok(b) => b,\n Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),\n Err(source) => {\n return Err(CacheError::Io {\n path: self.path.clone(),\n source,\n });\n }\n };\n let payload: CachedPayload = serde_json::from_slice(&bytes)?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let age = Duration::from_secs(now.saturating_sub(payload.fetched_at));\n Ok(Some((payload, age)))\n }\n}\n\n/// Resolve the bitrouter cache directory. Follows the XDG Base Directory spec\n/// on Unix; uses `%LOCALAPPDATA%` on Windows.\nfn default_cache_dir() -> Result {\n if let Some(dir) = std::env::var_os(\"XDG_CACHE_HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\"));\n }\n #[cfg(windows)]\n if let Some(dir) = std::env::var_os(\"LOCALAPPDATA\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\").join(\"cache\"));\n }\n if let Some(home) = std::env::var_os(\"HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(home).join(\".cache\").join(\"bitrouter\"));\n }\n Err(CacheError::NoCacheDir)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::registry::types::{CanonicalModel, RegistryAccess, RegistryProvider};\n\n fn sample_data() -> RegistryData {\n RegistryData {\n providers: vec![RegistryProvider {\n name: \"deepseek\".into(),\n display_name: None,\n api_base: Some(\"https://api.deepseek.com/v1\".into()),\n api_protocol: Vec::new(),\n protocol_endpoints: None,\n models: Vec::new(),\n status: \"active\".into(),\n kind: None,\n auth: None,\n required_config: Vec::new(),\n doc_url: None,\n community: false,\n access: Some(RegistryAccess::ApiKey),\n byok: Some(true),\n billing: Default::default(),\n }],\n canonical: vec![CanonicalModel {\n id: \"deepseek/deepseek-v3.2\".into(),\n }],\n }\n }\n\n /// Unique tmp dir per call — tests run in parallel and a shared directory\n /// races with itself.\n fn tmp_dir() -> PathBuf {\n use std::sync::atomic::{AtomicU64, Ordering};\n static COUNTER: AtomicU64 = AtomicU64::new(0);\n let id = COUNTER.fetch_add(1, Ordering::Relaxed);\n let dir = std::env::temp_dir().join(format!(\n \"bitrouter-registry-cache-{}-{id}\",\n std::process::id()\n ));\n let _ = fs::remove_dir_all(&dir);\n fs::create_dir_all(&dir).expect(\"create tmp dir\");\n dir\n }\n\n #[test]\n fn writes_and_reads_fresh() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"reg.json\"));\n cache.write(&sample_data()).expect(\"write\");\n let got = cache.read_fresh().expect(\"read\").expect(\"fresh\");\n assert_eq!(got.providers.len(), 1);\n assert_eq!(got.canonical[0].id, \"deepseek/deepseek-v3.2\");\n }\n\n #[test]\n fn missing_file_returns_none() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"absent.json\"));\n assert!(cache.read_fresh().expect(\"read_fresh\").is_none());\n assert!(cache.read_any().expect(\"read_any\").is_none());\n }\n\n #[test]\n fn stale_payload_only_visible_via_read_any() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n let stale = CachedPayload {\n fetched_at: 0,\n data: sample_data(),\n };\n fs::write(&path, serde_json::to_vec_pretty(&stale).expect(\"ser\")).expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(\n cache.read_fresh().expect(\"read_fresh\").is_none(),\n \"TTL must reject stale\"\n );\n assert!(\n cache.read_any().expect(\"read_any\").is_some(),\n \"read_any must still surface stale data\"\n );\n }\n\n #[test]\n fn rejects_corrupt_payload() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n fs::write(&path, b\"not json\").expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(matches!(cache.read_any(), Err(CacheError::Json(_))));\n }\n}\n"}, "0dc80cda963d54f09fd31665e4cd025b54510f77": {"t": 1786068398.0544245, "n": 8987, "h": "23a2888502c2b3d4", "c": "//! File-based on-disk cache for the public registry distribution.\n//!\n//! Layout: a single JSON file `{ \"fetched_at\": , \"data\": }`.\n//! Default path: `$XDG_CACHE_HOME/bitrouter/registry.json` (falling\n//! back to `~/.cache/bitrouter/…` on Unix or\n//! `%LOCALAPPDATA%\\bitrouter\\cache\\…` on Windows), per the XDG Base Directory\n//! spec ().\n//!\n//! TTL: 24h. After expiry the cache is \"stale\" — callers should re-fetch, but\n//! [`DiskCache::read_any`] still returns the stale data so a network outage\n//! doesn't blank the routable provider set.\n\nuse std::fs;\nuse std::io;\nuse std::path::PathBuf;\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::registry::types::RegistryData;\n\n/// Freshness window — 24 hours. After this the cached payload is \"stale\".\npub const TTL: Duration = Duration::from_secs(24 * 60 * 60);\n\n/// Default filename inside the bitrouter cache directory.\npub const DEFAULT_FILENAME: &str = \"registry.json\";\n\n/// Errors raised by the disk cache.\n#[derive(Debug, thiserror::Error)]\npub enum CacheError {\n /// File I/O failure (open / write / mkdir / rename).\n #[error(\"registry cache I/O error at {path}: {source}\")]\n Io {\n /// The path that failed.\n path: PathBuf,\n /// The underlying I/O error.\n #[source]\n source: io::Error,\n },\n /// JSON parse / serialise failure.\n #[error(\"registry cache JSON error: {0}\")]\n Json(#[from] serde_json::Error),\n /// No home / cache directory could be resolved.\n #[error(\"could not resolve a cache directory\")]\n NoCacheDir,\n}\n\n/// One on-disk cache slot for the registry.\npub struct DiskCache {\n path: PathBuf,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct CachedPayload {\n /// Unix seconds when the registry was fetched.\n fetched_at: u64,\n data: RegistryData,\n}\n\nimpl DiskCache {\n /// Use an explicit cache path. Tests use this; the binary calls\n /// [`Self::default_path`].\n pub fn at(path: impl Into) -> Self {\n Self { path: path.into() }\n }\n\n /// Resolve the default cache file under `$XDG_CACHE_HOME/bitrouter/`.\n pub fn default_path() -> Result {\n let dir = default_cache_dir()?;\n Ok(Self::at(dir.join(DEFAULT_FILENAME)))\n }\n\n /// Read the cached data if it exists AND is within the TTL window. Returns\n /// `Ok(None)` for missing or stale entries — callers that don't care about\n /// freshness should call [`Self::read_any`].\n pub fn read_fresh(&self) -> Result, CacheError> {\n let Some((payload, age)) = self.read_with_age()? else {\n return Ok(None);\n };\n if age <= TTL {\n Ok(Some(payload.data))\n } else {\n Ok(None)\n }\n }\n\n /// Read the cached data whether or not it is fresh. Returns `Ok(None)` only\n /// when the file is absent or unreadable. Use after a fetch fails so a\n /// transient network outage still serves the provider set.\n pub fn read_any(&self) -> Result, CacheError> {\n Ok(self.read_with_age()?.map(|(p, _)| p.data))\n }\n\n /// Write freshly-fetched registry data, stamping the current time.\n pub fn write(&self, data: &RegistryData) -> Result<(), CacheError> {\n let parent = self.path.parent().ok_or(CacheError::NoCacheDir)?;\n fs::create_dir_all(parent).map_err(|source| CacheError::Io {\n path: parent.to_path_buf(),\n source,\n })?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let payload = CachedPayload {\n fetched_at: now,\n data: data.clone(),\n };\n let bytes = serde_json::to_vec_pretty(&payload)?;\n let tmp = self.path.with_extension(\"json.tmp\");\n // atomic-replace: write to a sibling then rename, so a crash mid-write\n // never leaves a half-truncated cache.\n fs::write(&tmp, &bytes).map_err(|source| CacheError::Io {\n path: tmp.clone(),\n source,\n })?;\n fs::rename(&tmp, &self.path).map_err(|source| CacheError::Io {\n path: self.path.clone(),\n source,\n })?;\n Ok(())\n }\n\n fn read_with_age(&self) -> Result, CacheError> {\n let bytes = match fs::read(&self.path) {\n Ok(b) => b,\n Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),\n Err(source) => {\n return Err(CacheError::Io {\n path: self.path.clone(),\n source,\n });\n }\n };\n let payload: CachedPayload = serde_json::from_slice(&bytes)?;\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .map(|d| d.as_secs())\n .unwrap_or(0);\n let age = Duration::from_secs(now.saturating_sub(payload.fetched_at));\n Ok(Some((payload, age)))\n }\n}\n\n/// Resolve the bitrouter cache directory. Follows the XDG Base Directory spec\n/// on Unix; uses `%LOCALAPPDATA%` on Windows.\nfn default_cache_dir() -> Result {\n if let Some(dir) = std::env::var_os(\"XDG_CACHE_HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\"));\n }\n #[cfg(windows)]\n if let Some(dir) = std::env::var_os(\"LOCALAPPDATA\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(dir).join(\"bitrouter\").join(\"cache\"));\n }\n if let Some(home) = std::env::var_os(\"HOME\").filter(|v| !v.is_empty()) {\n return Ok(PathBuf::from(home).join(\".cache\").join(\"bitrouter\"));\n }\n Err(CacheError::NoCacheDir)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::registry::types::{CanonicalModel, RegistryAccess, RegistryProvider};\n\n fn sample_data() -> RegistryData {\n RegistryData {\n providers: vec![RegistryProvider {\n name: \"deepseek\".into(),\n display_name: None,\n api_base: Some(\"https://api.deepseek.com/v1\".into()),\n api_protocol: Vec::new(),\n protocol_endpoints: None,\n models: Vec::new(),\n status: \"active\".into(),\n kind: None,\n auth: None,\n required_config: Vec::new(),\n doc_url: None,\n community: false,\n access: Some(RegistryAccess::ApiKey),\n byok: Some(true),\n billing: Default::default(),\n }],\n canonical: vec![CanonicalModel {\n id: \"deepseek/deepseek-v3.2\".into(),\n }],\n }\n }\n\n /// Unique tmp dir per call — tests run in parallel and a shared directory\n /// races with itself.\n fn tmp_dir() -> PathBuf {\n use std::sync::atomic::{AtomicU64, Ordering};\n static COUNTER: AtomicU64 = AtomicU64::new(0);\n let id = COUNTER.fetch_add(1, Ordering::Relaxed);\n let dir = std::env::temp_dir().join(format!(\n \"bitrouter-registry-cache-{}-{id}\",\n std::process::id()\n ));\n let _ = fs::remove_dir_all(&dir);\n fs::create_dir_all(&dir).expect(\"create tmp dir\");\n dir\n }\n\n #[test]\n fn writes_and_reads_fresh() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"reg.json\"));\n cache.write(&sample_data()).expect(\"write\");\n let got = cache.read_fresh().expect(\"read\").expect(\"fresh\");\n assert_eq!(got.providers.len(), 1);\n assert_eq!(got.canonical[0].id, \"deepseek/deepseek-v3.2\");\n }\n\n #[test]\n fn missing_file_returns_none() {\n let dir = tmp_dir();\n let cache = DiskCache::at(dir.join(\"absent.json\"));\n assert!(cache.read_fresh().expect(\"read_fresh\").is_none());\n assert!(cache.read_any().expect(\"read_any\").is_none());\n }\n\n #[test]\n fn stale_payload_only_visible_via_read_any() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n let stale = CachedPayload {\n fetched_at: 0,\n data: sample_data(),\n };\n fs::write(&path, serde_json::to_vec_pretty(&stale).expect(\"ser\")).expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(\n cache.read_fresh().expect(\"read_fresh\").is_none(),\n \"TTL must reject stale\"\n );\n assert!(\n cache.read_any().expect(\"read_any\").is_some(),\n \"read_any must still surface stale data\"\n );\n }\n\n #[test]\n fn rejects_corrupt_payload() {\n let dir = tmp_dir();\n let path = dir.join(\"reg.json\");\n fs::write(&path, b\"not json\").expect(\"write\");\n let cache = DiskCache::at(&path);\n assert!(matches!(cache.read_any(), Err(CacheError::Json(_))));\n }\n}\n"}}}}