Skip to content

Commit 9a230c9

Browse files
enriquephlclaude
andcommitted
feat(llm): X-OpenRouter-Categories attribution header + canonical X-OpenRouter-Title
Add OPENROUTER_APP_CATEGORIES as a third optional app-attribution env var, mirroring OPENROUTER_APP_REFERER / OPENROUTER_APP_TITLE. When set, its value is sent verbatim as the X-OpenRouter-Categories header (comma-separated OpenRouter marketplace categories). OpenRouter silently ignores unrecognised values, so the engine does no validation; an unparseable value is dropped with a warning, like the other two headers. Also switch the title header from the legacy X-Title to the current canonical X-OpenRouter-Title (OpenRouter still accepts X-Title as an alias). Docs (.env.example, README, README.zh, llm-audit, llm-audit.zh) and the three attribution tests updated. OpenAPI snapshot unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 53b9fb9 commit 9a230c9

7 files changed

Lines changed: 83 additions & 21 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ OPENROUTER_API_KEY=sk-or-...
1313
# remain anonymous.
1414
# OPENROUTER_APP_REFERER=https://eros.example
1515
# OPENROUTER_APP_TITLE=Eros
16+
# Comma-separated OpenRouter marketplace categories (sent as
17+
# X-OpenRouter-Categories). Only takes effect alongside the referer;
18+
# OpenRouter silently ignores unrecognised values.
19+
# OPENROUTER_APP_CATEGORIES=roleplay,general-chat
1620

1721
# OpenRouter response-side usage filter (optional). Comma-separated list of
1822
# top-level keys to strip from the `usage` object before it leaves the engine

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,8 @@ for frame layout and error semantics.
234234
| `DATABASE_URL` | yes | Postgres with `pgvector`; tables are created under `engine.*`. |
235235
| `OPENROUTER_API_KEY` | yes | Chat completions, routed by `examples/model_config.toml` unless overridden. |
236236
| `OPENROUTER_APP_REFERER` | no | When set, sent as `HTTP-Referer` on every outbound OpenRouter call. Shows up on OpenRouter's app analytics dashboard. |
237-
| `OPENROUTER_APP_TITLE` | no | When set, sent as `X-Title`. Display name in OpenRouter app analytics. Pairs with `OPENROUTER_APP_REFERER`; both optional. |
237+
| `OPENROUTER_APP_TITLE` | no | When set, sent as `X-OpenRouter-Title`. Display name in OpenRouter app analytics. Pairs with `OPENROUTER_APP_REFERER`; both optional. |
238+
| `OPENROUTER_APP_CATEGORIES` | no | When set, sent as `X-OpenRouter-Categories` — comma-separated OpenRouter marketplace categories (e.g. `roleplay,general-chat`). Passed through verbatim; OpenRouter ignores unrecognised values and only honours it alongside `OPENROUTER_APP_REFERER`. |
238239
| `OPENROUTER_USAGE_HIDDEN_KEYS` | no | Comma-separated list of top-level keys to strip from the `usage` object on the SSE streaming `done` frame. Useful for hiding wholesale `cost` / `cost_details` from downstream customers. The full usage is still persisted and traced server-side. |
239240
| `VOYAGE_API_KEY` | yes | Embeddings. Empty keys fail server boot. |
240241
| `SUPABASE_URL` | no | Supabase project URL. When set, the server derives the JWKS endpoint (`<url>/auth/v1/.well-known/jwks.json`) for asymmetric (RS256/ES256) JWT validation — the post-2025 Supabase default. |

README.zh.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ Server 默認監聽 `0.0.0.0:8080`。Scalar API docs 在 `/docs`,OpenAPI JSON
178178
| `DATABASE_URL` ||`pgvector` 的 Postgres;表建在 `engine.*`|
179179
| `OPENROUTER_API_KEY` || Chat completions;默認由 `examples/model_config.toml` 路由。 |
180180
| `OPENROUTER_APP_REFERER` || 設了之後每次出站 OpenRouter 調用都帶 `HTTP-Referer`。會出現在 OpenRouter 的 app 分析面板上。 |
181-
| `OPENROUTER_APP_TITLE` || 設了之後帶 `X-Title`。OpenRouter app analytics 顯示名稱。和 `OPENROUTER_APP_REFERER` 一對;兩個都可選。 |
181+
| `OPENROUTER_APP_TITLE` || 設了之後帶 `X-OpenRouter-Title`。OpenRouter app analytics 顯示名稱。和 `OPENROUTER_APP_REFERER` 一對;兩個都可選。 |
182+
| `OPENROUTER_APP_CATEGORIES` || 设了之后带 `X-OpenRouter-Categories` —— 逗号分隔的 OpenRouter marketplace 分类(如 `roleplay,general-chat`)。原样透传;OpenRouter 对无法识别的值静默忽略,且只有在同时设了 `OPENROUTER_APP_REFERER` 时才生效。 |
182183
| `OPENROUTER_USAGE_HIDDEN_KEYS` || 逗号分隔的顶层 key 列表,从 `usage` 对象里剔除 —— 在 SSE 流式 `done` 帧上生效。常用于把批发 `cost` / `cost_details` 隐藏起来不外泄给下游客户。完整 usage 仍会落库并写入服务器端 tracing。 |
183184
| `VOYAGE_API_KEY` || Embeddings。空 key 會拒絕啟動。 |
184185
| `SUPABASE_URL` || Supabase project URL。保留在 `.env.example` 裡方便 client / deploy 約定;目前 server 不讀取它。 |

