Skip to content

Commit 41a4d59

Browse files
authored
feat: openrouter_slug override for OpenRouter provider listing (#740)
* feat: openrouter_slug override for OpenRouter provider listing OpenRouter's provider spec requires the model `id` in GET /v1/models to exactly match OpenRouter's canonical slug, or for the provider to supply an explicit override. 8 of our 9 listed models do not match OR's slug. Add an admin-settable per-model `openrouter_slug` field that surfaces on the public GET /v1/models as a nested `openrouter: { slug }` object, emitted only when set (omitted entirely when NULL). Mirrors the datacenters/is_ready/ deprecation_date precedent end-to-end: - migration V0056: nullable `openrouter_slug TEXT` on models + model_history - Model / ModelHistory / service structs + repository read/write round-trip - UpdateModelApiRequest: `openrouterSlug` (camelCase), tri-state Nullable so JSON null clears it; omitted leaves it unchanged - admin write-path validation: rejects non lowercase `author/slug` shape (400) - public serializer model_with_pricing_to_info emits the nested object - unit tests (slug validator + serializer emit/omit) and e2e tests (write+read round-trip, public nested object, clear via null, tri-state omit guard, invalid-slug rejection) Post-merge admin backfill is a separate prod write step (see PR body). * fix(db): renumber openrouter_slug migration V0056->V0057 (avoid collision with #737)
1 parent 4cefef7 commit 41a4d59

12 files changed

Lines changed: 466 additions & 23 deletions

File tree

crates/api/src/models.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,24 @@ pub struct ModelInfo {
837837
/// `[{ "country_code": "US" }]`. Omitted when unset.
838838
#[serde(skip_serializing_if = "Option::is_none")]
839839
pub datacenters: Option<Vec<Datacenter>>,
840+
/// OpenRouter slug override: `{ "slug": "<value>" }`. Present only when an
841+
/// override is set (our `id` does not match OpenRouter's canonical slug).
842+
/// Omitted entirely when unset.
843+
#[serde(skip_serializing_if = "Option::is_none")]
844+
pub openrouter: Option<OpenRouter>,
845+
}
846+
847+
/// OpenRouter slug-override block.
848+
///
849+
/// OpenRouter's provider spec
850+
/// (https://openrouter.ai/docs/guides/community/for-providers) requires the
851+
/// model `id` to EXACTLY match OpenRouter's canonical slug, OR for the provider
852+
/// to supply an explicit override via a nested `openrouter` object carrying the
853+
/// canonical slug. We emit this object only when an override is configured.
854+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
855+
pub struct OpenRouter {
856+
/// OpenRouter's canonical slug for this model (lowercase `author/slug`).
857+
pub slug: String,
840858
}
841859

842860
/// OpenRouter `datacenters` entry: a single datacenter the model runs in.
@@ -3047,6 +3065,11 @@ pub struct ModelMetadata {
30473065
/// string. Omitted when there is no planned deprecation.
30483066
#[serde(rename = "deprecationDate", skip_serializing_if = "Option::is_none")]
30493067
pub deprecation_date: Option<String>,
3068+
/// OpenRouter `openrouter.slug` override (lowercase `author/slug`). Omitted
3069+
/// when unset. On public `GET /v1/models` this surfaces as the nested
3070+
/// `openrouter: { slug }` object; the admin view exposes the raw value.
3071+
#[serde(rename = "openrouterSlug", skip_serializing_if = "Option::is_none")]
3072+
pub openrouter_slug: Option<String>,
30503073
}
30513074

30523075
/// Request to update model pricing (admin endpoint)
@@ -3151,6 +3174,24 @@ pub struct UpdateModelApiRequest {
31513174
)]
31523175
#[schema(value_type = Option<String>)]
31533176
pub deprecation_date: Nullable<String>,
3177+
/// OpenRouter `openrouter.slug` override (lowercase `author/slug`, e.g.
3178+
/// `z-ai/glm-5.1`). Set when our canonical `model_name` does not match
3179+
/// OpenRouter's slug; surfaced as the nested `openrouter: { slug }` object
3180+
/// on `GET /v1/models`. Validated at the write path; rejected if it does
3181+
/// not match the `author/slug` shape.
3182+
///
3183+
/// Tri-state PATCH semantics:
3184+
/// - omitted → leave unchanged
3185+
/// - `null` → clear back to "unset" (column set to NULL)
3186+
/// - a string → set verbatim
3187+
#[serde(
3188+
rename = "openrouterSlug",
3189+
default,
3190+
deserialize_with = "deserialize_nullable",
3191+
skip_serializing_if = "Option::is_none"
3192+
)]
3193+
#[schema(value_type = Option<String>)]
3194+
pub openrouter_slug: Nullable<String>,
31543195
#[serde(rename = "changeReason", skip_serializing_if = "Option::is_none")]
31553196
pub change_reason: Option<String>,
31563197
}
@@ -3332,6 +3373,9 @@ pub struct ModelHistoryEntry {
33323373
pub is_ready: Option<bool>,
33333374
#[serde(rename = "deprecationDate", skip_serializing_if = "Option::is_none")]
33343375
pub deprecation_date: Option<String>,
3376+
/// OpenRouter `openrouter.slug` override the model carried at this point.
3377+
#[serde(rename = "openrouterSlug", skip_serializing_if = "Option::is_none")]
3378+
pub openrouter_slug: Option<String>,
33353379
}
33363380

