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:
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user