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:
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user