33373381
/// Model history response - complete history of model changes

crates/api/src/routes/admin.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,37 @@ pub(crate) fn format_deprecation_date(dt: &DateTime<Utc>) -> String {
9090
dt.format("%Y-%m-%dT%H:00:00Z").to_string()
9191
}
9292

93+
/// Validate an OpenRouter `openrouter.slug` override.
94+
///
95+
/// OpenRouter's `/api/v1/models` ids are lowercase `author/slug` pairs (e.g.
96+
/// `z-ai/glm-5.1`, `qwen/qwen3.6-27b`). We accept exactly that shape: one `/`
97+
/// separator, each segment starting and ending with `[a-z0-9]` and containing
98+
/// only `[a-z0-9._-]` in between. This mirrors the regex
99+
/// `^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$`
100+
/// without pulling in a regex dependency.
101+
pub(crate) fn is_valid_openrouter_slug(slug: &str) -> bool {
102+
fn is_valid_segment(seg: &str) -> bool {
103+
let bytes = seg.as_bytes();
104+
if bytes.is_empty() {
105+
return false;
106+
}
107+
let is_boundary = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit();
108+
let is_interior = |b: u8| is_boundary(b) || matches!(b, b'.' | b'_' | b'-');
109+
// First and last must be boundary chars; interior may use . _ -
110+
if !is_boundary(bytes[0]) || !is_boundary(bytes[bytes.len() - 1]) {
111+
return false;
112+
}
113+
bytes.iter().all(|&b| is_interior(b))
114+
}
115+
116+
let mut parts = slug.split('/');
117+
match (parts.next(), parts.next(), parts.next()) {
118+
// Exactly two non-empty, well-formed segments (no second `/`).
119+
(Some(author), Some(name), None) => is_valid_segment(author) && is_valid_segment(name),
120+
_ => false,
121+
}
122+
}
123+
93124
#[derive(Clone)]
94125
pub struct AdminAppState {
95126
pub admin_service: Arc<dyn AdminService + Send + Sync>,
@@ -301,6 +332,24 @@ pub async fn batch_upsert_models(
301332
));
302333
}
303334
}
335+
// `openrouter.slug` override must be a lowercase `author/slug` (the
336+
// canonical shape OpenRouter uses in its `/api/v1/models` ids, e.g.
337+
// `z-ai/glm-5.1`). Reject anything else at the write path so the
338+
// catalog can't emit a slug OpenRouter would refuse to match. An
339+
// explicit `null` (clear) and an omitted field both skip this check.
340+
if let Some(Some(slug)) = &request.openrouter_slug {
341+
if !is_valid_openrouter_slug(slug) {
342+
return Err((
343+
StatusCode::BAD_REQUEST,
344+
ResponseJson(ErrorResponse::new(
345+
format!(
346+
"model '{model_name}': openrouterSlug: '{slug}' is not a valid OpenRouter slug; expected lowercase 'author/slug' (e.g. 'z-ai/glm-5.1')"
347+
),
348+
"invalid_request".to_string(),
349+
)),
350+
));
351+
}
352+
}
304353
}
305354

