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.
|
/// Where a platform's base URL is resolved from.
|
||||||
enum BaseUrlSource {
|
enum BaseUrlSource {
|
||||||
/// The Kimi Code subscription base, owned by `kigi_env`
|
/// The Kimi Code subscription base, owned by `kigi_env`
|
||||||
@@ -91,6 +100,13 @@ struct PlatformSpec {
|
|||||||
/// window / thinking metadata — enrichment (and its network refresh) is
|
/// window / thinking metadata — enrichment (and its network refresh) is
|
||||||
/// skipped entirely for such platforms.
|
/// skipped entirely for such platforms.
|
||||||
wire_serves_metadata: bool,
|
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 {
|
const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
|
||||||
@@ -105,6 +121,8 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
login_label: None,
|
login_label: None,
|
||||||
models_dev_id: Some("kimi-for-coding"),
|
models_dev_id: Some("kimi-for-coding"),
|
||||||
wire_serves_metadata: true,
|
wire_serves_metadata: true,
|
||||||
|
wire_api: PlatformWireApi::ChatCompletions,
|
||||||
|
restrict_to_enriched: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
|
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)"),
|
login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),
|
||||||
models_dev_id: Some("moonshotai-cn"),
|
models_dev_id: Some("moonshotai-cn"),
|
||||||
wire_serves_metadata: true,
|
wire_serves_metadata: true,
|
||||||
|
wire_api: PlatformWireApi::ChatCompletions,
|
||||||
|
restrict_to_enriched: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
|
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)"),
|
login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.ai)"),
|
||||||
models_dev_id: Some("moonshotai"),
|
models_dev_id: Some("moonshotai"),
|
||||||
wire_serves_metadata: true,
|
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
|
/// The platform registry. Platforms are compiled-in spec rows; there is no
|
||||||
@@ -151,12 +197,19 @@ pub enum PlatformId {
|
|||||||
MoonshotCn,
|
MoonshotCn,
|
||||||
/// Moonshot AI open platform, api.moonshot.ai (API key).
|
/// Moonshot AI open platform, api.moonshot.ai (API key).
|
||||||
MoonshotAi,
|
MoonshotAi,
|
||||||
|
/// OpenAI platform API (API key, Responses dialect).
|
||||||
|
OpenAi,
|
||||||
}
|
}
|
||||||
|
|
||||||
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; 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
|
/// The registry row backing this platform (single source of per-platform
|
||||||
/// data; every accessor below reads it).
|
/// data; every accessor below reads it).
|
||||||
@@ -165,6 +218,7 @@ impl PlatformId {
|
|||||||
Self::KimiCode => &KIMI_CODE_SPEC,
|
Self::KimiCode => &KIMI_CODE_SPEC,
|
||||||
Self::MoonshotCn => &MOONSHOT_CN_SPEC,
|
Self::MoonshotCn => &MOONSHOT_CN_SPEC,
|
||||||
Self::MoonshotAi => &MOONSHOT_AI_SPEC,
|
Self::MoonshotAi => &MOONSHOT_AI_SPEC,
|
||||||
|
Self::OpenAi => &OPENAI_SPEC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +297,17 @@ impl PlatformId {
|
|||||||
pub fn wire_serves_metadata(self) -> bool {
|
pub fn wire_serves_metadata(self) -> bool {
|
||||||
self.spec().wire_serves_metadata
|
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
|
/// Split a managed catalog key `{platform_id}/{model_id}` back into its
|
||||||
@@ -573,9 +638,10 @@ mod tests {
|
|||||||
PlatformId::KimiCode => 0,
|
PlatformId::KimiCode => 0,
|
||||||
PlatformId::MoonshotCn => 1,
|
PlatformId::MoonshotCn => 1,
|
||||||
PlatformId::MoonshotAi => 2,
|
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();
|
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
|
||||||
seen.sort_unstable();
|
seen.sort_unstable();
|
||||||
seen.dedup();
|
seen.dedup();
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ fn deserialize_response_event(data: &str) -> Result<rs::ResponseStreamEvent> {
|
|||||||
Err(first_err) => {
|
Err(first_err) => {
|
||||||
// Try sanitizing: parse as Value, strip unknown tools, retry.
|
// Try sanitizing: parse as Value, strip unknown tools, retry.
|
||||||
if let Ok(mut value) = serde_json::from_str::<serde_json::Value>(data) {
|
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
|
// Strip tools that async_openai's rs::Tool can't deserialize
|
||||||
// (e.g., xAI-specific "x_search"). Instead of maintaining a
|
// (e.g., xAI-specific "x_search"). Instead of maintaining a
|
||||||
// hardcoded allowlist, try deserializing each tool entry —
|
// 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
|
// it in post-serialize. This is the last surviving piece of the
|
||||||
// old raw_output machinery.
|
// old raw_output machinery.
|
||||||
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
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 http_request = self.post(self.endpoint("responses")).json(&request_body);
|
||||||
|
|
||||||
let response = http_request.send().await.map_err(|e| {
|
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);
|
let raw_body = String::from_utf8_lossy(&bytes);
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error = %e,
|
error = %e,
|
||||||
@@ -1156,6 +1169,7 @@ impl SamplingClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
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`
|
// Fresh per attempt so signals never leak across retries; `None`
|
||||||
// (check disabled) sends no header and does no peek work per event.
|
// (check disabled) sends no header and does no peek work per event.
|
||||||
let doom_loop = self
|
let doom_loop = self
|
||||||
@@ -1687,6 +1701,7 @@ impl SamplingClient {
|
|||||||
let responses_request: rs::CreateResponse = (&request).into();
|
let responses_request: rs::CreateResponse = (&request).into();
|
||||||
|
|
||||||
let mut wrapper = CreateResponseWrapper::new(responses_request);
|
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_conv_id = x_kigi_conv_id;
|
||||||
wrapper.x_kigi_req_id = x_kigi_req_id;
|
wrapper.x_kigi_req_id = x_kigi_req_id;
|
||||||
wrapper.x_kigi_session_id = x_kigi_session_id;
|
wrapper.x_kigi_session_id = x_kigi_session_id;
|
||||||
@@ -1720,6 +1735,7 @@ impl SamplingClient {
|
|||||||
let responses_request: rs::CreateResponse = (&request).into();
|
let responses_request: rs::CreateResponse = (&request).into();
|
||||||
|
|
||||||
let mut wrapper = CreateResponseWrapper::new(responses_request);
|
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_conv_id = x_kigi_conv_id;
|
||||||
wrapper.x_kigi_req_id = x_kigi_req_id;
|
wrapper.x_kigi_req_id = x_kigi_req_id;
|
||||||
wrapper.x_kigi_session_id = x_kigi_session_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_key: None,
|
||||||
prompt_cache_retention: None,
|
prompt_cache_retention: None,
|
||||||
reasoning: Some(rs::Reasoning {
|
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),
|
summary: Some(rs::ReasoningSummary::Concise),
|
||||||
}),
|
}),
|
||||||
safety_identifier: None,
|
safety_identifier: None,
|
||||||
@@ -5191,6 +5194,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_responses_request_carries_reasoning_effort_nested() {
|
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 [
|
for (variant, expected) in [
|
||||||
(crate::ReasoningEffort::None, "none"),
|
(crate::ReasoningEffort::None, "none"),
|
||||||
(crate::ReasoningEffort::Minimal, "minimal"),
|
(crate::ReasoningEffort::Minimal, "minimal"),
|
||||||
@@ -5198,6 +5205,7 @@ mod tests {
|
|||||||
(crate::ReasoningEffort::Medium, "medium"),
|
(crate::ReasoningEffort::Medium, "medium"),
|
||||||
(crate::ReasoningEffort::High, "high"),
|
(crate::ReasoningEffort::High, "high"),
|
||||||
(crate::ReasoningEffort::Xhigh, "xhigh"),
|
(crate::ReasoningEffort::Xhigh, "xhigh"),
|
||||||
|
(crate::ReasoningEffort::Max, "max"),
|
||||||
] {
|
] {
|
||||||
let req = ConversationRequest {
|
let req = ConversationRequest {
|
||||||
reasoning_effort: Some(variant),
|
reasoning_effort: Some(variant),
|
||||||
@@ -5205,15 +5213,56 @@ mod tests {
|
|||||||
.with_model("test")
|
.with_model("test")
|
||||||
};
|
};
|
||||||
let resp: crate::rs::CreateResponse = (&req).into();
|
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!(
|
assert_eq!(
|
||||||
json.pointer("/reasoning/effort").and_then(|v| v.as_str()),
|
json.pointer("/reasoning/effort").and_then(|v| v.as_str()),
|
||||||
Some(expected),
|
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]
|
#[test]
|
||||||
fn test_responses_request_omits_effort_when_unset() {
|
fn test_responses_request_omits_effort_when_unset() {
|
||||||
let req =
|
let req =
|
||||||
|
|||||||
@@ -794,34 +794,11 @@ pub enum ReasoningEffort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReasoningEffort {
|
impl ReasoningEffort {
|
||||||
pub fn to_responses_api(self) -> crate::rs::ReasoningEffort {
|
/// The canonical effort behind a typed Responses-API echo
|
||||||
match self {
|
/// (`response.reasoning.effort`). `max` echoes never reach the typed
|
||||||
Self::None => crate::rs::ReasoningEffort::None,
|
/// enum — [`normalize_effort_echo`] drops them pre-parse (async-openai
|
||||||
Self::Minimal => crate::rs::ReasoningEffort::Minimal,
|
/// has no such variant); the request direction writes the wire string
|
||||||
Self::Low => crate::rs::ReasoningEffort::Low,
|
/// via [`patch_reasoning_effort`].
|
||||||
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.)
|
|
||||||
pub fn from_responses_api(effort: crate::rs::ReasoningEffort) -> Self {
|
pub fn from_responses_api(effort: crate::rs::ReasoningEffort) -> Self {
|
||||||
match effort {
|
match effort {
|
||||||
crate::rs::ReasoningEffort::None => Self::None,
|
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 REASONING_EFFORT_META_KEY: &str = "reasoningEffort";
|
||||||
pub const SUPPORTS_REASONING_EFFORT_META_KEY: &str = "supportsReasoningEffort";
|
pub const SUPPORTS_REASONING_EFFORT_META_KEY: &str = "supportsReasoningEffort";
|
||||||
|
|
||||||
@@ -1126,6 +1149,12 @@ pub struct CreateResponseWrapper {
|
|||||||
/// The inner Responses API request.
|
/// The inner Responses API request.
|
||||||
pub inner: crate::rs::CreateResponse,
|
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.
|
/// Custom header: conversation ID for tracking.
|
||||||
pub x_kigi_conv_id: Option<String>,
|
pub x_kigi_conv_id: Option<String>,
|
||||||
|
|
||||||
@@ -1152,6 +1181,7 @@ impl CreateResponseWrapper {
|
|||||||
pub fn new(inner: crate::rs::CreateResponse) -> Self {
|
pub fn new(inner: crate::rs::CreateResponse) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner,
|
inner,
|
||||||
|
reasoning_effort: None,
|
||||||
x_kigi_conv_id: None,
|
x_kigi_conv_id: None,
|
||||||
x_kigi_req_id: None,
|
x_kigi_req_id: None,
|
||||||
x_kigi_session_id: None,
|
x_kigi_session_id: None,
|
||||||
|
|||||||
@@ -583,7 +583,8 @@ mod tests {
|
|||||||
XAI_API_KEY_METHOD_ID,
|
XAI_API_KEY_METHOD_ID,
|
||||||
KIMI_CODE_METHOD_ID,
|
KIMI_CODE_METHOD_ID,
|
||||||
MOONSHOT_CN_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));
|
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
||||||
@@ -609,7 +610,8 @@ mod tests {
|
|||||||
CACHED_TOKEN_AUTH_METHOD_ID,
|
CACHED_TOKEN_AUTH_METHOD_ID,
|
||||||
KIMI_CODE_METHOD_ID,
|
KIMI_CODE_METHOD_ID,
|
||||||
MOONSHOT_CN_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));
|
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||||
@@ -628,7 +630,8 @@ mod tests {
|
|||||||
CACHED_TOKEN_AUTH_METHOD_ID,
|
CACHED_TOKEN_AUTH_METHOD_ID,
|
||||||
KIMI_CODE_METHOD_ID,
|
KIMI_CODE_METHOD_ID,
|
||||||
MOONSHOT_CN_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));
|
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||||
@@ -650,7 +653,8 @@ mod tests {
|
|||||||
vec![
|
vec![
|
||||||
KIMI_CODE_METHOD_ID,
|
KIMI_CODE_METHOD_ID,
|
||||||
MOONSHOT_CN_METHOD_ID,
|
MOONSHOT_CN_METHOD_ID,
|
||||||
MOONSHOT_AI_METHOD_ID
|
MOONSHOT_AI_METHOD_ID,
|
||||||
|
"openai"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), None);
|
assert_eq!(default_id(&built), None);
|
||||||
|
|||||||
@@ -57,8 +57,15 @@ fn registry_models_dev_ids() -> BTreeSet<&'static str> {
|
|||||||
.collect()
|
.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 {
|
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> {
|
fn refresh_url() -> Option<String> {
|
||||||
@@ -198,13 +205,21 @@ mod tests {
|
|||||||
|
|
||||||
/// Wire-served-only platform sets never trigger IO — and never force the
|
/// Wire-served-only platform sets never trigger IO — and never force the
|
||||||
/// bundled parse (empty owned catalog; the merge branch is gated off).
|
/// 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]
|
#[test]
|
||||||
fn wire_served_platforms_get_empty_catalog_without_io() {
|
fn wire_served_platforms_get_empty_catalog_without_io() {
|
||||||
assert!(!any_platform_needs_enrichment(
|
let wire_served = [
|
||||||
&kigi_models::PlatformId::ALL
|
kigi_models::PlatformId::KimiCode,
|
||||||
));
|
kigi_models::PlatformId::MoonshotCn,
|
||||||
let catalog = load_enrichment_catalog(&kigi_models::PlatformId::ALL);
|
kigi_models::PlatformId::MoonshotAi,
|
||||||
|
];
|
||||||
|
assert!(!any_platform_needs_enrichment(&wire_served));
|
||||||
|
let catalog = load_enrichment_catalog(&wire_served);
|
||||||
assert!(catalog.is_empty());
|
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 {
|
fn cache_file_in(dir: &tempfile::TempDir) -> std::path::PathBuf {
|
||||||
|
|||||||
@@ -141,6 +141,14 @@ impl PlatformApiKeys {
|
|||||||
}
|
}
|
||||||
Self { keys }
|
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(
|
pub(crate) fn task_model_error_for_catalog(
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ fn fetch_one_platform_models(
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
let listing: kigi_models::WireModelsResponse = response.json()?;
|
let listing: kigi_models::WireModelsResponse = response.json()?;
|
||||||
let total = listing.data.len();
|
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 {
|
if filtered.len() != total {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
platform = platform.as_str(),
|
platform = platform.as_str(),
|
||||||
@@ -293,6 +293,51 @@ fn fetch_one_platform_models(
|
|||||||
"applied platform model-prefix filter"
|
"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() {
|
let base_url = if platform.uses_oauth() {
|
||||||
endpoints.proxy_url()
|
endpoints.proxy_url()
|
||||||
} else {
|
} else {
|
||||||
@@ -329,8 +374,9 @@ fn fetch_one_platform_models(
|
|||||||
/// Map a live `think_efforts` block to catalog effort options. The wire
|
/// Map a live `think_efforts` block to catalog effort options. The wire
|
||||||
/// token stays the option id/label (`"max"` → label `"Max"`) so the UI
|
/// token stays the option id/label (`"max"` → label `"Max"`) so the UI
|
||||||
/// mirrors the server's vocabulary, while the canonical value maps through
|
/// mirrors the server's vocabulary, while the canonical value maps through
|
||||||
/// the [`kigi_sampling_types::ReasoningEffort`] parser (`"max"` → `Xhigh`).
|
/// the [`kigi_sampling_types::ReasoningEffort`] parser (`"max"` → `Max`
|
||||||
/// Unknown tokens are dropped with a warning rather than inventing a level.
|
/// since the Xhigh/Max split). Unknown tokens are dropped with a warning
|
||||||
|
/// rather than inventing a level.
|
||||||
fn think_efforts_to_options(
|
fn think_efforts_to_options(
|
||||||
think: &kigi_models::WireThinkEfforts,
|
think: &kigi_models::WireThinkEfforts,
|
||||||
) -> Vec<kigi_sampling_types::ReasoningEffortOption> {
|
) -> Vec<kigi_sampling_types::ReasoningEffortOption> {
|
||||||
@@ -379,6 +425,13 @@ pub(crate) fn platform_wire_model_to_entry(
|
|||||||
});
|
});
|
||||||
let env_key = (!platform.uses_oauth())
|
let env_key = (!platform.uses_oauth())
|
||||||
.then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied()));
|
.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 {
|
crate::agent::config::ModelEntryConfig {
|
||||||
id: Some(platform.managed_model_key(&wire.id)),
|
id: Some(platform.managed_model_key(&wire.id)),
|
||||||
name: Some(wire.display_name.clone().unwrap_or_else(|| wire.id.clone())),
|
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,
|
top_p: None,
|
||||||
api_key: None,
|
api_key: None,
|
||||||
env_key,
|
env_key,
|
||||||
api_backend: Default::default(),
|
api_backend,
|
||||||
auth_scheme: None,
|
auth_scheme: None,
|
||||||
reasoning_effort: think_efforts
|
reasoning_effort: think_efforts
|
||||||
.and_then(|t| t.default_effort.as_deref())
|
.and_then(|t| t.default_effort.as_deref())
|
||||||
@@ -662,6 +715,127 @@ fn get_string_map(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[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;
|
||||||
@@ -1161,6 +1335,10 @@ mod tests {
|
|||||||
platform_models_url(kigi_models::PlatformId::MoonshotAi, &cfg),
|
platform_models_url(kigi_models::PlatformId::MoonshotAi, &cfg),
|
||||||
"https://api.moonshot.ai/v1/models"
|
"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.
|
// Proxy override re-points the subscription platform only.
|
||||||
let proxied = EndpointsConfig::from_config_value(
|
let proxied = EndpointsConfig::from_config_value(
|
||||||
&toml::from_str(
|
&toml::from_str(
|
||||||
|
|||||||
@@ -160,18 +160,22 @@ struct KimiProviderToml {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Built-in kigi platform a kimi provider duplicates, if any: provider type
|
/// Built-in kigi platform a kimi provider duplicates, if any: provider type
|
||||||
/// `kimi` is the Kimi Code subscription channel; the two Moonshot open
|
/// `kimi` is the Kimi Code subscription channel; API-key platforms are
|
||||||
/// platforms are recognized by their fixed production hosts (the same hosts
|
/// recognized by their production hosts (the same hosts
|
||||||
/// `kigi_models::PlatformId::base_url` compiles in).
|
/// `kigi_models::PlatformId::base_url` compiles in — moonshot, openai, and
|
||||||
|
/// every future registry row automatically).
|
||||||
fn builtin_platform(provider: &KimiProviderToml) -> Option<PlatformId> {
|
fn builtin_platform(provider: &KimiProviderToml) -> Option<PlatformId> {
|
||||||
if provider.provider_type == "kimi" {
|
if provider.provider_type == "kimi" {
|
||||||
return Some(PlatformId::KimiCode);
|
return Some(PlatformId::KimiCode);
|
||||||
}
|
}
|
||||||
match url_host(&provider.base_url) {
|
let host = url_host(&provider.base_url)?;
|
||||||
Some("api.moonshot.cn") => Some(PlatformId::MoonshotCn),
|
PlatformId::ALL.into_iter().find(|platform| {
|
||||||
Some("api.moonshot.ai") => Some(PlatformId::MoonshotAi),
|
if platform.uses_oauth() {
|
||||||
_ => None,
|
return false;
|
||||||
}
|
}
|
||||||
|
let base = platform.base_url();
|
||||||
|
url_host(&base) == Some(host)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host component of an http(s) URL. `None` for other schemes.
|
/// Host component of an http(s) URL. `None` for other schemes.
|
||||||
|
|||||||
@@ -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(), 4, "3 login rows + Quit, got {items:?}");
|
assert_eq!(items.len(), 5, "4 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 {:?}",
|
||||||
@@ -6914,7 +6914,15 @@ pub(crate) mod tests {
|
|||||||
label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(),
|
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.
|
// 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 {
|
||||||
@@ -6925,7 +6933,7 @@ pub(crate) mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pending_menu_items(&byok.methods, None).len(),
|
pending_menu_items(&byok.methods, None).len(),
|
||||||
4,
|
5,
|
||||||
"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