crates/eros-engine-llm/src/openrouter.rs

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ const BASE_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
1717
/// names become legacy and (if a transition window applies) get added as
1818
/// a parallel alias below.
1919
const HEADER_REFERER: &str = "HTTP-Referer";
20-
const HEADER_TITLE: &str = "X-Title";
20+
const HEADER_TITLE: &str = "X-OpenRouter-Title";
21+
const HEADER_CATEGORIES: &str = "X-OpenRouter-Categories";
2122

2223
#[derive(Debug, Clone, Serialize, Deserialize)]
2324
pub struct ChatMessage {
@@ -241,8 +242,15 @@ struct WireStreamFrame {
241242
pub struct AppAttribution {
242243
/// Sent as `HTTP-Referer`. Identifies the deploying app to OpenRouter.
243244
pub referer: Option<String>,
244-
/// Sent as `X-Title`. Display name in OpenRouter's app analytics.
245+
/// Sent as `X-OpenRouter-Title`. Display name in OpenRouter's app
246+
/// analytics. (OpenRouter also accepts the legacy `X-Title` alias; we
247+
/// send the current canonical name.)
245248
pub title: Option<String>,
249+
/// Sent as `X-OpenRouter-Categories`. Comma-separated marketplace
250+
/// categories for OpenRouter's app directory. Passed through verbatim;
251+
/// OpenRouter silently ignores unrecognised values, so the engine does
252+
/// no validation. Only takes effect when paired with `referer`.
253+
pub categories: Option<String>,
246254
}
247255

248256
#[derive(Clone)]
@@ -290,6 +298,18 @@ impl OpenRouterClient {
290298
),
291299
}
292300
}
301+
if let Some(ref categories) = attribution.categories {
302+
match reqwest::header::HeaderValue::from_str(categories) {
303+
Ok(v) => {
304+
headers.insert(HEADER_CATEGORIES, v);
305+
}
306+
Err(e) => tracing::warn!(
307+
error = %e,
308+
header = HEADER_CATEGORIES,
309+
"openrouter: dropping invalid attribution value"
310+
),
311+
}
312+
}
293313
let http = reqwest::Client::builder()
294314
.default_headers(headers)
295315
.build()
@@ -615,7 +635,7 @@ mod tests {
615635
let server = MockServer::start().await;
616636
Mock::given(path("/api/v1/chat/completions"))
617637
.and(header("HTTP-Referer", "https://eros.example"))
618-
.and(header("X-Title", "Eros"))
638+
.and(header("X-OpenRouter-Title", "Eros"))
619639
.respond_with(ResponseTemplate::new(200).set_body_json(ok_response()))
620640
.expect(1)
621641
.mount(&server)
@@ -626,6 +646,7 @@ mod tests {
626646
AppAttribution {
627647
referer: Some("https://eros.example".into()),
628648
title: Some("Eros".into()),
649+
categories: Some("roleplay,general-chat".into()),
629650
},
630651
format!("{}/api/v1/chat/completions", server.uri()),
631652
);
@@ -643,6 +664,20 @@ mod tests {
643664
})
644665
.await
645666
.expect("call succeeds");
667+
668+
// Categories is checked on the raw received value rather than via
669+
// wiremock's `header` matcher: that matcher splits the received value
670+
// on commas, so a comma-joined string would never compare equal. We
671+
// want to prove the verbatim comma-separated string reaches the wire.
672+
let reqs = server.received_requests().await.unwrap_or_default();
673+
let categories = reqs
674+
.iter()
675+
.find_map(|r| r.headers.get("x-openrouter-categories"))
676+
.expect("X-OpenRouter-Categories header present");
677+
assert_eq!(
678+
categories.to_str().expect("header is valid utf-8"),
679+
"roleplay,general-chat"
680+
);
646681
}
647682