306355
// Extract admin user context for audit tracking
@@ -353,6 +402,10 @@ pub async fn batch_upsert_models(
353402
.deprecation_date
354403
.as_ref()
355404
.map(|inner| inner.as_deref().and_then(parse_deprecation_date)),
405+
// Tri-state passes straight through: outer None = leave
406+
// unchanged, Some(None) = clear, Some(Some(v)) = set. The
407+
// value was already shape-validated above.
408+
openrouter_slug: request.openrouter_slug.clone(),
356409
change_reason: request.change_reason.clone(),
357410
changed_by_user_id: Some(admin_user_id),
358411
changed_by_user_email: Some(admin_user_email.clone()),
@@ -562,6 +615,7 @@ pub async fn batch_upsert_models(
562615
.deprecation_date
563616
.as_ref()
564617
.map(format_deprecation_date),
618+
openrouter_slug: updated_model.openrouter_slug,
565619
},
566620
})
567621
.collect();
@@ -668,6 +722,7 @@ pub async fn list_models(
668722
datacenters: crate::models::Datacenter::from_codes(model.datacenters),
669723
is_ready: model.is_ready,
670724
deprecation_date: model.deprecation_date.as_ref().map(format_deprecation_date),
725+
openrouter_slug: model.openrouter_slug,
671726
},
672727
is_active: model.is_active,
673728
created_at: model.created_at,
@@ -806,6 +861,7 @@ pub async fn get_model_history(
806861
datacenters: crate::models::Datacenter::from_codes(h.datacenters),
807862
is_ready: h.is_ready,
808863
deprecation_date: h.deprecation_date.as_ref().map(format_deprecation_date),
864+
openrouter_slug: h.openrouter_slug,
809865
})
810866
.collect();
811867

@@ -1313,6 +1369,7 @@ pub async fn deprecate_model(
13131369
datacenters: crate::models::Datacenter::from_codes(m.datacenters),
13141370
is_ready: m.is_ready,
13151371
deprecation_date: m.deprecation_date.as_ref().map(format_deprecation_date),
1372+
openrouter_slug: m.openrouter_slug,
13161373
},
13171374
};
13181375

@@ -3143,3 +3200,55 @@ mod deprecation_date_tests {
31433200
assert!(parse_deprecation_date("2025-13-01").is_none());
31443201
}
31453202
}
3203+
3204+
#[cfg(test)]
3205+
mod openrouter_slug_tests {
3206+
use super::is_valid_openrouter_slug;
3207+
3208+
#[test]
3209+
fn accepts_canonical_openrouter_slugs() {
3210+
// The real slugs we need to back-fill (per the OpenRouter for-providers
3211+
// spec) must all validate.
3212+
for slug in [
3213+
"z-ai/glm-5.1",
3214+
"deepseek/deepseek-v4-flash",
3215+
"google/gemma-4-31b-it",
3216+
"qwen/qwen3.5-122b-a10b",
3217+
"qwen/qwen3.6-27b",
3218+
"qwen/qwen3.6-35b-a3b",
3219+
"qwen/qwen3-vl-30b-a3b-instruct",
3220+
"qwen/qwen3-30b-a3b-instruct-2507",
3221+
"openai/gpt-oss-120b",
3222+
"a/b", // minimal single-char segments
3223+
"a.b_c-d/e.f", // all interior punctuation classes
3224+
] {
3225+
assert!(
3226+
is_valid_openrouter_slug(slug),
3227+
"expected '{slug}' to be a valid OpenRouter slug"
3228+
);
3229+
}
3230+
}
3231+
3232+
#[test]
3233+
fn rejects_malformed_slugs() {
3234+
for slug in [
3235+
"", // empty
3236+
"glm-5.1", // missing author/ segment
3237+
"Z-AI/glm-5.1", // uppercase
3238+
"z-ai/GLM-5.1", // uppercase in slug
3239+
"z-ai/", // empty slug segment
3240+
"/glm-5.1", // empty author segment
3241+
"z-ai/glm/5.1", // more than one separator
3242+
"-z-ai/glm-5.1", // leading punctuation
3243+
"z-ai-/glm-5.1", // trailing punctuation on author
3244+
"z-ai/glm-5.1-", // trailing punctuation on slug
3245+
"z-ai/.glm", // leading punctuation on slug
3246+
"z ai/glm-5.1", // space is not allowed
3247+
] {
3248+
assert!(
3249+
!is_valid_openrouter_slug(slug),
3250+
"expected '{slug}' to be rejected"
3251+
);
3252+
}
3253+
}
3254+
}

