Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.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.<id>.headers]`.

## Supported Model Families

| Parent brand | Catalog | Examples covered |
Expand Down
1 change: 1 addition & 0 deletions codex-warp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions configs/openrouter.toml
Original file line number Diff line number Diff line change
@@ -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"
73 changes: 73 additions & 0 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.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!(
"{}/{}",
Expand Down Expand Up @@ -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)
Expand Down
246 changes: 246 additions & 0 deletions src/http_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
}