648683
#[tokio::test]
@@ -680,8 +715,12 @@ mod tests {
680715
"HTTP-Referer must be absent when unset"
681716
);
682717
assert!(
683-
req.headers.get("x-title").is_none(),
684-
"X-Title must be absent when unset"
718+
req.headers.get("x-openrouter-title").is_none(),
719+
"X-OpenRouter-Title must be absent when unset"
720+
);
721+
assert!(
722+
req.headers.get("x-openrouter-categories").is_none(),
723+
"X-OpenRouter-Categories must be absent when unset"
685724
);
686725
}
687726
}
@@ -700,6 +739,7 @@ mod tests {
700739
AppAttribution {
701740
referer: Some("bad\nvalue".into()),
702741
title: Some("also\rbad".into()),
742+
categories: Some("still\nbad".into()),
703743
},
704744
format!("{}/api/v1/chat/completions", server.uri()),
705745
);
@@ -724,8 +764,12 @@ mod tests {
724764
"HTTP-Referer must be dropped"
725765
);
726766
assert!(
727-
req.headers.get("x-title").is_none(),
728-
"X-Title must be dropped"
767+
req.headers.get("x-openrouter-title").is_none(),
768+
"X-OpenRouter-Title must be dropped"
769+
);
770+
assert!(
771+
req.headers.get("x-openrouter-categories").is_none(),
772+
"X-OpenRouter-Categories must be dropped"
729773
);
730774
}
731775
}

crates/eros-engine-server/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ async fn run_server() -> Result<()> {
208208
title: std::env::var("OPENROUTER_APP_TITLE")
209209
.ok()
210210
.filter(|s| !s.is_empty()),
211+
categories: std::env::var("OPENROUTER_APP_CATEGORIES")
212+
.ok()
213+
.filter(|s| !s.is_empty()),
211214
};
212215
let openrouter = Arc::new(eros_engine_llm::openrouter::OpenRouterClient::new(
213216
openrouter_key,

docs/llm-audit.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,22 @@ prompt_tokens=… completion_tokens=… total_tokens=… cost=…
9696

9797
## App-attribution headers
9898

99-
Two optional env vars add headers to every outbound OpenRouter call:
99+
Three optional env vars add headers to every outbound OpenRouter call:
100100

101-
| Env | Header | Purpose |
102-
|---------------------------|----------------|--------------------------------------------------|
103-
| `OPENROUTER_APP_REFERER` | `HTTP-Referer` | App identifier on OpenRouter dashboards |
104-
| `OPENROUTER_APP_TITLE` | `X-Title` | Display name in OpenRouter app analytics |
101+
| Env | Header | Purpose |
102+
|-----------------------------|---------------------------|--------------------------------------------------|
103+
| `OPENROUTER_APP_REFERER` | `HTTP-Referer` | App identifier on OpenRouter dashboards |
104+
| `OPENROUTER_APP_TITLE` | `X-OpenRouter-Title` | Display name in OpenRouter app analytics |
105+
| `OPENROUTER_APP_CATEGORIES` | `X-OpenRouter-Categories` | Comma-separated marketplace categories |
105106

106-
Both unset → today's behaviour (no attribution headers). They are set
107+
All unset → today's behaviour (no attribution headers). They are set
107108
per deployment, not per request — App-Attribution is intended for
108109
app-level aggregation. Per-user attribution belongs in `audit.user`.
109110

111+
`OPENROUTER_APP_CATEGORIES` is passed through verbatim; OpenRouter
112+
silently ignores unrecognised values and only honours it when
113+
`OPENROUTER_APP_REFERER` is also set.
114+
110115
Invalid values (control characters, non-ASCII outside header rules)
111116
are dropped at construction time with a `tracing::warn!`; the client
112117
still works.

docs/llm-audit.zh.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,17 +90,21 @@ prompt_tokens=… completion_tokens=… total_tokens=… cost=…
9090

9191
## App-attribution headers
9292

93-
两个可选环境变量给每次出站 OpenRouter 调用加 header:
93+
三个可选环境变量给每次出站 OpenRouter 调用加 header:
9494

95-
| Env | Header | 用途 |
96-
|---------------------------|----------------|-----------------------------------------------|
97-
| `OPENROUTER_APP_REFERER` | `HTTP-Referer` | OpenRouter 仪表盘上的 app 标识 |
98-
| `OPENROUTER_APP_TITLE` | `X-Title` | OpenRouter app analytics 里显示的名字 |
95+
| Env | Header | 用途 |
96+
|-----------------------------|---------------------------|-----------------------------------------------|
97+
| `OPENROUTER_APP_REFERER` | `HTTP-Referer` | OpenRouter 仪表盘上的 app 标识 |
98+
| `OPENROUTER_APP_TITLE` | `X-OpenRouter-Title` | OpenRouter app analytics 里显示的名字 |
99+
| `OPENROUTER_APP_CATEGORIES` | `X-OpenRouter-Categories` | 逗号分隔的 marketplace 分类 |
99100

100-
两个都不设 → 维持现状(不发任何 attribution header)。它们是
101+
都不设 → 维持现状(不发任何 attribution header)。它们是
101102
deployment 级别的设置,不是 per-request —— App-Attribution 的目的是
102103
app-level 聚合。Per-user 维度走 `audit.user`
103104

105+
`OPENROUTER_APP_CATEGORIES` 原样透传;OpenRouter 对无法识别的值静默
106+
忽略,且只有在同时设了 `OPENROUTER_APP_REFERER` 时才生效。
107+
104108
非法值(控制字符、非 ASCII 之类)在构造时被丢弃并打一条
105109
`tracing::warn!`,client 仍然可用。
106110

0 commit comments

Comments
 (0)