diff --git a/README.md b/README.md index e2f64fa..25058de 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,30 @@ 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 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 + +Codex Warp automatically attaches [OpenRouter app attribution](https://openrouter.ai/docs/app-attribution) +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 +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..22f8d3b --- /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) on every +# upstream request across all gateways and models. To override any of them, set +# the header under [providers..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..8ebd70e 100644 --- a/src/http.rs +++ b/src/http.rs @@ -8,6 +8,44 @@ use serde_json::json; 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 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 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"; + +fn apply_openrouter_attribution( + mut request: reqwest::RequestBuilder, + provider: &ProviderConfig, +) -> reqwest::RequestBuilder { + 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); + request = request.header("X-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 +86,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..4841cfd 100644 --- a/src/http_tests.rs +++ b/src/http_tests.rs @@ -28,3 +28,208 @@ fn upstream_requests_report_codex_warp_user_agent() { Some(expected.as_str()) ); } + +#[test] +fn all_providers_get_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_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-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") + ); + 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_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-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") + ); + 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 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") + ); + 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); +} + +#[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") + ); + assert_eq!( + headers.get("X-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}" + ); + } +}