From c0a5939e0e442ea3184e48bdef15f1a2860d66c9 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 11 Jul 2026 21:40:35 -0700 Subject: [PATCH 1/5] Add OpenRouter app attribution headers Automatically attach OpenRouter app attribution headers (HTTP-Referer, X-OpenRouter-Title, X-OpenRouter-Categories) whenever a provider's base_url points at openrouter.ai, so Codex Warp usage appears in OpenRouter's public rankings and analytics. - Inject attribution in apply_headers_with_accept (src/http.rs), covering chat completions, native /responses, and /models - User-set [providers..headers] override the automatic values - Add configs/openrouter.toml profile and document in README - Add unit tests in src/http_tests.rs --- README.md | 16 +++++++++ codex-warp.toml | 1 + configs/openrouter.toml | 18 ++++++++++ src/http.rs | 46 +++++++++++++++++++++++++ src/http_tests.rs | 75 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+) create mode 100644 configs/openrouter.toml diff --git a/README.md b/README.md index e2f64fa..ea2daec 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,24 @@ for deployment examples. | Moonshot KimiCode | [`configs/moonshot-kimicode.toml`](configs/moonshot-kimicode.toml) | Ready profile for Moonshot KimiCode subscription keys with a local Kimi model catalog fallback. | No | | OpenCode Go | [`configs/opencode-go.toml`](configs/opencode-go.toml) | Ready profile for OpenCode Go subscription keys, limited to its OpenAI-compatible chat-completions models. | No | | Xiaomi Token Plan | [`configs/xiaomi-token-plan.toml`](configs/xiaomi-token-plan.toml) | Ready profile for `https://token-plan-sgp.xiaomimimo.com/v1`. | No | +| OpenRouter | [`configs/openrouter.toml`](configs/openrouter.toml) | Ready profile for OpenRouter; app attribution headers are attached automatically. | No | | Destination override | `--destination https://provider.example/v1` | Quick one-off target without editing provider config. | Only when passed | +## OpenRouter App Attribution + +When Codex Warp proxies requests to an OpenRouter endpoint (any provider whose +`base_url` contains `openrouter.ai`, including the [`configs/openrouter.toml`](configs/openrouter.toml) +profile), it automatically attaches [OpenRouter app attribution](https://openrouter.ai/docs/app-attribution) +headers so the proxy's usage appears in OpenRouter's public rankings and analytics: + +- `HTTP-Referer`: `https://github.com/jatmn/Codex-warp` +- `X-OpenRouter-Title`: `Codex Warp` +- `X-OpenRouter-Categories`: `cli-agent,programming-app` + +These are Codex Warp's own identity values. To override any of them for a +specific provider, set the header under that provider's `[providers..headers]` +section — user-supplied headers always take precedence over the automatic ones. + ## Supported Model Families | Parent brand | Catalog | Examples covered | diff --git a/codex-warp.toml b/codex-warp.toml index 5b64193..372eead 100644 --- a/codex-warp.toml +++ b/codex-warp.toml @@ -28,6 +28,7 @@ hide_codex_builtin_models = true # "configs/opencode-go.toml", # "configs/xiaomi-token-plan.toml", # "configs/openai-compatible.toml", +# "configs/openrouter.toml", # ] model_family_include = [ "configs/model-families/deepseek.toml", diff --git a/configs/openrouter.toml b/configs/openrouter.toml new file mode 100644 index 0000000..b57c715 --- /dev/null +++ b/configs/openrouter.toml @@ -0,0 +1,18 @@ +# OpenRouter provider profile. +# Docs: https://openrouter.ai/docs/app-attribution +# +# OpenRouter exposes a large live /models catalog, so no local model_catalog is +# needed here. Codex Warp automatically attaches the OpenRouter app attribution +# headers (HTTP-Referer, X-OpenRouter-Title, X-OpenRouter-Categories) whenever +# the upstream base_url points at openrouter.ai. To override any of them, set +# the header under [providers.openrouter.headers]. + +[providers.openrouter] +name = "OpenRouter" +base_url = "https://openrouter.ai/api/v1" +api_key_env = "OPENROUTER_API_KEY" +auth_header = "authorization" +auth_scheme = "Bearer" +responses_path = "/responses" +chat_completions_path = "/chat/completions" +models_path = "/models" diff --git a/src/http.rs b/src/http.rs index b891e79..34df082 100644 --- a/src/http.rs +++ b/src/http.rs @@ -8,6 +8,50 @@ use serde_json::json; use crate::config::ProviderConfig; use crate::version::user_agent; +// OpenRouter app attribution (https://openrouter.ai/docs/app-attribution). +// When Codex Warp proxies to OpenRouter it identifies itself so usage shows up +// in OpenRouter's public rankings and analytics. These are the project's own +// identity values; they can be overridden per provider via +// [providers..headers] in config. +const OPENROUTER_REFERER: &str = "https://github.com/jatmn/Codex-warp"; +const OPENROUTER_TITLE: &str = "Codex Warp"; +const OPENROUTER_CATEGORIES: &str = "cli-agent,programming-app"; + +const OPENROUTER_HOST: &str = "openrouter.ai"; + +fn is_openrouter(provider: &ProviderConfig) -> bool { + provider + .base_url + .to_ascii_lowercase() + .contains(OPENROUTER_HOST) +} + +fn apply_openrouter_attribution( + request: reqwest::RequestBuilder, + provider: &ProviderConfig, +) -> reqwest::RequestBuilder { + if !is_openrouter(provider) { + return request; + } + let has_header = |name: &str| { + provider + .headers + .keys() + .any(|key| key.eq_ignore_ascii_case(name)) + }; + let mut request = request; + if !has_header("HTTP-Referer") { + request = request.header("HTTP-Referer", OPENROUTER_REFERER); + } + if !has_header("X-OpenRouter-Title") && !has_header("X-Title") { + request = request.header("X-OpenRouter-Title", OPENROUTER_TITLE); + } + if !has_header("X-OpenRouter-Categories") { + request = request.header("X-OpenRouter-Categories", OPENROUTER_CATEGORIES); + } + request +} + pub(crate) fn endpoint_url(provider: &ProviderConfig, path: &str) -> String { format!( "{}/{}", @@ -48,6 +92,8 @@ pub(crate) fn apply_headers_with_accept( request = request.header(name, value); } + let request = apply_openrouter_attribution(request, provider); + request .header(axum::http::header::USER_AGENT, user_agent()) .header(axum::http::header::ACCEPT, accept) diff --git a/src/http_tests.rs b/src/http_tests.rs index c0aa6ad..d3c57dc 100644 --- a/src/http_tests.rs +++ b/src/http_tests.rs @@ -28,3 +28,78 @@ fn upstream_requests_report_codex_warp_user_agent() { Some(expected.as_str()) ); } + +#[test] +fn openrouter_provider_gets_attribution_headers() { + let mut provider = ProviderConfig::default(); + provider.base_url = "https://openrouter.ai/api/v1".to_string(); + + let request = Client::new().post("https://openrouter.ai/api/v1/chat/completions"); + let request = + apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") + .build() + .expect("request builds"); + let headers = request.headers(); + + assert_eq!( + headers.get("HTTP-Referer").and_then(|v| v.to_str().ok()), + Some("https://github.com/jatmn/Codex-warp") + ); + assert_eq!( + headers + .get("X-OpenRouter-Title") + .and_then(|v| v.to_str().ok()), + Some("Codex Warp") + ); + assert_eq!( + headers + .get("X-OpenRouter-Categories") + .and_then(|v| v.to_str().ok()), + Some("cli-agent,programming-app") + ); +} + +#[test] +fn non_openrouter_provider_skips_attribution_headers() { + let mut provider = ProviderConfig::default(); + provider.base_url = "https://api.example.com/v1".to_string(); + + let request = Client::new().post("https://api.example.com/v1/chat/completions"); + let request = + apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") + .build() + .expect("request builds"); + let headers = request.headers(); + + assert!(headers.get("HTTP-Referer").is_none()); + assert!(headers.get("X-OpenRouter-Title").is_none()); + assert!(headers.get("X-OpenRouter-Categories").is_none()); +} + +#[test] +fn user_headers_override_openrouter_attribution() { + let mut provider = ProviderConfig::default(); + provider.base_url = "https://openrouter.ai/api/v1".to_string(); + provider.headers.insert( + "HTTP-Referer".to_string(), + "https://my-custom-app.example".to_string(), + ); + + let request = Client::new().post("https://openrouter.ai/api/v1/chat/completions"); + let request = + apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") + .build() + .expect("request builds"); + let headers = request.headers(); + + assert_eq!( + headers.get("HTTP-Referer").and_then(|v| v.to_str().ok()), + Some("https://my-custom-app.example") + ); + assert_eq!( + headers + .get("X-OpenRouter-Title") + .and_then(|v| v.to_str().ok()), + Some("Codex Warp") + ); +} From aa42aa96217c60ecfba37ffee44f9fa52de43a0f Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 11 Jul 2026 22:06:05 -0700 Subject: [PATCH 2/5] Harden OpenRouter attribution: anchor host detection, expand tests - Detect OpenRouter by the parsed host (== openrouter.ai or a *.openrouter.ai subdomain) instead of a raw substring match, so look-alike hosts (e.g. openrouter.ai.attacker.example) are no longer misidentified as OpenRouter. - Add tests for case-insensitive host, subdomain, look-alike false-positive, X-Title alias suppression, X-OpenRouter-Categories override, and /responses + /models paths; assert exactly one value per header to catch duplicate-header regressions. - Document the opt-out/override and update detection wording in the README and configs/openrouter.toml. - Use a mut request parameter and add a rationale comment for the hardcoded identity values. Validated: cargo fmt --check, cargo build --locked, cargo test --locked (134 passing). --- README.md | 5 +- configs/openrouter.toml | 2 +- src/http.rs | 39 +++++++-- src/http_tests.rs | 171 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ea2daec..136c16b 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,7 @@ for deployment examples. ## OpenRouter App Attribution -When Codex Warp proxies requests to an OpenRouter endpoint (any provider whose -`base_url` contains `openrouter.ai`, including the [`configs/openrouter.toml`](configs/openrouter.toml) +When Codex Warp proxies requests to an OpenRouter endpoint (any provider whose `base_url` host is `openrouter.ai` (or a `*.openrouter.ai` subdomain), including the [`configs/openrouter.toml`](configs/openrouter.toml) profile), it automatically attaches [OpenRouter app attribution](https://openrouter.ai/docs/app-attribution) headers so the proxy's usage appears in OpenRouter's public rankings and analytics: @@ -175,6 +174,8 @@ These are Codex Warp's own identity values. To override any of them for a specific provider, set the header under that provider's `[providers..headers]` section — user-supplied headers always take precedence over the automatic ones. +Note: `HTTP-Referer` is Codex Warp's public GitHub URL, so all deployments report usage under that identity in OpenRouter's public rankings. To attribute traffic to your own project instead, override `HTTP-Referer` (and the other headers) under `[providers..headers]`. + ## Supported Model Families | Parent brand | Catalog | Examples covered | diff --git a/configs/openrouter.toml b/configs/openrouter.toml index b57c715..b30c75e 100644 --- a/configs/openrouter.toml +++ b/configs/openrouter.toml @@ -4,7 +4,7 @@ # OpenRouter exposes a large live /models catalog, so no local model_catalog is # needed here. Codex Warp automatically attaches the OpenRouter app attribution # headers (HTTP-Referer, X-OpenRouter-Title, X-OpenRouter-Categories) whenever -# the upstream base_url points at openrouter.ai. To override any of them, set +# the upstream base_url host is openrouter.ai (or a *.openrouter.ai subdomain). To override any of them, set # the header under [providers.openrouter.headers]. [providers.openrouter] diff --git a/src/http.rs b/src/http.rs index 34df082..5511c7b 100644 --- a/src/http.rs +++ b/src/http.rs @@ -13,21 +13,49 @@ use crate::version::user_agent; // in OpenRouter's public rankings and analytics. These are the project's own // identity values; they can be overridden per provider via // [providers..headers] in config. +// +// The values are hardcoded in Rust (rather than in configs/openrouter.toml) on +// purpose: attribution must apply to ANY provider whose upstream `base_url` host +// points at OpenRouter — including `--destination` overrides and user-created +// custom profiles — not only the shipped `openrouter` profile. Keeping the +// detection and identity here means a single code path covers every such +// provider while still letting operators override individual headers via config. const OPENROUTER_REFERER: &str = "https://github.com/jatmn/Codex-warp"; const OPENROUTER_TITLE: &str = "Codex Warp"; const OPENROUTER_CATEGORIES: &str = "cli-agent,programming-app"; +// The bare host that identifies OpenRouter. Detection compares the parsed +// request host against this value (exact, or as a `.openrouter.ai` subdomain) +// rather than a raw substring match, so look-alike hosts such as +// `openrouter.ai.attacker.example` are not misidentified as OpenRouter. const OPENROUTER_HOST: &str = "openrouter.ai"; +/// Returns the host portion of a URL (the `host` in `scheme://host...`), or +/// `None` if the string is not a recognizable absolute URL. +fn url_host(url: &str) -> Option<&str> { + let authority = url.split("://").nth(1)?.split(['/', '?', '#']).next()?; + // Drop any userinfo (user@host). + let hostport = authority.rsplit('@').next()?; + // Handle bracketed IPv6 literals ([::1]:port). + if let Some(rest) = hostport.strip_prefix('[') { + return rest.split(']').next(); + } + // Strip the port, if present. + hostport.split(':').next() +} + fn is_openrouter(provider: &ProviderConfig) -> bool { - provider - .base_url - .to_ascii_lowercase() - .contains(OPENROUTER_HOST) + match url_host(&provider.base_url) { + Some(host) => { + let host = host.to_ascii_lowercase(); + host == OPENROUTER_HOST || host.ends_with(&format!(".{OPENROUTER_HOST}")) + } + None => false, + } } fn apply_openrouter_attribution( - request: reqwest::RequestBuilder, + mut request: reqwest::RequestBuilder, provider: &ProviderConfig, ) -> reqwest::RequestBuilder { if !is_openrouter(provider) { @@ -39,7 +67,6 @@ fn apply_openrouter_attribution( .keys() .any(|key| key.eq_ignore_ascii_case(name)) }; - let mut request = request; if !has_header("HTTP-Referer") { request = request.header("HTTP-Referer", OPENROUTER_REFERER); } diff --git a/src/http_tests.rs b/src/http_tests.rs index d3c57dc..eb82c71 100644 --- a/src/http_tests.rs +++ b/src/http_tests.rs @@ -57,6 +57,10 @@ fn openrouter_provider_gets_attribution_headers() { .and_then(|v| v.to_str().ok()), Some("cli-agent,programming-app") ); + // Exactly one value per header (no duplicate auto + auto emission). + assert_eq!(headers.get_all("HTTP-Referer").iter().count(), 1); + assert_eq!(headers.get_all("X-OpenRouter-Title").iter().count(), 1); + assert_eq!(headers.get_all("X-OpenRouter-Categories").iter().count(), 1); } #[test] @@ -102,4 +106,171 @@ fn user_headers_override_openrouter_attribution() { .and_then(|v| v.to_str().ok()), Some("Codex Warp") ); + // The user override is the sole value — no duplicate auto header is appended. + assert_eq!(headers.get_all("HTTP-Referer").iter().count(), 1); + assert_eq!(headers.get_all("X-OpenRouter-Title").iter().count(), 1); +} + +#[test] +fn openrouter_case_insensitive_host_gets_attribution_headers() { + let mut provider = ProviderConfig::default(); + provider.base_url = "https://OPENROUTER.AI/api/v1".to_string(); + + let request = Client::new().post("https://OPENROUTER.AI/api/v1/chat/completions"); + let request = + apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") + .build() + .expect("request builds"); + let headers = request.headers(); + + assert_eq!( + headers.get("HTTP-Referer").and_then(|v| v.to_str().ok()), + Some("https://github.com/jatmn/Codex-warp") + ); + assert_eq!( + headers + .get("X-OpenRouter-Title") + .and_then(|v| v.to_str().ok()), + Some("Codex Warp") + ); + assert_eq!( + headers + .get("X-OpenRouter-Categories") + .and_then(|v| v.to_str().ok()), + Some("cli-agent,programming-app") + ); +} + +#[test] +fn openrouter_subdomain_host_gets_attribution_headers() { + let mut provider = ProviderConfig::default(); + provider.base_url = "https://api.openrouter.ai/v1".to_string(); + + let request = Client::new().post("https://api.openrouter.ai/v1/chat/completions"); + let request = + apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") + .build() + .expect("request builds"); + let headers = request.headers(); + + assert_eq!( + headers.get("HTTP-Referer").and_then(|v| v.to_str().ok()), + Some("https://github.com/jatmn/Codex-warp") + ); + assert_eq!( + headers + .get("X-OpenRouter-Title") + .and_then(|v| v.to_str().ok()), + Some("Codex Warp") + ); +} + +#[test] +fn lookalike_host_does_not_get_attribution_headers() { + let mut provider = ProviderConfig::default(); + // The substring "openrouter.ai" appears, but it is not the request host. + provider.base_url = "https://openrouter.ai.attacker.example/v1".to_string(); + + let request = Client::new().post("https://openrouter.ai.attacker.example/v1/chat/completions"); + let request = + apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") + .build() + .expect("request builds"); + let headers = request.headers(); + + assert!(headers.get("HTTP-Referer").is_none()); + assert!(headers.get("X-OpenRouter-Title").is_none()); + assert!(headers.get("X-OpenRouter-Categories").is_none()); +} + +#[test] +fn x_title_alias_suppresses_openrouter_title() { + let mut provider = ProviderConfig::default(); + provider.base_url = "https://openrouter.ai/api/v1".to_string(); + provider + .headers + .insert("X-Title".to_string(), "My App".to_string()); + + let request = Client::new().post("https://openrouter.ai/api/v1/chat/completions"); + let request = + apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") + .build() + .expect("request builds"); + let headers = request.headers(); + + // User's X-Title wins; the automatic X-OpenRouter-Title must not be added. + assert_eq!( + headers.get("X-Title").and_then(|v| v.to_str().ok()), + Some("My App") + ); + assert!(headers.get("X-OpenRouter-Title").is_none()); + // The other attribution headers are still applied. + assert_eq!( + headers.get("HTTP-Referer").and_then(|v| v.to_str().ok()), + Some("https://github.com/jatmn/Codex-warp") + ); + assert_eq!(headers.get_all("X-Title").iter().count(), 1); + assert_eq!(headers.get_all("X-OpenRouter-Title").iter().count(), 0); +} + +#[test] +fn user_categories_override_openrouter_attribution() { + let mut provider = ProviderConfig::default(); + provider.base_url = "https://openrouter.ai/api/v1".to_string(); + provider.headers.insert( + "X-OpenRouter-Categories".to_string(), + "my-category".to_string(), + ); + + let request = Client::new().post("https://openrouter.ai/api/v1/chat/completions"); + let request = + apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") + .build() + .expect("request builds"); + let headers = request.headers(); + + // Exactly one X-OpenRouter-Categories value: the user's override. + assert_eq!( + headers + .get("X-OpenRouter-Categories") + .and_then(|v| v.to_str().ok()), + Some("my-category") + ); + assert_eq!(headers.get_all("X-OpenRouter-Categories").iter().count(), 1); + // Title still auto-applied (not overridden here). + assert_eq!( + headers + .get("X-OpenRouter-Title") + .and_then(|v| v.to_str().ok()), + Some("Codex Warp") + ); +} + +#[test] +fn responses_and_models_paths_get_attribution_headers() { + let mut provider = ProviderConfig::default(); + provider.base_url = "https://openrouter.ai/api/v1".to_string(); + + for path in ["/responses", "/models"] { + let url = format!("https://openrouter.ai/api/v1{path}"); + let request = Client::new().post(&url); + let request = + apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") + .build() + .expect("request builds"); + let headers = request.headers(); + + assert_eq!( + headers.get("HTTP-Referer").and_then(|v| v.to_str().ok()), + Some("https://github.com/jatmn/Codex-warp"), + "missing attribution on {path}" + ); + assert_eq!( + headers + .get("X-OpenRouter-Title") + .and_then(|v| v.to_str().ok()), + Some("Codex Warp"), + "missing title on {path}" + ); + } } From 269921fd2142046a6e56d50276cdcb254dbc4b21 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 13:45:44 -0700 Subject: [PATCH 3/5] Apply OpenRouter attribution on all upstream requests Attribution headers were only attached when the active provider's base_url pointed at openrouter.ai. Send them on every upstream request instead so usage is reported across all gateways and models in multi-provider setups. User-supplied [providers..headers] still override the defaults. --- README.md | 9 ++-- configs/openrouter.toml | 6 +-- src/http.rs | 49 ++++------------------ src/http_tests.rs | 93 ++++++++--------------------------------- 4 files changed, 33 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 136c16b..42f47b2 100644 --- a/README.md +++ b/README.md @@ -157,14 +157,15 @@ for deployment examples. | Moonshot KimiCode | [`configs/moonshot-kimicode.toml`](configs/moonshot-kimicode.toml) | Ready profile for Moonshot KimiCode subscription keys with a local Kimi model catalog fallback. | No | | OpenCode Go | [`configs/opencode-go.toml`](configs/opencode-go.toml) | Ready profile for OpenCode Go subscription keys, limited to its OpenAI-compatible chat-completions models. | No | | Xiaomi Token Plan | [`configs/xiaomi-token-plan.toml`](configs/xiaomi-token-plan.toml) | Ready profile for `https://token-plan-sgp.xiaomimimo.com/v1`. | No | -| OpenRouter | [`configs/openrouter.toml`](configs/openrouter.toml) | Ready profile for OpenRouter; app attribution headers are attached automatically. | No | +| OpenRouter | [`configs/openrouter.toml`](configs/openrouter.toml) | Ready profile for OpenRouter; app attribution headers are attached on all upstream requests. | No | | Destination override | `--destination https://provider.example/v1` | Quick one-off target without editing provider config. | Only when passed | ## OpenRouter App Attribution -When Codex Warp proxies requests to an OpenRouter endpoint (any provider whose `base_url` host is `openrouter.ai` (or a `*.openrouter.ai` subdomain), including the [`configs/openrouter.toml`](configs/openrouter.toml) -profile), it automatically attaches [OpenRouter app attribution](https://openrouter.ai/docs/app-attribution) -headers so the proxy's usage appears in OpenRouter's public rankings and analytics: +Codex Warp automatically attaches [OpenRouter app attribution](https://openrouter.ai/docs/app-attribution) +headers on every upstream request — for all configured gateways and models, not +only when the [`configs/openrouter.toml`](configs/openrouter.toml) profile is +active — so the proxy's OpenRouter usage appears in public rankings and analytics: - `HTTP-Referer`: `https://github.com/jatmn/Codex-warp` - `X-OpenRouter-Title`: `Codex Warp` diff --git a/configs/openrouter.toml b/configs/openrouter.toml index b30c75e..22f8d3b 100644 --- a/configs/openrouter.toml +++ b/configs/openrouter.toml @@ -3,9 +3,9 @@ # # OpenRouter exposes a large live /models catalog, so no local model_catalog is # needed here. Codex Warp automatically attaches the OpenRouter app attribution -# headers (HTTP-Referer, X-OpenRouter-Title, X-OpenRouter-Categories) whenever -# the upstream base_url host is openrouter.ai (or a *.openrouter.ai subdomain). To override any of them, set -# the header under [providers.openrouter.headers]. +# headers (HTTP-Referer, X-OpenRouter-Title, X-OpenRouter-Categories) on every +# upstream request across all gateways and models. To override any of them, set +# the header under [providers..headers]. [providers.openrouter] name = "OpenRouter" diff --git a/src/http.rs b/src/http.rs index 5511c7b..ac3fb89 100644 --- a/src/http.rs +++ b/src/http.rs @@ -9,58 +9,23 @@ use crate::config::ProviderConfig; use crate::version::user_agent; // OpenRouter app attribution (https://openrouter.ai/docs/app-attribution). -// When Codex Warp proxies to OpenRouter it identifies itself so usage shows up -// in OpenRouter's public rankings and analytics. These are the project's own -// identity values; they can be overridden per provider via -// [providers..headers] in config. +// Codex Warp identifies itself on every upstream request so OpenRouter usage is +// attributed in public rankings and analytics regardless of which gateway profile +// or model is selected. These are the project's own identity values; they can be +// overridden per provider via [providers..headers] in config. // // The values are hardcoded in Rust (rather than in configs/openrouter.toml) on -// purpose: attribution must apply to ANY provider whose upstream `base_url` host -// points at OpenRouter — including `--destination` overrides and user-created -// custom profiles — not only the shipped `openrouter` profile. Keeping the -// detection and identity here means a single code path covers every such -// provider while still letting operators override individual headers via config. +// purpose: attribution must apply across all gateways and models — including +// multi-gateway setups, `--destination` overrides, and user-created custom +// profiles — not only when the shipped `openrouter` profile is active. const OPENROUTER_REFERER: &str = "https://github.com/jatmn/Codex-warp"; const OPENROUTER_TITLE: &str = "Codex Warp"; const OPENROUTER_CATEGORIES: &str = "cli-agent,programming-app"; -// The bare host that identifies OpenRouter. Detection compares the parsed -// request host against this value (exact, or as a `.openrouter.ai` subdomain) -// rather than a raw substring match, so look-alike hosts such as -// `openrouter.ai.attacker.example` are not misidentified as OpenRouter. -const OPENROUTER_HOST: &str = "openrouter.ai"; - -/// Returns the host portion of a URL (the `host` in `scheme://host...`), or -/// `None` if the string is not a recognizable absolute URL. -fn url_host(url: &str) -> Option<&str> { - let authority = url.split("://").nth(1)?.split(['/', '?', '#']).next()?; - // Drop any userinfo (user@host). - let hostport = authority.rsplit('@').next()?; - // Handle bracketed IPv6 literals ([::1]:port). - if let Some(rest) = hostport.strip_prefix('[') { - return rest.split(']').next(); - } - // Strip the port, if present. - hostport.split(':').next() -} - -fn is_openrouter(provider: &ProviderConfig) -> bool { - match url_host(&provider.base_url) { - Some(host) => { - let host = host.to_ascii_lowercase(); - host == OPENROUTER_HOST || host.ends_with(&format!(".{OPENROUTER_HOST}")) - } - None => false, - } -} - fn apply_openrouter_attribution( mut request: reqwest::RequestBuilder, provider: &ProviderConfig, ) -> reqwest::RequestBuilder { - if !is_openrouter(provider) { - return request; - } let has_header = |name: &str| { provider .headers diff --git a/src/http_tests.rs b/src/http_tests.rs index eb82c71..dd2740b 100644 --- a/src/http_tests.rs +++ b/src/http_tests.rs @@ -30,11 +30,11 @@ fn upstream_requests_report_codex_warp_user_agent() { } #[test] -fn openrouter_provider_gets_attribution_headers() { +fn all_providers_get_attribution_headers() { let mut provider = ProviderConfig::default(); - provider.base_url = "https://openrouter.ai/api/v1".to_string(); + provider.base_url = "https://api.example.com/v1".to_string(); - let request = Client::new().post("https://openrouter.ai/api/v1/chat/completions"); + let request = Client::new().post("https://api.example.com/v1/chat/completions"); let request = apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") .build() @@ -57,37 +57,15 @@ fn openrouter_provider_gets_attribution_headers() { .and_then(|v| v.to_str().ok()), Some("cli-agent,programming-app") ); - // Exactly one value per header (no duplicate auto + auto emission). assert_eq!(headers.get_all("HTTP-Referer").iter().count(), 1); assert_eq!(headers.get_all("X-OpenRouter-Title").iter().count(), 1); assert_eq!(headers.get_all("X-OpenRouter-Categories").iter().count(), 1); } #[test] -fn non_openrouter_provider_skips_attribution_headers() { - let mut provider = ProviderConfig::default(); - provider.base_url = "https://api.example.com/v1".to_string(); - - let request = Client::new().post("https://api.example.com/v1/chat/completions"); - let request = - apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") - .build() - .expect("request builds"); - let headers = request.headers(); - - assert!(headers.get("HTTP-Referer").is_none()); - assert!(headers.get("X-OpenRouter-Title").is_none()); - assert!(headers.get("X-OpenRouter-Categories").is_none()); -} - -#[test] -fn user_headers_override_openrouter_attribution() { +fn openrouter_provider_gets_attribution_headers() { let mut provider = ProviderConfig::default(); provider.base_url = "https://openrouter.ai/api/v1".to_string(); - provider.headers.insert( - "HTTP-Referer".to_string(), - "https://my-custom-app.example".to_string(), - ); let request = Client::new().post("https://openrouter.ai/api/v1/chat/completions"); let request = @@ -96,33 +74,6 @@ fn user_headers_override_openrouter_attribution() { .expect("request builds"); let headers = request.headers(); - assert_eq!( - headers.get("HTTP-Referer").and_then(|v| v.to_str().ok()), - Some("https://my-custom-app.example") - ); - assert_eq!( - headers - .get("X-OpenRouter-Title") - .and_then(|v| v.to_str().ok()), - Some("Codex Warp") - ); - // The user override is the sole value — no duplicate auto header is appended. - assert_eq!(headers.get_all("HTTP-Referer").iter().count(), 1); - assert_eq!(headers.get_all("X-OpenRouter-Title").iter().count(), 1); -} - -#[test] -fn openrouter_case_insensitive_host_gets_attribution_headers() { - let mut provider = ProviderConfig::default(); - provider.base_url = "https://OPENROUTER.AI/api/v1".to_string(); - - let request = Client::new().post("https://OPENROUTER.AI/api/v1/chat/completions"); - let request = - apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") - .build() - .expect("request builds"); - let headers = request.headers(); - assert_eq!( headers.get("HTTP-Referer").and_then(|v| v.to_str().ok()), Some("https://github.com/jatmn/Codex-warp") @@ -139,14 +90,21 @@ fn openrouter_case_insensitive_host_gets_attribution_headers() { .and_then(|v| v.to_str().ok()), Some("cli-agent,programming-app") ); + assert_eq!(headers.get_all("HTTP-Referer").iter().count(), 1); + assert_eq!(headers.get_all("X-OpenRouter-Title").iter().count(), 1); + assert_eq!(headers.get_all("X-OpenRouter-Categories").iter().count(), 1); } #[test] -fn openrouter_subdomain_host_gets_attribution_headers() { +fn user_headers_override_openrouter_attribution() { let mut provider = ProviderConfig::default(); - provider.base_url = "https://api.openrouter.ai/v1".to_string(); + provider.base_url = "https://openrouter.ai/api/v1".to_string(); + provider.headers.insert( + "HTTP-Referer".to_string(), + "https://my-custom-app.example".to_string(), + ); - let request = Client::new().post("https://api.openrouter.ai/v1/chat/completions"); + let request = Client::new().post("https://openrouter.ai/api/v1/chat/completions"); let request = apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") .build() @@ -155,7 +113,7 @@ fn openrouter_subdomain_host_gets_attribution_headers() { assert_eq!( headers.get("HTTP-Referer").and_then(|v| v.to_str().ok()), - Some("https://github.com/jatmn/Codex-warp") + Some("https://my-custom-app.example") ); assert_eq!( headers @@ -163,24 +121,9 @@ fn openrouter_subdomain_host_gets_attribution_headers() { .and_then(|v| v.to_str().ok()), Some("Codex Warp") ); -} - -#[test] -fn lookalike_host_does_not_get_attribution_headers() { - let mut provider = ProviderConfig::default(); - // The substring "openrouter.ai" appears, but it is not the request host. - provider.base_url = "https://openrouter.ai.attacker.example/v1".to_string(); - - let request = Client::new().post("https://openrouter.ai.attacker.example/v1/chat/completions"); - let request = - apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") - .build() - .expect("request builds"); - let headers = request.headers(); - - assert!(headers.get("HTTP-Referer").is_none()); - assert!(headers.get("X-OpenRouter-Title").is_none()); - assert!(headers.get("X-OpenRouter-Categories").is_none()); + // The user override is the sole value — no duplicate auto header is appended. + assert_eq!(headers.get_all("HTTP-Referer").iter().count(), 1); + assert_eq!(headers.get_all("X-OpenRouter-Title").iter().count(), 1); } #[test] From a3db34ab677c22114aa0efc3e2132f79a3da504c Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 14:03:36 -0700 Subject: [PATCH 4/5] Send OpenRouter attribution on every upstream API path Add X-Title alias alongside X-OpenRouter-Title and clarify that attribution headers apply to all outbound Warp calls (chat, responses, models, etc.), not only when the openrouter gateway profile is the default provider. --- README.md | 10 +++++++--- src/http.rs | 16 +++++++++------- src/http_tests.rs | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 42f47b2..25058de 100644 --- a/README.md +++ b/README.md @@ -163,12 +163,16 @@ for deployment examples. ## OpenRouter App Attribution Codex Warp automatically attaches [OpenRouter app attribution](https://openrouter.ai/docs/app-attribution) -headers on every upstream request — for all configured gateways and models, not -only when the [`configs/openrouter.toml`](configs/openrouter.toml) profile is -active — so the proxy's OpenRouter usage appears in public rankings and analytics: +headers on **every upstream request** — for all configured gateways, models, and +API paths (`/chat/completions`, native `/responses`, `/models`, and any other +outbound call) — not only when the [`configs/openrouter.toml`](configs/openrouter.toml) +profile is the default gateway. OpenRouter documents attribution across all of +its API routes and models; Warp always sends the headers so no gateway/model +combination can skip them. - `HTTP-Referer`: `https://github.com/jatmn/Codex-warp` - `X-OpenRouter-Title`: `Codex Warp` +- `X-Title`: `Codex Warp` (backwards-compatible alias) - `X-OpenRouter-Categories`: `cli-agent,programming-app` These are Codex Warp's own identity values. To override any of them for a diff --git a/src/http.rs b/src/http.rs index ac3fb89..8ebd70e 100644 --- a/src/http.rs +++ b/src/http.rs @@ -9,15 +9,16 @@ use crate::config::ProviderConfig; use crate::version::user_agent; // OpenRouter app attribution (https://openrouter.ai/docs/app-attribution). -// Codex Warp identifies itself on every upstream request so OpenRouter usage is -// attributed in public rankings and analytics regardless of which gateway profile -// or model is selected. These are the project's own identity values; they can be -// overridden per provider via [providers..headers] in config. +// Codex Warp identifies itself on every upstream request so OpenRouter can +// attribute usage across all of its API routes and models (chat completions, +// native /responses, /models, and any other outbound call) regardless of which +// gateway profile or model is selected. These are the project's own identity +// values; they can be overridden per provider via [providers..headers]. // // The values are hardcoded in Rust (rather than in configs/openrouter.toml) on -// purpose: attribution must apply across all gateways and models — including -// multi-gateway setups, `--destination` overrides, and user-created custom -// profiles — not only when the shipped `openrouter` profile is active. +// purpose: attribution must not depend on loading the shipped `openrouter` +// profile or on which gateway happens to be the default in a multi-provider +// setup. const OPENROUTER_REFERER: &str = "https://github.com/jatmn/Codex-warp"; const OPENROUTER_TITLE: &str = "Codex Warp"; const OPENROUTER_CATEGORIES: &str = "cli-agent,programming-app"; @@ -37,6 +38,7 @@ fn apply_openrouter_attribution( } if !has_header("X-OpenRouter-Title") && !has_header("X-Title") { request = request.header("X-OpenRouter-Title", OPENROUTER_TITLE); + request = request.header("X-Title", OPENROUTER_TITLE); } if !has_header("X-OpenRouter-Categories") { request = request.header("X-OpenRouter-Categories", OPENROUTER_CATEGORIES); diff --git a/src/http_tests.rs b/src/http_tests.rs index dd2740b..4841cfd 100644 --- a/src/http_tests.rs +++ b/src/http_tests.rs @@ -51,6 +51,10 @@ fn all_providers_get_attribution_headers() { .and_then(|v| v.to_str().ok()), Some("Codex Warp") ); + assert_eq!( + headers.get("X-Title").and_then(|v| v.to_str().ok()), + Some("Codex Warp") + ); assert_eq!( headers .get("X-OpenRouter-Categories") @@ -84,6 +88,10 @@ fn openrouter_provider_gets_attribution_headers() { .and_then(|v| v.to_str().ok()), Some("Codex Warp") ); + assert_eq!( + headers.get("X-Title").and_then(|v| v.to_str().ok()), + Some("Codex Warp") + ); assert_eq!( headers .get("X-OpenRouter-Categories") @@ -121,6 +129,10 @@ fn user_headers_override_openrouter_attribution() { .and_then(|v| v.to_str().ok()), Some("Codex Warp") ); + assert_eq!( + headers.get("X-Title").and_then(|v| v.to_str().ok()), + Some("Codex Warp") + ); // The user override is the sole value — no duplicate auto header is appended. assert_eq!(headers.get_all("HTTP-Referer").iter().count(), 1); assert_eq!(headers.get_all("X-OpenRouter-Title").iter().count(), 1); @@ -187,6 +199,10 @@ fn user_categories_override_openrouter_attribution() { .and_then(|v| v.to_str().ok()), Some("Codex Warp") ); + assert_eq!( + headers.get("X-Title").and_then(|v| v.to_str().ok()), + Some("Codex Warp") + ); } #[test] From 9f87a48217ac8f31e8179741d76213fcca08c9a5 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 16:40:24 -0700 Subject: [PATCH 5/5] Polish OpenRouter attribution docs, tests, and Referer alias Treat Referer as an override alias for HTTP-Referer, document automatic attribution and both [provider.headers] / [providers..headers] override paths in README and operator guides, add openrouter.toml to shipped-profile parse tests, and strengthen /responses + /models attribution coverage. --- README.md | 12 ++++++++---- configs/openrouter.toml | 6 +++--- docs/configuration.md | 5 +++++ docs/provider-catalogs.md | 8 +++++++- src/config_tests.rs | 21 ++++++++++++++++++++ src/http.rs | 5 +++-- src/http_tests.rs | 40 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 87 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 25058de..16638fa 100644 --- a/README.md +++ b/README.md @@ -176,10 +176,14 @@ combination can skip them. - `X-OpenRouter-Categories`: `cli-agent,programming-app` These are Codex Warp's own identity values. To override any of them for a -specific provider, set the header under that provider's `[providers..headers]` -section — user-supplied headers always take precedence over the automatic ones. - -Note: `HTTP-Referer` is Codex Warp's public GitHub URL, so all deployments report usage under that identity in OpenRouter's public rankings. To attribute traffic to your own project instead, override `HTTP-Referer` (and the other headers) under `[providers..headers]`. +specific provider, set the header under that provider's `[provider.headers]` or +`[providers..headers]` section — user-supplied headers always take +precedence over the automatic ones. + +Note: `HTTP-Referer` is Codex Warp's public GitHub URL, so traffic sent through +OpenRouter is attributed under that identity in OpenRouter's public rankings. +To attribute traffic to your own project instead, override `HTTP-Referer` (and +the other headers) under `[provider.headers]` or `[providers..headers]`. ## Supported Model Families diff --git a/configs/openrouter.toml b/configs/openrouter.toml index 22f8d3b..b3f2537 100644 --- a/configs/openrouter.toml +++ b/configs/openrouter.toml @@ -3,9 +3,9 @@ # # OpenRouter exposes a large live /models catalog, so no local model_catalog is # needed here. Codex Warp automatically attaches the OpenRouter app attribution -# headers (HTTP-Referer, X-OpenRouter-Title, X-OpenRouter-Categories) on every -# upstream request across all gateways and models. To override any of them, set -# the header under [providers..headers]. +# headers (HTTP-Referer, X-OpenRouter-Title, X-Title, X-OpenRouter-Categories) +# on every upstream request across all gateways and models. To override any of +# them, set the header under [provider.headers] or [providers..headers]. [providers.openrouter] name = "OpenRouter" diff --git a/docs/configuration.md b/docs/configuration.md index dd1e316..4b33198 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,6 +109,11 @@ Some providers require extra headers: "X-Title" = "Codex Warp" ``` +Codex Warp also attaches [OpenRouter app attribution](../README.md#openrouter-app-attribution) +headers on every upstream request (`HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, +and `X-OpenRouter-Categories`). Set any of those names under `[provider.headers]` +or `[providers..headers]` to override the automatic values for that gateway. + Codex Warp always sends its own `User-Agent` as `codex-warp/` to upstream providers. Configured `User-Agent` values are ignored so provider logs can identify the proxy consistently. diff --git a/docs/provider-catalogs.md b/docs/provider-catalogs.md index b3cf049..6867f78 100644 --- a/docs/provider-catalogs.md +++ b/docs/provider-catalogs.md @@ -69,6 +69,11 @@ model_catalog_only = false "X-Title" = "Codex Warp" ``` +Codex Warp also auto-attaches [OpenRouter app attribution](../README.md#openrouter-app-attribution) +headers on every upstream request. Set any of those header names under +`[provider.headers]` or `[providers..headers]` to override the defaults for +that gateway. + Use named providers when you want Codex Warp to merge more than one upstream model catalog. Codex Warp groups the merged `/v1/models` response by gateway and prefixes display names with `[name]`, for example `[Provider A] Model`. @@ -83,7 +88,7 @@ and prefixes display names with `[name]`, for example `[Provider A] Model`. | `api_key` | Inline upstream key. Useful for local experiments, but avoid committing it. | | `auth_header` | Header used for auth. Defaults to `authorization`. | | `auth_scheme` | Prefix for the key. Defaults to `Bearer`; set to `""` for raw keys. | -| `headers` | Static extra headers required by the gateway. `User-Agent` is ignored here because Codex Warp always reports itself as `codex-warp/`. | +| `headers` | Static extra headers required by the gateway. `User-Agent` is ignored here because Codex Warp always reports itself as `codex-warp/`. OpenRouter attribution headers are also auto-attached on every upstream request; set them here to override. See [OpenRouter App Attribution](../README.md#openrouter-app-attribution). | | `responses_path` | Upstream Responses endpoint path. | | `chat_completions_path` | Upstream chat completions endpoint path. | | `models_path` | Upstream model catalog endpoint path. | @@ -124,6 +129,7 @@ models_path = "/models" model_catalog_only = true [providers.acme_ai.headers] +# Optional: override auto OpenRouter attribution headers for this gateway. "X-Title" = "Codex Warp" [[providers.acme_ai.model_catalog]] diff --git a/src/config_tests.rs b/src/config_tests.rs index 53e1017..f6c0c17 100644 --- a/src/config_tests.rs +++ b/src/config_tests.rs @@ -17,6 +17,8 @@ fn example_configs_parse_request_morphs() { .expect("opencode go layered config parses"); let generic_config = load_config_layers(&[PathBuf::from("configs/openai-compatible.toml")]) .expect("generic openai-compatible profile parses"); + let openrouter_config = load_config_layers(&[PathBuf::from("configs/openrouter.toml")]) + .expect("openrouter layered config parses"); assert!( default_config @@ -130,6 +132,24 @@ fn example_configs_parse_request_morphs() { .iter() .any(|entry| entry.id == "kimi-k2.7-code-highspeed") ); + assert_eq!( + provider_id_for_config_model(&kimicode_config, "kimi-k2.7-code-highspeed").as_deref(), + Some("moonshot_kimicode") + ); + let openrouter = openrouter_config + .providers + .get("openrouter") + .expect("openrouter provider exists"); + assert_eq!(openrouter.name.as_deref(), Some("OpenRouter")); + assert_eq!(openrouter.base_url, "https://openrouter.ai/api/v1"); + assert_eq!( + openrouter.api_key_env.as_deref(), + Some("OPENROUTER_API_KEY") + ); + assert_eq!( + provider_id_for_config_model(&openrouter_config, "openrouter").as_deref(), + None + ); assert_eq!( xiaomi_config.provider.base_url, "https://token-plan-sgp.xiaomimimo.com/v1" @@ -159,6 +179,7 @@ fn reusable_provider_profiles_leave_auto_review_to_model_families() { "configs/clinepass.toml", "configs/moonshot-kimicode.toml", "configs/opencode-go.toml", + "configs/openrouter.toml", "configs/xiaomi-token-plan.toml", ] { let config = load_config_layers(&[PathBuf::from(config_path)]) diff --git a/src/http.rs b/src/http.rs index 8ebd70e..df7f597 100644 --- a/src/http.rs +++ b/src/http.rs @@ -13,7 +13,8 @@ use crate::version::user_agent; // attribute usage across all of its API routes and models (chat completions, // native /responses, /models, and any other outbound call) regardless of which // gateway profile or model is selected. These are the project's own identity -// values; they can be overridden per provider via [providers..headers]. +// values; they can be overridden per provider via [provider.headers] or +// [providers..headers]. // // The values are hardcoded in Rust (rather than in configs/openrouter.toml) on // purpose: attribution must not depend on loading the shipped `openrouter` @@ -33,7 +34,7 @@ fn apply_openrouter_attribution( .keys() .any(|key| key.eq_ignore_ascii_case(name)) }; - if !has_header("HTTP-Referer") { + if !has_header("HTTP-Referer") && !has_header("Referer") { request = request.header("HTTP-Referer", OPENROUTER_REFERER); } if !has_header("X-OpenRouter-Title") && !has_header("X-Title") { diff --git a/src/http_tests.rs b/src/http_tests.rs index 4841cfd..d0e2239 100644 --- a/src/http_tests.rs +++ b/src/http_tests.rs @@ -138,6 +138,31 @@ fn user_headers_override_openrouter_attribution() { assert_eq!(headers.get_all("X-OpenRouter-Title").iter().count(), 1); } +#[test] +fn referer_alias_suppresses_http_referer() { + let mut provider = ProviderConfig::default(); + provider.base_url = "https://openrouter.ai/api/v1".to_string(); + provider.headers.insert( + "Referer".to_string(), + "https://my-custom-app.example".to_string(), + ); + + let request = Client::new().post("https://openrouter.ai/api/v1/chat/completions"); + let request = + apply_headers_with_accept(request, &provider, &HeaderMap::new(), "text/event-stream") + .build() + .expect("request builds"); + let headers = request.headers(); + + assert_eq!( + headers.get("Referer").and_then(|v| v.to_str().ok()), + Some("https://my-custom-app.example") + ); + assert!(headers.get("HTTP-Referer").is_none()); + assert_eq!(headers.get_all("Referer").iter().count(), 1); + assert_eq!(headers.get_all("HTTP-Referer").iter().count(), 0); +} + #[test] fn x_title_alias_suppresses_openrouter_title() { let mut provider = ProviderConfig::default(); @@ -231,5 +256,20 @@ fn responses_and_models_paths_get_attribution_headers() { Some("Codex Warp"), "missing title on {path}" ); + assert_eq!( + headers.get("X-Title").and_then(|v| v.to_str().ok()), + Some("Codex Warp"), + "missing X-Title on {path}" + ); + assert_eq!( + headers + .get("X-OpenRouter-Categories") + .and_then(|v| v.to_str().ok()), + Some("cli-agent,programming-app"), + "missing categories on {path}" + ); + assert_eq!(headers.get_all("HTTP-Referer").iter().count(), 1); + assert_eq!(headers.get_all("X-OpenRouter-Title").iter().count(), 1); + assert_eq!(headers.get_all("X-OpenRouter-Categories").iter().count(), 1); } }