Add OpenAI platform: live model fetching with enrichment (provider 1)

The 4th registry row: id "openai", OPENAI_API_KEY env > auth.json
"openai" scope (login picker/paste/validation all registry-generic —
zero TUI changes needed, pinned by the picker test), base
https://api.openai.com/v1 with KIGI_OPENAI_BASE_URL override, Responses
dialect via the new PlatformWireApi spec field, enrichment-backed
metadata (wire_serves_metadata=false).

OpenAI's GET /v1/models returns bare ids and is polluted with
tts/whisper/embeddings entries: the listing is restricted to
enrichment-known TOOL-CALLING models (review caught that membership
alone admitted models.dev-known embeddings models, which would 400 on
every agentic request; dropped ids are debug-logged for launch-day
diagnosability). Context windows, effort menus, display names, and
thinking capability come from the enrichment pipeline — wiremock e2e
pins the full contract: polluted live listing + models.dev →
one Responses-backed chat model with a 400k documented context window.

Responses max-effort wiring (closes the P0c-1 debt): canonical effort
rides a CreateResponseWrapper sidecar and patch_reasoning_effort writes
it onto the serialized body at both send sites (all seven levels pinned,
xhigh/max distinct, summary preserved); normalize_effort_echo drops
echoes async-openai's typed enum cannot represent at both the non-stream
and SSE parse seams; the dead typed to_responses_api converter is
deleted. Kimi/moonshot stay byte-identical (ChatCompletions untouched,
wire_api maps to the same default; kimi wire tests green).

