diff --git a/README.md b/README.md index e2f64fa..136c16b 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,25 @@ 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` 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: + +- `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. + +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/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..b30c75e --- /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 host is openrouter.ai (or a *.openrouter.ai subdomain). 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..5511c7b 100644 --- a/src/http.rs +++ b/src/http.rs @@ -8,6 +8,77 @@ 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. +// +// 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 { + 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 + .keys() + .any(|key| key.eq_ignore_ascii_case(name)) + }; + 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 +119,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..eb82c71 100644 --- a/src/http_tests.rs +++ b/src/http_tests.rs @@ -28,3 +28,249 @@ 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") + ); + // 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() { + 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") + ); + // 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}" + ); + } +}