From c0a5939e0e442ea3184e48bdef15f1a2860d66c9 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 11 Jul 2026 21:40:35 -0700 Subject: [PATCH 1/2] 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/2] 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}" + ); + } }