kimi-import now recognizes ANY registry platform host as built-in
(was hardcoded moonshot), covering openai and future rows.
This commit is contained in:
2026-07-21 05:04:55 -04:00
parent fdf9b956f5
commit 23e94939c0
10 changed files with 436 additions and 58 deletions
+68 -2
View File
@@ -42,6 +42,15 @@ fn env_or(var: &str, compiled: &str) -> String {
}
}
/// Inference dialect a platform speaks. Leaf-safe mirror of the sampler's
/// `ApiBackend` (kigi-models must stay dependency-light); the shell maps it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlatformWireApi {
ChatCompletions,
Responses,
Messages,
}
/// Where a platform's base URL is resolved from.
enum BaseUrlSource {
/// The Kimi Code subscription base, owned by `kigi_env`
@@ -91,6 +100,13 @@ struct PlatformSpec {
/// window / thinking metadata — enrichment (and its network refresh) is
/// skipped entirely for such platforms.
wire_serves_metadata: bool,
/// Inference dialect (mapped to the sampler backend by the shell).
wire_api: PlatformWireApi,
/// Restrict the live listing to models the enrichment catalog knows —
/// for providers whose `/models` is polluted with non-chat entries
/// (tts/embeddings/image). Availability still requires the LIVE listing;
/// this only drops listing noise, never adds models.
restrict_to_enriched: bool,
}
const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
@@ -105,6 +121,8 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
login_label: None,
models_dev_id: Some("kimi-for-coding"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
restrict_to_enriched: false,
};
const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
@@ -122,6 +140,8 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),
models_dev_id: Some("moonshotai-cn"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
restrict_to_enriched: false,
};
const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
@@ -139,6 +159,32 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.ai)"),
models_dev_id: Some("moonshotai"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
restrict_to_enriched: false,
};
/// Base-URL override for OpenAI (dev/test escape hatch).
pub const OPENAI_BASE_URL_ENV: &str = "KIGI_OPENAI_BASE_URL";
const OPENAI_SPEC: PlatformSpec = PlatformSpec {
id: "openai",
display_name: "OpenAI",
base_url: BaseUrlSource::EnvOr {
env: OPENAI_BASE_URL_ENV,
default: "https://api.openai.com/v1",
},
uses_oauth: false,
allowed_model_prefixes: None,
api_key_envs: &["OPENAI_API_KEY"],
vendor: "OpenAI",
console_host: Some("platform.openai.com"),
login_label: Some("OpenAI (API key)"),
models_dev_id: Some("openai"),
// GET /v1/models returns bare ids only (no context/thinking metadata)
// and is polluted with tts/embeddings/image entries.
wire_serves_metadata: false,
wire_api: PlatformWireApi::Responses,
restrict_to_enriched: true,
};
/// The platform registry. Platforms are compiled-in spec rows; there is no
@@ -151,12 +197,19 @@ pub enum PlatformId {
MoonshotCn,
/// Moonshot AI open platform, api.moonshot.ai (API key).
MoonshotAi,
/// OpenAI platform API (API key, Responses dialect).
OpenAi,
}
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; 3] = [Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi];
pub const ALL: [PlatformId; 4] = [
Self::KimiCode,
Self::MoonshotCn,
Self::MoonshotAi,
Self::OpenAi,
];
/// The registry row backing this platform (single source of per-platform
/// data; every accessor below reads it).
@@ -165,6 +218,7 @@ impl PlatformId {
Self::KimiCode => &KIMI_CODE_SPEC,
Self::MoonshotCn => &MOONSHOT_CN_SPEC,
Self::MoonshotAi => &MOONSHOT_AI_SPEC,
Self::OpenAi => &OPENAI_SPEC,
}
}
@@ -243,6 +297,17 @@ impl PlatformId {
pub fn wire_serves_metadata(self) -> bool {
self.spec().wire_serves_metadata
}
/// Inference dialect this platform speaks (shell maps to `ApiBackend`).
pub fn wire_api(self) -> PlatformWireApi {
self.spec().wire_api
}
/// Restrict the live listing to enrichment-known models (drops non-chat
/// listing noise on polluted providers). Never adds models.
pub fn restrict_to_enriched(self) -> bool {
self.spec().restrict_to_enriched
}
}
/// Split a managed catalog key `{platform_id}/{model_id}` back into its
@@ -573,9 +638,10 @@ mod tests {
PlatformId::KimiCode => 0,
PlatformId::MoonshotCn => 1,
PlatformId::MoonshotAi => 2,
PlatformId::OpenAi => 3,
}
}
const VARIANT_COUNT: usize = 3; // update together with `ordinal`
const VARIANT_COUNT: usize = 4; // update together with `ordinal`
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
seen.sort_unstable();
seen.dedup();
+17 -1
View File
@@ -70,6 +70,9 @@ fn deserialize_response_event(data: &str) -> Result<rs::ResponseStreamEvent> {
Err(first_err) => {
// Try sanitizing: parse as Value, strip unknown tools, retry.
if let Ok(mut value) = serde_json::from_str::<serde_json::Value>(data) {
// A `max` reasoning-effort echo is unrepresentable in the
// typed enum; drop it so the event parses.
kigi_sampling_types::normalize_effort_echo(&mut value);
// Strip tools that async_openai's rs::Tool can't deserialize
// (e.g., xAI-specific "x_search"). Instead of maintaining a
// hardcoded allowlist, try deserializing each tool entry —
@@ -1028,6 +1031,7 @@ impl SamplingClient {
// it in post-serialize. This is the last surviving piece of the
// old raw_output machinery.
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
let http_request = self.post(self.endpoint("responses")).json(&request_body);
let response = http_request.send().await.map_err(|e| {
@@ -1074,7 +1078,16 @@ impl SamplingClient {
});
}
let response_obj = serde_json::from_slice::<rs::Response>(&bytes).map_err(|e| {
let mut response_value =
serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|e| {
let raw_body = String::from_utf8_lossy(&bytes);
tracing::error!(error = %e, raw_body = %raw_body, "Response body is not JSON");
SamplingError::Serialization(e)
})?;
// A `max` effort echo is unrepresentable in the typed enum — drop it
// rather than failing the whole response.
kigi_sampling_types::normalize_effort_echo(&mut response_value);
let response_obj = serde_json::from_value::<rs::Response>(response_value).map_err(|e| {
let raw_body = String::from_utf8_lossy(&bytes);
tracing::error!(
error = %e,
@@ -1156,6 +1169,7 @@ impl SamplingClient {
}
}
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
// Fresh per attempt so signals never leak across retries; `None`
// (check disabled) sends no header and does no peek work per event.
let doom_loop = self
@@ -1687,6 +1701,7 @@ impl SamplingClient {
let responses_request: rs::CreateResponse = (&request).into();
let mut wrapper = CreateResponseWrapper::new(responses_request);
wrapper.reasoning_effort = request.reasoning_effort;
wrapper.x_kigi_conv_id = x_kigi_conv_id;
wrapper.x_kigi_req_id = x_kigi_req_id;
wrapper.x_kigi_session_id = x_kigi_session_id;
@@ -1720,6 +1735,7 @@ impl SamplingClient {
let responses_request: rs::CreateResponse = (&request).into();
let mut wrapper = CreateResponseWrapper::new(responses_request);
wrapper.reasoning_effort = request.reasoning_effort;
wrapper.x_kigi_conv_id = x_kigi_conv_id;
wrapper.x_kigi_req_id = x_kigi_req_id;
wrapper.x_kigi_session_id = x_kigi_session_id;
@@ -2158,7 +2158,10 @@ impl From<&ConversationRequest> for rs::CreateResponse {
prompt_cache_key: None,
prompt_cache_retention: None,
reasoning: Some(rs::Reasoning {
effort: req.reasoning_effort.map(|e| e.to_responses_api()),
// Effort is written onto the serialized body by
// `patch_reasoning_effort` (the typed enum cannot spell
// `max`); the wrapper carries the canonical value.
effort: None,
summary: Some(rs::ReasoningSummary::Concise),
}),
safety_identifier: None,
@@ -5191,6 +5194,10 @@ mod tests {
#[test]
fn test_responses_request_carries_reasoning_effort_nested() {
// Wire contract since the Max split: the typed conversion leaves
// reasoning.effort UNSET (async-openai's enum cannot spell `max`);
// `patch_reasoning_effort` writes the canonical token onto the
// serialized body — every level, `xhigh` and `max` distinct.
for (variant, expected) in [
(crate::ReasoningEffort::None, "none"),
(crate::ReasoningEffort::Minimal, "minimal"),
@@ -5198,6 +5205,7 @@ mod tests {
(crate::ReasoningEffort::Medium, "medium"),
(crate::ReasoningEffort::High, "high"),
(crate::ReasoningEffort::Xhigh, "xhigh"),
(crate::ReasoningEffort::Max, "max"),
] {
let req = ConversationRequest {
reasoning_effort: Some(variant),
@@ -5205,15 +5213,56 @@ mod tests {
.with_model("test")
};
let resp: crate::rs::CreateResponse = (&req).into();
let json = serde_json::to_value(&resp).unwrap();
let mut json = serde_json::to_value(&resp).unwrap();
assert_eq!(
json.pointer("/reasoning/effort"),
None,
"typed conversion must leave effort unset ({variant:?})"
);
crate::patch_reasoning_effort(&mut json, req.reasoning_effort);
assert_eq!(
json.pointer("/reasoning/effort").and_then(|v| v.as_str()),
Some(expected),
"{variant:?} should serialize as reasoning.effort={expected:?}; got: {json:#}",
"{variant:?} should be patched as reasoning.effort={expected:?}; got: {json:#}",
);
assert_eq!(
json.pointer("/reasoning/summary").and_then(|v| v.as_str()),
Some("concise"),
"the patch must not clobber reasoning.summary"
);
}
}
#[test]
fn normalize_effort_echo_drops_only_unrepresentable_tokens() {
// `max` echo (bare response shape) is dropped so typed parsing
// succeeds; known tokens pass through; stream-event envelopes are
// handled via /response/reasoning.
let mut bare = serde_json::json!({ "reasoning": { "effort": "max", "summary": "c" } });
crate::normalize_effort_echo(&mut bare);
assert_eq!(bare.pointer("/reasoning/effort"), None);
assert_eq!(
bare.pointer("/reasoning/summary").and_then(|v| v.as_str()),
Some("c")
);
let mut known = serde_json::json!({ "reasoning": { "effort": "high" } });
crate::normalize_effort_echo(&mut known);
assert_eq!(
known.pointer("/reasoning/effort").and_then(|v| v.as_str()),
Some("high"),
"representable echoes must pass through"
);
let mut event = serde_json::json!({ "response": { "reasoning": { "effort": "max" } } });
crate::normalize_effort_echo(&mut event);
assert_eq!(event.pointer("/response/reasoning/effort"), None);
let mut absent = serde_json::json!({ "output": [] });
crate::normalize_effort_echo(&mut absent);
assert_eq!(absent, serde_json::json!({ "output": [] }));
}
#[test]
fn test_responses_request_omits_effort_when_unset() {
let req =
+58 -28
View File
@@ -794,34 +794,11 @@ pub enum ReasoningEffort {
}
impl ReasoningEffort {
pub fn to_responses_api(self) -> crate::rs::ReasoningEffort {
match self {
Self::None => crate::rs::ReasoningEffort::None,
Self::Minimal => crate::rs::ReasoningEffort::Minimal,
Self::Low => crate::rs::ReasoningEffort::Low,
Self::Medium => crate::rs::ReasoningEffort::Medium,
Self::High => crate::rs::ReasoningEffort::High,
Self::Xhigh => crate::rs::ReasoningEffort::Xhigh,
// INTERIM: async-openai (0.33 pinned; upstream through 0.41
// still lacks it) has no `Max` variant. The Responses backend
// gains true `max` via a post-serialize body patch in the OpenAI
// provider cycle; until then Max downgrades to xhigh — reachable
// via `[model.*] reasoning_effort = "max"` on a responses-backend
// model, hence the loud warn.
Self::Max => {
tracing::warn!(
"reasoning effort `max` downgraded to `xhigh` on the \
Responses backend (typed wire enum has no max yet)"
);
crate::rs::ReasoningEffort::Xhigh
}
}
}
/// Inverse of [`to_responses_api`](Self::to_responses_api): the effort the
/// Responses API echoes back on `response.reasoning.effort`. (`max`
/// echoes cannot reach here — async-openai's enum has no such variant;
/// the OpenAI provider cycle handles them before typed parsing.)
/// The canonical effort behind a typed Responses-API echo
/// (`response.reasoning.effort`). `max` echoes never reach the typed
/// enum — [`normalize_effort_echo`] drops them pre-parse (async-openai
/// has no such variant); the request direction writes the wire string
/// via [`patch_reasoning_effort`].
pub fn from_responses_api(effort: crate::rs::ReasoningEffort) -> Self {
match effort {
crate::rs::ReasoningEffort::None => Self::None,
@@ -911,6 +888,52 @@ where
}))
}
/// Write the canonical effort onto a serialized Responses request body
/// (`body.reasoning.effort`). This is the ONLY place effort reaches the
/// Responses wire: the typed `rs::ReasoningEffort` tops out at `xhigh`, so
/// `max` must be written post-serialize. A `None` effort leaves the body
/// untouched (the provider default applies).
pub fn patch_reasoning_effort(body: &mut Value, effort: Option<ReasoningEffort>) {
let Some(effort) = effort else { return };
let Some(obj) = body.as_object_mut() else {
return;
};
let reasoning = obj
.entry("reasoning")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Some(reasoning) = reasoning.as_object_mut() {
reasoning.insert(
"effort".to_string(),
Value::String(effort.as_str().to_string()),
);
}
}
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
/// (`max`): remove it so response deserialization succeeds. The turn's
/// canonical effort lives in the session sampling config regardless; only
/// the per-item echo metadata is dropped, recorded as not-echoed.
/// (Ceiling: async-openai lacks a Max variant; delete this when it grows
/// one.) Handles both bare response bodies (`/reasoning/effort`) and
/// stream-event envelopes (`/response/reasoning/effort`).
pub fn normalize_effort_echo(value: &mut Value) {
for path in ["/reasoning", "/response/reasoning"] {
if let Some(reasoning) = value.pointer_mut(path).and_then(|v| v.as_object_mut())
&& let Some(effort) = reasoning.get("effort").and_then(|v| v.as_str())
&& crate::rs::ReasoningEffort::deserialize(serde_json::Value::String(
effort.to_string(),
))
.is_err()
{
tracing::debug!(
effort,
"reasoning.effort echo unrepresentable in the typed enum; dropping"
);
reasoning.remove("effort");
}
}
}
pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort";
pub const SUPPORTS_REASONING_EFFORT_META_KEY: &str = "supportsReasoningEffort";
@@ -1126,6 +1149,12 @@ pub struct CreateResponseWrapper {
/// The inner Responses API request.
pub inner: crate::rs::CreateResponse,
/// Canonical reasoning effort for this request. The typed
/// `inner.reasoning.effort` stays `None` (async-openai's enum cannot
/// spell `max`); [`patch_reasoning_effort`] writes this value onto the
/// serialized body just before send.
pub reasoning_effort: Option<ReasoningEffort>,
/// Custom header: conversation ID for tracking.
pub x_kigi_conv_id: Option<String>,
@@ -1152,6 +1181,7 @@ impl CreateResponseWrapper {
pub fn new(inner: crate::rs::CreateResponse) -> Self {
Self {
inner,
reasoning_effort: None,
x_kigi_conv_id: None,
x_kigi_req_id: None,
x_kigi_session_id: None,
@@ -583,7 +583,8 @@ mod tests {
XAI_API_KEY_METHOD_ID,
KIMI_CODE_METHOD_ID,
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID
MOONSHOT_AI_METHOD_ID,
"openai"
]
);
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
@@ -609,7 +610,8 @@ mod tests {
CACHED_TOKEN_AUTH_METHOD_ID,
KIMI_CODE_METHOD_ID,
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID
MOONSHOT_AI_METHOD_ID,
"openai"
]
);
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
@@ -628,7 +630,8 @@ mod tests {
CACHED_TOKEN_AUTH_METHOD_ID,
KIMI_CODE_METHOD_ID,
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID
MOONSHOT_AI_METHOD_ID,
"openai"
]
);
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
@@ -650,7 +653,8 @@ mod tests {
vec![
KIMI_CODE_METHOD_ID,
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID
MOONSHOT_AI_METHOD_ID,
"openai"
]
);
assert_eq!(default_id(&built), None);
@@ -57,8 +57,15 @@ fn registry_models_dev_ids() -> BTreeSet<&'static str> {
.collect()
}
/// Cache dir override (tests re-home the cache away from the real
/// `~/.kigi` — same pattern as `KIGI_MODELS_CACHE_DIR`).
pub(crate) const MODELS_DEV_CACHE_DIR_ENV: &str = "KIGI_MODELS_DEV_CACHE_DIR";
fn cache_path() -> std::path::PathBuf {
crate::util::kigi_home::kigi_home().join(CACHE_FILE)
match std::env::var(MODELS_DEV_CACHE_DIR_ENV) {
Ok(dir) if !dir.trim().is_empty() => std::path::PathBuf::from(dir).join(CACHE_FILE),
_ => crate::util::kigi_home::kigi_home().join(CACHE_FILE),
}
}
fn refresh_url() -> Option<String> {
@@ -198,13 +205,21 @@ mod tests {
/// Wire-served-only platform sets never trigger IO — and never force the
/// bundled parse (empty owned catalog; the merge branch is gated off).
/// Kimi/Moonshot users therefore keep a zero-egress, zero-cache fetch
/// path even now that enrichment-needing platforms (OpenAI) exist.
#[test]
fn wire_served_platforms_get_empty_catalog_without_io() {
assert!(!any_platform_needs_enrichment(
&kigi_models::PlatformId::ALL
));
let catalog = load_enrichment_catalog(&kigi_models::PlatformId::ALL);
let wire_served = [
kigi_models::PlatformId::KimiCode,
kigi_models::PlatformId::MoonshotCn,
kigi_models::PlatformId::MoonshotAi,
];
assert!(!any_platform_needs_enrichment(&wire_served));
let catalog = load_enrichment_catalog(&wire_served);
assert!(catalog.is_empty());
// The full registry now DOES need enrichment (OpenAI is
// wire_serves_metadata=false) — the fast path must not hide that.
assert!(any_platform_needs_enrichment(&kigi_models::PlatformId::ALL));
}
fn cache_file_in(dir: &tempfile::TempDir) -> std::path::PathBuf {
@@ -141,6 +141,14 @@ impl PlatformApiKeys {
}
Self { keys }
}
/// Test-only constructor for a single API-key platform.
#[cfg(test)]
pub(crate) fn test_single(platform: kigi_models::PlatformId, key: &str) -> Self {
Self {
keys: std::collections::BTreeMap::from([(platform, key.to_owned())]),
}
}
}
pub(crate) fn task_model_error_for_catalog(
@@ -284,7 +284,7 @@ fn fetch_one_platform_models(
.map(|s| s.to_string());
let listing: kigi_models::WireModelsResponse = response.json()?;
let total = listing.data.len();
let filtered = kigi_models::filter_allowed_models(platform, listing.data);
let mut filtered = kigi_models::filter_allowed_models(platform, listing.data);
if filtered.len() != total {
tracing::info!(
platform = platform.as_str(),
@@ -293,6 +293,51 @@ fn fetch_one_platform_models(
"applied platform model-prefix filter"
);
}
// Polluted listings (tts/embeddings/image entries) are restricted to
// models the enrichment catalog knows. FAIL-SAFE: if enrichment has no
// data for this provider at all (refresh broken AND snapshot gap), keep
// the full listing with a warning — a noisy picker beats an empty one.
if platform.restrict_to_enriched()
&& let Some(dev_id) = platform.models_dev_id()
{
let provider_known = enrichment.get(dev_id).is_some_and(|m| !m.is_empty());
if provider_known {
let before = filtered.len();
let mut dropped: Vec<String> = Vec::new();
// Keep only tool-calling chat models: membership alone would
// admit models.dev-known embeddings/moderation entries, which
// would 400 on every agentic request (EnrichmentModel.tool_call
// exists exactly for this cut).
filtered.retain(|wire| {
let keep = kigi_models::enrichment::lookup(enrichment, dev_id, &wire.id)
.is_some_and(|meta| meta.tool_call);
if !keep {
dropped.push(wire.id.clone());
}
keep
});
if filtered.len() != before {
tracing::info!(
platform = platform.as_str(),
before,
kept = filtered.len(),
"restricted listing to tool-calling enrichment-known models"
);
// A launch-day model missing from enrichment lands here for
// up to models.dev lag + cache TTL — keep the ids traceable.
tracing::debug!(
platform = platform.as_str(),
dropped = ?dropped,
"listing ids dropped by the enrichment restriction"
);
}
} else {
tracing::warn!(
platform = platform.as_str(),
"no enrichment data for provider; keeping full listing"
);
}
}
let base_url = if platform.uses_oauth() {
endpoints.proxy_url()
} else {
@@ -329,8 +374,9 @@ fn fetch_one_platform_models(
/// Map a live `think_efforts` block to catalog effort options. The wire
/// token stays the option id/label (`"max"` → label `"Max"`) so the UI
/// mirrors the server's vocabulary, while the canonical value maps through
/// the [`kigi_sampling_types::ReasoningEffort`] parser (`"max"` → `Xhigh`).
/// Unknown tokens are dropped with a warning rather than inventing a level.
/// the [`kigi_sampling_types::ReasoningEffort`] parser (`"max"` → `Max`
/// since the Xhigh/Max split). Unknown tokens are dropped with a warning
/// rather than inventing a level.
fn think_efforts_to_options(
think: &kigi_models::WireThinkEfforts,
) -> Vec<kigi_sampling_types::ReasoningEffortOption> {
@@ -379,6 +425,13 @@ pub(crate) fn platform_wire_model_to_entry(
});
let env_key = (!platform.uses_oauth())
.then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied()));
let api_backend = match platform.wire_api() {
kigi_models::PlatformWireApi::ChatCompletions => {
crate::sampling::ApiBackend::ChatCompletions
}
kigi_models::PlatformWireApi::Responses => crate::sampling::ApiBackend::Responses,
kigi_models::PlatformWireApi::Messages => crate::sampling::ApiBackend::Messages,
};
crate::agent::config::ModelEntryConfig {
id: Some(platform.managed_model_key(&wire.id)),
name: Some(wire.display_name.clone().unwrap_or_else(|| wire.id.clone())),
@@ -390,7 +443,7 @@ pub(crate) fn platform_wire_model_to_entry(
top_p: None,
api_key: None,
env_key,
api_backend: Default::default(),
api_backend,
auth_scheme: None,
reasoning_effort: think_efforts
.and_then(|t| t.default_effort.as_deref())
@@ -662,6 +715,127 @@ fn get_string_map(
#[cfg(test)]
mod tests {
use super::*;
/// OpenAI-cycle e2e (mock wire): a polluted bare-id `/models` listing +
/// a models.dev refresh produce a catalog with ONLY chat models, enriched
/// context windows / efforts, and the Responses backend — the full
/// "live list + documented metadata" contract.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn openai_listing_is_enriched_filtered_and_responses_backed() {
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 sk-oai"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{ "id": "gpt-5-test", "object": "model", "owned_by": "openai" },
{ "id": "whisper-1", "object": "model", "owned_by": "openai" },
{ "id": "text-embedding-tiny", "object": "model" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "openai": { "models": {
"gpt-5-test": {
"name": "GPT-5 Test",
"reasoning": true,
"reasoning_options": [
{"type": "effort", "values": ["low", "medium", "high"]}
],
"limit": {"context": 400000, "output": 128000},
"modalities": {"input": ["text", "image"]},
"tool_call": true
},
// models.dev KNOWS embeddings models — membership alone
// must not admit them; the tool_call cut does.
"text-embedding-tiny": {
"limit": {"context": 8191}
}
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::OPENAI_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
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::OpenAi,
"sk-oai",
);
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!["openai/gpt-5-test"],
"pollution must be filtered: whisper (enrichment-unknown) AND \
text-embedding-tiny (enrichment-known but not tool-calling)"
);
let entry = &result.models[0];
assert_eq!(
entry.context_window.get(),
400_000,
"context window must come from enrichment (wire had none)"
);
assert_eq!(
entry.api_backend,
crate::sampling::ApiBackend::Responses,
"OpenAI entries must use the Responses backend"
);
assert_eq!(entry.name.as_deref(), Some("GPT-5 Test"));
assert!(entry.supports_reasoning_effort, "efforts must be filled");
assert_eq!(
entry
.reasoning_efforts
.iter()
.map(|o| o.id.as_str())
.collect::<Vec<_>>(),
vec!["low", "medium", "high"]
);
assert!(
entry
.capabilities
.contains(&kigi_models::ModelCapability::Thinking),
"enrichment reasoning flag must derive the thinking capability"
);
assert_eq!(
entry.env_key,
Some(crate::agent::config::EnvKeys::single("OPENAI_API_KEY")),
"entries carry the env NAME (never key values)"
);
assert!(
cache_dir.path().join("models_dev_cache.json").exists(),
"the refresh must be cached in the overridden dir"
);
}
#[test]
fn get_env_keys_parses_strings_and_rejects_non_strings() {
use crate::agent::config::EnvKeys;
@@ -1161,6 +1335,10 @@ mod tests {
platform_models_url(kigi_models::PlatformId::MoonshotAi, &cfg),
"https://api.moonshot.ai/v1/models"
);
assert_eq!(
platform_models_url(kigi_models::PlatformId::OpenAi, &cfg),
"https://api.openai.com/v1/models"
);
// Proxy override re-points the subscription platform only.
let proxied = EndpointsConfig::from_config_value(
&toml::from_str(
+11 -7
View File
@@ -160,18 +160,22 @@ struct KimiProviderToml {
}
/// Built-in kigi platform a kimi provider duplicates, if any: provider type
/// `kimi` is the Kimi Code subscription channel; the two Moonshot open
/// platforms are recognized by their fixed production hosts (the same hosts
/// `kigi_models::PlatformId::base_url` compiles in).
/// `kimi` is the Kimi Code subscription channel; API-key platforms are
/// recognized by their production hosts (the same hosts
/// `kigi_models::PlatformId::base_url` compiles in — moonshot, openai, and
/// every future registry row automatically).
fn builtin_platform(provider: &KimiProviderToml) -> Option<PlatformId> {
if provider.provider_type == "kimi" {
return Some(PlatformId::KimiCode);
}
match url_host(&provider.base_url) {
Some("api.moonshot.cn") => Some(PlatformId::MoonshotCn),
Some("api.moonshot.ai") => Some(PlatformId::MoonshotAi),
_ => None,
let host = url_host(&provider.base_url)?;
PlatformId::ALL.into_iter().find(|platform| {
if platform.uses_oauth() {
return false;
}
let base = platform.base_url();
url_host(&base) == Some(host)
})
}
/// Host component of an http(s) URL. `None` for other schemes.
+11 -3
View File
@@ -6894,7 +6894,7 @@ pub(crate) mod tests {
#[test]
fn pending_menu_items_lists_interactive_methods_plus_quit() {
let items = pending_menu_items(&fresh_user_auth_methods(), None);
assert_eq!(items.len(), 4, "3 login rows + Quit, got {items:?}");
assert_eq!(items.len(), 5, "4 login rows + Quit, got {items:?}");
assert!(
matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"),
"row 0 must be the OAuth login, got {:?}",
@@ -6914,7 +6914,15 @@ pub(crate) mod tests {
label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(),
}
);
assert_eq!(items[3], PendingMenuItem::Quit);
assert_eq!(
items[3],
PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::OpenAi),
label: "OpenAI (API key)".into(),
},
"new registry rows must appear in the picker with zero TUI changes"
);
assert_eq!(items[4], PendingMenuItem::Quit);
// The non-interactive methods must never appear as rows.
let byok = kigi_shell::agent::auth_method::build_auth_methods(
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
@@ -6925,7 +6933,7 @@ pub(crate) mod tests {
);
assert_eq!(
pending_menu_items(&byok.methods, None).len(),
4,
5,
"xai.api_key / cached_token must not add rows"
);
}