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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<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) on every
# upstream request across all gateways and models. To override any of them, set
# the header under [providers.<id>.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"
40 changes: 40 additions & 0 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.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!(
"{}/{}",
Expand Down Expand Up @@ -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)
Expand Down
205 changes: 205 additions & 0 deletions src/http_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
}