Add OpenRouter platform: wire-served metadata (provider 8)

The 11th registry row and the first THIRD-PARTY wire_serves_metadata=true
provider: id "openrouter", OPENROUTER_API_KEY > auth.json "openrouter"
scope, https://openrouter.ai/api/v1 (note /api/v1) with
KIGI_OPENROUTER_BASE_URL override, Bearer, OpenAI listing +
ChatCompletions + Passthrough.

OpenRouter's public /models serves context_length for every model
(verified live: 340/340), so it needs NO enrichment: models_dev_id=None,
wire_serves_metadata=true, restrict_to_enriched=false. An OpenRouter-only
user makes zero models.dev calls; context comes straight from the listing.
Slashed ids (anthropic/claude-opus-4.8) round-trip through the managed key
via the first-slash split; the native id rides the wire. The e2e pins all
of this with the models.dev refresh disabled.

Review-confirmed defect fixed (and independently re-verified with live
curls): OpenRouter's /models is PUBLIC — GET /models returns 200 for ANY
key — so login key-validation would false-accept a bad key, deferring the
failure to the first chat 401. New spec field key_validation_path lets a
public-listing platform validate against an auth-requiring endpoint;
OpenRouter uses /key (401s for bad keys). Reusable for Vercel (also
public). Tests pin the /key validation and no regression to the default
/models path.

