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