crates/api/src/routes/completions.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2288,6 +2288,13 @@ fn model_with_pricing_to_info(model: services::models::ModelWithPricing) -> Mode
22882288
is_moderated: false,
22892289
}),
22902290
datacenters: crate::models::Datacenter::from_codes(model.datacenters),
2291+
// OpenRouter requires `id` to match its canonical slug, or an explicit
2292+
// override via this nested object. Emit it only when an override is set
2293+
// (NULL/empty → omit the key entirely).
2294+
openrouter: model
2295+
.openrouter_slug
2296+
.filter(|s| !s.is_empty())
2297+
.map(|slug| crate::models::OpenRouter { slug }),
22912298
}
22922299
}
22932300

@@ -2376,6 +2383,7 @@ mod tests {
23762383
datacenters: None,
23772384
is_ready: None,
23782385
deprecation_date: None,
2386+
openrouter_slug: None,
23792387
created_at: chrono::Utc::now(),
23802388
}
23812389
}
@@ -2420,6 +2428,44 @@ mod tests {
24202428
assert_eq!(architecture.output_modalities, vec!["text".to_string()]);
24212429
}
24222430

2431+
#[test]
2432+
fn model_without_openrouter_slug_omits_nested_object() {
2433+
// No override set → the public ModelInfo must not carry the nested
2434+
// `openrouter` object at all (serde skips it when None).
2435+
let info = model_with_pricing_to_info(make_model_with_pricing(None, None));
2436+
assert!(
2437+
info.openrouter.is_none(),
2438+
"openrouter object must be omitted when no slug override is set"
2439+
);
2440+
// And it must not appear in the serialized JSON either.
2441+
let json = serde_json::to_value(&info).unwrap();
2442+
assert!(
2443+
json.get("openrouter").is_none(),
2444+
"serialized JSON must omit the openrouter key when unset"
2445+
);
2446+
}
2447+
2448+
#[test]
2449+
fn model_with_openrouter_slug_emits_nested_object() {
2450+
// Override set → the public ModelInfo must carry
2451+
// `openrouter: { slug: <value> }`.
2452+
let mut model = make_model_with_pricing(None, None);
2453+
model.openrouter_slug = Some("z-ai/glm-5.1".to_string());
2454+
let info = model_with_pricing_to_info(model);
2455+
let openrouter = info
2456+
.openrouter
2457+
.as_ref()
2458+
.expect("openrouter object must be present when slug override is set");
2459+
assert_eq!(openrouter.slug, "z-ai/glm-5.1");
2460+
2461+
let json = serde_json::to_value(&info).unwrap();
2462+
assert_eq!(
2463+
json["openrouter"]["slug"],
2464+
serde_json::json!("z-ai/glm-5.1"),
2465+
"serialized JSON must nest the slug under openrouter.slug"
2466+
);
2467+
}
2468+
24232469
fn make_chat_chunk(id: &str) -> inference_providers::StreamChunk {
24242470
inference_providers::StreamChunk::Chat(inference_providers::models::ChatCompletionChunk {
24252471
id: id.to_string(),

crates/api/src/routes/models.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ pub async fn list_models(
160160
.deprecation_date
161161
.as_ref()
162162
.map(crate::routes::admin::format_deprecation_date),
163+
openrouter_slug: model.openrouter_slug,
163164
},
164165
})
165166
.collect();
@@ -274,6 +275,7 @@ pub async fn get_model_by_name(
274275
.deprecation_date
275276
.as_ref()
276277
.map(crate::routes::admin::format_deprecation_date),
278+
openrouter_slug: model.openrouter_slug,
277279
},
278280
};
279281

0 commit comments

Comments
 (0)