Gate caught a fixture regression: the kimi_import test used openrouter.ai
to represent a CUSTOM provider, which now correctly dedupes to the builtin
OpenRouter — moved the fixture to a reserved llm.example.test host that no
future platform can shadow.
This commit is contained in:
2026-07-21 12:41:14 -04:00
parent 7a2cd8a726
commit e7dbb98207
5 changed files with 257 additions and 15 deletions
+78 -2
View File
@@ -144,6 +144,11 @@ struct PlatformSpec {
/// (tts/embeddings/image). Availability still requires the LIVE listing;
/// this only drops listing noise, never adds models.
restrict_to_enriched: bool,
/// Path (relative to base) to hit for API-key VALIDATION at login, when
/// the listing endpoint can't validate. OpenRouter's `/models` is public
/// (200 for any key), so a bad key would false-accept at login; its
/// `/key` endpoint 401s properly. `None` = validate against `/models`.
key_validation_path: Option<&'static str>,
/// A prefix to strip from each live-listing model id before filtering,
/// enrichment lookup, and managed-key formation. Google's OpenAI-compat
/// `/models` returns `models/`-prefixed ids while its chat endpoint (and
@@ -171,6 +176,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
chat_compat: PlatformChatCompat::Kimi,
key_header: PlatformKeyHeader::Bearer,
restrict_to_enriched: false,
key_validation_path: None,
strip_listing_id_prefix: None,
};
@@ -194,6 +200,7 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
chat_compat: PlatformChatCompat::Kimi,
key_header: PlatformKeyHeader::Bearer,
restrict_to_enriched: false,
key_validation_path: None,
strip_listing_id_prefix: None,
};
@@ -217,6 +224,7 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
chat_compat: PlatformChatCompat::Kimi,
key_header: PlatformKeyHeader::Bearer,
restrict_to_enriched: false,
key_validation_path: None,
strip_listing_id_prefix: None,
};
@@ -245,6 +253,7 @@ const OPENAI_SPEC: PlatformSpec = PlatformSpec {
chat_compat: PlatformChatCompat::Passthrough,
key_header: PlatformKeyHeader::Bearer,
restrict_to_enriched: true,
key_validation_path: None,
strip_listing_id_prefix: None,
};
@@ -273,6 +282,7 @@ const ANTHROPIC_SPEC: PlatformSpec = PlatformSpec {
chat_compat: PlatformChatCompat::Passthrough,
key_header: PlatformKeyHeader::XApiKey,
restrict_to_enriched: false,
key_validation_path: None,
strip_listing_id_prefix: None,
};
@@ -301,6 +311,7 @@ const DEEPSEEK_SPEC: PlatformSpec = PlatformSpec {
chat_compat: PlatformChatCompat::DeepSeek,
key_header: PlatformKeyHeader::Bearer,
restrict_to_enriched: false,
key_validation_path: None,
strip_listing_id_prefix: None,
};
@@ -329,6 +340,7 @@ const GROQ_SPEC: PlatformSpec = PlatformSpec {
// The listing carries whisper/tts entries; keep tool-calling chat
// models only.
restrict_to_enriched: true,
key_validation_path: None,
strip_listing_id_prefix: None,
};
@@ -359,6 +371,7 @@ const MISTRAL_SPEC: PlatformSpec = PlatformSpec {
// The listing carries embed/moderation/OCR entries; keep tool-calling
// chat models only.
restrict_to_enriched: true,
key_validation_path: None,
strip_listing_id_prefix: None,
};
@@ -388,6 +401,7 @@ const FIREWORKS_SPEC: PlatformSpec = PlatformSpec {
// The inference /models listing can include embedding/non-chat models;
// keep tool-calling enrichment-known models only.
restrict_to_enriched: true,
key_validation_path: None,
strip_listing_id_prefix: None,
};
@@ -417,9 +431,41 @@ const GOOGLE_SPEC: PlatformSpec = PlatformSpec {
// The compat listing carries embedding/tts/image models; keep
// tool-calling enrichment-known chat models only.
restrict_to_enriched: true,
key_validation_path: None,
strip_listing_id_prefix: Some("models/"),
};
/// Base-URL override for OpenRouter (dev/test escape hatch).
pub const OPENROUTER_BASE_URL_ENV: &str = "KIGI_OPENROUTER_BASE_URL";
const OPENROUTER_SPEC: PlatformSpec = PlatformSpec {
id: "openrouter",
display_name: "OpenRouter",
base_url: BaseUrlSource::EnvOr {
env: OPENROUTER_BASE_URL_ENV,
// Note /api/v1, not /v1.
default: "https://openrouter.ai/api/v1",
},
uses_oauth: false,
allowed_model_prefixes: None,
api_key_envs: &["OPENROUTER_API_KEY"],
vendor: "OpenRouter",
console_host: Some("openrouter.ai"),
login_label: Some("OpenRouter (API key)"),
// OpenRouter's public /models serves context_length for every model, so
// enrichment is neither needed nor fetched (verified live: 340/340 carry
// a top-level context_length). Not a models.dev provider here.
models_dev_id: None,
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
chat_compat: PlatformChatCompat::Passthrough,
key_header: PlatformKeyHeader::Bearer,
restrict_to_enriched: false,
key_validation_path: Some("/key"),
strip_listing_id_prefix: None,
};
/// The platform registry. Platforms are compiled-in spec rows; there is no
/// dynamic provider registration (PRD F2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -444,12 +490,14 @@ pub enum PlatformId {
Fireworks,
/// Google Gemini platform API (API key, OpenAI-compatibility shim).
Google,
/// OpenRouter meta-provider (API key, wire-served metadata).
OpenRouter,
}
impl PlatformId {
/// All platforms, in catalog precedence order: the subscription channel
/// first so "default model = first list item" favors it when present.
pub const ALL: [PlatformId; 10] = [
pub const ALL: [PlatformId; 11] = [
Self::KimiCode,
Self::MoonshotCn,
Self::MoonshotAi,
@@ -460,6 +508,7 @@ impl PlatformId {
Self::Mistral,
Self::Fireworks,
Self::Google,
Self::OpenRouter,
];
/// The registry row backing this platform (single source of per-platform
@@ -476,6 +525,7 @@ impl PlatformId {
Self::Mistral => &MISTRAL_SPEC,
Self::Fireworks => &FIREWORKS_SPEC,
Self::Google => &GOOGLE_SPEC,
Self::OpenRouter => &OPENROUTER_SPEC,
}
}
@@ -572,6 +622,13 @@ impl PlatformId {
self.spec().strip_listing_id_prefix
}
/// Path (relative to base) for API-key validation at login. Defaults to
/// `/models`; a platform whose listing is public (OpenRouter) overrides
/// it with an auth-requiring endpoint so a bad key can't false-accept.
pub fn key_validation_path(self) -> &'static str {
self.spec().key_validation_path.unwrap_or("/models")
}
/// Model-listing endpoint shape + headers.
pub fn listing(self) -> ListingDialect {
self.spec().listing
@@ -1116,6 +1173,24 @@ mod tests {
);
}
/// OpenRouter's `/models` is public, so its key validation must target
/// an auth-requiring endpoint; every other platform validates against
/// the default `/models`.
#[test]
fn only_openrouter_overrides_the_validation_path() {
assert_eq!(PlatformId::OpenRouter.key_validation_path(), "/key");
for p in PlatformId::ALL {
if p != PlatformId::OpenRouter {
assert_eq!(
p.key_validation_path(),
"/models",
"{} must validate against /models",
p.as_str()
);
}
}
}
/// Google's compat listing returns `models/`-prefixed ids; the spec
/// must declare the strip so they canonicalize to the bare snapshot
/// form. Every other platform uses ids verbatim.
@@ -1163,9 +1238,10 @@ mod tests {
PlatformId::Mistral => 7,
PlatformId::Fireworks => 8,
PlatformId::Google => 9,
PlatformId::OpenRouter => 10,
}
}
const VARIANT_COUNT: usize = 10; // update together with `ordinal`
const VARIANT_COUNT: usize = 11; // update together with `ordinal`
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
seen.sort_unstable();
seen.dedup();