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:
@@ -144,6 +144,11 @@ struct PlatformSpec {
|
|||||||
/// (tts/embeddings/image). Availability still requires the LIVE listing;
|
/// (tts/embeddings/image). Availability still requires the LIVE listing;
|
||||||
/// this only drops listing noise, never adds models.
|
/// this only drops listing noise, never adds models.
|
||||||
restrict_to_enriched: bool,
|
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,
|
/// A prefix to strip from each live-listing model id before filtering,
|
||||||
/// enrichment lookup, and managed-key formation. Google's OpenAI-compat
|
/// enrichment lookup, and managed-key formation. Google's OpenAI-compat
|
||||||
/// `/models` returns `models/`-prefixed ids while its chat endpoint (and
|
/// `/models` returns `models/`-prefixed ids while its chat endpoint (and
|
||||||
@@ -171,6 +176,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
chat_compat: PlatformChatCompat::Kimi,
|
chat_compat: PlatformChatCompat::Kimi,
|
||||||
key_header: PlatformKeyHeader::Bearer,
|
key_header: PlatformKeyHeader::Bearer,
|
||||||
restrict_to_enriched: false,
|
restrict_to_enriched: false,
|
||||||
|
key_validation_path: None,
|
||||||
strip_listing_id_prefix: None,
|
strip_listing_id_prefix: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,6 +200,7 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
chat_compat: PlatformChatCompat::Kimi,
|
chat_compat: PlatformChatCompat::Kimi,
|
||||||
key_header: PlatformKeyHeader::Bearer,
|
key_header: PlatformKeyHeader::Bearer,
|
||||||
restrict_to_enriched: false,
|
restrict_to_enriched: false,
|
||||||
|
key_validation_path: None,
|
||||||
strip_listing_id_prefix: None,
|
strip_listing_id_prefix: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,6 +224,7 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
chat_compat: PlatformChatCompat::Kimi,
|
chat_compat: PlatformChatCompat::Kimi,
|
||||||
key_header: PlatformKeyHeader::Bearer,
|
key_header: PlatformKeyHeader::Bearer,
|
||||||
restrict_to_enriched: false,
|
restrict_to_enriched: false,
|
||||||
|
key_validation_path: None,
|
||||||
strip_listing_id_prefix: None,
|
strip_listing_id_prefix: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,6 +253,7 @@ const OPENAI_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
chat_compat: PlatformChatCompat::Passthrough,
|
chat_compat: PlatformChatCompat::Passthrough,
|
||||||
key_header: PlatformKeyHeader::Bearer,
|
key_header: PlatformKeyHeader::Bearer,
|
||||||
restrict_to_enriched: true,
|
restrict_to_enriched: true,
|
||||||
|
key_validation_path: None,
|
||||||
strip_listing_id_prefix: None,
|
strip_listing_id_prefix: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -273,6 +282,7 @@ const ANTHROPIC_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
chat_compat: PlatformChatCompat::Passthrough,
|
chat_compat: PlatformChatCompat::Passthrough,
|
||||||
key_header: PlatformKeyHeader::XApiKey,
|
key_header: PlatformKeyHeader::XApiKey,
|
||||||
restrict_to_enriched: false,
|
restrict_to_enriched: false,
|
||||||
|
key_validation_path: None,
|
||||||
strip_listing_id_prefix: None,
|
strip_listing_id_prefix: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -301,6 +311,7 @@ const DEEPSEEK_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
chat_compat: PlatformChatCompat::DeepSeek,
|
chat_compat: PlatformChatCompat::DeepSeek,
|
||||||
key_header: PlatformKeyHeader::Bearer,
|
key_header: PlatformKeyHeader::Bearer,
|
||||||
restrict_to_enriched: false,
|
restrict_to_enriched: false,
|
||||||
|
key_validation_path: None,
|
||||||
strip_listing_id_prefix: 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
|
// The listing carries whisper/tts entries; keep tool-calling chat
|
||||||
// models only.
|
// models only.
|
||||||
restrict_to_enriched: true,
|
restrict_to_enriched: true,
|
||||||
|
key_validation_path: None,
|
||||||
strip_listing_id_prefix: 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
|
// The listing carries embed/moderation/OCR entries; keep tool-calling
|
||||||
// chat models only.
|
// chat models only.
|
||||||
restrict_to_enriched: true,
|
restrict_to_enriched: true,
|
||||||
|
key_validation_path: None,
|
||||||
strip_listing_id_prefix: 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;
|
// The inference /models listing can include embedding/non-chat models;
|
||||||
// keep tool-calling enrichment-known models only.
|
// keep tool-calling enrichment-known models only.
|
||||||
restrict_to_enriched: true,
|
restrict_to_enriched: true,
|
||||||
|
key_validation_path: None,
|
||||||
strip_listing_id_prefix: None,
|
strip_listing_id_prefix: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -417,9 +431,41 @@ const GOOGLE_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
// The compat listing carries embedding/tts/image models; keep
|
// The compat listing carries embedding/tts/image models; keep
|
||||||
// tool-calling enrichment-known chat models only.
|
// tool-calling enrichment-known chat models only.
|
||||||
restrict_to_enriched: true,
|
restrict_to_enriched: true,
|
||||||
|
key_validation_path: None,
|
||||||
strip_listing_id_prefix: Some("models/"),
|
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
|
/// The platform registry. Platforms are compiled-in spec rows; there is no
|
||||||
/// dynamic provider registration (PRD F2).
|
/// dynamic provider registration (PRD F2).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
@@ -444,12 +490,14 @@ pub enum PlatformId {
|
|||||||
Fireworks,
|
Fireworks,
|
||||||
/// Google Gemini platform API (API key, OpenAI-compatibility shim).
|
/// Google Gemini platform API (API key, OpenAI-compatibility shim).
|
||||||
Google,
|
Google,
|
||||||
|
/// OpenRouter meta-provider (API key, wire-served metadata).
|
||||||
|
OpenRouter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlatformId {
|
impl PlatformId {
|
||||||
/// All platforms, in catalog precedence order: the subscription channel
|
/// All platforms, in catalog precedence order: the subscription channel
|
||||||
/// first so "default model = first list item" favors it when present.
|
/// first so "default model = first list item" favors it when present.
|
||||||
pub const ALL: [PlatformId; 10] = [
|
pub const ALL: [PlatformId; 11] = [
|
||||||
Self::KimiCode,
|
Self::KimiCode,
|
||||||
Self::MoonshotCn,
|
Self::MoonshotCn,
|
||||||
Self::MoonshotAi,
|
Self::MoonshotAi,
|
||||||
@@ -460,6 +508,7 @@ impl PlatformId {
|
|||||||
Self::Mistral,
|
Self::Mistral,
|
||||||
Self::Fireworks,
|
Self::Fireworks,
|
||||||
Self::Google,
|
Self::Google,
|
||||||
|
Self::OpenRouter,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The registry row backing this platform (single source of per-platform
|
/// The registry row backing this platform (single source of per-platform
|
||||||
@@ -476,6 +525,7 @@ impl PlatformId {
|
|||||||
Self::Mistral => &MISTRAL_SPEC,
|
Self::Mistral => &MISTRAL_SPEC,
|
||||||
Self::Fireworks => &FIREWORKS_SPEC,
|
Self::Fireworks => &FIREWORKS_SPEC,
|
||||||
Self::Google => &GOOGLE_SPEC,
|
Self::Google => &GOOGLE_SPEC,
|
||||||
|
Self::OpenRouter => &OPENROUTER_SPEC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,6 +622,13 @@ impl PlatformId {
|
|||||||
self.spec().strip_listing_id_prefix
|
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.
|
/// Model-listing endpoint shape + headers.
|
||||||
pub fn listing(self) -> ListingDialect {
|
pub fn listing(self) -> ListingDialect {
|
||||||
self.spec().listing
|
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
|
/// Google's compat listing returns `models/`-prefixed ids; the spec
|
||||||
/// must declare the strip so they canonicalize to the bare snapshot
|
/// must declare the strip so they canonicalize to the bare snapshot
|
||||||
/// form. Every other platform uses ids verbatim.
|
/// form. Every other platform uses ids verbatim.
|
||||||
@@ -1163,9 +1238,10 @@ mod tests {
|
|||||||
PlatformId::Mistral => 7,
|
PlatformId::Mistral => 7,
|
||||||
PlatformId::Fireworks => 8,
|
PlatformId::Fireworks => 8,
|
||||||
PlatformId::Google => 9,
|
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();
|
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
|
||||||
seen.sort_unstable();
|
seen.sort_unstable();
|
||||||
seen.dedup();
|
seen.dedup();
|
||||||
|
|||||||
@@ -394,7 +394,11 @@ pub(crate) async fn authenticate_platform_api_key(
|
|||||||
let Some(key) = key else {
|
let Some(key) = key else {
|
||||||
return Err(auth_err(missing_platform_key_error(platform)));
|
return Err(auth_err(missing_platform_key_error(platform)));
|
||||||
};
|
};
|
||||||
let url = format!("{}/models", platform.base_url().trim_end_matches('/'));
|
let url = format!(
|
||||||
|
"{}{}",
|
||||||
|
platform.base_url().trim_end_matches('/'),
|
||||||
|
platform.key_validation_path()
|
||||||
|
);
|
||||||
let request = match platform.key_header() {
|
let request = match platform.key_header() {
|
||||||
kigi_models::PlatformKeyHeader::Bearer => crate::http::shared_client()
|
kigi_models::PlatformKeyHeader::Bearer => crate::http::shared_client()
|
||||||
.get(&url)
|
.get(&url)
|
||||||
@@ -598,7 +602,8 @@ mod tests {
|
|||||||
"groq",
|
"groq",
|
||||||
"mistral",
|
"mistral",
|
||||||
"fireworks",
|
"fireworks",
|
||||||
"google"
|
"google",
|
||||||
|
"openrouter"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
||||||
@@ -631,7 +636,8 @@ mod tests {
|
|||||||
"groq",
|
"groq",
|
||||||
"mistral",
|
"mistral",
|
||||||
"fireworks",
|
"fireworks",
|
||||||
"google"
|
"google",
|
||||||
|
"openrouter"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||||
@@ -657,7 +663,8 @@ mod tests {
|
|||||||
"groq",
|
"groq",
|
||||||
"mistral",
|
"mistral",
|
||||||
"fireworks",
|
"fireworks",
|
||||||
"google"
|
"google",
|
||||||
|
"openrouter"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||||
@@ -686,7 +693,8 @@ mod tests {
|
|||||||
"groq",
|
"groq",
|
||||||
"mistral",
|
"mistral",
|
||||||
"fireworks",
|
"fireworks",
|
||||||
"google"
|
"google",
|
||||||
|
"openrouter"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), None);
|
assert_eq!(default_id(&built), None);
|
||||||
@@ -816,4 +824,63 @@ mod tests {
|
|||||||
"the key must never leak into errors"
|
"the key must never leak into errors"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OpenRouter's `/models` is PUBLIC (200 for any key), so validation must
|
||||||
|
/// hit its auth-requiring `/key` endpoint instead — otherwise a bad key
|
||||||
|
/// false-accepts at login. The mock serves `/models` 200 always; a bad
|
||||||
|
/// key must still be rejected (proving `/models` is NOT what's validated).
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn openrouter_validates_against_key_endpoint_not_public_models() {
|
||||||
|
use wiremock::matchers::{method, path};
|
||||||
|
let server = wiremock::MockServer::start().await;
|
||||||
|
// Public listing: 200 for anyone. If validation used this, a bad key
|
||||||
|
// would pass.
|
||||||
|
wiremock::Mock::given(method("GET"))
|
||||||
|
.and(path("/models"))
|
||||||
|
.respond_with(
|
||||||
|
wiremock::ResponseTemplate::new(200)
|
||||||
|
.set_body_json(serde_json::json!({ "data": [] })),
|
||||||
|
)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
// Auth-required key endpoint: 401 for a bad key.
|
||||||
|
wiremock::Mock::given(method("GET"))
|
||||||
|
.and(path("/key"))
|
||||||
|
.respond_with(wiremock::ResponseTemplate::new(401))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let _base = EnvGuard::set(kigi_models::OPENROUTER_BASE_URL_ENV, &server.uri());
|
||||||
|
let err =
|
||||||
|
authenticate_platform_api_key(kigi_models::PlatformId::OpenRouter, Some("sk-or-bad"))
|
||||||
|
.await
|
||||||
|
.expect_err("a bad key must be rejected via /key, not accepted via /models");
|
||||||
|
assert_eq!(
|
||||||
|
err.message,
|
||||||
|
"Invalid API key for openrouter \u{2014} check your key on openrouter.ai"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid OpenRouter key: `/key` returns 200 → accepted.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn openrouter_valid_key_succeeds_via_key_endpoint() {
|
||||||
|
use wiremock::matchers::{header, method, path};
|
||||||
|
let server = wiremock::MockServer::start().await;
|
||||||
|
wiremock::Mock::given(method("GET"))
|
||||||
|
.and(path("/key"))
|
||||||
|
.and(header("Authorization", "Bearer sk-or-good"))
|
||||||
|
.respond_with(
|
||||||
|
wiremock::ResponseTemplate::new(200)
|
||||||
|
.set_body_json(serde_json::json!({ "data": { "label": "k" } })),
|
||||||
|
)
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let _base = EnvGuard::set(kigi_models::OPENROUTER_BASE_URL_ENV, &server.uri());
|
||||||
|
authenticate_platform_api_key(kigi_models::PlatformId::OpenRouter, Some("sk-or-good"))
|
||||||
|
.await
|
||||||
|
.expect("200 from /key must validate the key");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1505,6 +1505,98 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OpenRouter-cycle e2e: wire_serves_metadata=true — the listing itself
|
||||||
|
/// carries `context_length`, so context comes from the WIRE with NO
|
||||||
|
/// enrichment fetch and NO restriction (all models kept). The models.dev
|
||||||
|
/// refresh is disabled to prove it is never consulted. Slashed ids
|
||||||
|
/// round-trip; Passthrough dialect.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn openrouter_wire_metadata_needs_no_enrichment_and_keeps_all() {
|
||||||
|
let platform_server = wiremock::MockServer::start().await;
|
||||||
|
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||||
|
.and(wiremock::matchers::path("/models"))
|
||||||
|
.and(wiremock::matchers::header("Authorization", "Bearer or-1"))
|
||||||
|
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
|
||||||
|
serde_json::json!({ "data": [
|
||||||
|
{ "id": "anthropic/claude-opus-4.8", "context_length": 1000000,
|
||||||
|
"supported_parameters": ["reasoning_effort", "tools"] },
|
||||||
|
{ "id": "openai/gpt-5.5", "context_length": 400000,
|
||||||
|
"supported_parameters": ["tools"] }
|
||||||
|
]}),
|
||||||
|
))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&platform_server)
|
||||||
|
.await;
|
||||||
|
let cache_dir = tempfile::tempdir().unwrap();
|
||||||
|
let _base = kigi_test_support::EnvGuard::set(
|
||||||
|
kigi_models::OPENROUTER_BASE_URL_ENV,
|
||||||
|
platform_server.uri(),
|
||||||
|
);
|
||||||
|
// Kill switch: proves enrichment is never fetched for a wire-served
|
||||||
|
// platform (any attempt would need this URL).
|
||||||
|
let _mdev = kigi_test_support::EnvGuard::set(
|
||||||
|
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
|
||||||
|
"0",
|
||||||
|
);
|
||||||
|
let _mdev_cache = kigi_test_support::EnvGuard::set(
|
||||||
|
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
|
||||||
|
cache_dir.path(),
|
||||||
|
);
|
||||||
|
let endpoints = crate::agent::config::EndpointsConfig::default();
|
||||||
|
let keys = crate::agent::models::PlatformApiKeys::test_single(
|
||||||
|
kigi_models::PlatformId::OpenRouter,
|
||||||
|
"or-1",
|
||||||
|
);
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
fetch_platform_models_blocking(&endpoints, None, &keys)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("fetch must succeed");
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.id.as_deref().unwrap_or_default())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
"openrouter/anthropic/claude-opus-4.8",
|
||||||
|
"openrouter/openai/gpt-5.5"
|
||||||
|
],
|
||||||
|
"no restriction — all listed models kept; slashed ids in the key"
|
||||||
|
);
|
||||||
|
let opus = &result.models[0];
|
||||||
|
assert_eq!(
|
||||||
|
opus.context_window.get(),
|
||||||
|
1_000_000,
|
||||||
|
"context window comes from the wire listing, not enrichment"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
opus.model, "anthropic/claude-opus-4.8",
|
||||||
|
"the native slashed id rides the wire"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kigi_models::parse_managed_model_key(opus.id.as_deref().unwrap()),
|
||||||
|
Some((
|
||||||
|
kigi_models::PlatformId::OpenRouter,
|
||||||
|
"anthropic/claude-opus-4.8"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
let model_entry = crate::agent::config::ModelEntry::from_config_entry(opus);
|
||||||
|
let creds = crate::agent::config::ResolvedCredentials {
|
||||||
|
api_key: Some("or-1".into()),
|
||||||
|
base_url: opus.base_url.clone(),
|
||||||
|
auth_type: kigi_chat_state::AuthType::ApiKey,
|
||||||
|
auth_scheme: Default::default(),
|
||||||
|
};
|
||||||
|
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.chat_compat,
|
||||||
|
kigi_sampling_types::ChatCompat::Passthrough
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_env_keys_parses_strings_and_rejects_non_strings() {
|
fn get_env_keys_parses_strings_and_rejects_non_strings() {
|
||||||
use crate::agent::config::EnvKeys;
|
use crate::agent::config::EnvKeys;
|
||||||
|
|||||||
@@ -611,7 +611,7 @@ model = "kimi-for-coding"
|
|||||||
max_context_size = 262144
|
max_context_size = 262144
|
||||||
|
|
||||||
[models.my-openai]
|
[models.my-openai]
|
||||||
provider = "openrouter"
|
provider = "customllm"
|
||||||
model = "gpt-x"
|
model = "gpt-x"
|
||||||
max_context_size = 128000
|
max_context_size = 128000
|
||||||
|
|
||||||
@@ -620,9 +620,9 @@ type = "kimi"
|
|||||||
base_url = "https://api.kimi.com/coding/v1"
|
base_url = "https://api.kimi.com/coding/v1"
|
||||||
api_key = "sk-kimi-secret"
|
api_key = "sk-kimi-secret"
|
||||||
|
|
||||||
[providers.openrouter]
|
[providers.customllm]
|
||||||
type = "openai_legacy"
|
type = "openai_legacy"
|
||||||
base_url = "https://openrouter.ai/api/v1"
|
base_url = "https://llm.example.test/v1"
|
||||||
api_key = "sk-or-secret"
|
api_key = "sk-or-secret"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -711,7 +711,7 @@ api_key = "sk-or-secret"
|
|||||||
let m = &plan.custom_models[0];
|
let m = &plan.custom_models[0];
|
||||||
assert_eq!(m.alias, "my-openai");
|
assert_eq!(m.alias, "my-openai");
|
||||||
assert_eq!(m.model, "gpt-x");
|
assert_eq!(m.model, "gpt-x");
|
||||||
assert_eq!(m.base_url, "https://openrouter.ai/api/v1");
|
assert_eq!(m.base_url, "https://llm.example.test/v1");
|
||||||
assert_eq!(m.api_key.as_deref(), Some("sk-or-secret"));
|
assert_eq!(m.api_key.as_deref(), Some("sk-or-secret"));
|
||||||
assert_eq!(m.context_window, Some(128_000));
|
assert_eq!(m.context_window, Some(128_000));
|
||||||
|
|
||||||
@@ -850,7 +850,7 @@ api_key = "sk-ms"
|
|||||||
assert_eq!(model["model"].as_str().unwrap(), "gpt-x");
|
assert_eq!(model["model"].as_str().unwrap(), "gpt-x");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
model["base_url"].as_str().unwrap(),
|
model["base_url"].as_str().unwrap(),
|
||||||
"https://openrouter.ai/api/v1"
|
"https://llm.example.test/v1"
|
||||||
);
|
);
|
||||||
assert_eq!(model["api_key"].as_str().unwrap(), "sk-or-secret");
|
assert_eq!(model["api_key"].as_str().unwrap(), "sk-or-secret");
|
||||||
assert_eq!(model["context_window"].as_integer().unwrap(), 128_000);
|
assert_eq!(model["context_window"].as_integer().unwrap(), 128_000);
|
||||||
|
|||||||
@@ -6894,7 +6894,7 @@ pub(crate) mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
||||||
let items = pending_menu_items(&fresh_user_auth_methods(), None);
|
let items = pending_menu_items(&fresh_user_auth_methods(), None);
|
||||||
assert_eq!(items.len(), 11, "10 login rows + Quit, got {items:?}");
|
assert_eq!(items.len(), 12, "11 login rows + Quit, got {items:?}");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"),
|
matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"),
|
||||||
"row 0 must be the OAuth login, got {:?}",
|
"row 0 must be the OAuth login, got {:?}",
|
||||||
@@ -6964,7 +6964,14 @@ pub(crate) mod tests {
|
|||||||
label: "Google Gemini (API key)".into(),
|
label: "Google Gemini (API key)".into(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(items[10], PendingMenuItem::Quit);
|
assert_eq!(
|
||||||
|
items[10],
|
||||||
|
PendingMenuItem::ApiKey {
|
||||||
|
target: PlatformLogin(kigi_shell::models::PlatformId::OpenRouter),
|
||||||
|
label: "OpenRouter (API key)".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(items[11], PendingMenuItem::Quit);
|
||||||
// The non-interactive methods must never appear as rows.
|
// The non-interactive methods must never appear as rows.
|
||||||
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
||||||
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
||||||
@@ -6975,7 +6982,7 @@ pub(crate) mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pending_menu_items(&byok.methods, None).len(),
|
pending_menu_items(&byok.methods, None).len(),
|
||||||
11,
|
12,
|
||||||
"xai.api_key / cached_token must not add rows"
|
"xai.api_key / cached_token must not add rows"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user