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
@@ -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(