F3: Kimi inference pipeline + full grok cloud-surface excision

Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
  dialect (thinking-field mapping, model_id stripping, empty-content
  tool-call message fix, stream_options.include_usage), with kimi-cli
  source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
  refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
  x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)

Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
  share command, credit bar: deleted (single local session lane;
  paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
  gate/tier/team/ZDR fields, app/subscription.rs watch loop,
  dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
  chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
  SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
  RPC; Kigi is zero-egress — nothing to share or retain remotely)

Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
  (https://www.kimi.com/code/authorize_device?user_code=..., verified
  against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
  URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
  wiremock fixture URLs into the real browser (root cause of the
  "garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
  kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
This commit is contained in:
2026-07-17 16:05:51 -04:00
parent fe1f885bb3
commit ea0ce9d15f
231 changed files with 4730 additions and 26358 deletions
@@ -666,10 +666,15 @@ impl TokenUsage {
impl From<Usage> for TokenUsage {
fn from(u: Usage) -> Self {
let cached_prompt_tokens = u
.prompt_tokens_details
.as_ref()
.map_or(0, |d| d.cached_tokens);
// Kimi/Moonshot deviation: prefer the top-level `cached_tokens` field
// when present, falling back to the OpenAI-standard
// `prompt_tokens_details.cached_tokens`. Same precedence as kimi-cli
// (packages/kosong/src/kosong/chat_provider/kimi.py:427-437).
let cached_prompt_tokens = u.cached_tokens.unwrap_or_else(|| {
u.prompt_tokens_details
.as_ref()
.map_or(0, |d| d.cached_tokens)
});
Self {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
+39 -70
View File
@@ -96,13 +96,9 @@ pub enum SamplingError {
status: StatusCode,
message: String,
model_metadata: Option<ResponseModelMetadata>,
/// Parsed from the `Retry-After` response header (seconds).
/// Parsed from the standard `Retry-After` response header (seconds).
/// The Kimi API only emits delta-seconds; HTTP-dates are ignored.
retry_after_secs: Option<u64>,
/// Parsed from the `x-should-retry` response header.
/// `Some(true)` = transient, retry may help.
/// `Some(false)` = request-content error, don't retry.
/// `None` = header absent (old server or non-proxy origin).
should_retry: Option<bool>,
},
#[error("reqwest error stream: {0}")]
EventStreamError(String),
@@ -271,14 +267,6 @@ impl SamplingError {
}
}
/// Server hint on whether this error is worth retrying.
pub fn should_retry_header(&self) -> Option<bool> {
match self {
SamplingError::Api { should_retry, .. } => *should_retry,
_ => None,
}
}
/// True when this error is a context-window/size overflow — deterministic,
/// so retrying the same payload can't help. See [`is_context_length_error`].
pub fn is_context_length_error(&self) -> bool {
@@ -304,7 +292,14 @@ impl From<serde_json::Error> for SamplingError {
}
}
/// OpenAI-standard provider error format: `{"error": {"message": "...", "type": "..."}}`.
/// Kimi/Moonshot (OpenAI-compatible) error body:
/// `{"error": {"message": "...", "type": "..."}}`.
///
/// This is the only error format the Kimi chat/completions endpoint emits —
/// the same shape the official client parses via the OpenAI SDK
/// (kimi-cli packages/kosong/src/kosong/chat_provider/openai_common.py:83-87
/// maps `openai.APIStatusError` → status + message). The old xAI proxy's
/// flat `{"code": "...", "error": "..."}` format was removed with the proxy.
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: ErrorBody,
@@ -317,36 +312,20 @@ struct ErrorBody {
kind: Option<String>,
}
/// Flat error from the Grok proxy/gateway: `{"code": "...", "error": "..."}`.
#[derive(Debug, Deserialize)]
struct FlatErrorResponse {
error: String,
#[serde(default)]
code: Option<String>,
}
/// Extract `(error_type, message)` from either error format.
/// Extract `(error_type, message)` from an OpenAI-compatible error body.
fn try_parse_error(data: &str) -> Option<(String, String)> {
if let Ok(resp) = serde_json::from_str::<ErrorResponse>(data) {
return Some((
resp.error.kind.unwrap_or_else(|| "unknown".to_string()),
resp.error
.message
.unwrap_or_else(|| "unknown error".to_string()),
));
}
if let Ok(flat) = serde_json::from_str::<FlatErrorResponse>(data) {
return Some((
flat.code.unwrap_or_else(|| "server_error".to_string()),
flat.error,
));
}
None
let resp = serde_json::from_str::<ErrorResponse>(data).ok()?;
Some((
resp.error.kind.unwrap_or_else(|| "unknown".to_string()),
resp.error
.message
.unwrap_or_else(|| "unknown error".to_string()),
))
}
pub fn parse_error_bytes(bytes: &[u8]) -> String {
if let Some((error_type, message)) = std::str::from_utf8(bytes).ok().and_then(try_parse_error) {
if error_type == "unknown" || error_type == "server_error" {
if error_type == "unknown" {
return message;
}
return format!("{error_type}: {message}");
@@ -420,7 +399,6 @@ mod tests {
message: "none: The prompt is too long for this model's context window.".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(api.is_context_length_error());
assert!(
@@ -489,20 +467,20 @@ mod tests {
);
}
/// Moonshot rate-limit body, OpenAI error shape (the format the Kimi
/// endpoints emit; see kimi-cli packages/kosong/src/kosong/chat_provider/chaos.py:88
/// for the reference 429 body used by the official client's chaos tests).
#[test]
fn try_parse_stream_error_flat_format() {
let data = r#"{"code":"The service is currently unavailable","error":"Service temporarily unavailable. The model did not respond to this request."}"#;
let err = try_parse_stream_error(data).expect("should parse flat error");
fn try_parse_stream_error_openai_format() {
let data = r#"{"error":{"message":"Your account is rate limited","type":"rate_limit_reached_error"}}"#;
let err = try_parse_stream_error(data).expect("should parse OpenAI-shaped error");
match err {
SamplingError::StreamError {
error_type,
message,
} => {
assert_eq!(error_type, "The service is currently unavailable");
assert_eq!(
message,
"Service temporarily unavailable. The model did not respond to this request."
);
assert_eq!(error_type, "rate_limit_reached_error");
assert_eq!(message, "Your account is rate limited");
}
other => panic!("expected StreamError, got {other:?}"),
}
@@ -518,13 +496,19 @@ mod tests {
}
#[test]
fn parse_error_bytes_flat_format() {
let bytes =
br#"{"code":"The service is currently unavailable","error":"Service temporarily unavailable."}"#;
let msg = parse_error_bytes(bytes);
fn parse_error_bytes_openai_format_prefixes_type() {
let bytes = br#"{"error":{"message":"Your account is rate limited","type":"rate_limit_reached_error"}}"#;
assert_eq!(
msg,
"The service is currently unavailable: Service temporarily unavailable."
parse_error_bytes(bytes),
"rate_limit_reached_error: Your account is rate limited"
);
}
#[test]
fn parse_error_bytes_non_json_falls_back_to_raw_text() {
assert_eq!(
parse_error_bytes(b" upstream exploded "),
"upstream exploded"
);
}
@@ -543,7 +527,6 @@ mod tests {
message: "Content violates usage guidelines.".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(
!err.is_auth_error(),
@@ -558,7 +541,6 @@ mod tests {
message: "Invalid or expired credentials".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(
err.is_auth_error(),
@@ -579,7 +561,6 @@ mod tests {
message: "Rate limit exceeded".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(err.is_rate_limited());
assert!(err.is_retryable(), "429 should be retryable");
@@ -594,7 +575,6 @@ mod tests {
message: "internal".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(!server_error.is_rate_limited());
@@ -612,7 +592,6 @@ mod tests {
message: "slow down".into(),
model_metadata: None,
retry_after_secs: Some(42),
should_retry: None,
};
assert_eq!(err.retry_after(), Some(42));
}
@@ -624,7 +603,6 @@ mod tests {
message: "slow down".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert_eq!(err.retry_after(), None);
}
@@ -645,7 +623,6 @@ mod tests {
message: "Could not decrypt the provided encrypted_content. Ensure the value is the unmodified encrypted_content from a previous response.".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(err.is_encrypted_content_error());
assert!(
@@ -661,7 +638,6 @@ mod tests {
message: "encrypted_content decryption failed".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(
!err.is_encrypted_content_error(),
@@ -676,7 +652,6 @@ mod tests {
message: "Invalid model parameter".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(
!err.is_encrypted_content_error(),
@@ -691,7 +666,6 @@ mod tests {
message: "Could not process image: unsupported format".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(err.is_image_processing_error());
assert!(!err.is_encrypted_content_error());
@@ -704,7 +678,6 @@ mod tests {
message: "upstream error: 400 Bad Request: Could not process image".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(err.is_image_processing_error());
}
@@ -716,7 +689,6 @@ mod tests {
message: "Invalid model parameter".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(!err.is_image_processing_error());
}
@@ -728,7 +700,6 @@ mod tests {
message: "internal server error".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(!err.is_image_processing_error());
}
@@ -740,7 +711,6 @@ mod tests {
message: "Could not process image".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(
!err.is_image_processing_error(),
@@ -755,7 +725,6 @@ mod tests {
message: "Could not process image".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
assert!(
!err.is_retryable(),
@@ -537,6 +537,15 @@ pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
/// Kimi/Moonshot deviation: the Moonshot chat/completions API reports
/// cache hits as a top-level `cached_tokens` field on `usage` instead of
/// the OpenAI-standard `prompt_tokens_details.cached_tokens`. Ported from
/// kimi-cli's parser (packages/kosong/src/kosong/chat_provider/kimi.py:427-437,
/// which checks the top-level field first and cites
/// platform.moonshot.cn/docs/api/chat). `From<Usage> for TokenUsage`
/// applies the same precedence.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cached_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_tokens_details: Option<PromptTokensDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -592,6 +601,14 @@ pub struct ChatChunkChoice {
pub delta: ChatChunkDelta,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<FinishReason>,
/// Kimi/Moonshot deviation: some Kimi deployments attach the final
/// `usage` object to the last *choice* instead of (or in addition to)
/// the chunk's top-level `usage`. Ported from kimi-cli's
/// `extract_usage_from_chunk`
/// (packages/kosong/src/kosong/chat_provider/kimi.py:522-533), which
/// falls back to `choices[0].usage` when `chunk.usage` is absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
/// Streaming delta for a tool call.