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
Generated
-13
View File
@@ -5785,7 +5785,6 @@ dependencies = [
"dunce", "dunce",
"kigi-tty-utils", "kigi-tty-utils",
"kigi-version", "kigi-version",
"prod-mc-cli-chat-proxy-types",
"ring", "ring",
"semver", "semver",
"serde", "serde",
@@ -6385,7 +6384,6 @@ dependencies = [
"libc", "libc",
"objc2 0.6.4", "objc2 0.6.4",
"parking_lot", "parking_lot",
"prod-mc-cli-chat-proxy-types",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
@@ -6490,7 +6488,6 @@ dependencies = [
"parking_lot", "parking_lot",
"portable-pty", "portable-pty",
"process-wrap", "process-wrap",
"prod-mc-cli-chat-proxy-types",
"prost", "prost",
"rand 0.9.5", "rand 0.9.5",
"regex", "regex",
@@ -9047,16 +9044,6 @@ dependencies = [
"hex", "hex",
] ]
[[package]]
name = "prod-mc-cli-chat-proxy-types"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"toml",
]
[[package]] [[package]]
name = "prodash" name = "prodash"
version = "31.0.0" version = "31.0.0"
+3 -13
View File
@@ -75,7 +75,6 @@ members = [
"crates/common/kigi-tool-runtime", "crates/common/kigi-tool-runtime",
"crates/common/kigi-tool-types", "crates/common/kigi-tool-types",
"crates/common/kigi-tracing", "crates/common/kigi-tracing",
"prod/mc/cli-chat-proxy-types",
"third_party/dagre_rust", "third_party/dagre_rust",
"third_party/graphlib_rust", "third_party/graphlib_rust",
"third_party/mermaid-to-svg", "third_party/mermaid-to-svg",
@@ -337,17 +336,6 @@ strip = false
debug = 1 debug = 1
split-debuginfo = "off" split-debuginfo = "off"
# Production profile for latency-sensitive x-product services (VF, home-mixer).
# Thin LTO gives ~90% of full LTO benefit at significantly faster link time.
# Keeps symbols + line tables for prod debuggability (perf, flamegraph, stack traces).
[profile.x-prod]
inherits = "release"
lto = "thin"
strip = false
codegen-units = 1
debug = "line-tables-only"
panic = "unwind"
# Desktop release profile. Functionally identical to release-dist — kept as a # Desktop release profile. Functionally identical to release-dist — kept as a
# named alias so the desktop workflow can reference it without coupling to the # named alias so the desktop workflow can reference it without coupling to the
# CLI pipeline's profile name. Alpha and stable share a single release-dist # CLI pipeline's profile name. Alpha and stable share a single release-dist
@@ -365,7 +353,9 @@ codegen-units = 128
debug = "line-tables-only" debug = "line-tables-only"
opt-level = 0 opt-level = 0
lto = false lto = false
incremental = true # Incremental caches balloon to tens of GB across this workspace's 60+ crates
# under iterative full-workspace builds; rebuild cost is the cheaper trade.
incremental = false
[profile.bench] [profile.bench]
debug = true debug = true
+29 -128
View File
@@ -35,7 +35,7 @@ use kigi_shell::leader::{
use kigi_shell::leader::{ControlPayload, LeaderClient, connect_or_spawn, default_socket_path}; use kigi_shell::leader::{ControlPayload, LeaderClient, connect_or_spawn, default_socket_path};
use kigi_tui::app::{ use kigi_tui::app::{
AgentCmd, Command, LeaderMgmtArgs, LeaderMgmtCommand, LeaderTargetArgs, PagerArgs, AgentCmd, Command, LeaderMgmtArgs, LeaderMgmtCommand, LeaderTargetArgs, PagerArgs,
join_early_prefetch, resolve_use_leader, resolve_use_leader,
}; };
use kigi_tui::app::{WorkspaceMgmtArgs, WorkspaceMgmtCommand, WorkspaceStartArgs}; use kigi_tui::app::{WorkspaceMgmtArgs, WorkspaceMgmtCommand, WorkspaceStartArgs};
use kigi_tui::client_identity::PAGER_CLIENT_VERSION; use kigi_tui::client_identity::PAGER_CLIENT_VERSION;
@@ -45,8 +45,8 @@ use std::net::SocketAddr;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
/// Apply global endpoint CLI args to an existing config. /// Apply global endpoint CLI args to an existing config.
fn apply_agent_endpoint_args(agent_args: &kigi_tui::app::AgentArgs, config: &mut AgentConfig) { fn apply_agent_endpoint_args(agent_args: &kigi_tui::app::AgentArgs, config: &mut AgentConfig) {
if let Some(v) = &agent_args.cli_chat_proxy_base_url { if let Some(v) = &agent_args.coding_api_base_url {
config.endpoints.cli_chat_proxy_base_url = Some(v.clone()); config.endpoints.coding_api_base_url = Some(v.clone());
} }
if let Some(v) = &agent_args.xai_api_base_url { if let Some(v) = &agent_args.xai_api_base_url {
config.endpoints.xai_api_base_url = v.clone(); config.endpoints.xai_api_base_url = v.clone();
@@ -324,43 +324,20 @@ fn ensure_control_caps(reg: &LeaderRegistration) -> Result<&LeaderCapabilities>
.as_ref() .as_ref()
.ok_or_else(|| anyhow::anyhow!("Leader does not advertise capabilities (legacy version)")) .ok_or_else(|| anyhow::anyhow!("Leader does not advertise capabilities (legacy version)"))
} }
/// Env override for the `grok workspace` gate: any truthy value enables the /// Env override for the `kigi workspace` gate: any truthy value enables the
/// command locally, a falsy one disables it — bypassing the remote settings flag. /// command locally, a falsy one disables it. This is the only gate now that
/// the server-side feature flag (xAI remote settings) is gone.
const WORKSPACE_COMMAND_ENV: &str = "KIGI_WORKSPACE_COMMAND"; const WORKSPACE_COMMAND_ENV: &str = "KIGI_WORKSPACE_COMMAND";
/// Resolution of the `grok workspace` gate. `Unknown` is kept separate from
/// `Disabled` so we don't tell the user the flag is off when the settings were
/// simply never read (both fail closed, but `Unknown` earns an honest message).
#[derive(Debug, PartialEq, Eq)]
enum WorkspaceGate {
Enabled,
Disabled,
Unknown,
}
/// The `KIGI_WORKSPACE_COMMAND` override, if set (`Some(true)`/`Some(false)`); /// The `KIGI_WORKSPACE_COMMAND` override, if set (`Some(true)`/`Some(false)`);
/// `None` defers to the remote settings flag. /// `None` means unset (the command stays disabled by default).
fn workspace_command_env_override() -> Option<bool> { fn workspace_command_env_override() -> Option<bool> {
std::env::var(WORKSPACE_COMMAND_ENV) std::env::var(WORKSPACE_COMMAND_ENV)
.ok() .ok()
.map(|v| env_flag_enabled(&v)) .map(|v| env_flag_enabled(&v))
} }
/// Resolve the gate. Precedence: env override > remote `Some(true)` > /// Resolve the gate: enabled exactly when the env override says so.
/// loaded-but-off (`Disabled`) > settings-not-loaded (`Unknown`). fn workspace_command_gate(env_override: Option<bool>) -> bool {
fn workspace_command_gate( env_override.unwrap_or(false)
env_override: Option<bool>,
remote_settings: Option<&kigi_shell::util::config::RemoteSettings>,
) -> WorkspaceGate {
if let Some(enabled) = env_override {
return if enabled {
WorkspaceGate::Enabled
} else {
WorkspaceGate::Disabled
};
}
match remote_settings {
Some(rs) if rs.workspace_command_enabled.unwrap_or(false) => WorkspaceGate::Enabled,
Some(_) => WorkspaceGate::Disabled,
None => WorkspaceGate::Unknown,
}
} }
/// Truthy parse for grok on/off env vars: everything enables except the common /// Truthy parse for grok on/off env vars: everything enables except the common
/// falsy spellings (`0`, `false`, `off`, `no`, empty). /// falsy spellings (`0`, `false`, `off`, `no`, empty).
@@ -370,40 +347,16 @@ fn env_flag_enabled(value: &str) -> bool {
"" | "0" | "false" | "off" | "no" "" | "0" | "false" | "off" | "no"
) )
} }
/// Blocking fetch of remote settings via the startup prefetch path.
fn fetch_remote_settings() -> Option<kigi_shell::util::config::RemoteSettings> {
join_early_prefetch(kigi_shell::agent::models::start_early_prefetch(None))
}
async fn run_workspace_mgmt(args: WorkspaceMgmtArgs) -> Result<()> { async fn run_workspace_mgmt(args: WorkspaceMgmtArgs) -> Result<()> {
let env_override = workspace_command_env_override(); if !workspace_command_gate(workspace_command_env_override()) {
let remote_settings = if env_override.is_none() {
fetch_remote_settings()
} else {
None
};
match workspace_command_gate(env_override, remote_settings.as_ref()) {
WorkspaceGate::Enabled => {}
WorkspaceGate::Disabled => {
anyhow::bail!( anyhow::bail!(
"`grok workspace` is not enabled for this account \ "`kigi workspace` is experimental and disabled by default. \
(gated by a server-side feature flag that is currently off)." Set {WORKSPACE_COMMAND_ENV}=1 to enable it."
) )
} }
WorkspaceGate::Unknown => {
anyhow::bail!(
"Could not load your settings for `grok workspace`. Check your \
network connection (run `grok login` if you are signed out), then \
try again."
)
}
}
match args.command { match args.command {
WorkspaceMgmtCommand::Start(a) => { WorkspaceMgmtCommand::Start(a) => workspace_start(a, false).await,
workspace_start(a, false, remote_settings.or_else(fetch_remote_settings)).await WorkspaceMgmtCommand::Restart(a) => workspace_start(a, true).await,
}
WorkspaceMgmtCommand::Restart(a) => {
workspace_start(a, true, remote_settings.or_else(fetch_remote_settings)).await
}
WorkspaceMgmtCommand::Pause { target, json } => { WorkspaceMgmtCommand::Pause { target, json } => {
workspace_control(&target, json, ControlCommand::WorkspacePause).await workspace_control(&target, json, ControlCommand::WorkspacePause).await
} }
@@ -467,24 +420,13 @@ async fn workspace_control(
client.cancel(); client.cancel();
Ok(()) Ok(())
} }
async fn workspace_start( async fn workspace_start(args: WorkspaceStartArgs, restart: bool) -> Result<()> {
args: WorkspaceStartArgs,
restart: bool,
remote_settings: Option<kigi_shell::util::config::RemoteSettings>,
) -> Result<()> {
use kigi_shell::auth::ensure_authenticated; use kigi_shell::auth::ensure_authenticated;
kigi_shell::util::config::set_remote_campaigns_from_settings(remote_settings.as_ref());
let raw_config = kigi_shell::config::load_effective_config() let raw_config = kigi_shell::config::load_effective_config()
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?; .map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let agent_config = AgentConfig::new_from_toml_cfg(&raw_config) let agent_config = AgentConfig::new_from_toml_cfg(&raw_config)
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?; .map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
let (use_leader, _) = resolve_use_leader( let (use_leader, _) = resolve_use_leader(args.leader, args.no_leader, &raw_config, true);
args.leader,
args.no_leader,
&raw_config,
remote_settings.as_ref(),
true,
);
if !use_leader { if !use_leader {
anyhow::bail!( anyhow::bail!(
"`grok workspace` requires leader mode (the workspace is shared via the leader).\n\ "`grok workspace` requires leader mode (the workspace is shared via the leader).\n\
@@ -949,7 +891,9 @@ async fn run_agent_command(
} }
} }
} }
let early_prefetch = kigi_shell::agent::models::start_early_prefetch(None); // Fire-and-forget model-catalog warmup (nothing joins the handle now that
// the xAI settings fetch it used to carry is gone).
drop(kigi_shell::agent::models::start_early_prefetch(None));
kigi_shell::agent::mvp_agent::warm_async_http_client(); kigi_shell::agent::mvp_agent::warm_async_http_client();
tokio::task::spawn_blocking(|| {}); tokio::task::spawn_blocking(|| {});
let is_stdio = matches!(agent_args.mode, AgentCmd::Stdio); let is_stdio = matches!(agent_args.mode, AgentCmd::Stdio);
@@ -972,8 +916,6 @@ async fn run_agent_command(
.ok(); .ok();
} }
} }
let remote_settings = join_early_prefetch(early_prefetch);
kigi_shell::util::config::set_remote_campaigns_from_settings(remote_settings.as_ref());
let raw_config = kigi_shell::config::load_effective_config() let raw_config = kigi_shell::config::load_effective_config()
.map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))?;
let mut agent_config = AgentConfig::new_from_toml_cfg(&raw_config) let mut agent_config = AgentConfig::new_from_toml_cfg(&raw_config)
@@ -1008,10 +950,9 @@ async fn run_agent_command(
agent_config.plugins.cli_plugin_dirs = agent_args.canonical_plugin_dirs(); agent_config.plugins.cli_plugin_dirs = agent_args.canonical_plugin_dirs();
} }
apply_agent_endpoint_args(&agent_args, &mut agent_config); apply_agent_endpoint_args(&agent_args, &mut agent_config);
agent_config.remote_settings = remote_settings.clone();
agent_config.resolve_runtime_fields(&kigi_shell::agent::config::RuntimeResolutionContext { agent_config.resolve_runtime_fields(&kigi_shell::agent::config::RuntimeResolutionContext {
raw_config: &raw_config, raw_config: &raw_config,
remote_settings: remote_settings.as_ref(), remote_settings: None,
cwd: None, cwd: None,
is_headless: !is_leader, is_headless: !is_leader,
cli_subagents: None, cli_subagents: None,
@@ -1030,7 +971,6 @@ async fn run_agent_command(
agent_args.leader, agent_args.leader,
agent_args.no_leader, agent_args.no_leader,
&raw_config, &raw_config,
remote_settings.as_ref(),
leader_eligible, leader_eligible,
); );
tracing::info!(use_leader, ?policy_disable_reason, "leader mode resolved"); tracing::info!(use_leader, ?policy_disable_reason, "leader mode resolved");
@@ -1492,9 +1432,10 @@ async fn async_main() -> Result<()> {
unsafe { std::env::set_var("KIGI_COMPACTION_DETAIL", detail) }; unsafe { std::env::set_var("KIGI_COMPACTION_DETAIL", detail) };
} }
if args.chat() { if args.chat() {
unsafe { anyhow::bail!(
std::env::set_var(kigi_shell::agent::chat_modes::KIGI_CHAT_MODE_ENV, "1"); "--chat is no longer supported: the grok.com chat frontend it drove was \
} removed along with the xAI backend."
);
} }
if let Some(ref socket) = args.leader_socket { if let Some(ref socket) = args.leader_socket {
unsafe { std::env::set_var(kigi_shell::leader::LEADER_SOCKET_ENV, socket) }; unsafe { std::env::set_var(kigi_shell::leader::LEADER_SOCKET_ENV, socket) };
@@ -1632,19 +1573,7 @@ async fn async_main() -> Result<()> {
} }
Command::Sessions(sessions_args) => { Command::Sessions(sessions_args) => {
init_tracing_simple("cli"); init_tracing_simple("cli");
let config = kigi_shell::config::load_effective_config_disk_only() return kigi_tui::sessions_cmd::run(sessions_args).await;
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let agent_config = AgentConfig::new_from_toml_cfg(&config)
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
return kigi_tui::sessions_cmd::run(sessions_args, &agent_config).await;
}
Command::Share(ref share_args) => {
init_tracing_simple("cli");
let config = kigi_shell::config::load_effective_config_disk_only()
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let agent_config = AgentConfig::new_from_toml_cfg(&config)
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
return kigi_tui::share_cmd::run(share_args, &agent_config).await;
} }
Command::Export(export_args) => { Command::Export(export_args) => {
init_tracing_simple("cli"); init_tracing_simple("cli");
@@ -2130,37 +2059,9 @@ mod tests {
} }
#[test] #[test]
fn workspace_command_gate_resolution() { fn workspace_command_gate_resolution() {
use kigi_shell::util::config::RemoteSettings; assert!(workspace_command_gate(Some(true)));
let on = RemoteSettings { assert!(!workspace_command_gate(Some(false)));
workspace_command_enabled: Some(true), assert!(!workspace_command_gate(None), "unset env defaults to off");
..RemoteSettings::default()
};
let off = RemoteSettings::default();
assert_eq!(
workspace_command_gate(None, Some(&on)),
WorkspaceGate::Enabled
);
assert_eq!(
workspace_command_gate(None, Some(&off)),
WorkspaceGate::Disabled
);
assert_eq!(workspace_command_gate(None, None), WorkspaceGate::Unknown);
assert_eq!(
workspace_command_gate(Some(true), Some(&off)),
WorkspaceGate::Enabled
);
assert_eq!(
workspace_command_gate(Some(true), None),
WorkspaceGate::Enabled
);
assert_eq!(
workspace_command_gate(Some(false), Some(&on)),
WorkspaceGate::Disabled
);
assert_eq!(
workspace_command_gate(Some(false), None),
WorkspaceGate::Disabled
);
} }
#[serial_test::serial(KIGI_WORKSPACE_COMMAND)] #[serial_test::serial(KIGI_WORKSPACE_COMMAND)]
#[test] #[test]
@@ -123,8 +123,6 @@ pub struct Credentials {
pub auth_type: AuthType, pub auth_type: AuthType,
/// Optional extra auth material forwarded with requests when present. /// Optional extra auth material forwarded with requests when present.
pub alpha_test_key: Option<String>, pub alpha_test_key: Option<String>,
/// Client version string.
pub client_version: Option<String>,
} }
/// The messages captured during a single conversation turn. /// The messages captured during a single conversation turn.
@@ -282,12 +282,6 @@ pub struct RemoteSettings {
pub dream_min_sessions: Option<u64>, pub dream_min_sessions: Option<u64>,
#[serde(default)] #[serde(default)]
pub dream_check_interval_secs: Option<u64>, pub dream_check_interval_secs: Option<u64>,
/// Cadence (seconds) of the pager's free→paid subscription watch.
/// `0` disables it; the pager clamps and defaults (see its
/// `app::subscription` module). Forwarded from the `grok_build_settings`
/// remote settings flag via the CCP `/settings` flatten catch-all.
#[serde(default)]
pub subscription_watch_interval_secs: Option<u64>,
#[serde(default)] #[serde(default)]
pub writeback_enabled: Option<bool>, pub writeback_enabled: Option<bool>,
/// OAuth2 provider issuer URL (e.g., "https://auth.x.ai"). When present /// OAuth2 provider issuer URL (e.g., "https://auth.x.ai"). When present
@@ -601,10 +595,6 @@ pub struct RemoteSettings {
/// is a separate client tier gate. /// is a separate client tier gate.
#[serde(default)] #[serde(default)]
pub voice_mode_enabled: Option<bool>, pub voice_mode_enabled: Option<bool>,
/// Whether ZDR (Zero Data Retention) users are allowed to use the product.
/// Controlled via remote settings. Default `false` (blocked) during beta.
#[serde(default)]
pub zdr_access_enabled: Option<bool>,
/// remote settings tier of the `remember_tool_approvals` gate (whether per-tool /// remote settings tier of the `remember_tool_approvals` gate (whether per-tool
/// "Always allow …" prompt options are shown). Lowest precedence; typically /// "Always allow …" prompt options are shown). Lowest precedence; typically
/// targeted per-org. Default `false`. /// targeted per-org. Default `false`.
@@ -652,33 +642,10 @@ pub struct RemoteSettings {
/// `"default"`). Used only when no effective TOML permission key is set. /// `"default"`). Used only when no effective TOML permission key is set.
#[serde(default)] #[serde(default)]
pub permission_mode: Option<String>, pub permission_mode: Option<String>,
/// User's subscription tier from remote settings `grok_build_access_gate`.
/// E.g. "free", "premium", "supergrok", "supergrok_heavy".
/// Stamped on analytics events + user profile for filtering.
#[serde(default)]
pub subscription_tier: Option<String>,
#[serde(default)]
pub gate_message: Option<String>,
#[serde(default)]
pub gate_url: Option<String>,
#[serde(default)]
pub gate_label: Option<String>,
/// Whether the session picker groups entries by repo name. /// Whether the session picker groups entries by repo name.
/// When `None` or `Some(false)`, sessions are shown in a flat list. /// When `None` or `Some(false)`, sessions are shown in a flat list.
#[serde(default)] #[serde(default)]
pub session_picker_grouped: Option<bool>, pub session_picker_grouped: Option<bool>,
/// Whether the user is allowed to use Grok Build. Set by remote settings
/// `grok_build_access_gate` targeting rules. `None` = no server response
/// yet (client uses own fallback check). `Some(false)` = blocked.
#[serde(default)]
pub allow_access: Option<bool>,
/// User-friendly display name for the current subscription tier
/// (e.g. "SuperGrok", "X Premium+", "Free", "API Key"). Set by CCP
/// from the JWT tier claim (OAuth) or credential kind (API key).
/// Free/Invalid OAuth → `"Free"`; API keys → `"API Key"` (Mixpanel
/// `api_key`, never free).
#[serde(default)]
pub subscription_tier_display: Option<String>,
/// Whether on-demand credit usage is enabled. When `Some(false)`, the /// Whether on-demand credit usage is enabled. When `Some(false)`, the
/// billing extension blocks on-demand cap changes. /// billing extension blocks on-demand cap changes.
#[serde(default)] #[serde(default)]
-2
View File
@@ -9,8 +9,6 @@ description = "Shared config loading for Grok — kigi_home, effective config (r
base64 = { workspace = true } base64 = { workspace = true }
blake3 = { workspace = true } blake3 = { workspace = true }
dunce = { workspace = true } dunce = { workspace = true }
# Shared signed deployment-config envelope contract with the cli-chat-proxy signer.
prod-mc-cli-chat-proxy-types = { path = "../../../prod/mc/cli-chat-proxy-types" }
ring = { workspace = true } ring = { workspace = true }
semver = { workspace = true } semver = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@@ -8,7 +8,64 @@
//! Inert until a public key is provisioned: with no embedded keys the cache //! Inert until a public key is provisioned: with no embedded keys the cache
//! marker stays the (best-effort) authority. //! marker stays the (best-effort) authority.
use base64::Engine; use base64::Engine;
pub use prod_mc_cli_chat_proxy_types::{SignatureEnvelope, SignedPayload, now_unix}; use serde::{Deserialize, Serialize};
/// The payload format version the server currently signs. Bump when the payload
/// gains semantics (e.g. an anti-replay counter or a key-fingerprint binding) so
/// verifiers can distinguish generations; `0` means a pre-versioned payload.
pub const SIGNED_PAYLOAD_VERSION: u32 = 1;
/// The exact bytes the server signs: the served policy, the principal it is
/// bound to, and an expiry. Serialized once on the server and shipped verbatim
/// as `signed_payload`, so the client verifies the received bytes directly
/// instead of re-canonicalizing (no cross-language serialization drift).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignedPayload {
/// Payload format version ([`SIGNED_PAYLOAD_VERSION`]); `default` 0 so
/// pre-versioned sidecars parse and verify unchanged.
#[serde(default)]
pub version: u32,
#[serde(default)]
pub deployment_id: Option<String>,
#[serde(default)]
pub team_id: Option<String>,
#[serde(default)]
pub managed_config: Option<String>,
#[serde(default)]
pub requirements: Option<String>,
/// Strict (fail-closed) opt-in, carried in the SIGNED bytes so a local actor can't
/// flip enforcement. `default` false so an older/unsigned payload stays lenient.
#[serde(default)]
pub fail_closed: bool,
/// Unix seconds after which the signature is no longer trusted.
pub expires_at: u64,
/// Identifies the signing key, so a rotation can be distinguished.
pub key_id: String,
}
/// One signed envelope carried alongside the legacy policy fields in the
/// deployment-config response (additive: old clients ignore it). Also the
/// shape the client persists as its on-disk signature sidecar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignatureEnvelope {
/// The exact JSON string that was signed (a serialized [`SignedPayload`]).
pub signed_payload: String,
/// Base64 (standard) Ed25519 signature over `signed_payload`'s UTF-8 bytes.
pub signature: String,
/// Untrusted (outside the signed bytes): a hint for picking among multiple
/// envelopes, never for selecting the verifying key — only the signed
/// payload's `key_id` is authoritative.
#[serde(default)]
pub key_id: String,
}
/// Unix seconds now (saturating to 0 on a pre-epoch clock).
pub fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Compiled-in trusted Ed25519 public keys, `(key_id, raw 32 bytes)`; more than one /// Compiled-in trusted Ed25519 public keys, `(key_id, raw 32 bytes)`; more than one
/// entry only during a rotation. Empty ships dark (see [`verification_active`]). /// entry only during a rotation. Empty ships dark (see [`verification_active`]).
/// Compile-time, not an env flag: the local attacker controls their env. /// Compile-time, not an env flag: the local attacker controls their env.
@@ -956,3 +956,28 @@ fn rotation_selects_the_trusted_key_by_signed_key_id() {
Err(SigError::SignatureMismatch) Err(SigError::SignatureMismatch)
); );
} }
/// The version field round-trips, and a pre-versioned payload (no `version`
/// key) defaults to 0 — old sidecars keep parsing.
#[test]
fn signed_payload_version_round_trips_and_defaults() {
let versioned = SignedPayload {
version: SIGNED_PAYLOAD_VERSION,
deployment_id: None,
team_id: Some("team-007".into()),
managed_config: None,
requirements: None,
fail_closed: false,
expires_at: 4_000_000_000,
key_id: "v1".into(),
};
let json = serde_json::to_string(&versioned).unwrap();
assert_eq!(
serde_json::from_str::<SignedPayload>(&json).unwrap(),
versioned
);
let legacy: SignedPayload =
serde_json::from_str(r#"{"expires_at": 1, "key_id": "v1"}"#).unwrap();
assert_eq!(legacy.version, 0, "pre-versioned payloads default to 0");
}
+15 -5
View File
@@ -7,11 +7,21 @@ use crate::loader::{apply_version_overrides_with_registered, load_toml_file};
use crate::paths::{system_config_dir, user_kigi_home}; use crate::paths::{system_config_dir, user_kigi_home};
use crate::version_overrides::{VersionOverrideError, apply_version_overrides}; use crate::version_overrides::{VersionOverrideError, apply_version_overrides};
use prod_mc_cli_chat_proxy_types::FAIL_CLOSED_KEY; /// The `requirements.toml` opt-in key for strict (fail-closed) enforcement.
/// The canonical opt-in key + string parse live in the shared types crate, next to /// Lives next to the signed payload that carries the flag
/// the signed payload that carries the flag, so the server-side signer and this /// ([`crate::signed_policy::SignedPayload::fail_closed`]) so the two sides
/// client parse the same semantics. /// can't drift.
pub use prod_mc_cli_chat_proxy_types::fail_closed_flag_from_str; pub const FAIL_CLOSED_KEY: &str = "fail_closed";
/// Read the `fail_closed` opt-in from a requirements-TOML string — THE canonical
/// parse shared by every caller so the semantics can't drift.
/// Invalid TOML or a non-bool value → `false`.
pub fn fail_closed_flag_from_str(requirements: &str) -> bool {
toml::from_str::<toml::Value>(requirements)
.ok()
.and_then(|v| v.get(FAIL_CLOSED_KEY).and_then(toml::Value::as_bool))
.unwrap_or(false)
}
/// Read the `fail_closed` opt-in from a parsed requirements layer — same semantics as /// Read the `fail_closed` opt-in from a parsed requirements layer — same semantics as
/// [`fail_closed_flag_from_str`]. Env tightening (file vs `KIGI_MANAGED_CONFIG_FAIL_CLOSED`) /// [`fail_closed_flag_from_str`]. Env tightening (file vs `KIGI_MANAGED_CONFIG_FAIL_CLOSED`)
+9 -29
View File
@@ -176,7 +176,7 @@ pub fn process_user_agent_string() -> String {
UserAgent { UserAgent {
origin, origin,
agent_product: "grok-shell", agent_product: "kigi",
agent_version, agent_version,
platform: PlatformInfo::current(), platform: PlatformInfo::current(),
} }
@@ -186,7 +186,7 @@ pub fn process_user_agent_string() -> String {
pub fn session_user_agent_string(origin: &OriginClientInfo) -> String { pub fn session_user_agent_string(origin: &OriginClientInfo) -> String {
UserAgent { UserAgent {
origin: origin.clone(), origin: origin.clone(),
agent_product: "grok-shell", agent_product: "kigi",
agent_version: agent_version(), agent_version: agent_version(),
platform: PlatformInfo::current(), platform: PlatformInfo::current(),
} }
@@ -234,29 +234,9 @@ pub fn client_type_from_origin(origin: Option<&OriginClientInfo>) -> ClientType
ClientType::from_client_identifier(origin.map(|o| o.product.as_str())) ClientType::from_client_identifier(origin.map(|o| o.product.as_str()))
} }
/// Process-level client identifier (`KIGI_CLIENT_NAME` env var, default `"grok-shell"`). /// Process-level client identifier (`KIGI_CLIENT_NAME` env var, default `"kigi"`).
pub fn process_client_identifier() -> String { pub fn process_client_identifier() -> String {
std::env::var("KIGI_CLIENT_NAME").unwrap_or_else(|_| "grok-shell".to_string()) std::env::var("KIGI_CLIENT_NAME").unwrap_or_else(|_| "kigi".to_string())
}
/// Header telling cli-chat-proxy whether this process is a single-prompt
/// (`grok -p`) run or an interactive session; feeds the `client_mode`
/// metric label.
pub const CLIENT_MODE_HEADER: &str = "x-grok-client-mode";
/// One-way latch: set to `"headless"` at startup by the non-TUI entry points
/// (`run_single_turn` for `grok -p`, `run_headless_inner` for
/// `grok agent [headless]`), `"interactive"` otherwise.
static CLIENT_MODE: OnceLock<&'static str> = OnceLock::new();
/// Mark this process as headless (single-prompt). No-op if already set.
pub fn set_process_client_mode_headless() {
let _ = CLIENT_MODE.set("headless");
}
/// The mode sent in [`CLIENT_MODE_HEADER`]; defaults to `"interactive"`.
pub fn process_client_mode() -> &'static str {
CLIENT_MODE.get().copied().unwrap_or("interactive")
} }
pub fn user_agent_string_for(origin: &OriginClientInfo) -> String { pub fn user_agent_string_for(origin: &OriginClientInfo) -> String {
@@ -568,14 +548,14 @@ mod tests {
product: "grok-desktop".to_string(), product: "grok-desktop".to_string(),
version: Some("1.2.3".to_string()), version: Some("1.2.3".to_string()),
}); });
assert!(with_version.starts_with("grok-desktop/1.2.3 grok-shell/")); assert!(with_version.starts_with("grok-desktop/1.2.3 kigi/"));
assert!(with_version.contains(" (")); assert!(with_version.contains(" ("));
let without_version = session_user_agent_string(&OriginClientInfo { let without_version = session_user_agent_string(&OriginClientInfo {
product: "grok-web".to_string(), product: "grok-web".to_string(),
version: None, version: None,
}); });
assert!(without_version.starts_with("grok-web grok-shell/")); assert!(without_version.starts_with("grok-web kigi/"));
assert!(!without_version.starts_with("grok-web/")); assert!(!without_version.starts_with("grok-web/"));
} }
@@ -583,10 +563,10 @@ mod tests {
fn user_agent_render_collapses_duplicate_origin_and_agent_identity() { fn user_agent_render_collapses_duplicate_origin_and_agent_identity() {
let ua = UserAgent { let ua = UserAgent {
origin: OriginClientInfo { origin: OriginClientInfo {
product: "grok-shell".to_string(), product: "kigi".to_string(),
version: Some("0.1.171".to_string()), version: Some("0.1.171".to_string()),
}, },
agent_product: "grok-shell", agent_product: "kigi",
agent_version: "0.1.171".to_string(), agent_version: "0.1.171".to_string(),
platform: PlatformInfo { platform: PlatformInfo {
os: "macos".to_string(), os: "macos".to_string(),
@@ -594,6 +574,6 @@ mod tests {
}, },
}; };
assert_eq!(ua.render(), "grok-shell/0.1.171 (macos; aarch64)"); assert_eq!(ua.render(), "kigi/0.1.171 (macos; aarch64)");
} }
} }
+12 -8
View File
@@ -141,7 +141,7 @@ pub(super) fn render_auth(buf: &mut Buffer, area: Rect, theme: &Theme, hint: &Mi
area, area,
y, y,
bottom, bottom,
Line::from(Span::styled("Sign in to Grok", bold)), Line::from(Span::styled("Sign in to Kimi", bold)),
); );
y = put_line(buf, area, y, bottom, Line::default()); y = put_line(buf, area, y, bottom, Line::default());
match url { match url {
@@ -242,12 +242,13 @@ mod tests {
#[test] #[test]
fn device_user_code_parses_verification_url() { fn device_user_code_parses_verification_url() {
// The live Kimi device flow returns this exact URL shape.
assert_eq!( assert_eq!(
device_user_code("https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH"), device_user_code("https://www.kimi.com/code/authorize_device?user_code=ABCD-EFGH"),
Some("ABCD-EFGH") Some("ABCD-EFGH")
); );
assert_eq!( assert_eq!(
device_user_code("https://accounts.x.ai/oauth2/device"), device_user_code("https://www.kimi.com/code/authorize_device"),
None None
); );
assert_eq!(device_user_code("https://x/device?other=1"), None); assert_eq!(device_user_code("https://x/device?other=1"), None);
@@ -261,14 +262,14 @@ mod tests {
let st = AuthState::Authenticating { let st = AuthState::Authenticating {
request_seq: 1, request_seq: 1,
handle: None, handle: None,
auth_url: Some("https://accounts.x.ai/device?user_code=ABCD-EFGH".into()), auth_url: Some("https://www.kimi.com/code/authorize_device?user_code=ABCD-EFGH".into()),
mode: AuthMode::Device, mode: AuthMode::Device,
}; };
match minimal_auth_hint(&st) { match minimal_auth_hint(&st) {
MinimalAuthHint::SigningIn { url, code } => { MinimalAuthHint::SigningIn { url, code } => {
assert_eq!( assert_eq!(
url.as_deref(), url.as_deref(),
Some("https://accounts.x.ai/device?user_code=ABCD-EFGH") Some("https://www.kimi.com/code/authorize_device?user_code=ABCD-EFGH")
); );
assert_eq!(code.as_deref(), Some("ABCD-EFGH")); assert_eq!(code.as_deref(), Some("ABCD-EFGH"));
} }
@@ -308,7 +309,7 @@ mod tests {
let area = Rect::new(0, 0, 80, 12); let area = Rect::new(0, 0, 80, 12);
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
let hint = MinimalAuthHint::SigningIn { let hint = MinimalAuthHint::SigningIn {
url: Some("https://accounts.x.ai/device?user_code=ABCD-EFGH".into()), url: Some("https://www.kimi.com/code/authorize_device?user_code=ABCD-EFGH".into()),
code: Some("ABCD-EFGH".into()), code: Some("ABCD-EFGH".into()),
}; };
render_auth(&mut buf, area, &theme, &hint); render_auth(&mut buf, area, &theme, &hint);
@@ -320,8 +321,11 @@ mod tests {
} }
} }
} }
assert!(text.contains("Sign in to Grok"), "header: {text:?}"); assert!(text.contains("Sign in to Kimi"), "header: {text:?}");
assert!(text.contains("accounts.x.ai/device"), "url: {text:?}"); assert!(
text.contains("www.kimi.com/code/authorize_device"),
"url: {text:?}"
);
assert!(text.contains("ABCD-EFGH"), "device code: {text:?}"); assert!(text.contains("ABCD-EFGH"), "device code: {text:?}");
assert!( assert!(
text.contains("Waiting for approval"), text.contains("Waiting for approval"),
@@ -90,7 +90,7 @@ impl ContentController {
// config.toml when $HOME alone isn't sufficient (e.g. if // config.toml when $HOME alone isn't sufficient (e.g. if
// KIGI_SHARE_DIR is set in the test runner's env). // KIGI_SHARE_DIR is set in the test runner's env).
("KIGI_SHARE_DIR".into(), kigi_home), ("KIGI_SHARE_DIR".into(), kigi_home),
("KIGI_CLI_CHAT_PROXY_BASE_URL".into(), self.url()), ("KIGI_CODE_BASE_URL".into(), self.url()),
("KIGI_XAI_API_BASE_URL".into(), self.url()), ("KIGI_XAI_API_BASE_URL".into(), self.url()),
("XAI_API_KEY".into(), "test-key-for-ci".into()), ("XAI_API_KEY".into(), "test-key-for-ci".into()),
("KIGI_TELEMETRY_ENABLED".into(), "false".into()), ("KIGI_TELEMETRY_ENABLED".into(), "false".into()),
@@ -278,7 +278,7 @@ mod tests {
get("KIGI_SHARE_DIR").as_deref(), get("KIGI_SHARE_DIR").as_deref(),
content.home().join(".kigi").to_str() content.home().join(".kigi").to_str()
); );
assert_eq!(get("KIGI_CLI_CHAT_PROXY_BASE_URL"), Some(content.url())); assert_eq!(get("KIGI_CODE_BASE_URL"), Some(content.url()));
assert_eq!(get("KIGI_XAI_API_BASE_URL"), Some(content.url())); assert_eq!(get("KIGI_XAI_API_BASE_URL"), Some(content.url()));
assert_eq!(get("XAI_API_KEY").as_deref(), Some("test-key-for-ci")); assert_eq!(get("XAI_API_KEY").as_deref(), Some("test-key-for-ci"));
assert_eq!(get("KIGI_TELEMETRY_ENABLED").as_deref(), Some("false")); assert_eq!(get("KIGI_TELEMETRY_ENABLED").as_deref(), Some("false"));
+1 -1
View File
@@ -3,7 +3,7 @@ license = "Apache-2.0"
name = "kigi-sampler" name = "kigi-sampler"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
description = "Actor-based sampling/inference layer for xAI grok (HTTP streaming + retry, no shell coupling)" description = "Actor-based sampling/inference layer for the Kimi APIs (HTTP streaming + retry, no shell coupling)"
[dependencies] [dependencies]
# Internal # Internal
@@ -373,12 +373,9 @@ async fn apply_retry_decision(
RetryDecision::Fatal(fatal_err) => { RetryDecision::Fatal(fatal_err) => {
// Emit only on true budget exhaustion (hit the retry / rate-limit // Emit only on true budget exhaustion (hit the retry / rate-limit
// cap), mirroring `classify_error`'s Fatal conditions — NOT on a // cap), mirroring `classify_error`'s Fatal conditions — NOT on a
// server `x-should-retry: false` or a non-retryable error, which // non-retryable error, which is also Fatal but not "exhausted".
// are also Fatal but are not "exhausted".
let next_attempt = *retry_count + 1; let next_attempt = *retry_count + 1;
let server_said_stop = matches!(err.should_retry_header(), Some(false)); let budget_exhausted = if err.is_rate_limited() {
let budget_exhausted = !server_said_stop
&& if err.is_rate_limited() {
next_attempt >= max_retries.min(rate_limit_threshold) next_attempt >= max_retries.min(rate_limit_threshold)
} else { } else {
err.is_retryable() && next_attempt >= max_retries err.is_retryable() && next_attempt >= max_retries
@@ -609,7 +606,6 @@ fn synthesize_from_info(info: &SamplingErrorInfo) -> SamplingError {
message: info.message.clone(), message: info.message.clone(),
model_metadata: info.model_metadata.clone(), model_metadata: info.model_metadata.clone(),
retry_after_secs: info.retry_after_secs, retry_after_secs: info.retry_after_secs,
should_retry: None,
} }
} }
SamplingErrorKind::EmptyResponse => { SamplingErrorKind::EmptyResponse => {
@@ -97,10 +97,6 @@ mod tests {
idle_timeout_secs: None, idle_timeout_secs: None,
reasoning_effort: None, reasoning_effort: None,
origin_client: None, origin_client: None,
client_identifier: None,
deployment_id: None,
user_id: None,
client_version: None,
attribution_callback: None, attribution_callback: None,
bearer_resolver: None, bearer_resolver: None,
supports_backend_search: false, supports_backend_search: false,
+58 -263
View File
@@ -1,16 +1,20 @@
//! HTTP client for the xAI sampling APIs. //! HTTP client for the sampling APIs.
//! //!
//! Owns the `reqwest::Client`, default request headers, and per-method //! Owns the `reqwest::Client`, default request headers, and per-method
//! defaults. Talks to three backend shapes: //! defaults. Talks to three backend shapes:
//! //!
//! * Chat Completions (`/chat/completions`) //! * Chat Completions (`/chat/completions`) — the Kimi dialect; both
//! * Responses API (`/responses`) //! product channels (subscription OAuth, Moonshot API key) ride it.
//! * Anthropic Messages API (`/messages`) //! Kimi-specific request deviations are absorbed by [`crate::kimi_compat`].
//! * Responses API (`/responses`) — kept for custom providers.
//! * Anthropic Messages API (`/messages`) — kept for custom providers.
//! //!
//! All trace-upload and URL-based header injection is intentionally //! Auth on the wire is a plain `Authorization: Bearer {token}` (or
//! *not* here. The session is responsible for putting any per-request //! `x-api-key` for Anthropic-scheme custom providers). All URL-based
//! headers (proxy auth, OTel context, etc.) //! header injection is intentionally *not* here. The session is
//! into [`SamplerConfig::extra_headers`] before constructing the client. //! responsible for putting any per-request headers (device identity,
//! OTel context, etc.) into [`SamplerConfig::extra_headers`] before
//! constructing the client.
use eventsource_stream::Eventsource; use eventsource_stream::Eventsource;
use futures_util::StreamExt; use futures_util::StreamExt;
@@ -33,46 +37,10 @@ use crate::config::{AuthScheme, OriginClientInfo, SamplerConfig};
// Re-export ApiBackend from the shared types crate for downstream callers. // Re-export ApiBackend from the shared types crate for downstream callers.
pub use kigi_sampling_types::ApiBackend; pub use kigi_sampling_types::ApiBackend;
/// Process-level fallback for the `x-grok-client-identifier` header. /// Product identifier baked into User-Agent strings (PRD F3: `kigi/{version}`).
const DEFAULT_CLIENT_IDENTIFIER: &str = "grok-shell"; const AGENT_PRODUCT: &str = "kigi";
/// Product identifier baked into User-Agent strings.
const AGENT_PRODUCT: &str = "grok-shell";
const ANTHROPIC_DEFAULT_MAX_TOKENS: u32 = 128_000; const ANTHROPIC_DEFAULT_MAX_TOKENS: u32 = 128_000;
/// Per-request `x-grok-*` headers. Optional fields are skipped when empty/`None`.
struct GrokRequestHeaders<'a> {
conv_id: &'a str,
req_id: &'a str,
model_id: &'a str,
session_id: &'a str,
turn_idx: Option<&'a str>,
agent_id: &'a str,
deployment_id: Option<&'a str>,
user_id: Option<&'a str>,
}
impl GrokRequestHeaders<'_> {
fn apply(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let mut b = builder
.header("x-grok-conv-id", self.conv_id)
.header("x-grok-req-id", self.req_id)
.header("x-grok-model-override", self.model_id)
.header("x-grok-session-id", self.session_id)
.header("x-grok-agent-id", self.agent_id);
if let Some(idx) = self.turn_idx {
b = b.header("x-grok-turn-idx", idx);
}
if let Some(id) = self.deployment_id.filter(|s| !s.is_empty()) {
b = b.header("x-grok-deployment-id", id);
}
if let Some(id) = self.user_id.filter(|s| !s.is_empty()) {
b = b.header("x-grok-user-id", id);
}
b
}
}
/// Parse the `Retry-After` response header as delta-seconds. /// Parse the `Retry-After` response header as delta-seconds.
/// Our inference backends only emit integer seconds (never HTTP-date), /// Our inference backends only emit integer seconds (never HTTP-date),
/// so we only handle that form. HTTP-dates silently return `None` and /// so we only handle that form. HTTP-dates silently return `None` and
@@ -211,21 +179,6 @@ fn extract_retry_after(headers: &reqwest::header::HeaderMap) -> Option<u64> {
.map(|s| s.min(120)) .map(|s| s.min(120))
} }
fn extract_should_retry(headers: &reqwest::header::HeaderMap) -> Option<bool> {
headers
.get("x-should-retry")
.and_then(|v| v.to_str().ok())
.and_then(|s| {
if s.eq_ignore_ascii_case("true") {
Some(true)
} else if s.eq_ignore_ascii_case("false") {
Some(false)
} else {
None // unknown value — treat as absent
}
})
}
fn extract_model_metadata(headers: &reqwest::header::HeaderMap) -> Option<ResponseModelMetadata> { fn extract_model_metadata(headers: &reqwest::header::HeaderMap) -> Option<ResponseModelMetadata> {
let context_window = headers let context_window = headers
.get("x-grok-context-window") .get("x-grok-context-window")
@@ -444,45 +397,11 @@ impl SamplingClient {
headers.insert(header_name, header_value); headers.insert(header_name, header_value);
} }
// Add x-grok-client-version header for version gating at the proxy.
if let Some(client_version) = config.client_version.as_ref()
&& let Ok(header_value) = HeaderValue::from_str(client_version)
{
headers.insert(
HeaderName::from_static("x-grok-client-version"),
header_value,
);
}
if let Some(deployment_id) = config.deployment_id.as_ref()
&& let Ok(header_value) = HeaderValue::from_str(deployment_id)
{
headers.insert(
HeaderName::from_static("x-grok-deployment-id"),
header_value,
);
}
if let Some(user_id) = config.user_id.as_ref()
&& let Ok(header_value) = HeaderValue::from_str(user_id)
{
headers.insert(HeaderName::from_static("x-grok-user-id"), header_value);
}
{
let client_id = config
.client_identifier
.clone()
.unwrap_or_else(|| DEFAULT_CLIENT_IDENTIFIER.to_string());
if let Ok(header_value) = HeaderValue::from_str(&client_id) {
headers.insert(
HeaderName::from_static("x-grok-client-identifier"),
header_value,
);
}
}
// Always set User-Agent: per-session origin if available, else fallback. // Always set User-Agent: per-session origin if available, else fallback.
// This and `extra_headers` are the only client-identity signals on the
// wire — the old xAI proxy's `x-grok-*` marker headers are gone
// (PRD F3: auth is a plain bearer; kimi-cli sends only User-Agent
// plus the OAuth device headers, src/kimi_cli/llm.py:317-323).
{ {
let ua_string = match config.origin_client.as_ref() { let ua_string = match config.origin_client.as_ref() {
Some(origin) => user_agent_string_for(origin), Some(origin) => user_agent_string_for(origin),
@@ -689,13 +608,7 @@ impl SamplingClient {
} }
/// Build request headers string for error messages (redacting sensitive values). /// Build request headers string for error messages (redacting sensitive values).
fn format_request_headers( fn format_request_headers(&self, include_accept: bool) -> Vec<String> {
&self,
x_grok_conv_id: &str,
x_grok_req_id: &str,
model_id: &str,
include_accept: bool,
) -> Vec<String> {
let mut req_headers: Vec<String> = self let mut req_headers: Vec<String> = self
.default_headers .default_headers
.iter() .iter()
@@ -704,9 +617,6 @@ impl SamplingClient {
}) })
.collect(); .collect();
req_headers.push(Self::format_header("x-grok-conv-id", x_grok_conv_id));
req_headers.push(Self::format_header("x-grok-req-id", x_grok_req_id));
req_headers.push(Self::format_header("x-grok-model-override", model_id));
if include_accept { if include_accept {
req_headers.push(Self::format_header("accept", "text/event-stream")); req_headers.push(Self::format_header("accept", "text/event-stream"));
} }
@@ -799,7 +709,6 @@ impl SamplingClient {
let status = response.status(); let status = response.status();
let model_metadata = extract_model_metadata(response.headers()); let model_metadata = extract_model_metadata(response.headers());
let retry_after_secs = extract_retry_after(response.headers()); let retry_after_secs = extract_retry_after(response.headers());
let should_retry = extract_should_retry(response.headers());
let bytes = response.bytes().await?; let bytes = response.bytes().await?;
if !status.is_success() { if !status.is_success() {
@@ -816,7 +725,6 @@ impl SamplingClient {
message, message,
model_metadata, model_metadata,
retry_after_secs, retry_after_secs,
should_retry,
}); });
} }
@@ -841,8 +749,6 @@ impl SamplingClient {
request: ChatCompletionRequest, request: ChatCompletionRequest,
) -> Result<ChatCompletionResponse> { ) -> Result<ChatCompletionResponse> {
let payload = self.apply_defaults(request)?; let payload = self.apply_defaults(request)?;
let x_grok_conv_id = &payload.x_grok_conv_id.clone().unwrap_or_default();
let x_grok_req_id = &payload.x_grok_req_id.clone().unwrap_or_default();
let model_id = payload.model.clone().unwrap_or_default(); let model_id = payload.model.clone().unwrap_or_default();
tracing::debug!( tracing::debug!(
@@ -851,19 +757,18 @@ impl SamplingClient {
"Sending chat completion request" "Sending chat completion request"
); );
let grok_headers = GrokRequestHeaders { // Serialize, then run the Kimi dialect adaptations (single
conv_id: x_grok_conv_id, // adaptation point for every request-side deviation; see
req_id: x_grok_req_id, // `crate::kimi_compat`).
model_id: &model_id, let mut request_body = serde_json::to_value(&payload).map_err(|e| {
session_id: payload.x_grok_session_id.as_deref().unwrap_or_default(), tracing::error!("Failed to serialize chat/completions request: {}", e);
turn_idx: payload.x_grok_turn_idx.as_deref(), SamplingError::Serialization(e)
agent_id: payload.x_grok_agent_id.as_deref().unwrap_or_default(), })?;
deployment_id: payload.x_grok_deployment_id.as_deref(), crate::kimi_compat::adapt_chat_completions_body(&mut request_body);
user_id: payload.x_grok_user_id.as_deref(),
}; let http_request = self
let http_request = grok_headers .post(self.endpoint("chat/completions"))
.apply(self.post(self.endpoint("chat/completions"))) .json(&request_body);
.json(&payload);
let response = http_request.send().await.map_err(|e| { let response = http_request.send().await.map_err(|e| {
// Log at debug level; errors are surfaced to the caller. // Log at debug level; errors are surfaced to the caller.
@@ -894,13 +799,14 @@ impl SamplingClient {
Option<ResponseModelMetadata>, Option<ResponseModelMetadata>,
)> { )> {
let payload = self.apply_defaults(request)?; let payload = self.apply_defaults(request)?;
let x_grok_conv_id = &payload.x_grok_conv_id.clone().unwrap_or_default();
let x_grok_req_id = &payload.x_grok_req_id.clone().unwrap_or_default();
let model_id = payload.model.clone().unwrap_or_default(); let model_id = payload.model.clone().unwrap_or_default();
// Wrap the request with streaming fields and serialize once. // Wrap the request with the streaming fields the Kimi API expects
// Previously this path serialized twice: first to serde_json::Value // (`stream: true` + `stream_options.include_usage: true`, exactly
// (to inject `stream` and `stream_options`), then to HTTP body bytes. // what kimi-cli sends —
// packages/kosong/src/kosong/chat_provider/kimi.py:174-181), then
// serialize and run the Kimi dialect adaptations (single adaptation
// point for every request-side deviation; see `crate::kimi_compat`).
let streaming_request = StreamingChatRequest { let streaming_request = StreamingChatRequest {
inner: &payload, inner: &payload,
stream: true, stream: true,
@@ -908,21 +814,16 @@ impl SamplingClient {
include_usage: true, include_usage: true,
}, },
}; };
let mut request_body = serde_json::to_value(&streaming_request).map_err(|e| {
tracing::error!("Failed to serialize chat/completions request: {}", e);
SamplingError::Serialization(e)
})?;
crate::kimi_compat::adapt_chat_completions_body(&mut request_body);
let grok_headers = GrokRequestHeaders { let http_request = self
conv_id: x_grok_conv_id, .post(self.endpoint("chat/completions"))
req_id: x_grok_req_id,
model_id: &model_id,
session_id: payload.x_grok_session_id.as_deref().unwrap_or_default(),
turn_idx: payload.x_grok_turn_idx.as_deref(),
agent_id: payload.x_grok_agent_id.as_deref().unwrap_or_default(),
deployment_id: payload.x_grok_deployment_id.as_deref(),
user_id: payload.x_grok_user_id.as_deref(),
};
let http_request = grok_headers
.apply(self.post(self.endpoint("chat/completions")))
.header(ACCEPT, HeaderValue::from_static("text/event-stream")) .header(ACCEPT, HeaderValue::from_static("text/event-stream"))
.json(&streaming_request); .json(&request_body);
let built_request = http_request.build().map_err(|e| { let built_request = http_request.build().map_err(|e| {
tracing::error!("Failed to build HTTP request: {}", e); tracing::error!("Failed to build HTTP request: {}", e);
@@ -948,7 +849,6 @@ impl SamplingClient {
span.record("success", status.is_success()); span.record("success", status.is_success());
let model_metadata = extract_model_metadata(response.headers()); let model_metadata = extract_model_metadata(response.headers());
let retry_after_secs = extract_retry_after(response.headers()); let retry_after_secs = extract_retry_after(response.headers());
let should_retry = extract_should_retry(response.headers());
if !status.is_success() { if !status.is_success() {
if status == reqwest::StatusCode::UNAUTHORIZED { if status == reqwest::StatusCode::UNAUTHORIZED {
span.record("error", "unauthorized (401)"); span.record("error", "unauthorized (401)");
@@ -962,8 +862,7 @@ impl SamplingClient {
))); )));
} }
let req_headers = let req_headers = self.format_request_headers(true);
self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, true);
let resp_headers = Self::format_response_headers(&response); let resp_headers = Self::format_response_headers(&response);
let bytes = response.bytes().await?; let bytes = response.bytes().await?;
let server_message = parse_error_bytes(bytes.as_ref()); let server_message = parse_error_bytes(bytes.as_ref());
@@ -988,7 +887,6 @@ impl SamplingClient {
message, message,
model_metadata, model_metadata,
retry_after_secs, retry_after_secs,
should_retry,
}); });
} }
@@ -1111,8 +1009,6 @@ impl SamplingClient {
) -> Result<rs::Response> { ) -> Result<rs::Response> {
self.apply_response_defaults(&mut request)?; self.apply_response_defaults(&mut request)?;
let x_grok_conv_id = request.x_grok_conv_id.as_deref().unwrap_or_default();
let x_grok_req_id = request.x_grok_req_id.as_deref().unwrap_or_default();
let model_id = request.inner.model.clone().unwrap_or_default(); let model_id = request.inner.model.clone().unwrap_or_default();
// The trace field is process-local: it is consumed by upstream // The trace field is process-local: it is consumed by upstream
@@ -1123,16 +1019,6 @@ impl SamplingClient {
tracing::debug!("create_response: {:?}", &request); tracing::debug!("create_response: {:?}", &request);
tracing::debug!("endpoint: {:?}", self.endpoint("responses")); tracing::debug!("endpoint: {:?}", self.endpoint("responses"));
let grok_headers = GrokRequestHeaders {
conv_id: x_grok_conv_id,
req_id: x_grok_req_id,
model_id: &model_id,
session_id: request.x_grok_session_id.as_deref().unwrap_or_default(),
turn_idx: request.x_grok_turn_idx.as_deref(),
agent_id: request.x_grok_agent_id.as_deref().unwrap_or_default(),
deployment_id: request.x_grok_deployment_id.as_deref(),
user_id: request.x_grok_user_id.as_deref(),
};
let mut request_body = serde_json::to_value(&request.inner).map_err(|e| { let mut request_body = serde_json::to_value(&request.inner).map_err(|e| {
tracing::error!("Failed to serialize responses request: {}", e); tracing::error!("Failed to serialize responses request: {}", e);
SamplingError::Serialization(e) SamplingError::Serialization(e)
@@ -1142,9 +1028,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);
let http_request = grok_headers let http_request = self.post(self.endpoint("responses")).json(&request_body);
.apply(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| {
tracing::debug!("HTTP request failed: {}", e); tracing::debug!("HTTP request failed: {}", e);
@@ -1154,7 +1038,6 @@ impl SamplingClient {
let status = response.status(); let status = response.status();
let model_metadata = extract_model_metadata(response.headers()); let model_metadata = extract_model_metadata(response.headers());
let retry_after_secs = extract_retry_after(response.headers()); let retry_after_secs = extract_retry_after(response.headers());
let should_retry = extract_should_retry(response.headers());
let bytes = response.bytes().await?; let bytes = response.bytes().await?;
if !status.is_success() { if !status.is_success() {
@@ -1167,8 +1050,7 @@ impl SamplingClient {
))); )));
} }
let req_headers = let req_headers = self.format_request_headers(false);
self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, false);
let server_message = parse_error_bytes(bytes.as_ref()); let server_message = parse_error_bytes(bytes.as_ref());
let message = self.build_api_error_message( let message = self.build_api_error_message(
@@ -1189,7 +1071,6 @@ impl SamplingClient {
message, message,
model_metadata, model_metadata,
retry_after_secs, retry_after_secs,
should_retry,
}); });
} }
@@ -1245,8 +1126,6 @@ impl SamplingClient {
// Enable streaming // Enable streaming
request.inner.stream = Some(true); request.inner.stream = Some(true);
let x_grok_conv_id = request.x_grok_conv_id.as_deref().unwrap_or_default();
let x_grok_req_id = request.x_grok_req_id.as_deref().unwrap_or_default();
let model_id = request.inner.model.clone().unwrap_or_default(); let model_id = request.inner.model.clone().unwrap_or_default();
// Drop process-local trace data (see note in `create_response`). // Drop process-local trace data (see note in `create_response`).
@@ -1258,16 +1137,6 @@ impl SamplingClient {
"Sending responses API stream request" "Sending responses API stream request"
); );
let grok_headers = GrokRequestHeaders {
conv_id: x_grok_conv_id,
req_id: x_grok_req_id,
model_id: &model_id,
session_id: request.x_grok_session_id.as_deref().unwrap_or_default(),
turn_idx: request.x_grok_turn_idx.as_deref(),
agent_id: request.x_grok_agent_id.as_deref().unwrap_or_default(),
deployment_id: request.x_grok_deployment_id.as_deref(),
user_id: request.x_grok_user_id.as_deref(),
};
let extra_raw_tools = std::mem::take(&mut request.extra_raw_tools); let extra_raw_tools = std::mem::take(&mut request.extra_raw_tools);
let mut request_body = serde_json::to_value(&request.inner).map_err(|e| { let mut request_body = serde_json::to_value(&request.inner).map_err(|e| {
tracing::error!("Failed to serialize responses request: {}", e); tracing::error!("Failed to serialize responses request: {}", e);
@@ -1293,8 +1162,8 @@ impl SamplingClient {
.defaults .defaults
.doom_loop_recovery .doom_loop_recovery
.map(crate::doom_loop::DoomLoopSignalCollector::new); .map(crate::doom_loop::DoomLoopSignalCollector::new);
let mut http_request = grok_headers let mut http_request = self
.apply(self.post(self.endpoint("responses"))) .post(self.endpoint("responses"))
.header(ACCEPT, HeaderValue::from_static("text/event-stream")); .header(ACCEPT, HeaderValue::from_static("text/event-stream"));
if doom_loop.is_some() { if doom_loop.is_some() {
// Presence opts in; the server ignores the value. // Presence opts in; the server ignores the value.
@@ -1336,9 +1205,7 @@ impl SamplingClient {
} }
let model_metadata = extract_model_metadata(response.headers()); let model_metadata = extract_model_metadata(response.headers());
let retry_after_secs = extract_retry_after(response.headers()); let retry_after_secs = extract_retry_after(response.headers());
let should_retry = extract_should_retry(response.headers()); let req_headers = self.format_request_headers(true);
let req_headers =
self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, true);
let resp_headers = Self::format_response_headers(&response); let resp_headers = Self::format_response_headers(&response);
let bytes = response.bytes().await?; let bytes = response.bytes().await?;
let server_message = parse_error_bytes(bytes.as_ref()); let server_message = parse_error_bytes(bytes.as_ref());
@@ -1363,7 +1230,6 @@ impl SamplingClient {
message, message,
model_metadata, model_metadata,
retry_after_secs, retry_after_secs,
should_retry,
}); });
} }
@@ -1480,8 +1346,6 @@ impl SamplingClient {
) -> Result<messages::MessagesResponse> { ) -> Result<messages::MessagesResponse> {
self.apply_message_defaults(&mut request)?; self.apply_message_defaults(&mut request)?;
let x_grok_conv_id = request.x_grok_conv_id.as_deref().unwrap_or_default();
let x_grok_req_id = request.x_grok_req_id.as_deref().unwrap_or_default();
let model_id = request.inner.model.clone(); let model_id = request.inner.model.clone();
// Drop process-local trace data. // Drop process-local trace data.
@@ -1490,19 +1354,7 @@ impl SamplingClient {
tracing::debug!("create_message: {:?}", &request.inner); tracing::debug!("create_message: {:?}", &request.inner);
tracing::debug!("endpoint: {:?}", self.endpoint("messages")); tracing::debug!("endpoint: {:?}", self.endpoint("messages"));
let grok_headers = GrokRequestHeaders { let http_request = self.post(self.endpoint("messages")).json(&request.inner);
conv_id: x_grok_conv_id,
req_id: x_grok_req_id,
model_id: &model_id,
session_id: request.x_grok_session_id.as_deref().unwrap_or_default(),
turn_idx: request.x_grok_turn_idx.as_deref(),
agent_id: request.x_grok_agent_id.as_deref().unwrap_or_default(),
deployment_id: request.x_grok_deployment_id.as_deref(),
user_id: request.x_grok_user_id.as_deref(),
};
let http_request = grok_headers
.apply(self.post(self.endpoint("messages")))
.json(&request.inner);
let response = http_request.send().await.map_err(|e| { let response = http_request.send().await.map_err(|e| {
tracing::debug!("HTTP request failed: {}", e); tracing::debug!("HTTP request failed: {}", e);
@@ -1512,7 +1364,6 @@ impl SamplingClient {
let status = response.status(); let status = response.status();
let model_metadata = extract_model_metadata(response.headers()); let model_metadata = extract_model_metadata(response.headers());
let retry_after_secs = extract_retry_after(response.headers()); let retry_after_secs = extract_retry_after(response.headers());
let should_retry = extract_should_retry(response.headers());
let bytes = response.bytes().await?; let bytes = response.bytes().await?;
if !status.is_success() { if !status.is_success() {
@@ -1525,8 +1376,7 @@ impl SamplingClient {
))); )));
} }
let req_headers = let req_headers = self.format_request_headers(false);
self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, false);
let server_message = parse_error_bytes(bytes.as_ref()); let server_message = parse_error_bytes(bytes.as_ref());
let message = self.build_api_error_message( let message = self.build_api_error_message(
@@ -1547,7 +1397,6 @@ impl SamplingClient {
message, message,
model_metadata, model_metadata,
retry_after_secs, retry_after_secs,
should_retry,
}); });
} }
@@ -1593,8 +1442,6 @@ impl SamplingClient {
// Enable streaming // Enable streaming
request.inner.stream = Some(true); request.inner.stream = Some(true);
let x_grok_conv_id = request.x_grok_conv_id.as_deref().unwrap_or_default();
let x_grok_req_id = request.x_grok_req_id.as_deref().unwrap_or_default();
let model_id = request.inner.model.clone(); let model_id = request.inner.model.clone();
// Drop process-local trace data. // Drop process-local trace data.
@@ -1606,18 +1453,8 @@ impl SamplingClient {
"Sending Messages API stream request" "Sending Messages API stream request"
); );
let grok_headers = GrokRequestHeaders { let http_request = self
conv_id: x_grok_conv_id, .post(self.endpoint("messages"))
req_id: x_grok_req_id,
model_id: &model_id,
session_id: request.x_grok_session_id.as_deref().unwrap_or_default(),
turn_idx: request.x_grok_turn_idx.as_deref(),
agent_id: request.x_grok_agent_id.as_deref().unwrap_or_default(),
deployment_id: request.x_grok_deployment_id.as_deref(),
user_id: request.x_grok_user_id.as_deref(),
};
let http_request = grok_headers
.apply(self.post(self.endpoint("messages")))
.header(ACCEPT, HeaderValue::from_static("text/event-stream")) .header(ACCEPT, HeaderValue::from_static("text/event-stream"))
.json(&request.inner); .json(&request.inner);
@@ -1655,9 +1492,7 @@ impl SamplingClient {
} }
let model_metadata = extract_model_metadata(response.headers()); let model_metadata = extract_model_metadata(response.headers());
let retry_after_secs = extract_retry_after(response.headers()); let retry_after_secs = extract_retry_after(response.headers());
let should_retry = extract_should_retry(response.headers()); let req_headers = self.format_request_headers(true);
let req_headers =
self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, true);
let resp_headers = Self::format_response_headers(&response); let resp_headers = Self::format_response_headers(&response);
let bytes = response.bytes().await?; let bytes = response.bytes().await?;
let server_message = parse_error_bytes(bytes.as_ref()); let server_message = parse_error_bytes(bytes.as_ref());
@@ -1682,7 +1517,6 @@ impl SamplingClient {
message, message,
model_metadata, model_metadata,
retry_after_secs, retry_after_secs,
should_retry,
}); });
} }
@@ -2002,7 +1836,6 @@ impl SamplingClient {
message: info.message, message: info.message,
model_metadata: info.model_metadata, model_metadata: info.model_metadata,
retry_after_secs: info.retry_after_secs, retry_after_secs: info.retry_after_secs,
should_retry: None,
}) })
} }
} }
@@ -2031,10 +1864,6 @@ mod tests {
idle_timeout_secs: None, idle_timeout_secs: None,
reasoning_effort: None, reasoning_effort: None,
origin_client: None, origin_client: None,
client_identifier: None,
deployment_id: None,
user_id: None,
client_version: None,
attribution_callback: None, attribution_callback: None,
bearer_resolver: None, bearer_resolver: None,
supports_backend_search: false, supports_backend_search: false,
@@ -2146,40 +1975,6 @@ mod tests {
assert_eq!(extract_retry_after(&headers), None); assert_eq!(extract_retry_after(&headers), None);
} }
#[test]
fn extract_should_retry_true() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("x-should-retry", "true".parse().unwrap());
assert_eq!(extract_should_retry(&headers), Some(true));
}
#[test]
fn extract_should_retry_true_case_insensitive() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("x-should-retry", "TRUE".parse().unwrap());
assert_eq!(extract_should_retry(&headers), Some(true));
}
#[test]
fn extract_should_retry_false() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("x-should-retry", "false".parse().unwrap());
assert_eq!(extract_should_retry(&headers), Some(false));
}
#[test]
fn extract_should_retry_unknown_value_is_none() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("x-should-retry", "banana".parse().unwrap());
assert_eq!(extract_should_retry(&headers), None);
}
#[test]
fn extract_should_retry_absent_is_none() {
let headers = reqwest::header::HeaderMap::new();
assert_eq!(extract_should_retry(&headers), None);
}
#[test] #[test]
fn new_with_minimal_config_succeeds() { fn new_with_minimal_config_succeeds() {
let client = SamplingClient::new(minimal_config()).expect("client should construct"); let client = SamplingClient::new(minimal_config()).expect("client should construct");
@@ -2286,8 +2081,8 @@ mod tests {
version: None, version: None,
}; };
let ua = user_agent_string_for(&origin); let ua = user_agent_string_for(&origin);
// No slash between product and the grok-shell agent product. // No slash between product and the kigi agent product.
assert!(ua.starts_with("my-client grok-shell/")); assert!(ua.starts_with("my-client kigi/"));
} }
#[test] #[test]
+5 -9
View File
@@ -71,12 +71,12 @@ pub struct SamplerConfig {
// Reasoning effort // Reasoning effort
pub reasoning_effort: Option<ReasoningEffort>, pub reasoning_effort: Option<ReasoningEffort>,
// Client identity /// Client identity for the User-Agent header (`kigi/{version}` plus an
/// optional origin product). The old xAI proxy's identity headers
/// (`x-grok-client-identifier` / `-client-version` / `-deployment-id` /
/// `-user-id`) are gone — User-Agent and `extra_headers` are the only
/// identity signals on the wire.
pub origin_client: Option<OriginClientInfo>, pub origin_client: Option<OriginClientInfo>,
pub client_identifier: Option<String>,
pub deployment_id: Option<String>,
pub user_id: Option<String>,
pub client_version: Option<String>,
/// Optional hook invoked at every UNAUTHORIZED (401) response /// Optional hook invoked at every UNAUTHORIZED (401) response
/// site. The sampler passes the bearer that was actually sent on /// site. The sampler passes the bearer that was actually sent on
@@ -146,10 +146,6 @@ impl Default for SamplerConfig {
idle_timeout_secs: None, idle_timeout_secs: None,
reasoning_effort: None, reasoning_effort: None,
origin_client: None, origin_client: None,
client_identifier: None,
deployment_id: None,
user_id: None,
client_version: None,
attribution_callback: None, attribution_callback: None,
bearer_resolver: None, bearer_resolver: None,
supports_backend_search: false, supports_backend_search: false,
@@ -292,7 +292,6 @@ mod tests {
message: "boom".into(), message: "boom".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
let info = SamplingErrorInfo::from(&err); let info = SamplingErrorInfo::from(&err);
assert_eq!(info.kind, SamplingErrorKind::Api); assert_eq!(info.kind, SamplingErrorKind::Api);
@@ -307,7 +306,6 @@ mod tests {
message: "slow down".into(), message: "slow down".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: Some(15), retry_after_secs: Some(15),
should_retry: None,
}; };
let info = SamplingErrorInfo::from(&err); let info = SamplingErrorInfo::from(&err);
assert_eq!(info.kind, SamplingErrorKind::RateLimited); assert_eq!(info.kind, SamplingErrorKind::RateLimited);
@@ -326,7 +324,6 @@ mod tests {
..Default::default() ..Default::default()
}), }),
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
let info = SamplingErrorInfo::from(&err); let info = SamplingErrorInfo::from(&err);
assert_eq!(info.kind, SamplingErrorKind::Api); assert_eq!(info.kind, SamplingErrorKind::Api);
@@ -0,0 +1,424 @@
//! Kimi (Moonshot) chat/completions request adaptations.
//!
//! The Kimi endpoints are OpenAI-compatible but deviate in a handful of
//! places (PRD F3 Q1). Every request-side deviation is absorbed HERE, in a
//! single adaptation point applied to the serialized chat/completions body
//! just before it is sent — never as scattered special-cases at call sites.
//! Each adaptation cites the kimi-cli source it was derived from
//! (kimi-cli == the authoritative official client; paths are relative to
//! that repository).
//!
//! Response-side deviations live with the wire types themselves
//! (`kigi_sampling_types::Usage::cached_tokens`,
//! `ChatChunkChoice::usage`) and the L2 stream transform
//! (`stream::chat_completions` synthesizes missing tool-call ids).
//!
//! The `ApiBackend::ChatCompletions` backend is the Kimi dialect: both
//! product channels (subscription OAuth and Moonshot API keys) ride it.
//! Custom providers that need vanilla OpenAI semantics for reasoning use
//! the `Responses` backend, which stays available in model configuration.
use serde_json::Value;
/// Adapt a fully-serialized chat/completions request body to the Kimi
/// dialect, in place. Applied by [`crate::SamplingClient`] to both the
/// streaming and non-streaming chat/completions paths.
pub(crate) fn adapt_chat_completions_body(body: &mut Value) {
adapt_thinking(body);
adapt_messages(body);
adapt_tool_schemas(body);
}
/// Map the OpenAI-style `reasoning_effort` knob onto Kimi's `thinking`
/// request field and drop `reasoning_effort` from the wire.
///
/// kimi-cli controls thinking exclusively through the request body's
/// `thinking: {"type": "enabled" | "disabled"}` field
/// (packages/kosong/src/kosong/chat_provider/kimi.py:214-223 `with_thinking`:
/// `"enabled" if effort != "off" else "disabled"`; wired by
/// src/kimi_cli/llm.py:475-481). When no effort is configured, nothing is
/// sent and the server default applies (llm.py:482 "leave as-is").
fn adapt_thinking(body: &mut Value) {
let Some(obj) = body.as_object_mut() else {
return;
};
let Some(effort) = obj.remove("reasoning_effort") else {
return;
};
let enabled = effort.as_str() != Some("none");
obj.insert(
"thinking".to_owned(),
serde_json::json!({ "type": if enabled { "enabled" } else { "disabled" } }),
);
}
/// Message-level adaptations:
///
/// * Drop `model_id` — a grok-build extension recorded on assistant turns;
/// kimi-cli's message serializer sends no such field
/// (packages/kosong/src/kosong/chat_provider/kimi.py:326-353).
/// * Drop `content` from assistant tool-call messages whose visible content
/// is effectively empty. The Kimi-for-Coding compat layer rejects an
/// empty text content part with 400 "text content is empty"; omitting
/// `content` entirely is always accepted
/// (packages/kosong/src/kosong/chat_provider/kimi.py:339-350, with the
/// "effectively empty" predicate at kimi.py:356-362).
fn adapt_messages(body: &mut Value) {
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
return;
};
for message in messages {
let Some(obj) = message.as_object_mut() else {
continue;
};
obj.remove("model_id");
let is_assistant = obj.get("role").and_then(Value::as_str) == Some("assistant");
let has_tool_calls = obj
.get("tool_calls")
.and_then(Value::as_array)
.is_some_and(|calls| !calls.is_empty());
if is_assistant
&& has_tool_calls
&& obj.get("content").is_some_and(is_effectively_empty_content)
{
obj.remove("content");
}
}
}
/// Port of kimi-cli `_is_effectively_empty_content_parts`
/// (packages/kosong/src/kosong/chat_provider/kimi.py:356-362): a bare
/// whitespace-only string, or a block list whose entries are all
/// whitespace-only text blocks. Any non-text block (e.g. an image) makes
/// the content non-empty.
fn is_effectively_empty_content(content: &Value) -> bool {
match content {
Value::String(s) => s.trim().is_empty(),
Value::Array(blocks) => blocks.iter().all(|block| {
block.get("type").and_then(Value::as_str) == Some("text")
&& block
.get("text")
.and_then(Value::as_str)
.is_some_and(|t| t.trim().is_empty())
}),
Value::Null => true,
_ => false,
}
}
/// Moonshot's schema validator rejects tool parameter schemas whose
/// property schemas omit `type` (e.g. enum-only properties exposed by some
/// MCP servers): HTTP 400 "At path 'properties.X': type is not defined".
/// Fill in an inferred `type` locally so such tools keep working. Port of
/// kimi-cli `ensure_property_types`
/// (packages/kosong/src/kosong/utils/jsonschema.py:88-142, applied per tool
/// at packages/kosong/src/kosong/chat_provider/kimi.py:378-388).
fn adapt_tool_schemas(body: &mut Value) {
let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) else {
return;
};
for tool in tools {
if let Some(parameters) = tool.pointer_mut("/function/parameters") {
recurse_schema(parameters);
}
}
}
/// JSON Schema keywords that describe a property's shape without a `type`
/// keyword; nodes carrying one are left alone
/// (kosong/utils/jsonschema.py:15-24 `_COMBINATOR_KEYS`).
const COMBINATOR_KEYS: [&str; 8] = [
"anyOf", "oneOf", "allOf", "not", "if", "then", "else", "$ref",
];
/// Walk property-schema positions under `node` (`properties`, `items`,
/// `additionalProperties`, `anyOf`/`oneOf`/`allOf`); `node` itself is a
/// container and is not normalized (kosong/utils/jsonschema.py:114-142).
fn recurse_schema(node: &mut Value) {
let Some(obj) = node.as_object_mut() else {
return;
};
if let Some(props) = obj.get_mut("properties").and_then(Value::as_object_mut) {
for value in props.values_mut() {
normalize_property(value);
}
}
match obj.get_mut("items") {
Some(items @ Value::Object(_)) => normalize_property(items),
Some(Value::Array(items)) => {
for value in items {
normalize_property(value);
}
}
_ => {}
}
if let Some(additional @ Value::Object(_)) = obj.get_mut("additionalProperties") {
normalize_property(additional);
}
for key in ["anyOf", "oneOf", "allOf"] {
if let Some(branches) = obj.get_mut(key).and_then(Value::as_array_mut) {
for value in branches {
normalize_property(value);
}
}
}
}
/// Ensure a property schema declares `type`, then recurse into it
/// (kosong/utils/jsonschema.py:145-162 `_normalize_property`).
fn normalize_property(node: &mut Value) {
let Some(obj) = node.as_object_mut() else {
return;
};
if !obj.contains_key("type") && !COMBINATOR_KEYS.iter().any(|k| obj.contains_key(*k)) {
let inferred = if let Some(Value::Array(values)) = obj.get("enum") {
if values.is_empty() {
infer_type_from_structure(obj)
} else {
infer_type_from_values(values)
}
} else if let Some(constant) = obj.get("const") {
infer_type_from_values(std::slice::from_ref(constant))
} else {
infer_type_from_structure(obj)
};
obj.insert("type".to_owned(), Value::String(inferred.to_owned()));
}
recurse_schema(node);
}
/// Infer `type` from structural keywords when no enum/const is present;
/// defaults to `"string"` only with no structural hints at all
/// (kosong/utils/jsonschema.py:165-215 `_infer_type_from_structure`).
fn infer_type_from_structure(obj: &serde_json::Map<String, Value>) -> &'static str {
const OBJECT_KEYWORDS: [&str; 7] = [
"properties",
"additionalProperties",
"patternProperties",
"propertyNames",
"required",
"minProperties",
"maxProperties",
];
const ARRAY_KEYWORDS: [&str; 6] = [
"items",
"prefixItems",
"minItems",
"maxItems",
"uniqueItems",
"contains",
];
const STRING_KEYWORDS: [&str; 4] = ["minLength", "maxLength", "pattern", "format"];
const NUMERIC_KEYWORDS: [&str; 5] = [
"minimum",
"maximum",
"multipleOf",
"exclusiveMinimum",
"exclusiveMaximum",
];
if OBJECT_KEYWORDS.iter().any(|k| obj.contains_key(*k)) {
"object"
} else if ARRAY_KEYWORDS.iter().any(|k| obj.contains_key(*k)) {
"array"
} else if STRING_KEYWORDS.iter().any(|k| obj.contains_key(*k)) {
"string"
} else if NUMERIC_KEYWORDS.iter().any(|k| obj.contains_key(*k)) {
"number"
} else {
"string"
}
}
/// Infer a `type` from concrete enum/const values: single JSON type wins,
/// `{integer, number}` collapses to `"number"`, any other mix falls back to
/// `"string"` (kosong/utils/jsonschema.py:218-247 `_infer_type_from_values`).
fn infer_type_from_values(values: &[Value]) -> &'static str {
let mut inferred = std::collections::BTreeSet::new();
for value in values {
let ty = match value {
Value::Bool(_) => "boolean",
Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Null => "null",
Value::Object(_) => "object",
Value::Array(_) => "array",
};
inferred.insert(ty);
}
if inferred.len() == 1 {
return inferred.pop_first().expect("non-empty set");
}
if inferred == std::collections::BTreeSet::from(["integer", "number"]) {
return "number";
}
"string"
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn reasoning_effort_maps_to_kimi_thinking_field() {
let mut body = json!({ "model": "kimi-for-coding", "reasoning_effort": "high" });
adapt_chat_completions_body(&mut body);
assert_eq!(body.get("reasoning_effort"), None);
assert_eq!(body["thinking"], json!({ "type": "enabled" }));
// kimi.py:218: "off" (our ReasoningEffort::None) → disabled.
let mut body = json!({ "reasoning_effort": "none" });
adapt_chat_completions_body(&mut body);
assert_eq!(body["thinking"], json!({ "type": "disabled" }));
// llm.py:482: unset → leave as-is (no `thinking` at all).
let mut body = json!({ "model": "kimi-for-coding" });
adapt_chat_completions_body(&mut body);
assert_eq!(body.get("thinking"), None);
}
#[test]
fn assistant_tool_call_with_empty_content_drops_content() {
let mut body = json!({
"messages": [
{ "role": "user", "content": "hi" },
{
"role": "assistant",
"content": "",
"model_id": "kimi-for-coding",
"tool_calls": [{ "id": "c1", "type": "function",
"function": { "name": "f", "arguments": "{}" } }]
},
]
});
adapt_chat_completions_body(&mut body);
let assistant = &body["messages"][1];
assert_eq!(assistant.get("content"), None, "empty content dropped");
assert_eq!(assistant.get("model_id"), None, "grok extension dropped");
assert!(assistant.get("tool_calls").is_some());
// The user message keeps its content.
assert_eq!(body["messages"][0]["content"], json!("hi"));
}
#[test]
fn assistant_tool_call_with_real_content_keeps_content() {
let mut body = json!({
"messages": [{
"role": "assistant",
"content": "let me check",
"tool_calls": [{ "id": "c1", "type": "function",
"function": { "name": "f", "arguments": "{}" } }]
}]
});
adapt_chat_completions_body(&mut body);
assert_eq!(body["messages"][0]["content"], json!("let me check"));
}
#[test]
fn assistant_without_tool_calls_keeps_empty_content() {
// Only tool-call turns drop content (kimi.py:339-350 guards on
// `message.tool_calls`); a plain empty assistant turn is left alone.
let mut body = json!({
"messages": [{ "role": "assistant", "content": "" }]
});
adapt_chat_completions_body(&mut body);
assert_eq!(body["messages"][0]["content"], json!(""));
}
#[test]
fn empty_text_block_list_counts_as_empty_content() {
let mut body = json!({
"messages": [{
"role": "assistant",
"content": [{ "type": "text", "text": " " }],
"tool_calls": [{ "id": "c1", "type": "function",
"function": { "name": "f", "arguments": "{}" } }]
}]
});
adapt_chat_completions_body(&mut body);
assert_eq!(body["messages"][0].get("content"), None);
}
#[test]
fn image_block_is_not_empty_content() {
let mut body = json!({
"messages": [{
"role": "assistant",
"content": [{ "type": "image_url", "image_url": { "url": "data:x" } }],
"tool_calls": [{ "id": "c1", "type": "function",
"function": { "name": "f", "arguments": "{}" } }]
}]
});
adapt_chat_completions_body(&mut body);
assert!(body["messages"][0].get("content").is_some());
}
#[test]
fn enum_only_property_gains_inferred_type() {
// The Moonshot validator 400s on `{"enum": [...]}` without `type`
// (kosong/utils/jsonschema.py:91-96).
let mut body = json!({
"tools": [{
"type": "function",
"function": {
"name": "search",
"parameters": {
"type": "object",
"properties": {
"mode": { "enum": ["smart", "full"] },
"count": { "enum": [1, 2, 3] },
"ratio": { "enum": [1, 2.5] },
"nested": {
"type": "object",
"properties": { "inner": { "enum": ["a"] } }
},
"combined": { "anyOf": [{ "type": "string" }] }
}
}
}
}]
});
adapt_chat_completions_body(&mut body);
let props = &body["tools"][0]["function"]["parameters"]["properties"];
assert_eq!(props["mode"]["type"], json!("string"));
assert_eq!(props["count"]["type"], json!("integer"));
assert_eq!(props["ratio"]["type"], json!("number"));
assert_eq!(
props["nested"]["properties"]["inner"]["type"],
json!("string")
);
assert_eq!(
props["combined"].get("type"),
None,
"combinator nodes are left alone"
);
}
#[test]
fn structural_keywords_infer_shape_not_string() {
let mut body = json!({
"tools": [{
"type": "function",
"function": {
"name": "t",
"parameters": {
"type": "object",
"properties": {
"obj": { "properties": { "x": { "type": "string" } } },
"arr": { "items": { "type": "string" } },
"num": { "minimum": 0 },
"free": {}
}
}
}
}]
});
adapt_chat_completions_body(&mut body);
let props = &body["tools"][0]["function"]["parameters"]["properties"];
assert_eq!(props["obj"]["type"], json!("object"));
assert_eq!(props["arr"]["type"], json!("array"));
assert_eq!(props["num"]["type"], json!("number"));
assert_eq!(props["free"]["type"], json!("string"));
}
}
+2 -1
View File
@@ -1,4 +1,4 @@
//! kigi-sampler - Actor-based sampling layer for xAI grok. //! kigi-sampler - Actor-based sampling layer for the Kimi inference APIs.
//! //!
//! This crate extracts the HTTP streaming + retry logic out of //! This crate extracts the HTTP streaming + retry logic out of
//! `kigi-shell`'s session actor into a standalone, reusable //! `kigi-shell`'s session actor into a standalone, reusable
@@ -24,6 +24,7 @@ pub mod config;
pub mod doom_loop; pub mod doom_loop;
pub mod events; pub mod events;
pub mod handle; pub mod handle;
mod kimi_compat;
pub mod metrics; pub mod metrics;
pub mod retry; pub mod retry;
pub mod sampling_log; pub mod sampling_log;
+7 -80
View File
@@ -24,14 +24,10 @@
//! - `Serialization` (response parsing failure) //! - `Serialization` (response parsing failure)
//! - `MaxTokensTruncation` (by design) //! - `MaxTokensTruncation` (by design)
//! //!
//! **Server hint** (`x-should-retry` header from CCP): //! 429 handling honors the standard `Retry-After` response header when
//! - `false` → Fatal immediately, regardless of status code //! present (delta-seconds; see `client::extract_retry_after`), matching
//! - `true` / absent → falls through to status-code logic above //! the Kimi/Moonshot API. The old xAI proxy's `x-should-retry` hint
//! //! header was removed with the proxy.
//! Today CCP's header mirrors the client's `is_retryable()` logic
//! (4xx except 429 = false, 5xx + 429 = true), so no behavior changes
//! on merge. The header enables future CCP-side refinements (e.g.
//! marking content-caused 500s as non-retryable) without client updates.
use std::time::Duration; use std::time::Duration;
@@ -170,22 +166,6 @@ pub fn classify_error(
return RetryDecision::RetryWithImageStrip; return RetryDecision::RetryWithImageStrip;
} }
// Server explicitly said don't retry (x-should-retry: false).
// Trust the server — it knows if the error is request-content-caused
// (e.g. malformed tool call in conversation history) vs transient.
//
// x-should-retry: true is intentionally NOT handled here — we only
// use the header to suppress retries (false), not to force them
// (true). Forcing retries on non-retryable status codes could
// amplify failures. true falls through to existing status-code logic.
//
// Checked AFTER image-strip guards: image stripping changes the
// request payload, so a server "don't retry" on the original
// request doesn't apply to the stripped request.
if let Some(false) = err.should_retry_header() {
return RetryDecision::Fatal(clone_error(err));
}
// Context-window / size overflow is deterministic — re-sending the same (or // Context-window / size overflow is deterministic — re-sending the same (or
// larger) payload always fails — so never retry it, whatever status the backend // larger) payload always fails — so never retry it, whatever status the backend
// used (in-stream `ResponseError`→500, HTTP 400/500, OpenAI/Anthropic variants). // used (in-stream `ResponseError`→500, HTTP 400/500, OpenAI/Anthropic variants).
@@ -401,13 +381,11 @@ pub(crate) fn clone_error(err: &SamplingError) -> SamplingError {
message, message,
model_metadata, model_metadata,
retry_after_secs, retry_after_secs,
should_retry,
} => SamplingError::Api { } => SamplingError::Api {
status: *status, status: *status,
message: message.clone(), message: message.clone(),
model_metadata: model_metadata.clone(), model_metadata: model_metadata.clone(),
retry_after_secs: *retry_after_secs, retry_after_secs: *retry_after_secs,
should_retry: *should_retry,
}, },
SamplingError::EventStreamError(msg) => SamplingError::EventStreamError(msg.clone()), SamplingError::EventStreamError(msg) => SamplingError::EventStreamError(msg.clone()),
SamplingError::StreamError { SamplingError::StreamError {
@@ -445,7 +423,6 @@ mod tests {
message: message.to_string(), message: message.to_string(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
} }
} }
@@ -455,7 +432,6 @@ mod tests {
message: "x".to_string(), message: "x".to_string(),
model_metadata: None, model_metadata: None,
retry_after_secs: Some(retry_after), retry_after_secs: Some(retry_after),
should_retry: None,
} }
} }
@@ -757,31 +733,15 @@ mod tests {
assert!(s.contains("240s")); assert!(s.contains("240s"));
} }
#[test]
fn should_retry_false_overrides_retryable_status() {
let err = SamplingError::Api {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: "boom".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: Some(false),
};
assert!(matches!(
classify_error(&err, 0, 15, RATE_LIMIT_RETRY_THRESHOLD),
RetryDecision::Fatal(_)
));
}
#[test] #[test]
fn context_length_overflow_is_fatal_even_as_500() { fn context_length_overflow_is_fatal_even_as_500() {
// The backend streams a size overflow as a ResponseError that becomes a 500 with no // The backend streams a size overflow as a ResponseError that becomes a 500;
// should_retry hint; without the context-length check it would retry the full budget. // without the context-length check it would retry the full budget.
let err = SamplingError::Api { let err = SamplingError::Api {
status: StatusCode::INTERNAL_SERVER_ERROR, status: StatusCode::INTERNAL_SERVER_ERROR,
message: "none: The prompt is too long for this model's context window.".into(), message: "none: The prompt is too long for this model's context window.".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!(matches!( assert!(matches!(
classify_error(&err, 0, 15, RATE_LIMIT_RETRY_THRESHOLD), classify_error(&err, 0, 15, RATE_LIMIT_RETRY_THRESHOLD),
@@ -790,28 +750,12 @@ mod tests {
} }
#[test] #[test]
fn should_retry_true_falls_through_to_existing_logic() { fn api_500_first_failure_retries_with_client_rebuild() {
let err = SamplingError::Api { let err = SamplingError::Api {
status: StatusCode::INTERNAL_SERVER_ERROR, status: StatusCode::INTERNAL_SERVER_ERROR,
message: "boom".into(), message: "boom".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: Some(true),
};
assert!(matches!(
classify_error(&err, 0, 15, RATE_LIMIT_RETRY_THRESHOLD),
RetryDecision::RetryWithClientRebuild { .. }
));
}
#[test]
fn should_retry_absent_falls_through() {
let err = SamplingError::Api {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: "boom".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
}; };
assert!(matches!( assert!(matches!(
classify_error(&err, 0, 15, RATE_LIMIT_RETRY_THRESHOLD), classify_error(&err, 0, 15, RATE_LIMIT_RETRY_THRESHOLD),
@@ -836,21 +780,4 @@ mod tests {
} }
} }
} }
#[test]
fn should_retry_false_on_429_is_fatal() {
// Server says don't retry, even though 429 is normally retryable.
// should_retry check runs before rate-limit check.
let err = SamplingError::Api {
status: StatusCode::TOO_MANY_REQUESTS,
message: "rate limited".into(),
model_metadata: None,
retry_after_secs: Some(10),
should_retry: Some(false),
};
assert!(matches!(
classify_error(&err, 0, 15, RATE_LIMIT_RETRY_THRESHOLD),
RetryDecision::Fatal(_)
));
}
} }
@@ -128,7 +128,16 @@ pub fn stream_chat_completions<'a>(
first_chunk_seen = true; first_chunk_seen = true;
} }
if let Some(u) = chunk.usage.clone() { // Kimi/Moonshot deviation: usage may ride inside a choice instead
// of (or in addition to) the chunk's top-level `usage`. Same
// fallback as kimi-cli's `extract_usage_from_chunk`
// (packages/kosong/src/kosong/chat_provider/kimi.py:522-533):
// top-level wins, else the first choice carrying one.
let chunk_usage = chunk
.usage
.clone()
.or_else(|| chunk.choices.iter().find_map(|c| c.usage.clone()));
if let Some(u) = chunk_usage {
// Wire cost is cumulative for the response, so last-write-wins. // Wire cost is cumulative for the response, so last-write-wins.
// Never clobber a known cost with missing/unreported. // Never clobber a known cost with missing/unreported.
let chunk_cost = kigi_sampling_types::reported_cost_ticks(u.cost_in_usd_ticks); let chunk_cost = kigi_sampling_types::reported_cost_ticks(u.cost_in_usd_ticks);
@@ -247,10 +256,27 @@ pub fn stream_chat_completions<'a>(
// ── Build the final response ───────────────────────────────── // ── Build the final response ─────────────────────────────────
let tool_calls: Vec<ToolCall> = tool_call_acc let tool_calls: Vec<ToolCall> = tool_call_acc
.into_values() .into_values()
.map(|(id, name, arguments)| ToolCall { .map(|(id, name, arguments)| {
// Kimi/Moonshot deviation: tool-call deltas may omit `id`.
// Synthesize one so the tool-result round-trip stays keyed,
// exactly like kimi-cli (`id=tool_call.id or str(uuid.uuid4())`,
// packages/kosong/src/kosong/chat_provider/kimi.py:505).
let id = if id.is_empty() {
let synthesized = uuid::Uuid::new_v4().to_string();
tracing::debug!(
tool_name = %name,
synthesized_id = %synthesized,
"tool-call delta carried no id; synthesized one"
);
synthesized
} else {
id
};
ToolCall {
id: std::sync::Arc::<str>::from(id), id: std::sync::Arc::<str>::from(id),
name, name,
arguments: std::sync::Arc::<str>::from(arguments), arguments: std::sync::Arc::<str>::from(arguments),
}
}) })
.collect(); .collect();
@@ -329,6 +355,7 @@ mod tests {
index: i as u32, index: i as u32,
delta, delta,
finish_reason: None, finish_reason: None,
usage: None,
}) })
.collect(), .collect(),
usage: None, usage: None,
@@ -664,6 +691,7 @@ mod tests {
prompt_tokens: 100, prompt_tokens: 100,
completion_tokens: 50, completion_tokens: 50,
total_tokens: 150, total_tokens: 150,
cached_tokens: None,
prompt_tokens_details: None, prompt_tokens_details: None,
completion_tokens_details: None, completion_tokens_details: None,
cost_in_usd_ticks: None, cost_in_usd_ticks: None,
@@ -704,6 +732,7 @@ mod tests {
prompt_tokens: 10, prompt_tokens: 10,
completion_tokens: 5, completion_tokens: 5,
total_tokens: 15, total_tokens: 15,
cached_tokens: None,
prompt_tokens_details: None, prompt_tokens_details: None,
completion_tokens_details: None, completion_tokens_details: None,
cost_in_usd_ticks: wire, cost_in_usd_ticks: wire,
@@ -737,6 +766,7 @@ mod tests {
prompt_tokens: 10, prompt_tokens: 10,
completion_tokens: 5, completion_tokens: 5,
total_tokens: 15, total_tokens: 15,
cached_tokens: None,
prompt_tokens_details: None, prompt_tokens_details: None,
completion_tokens_details: None, completion_tokens_details: None,
cost_in_usd_ticks: Some(99), cost_in_usd_ticks: Some(99),
@@ -746,6 +776,7 @@ mod tests {
prompt_tokens: 12, prompt_tokens: 12,
completion_tokens: 6, completion_tokens: 6,
total_tokens: 18, total_tokens: 18,
cached_tokens: None,
prompt_tokens_details: None, prompt_tokens_details: None,
completion_tokens_details: None, completion_tokens_details: None,
cost_in_usd_ticks: Some(0), cost_in_usd_ticks: Some(0),
@@ -90,6 +90,7 @@ mod tests {
tool_call_id: None, tool_call_id: None,
}, },
finish_reason: None, finish_reason: None,
usage: None,
}], }],
usage: None, usage: None,
system_fingerprint: None, system_fingerprint: None,
@@ -106,6 +107,7 @@ mod tests {
index: 0, index: 0,
delta: ChatChunkDelta::default(), delta: ChatChunkDelta::default(),
finish_reason: Some(FinishReason::Stop), finish_reason: Some(FinishReason::Stop),
usage: None,
}], }],
usage: None, usage: None,
system_fingerprint: None, system_fingerprint: None,
@@ -429,7 +429,6 @@ pub fn stream_messages<'a>(
message: error_message, message: error_message,
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
yield SamplingEvent::Failed { yield SamplingEvent::Failed {
request_id: request_id.clone(), request_id: request_id.clone(),
@@ -299,7 +299,6 @@ pub fn stream_responses<'a>(
message: error_message, message: error_message,
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
yield SamplingEvent::Failed { yield SamplingEvent::Failed {
request_id: request_id.clone(), request_id: request_id.clone(),
@@ -316,7 +315,6 @@ pub fn stream_responses<'a>(
message: error_message, message: error_message,
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
yield SamplingEvent::Failed { yield SamplingEvent::Failed {
request_id: request_id.clone(), request_id: request_id.clone(),
@@ -419,7 +417,6 @@ pub fn stream_responses<'a>(
.to_string(), .to_string(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
yield SamplingEvent::Failed { yield SamplingEvent::Failed {
request_id: request_id.clone(), request_id: request_id.clone(),
@@ -87,10 +87,6 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig {
idle_timeout_secs: Some(30), idle_timeout_secs: Some(30),
reasoning_effort: None, reasoning_effort: None,
origin_client: None, origin_client: None,
client_identifier: None,
deployment_id: None,
user_id: None,
client_version: None,
attribution_callback: None, attribution_callback: None,
bearer_resolver: None, bearer_resolver: None,
supports_backend_search: false, supports_backend_search: false,
@@ -0,0 +1,564 @@
//! Kimi chat/completions wire tests (PRD F3 acceptance).
//!
//! Exercises the sampler end-to-end against a mock HTTP server:
//! * streaming happy path with `reasoning_content` deltas, tool-call deltas,
//! and a Kimi-shaped usage chunk (usage riding inside the choice, cache
//! hits as top-level `cached_tokens`),
//! * the request the wire actually carries: plain `Authorization: Bearer`,
//! `User-Agent: kigi/{version}`, no xAI proxy marker headers, and the
//! `crate::kimi_compat` body adaptations,
//! * 429 honoring the standard `Retry-After` header,
//! * mid-stream network drop recovering through the retry loop.
//!
//! 401-no-retry and the rate-limit retry threshold are covered by
//! `test_actor.rs`; this file does not duplicate them.
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::Router;
use axum::body::Bytes;
use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event, Sse};
use axum::routing::post;
use futures_util::stream::{self};
use indexmap::IndexMap;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::{mpsc, oneshot};
use kigi_sampler::{
ApiBackend, RequestId, RetryPolicy, SamplerActor, SamplerConfig, SamplingChannel, SamplingEvent,
};
use kigi_sampling_types::{
AssistantItem, ContentPart, ConversationItem, ConversationRequest, ReasoningEffort, ToolCall,
ToolResultItem, ToolSpec, UserItem, synthesized_reasoning_item,
};
// ---------------------------------------------------------------------------
// Mock server harness (same shape as test_actor.rs)
// ---------------------------------------------------------------------------
struct MockServer {
addr: SocketAddr,
shutdown_tx: oneshot::Sender<()>,
}
impl MockServer {
async fn spawn(app: Router) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
tokio::time::sleep(Duration::from_millis(20)).await;
Self { addr, shutdown_tx }
}
fn base_url(&self) -> String {
format!("http://{}/v1", self.addr)
}
fn shutdown(self) {
let _ = self.shutdown_tx.send(());
}
}
fn test_config(base_url: String) -> SamplerConfig {
SamplerConfig {
api_key: Some("test-kimi-key".into()),
base_url,
model: "kimi-for-coding".into(),
max_completion_tokens: Some(1024),
api_backend: ApiBackend::ChatCompletions,
extra_headers: IndexMap::new(),
context_window: 128_000,
max_retries: Some(3),
idle_timeout_secs: Some(30),
..Default::default()
}
}
fn user_request(text: &str) -> ConversationRequest {
ConversationRequest {
items: vec![ConversationItem::User(UserItem {
content: vec![ContentPart::Text {
text: Arc::<str>::from(text),
}],
synthetic_reason: None,
..Default::default()
})],
..Default::default()
}
}
fn chunk(delta: Value, finish: Option<&str>, usage: Option<Value>) -> Event {
let mut choice = json!({ "index": 0, "delta": delta });
choice["finish_reason"] = finish.map(Value::from).unwrap_or(Value::Null);
if let Some(u) = usage {
// Kimi deviation under test: usage rides INSIDE the choice
// (kimi-cli kimi.py:522-533 `extract_usage_from_chunk`).
choice["usage"] = u;
}
let body = json!({
"id": "chatcmpl-kimi",
"object": "chat.completion.chunk",
"created": 0,
"model": "kimi-for-coding",
"choices": [choice]
});
Event::default().data(body.to_string())
}
async fn drain_until_terminal(
rx: &mut mpsc::UnboundedReceiver<SamplingEvent>,
timeout: Duration,
) -> Vec<SamplingEvent> {
let mut out = Vec::new();
let deadline = tokio::time::Instant::now() + timeout;
loop {
let ev = tokio::time::timeout_at(deadline, rx.recv())
.await
.expect("timed out waiting for terminal event")
.expect("event channel closed before terminal event");
let terminal = matches!(
ev,
SamplingEvent::Completed { .. } | SamplingEvent::Failed { .. }
);
out.push(ev);
if terminal {
return out;
}
}
}
// ---------------------------------------------------------------------------
// Streaming happy path: reasoning + tool calls + Kimi usage shapes
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kimi_stream_reasoning_tool_calls_and_choice_usage() {
let app = Router::new().route(
"/v1/chat/completions",
post(|| async {
let events = vec![
chunk(
json!({ "role": "assistant", "reasoning_content": "let me think" }),
None,
None,
),
chunk(json!({ "reasoning_content": " harder" }), None, None),
chunk(json!({ "content": "Running the tool." }), None, None),
// Tool call split across chunks: id+name first, args continue.
chunk(
json!({ "tool_calls": [{ "index": 0, "id": "call_1", "type": "function",
"function": { "name": "read_file", "arguments": "{\"path\":" } }] }),
None,
None,
),
chunk(
json!({ "tool_calls": [{ "index": 0,
"function": { "arguments": "\"a.rs\"}" } }] }),
None,
None,
),
// Terminal chunk: finish_reason + usage inside the choice with
// Moonshot's top-level `cached_tokens` (kimi.py:427-431).
chunk(
json!({}),
Some("tool_calls"),
Some(json!({
"prompt_tokens": 100,
"completion_tokens": 20,
"total_tokens": 120,
"cached_tokens": 60
})),
),
];
Sse::new(stream::iter(
events.into_iter().map(Ok::<_, std::convert::Infallible>),
))
}),
);
let server = MockServer::spawn(app).await;
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let handle = SamplerActor::spawn(
test_config(server.base_url()),
RetryPolicy::default(),
event_tx,
);
handle.submit(RequestId::from("req-kimi"), user_request("hi"));
let events = drain_until_terminal(&mut event_rx, Duration::from_secs(30)).await;
server.shutdown();
// Reasoning tokens stream on the Reasoning channel, text on Text.
let reasoning: String = events
.iter()
.filter_map(|e| match e {
SamplingEvent::ChannelToken {
channel: SamplingChannel::Reasoning,
text,
..
} => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(reasoning, "let me think harder");
let text: String = events
.iter()
.filter_map(|e| match e {
SamplingEvent::ChannelToken {
channel: SamplingChannel::Text,
text,
..
} => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(text, "Running the tool.");
// Tool-call deltas surfaced incrementally.
assert!(events.iter().any(|e| matches!(
e,
SamplingEvent::ToolCallDelta { id: Some(id), .. } if id == "call_1"
)));
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let calls = response.tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id.as_ref(), "call_1");
assert_eq!(calls[0].name, "read_file");
assert_eq!(calls[0].arguments.as_ref(), "{\"path\":\"a.rs\"}");
let reasoning_item = response
.reasoning_items()
.next()
.expect("reasoning sibling preserved");
let kigi_sampling_types::rs::SummaryPart::SummaryText(t) = &reasoning_item.summary[0];
assert_eq!(t.text, "let me think harder");
// Choice-level usage + top-level cached_tokens both absorbed.
let usage = response.usage.as_ref().expect("usage from choice");
assert_eq!(usage.prompt_tokens, 100);
assert_eq!(usage.completion_tokens, 20);
assert_eq!(usage.cached_prompt_tokens, 60);
}
other => panic!("expected Completed, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// Request surface: bearer auth, kigi UA, kimi_compat body adaptations
// ---------------------------------------------------------------------------
type Captured = Arc<Mutex<Option<(HeaderMap, Value)>>>;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_carries_bearer_kigi_ua_and_kimi_dialect_body() {
let captured: Captured = Arc::new(Mutex::new(None));
let captured_handler = Arc::clone(&captured);
let app = Router::new().route(
"/v1/chat/completions",
post(move |headers: HeaderMap, body: Bytes| {
let captured = Arc::clone(&captured_handler);
async move {
let body: Value = serde_json::from_slice(&body).unwrap();
*captured.lock().unwrap() = Some((headers, body));
let events = vec![chunk(
json!({ "role": "assistant", "content": "ok" }),
Some("stop"),
None,
)];
Sse::new(stream::iter(
events.into_iter().map(Ok::<_, std::convert::Infallible>),
))
}
}),
);
let server = MockServer::spawn(app).await;
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let handle = SamplerActor::spawn(
test_config(server.base_url()),
RetryPolicy::default(),
event_tx,
);
// Multi-turn conversation exercising every request-side adaptation:
// reasoning folded onto the assistant, an empty-content tool-call turn,
// and an enum-only tool schema property.
let request = ConversationRequest {
items: vec![
ConversationItem::User(UserItem {
content: vec![ContentPart::Text {
text: Arc::<str>::from("read a.rs"),
}],
synthetic_reason: None,
..Default::default()
}),
ConversationItem::Reasoning(synthesized_reasoning_item("planning the read")),
ConversationItem::Assistant(AssistantItem {
content: Arc::<str>::from(""),
tool_calls: vec![ToolCall {
id: Arc::<str>::from("call_9"),
name: "read_file".into(),
arguments: Arc::<str>::from("{\"path\":\"a.rs\"}"),
}],
model_id: Some("kimi-for-coding".into()),
model_fingerprint: None,
reasoning_effort: None,
}),
ConversationItem::ToolResult(ToolResultItem {
tool_call_id: "call_9".into(),
content: Arc::<str>::from("fn main() {}"),
images: vec![],
}),
],
tools: vec![ToolSpec {
name: "read_file".into(),
description: Some("Read a file".into()),
parameters: json!({
"type": "object",
"properties": {
// Enum-only property: Moonshot 400s without a `type`.
"mode": { "enum": ["full", "head"] },
"path": { "type": "string" }
}
}),
}],
reasoning_effort: Some(ReasoningEffort::High),
..Default::default()
};
handle.submit(RequestId::from("req-wire"), request);
let events = drain_until_terminal(&mut event_rx, Duration::from_secs(30)).await;
server.shutdown();
assert!(matches!(
events.last().unwrap(),
SamplingEvent::Completed { .. }
));
let (headers, body) = captured.lock().unwrap().take().expect("request captured");
// -- Auth: plain bearer, nothing else (PRD F3).
assert_eq!(
headers.get("authorization").unwrap().to_str().unwrap(),
"Bearer test-kimi-key"
);
for gone in [
"x-xai-token-auth",
"x-authenticateresponse",
"x-grok-conv-id",
"x-grok-req-id",
"x-grok-model-override",
"x-grok-session-id",
"x-grok-agent-id",
"x-grok-client-identifier",
"x-grok-client-version",
"x-grok-deployment-id",
"x-grok-user-id",
"x-grok-client-mode",
] {
assert!(
headers.get(gone).is_none(),
"xAI proxy marker header must not be sent: {gone}"
);
}
// -- User-Agent: kigi/{version} (os; arch).
let ua = headers.get("user-agent").unwrap().to_str().unwrap();
let expected_prefix = format!("kigi/{}", kigi_version::VERSION);
assert!(
ua.starts_with(&expected_prefix),
"UA must start with {expected_prefix}, got {ua}"
);
// -- Streaming fields exactly as kimi-cli sends them (kimi.py:174-181).
assert_eq!(body["stream"], json!(true));
assert_eq!(body["stream_options"], json!({ "include_usage": true }));
// -- Thinking mapping (kimi.py:214-223): effort → thinking, no
// reasoning_effort on the wire.
assert_eq!(body["thinking"], json!({ "type": "enabled" }));
assert_eq!(body.get("reasoning_effort"), None);
// -- Message adaptations.
let messages = body["messages"].as_array().unwrap();
let assistant = messages
.iter()
.find(|m| m["role"] == "assistant")
.expect("assistant turn present");
assert_eq!(
assistant.get("content"),
None,
"empty tool-call content dropped (kimi.py:339-350)"
);
assert_eq!(assistant.get("model_id"), None, "grok extension dropped");
assert_eq!(
assistant["reasoning_content"],
json!("planning the read"),
"reasoning folded onto the assistant turn (kimi.py:351-352)"
);
assert_eq!(assistant["tool_calls"][0]["id"], json!("call_9"));
// -- Tool schema normalization (kosong jsonschema.py:88-142).
let props = &body["tools"][0]["function"]["parameters"]["properties"];
assert_eq!(props["mode"]["type"], json!("string"));
assert_eq!(props["path"]["type"], json!("string"));
}
// ---------------------------------------------------------------------------
// 429 with Retry-After
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rate_limit_honors_retry_after_then_succeeds() {
let counter = Arc::new(AtomicU32::new(0));
let counter_handler = Arc::clone(&counter);
let app = Router::new().route(
"/v1/chat/completions",
post(move || {
let counter = Arc::clone(&counter_handler);
async move {
let n = counter.fetch_add(1, Ordering::SeqCst);
if n == 0 {
// Moonshot-shaped 429 body + standard Retry-After.
let mut headers = HeaderMap::new();
headers.insert("retry-after", "1".parse().unwrap());
Err::<Sse<_>, (StatusCode, HeaderMap, String)>((
StatusCode::TOO_MANY_REQUESTS,
headers,
json!({ "error": {
"message": "Your account is rate limited",
"type": "rate_limit_reached_error"
}})
.to_string(),
))
} else {
let events = vec![chunk(
json!({ "role": "assistant", "content": "after limit" }),
Some("stop"),
None,
)];
Ok(Sse::new(stream::iter(
events.into_iter().map(Ok::<_, std::convert::Infallible>),
)))
}
}
}),
);
let server = MockServer::spawn(app).await;
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let handle = SamplerActor::spawn(
test_config(server.base_url()),
RetryPolicy::default(),
event_tx,
);
let started = std::time::Instant::now();
handle.submit(RequestId::from("req-ra"), user_request("hi"));
let events = drain_until_terminal(&mut event_rx, Duration::from_secs(30)).await;
let elapsed = started.elapsed();
server.shutdown();
// A Retrying event carried the classified rate-limit.
assert!(events.iter().any(|e| matches!(
e,
SamplingEvent::Retrying { kind, .. }
if *kind == kigi_sampler::SamplingErrorKind::RateLimited
)));
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(
response.assistant().unwrap().content.as_ref(),
"after limit"
);
}
other => panic!("expected Completed after Retry-After wait, got {other:?}"),
}
assert_eq!(counter.load(Ordering::SeqCst), 2, "exactly one retry");
// Retry-After: 1 replaces the ~2s jittered exponential backoff. The wait
// must be at least the advertised second (and clearly less than the
// exhaust-path 30s timeout).
assert!(
elapsed >= Duration::from_secs(1),
"waited less than Retry-After: {elapsed:?}"
);
}
// ---------------------------------------------------------------------------
// Mid-stream network drop → retry → recovery
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mid_stream_drop_recovers_via_retry() {
let counter = Arc::new(AtomicU32::new(0));
let counter_handler = Arc::clone(&counter);
let app = Router::new().route(
"/v1/chat/completions",
post(move || {
let counter = Arc::clone(&counter_handler);
async move {
let n = counter.fetch_add(1, Ordering::SeqCst);
if n == 0 {
// First attempt: a partial chunk, then the connection
// dies mid-body (simulated network drop).
let events: Vec<Result<Event, std::io::Error>> = vec![
Ok(chunk(
json!({ "role": "assistant", "content": "partial" }),
None,
None,
)),
Err(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"connection reset by peer",
)),
];
Sse::new(stream::iter(events))
} else {
let events: Vec<Result<Event, std::io::Error>> = vec![Ok(chunk(
json!({ "role": "assistant", "content": "recovered" }),
Some("stop"),
None,
))];
Sse::new(stream::iter(events))
}
}
}),
);
let server = MockServer::spawn(app).await;
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let handle = SamplerActor::spawn(
test_config(server.base_url()),
RetryPolicy::default(),
event_tx,
);
handle.submit(RequestId::from("req-drop"), user_request("hi"));
let events = drain_until_terminal(&mut event_rx, Duration::from_secs(60)).await;
server.shutdown();
assert!(
events
.iter()
.any(|e| matches!(e, SamplingEvent::Retrying { .. })),
"mid-stream drop must go through the retry loop"
);
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
// The poisoned partial attempt is discarded; only the fresh
// attempt's content survives.
assert_eq!(response.assistant().unwrap().content.as_ref(), "recovered");
}
other => panic!("expected Completed after recovery, got {other:?}"),
}
assert!(
counter.load(Ordering::SeqCst) >= 2,
"server hit at least twice"
);
}
@@ -666,10 +666,15 @@ impl TokenUsage {
impl From<Usage> for TokenUsage { impl From<Usage> for TokenUsage {
fn from(u: Usage) -> Self { fn from(u: Usage) -> Self {
let cached_prompt_tokens = u // Kimi/Moonshot deviation: prefer the top-level `cached_tokens` field
.prompt_tokens_details // 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() .as_ref()
.map_or(0, |d| d.cached_tokens); .map_or(0, |d| d.cached_tokens)
});
Self { Self {
prompt_tokens: u.prompt_tokens, prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens, completion_tokens: u.completion_tokens,
+35 -66
View File
@@ -96,13 +96,9 @@ pub enum SamplingError {
status: StatusCode, status: StatusCode,
message: String, message: String,
model_metadata: Option<ResponseModelMetadata>, 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>, 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}")] #[error("reqwest error stream: {0}")]
EventStreamError(String), 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, /// True when this error is a context-window/size overflow — deterministic,
/// so retrying the same payload can't help. See [`is_context_length_error`]. /// so retrying the same payload can't help. See [`is_context_length_error`].
pub fn is_context_length_error(&self) -> bool { 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)] #[derive(Debug, Deserialize)]
struct ErrorResponse { struct ErrorResponse {
error: ErrorBody, error: ErrorBody,
@@ -317,36 +312,20 @@ struct ErrorBody {
kind: Option<String>, kind: Option<String>,
} }
/// Flat error from the Grok proxy/gateway: `{"code": "...", "error": "..."}`. /// Extract `(error_type, message)` from an OpenAI-compatible error body.
#[derive(Debug, Deserialize)]
struct FlatErrorResponse {
error: String,
#[serde(default)]
code: Option<String>,
}
/// Extract `(error_type, message)` from either error format.
fn try_parse_error(data: &str) -> Option<(String, String)> { fn try_parse_error(data: &str) -> Option<(String, String)> {
if let Ok(resp) = serde_json::from_str::<ErrorResponse>(data) { let resp = serde_json::from_str::<ErrorResponse>(data).ok()?;
return Some(( Some((
resp.error.kind.unwrap_or_else(|| "unknown".to_string()), resp.error.kind.unwrap_or_else(|| "unknown".to_string()),
resp.error resp.error
.message .message
.unwrap_or_else(|| "unknown error".to_string()), .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
} }
pub fn parse_error_bytes(bytes: &[u8]) -> 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 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 message;
} }
return format!("{error_type}: {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(), message: "none: The prompt is too long for this model's context window.".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!(api.is_context_length_error()); assert!(api.is_context_length_error());
assert!( 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] #[test]
fn try_parse_stream_error_flat_format() { fn try_parse_stream_error_openai_format() {
let data = r#"{"code":"The service is currently unavailable","error":"Service temporarily unavailable. The model did not respond to this request."}"#; 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 flat error"); let err = try_parse_stream_error(data).expect("should parse OpenAI-shaped error");
match err { match err {
SamplingError::StreamError { SamplingError::StreamError {
error_type, error_type,
message, message,
} => { } => {
assert_eq!(error_type, "The service is currently unavailable"); assert_eq!(error_type, "rate_limit_reached_error");
assert_eq!( assert_eq!(message, "Your account is rate limited");
message,
"Service temporarily unavailable. The model did not respond to this request."
);
} }
other => panic!("expected StreamError, got {other:?}"), other => panic!("expected StreamError, got {other:?}"),
} }
@@ -518,13 +496,19 @@ mod tests {
} }
#[test] #[test]
fn parse_error_bytes_flat_format() { fn parse_error_bytes_openai_format_prefixes_type() {
let bytes = let bytes = br#"{"error":{"message":"Your account is rate limited","type":"rate_limit_reached_error"}}"#;
br#"{"code":"The service is currently unavailable","error":"Service temporarily unavailable."}"#;
let msg = parse_error_bytes(bytes);
assert_eq!( assert_eq!(
msg, parse_error_bytes(bytes),
"The service is currently unavailable: Service temporarily unavailable." "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(), message: "Content violates usage guidelines.".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!( assert!(
!err.is_auth_error(), !err.is_auth_error(),
@@ -558,7 +541,6 @@ mod tests {
message: "Invalid or expired credentials".into(), message: "Invalid or expired credentials".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!( assert!(
err.is_auth_error(), err.is_auth_error(),
@@ -579,7 +561,6 @@ mod tests {
message: "Rate limit exceeded".into(), message: "Rate limit exceeded".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!(err.is_rate_limited()); assert!(err.is_rate_limited());
assert!(err.is_retryable(), "429 should be retryable"); assert!(err.is_retryable(), "429 should be retryable");
@@ -594,7 +575,6 @@ mod tests {
message: "internal".into(), message: "internal".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!(!server_error.is_rate_limited()); assert!(!server_error.is_rate_limited());
@@ -612,7 +592,6 @@ mod tests {
message: "slow down".into(), message: "slow down".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: Some(42), retry_after_secs: Some(42),
should_retry: None,
}; };
assert_eq!(err.retry_after(), Some(42)); assert_eq!(err.retry_after(), Some(42));
} }
@@ -624,7 +603,6 @@ mod tests {
message: "slow down".into(), message: "slow down".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert_eq!(err.retry_after(), 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(), message: "Could not decrypt the provided encrypted_content. Ensure the value is the unmodified encrypted_content from a previous response.".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!(err.is_encrypted_content_error()); assert!(err.is_encrypted_content_error());
assert!( assert!(
@@ -661,7 +638,6 @@ mod tests {
message: "encrypted_content decryption failed".into(), message: "encrypted_content decryption failed".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!( assert!(
!err.is_encrypted_content_error(), !err.is_encrypted_content_error(),
@@ -676,7 +652,6 @@ mod tests {
message: "Invalid model parameter".into(), message: "Invalid model parameter".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!( assert!(
!err.is_encrypted_content_error(), !err.is_encrypted_content_error(),
@@ -691,7 +666,6 @@ mod tests {
message: "Could not process image: unsupported format".into(), message: "Could not process image: unsupported format".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!(err.is_image_processing_error()); assert!(err.is_image_processing_error());
assert!(!err.is_encrypted_content_error()); assert!(!err.is_encrypted_content_error());
@@ -704,7 +678,6 @@ mod tests {
message: "upstream error: 400 Bad Request: Could not process image".into(), message: "upstream error: 400 Bad Request: Could not process image".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!(err.is_image_processing_error()); assert!(err.is_image_processing_error());
} }
@@ -716,7 +689,6 @@ mod tests {
message: "Invalid model parameter".into(), message: "Invalid model parameter".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!(!err.is_image_processing_error()); assert!(!err.is_image_processing_error());
} }
@@ -728,7 +700,6 @@ mod tests {
message: "internal server error".into(), message: "internal server error".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!(!err.is_image_processing_error()); assert!(!err.is_image_processing_error());
} }
@@ -740,7 +711,6 @@ mod tests {
message: "Could not process image".into(), message: "Could not process image".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!( assert!(
!err.is_image_processing_error(), !err.is_image_processing_error(),
@@ -755,7 +725,6 @@ mod tests {
message: "Could not process image".into(), message: "Could not process image".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
assert!( assert!(
!err.is_retryable(), !err.is_retryable(),
@@ -537,6 +537,15 @@ pub struct Usage {
pub prompt_tokens: u32, pub prompt_tokens: u32,
pub completion_tokens: u32, pub completion_tokens: u32,
pub total_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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_tokens_details: Option<PromptTokensDetails>, pub prompt_tokens_details: Option<PromptTokensDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -592,6 +601,14 @@ pub struct ChatChunkChoice {
pub delta: ChatChunkDelta, pub delta: ChatChunkDelta,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<FinishReason>, 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. /// Streaming delta for a tool call.
-1
View File
@@ -11,7 +11,6 @@ dirs = "6"
dunce = { workspace = true } dunce = { workspace = true }
image = { workspace = true, features = ["png", "jpeg", "gif", "webp"] } image = { workspace = true, features = ["png", "jpeg", "gif", "webp"] }
parking_lot = { workspace = true } parking_lot = { workspace = true }
prod-mc-cli-chat-proxy-types = { path = "../../../prod/mc/cli-chat-proxy-types" }
regex = { workspace = true } regex = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true, features = ["preserve_order"] } serde_json = { workspace = true, features = ["preserve_order"] }
+35 -3
View File
@@ -1,12 +1,44 @@
use std::path::PathBuf; use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub mod info; pub mod info;
pub use info::Info; pub use info::Info;
// Re-export shared feedback wire types used by downstream crates /// Snapshot of the user's terminal environment at feedback time.
// (e.g. kigi-pager-render). ///
pub use prod_mc_cli_chat_proxy_types::feedback_types::FeedbackTerminalInfo; /// Shared here (rather than in kigi-shell) because kigi-pager-render builds it
/// from its terminal probes and the shell attaches it to feedback records.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FeedbackTerminalInfo {
/// Terminal emulator brand (e.g. "Ghostty", "iTerm2", "Unknown").
pub brand: String,
/// Multiplexer wrapping the session (e.g. "tmux", "Zellij", "None detected").
pub multiplexer: String,
/// Whether the session is over SSH.
pub is_ssh: bool,
/// Whether Byobu is wrapping the session.
pub is_byobu: bool,
/// Raw `TERM` environment variable value.
pub term_var: String,
/// tmux server version if inside tmux, otherwise "n/a".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tmux_version: Option<String>,
/// Hyperlink (OSC 8) support level (e.g. "native", "hostile_parser").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hyperlink_osc8_support: Option<String>,
/// Active clipboard legs, e.g. "native+osc52" or "native+tmux+osc52".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clipboard_route: Option<String>,
/// Native clipboard tool: "pbcopy", "wl-copy", "xclip", "xsel", "arboard".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clipboard_native_tool: Option<String>,
/// Display server: "wayland", "x11", "quartz", "win32", "unknown".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_server: Option<String>,
}
pub fn session_dir(info: &Info) -> PathBuf { pub fn session_dir(info: &Info) -> PathBuf {
kigi_tools::util::kigi_home::sessions_cwd_dir(&info.cwd).join(info.id.to_string()) kigi_tools::util::kigi_home::sessions_cwd_dir(&info.cwd).join(info.id.to_string())
+24 -40
View File
@@ -56,19 +56,19 @@ fn matches_trusted_base_url(candidate: &str, trusted_base: &str) -> bool {
/// True for subscription coding-API URLs (the compiled production endpoint; /// True for subscription coding-API URLs (the compiled production endpoint;
/// deliberately NOT the env-overridable [`kigi_env::coding_api_base_url`] so a /// deliberately NOT the env-overridable [`kigi_env::coding_api_base_url`] so a
/// runtime override can't widen this trust set). /// runtime override can't widen this trust set).
pub fn is_cli_chat_proxy_url(url: &str) -> bool { pub fn is_production_coding_api_url(url: &str) -> bool {
matches_trusted_base_url(url, kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url) matches_trusted_base_url(url, kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url)
} }
/// True for URLs the idle model-metadata refresh may re-fetch from: the /// True for URLs the idle model-metadata refresh may re-fetch from: the
/// *effective* subscription coding endpoint (the `KIGI_CODE_BASE_URL` /// *effective* subscription coding endpoint (the `KIGI_CODE_BASE_URL`
/// override when set, else the compiled production endpoint), plus loopback /// override when set, else the compiled production endpoint), plus loopback
/// hosts (local dev proxies and test mocks). Unlike [`is_cli_chat_proxy_url`] /// hosts (local dev proxies and test mocks). Unlike [`is_production_coding_api_url`]
/// this honours the env override and loopback, so use it only to gate traffic /// this honours the env override and loopback, so use it only to gate traffic
/// that already flows to the session's configured base URL (the refresh /// that already flows to the session's configured base URL (the refresh
/// re-fetches from the same host the session samples against); it must never /// re-fetches from the same host the session samples against); it must never
/// widen a security trust set. /// widen a security trust set.
pub fn is_effective_coding_endpoint_url(url: &str) -> bool { pub fn is_effective_coding_endpoint_url(url: &str) -> bool {
if is_cli_chat_proxy_url(url) { if is_production_coding_api_url(url) {
return true; return true;
} }
if matches_trusted_base_url(url, &kigi_env::coding_api_base_url()) { if matches_trusted_base_url(url, &kigi_env::coding_api_base_url()) {
@@ -83,18 +83,11 @@ pub fn is_effective_coding_endpoint_url(url: &str) -> bool {
None => false, None => false,
}) })
} }
/// True for first-party xAI endpoints (`*.x.ai`, cli-chat-proxy, and optional /// True for first-party endpoints: the Kimi subscription coding API. The
/// non-production first-party hosts when that feature is enabled). /// session-token 401-refresh gate only refreshes against these; other hosts
/// `disable_api_key_auth` refuses keys only for these; other hosts are BYOK and /// are BYOK and exempt. Safe against invalid URLs and suffix attacks.
/// exempt. Safe against invalid URLs and suffix attacks (`evil-x.ai.example`). pub fn is_first_party_url(url: &str) -> bool {
pub fn is_first_party_xai_url(url: &str) -> bool { is_production_coding_api_url(url)
if is_cli_chat_proxy_url(url) {
return true;
}
reqwest::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_owned()))
.is_some_and(|host| host == "x.ai" || host.ends_with(".x.ai"))
} }
/// Truncate a string to at most `max_chars` characters. /// Truncate a string to at most `max_chars` characters.
/// Slices at char boundaries so multi-byte UTF-8 never panics. /// Slices at char boundaries so multi-byte UTF-8 never panics.
@@ -229,18 +222,18 @@ pub fn is_grok_process(pid: u32) -> bool {
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_is_cli_chat_proxy_url_accepts_proxy_subpath() { fn test_is_production_coding_api_url_accepts_proxy_subpath() {
assert!(is_cli_chat_proxy_url( assert!(is_production_coding_api_url(
"https://api.kimi.com/coding/v1/chat/completions" "https://api.kimi.com/coding/v1/chat/completions"
)); ));
} }
#[test] #[test]
fn test_is_cli_chat_proxy_url_rejects_public_api() { fn test_is_production_coding_api_url_rejects_public_api() {
assert!(!is_cli_chat_proxy_url("https://api.x.ai/v1")); assert!(!is_production_coding_api_url("https://api.x.ai/v1"));
} }
#[test] #[test]
fn test_is_cli_chat_proxy_url_rejects_spoofed_hostname() { fn test_is_production_coding_api_url_rejects_spoofed_hostname() {
assert!(!is_cli_chat_proxy_url( assert!(!is_production_coding_api_url(
"https://api.kimi.com.evil.example/coding/v1" "https://api.kimi.com.evil.example/coding/v1"
)); ));
} }
@@ -261,31 +254,22 @@ mod tests {
)); ));
} }
#[test] #[test]
fn test_is_cli_chat_proxy_url_rejects_v11_prefix_confusion() { fn test_is_production_coding_api_url_rejects_v11_prefix_confusion() {
assert!(!is_cli_chat_proxy_url( assert!(!is_production_coding_api_url(
"https://api.kimi.com/coding/v11/chat/completions" "https://api.kimi.com/coding/v11/chat/completions"
)); ));
} }
#[test] #[test]
fn test_is_first_party_xai_url() { fn test_is_first_party_url() {
assert!(is_first_party_xai_url("https://api.x.ai/v1")); assert!(is_first_party_url(
assert!(is_first_party_xai_url(
"https://api.x.ai/v1/chat/completions"
));
assert!(is_first_party_xai_url("https://x.ai"));
assert!(is_first_party_xai_url(
"https://api.kimi.com/coding/v1/chat/completions" "https://api.kimi.com/coding/v1/chat/completions"
)); ));
assert!(!is_first_party_xai_url("https://api.openai.com/v1")); assert!(!is_first_party_url("https://api.x.ai/v1"));
assert!(!is_first_party_xai_url("https://api.anthropic.com/v1")); assert!(!is_first_party_url("https://api.openai.com/v1"));
assert!(!is_first_party_xai_url( assert!(!is_first_party_url("https://api.anthropic.com/v1"));
"https://generativelanguage.googleapis.com" assert!(!is_first_party_url("https://api.kimi.com.evil.example/v1"));
)); assert!(!is_first_party_url("not-a-url"));
assert!(!is_first_party_xai_url("https://api.x.ai.evil.example/v1")); assert!(!is_first_party_url(""));
assert!(!is_first_party_xai_url("https://evil-x.ai.attacker.com/v1"));
assert!(!is_first_party_xai_url("https://prefixx.ai/v1"));
assert!(!is_first_party_xai_url("not-a-url"));
assert!(!is_first_party_xai_url(""));
} }
#[test] #[test]
fn test_truncate() { fn test_truncate() {
-1
View File
@@ -147,7 +147,6 @@ kigi-auth = { workspace = true, features = ["middleware"] }
kigi-log = { workspace = true } kigi-log = { workspace = true }
kigi-http = { workspace = true } kigi-http = { workspace = true }
kigi-models = { workspace = true } kigi-models = { workspace = true }
prod-mc-cli-chat-proxy-types = { path = "../../../prod/mc/cli-chat-proxy-types" }
flate2 = { workspace = true } flate2 = { workspace = true }
fs2 = { workspace = true } fs2 = { workspace = true }
zstd = { workspace = true } zstd = { workspace = true }
@@ -536,7 +536,6 @@ fn summary_ids(summaries: &[Summary]) -> Vec<String> {
async fn build_local_list_with_delayed_peer(cwd: String) -> UnifiedListResult { async fn build_local_list_with_delayed_peer(cwd: String) -> UnifiedListResult {
let local = build_unified_list( let local = build_unified_list(
None,
None, None,
ListReq { ListReq {
cwd: Some(cwd), cwd: Some(cwd),
@@ -642,7 +641,6 @@ fn bench_session_list(c: &mut Criterion) {
|b| { |b| {
b.iter_with_large_drop(|| { b.iter_with_large_drop(|| {
black_box(runtime.block_on(build_unified_list( black_box(runtime.block_on(build_unified_list(
None,
None, None,
ListReq { ListReq {
cwd: Some(black_box(fixture.picker_cwd.clone())), cwd: Some(black_box(fixture.picker_cwd.clone())),
+4 -21
View File
@@ -621,10 +621,8 @@ pub async fn run_leader(
let fetch_auth_for_prefetch = ModelFetchAuth::resolve(&endpoints_for_prefetch); let fetch_auth_for_prefetch = ModelFetchAuth::resolve(&endpoints_for_prefetch);
let platform_keys_for_prefetch = let platform_keys_for_prefetch =
crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms); crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms);
// The shared pair helper owns the remote_fetch gate for both halves, so a let prefetched_models = tokio::task::spawn_blocking(move || {
// disabled knob cannot block leader readiness on settings retries. crate::agent::models::prefetch_models_blocking(
let (prefetched_models, remote_settings) = tokio::task::spawn_blocking(move || {
crate::agent::models::prefetch_models_and_settings_blocking(
&endpoints_for_prefetch, &endpoints_for_prefetch,
auth_for_prefetch.as_ref(), auth_for_prefetch.as_ref(),
fetch_auth_for_prefetch, fetch_auth_for_prefetch,
@@ -632,20 +630,7 @@ pub async fn run_leader(
) )
}) })
.await .await
.unwrap_or((None, None)); .unwrap_or(None);
// Process-wide image normalize cache: off by default, toggled here from
// `RemoteSettings.image_normalize_cache_enabled` once at startup.
let image_normalize_cache_enabled = remote_settings
.as_ref()
.and_then(|r| r.image_normalize_cache_enabled)
.unwrap_or(false);
crate::session::normalize_cache::NormalizeCache::global()
.set_enabled(image_normalize_cache_enabled);
tracing::debug!(
enabled = image_normalize_cache_enabled,
"image normalize cache toggle resolved from remote settings"
);
// ── Phase 7: Signal readiness ───────────────────────────────────────────── // ── Phase 7: Signal readiness ─────────────────────────────────────────────
// //
@@ -657,9 +642,7 @@ pub async fn run_leader(
// ── Phase 8: LocalSet — agent, bridges, config watcher ─────────────────── // ── Phase 8: LocalSet — agent, bridges, config watcher ───────────────────
let local_set = tokio::task::LocalSet::new(); let local_set = tokio::task::LocalSet::new();
let remote_settings_for_reloader = remote_settings.clone();
let mut agent_config_for_spawn = agent_config.clone(); let mut agent_config_for_spawn = agent_config.clone();
agent_config_for_spawn.remote_settings = remote_settings;
crate::util::config::sync_campaign_fields(&mut agent_config_for_spawn); crate::util::config::sync_campaign_fields(&mut agent_config_for_spawn);
let agent_to_ipc_tx_clone = agent_to_ipc_tx.clone(); let agent_to_ipc_tx_clone = agent_to_ipc_tx.clone();
let cancel_clone = cancel.clone(); let cancel_clone = cancel.clone();
@@ -891,7 +874,7 @@ pub async fn run_leader(
initial_auth_key_hash, initial_auth_key_hash,
initial_config, initial_config,
auth_scope, auth_scope,
remote_settings_for_reloader, None,
config_update_tx, config_update_tx,
agent_config.cli_experimental_memory, agent_config.cli_experimental_memory,
agent_config.cli_no_memory, agent_config.cli_no_memory,
+12 -330
View File
@@ -1,334 +1,16 @@
//! grok.com chat-product model catalog: caches `/rest/modes` and maps modes to //! Legacy `--chat` gateway gate.
//! the `SessionModelState` returned by `load_chat_session` (the chat analogue of //!
//! [`crate::agent::models::ModelsManager`]). NB: these "modes" populate the //! The grok.com chat-product model picker (`/rest/modes`, `ChatModesManager`)
//! desktop MODEL picker, not the ACP session plan-modes in `LoadSessionResponse.modes`. //! was removed with the xAI proxy: those "modes" came from a grok backend with
use crate::auth::AuthManager; //! no Kimi counterpart. Only the process-mode gate survives so the `--chat`
use crate::remote::chat_models_client::{ //! frontend path stays a compile-time-off no-op across crates without a
ChatModelsClient, ChatModelsError, ListModesResponse, Mode, //! cross-crate churn to delete every reference.
};
use agent_client_protocol as acp; /// Process-wide flag set by the pager when started with `--chat`.
use parking_lot::RwLock;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// ~54 min, matching grok-web's refetch cadence.
const CACHE_TTL: Duration = Duration::from_secs(54 * 60);
/// Cold-miss budget on the `session/load` critical path (warm/stale served instantly).
const COLD_FETCH_TIMEOUT: Duration = Duration::from_secs(2);
const DEFAULT_LOCALE: &str = "en";
/// Process-wide flag set by the pager when started with `--chat` so initialize
/// and early UI seed the chat `/rest/modes` catalog instead of build models.
pub const KIGI_CHAT_MODE_ENV: &str = "KIGI_CHAT_MODE"; pub const KIGI_CHAT_MODE_ENV: &str = "KIGI_CHAT_MODE";
/// True when the process is a gateway light-frontend (`--chat`) agent. /// True when the process is a gateway light-frontend (`--chat`) agent.
/// Hard-off in release builds so it can't be enabled via env. /// Hard-off: the grok chat-modes backend is gone, so this is always `false`.
pub fn process_chat_mode_enabled() -> bool { pub fn process_chat_mode_enabled() -> bool {
if true { false
return false;
}
match std::env::var(KIGI_CHAT_MODE_ENV) {
Ok(v) => {
let v = v.trim();
!v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
}
Err(_) => false,
}
}
#[derive(Clone)]
struct CachedModes {
/// Keyed by identity; a mismatch is a miss so one user's modes never leak to another.
user_id: String,
locale: String,
fetched_at: Instant,
response: ListModesResponse,
}
/// Thread-safe, cheaply-cloneable manager. Cloning bumps the inner `Arc`.
#[derive(Clone)]
pub struct ChatModesManager {
inner: Arc<Inner>,
}
struct Inner {
auth: Arc<AuthManager>,
cache: RwLock<Option<CachedModes>>,
/// Single-flight guard so concurrent fetches coalesce.
fetch_lock: tokio::sync::Mutex<()>,
}
impl ChatModesManager {
pub fn new(auth: Arc<AuthManager>) -> Self {
Self {
inner: Arc::new(Inner {
auth,
cache: RwLock::new(None),
fetch_lock: tokio::sync::Mutex::new(()),
}),
}
}
/// The active grok.com identity, or `None` when unauthenticated. Modes are
/// per-identity (tier/ACL), so every cache key and store is gated on it.
fn current_user_id(&self) -> Option<String> {
self.inner.auth.current_or_expired().map(|a| a.user_id)
}
/// Chat model state for a `session/load` response. On missing auth or fetch
/// failure, serves last-good cache else empty — never the build catalog.
pub async fn model_state(&self) -> acp::SessionModelState {
let Some(user_id) = self.current_user_id() else {
return empty_state();
};
let locale = DEFAULT_LOCALE;
{
let guard = self.inner.cache.read();
if let Some(c) = guard.as_ref()
&& c.user_id == user_id
&& c.locale == locale
{
if c.fetched_at.elapsed() < CACHE_TTL {
return modes_to_model_state(&c.response);
}
let stale = c.response.clone();
drop(guard);
self.spawn_refresh(user_id, locale);
return modes_to_model_state(&stale);
}
}
let _flight = self.inner.fetch_lock.lock().await;
{
let guard = self.inner.cache.read();
if let Some(c) = guard.as_ref()
&& c.user_id == user_id
&& c.locale == locale
&& c.fetched_at.elapsed() < CACHE_TTL
{
return modes_to_model_state(&c.response);
}
}
match self.fetch(locale).await {
Ok(resp) if !resp.modes.is_empty() => {
if self.current_user_id().as_deref() != Some(user_id.as_str()) {
return empty_state();
}
let mapped = modes_to_model_state(&resp);
if mapped.available_models.is_empty() {
tracing::warn!(
raw_modes = resp.modes.len(),
"chat modes: fetch returned modes but none selectable after availability filter"
);
}
self.store(user_id, locale.to_owned(), resp);
mapped
}
Ok(_) => empty_state(),
Err(err) => {
tracing::warn!(
error = % err, "chat modes fetch failed; serving cache/empty"
);
let guard = self.inner.cache.read();
match guard.as_ref() {
Some(c) if c.user_id == user_id => modes_to_model_state(&c.response),
_ => empty_state(),
}
}
}
}
async fn fetch(&self, locale: &str) -> Result<ListModesResponse, ChatModelsError> {
let client = ChatModelsClient::new(self.inner.auth.clone());
match tokio::time::timeout(COLD_FETCH_TIMEOUT, client.list_modes(locale)).await {
Ok(result) => result,
Err(_elapsed) => Err(ChatModelsError::Timeout),
}
}
fn store(&self, user_id: String, locale: String, response: ListModesResponse) {
*self.inner.cache.write() = Some(CachedModes {
user_id,
locale,
fetched_at: Instant::now(),
response,
});
}
/// Best-effort stale refresh; skips if a fetch is already in flight.
fn spawn_refresh(&self, user_id: String, locale: &'static str) {
let me = self.clone();
tokio::spawn(async move {
let Ok(_flight) = me.inner.fetch_lock.try_lock() else {
return;
};
if me.current_user_id().as_deref() != Some(user_id.as_str()) {
return;
}
if let Ok(resp) = me.fetch(locale).await
&& !resp.modes.is_empty()
&& me.current_user_id().as_deref() == Some(user_id.as_str())
{
me.store(user_id, locale.to_owned(), resp);
}
});
}
/// Kick a background `/rest/modes` fill when auth is already present so
/// `--chat` initialize / first `session/new` hit a warm cache.
pub fn warm_in_background(&self) {
let Some(user_id) = self.current_user_id() else {
return;
};
self.spawn_refresh(user_id, DEFAULT_LOCALE);
}
}
fn empty_state() -> acp::SessionModelState {
acp::SessionModelState::new(acp::ModelId::from(String::new()), Vec::new())
}
/// Maps grok.com modes → `SessionModelState`: keeps only `available` modes,
/// reconciles `current_model_id` (default → first available → empty, never
/// out-of-set), and stashes `badgeText`/`iconHint`/`tags` in `_meta`.
pub fn modes_to_model_state(resp: &ListModesResponse) -> acp::SessionModelState {
let available_models: Vec<acp::ModelInfo> = resp
.modes
.iter()
.filter(|m| m.is_available())
.map(mode_to_model_info)
.collect();
let current_model_id = reconcile_current(&resp.default_mode_id, &available_models);
acp::SessionModelState::new(current_model_id, available_models)
}
fn mode_to_model_info(m: &Mode) -> acp::ModelInfo {
let name = if m.title.trim().is_empty() {
m.id.clone()
} else {
m.title.clone()
};
acp::ModelInfo::new(acp::ModelId::from(m.id.clone()), name)
.description(if m.description.is_empty() {
None
} else {
Some(m.description.clone())
})
.meta(build_meta(m))
}
fn build_meta(m: &Mode) -> Option<acp::Meta> {
let mut map = serde_json::Map::new();
if let Some(badge) = m.badge_text.as_deref().filter(|s| !s.is_empty()) {
map.insert("badgeText".to_owned(), serde_json::json!(badge));
}
if !m.icon_hint.is_empty() {
map.insert("iconHint".to_owned(), serde_json::json!(m.icon_hint));
}
if !m.tags.is_empty() {
map.insert("tags".to_owned(), serde_json::json!(m.tags));
}
if map.is_empty() { None } else { Some(map) }
}
fn reconcile_current(default_mode_id: &str, available: &[acp::ModelInfo]) -> acp::ModelId {
let in_set = |id: &str| available.iter().any(|m| m.model_id.0.as_ref() == id);
if !default_mode_id.is_empty() && in_set(default_mode_id) {
acp::ModelId::from(default_mode_id.to_owned())
} else if let Some(first) = available.first() {
first.model_id.clone()
} else {
acp::ModelId::from(String::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::remote::chat_models_client::ModeAvailability;
fn available(id: &str, title: &str) -> Mode {
Mode {
id: id.to_owned(),
title: title.to_owned(),
availability: ModeAvailability {
available: Some(serde_json::json!({})),
..Default::default()
},
..Default::default()
}
}
fn requires_upgrade(id: &str) -> Mode {
Mode {
id: id.to_owned(),
availability: ModeAvailability {
requires_upgrade: Some(serde_json::json!({ "message" : "Upgrade" })),
..Default::default()
},
..Default::default()
}
}
#[test]
fn filters_to_available_modes() {
let resp = ListModesResponse {
modes: vec![
available("auto", "Auto"),
requires_upgrade("heavy"),
available("fast", "Fast"),
],
default_mode_id: "auto".to_owned(),
};
let state = modes_to_model_state(&resp);
let ids: Vec<String> = state
.available_models
.iter()
.map(|m| m.model_id.0.to_string())
.collect();
assert_eq!(ids, vec!["auto".to_string(), "fast".to_string()]);
assert_eq!(state.current_model_id.0.as_ref(), "auto");
}
#[test]
fn default_outside_filtered_set_falls_back_to_first_available() {
let resp = ListModesResponse {
modes: vec![requires_upgrade("heavy"), available("fast", "Fast")],
default_mode_id: "heavy".to_owned(),
};
let state = modes_to_model_state(&resp);
assert_eq!(state.current_model_id.0.as_ref(), "fast");
assert!(
state
.available_models
.iter()
.any(|m| m.model_id == state.current_model_id)
);
}
#[test]
fn empty_default_falls_back_to_first() {
let resp = ListModesResponse {
modes: vec![available("a", "A"), available("b", "B")],
default_mode_id: String::new(),
};
let state = modes_to_model_state(&resp);
assert_eq!(state.current_model_id.0.as_ref(), "a");
}
#[test]
fn no_available_modes_yields_empty_current() {
let resp = ListModesResponse {
modes: vec![requires_upgrade("heavy")],
default_mode_id: "heavy".to_owned(),
};
let state = modes_to_model_state(&resp);
assert!(state.available_models.is_empty());
assert_eq!(state.current_model_id.0.as_ref(), "");
}
#[test]
fn maps_fields_and_meta() {
let mut m = available("auto", "Auto");
m.description = "Picks the best model".to_owned();
m.badge_text = Some("New".to_owned());
m.icon_hint = "rocket".to_owned();
m.tags = vec!["TAG_PRIMARY".to_owned()];
let resp = ListModesResponse {
modes: vec![m],
default_mode_id: "auto".to_owned(),
};
let state = modes_to_model_state(&resp);
let info = &state.available_models[0];
assert_eq!(info.name, "Auto");
assert_eq!(info.description.as_deref(), Some("Picks the best model"));
let meta = info.meta.as_ref().unwrap();
assert_eq!(meta["badgeText"], serde_json::json!("New"));
assert_eq!(meta["iconHint"], serde_json::json!("rocket"));
assert_eq!(meta["tags"], serde_json::json!(["TAG_PRIMARY"]));
}
#[test]
fn name_falls_back_to_id_when_title_blank() {
let mut m = available("grok-4.5", "");
m.title = " ".to_owned();
let resp = ListModesResponse {
modes: vec![m],
default_mode_id: String::new(),
};
let state = modes_to_model_state(&resp);
assert_eq!(state.available_models[0].name, "grok-4.5");
}
} }
+67 -128
View File
@@ -1,6 +1,6 @@
use crate::agent::auth_method::ModelByok; use crate::agent::auth_method::ModelByok;
use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
use crate::auth::{AuthManager, KimiCodeConfig}; use crate::auth::{AuthManager, KimiCodeConfig};
use crate::remote::DEFAULT_CONTEXT_WINDOW;
use crate::{config::StorageMode, sampling::ApiBackend, tools::config::ShellToolsetConfig}; use crate::{config::StorageMode, sampling::ApiBackend, tools::config::ShellToolsetConfig};
use agent_client_protocol as acp; use agent_client_protocol as acp;
use indexmap::IndexMap; use indexmap::IndexMap;
@@ -141,7 +141,7 @@ pub struct EndpointsConfig {
/// `Some` = explicitly configured. Tracking explicitness (vs comparing to the /// `Some` = explicitly configured. Tracking explicitness (vs comparing to the
/// default value) lets an org pin the proxy to the default on purpose. /// default value) lets an org pin the proxy to the default on purpose.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cli_chat_proxy_base_url: Option<String>, pub coding_api_base_url: Option<String>,
/// Base URL for the public xAI API. /// Base URL for the public xAI API.
pub xai_api_base_url: String, pub xai_api_base_url: String,
/// Optional extra access-header value (applied only with the optional /// Optional extra access-header value (applied only with the optional
@@ -271,11 +271,11 @@ impl EndpointsConfig {
resolved resolved
} }
/// The subscription proxy base URL through which all auxiliary services (and /// The subscription proxy base URL through which all auxiliary services (and
/// OAuth/session inference) resolve: explicit `cli_chat_proxy_base_url`, else /// OAuth/session inference) resolve: explicit `coding_api_base_url`, else
/// [`kigi_env::coding_api_base_url`]. NEVER falls back to `xai_api_base_url` — /// [`kigi_env::coding_api_base_url`]. NEVER falls back to `xai_api_base_url` —
/// that is the inference endpoint (API-key auth) only. /// that is the inference endpoint (API-key auth) only.
pub fn proxy_url(&self) -> String { pub fn proxy_url(&self) -> String {
blank_as_unset(&self.cli_chat_proxy_base_url).unwrap_or_else(kigi_env::coding_api_base_url) blank_as_unset(&self.coding_api_base_url).unwrap_or_else(kigi_env::coding_api_base_url)
} }
pub fn resolve_inference_base_url(&self) -> String { pub fn resolve_inference_base_url(&self) -> String {
self.models_base_url self.models_base_url
@@ -406,7 +406,7 @@ impl EndpointsConfig {
impl Default for EndpointsConfig { impl Default for EndpointsConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
cli_chat_proxy_base_url: std::env::var("KIGI_CLI_CHAT_PROXY_BASE_URL").ok(), coding_api_base_url: std::env::var("KIGI_CODE_BASE_URL").ok(),
xai_api_base_url: std::env::var("KIGI_XAI_API_BASE_URL") xai_api_base_url: std::env::var("KIGI_XAI_API_BASE_URL")
.unwrap_or_else(|_| XAI_API_BASE_URL_DEFAULT.to_owned()), .unwrap_or_else(|_| XAI_API_BASE_URL_DEFAULT.to_owned()),
alpha_test_key: None, alpha_test_key: None,
@@ -4169,19 +4169,11 @@ pub fn resolve_aux_model_sampling_config(
endpoints: &EndpointsConfig, endpoints: &EndpointsConfig,
session_key: Option<&str>, session_key: Option<&str>,
alpha_test_key: Option<String>, alpha_test_key: Option<String>,
client_version: Option<String>,
) -> Option<SamplerConfig> { ) -> Option<SamplerConfig> {
let catalog_entry = find_model_by_id(models, model_id).cloned(); let catalog_entry = find_model_by_id(models, model_id).cloned();
if let Some(entry) = &catalog_entry { if let Some(entry) = &catalog_entry {
let credentials = resolve_credentials(entry, session_key); let credentials = resolve_credentials(entry, session_key);
let sampler = sampling_config_for_model( let sampler = sampling_config_for_model(entry, credentials, alpha_test_key.clone());
entry,
credentials,
alpha_test_key.clone(),
client_version.clone(),
None,
None,
);
if sampler.api_key.is_some() { if sampler.api_key.is_some() {
return Some(sampler); return Some(sampler);
} }
@@ -4232,14 +4224,7 @@ pub fn resolve_aux_model_sampling_config(
api_base_url: None, api_base_url: None,
}; };
let credentials = resolve_credentials(&entry, session_key); let credentials = resolve_credentials(&entry, session_key);
let sampler = sampling_config_for_model( let sampler = sampling_config_for_model(&entry, credentials, alpha_test_key);
&entry,
credentials,
alpha_test_key,
client_version,
None,
None,
);
return Some(sampler); return Some(sampler);
} }
tracing::warn!( tracing::warn!(
@@ -4252,21 +4237,19 @@ pub fn resolve_aux_model_sampling_config(
/// Shared so the aux resolve happy path and the /// Shared so the aux resolve happy path and the
/// `None` fallback cannot diverge between those entry points. /// `None` fallback cannot diverge between those entry points.
/// ///
/// On aux resolve `Some`, stamp session-local fields (client id, attribution, bearer, /// On aux resolve `Some`, stamp session-local fields (attribution, bearer,
/// retries) onto the helper config. On `None`, fall back to the active session model and /// retries) onto the helper config. On `None`, fall back to the active session model and
/// full config (not forcing `image_description_model` onto the agent endpoint, which 404s /// full config (not forcing `image_description_model` onto the agent endpoint, which 404s
/// on BYOK / non-proxy routes for internal slugs like `grok-build`). /// on BYOK / non-proxy routes for internal slugs).
/// Stamp the session-local fields (client id, attribution, bearer resolver, /// Stamp the session-local fields (attribution, bearer resolver, retries)
/// retries) from the active session onto a routed aux `SamplerConfig` so a /// from the active session onto a routed aux `SamplerConfig` so a
/// helper model keeps the session's auth/attribution. Shared by image-describe /// helper model keeps the session's auth/attribution. Shared by image-describe
/// and the auto-mode classifier so the two can't drift. /// and the auto-mode classifier so the two can't drift.
pub fn stamp_session_local_sampler_fields( pub fn stamp_session_local_sampler_fields(
cfg: &mut SamplerConfig, cfg: &mut SamplerConfig,
active_session_config: &SamplerConfig, active_session_config: &SamplerConfig,
client_identifier: Option<String>,
max_retries: Option<u32>, max_retries: Option<u32>,
) { ) {
cfg.client_identifier = client_identifier;
cfg.attribution_callback = active_session_config.attribution_callback.clone(); cfg.attribution_callback = active_session_config.attribution_callback.clone();
cfg.bearer_resolver = active_session_config.bearer_resolver.clone(); cfg.bearer_resolver = active_session_config.bearer_resolver.clone();
cfg.max_retries = max_retries; cfg.max_retries = max_retries;
@@ -4274,7 +4257,6 @@ pub fn stamp_session_local_sampler_fields(
pub fn finalize_image_describe_sampler_config( pub fn finalize_image_describe_sampler_config(
resolved_aux: Option<SamplerConfig>, resolved_aux: Option<SamplerConfig>,
active_session_config: &SamplerConfig, active_session_config: &SamplerConfig,
client_identifier: Option<String>,
max_retries: Option<u32>, max_retries: Option<u32>,
) -> (String, SamplerConfig) { ) -> (String, SamplerConfig) {
match resolved_aux { match resolved_aux {
@@ -4282,7 +4264,6 @@ pub fn finalize_image_describe_sampler_config(
stamp_session_local_sampler_fields( stamp_session_local_sampler_fields(
&mut describe_cfg, &mut describe_cfg,
active_session_config, active_session_config,
client_identifier,
max_retries, max_retries,
); );
let model = describe_cfg.model.clone(); let model = describe_cfg.model.clone();
@@ -4310,9 +4291,6 @@ pub fn sampling_config_for_model(
model: &ModelEntry, model: &ModelEntry,
credentials: ResolvedCredentials, credentials: ResolvedCredentials,
alpha_test_key: Option<String>, alpha_test_key: Option<String>,
client_version: Option<String>,
deployment_id: Option<String>,
user_id: Option<String>,
) -> SamplerConfig { ) -> SamplerConfig {
let info = model.info(); let info = model.info();
let model_name = info.model.clone(); let model_name = info.model.clone();
@@ -4337,15 +4315,11 @@ pub fn sampling_config_for_model(
auth_scheme: credentials.auth_scheme, auth_scheme: credentials.auth_scheme,
extra_headers, extra_headers,
context_window: info.context_window.get(), context_window: info.context_window.get(),
client_version,
reasoning_effort: info.reasoning_effort, reasoning_effort: info.reasoning_effort,
force_http1: false, force_http1: false,
max_retries: info.max_retries, max_retries: info.max_retries,
stream_tool_calls: info.stream_tool_calls.unwrap_or(false), stream_tool_calls: info.stream_tool_calls.unwrap_or(false),
idle_timeout_secs: None, idle_timeout_secs: None,
client_identifier: None,
deployment_id,
user_id,
origin_client: None, origin_client: None,
attribution_callback: None, attribution_callback: None,
bearer_resolver: None, bearer_resolver: None,
@@ -4359,11 +4333,19 @@ pub fn sampling_config_for_model(
/// Fold URL-derived headers into `extra_headers`. /// Fold URL-derived headers into `extra_headers`.
/// ///
/// The sampler crate is intentionally URL-agnostic: it does not inspect /// The sampler crate is intentionally URL-agnostic: it does not inspect
/// `base_url` to decide which auth or staging headers to add. Replicate the /// `base_url` to decide which auth or identity headers to add. Replicate the
/// URL-derived header logic at the shell boundary so callers downstream see a /// URL-derived header logic at the shell boundary so callers downstream see a
/// single homogenous header bag. /// single homogenous header bag.
/// ///
/// * First-party bases get the client-mode header. /// * First-party (Kimi subscription) bases get the `X-Msh-Device-*` identity
/// headers, mirroring the official client, which sends its OAuth device
/// headers on every inference request (kimi-cli src/kimi_cli/llm.py:317-323
/// `_kimi_default_headers` merges `oauth.common_headers()`). Third-party /
/// Moonshot-open-platform bases get none — only the bearer and User-Agent.
///
/// A device-id failure only skips the headers (with a warning): inference
/// must not hard-fail because `~/.kigi/device_id` is unwritable — unlike
/// OAuth login, where the id is mandatory.
/// ///
/// Existing entries are never overwritten so callers can pre-set a value. /// Existing entries are never overwritten so callers can pre-set a value.
pub fn inject_url_derived_headers( pub fn inject_url_derived_headers(
@@ -4371,10 +4353,20 @@ pub fn inject_url_derived_headers(
alpha_test_key: Option<&str>, alpha_test_key: Option<&str>,
base_url: &str, base_url: &str,
) { ) {
if crate::util::is_cli_chat_proxy_url(base_url) { if crate::util::is_production_coding_api_url(base_url) {
headers match crate::auth::device_headers() {
.entry(crate::http::CLIENT_MODE_HEADER.to_string()) Ok(device_headers) => {
.or_insert_with(|| crate::http::process_client_mode().to_string()); for (name, value) in device_headers {
headers.entry(name.to_string()).or_insert(value);
}
}
Err(e) => {
tracing::warn!(
error = %e,
"device identity headers unavailable; sending inference request without them"
);
}
}
} }
let _ = (alpha_test_key, base_url); let _ = (alpha_test_key, base_url);
} }
@@ -4383,7 +4375,6 @@ pub fn resolve_model_to_sampling_config(
models: &IndexMap<String, ModelEntry>, models: &IndexMap<String, ModelEntry>,
session_key: Option<&str>, session_key: Option<&str>,
alpha_test_key: Option<String>, alpha_test_key: Option<String>,
client_version: Option<String>,
fallback_entry: Option<ModelEntry>, fallback_entry: Option<ModelEntry>,
) -> Option<SamplerConfig> { ) -> Option<SamplerConfig> {
let entry = find_model_by_id(models, model_id) let entry = find_model_by_id(models, model_id)
@@ -4394,16 +4385,12 @@ pub fn resolve_model_to_sampling_config(
&entry, &entry,
credentials, credentials,
alpha_test_key, alpha_test_key,
client_version,
None,
None,
)) ))
} }
fn resolve_hidden_default_web_search_sampling_config( fn resolve_hidden_default_web_search_sampling_config(
model_id: &str, model_id: &str,
session_key: Option<&str>, session_key: Option<&str>,
alpha_test_key: Option<String>, alpha_test_key: Option<String>,
client_version: Option<String>,
endpoints: &EndpointsConfig, endpoints: &EndpointsConfig,
) -> SamplerConfig { ) -> SamplerConfig {
let entry = ModelEntry { let entry = ModelEntry {
@@ -4445,21 +4432,13 @@ fn resolve_hidden_default_web_search_sampling_config(
api_base_url: None, api_base_url: None,
}; };
let credentials = resolve_credentials(&entry, session_key); let credentials = resolve_credentials(&entry, session_key);
sampling_config_for_model( sampling_config_for_model(&entry, credentials, alpha_test_key)
&entry,
credentials,
alpha_test_key,
client_version,
None,
None,
)
} }
pub fn resolve_web_search_sampling_config( pub fn resolve_web_search_sampling_config(
model_id: &str, model_id: &str,
models: &IndexMap<String, ModelEntry>, models: &IndexMap<String, ModelEntry>,
session_key: Option<&str>, session_key: Option<&str>,
alpha_test_key: Option<String>, alpha_test_key: Option<String>,
client_version: Option<String>,
endpoints: &EndpointsConfig, endpoints: &EndpointsConfig,
) -> Option<SamplerConfig> { ) -> Option<SamplerConfig> {
let resolved = if let Some(entry) = find_model_by_id(models, model_id).cloned() { let resolved = if let Some(entry) = find_model_by_id(models, model_id).cloned() {
@@ -4468,16 +4447,12 @@ pub fn resolve_web_search_sampling_config(
&entry, &entry,
credentials, credentials,
alpha_test_key, alpha_test_key,
client_version,
None,
None,
)) ))
} else if model_id == crate::models::default_web_search_model() { } else if model_id == crate::models::default_web_search_model() {
Some(resolve_hidden_default_web_search_sampling_config( Some(resolve_hidden_default_web_search_sampling_config(
model_id, model_id,
session_key, session_key,
alpha_test_key, alpha_test_key,
client_version,
endpoints, endpoints,
)) ))
} else { } else {
@@ -4757,21 +4732,23 @@ reasoning_effort = "low"
} }
} }
#[test] #[test]
fn inject_url_derived_headers_adds_client_mode_for_first_party_url() { fn inject_url_derived_headers_adds_device_identity_for_first_party_url() {
let mut headers = IndexMap::new(); let mut headers = IndexMap::new();
inject_url_derived_headers( inject_url_derived_headers(
&mut headers, &mut headers,
None, None,
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url, kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
); );
assert!(headers.get(crate::http::CLIENT_MODE_HEADER).is_some()); assert!(headers.get("X-Msh-Device-Id").is_some());
assert!(headers.get("X-Msh-Device-Name").is_some());
assert!(headers.get("X-XAI-Token-Auth").is_none()); assert!(headers.get("X-XAI-Token-Auth").is_none());
} }
#[test] #[test]
fn inject_url_derived_headers_skips_headers_for_external_url() { fn inject_url_derived_headers_skips_headers_for_external_url() {
let mut headers = IndexMap::new(); let mut headers = IndexMap::new();
inject_url_derived_headers(&mut headers, None, "https://api.example.com/v1"); inject_url_derived_headers(&mut headers, None, "https://api.example.com/v1");
assert!(headers.get(crate::http::CLIENT_MODE_HEADER).is_none()); assert!(headers.get("X-Msh-Device-Id").is_none());
assert!(headers.get("X-Msh-Device-Name").is_none());
} }
#[test] #[test]
fn inject_url_derived_headers_preserves_caller_extra_headers() { fn inject_url_derived_headers_preserves_caller_extra_headers() {
@@ -4924,7 +4901,6 @@ reasoning_effort = "low"
&IndexMap::new(), &IndexMap::new(),
Some("session-token"), Some("session-token"),
None, None,
None,
&endpoints, &endpoints,
) )
.expect("hidden default web search model should resolve"); .expect("hidden default web search model should resolve");
@@ -4943,7 +4919,7 @@ reasoning_effort = "low"
model: "composer-session-model".into(), model: "composer-session-model".into(),
..Default::default() ..Default::default()
}; };
let (model, cfg) = finalize_image_describe_sampler_config(None, &active, None, Some(3)); let (model, cfg) = finalize_image_describe_sampler_config(None, &active, Some(3));
assert_eq!(model, "composer-session-model"); assert_eq!(model, "composer-session-model");
assert_eq!(cfg.model, "composer-session-model"); assert_eq!(cfg.model, "composer-session-model");
assert_ne!(cfg.model, "grok-build"); assert_ne!(cfg.model, "grok-build");
@@ -4958,11 +4934,9 @@ reasoning_effort = "low"
model: "grok-build".into(), model: "grok-build".into(),
..Default::default() ..Default::default()
}; };
let (model, cfg) = let (model, cfg) = finalize_image_describe_sampler_config(Some(aux), &active, Some(7));
finalize_image_describe_sampler_config(Some(aux), &active, Some("cli".into()), Some(7));
assert_eq!(model, "grok-build"); assert_eq!(model, "grok-build");
assert_eq!(cfg.model, "grok-build"); assert_eq!(cfg.model, "grok-build");
assert_eq!(cfg.client_identifier.as_deref(), Some("cli"));
assert_eq!(cfg.max_retries, Some(7)); assert_eq!(cfg.max_retries, Some(7));
} }
#[test] #[test]
@@ -4980,7 +4954,7 @@ reasoning_effort = "low"
), ),
); );
let resolved = let resolved =
resolve_aux_model_sampling_config("grok-build", &catalog, &endpoints, None, None, None) resolve_aux_model_sampling_config("grok-build", &catalog, &endpoints, None, None)
.expect("override entry has an API key, so resolution succeeds"); .expect("override entry has an API key, so resolution succeeds");
assert_eq!(resolved.model, "v9m-rl-learnability-tp8"); assert_eq!(resolved.model, "v9m-rl-learnability-tp8");
assert_eq!(resolved.base_url, "https://vendor.example/v1"); assert_eq!(resolved.base_url, "https://vendor.example/v1");
@@ -5085,14 +5059,8 @@ reasoning_effort = "low"
None, None,
None, None,
); );
let sampling_config = sampling_config_for_model( let sampling_config =
&model, sampling_config_for_model(&model, resolve_credentials(&model, None), None);
resolve_credentials(&model, None),
None,
None,
None,
None,
);
assert_eq!( assert_eq!(
sampling_config.api_key, sampling_config.api_key,
Some("model-specific-key".to_string()) Some("model-specific-key".to_string())
@@ -5111,9 +5079,6 @@ reasoning_effort = "low"
auth_scheme: AuthScheme::Bearer, auth_scheme: AuthScheme::Bearer,
}, },
None, None,
None,
None,
None,
); );
assert_eq!(sampling_config.api_key, Some("fallback-key".to_string())); assert_eq!(sampling_config.api_key, Some("fallback-key".to_string()));
} }
@@ -5388,14 +5353,8 @@ reasoning_effort = "low"
None, None,
); );
model.info.api_backend = ApiBackend::Messages; model.info.api_backend = ApiBackend::Messages;
let config = sampling_config_for_model( let config =
&model, sampling_config_for_model(&model, resolve_credentials(&model, Some("tok")), None);
resolve_credentials(&model, Some("tok")),
None,
None,
None,
None,
);
assert_eq!(config.api_backend, ApiBackend::Messages); assert_eq!(config.api_backend, ApiBackend::Messages);
assert_eq!(config.auth_scheme, AuthScheme::Bearer); assert_eq!(config.auth_scheme, AuthScheme::Bearer);
assert_eq!(config.api_key, Some("tok".to_string())); assert_eq!(config.api_key, Some("tok".to_string()));
@@ -5440,7 +5399,7 @@ reasoning_effort = "low"
assert_eq!(creds.auth_scheme, AuthScheme::XApiKey); assert_eq!(creds.auth_scheme, AuthScheme::XApiKey);
assert_eq!(creds.auth_type, kigi_chat_state::AuthType::ApiKey); assert_eq!(creds.auth_type, kigi_chat_state::AuthType::ApiKey);
assert_eq!(creds.api_key, Some("sk-ant-test-key".to_string())); assert_eq!(creds.api_key, Some("sk-ant-test-key".to_string()));
let config = sampling_config_for_model(&model, creds, None, None, None, None); let config = sampling_config_for_model(&model, creds, None);
assert_eq!(config.auth_scheme, AuthScheme::XApiKey); assert_eq!(config.auth_scheme, AuthScheme::XApiKey);
assert_eq!(config.api_backend, ApiBackend::Messages); assert_eq!(config.api_backend, ApiBackend::Messages);
let client = kigi_sampler::SamplingClient::new(config).expect("client should build"); let client = kigi_sampler::SamplingClient::new(config).expect("client should build");
@@ -5459,7 +5418,7 @@ reasoning_effort = "low"
assert_eq!(model.info.auth_scheme, AuthScheme::Bearer); assert_eq!(model.info.auth_scheme, AuthScheme::Bearer);
let creds = resolve_credentials(&model, None); let creds = resolve_credentials(&model, None);
assert_eq!(creds.auth_scheme, AuthScheme::Bearer); assert_eq!(creds.auth_scheme, AuthScheme::Bearer);
let config = sampling_config_for_model(&model, creds, None, None, None, None); let config = sampling_config_for_model(&model, creds, None);
assert_eq!(config.auth_scheme, AuthScheme::Bearer); assert_eq!(config.auth_scheme, AuthScheme::Bearer);
let client = kigi_sampler::SamplingClient::new(config).expect("client should build"); let client = kigi_sampler::SamplingClient::new(config).expect("client should build");
let info = client.auth_info(); let info = client.auth_info();
@@ -5771,25 +5730,11 @@ reasoning_effort = "low"
#[test] #[test]
fn sampling_config_context_window_from_entry_or_default() { fn sampling_config_context_window_from_entry_or_default() {
let model = test_model_entry("any-model", "https://api.x.ai/v1", None, None, None); let model = test_model_entry("any-model", "https://api.x.ai/v1", None, None, None);
let config = sampling_config_for_model( let config = sampling_config_for_model(&model, resolve_credentials(&model, None), None);
&model,
resolve_credentials(&model, None),
None,
None,
None,
None,
);
assert_eq!(config.context_window, 200_000); assert_eq!(config.context_window, 200_000);
let mut model = test_model_entry("any-model", "https://api.x.ai/v1", None, None, None); let mut model = test_model_entry("any-model", "https://api.x.ai/v1", None, None, None);
model.info.context_window = NonZeroU64::new(256_000).unwrap(); model.info.context_window = NonZeroU64::new(256_000).unwrap();
let config = sampling_config_for_model( let config = sampling_config_for_model(&model, resolve_credentials(&model, None), None);
&model,
resolve_credentials(&model, None),
None,
None,
None,
None,
);
assert_eq!(config.context_window, 256_000); assert_eq!(config.context_window, 256_000);
} }
#[test] #[test]
@@ -5917,14 +5862,8 @@ reasoning_effort = "low"
let mut model = let mut model =
test_model_entry("test-model", "https://api.example.com/v1", None, None, None); test_model_entry("test-model", "https://api.example.com/v1", None, None, None);
model.info.api_backend = ApiBackend::Responses; model.info.api_backend = ApiBackend::Responses;
let sampling_config = sampling_config_for_model( let sampling_config =
&model, sampling_config_for_model(&model, resolve_credentials(&model, None), None);
resolve_credentials(&model, None),
None,
None,
None,
None,
);
assert_eq!(sampling_config.api_backend, ApiBackend::Responses); assert_eq!(sampling_config.api_backend, ApiBackend::Responses);
} }
#[test] #[test]
@@ -6636,7 +6575,7 @@ reasoning_effort = "low"
} }
fn resolve_sampling(model: &ModelEntry, session_key: Option<&str>) -> SamplerConfig { fn resolve_sampling(model: &ModelEntry, session_key: Option<&str>) -> SamplerConfig {
let credentials = resolve_credentials(model, session_key); let credentials = resolve_credentials(model, session_key);
sampling_config_for_model(model, credentials, None, None, None, None) sampling_config_for_model(model, credentials, None)
} }
#[test] #[test]
#[serial] #[serial]
@@ -7022,7 +6961,7 @@ reasoning_effort = "low"
&format!( &format!(
r#" r#"
[endpoints] [endpoints]
cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1" coding_api_base_url = "https://enterprise-proxy.acme.com/v1"
[model."{BUNDLED_DEFAULT_KEY}"] [model."{BUNDLED_DEFAULT_KEY}"]
api_key = "acme-api-key" api_key = "acme-api-key"
@@ -7052,14 +6991,14 @@ reasoning_effort = "low"
let (_, models) = resolve_models_from_toml( let (_, models) = resolve_models_from_toml(
r#" r#"
[endpoints] [endpoints]
cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1" coding_api_base_url = "https://enterprise-proxy.acme.com/v1"
"#, "#,
None, None,
); );
let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist"); let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist");
assert_eq!( assert_eq!(
model.info.base_url, "https://enterprise-proxy.acme.com/v1", model.info.base_url, "https://enterprise-proxy.acme.com/v1",
"default model should use enterprise cli_chat_proxy_base_url" "default model should use enterprise coding_api_base_url"
); );
// The open-platform fallback entries keep their fixed moonshot bases; // The open-platform fallback entries keep their fixed moonshot bases;
// only the subscription entry follows the proxy override. // only the subscription entry follows the proxy override.
@@ -7073,7 +7012,7 @@ reasoning_effort = "low"
/// the ambient environment. Gated behind `#[serial]`. /// the ambient environment. Gated behind `#[serial]`.
fn unset_endpoint_env_vars() { fn unset_endpoint_env_vars() {
for k in [ for k in [
"KIGI_CLI_CHAT_PROXY_BASE_URL", "KIGI_CODE_BASE_URL",
kigi_env::CODE_BASE_URL_ENV, kigi_env::CODE_BASE_URL_ENV,
"KIGI_XAI_API_BASE_URL", "KIGI_XAI_API_BASE_URL",
"KIGI_FEEDBACK_BASE_URL", "KIGI_FEEDBACK_BASE_URL",
@@ -7101,7 +7040,7 @@ reasoning_effort = "low"
let inference = "https://inference.acme-corp.example/xai/v1"; let inference = "https://inference.acme-corp.example/xai/v1";
let cfg = EndpointsConfig { let cfg = EndpointsConfig {
xai_api_base_url: inference.to_string(), xai_api_base_url: inference.to_string(),
cli_chat_proxy_base_url: None, coding_api_base_url: None,
..Default::default() ..Default::default()
}; };
let proxy = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url; let proxy = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url;
@@ -7119,7 +7058,7 @@ reasoning_effort = "low"
); );
assert_eq!(cfg.xai_api_base_url, inference); assert_eq!(cfg.xai_api_base_url, inference);
let overridden = EndpointsConfig { let overridden = EndpointsConfig {
cli_chat_proxy_base_url: Some("https://proxy.enterprise.example/v1".to_string()), coding_api_base_url: Some("https://proxy.enterprise.example/v1".to_string()),
managed_config_url: Some( managed_config_url: Some(
"https://control.enterprise.example/deployment/config".to_string(), "https://control.enterprise.example/deployment/config".to_string(),
), ),
@@ -7159,7 +7098,7 @@ reasoning_effort = "low"
.unwrap(), .unwrap(),
) )
.expect("config should parse"); .expect("config should parse");
assert!(cfg.endpoints.cli_chat_proxy_base_url.is_none()); assert!(cfg.endpoints.coding_api_base_url.is_none());
assert_eq!( assert_eq!(
cfg.endpoints.resolve_managed_config_url(), cfg.endpoints.resolve_managed_config_url(),
format!( format!(
@@ -7181,7 +7120,7 @@ reasoning_effort = "low"
&format!( &format!(
r#" r#"
[endpoints] [endpoints]
cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1" coding_api_base_url = "https://enterprise-proxy.acme.com/v1"
[model."{dm}"] [model."{dm}"]
base_url = "https://my-special-proxy.example.com/v1" base_url = "https://my-special-proxy.example.com/v1"
@@ -8656,7 +8595,7 @@ agent_type = "cursor"
fn otlp_traces_endpoint_precedence() { fn otlp_traces_endpoint_precedence() {
let proxy = "https://inference.acme.com/v1".to_string(); let proxy = "https://inference.acme.com/v1".to_string();
let derived = EndpointsConfig { let derived = EndpointsConfig {
cli_chat_proxy_base_url: Some(proxy.clone()), coding_api_base_url: Some(proxy.clone()),
..Default::default() ..Default::default()
}; };
assert_eq!( assert_eq!(
@@ -8664,7 +8603,7 @@ agent_type = "cursor"
"https://inference.acme.com/v1/traces" "https://inference.acme.com/v1/traces"
); );
let base = EndpointsConfig { let base = EndpointsConfig {
cli_chat_proxy_base_url: Some(proxy.clone()), coding_api_base_url: Some(proxy.clone()),
otel_exporter_otlp_endpoint: Some("https://otel.acme.com".to_string()), otel_exporter_otlp_endpoint: Some("https://otel.acme.com".to_string()),
..Default::default() ..Default::default()
}; };
@@ -8673,7 +8612,7 @@ agent_type = "cursor"
"https://otel.acme.com/v1/traces" "https://otel.acme.com/v1/traces"
); );
let full = EndpointsConfig { let full = EndpointsConfig {
cli_chat_proxy_base_url: Some(proxy), coding_api_base_url: Some(proxy),
otel_exporter_otlp_endpoint: Some("https://ignored.example".to_string()), otel_exporter_otlp_endpoint: Some("https://ignored.example".to_string()),
otel_exporter_otlp_traces_endpoint: Some("https://otel.acme.com/v1/traces".to_string()), otel_exporter_otlp_traces_endpoint: Some("https://otel.acme.com/v1/traces".to_string()),
..Default::default() ..Default::default()
@@ -8702,7 +8641,7 @@ agent_type = "cursor"
/// explicitly unset so ambient env (via `Default`) can't leak in. /// explicitly unset so ambient env (via `Default`) can't leak in.
fn internal_otlp_test_config() -> EndpointsConfig { fn internal_otlp_test_config() -> EndpointsConfig {
EndpointsConfig { EndpointsConfig {
cli_chat_proxy_base_url: Some("https://proxy.example/v1".to_string()), coding_api_base_url: Some("https://proxy.example/v1".to_string()),
otel_exporter_otlp_endpoint: None, otel_exporter_otlp_endpoint: None,
otel_exporter_otlp_traces_endpoint: None, otel_exporter_otlp_traces_endpoint: None,
otel_exporter_otlp_headers: None, otel_exporter_otlp_headers: None,
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,2 @@
pub(crate) mod model_switch; pub(crate) mod model_switch;
pub(crate) mod session; pub(crate) mod session;
pub(crate) mod workspaces;
@@ -271,19 +271,10 @@ async fn handle_session_list(
// (never union) so every list surface is conversations-only. // (never union) so every list surface is conversations-only.
let req = unified_list::parse_list_req(args.params.get()) let req = unified_list::parse_list_req(args.params.get())
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?; .map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
tracing::debug!( tracing::debug!("session/list");
chat_mode_forced_kind = crate::agent::chat_modes::process_chat_mode_enabled(),
"session/list"
);
let registry_client = agent.session_registry_client(); let registry_client = agent.session_registry_client();
let conversations_client = agent.conversations_client(); let result = unified_list::build_unified_list(registry_client.as_ref(), req).await;
let result = unified_list::build_unified_list(
registry_client.as_ref(),
conversations_client.as_ref(),
req,
)
.await;
ExtMethodResult::success(unified_list::ext_list_response(result)) ExtMethodResult::success(unified_list::ext_list_response(result))
.to_ext_response() .to_ext_response()
@@ -1,174 +0,0 @@
use agent_client_protocol::{self as acp};
use serde::{Deserialize, Serialize};
use super::super::mvp_agent::MvpAgent;
use crate::remote::{ListWorkspacesPage, WsError, WsQuery};
use crate::session::ExtMethodResult;
const DEFAULT_PAGE_SIZE: i64 = 50;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorkspacesListRequest {
#[serde(default)]
page_size: Option<i64>,
#[serde(default)]
page_token: Option<String>,
#[serde(default)]
query: Option<String>,
#[serde(default)]
kind: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct WorkspaceRow {
id: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
create_time: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct WorkspacesListResponse {
workspaces: Vec<WorkspaceRow>,
#[serde(skip_serializing_if = "Option::is_none")]
next_page_token: Option<String>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
meta: Option<WorkspacesMeta>,
}
#[derive(Debug, Serialize)]
struct WorkspacesMeta {
#[serde(rename = "x.ai/partial")]
partial: PartialInfo,
}
#[derive(Debug, Serialize)]
struct PartialInfo {
workspaces: bool,
reason: &'static str,
}
pub async fn handle(
agent: &MvpAgent,
args: &acp::ExtRequest,
) -> Result<acp::ExtResponse, acp::Error> {
let req: WorkspacesListRequest = serde_json::from_str(args.params.get())
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
let q = WsQuery {
// Clamp to a sane positive page size: a missing, zero, or negative
// `pageSize` falls back to the default rather than being forwarded
// verbatim to `/rest/workspaces`.
page_size: match req.page_size {
Some(n) if n > 0 => n,
_ => DEFAULT_PAGE_SIZE,
},
page_token: req.page_token,
query: req.query,
kind: req.kind,
};
let response = match agent.workspaces_client().list_workspaces(&q).await {
Ok(page) => success_response(page),
Err(WsError::NoOauth) => degraded_response("no_oauth"),
Err(e) => {
// Degrade to a partial result, but don't silently swallow the
// cause — log it so field failures are diagnosable.
tracing::warn!("workspaces/list fetch failed: {e}");
degraded_response("error")
}
};
ExtMethodResult::success(response)
.to_ext_response()
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
}
fn success_response(page: ListWorkspacesPage) -> WorkspacesListResponse {
WorkspacesListResponse {
workspaces: page
.workspaces
.into_iter()
.map(|w| WorkspaceRow {
id: w.workspace_id,
name: w.name,
kind: w.kind,
create_time: w.create_time,
})
.collect(),
next_page_token: page.next_page_token,
meta: None,
}
}
fn degraded_response(reason: &'static str) -> WorkspacesListResponse {
WorkspacesListResponse {
workspaces: Vec::new(),
next_page_token: None,
meta: Some(WorkspacesMeta {
partial: PartialInfo {
workspaces: true,
reason,
},
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::remote::Workspace;
#[test]
fn request_parses_camelcase_and_defaults_page_size() {
let req: WorkspacesListRequest =
serde_json::from_value(serde_json::json!({})).expect("empty params parse");
assert!(req.page_size.is_none());
let req: WorkspacesListRequest = serde_json::from_value(serde_json::json!({
"pageSize": 10,
"pageToken": "tok",
"query": "gpu",
"kind": "WORKSPACE_KIND_IMAGINE"
}))
.expect("full params parse");
assert_eq!(req.page_size, Some(10));
assert_eq!(req.page_token.as_deref(), Some("tok"));
assert_eq!(req.query.as_deref(), Some("gpu"));
assert_eq!(req.kind.as_deref(), Some("WORKSPACE_KIND_IMAGINE"));
}
#[test]
fn success_response_projects_grok_workspace_fields() {
let page = ListWorkspacesPage {
workspaces: vec![Workspace {
workspace_id: "ws_1".into(),
name: "Research".into(),
create_time: Some("2026-06-18T17:30:00Z".into()),
kind: Some("WORKSPACE_KIND_IMAGINE".into()),
}],
next_page_token: Some("tok2".into()),
};
let value = serde_json::to_value(success_response(page)).unwrap();
assert_eq!(value["workspaces"][0]["id"], "ws_1");
assert_eq!(value["workspaces"][0]["name"], "Research");
assert_eq!(value["workspaces"][0]["kind"], "WORKSPACE_KIND_IMAGINE");
assert_eq!(value["workspaces"][0]["createTime"], "2026-06-18T17:30:00Z");
assert_eq!(value["nextPageToken"], "tok2");
assert!(value.get("_meta").is_none());
}
#[test]
fn degraded_response_carries_partial_reason() {
let value = serde_json::to_value(degraded_response("no_oauth")).unwrap();
assert_eq!(value["workspaces"].as_array().unwrap().len(), 0);
assert!(value.get("nextPageToken").is_none());
assert_eq!(value["_meta"]["x.ai/partial"]["workspaces"], true);
assert_eq!(value["_meta"]["x.ai/partial"]["reason"], "no_oauth");
}
}
@@ -73,27 +73,6 @@ fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig
tracing::info!(field = %e.path, value = %e.value, source = %e.source, "policy override"); tracing::info!(field = %e.path, value = %e.value, source = %e.source, "policy override");
} }
// Fallback: if the client didn't pre-supply remote settings, fetch them
// now so remote-settings-gated features work regardless of which client
// spawned us. Clients that already call `start_early_prefetch()` and
// thread the result into `cfg.remote_settings` skip this entirely.
if cfg.remote_settings.is_none()
&& let Some(handle) =
crate::agent::models::start_early_prefetch(Some(cfg.kimi_code_config.clone()))
{
match handle.join() {
Ok(result) => {
cfg.remote_settings = result.settings;
crate::util::config::set_remote_campaigns_from_settings(
cfg.remote_settings.as_ref(),
);
tracing::info!("remote_settings fetched as shell-level fallback");
}
Err(_) => {
tracing::warn!("remote_settings fallback prefetch thread panicked");
}
}
}
crate::util::config::sync_campaign_fields(&mut cfg); crate::util::config::sync_campaign_fields(&mut cfg);
crate::agent::config::apply_remote_settings_side_effects(cfg.remote_settings.as_ref()); crate::agent::config::apply_remote_settings_side_effects(cfg.remote_settings.as_ref());
+2 -1
View File
@@ -5,11 +5,12 @@ pub mod chat_modes;
pub mod config; pub mod config;
pub mod config_model_override_parse; pub mod config_model_override_parse;
mod ext_parsers; mod ext_parsers;
pub mod feedback_client; pub(crate) mod feedback_client;
pub mod folder_trust; pub mod folder_trust;
pub(crate) mod handlers; pub(crate) mod handlers;
pub mod init; pub mod init;
pub mod models; pub mod models;
pub(crate) mod models_fetch;
pub mod mvp_agent; pub mod mvp_agent;
pub(crate) mod proxy; pub(crate) mod proxy;
pub(crate) mod restore_code; pub(crate) mod restore_code;
+38 -63
View File
@@ -10,8 +10,8 @@ use chrono::{DateTime, Duration as ChronoDuration, Utc};
use indexmap::IndexMap; use indexmap::IndexMap;
use crate::agent::config::{self, ModelEntry, resolve_credentials, sampling_config_for_model}; use crate::agent::config::{self, ModelEntry, resolve_credentials, sampling_config_for_model};
use crate::agent::models_fetch::{FetchModelsResult, fetch_models_blocking};
use crate::auth::{AuthManager, KimiAuth, KimiCodeConfig}; use crate::auth::{AuthManager, KimiAuth, KimiCodeConfig};
use crate::remote::{FetchModelsResult, fetch_models_blocking};
use crate::sampling::SamplerConfig as SamplingConfig; use crate::sampling::SamplerConfig as SamplingConfig;
use globset::{Glob, GlobSet, GlobSetBuilder}; use globset::{Glob, GlobSet, GlobSetBuilder};
use kigi_sampling_types::{ReasoningEffort, ReasoningEffortOption}; use kigi_sampling_types::{ReasoningEffort, ReasoningEffortOption};
@@ -290,7 +290,7 @@ impl ModelsManager {
cache cache
.load_fresh( .load_fresh(
&fetch_auth.cache_auth_method(), &fetch_auth.cache_auth_method(),
&crate::remote::models_fetch_origin( &crate::agent::models_fetch::models_fetch_origin(
&cfg.endpoints, &cfg.endpoints,
fetch_auth, fetch_auth,
has_session, has_session,
@@ -1035,11 +1035,6 @@ impl ModelsManager {
current_model, current_model,
credentials, credentials,
config.endpoints.alpha_test_key.clone(), config.endpoints.alpha_test_key.clone(),
config.client_version.clone(),
crate::managed_config::resolve_deployment_id(
config.endpoints.deployment_key.as_deref(),
),
None,
) )
} }
@@ -1053,7 +1048,12 @@ impl ModelsManager {
let fetch_auth = *self.inner.fetch_auth.read(); let fetch_auth = *self.inner.fetch_auth.read();
let has_oauth = self.inner.auth_manager.current_or_expired().is_some(); let has_oauth = self.inner.auth_manager.current_or_expired().is_some();
let platform_keys = PlatformApiKeys::resolve(&platforms); let platform_keys = PlatformApiKeys::resolve(&platforms);
crate::remote::models_fetch_origin(&endpoints, fetch_auth, has_oauth, &platform_keys) crate::agent::models_fetch::models_fetch_origin(
&endpoints,
fetch_auth,
has_oauth,
&platform_keys,
)
} }
fn try_load_cache(&self) -> bool { fn try_load_cache(&self) -> bool {
@@ -1327,7 +1327,7 @@ struct ModelsCache {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
auth_method: Option<CacheAuthMethod>, auth_method: Option<CacheAuthMethod>,
/// Models-list URL this catalog was fetched from /// Models-list URL this catalog was fetched from
/// ([`crate::remote::models_list_url`]). Compared on load so a cache /// ([`crate::agent::models_fetch::models_fetch_origin`]). Compared on load so a cache
/// written against one backend is a miss for another: entries embed /// written against one backend is a miss for another: entries embed
/// absolute `base_url`s, so adopting a foreign-origin cache silently /// absolute `base_url`s, so adopting a foreign-origin cache silently
/// re-points inference (the windows lifecycle e2e failed exactly this /// re-points inference (the windows lifecycle e2e failed exactly this
@@ -1575,43 +1575,6 @@ pub(crate) fn prefetch_models_blocking(
.models .models
} }
/// Blocking models + `/v1/settings` prefetch pair, shared by the early
/// prefetch thread and the leader's startup phase so the settings gate lives
/// once. The remote_fetch knob is resolved a single time so the two fetch
/// decisions cannot disagree mid-startup.
pub(crate) fn prefetch_models_and_settings_blocking(
endpoints: &config::EndpointsConfig,
auth: Option<&KimiAuth>,
fetch_auth: ModelFetchAuth,
platform_keys: &PlatformApiKeys,
) -> (
Option<IndexMap<String, ModelEntry>>,
Option<crate::util::config::RemoteSettings>,
) {
let remote_fetch_enabled = crate::util::config::resolve_remote_fetch_enabled();
let models = prefetch_models_blocking_gated(
endpoints,
auth,
fetch_auth,
platform_keys,
remote_fetch_enabled,
)
.models;
// Settings need a subscription session; skip for API-key-only setups.
let settings = match auth {
Some(auth) if remote_fetch_enabled => {
let _timer = crate::instrumentation_timer!("startup.early_settings_fetch");
crate::remote::fetch_settings_blocking(
&endpoints.proxy_url(),
auth,
endpoints.alpha_test_key.as_deref(),
)
}
_ => None,
};
(models, settings)
}
/// `remote_fetch_enabled` is a parameter so the pair helper above resolves the /// `remote_fetch_enabled` is a parameter so the pair helper above resolves the
/// knob once for both halves. /// knob once for both halves.
fn prefetch_models_blocking_gated( fn prefetch_models_blocking_gated(
@@ -1624,8 +1587,12 @@ fn prefetch_models_blocking_gated(
let cache_auth = fetch_auth.cache_auth_method(); let cache_auth = fetch_auth.cache_auth_method();
// Same fetch plan the network path below executes — the cache is only // Same fetch plan the network path below executes — the cache is only
// valid for it. // valid for it.
let cache_origin = let cache_origin = crate::agent::models_fetch::models_fetch_origin(
crate::remote::models_fetch_origin(endpoints, fetch_auth, auth.is_some(), platform_keys); endpoints,
fetch_auth,
auth.is_some(),
platform_keys,
);
let cache = ModelsCacheManager::new(); let cache = ModelsCacheManager::new();
if let Some(cached) = cache.load_fresh(&cache_auth, &cache_origin) { if let Some(cached) = cache.load_fresh(&cache_auth, &cache_origin) {
tracing::info!( tracing::info!(
@@ -1703,10 +1670,9 @@ fn stale_cache_or_failure(
ModelsFetchOutcome::failed(oauth_unauthorized) ModelsFetchOutcome::failed(oauth_unauthorized)
} }
/// Startup prefetch result: models + remote settings. /// Startup prefetch result: the model catalog, when a fetch plan existed.
pub struct EarlyPrefetchResult { pub struct EarlyPrefetchResult {
pub models: Option<IndexMap<String, ModelEntry>>, pub models: Option<IndexMap<String, ModelEntry>>,
pub settings: Option<crate::util::config::RemoteSettings>,
} }
/// Handle for a startup prefetch thread. /// Handle for a startup prefetch thread.
@@ -1743,7 +1709,7 @@ fn resolve_prefetch_env_with_auth(auth: Option<KimiAuth>) -> Option<PrefetchEnv>
/// `has_custom_endpoint()` (which otherwise forces the prefetch to run): the /// `has_custom_endpoint()` (which otherwise forces the prefetch to run): the
/// explicit off switch must hold even when a stray login, a platform API key, /// explicit off switch must hold even when a stray login, a platform API key,
/// or a `deployment_key` would re-arm the prefetch — and with it the /// or a `deployment_key` would re-arm the prefetch — and with it the
/// `/v1/settings` fetch and the deployment-config sync on the prefetch thread. /// deployment-config sync on the prefetch thread.
/// ///
/// PRD F2 acceptance: a moonshot API key alone (no subscription login) must /// PRD F2 acceptance: a moonshot API key alone (no subscription login) must
/// arm the prefetch so the catalog syncs on startup. /// arm the prefetch so the catalog syncs on startup.
@@ -1754,7 +1720,7 @@ fn resolve_prefetch_env_from_parts(
remote_fetch_enabled: bool, remote_fetch_enabled: bool,
) -> Option<PrefetchEnv> { ) -> Option<PrefetchEnv> {
if !remote_fetch_enabled { if !remote_fetch_enabled {
tracing::info!("startup model/settings prefetch skipped: remote_fetch disabled"); tracing::info!("startup model prefetch skipped: remote_fetch disabled");
return None; return None;
} }
@@ -1779,7 +1745,7 @@ fn resolve_prefetch_env(kimi_code_config: Option<KimiCodeConfig>) -> Option<Pref
resolve_prefetch_env_with_auth(auth) resolve_prefetch_env_with_auth(auth)
} }
/// Start model + settings prefetch on a background thread using pre-resolved auth. /// Start the model-catalog prefetch on a background thread using pre-resolved auth.
/// ///
/// When the caller has already obtained valid credentials (e.g. via /// When the caller has already obtained valid credentials (e.g. via
/// `try_ensure_fresh_auth`), pass them here to avoid re-reading stale cached /// `try_ensure_fresh_auth`), pass them here to avoid re-reading stale cached
@@ -1789,7 +1755,7 @@ pub fn start_early_prefetch_with_auth(auth: Option<KimiAuth>) -> Option<EarlyPre
Some(spawn_prefetch_thread(env)) Some(spawn_prefetch_thread(env))
} }
/// Start model + settings prefetch on a background thread. /// Start the model-catalog prefetch on a background thread.
/// ///
/// Convenience wrapper that reads cached auth from disk. Prefer /// Convenience wrapper that reads cached auth from disk. Prefer
/// `start_early_prefetch_with_auth` when you have pre-resolved credentials. /// `start_early_prefetch_with_auth` when you have pre-resolved credentials.
@@ -1805,7 +1771,7 @@ fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
let mut timer = crate::instrumentation_timer!("startup.early_prefetch"); let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
let proxy_endpoint = env.endpoints.proxy_url(); let proxy_endpoint = env.endpoints.proxy_url();
timer.with_field("endpoint", proxy_endpoint.as_str()); timer.with_field("endpoint", proxy_endpoint.as_str());
let (models, settings) = prefetch_models_and_settings_blocking( let models = prefetch_models_blocking(
&env.endpoints, &env.endpoints,
env.auth.as_ref(), env.auth.as_ref(),
env.model_fetch_auth, env.model_fetch_auth,
@@ -1824,7 +1790,7 @@ fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
let _ = rt.block_on(crate::managed_config::sync()); let _ = rt.block_on(crate::managed_config::sync());
} }
EarlyPrefetchResult { models, settings } EarlyPrefetchResult { models }
}) })
} }
@@ -3868,7 +3834,7 @@ mod tests {
fn proxied_endpoints(server_uri: &str) -> config::EndpointsConfig { fn proxied_endpoints(server_uri: &str) -> config::EndpointsConfig {
config::EndpointsConfig { config::EndpointsConfig {
cli_chat_proxy_base_url: Some(server_uri.to_string()), coding_api_base_url: Some(server_uri.to_string()),
models_base_url: None, models_base_url: None,
models_list_url: None, models_list_url: None,
..config::EndpointsConfig::default() ..config::EndpointsConfig::default()
@@ -3900,7 +3866,7 @@ mod tests {
..KimiAuth::test_default() ..KimiAuth::test_default()
}; };
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
crate::remote::fetch_models_blocking( crate::agent::models_fetch::fetch_models_blocking(
&endpoints, &endpoints,
Some(&auth), Some(&auth),
ModelFetchAuth::Platforms, ModelFetchAuth::Platforms,
@@ -3969,7 +3935,12 @@ mod tests {
let endpoints = config::EndpointsConfig::default(); let endpoints = config::EndpointsConfig::default();
let keys = PlatformApiKeys::test_keys(Some("sk-cn-secret"), None); let keys = PlatformApiKeys::test_keys(Some("sk-cn-secret"), None);
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
crate::remote::fetch_models_blocking(&endpoints, None, ModelFetchAuth::Platforms, &keys) crate::agent::models_fetch::fetch_models_blocking(
&endpoints,
None,
ModelFetchAuth::Platforms,
&keys,
)
}) })
.await .await
.unwrap() .unwrap()
@@ -4062,7 +4033,7 @@ mod tests {
auth_manager.set_refresher(Arc::new(SwapRefresher)); auth_manager.set_refresher(Arc::new(SwapRefresher));
let mut cfg = config::Config::default(); let mut cfg = config::Config::default();
cfg.endpoints.cli_chat_proxy_base_url = Some(server.uri()); cfg.endpoints.coding_api_base_url = Some(server.uri());
let mgr = ModelsManager::new( let mgr = ModelsManager::new(
None, None,
IndexMap::new(), IndexMap::new(),
@@ -4120,8 +4091,12 @@ mod tests {
assert!(bundled.contains_key("moonshot-ai/kimi-k2-turbo-preview")); assert!(bundled.contains_key("moonshot-ai/kimi-k2-turbo-preview"));
// 2. A STALE cache for the same fetch plan is served on sync failure. // 2. A STALE cache for the same fetch plan is served on sync failure.
let origin = let origin = crate::agent::models_fetch::models_fetch_origin(
crate::remote::models_fetch_origin(&endpoints, ModelFetchAuth::Platforms, true, &keys); &endpoints,
ModelFetchAuth::Platforms,
true,
&keys,
);
let cache = ModelsCacheManager::new(); let cache = ModelsCacheManager::new();
let stale = ModelsCache { let stale = ModelsCache {
fetched_at: Utc::now() - ChronoDuration::seconds(86_400), fetched_at: Utc::now() - ChronoDuration::seconds(86_400),
@@ -4203,7 +4178,7 @@ mod tests {
let _cache = EnvGuard::set("KIGI_MODELS_CACHE_DIR", cache_dir.path().to_str().unwrap()); let _cache = EnvGuard::set("KIGI_MODELS_CACHE_DIR", cache_dir.path().to_str().unwrap());
let endpoints = proxied_endpoints("http://127.0.0.1:9"); let endpoints = proxied_endpoints("http://127.0.0.1:9");
// Cache written when a moonshot key was ALSO configured... // Cache written when a moonshot key was ALSO configured...
let with_key_origin = crate::remote::models_fetch_origin( let with_key_origin = crate::agent::models_fetch::models_fetch_origin(
&endpoints, &endpoints,
ModelFetchAuth::Platforms, ModelFetchAuth::Platforms,
true, true,
File diff suppressed because it is too large Load Diff
@@ -48,7 +48,6 @@ impl acp::Agent for MvpAgent {
); );
}); });
kigi_workspace::trust::migrate_legacy_hook_trust(); kigi_workspace::trust::migrate_legacy_hook_trust();
self.maybe_sync_bundle_in_background(false);
let mut client_type = arguments let mut client_type = arguments
.meta .meta
.as_ref() .as_ref()
@@ -278,11 +277,7 @@ impl acp::Agent for MvpAgent {
} }
self.spawn_initialize_launch_mcp_setup(fetch_managed_mcps); self.spawn_initialize_launch_mcp_setup(fetch_managed_mcps);
self.spawn_managed_gateway_tool_catalog_fetch(); self.spawn_managed_gateway_tool_catalog_fetch();
let init_model_state = if crate::agent::chat_modes::process_chat_mode_enabled() { let init_model_state = self.model_state(None);
self.chat_modes.model_state().await
} else {
self.model_state(None)
};
Ok( Ok(
acp::InitializeResponse::new(acp::ProtocolVersion::V1) acp::InitializeResponse::new(acp::ProtocolVersion::V1)
.agent_capabilities( .agent_capabilities(
@@ -374,9 +369,6 @@ impl acp::Agent for MvpAgent {
} }
} }
self.set_auth_method(arguments.method_id.clone()); self.set_auth_method(arguments.method_id.clone());
if crate::agent::chat_modes::process_chat_mode_enabled() {
self.chat_modes.warm_in_background();
}
emit_login_span(true, "api_key", None, None); emit_login_span(true, "api_key", None, None);
Ok(Default::default()) Ok(Default::default())
} }
@@ -421,9 +413,7 @@ impl acp::Agent for MvpAgent {
.authenticate_after_cached_token_unavailable(arguments) .authenticate_after_cached_token_unavailable(arguments)
.await; .await;
}; };
self.refresh_remote_settings(&auth).await;
self.emit_settings_update_notification(); self.emit_settings_update_notification();
self.maybe_sync_bundle_in_background(false);
{ {
let mut sampling_config = self.sampling_config.borrow_mut(); let mut sampling_config = self.sampling_config.borrow_mut();
sampling_config.api_key = Some(auth.key); sampling_config.api_key = Some(auth.key);
@@ -437,12 +427,8 @@ impl acp::Agent for MvpAgent {
); );
} }
self.set_auth_method(arguments.method_id.clone()); self.set_auth_method(arguments.method_id.clone());
if crate::agent::chat_modes::process_chat_mode_enabled() {
self.chat_modes.warm_in_background();
}
let uid = self.auth_manager.current().map(|a| a.user_id); let uid = self.auth_manager.current().map(|a| a.user_id);
emit_login_span(true, "cached_token", uid.as_deref(), None); emit_login_span(true, "cached_token", uid.as_deref(), None);
self.maybe_fetch_post_auth_settings().await;
Ok(self.auth_response_with_meta()) Ok(self.auth_response_with_meta())
} }
auth_method::KIGI_COM_METHOD_ID => { auth_method::KIGI_COM_METHOD_ID => {
@@ -517,21 +503,15 @@ impl acp::Agent for MvpAgent {
); );
} }
self.auth_manager.hot_swap(auth.clone()); self.auth_manager.hot_swap(auth.clone());
self.refresh_remote_settings(&auth).await;
self.emit_settings_update_notification(); self.emit_settings_update_notification();
self.maybe_sync_bundle_in_background(false);
self.set_auth_method(arguments.method_id.clone()); self.set_auth_method(arguments.method_id.clone());
self.models_manager.on_auth_changed().await; self.models_manager.on_auth_changed().await;
if crate::agent::chat_modes::process_chat_mode_enabled() {
self.chat_modes.warm_in_background();
}
emit_login_span( emit_login_span(
true, true,
arguments.method_id.0.as_ref(), arguments.method_id.0.as_ref(),
Some(auth.user_id.as_str()), Some(auth.user_id.as_str()),
None, None,
); );
self.maybe_fetch_post_auth_settings().await;
Ok(self.auth_response_with_meta()) Ok(self.auth_response_with_meta())
} }
_ => { _ => {
@@ -559,9 +539,7 @@ impl acp::Agent for MvpAgent {
.data("initialize must be called before new_session") .data("initialize must be called before new_session")
})?; })?;
self.seed_client_config_auth_if_available(); self.seed_client_config_auth_if_available();
if let Ok(auth) = self.auth_manager.auth().await { self.refresh_settings_and_reapply().await;
self.refresh_settings_and_reapply(&auth).await;
}
let cwd = AbsPathBuf::new(arguments.cwd.clone()) let cwd = AbsPathBuf::new(arguments.cwd.clone())
.map_err(|e| acp::Error::invalid_params().data(e.to_string()))?; .map_err(|e| acp::Error::invalid_params().data(e.to_string()))?;
let remote_settings = self.cfg.borrow().remote_settings.clone(); let remote_settings = self.cfg.borrow().remote_settings.clone();
@@ -858,8 +836,10 @@ impl acp::Agent for MvpAgent {
Some(serde_json::json!({ "cwd" : cwd.as_str() })), Some(serde_json::json!({ "cwd" : cwd.as_str() })),
); );
let models = if is_chat_kind { let models = if is_chat_kind {
// The grok.com chat-mode model picker was removed with the xAI
// proxy; a chat-kind session has no managed catalog to offer.
chat_new_session_model_state( chat_new_session_model_state(
self.chat_modes.model_state().await, acp::SessionModelState::new(acp::ModelId::from(String::new()), Vec::new()),
session_initial_model session_initial_model
.filter(|_| matches!(bridge_attach, BridgeAttach::Spawned)), .filter(|_| matches!(bridge_attach, BridgeAttach::Spawned)),
) )
@@ -982,14 +962,6 @@ impl acp::Agent for MvpAgent {
.build_summary_client(&load_session_sampling)?; .build_summary_client(&load_session_sampling)?;
let mut persistence_timer = crate::instrumentation_timer!("session.load_light"); let mut persistence_timer = crate::instrumentation_timer!("session.load_light");
persistence_timer.with_field("session_id", session_id.0.as_ref()); persistence_timer.with_field("session_id", session_id.0.as_ref());
let backend = if self.build_registry_config().is_some() {
Some(
crate::remote::BackendClient::new()
.with_auth_manager(self.auth_manager.clone()),
)
} else {
None
};
let registry_title_sync = self let registry_title_sync = self
.session_registry_client() .session_registry_client()
.map(|client| crate::session::persistence::RegistryGeneratedTitleSync { .map(|client| crate::session::persistence::RegistryGeneratedTitleSync {
@@ -999,9 +971,6 @@ impl acp::Agent for MvpAgent {
let (persistence_info, persistence) = crate::session::persistence::load_light( let (persistence_info, persistence) = crate::session::persistence::load_light(
&session_info, &session_info,
summary_client, summary_client,
self.storage_mode,
Some(self.auth_manager.clone()),
backend.as_ref(),
Some(self.gateway.clone()), Some(self.gateway.clone()),
summary_model, summary_model,
registry_title_sync, registry_title_sync,
@@ -2095,9 +2064,6 @@ impl acp::Agent for MvpAgent {
| "x.ai/sessions/list" => { | "x.ai/sessions/list" => {
crate::agent::handlers::session::handle(self, &args).await crate::agent::handlers::session::handle(self, &args).await
} }
"x.ai/workspaces/list" => {
crate::agent::handlers::workspaces::handle(self, &args).await
}
"x.ai/session/updates" => { "x.ai/session/updates" => {
crate::extensions::session_updates::handle(&args, &self.gateway).await crate::extensions::session_updates::handle(&args, &self.gateway).await
} }
@@ -2122,6 +2088,7 @@ impl acp::Agent for MvpAgent {
crate::extensions::session_admin::handle(self, &args).await crate::extensions::session_admin::handle(self, &args).await
} }
"x.ai/session/repair" => crate::extensions::repair::handle(self, &args).await, "x.ai/session/repair" => crate::extensions::repair::handle(self, &args).await,
"x.ai/billing" => crate::extensions::billing::handle(self, &args).await,
"x.ai/memory/flush" | "x.ai/memory/rewrite" => { "x.ai/memory/flush" | "x.ai/memory/rewrite" => {
crate::extensions::memory::handle(self, &args).await crate::extensions::memory::handle(self, &args).await
} }
@@ -2136,206 +2103,6 @@ impl acp::Agent for MvpAgent {
crate::extensions::feedback::handle(self, &args).await crate::extensions::feedback::handle(self, &args).await
} }
"x.ai/recap" => crate::extensions::recap::handle(self, &args).await, "x.ai/recap" => crate::extensions::recap::handle(self, &args).await,
"x.ai/cloud/terminate" => {
crate::extensions::auth_gate::require_xai_auth(
&self.auth_manager,
"Authentication required",
"Run `grok login` to authenticate.",
)?;
let params: serde_json::Value = serde_json::from_str(args.params.get())
.map_err(|e| acp::Error::invalid_params().data(e.to_string()))?;
let sandbox_id = params
.get("sandbox_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
acp::Error::invalid_params().data("missing sandbox_id")
})?;
let sandbox_client = crate::remote::SandboxClient::new(
self.cli_chat_proxy_base_url(),
self.auth_manager.clone(),
);
sandbox_client
.terminate_session(
sandbox_id,
&crate::remote::SandboxTerminateRequest {
environment_id: None,
},
)
.await
.map_err(|e| {
acp::Error::internal_error()
.data(format!("Failed to terminate sandbox: {e}"))
})?;
crate::extensions::to_raw_response(&serde_json::json!({ "ok" : true }))
}
"x.ai/cloud/env/list" => {
crate::extensions::auth_gate::require_xai_auth(
&self.auth_manager,
"Authentication required",
"Run `grok login` to authenticate.",
)?;
let sandbox_client = crate::remote::SandboxClient::new(
self.cli_chat_proxy_base_url(),
self.auth_manager.clone(),
);
let resp = sandbox_client
.list_environments(
&crate::remote::SandboxListEnvironmentsRequest::default(),
)
.await
.map_err(|e| {
acp::Error::internal_error()
.data(format!("Failed to list environments: {e}"))
})?;
crate::extensions::to_raw_response(
&serde_json::json!({ "environments" : resp.environments, }),
)
}
"x.ai/cloud/env/create" => {
crate::extensions::auth_gate::require_xai_auth(
&self.auth_manager,
"Authentication required",
"Run `grok login` to authenticate.",
)?;
let params: serde_json::Value = serde_json::from_str(args.params.get())
.map_err(|e| acp::Error::invalid_params().data(e.to_string()))?;
let sandbox_client = crate::remote::SandboxClient::new(
self.cli_chat_proxy_base_url(),
self.auth_manager.clone(),
);
let resp = sandbox_client
.create_environment(
&crate::remote::SandboxCreateEnvironmentRequest {
name: params
.get("name")
.and_then(|v| v.as_str())
.map(String::from),
description: params
.get("description")
.and_then(|v| v.as_str())
.map(String::from),
repository: params
.get("repository")
.and_then(|v| v.as_str())
.map(String::from),
default_branch: params
.get("default_branch")
.and_then(|v| v.as_str())
.map(String::from),
container_image: params
.get("container_image")
.and_then(|v| v.as_str())
.map(String::from),
setup_script: params
.get("setup_script")
.and_then(|v| v.as_str())
.map(String::from),
workspace_directory: Some("/workspace".to_string()),
internet_enabled: Some(true),
domain_allowlist_preset: Some("common".to_string()),
allowed_http_methods: Some("all".to_string()),
..Default::default()
},
)
.await
.map_err(|e| {
acp::Error::internal_error()
.data(format!("Failed to create environment: {e}"))
})?;
crate::extensions::to_raw_response(
&serde_json::json!({ "environment" : resp.environment, }),
)
}
"x.ai/cloud/env/update" => {
crate::extensions::auth_gate::require_xai_auth(
&self.auth_manager,
"Authentication required",
"Run `grok login` to authenticate.",
)?;
let params: serde_json::Value = serde_json::from_str(args.params.get())
.map_err(|e| acp::Error::invalid_params().data(e.to_string()))?;
let environment_id = params
.get("environment_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
acp::Error::invalid_params().data("missing environment_id")
})?;
let sandbox_client = crate::remote::SandboxClient::new(
self.cli_chat_proxy_base_url(),
self.auth_manager.clone(),
);
let resp = sandbox_client
.update_environment(
environment_id,
&crate::remote::SandboxUpdateEnvironmentRequest {
name: params
.get("name")
.and_then(|v| v.as_str())
.map(String::from),
description: params
.get("description")
.and_then(|v| v.as_str())
.map(String::from),
repository: params
.get("repository")
.and_then(|v| v.as_str())
.map(String::from),
default_branch: params
.get("default_branch")
.and_then(|v| v.as_str())
.map(String::from),
container_image: params
.get("container_image")
.and_then(|v| v.as_str())
.map(String::from),
setup_script: params
.get("setup_script")
.and_then(|v| v.as_str())
.map(String::from),
..Default::default()
},
)
.await
.map_err(|e| {
acp::Error::internal_error()
.data(format!("Failed to update environment: {e}"))
})?;
crate::extensions::to_raw_response(
&serde_json::json!({ "environment" : resp.environment, }),
)
}
"x.ai/cloud/env/delete" => {
crate::extensions::auth_gate::require_xai_auth(
&self.auth_manager,
"Authentication required",
"Run `grok login` to authenticate.",
)?;
let params: serde_json::Value = serde_json::from_str(args.params.get())
.map_err(|e| acp::Error::invalid_params().data(e.to_string()))?;
let environment_id = params
.get("environment_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
acp::Error::invalid_params().data("missing environment_id")
})?;
let sandbox_client = crate::remote::SandboxClient::new(
self.cli_chat_proxy_base_url(),
self.auth_manager.clone(),
);
sandbox_client
.delete_environment(environment_id)
.await
.map_err(|e| {
acp::Error::internal_error()
.data(format!("Failed to delete environment: {e}"))
})?;
crate::extensions::to_raw_response(&serde_json::json!({ "ok" : true }))
}
"x.ai/billing" => crate::extensions::billing::handle(self, &args).await,
"x.ai/auto-topup-rule" => {
crate::extensions::billing::handle(self, &args).await
}
"x.ai/share_session" => crate::extensions::share::handle(self, &args).await,
"x.ai/rollout/survey" => { "x.ai/rollout/survey" => {
crate::extensions::rollout::handle(self, &args).await crate::extensions::rollout::handle(self, &args).await
} }
@@ -2395,9 +2162,6 @@ impl acp::Agent for MvpAgent {
s if s.starts_with("x.ai/search/") => { s if s.starts_with("x.ai/search/") => {
crate::extensions::search::handle(self, &args).await crate::extensions::search::handle(self, &args).await
} }
s if s.starts_with("x.ai/bundle/") => {
crate::extensions::bundle::handle(self, &args).await
}
s if s.starts_with("x.ai/code/") => { s if s.starts_with("x.ai/code/") => {
let ops = self.resolve_workspace_ops()?; let ops = self.resolve_workspace_ops()?;
crate::extensions::code_nav::handle(self, &ops, &args).await crate::extensions::code_nav::handle(self, &ops, &args).await
@@ -28,23 +28,15 @@ impl MvpAgent {
let session_key = self.auth_manager.current_or_expired().map(|a| a.key.clone()); let session_key = self.auth_manager.current_or_expired().map(|a| a.key.clone());
let models = self.models_manager.models(); let models = self.models_manager.models();
let endpoints = self.models_manager.endpoints(); let endpoints = self.models_manager.endpoints();
let (alpha_test_key, client_version) = { let alpha_test_key = self.cfg.borrow().endpoints.alpha_test_key.clone();
let cfg = self.cfg.borrow();
(
cfg.endpoints.alpha_test_key.clone(),
cfg.client_version.clone(),
)
};
let config = match crate::agent::config::resolve_aux_model_sampling_config( let config = match crate::agent::config::resolve_aux_model_sampling_config(
&slug, &slug,
&models, &models,
&endpoints, &endpoints,
session_key.as_deref(), session_key.as_deref(),
alpha_test_key, alpha_test_key,
client_version,
) { ) {
Some(mut cfg) => { Some(mut cfg) => {
cfg.client_identifier = primary.client_identifier.clone();
cfg.attribution_callback = primary.attribution_callback.clone(); cfg.attribution_callback = primary.attribution_callback.clone();
cfg.bearer_resolver = primary.bearer_resolver.clone(); cfg.bearer_resolver = primary.bearer_resolver.clone();
cfg.max_retries = primary.max_retries; cfg.max_retries = primary.max_retries;
@@ -60,10 +52,6 @@ impl MvpAgent {
let client = OaiCompatClient::new(config).map_err(map_sampling_err_to_acp)?; let client = OaiCompatClient::new(config).map_err(map_sampling_err_to_acp)?;
Ok((client, model)) Ok((client, model))
} }
fn has_proxy_credentials(&self) -> bool {
self.cfg.borrow().endpoints.deployment_key.is_some()
|| self.auth_manager.current_or_expired().is_some_and(|a| a.is_session_auth())
}
/// `true` for session-based ACP auth methods. /// `true` for session-based ACP auth methods.
fn is_session_based_auth(&self) -> bool { fn is_session_based_auth(&self) -> bool {
self.auth_method_id self.auth_method_id
@@ -384,39 +372,23 @@ impl MvpAgent {
); );
} }
} }
/// Extract feedback credentials when proxy credentials are available. /// Feedback endpoint base when this is a subscription (OAuth) session —
/// /// the Kimi Code feedback endpoint only takes the OAuth Bearer, so
/// Returns `(base_url, user_token, optional_extra_access_key, deployment_key)`. /// API-key-only setups get `None` (they are pointed at the issue
/// Used by both [`feedback_client`] and session spawning to avoid /// tracker instead; kimi-cli slash.py parity).
/// duplicating the credential assembly logic. fn feedback_base_url(&self) -> Option<String> {
#[allow(clippy::type_complexity)] let has_session = self
fn feedback_credentials(
&self,
) -> Option<(String, Option<String>, Option<String>, Option<String>)> {
if !self.has_proxy_credentials() {
return None;
}
let user_token = self
.auth_manager .auth_manager
.current_or_expired() .current_or_expired()
.filter(|a| a.is_session_auth()) .is_some_and(|a| a.is_session_auth());
.map(|a| a.key.clone()); has_session.then(|| self.cfg.borrow().endpoints.resolve_feedback_base_url())
let cfg = self.cfg.borrow();
let base_url = cfg.endpoints.resolve_feedback_base_url();
let alpha_test_key = cfg.endpoints.alpha_test_key.clone();
let deployment_key = cfg.endpoints.deployment_key.clone();
Some((base_url, user_token, alpha_test_key, deployment_key))
} }
/// Build a `FeedbackClient` with resolved feedback URL and credentials. /// Build a `FeedbackClient` for subscription sessions.
pub(crate) fn feedback_client(&self) -> Option<FeedbackClient> { pub(crate) fn feedback_client(&self) -> Option<FeedbackClient> {
let (base_url, user_token, alpha_test_key, deployment_key) = self Some(FeedbackClient::new(
.feedback_credentials()?; self.feedback_base_url()?,
Some( self.auth_manager.clone(),
FeedbackClient::new(base_url, user_token) ))
.with_alpha_test_key(alpha_test_key)
.with_deployment_key(deployment_key)
.with_auth_manager(self.auth_manager.clone()),
)
} }
/// Build a `RegistryConfig` if the feature is enabled (for passing to persistence actor). /// Build a `RegistryConfig` if the feature is enabled (for passing to persistence actor).
pub(super) fn build_registry_config( pub(super) fn build_registry_config(
@@ -460,17 +432,6 @@ impl MvpAgent {
.with_auth(self.auth_manager.clone()), .with_auth(self.auth_manager.clone()),
) )
} }
pub(crate) fn conversations_client(
&self,
) -> Option<crate::remote::ConversationsClient> {
if !crate::session::unified_list::conversations_lane_active() {
return None;
}
Some(crate::remote::ConversationsClient::new(self.auth_manager.clone()))
}
pub(crate) fn workspaces_client(&self) -> crate::remote::WorkspacesClient {
crate::remote::WorkspacesClient::new(self.auth_manager.clone())
}
/// Pre-session command availability snapshot. /// Pre-session command availability snapshot.
/// ///
/// Used by the `x.ai/commands/list` ext method and the /// Used by the `x.ai/commands/list` ext method and the
@@ -515,13 +476,9 @@ impl MvpAgent {
) -> &kigi_agent::plugins::SharedPluginRegistryHandle { ) -> &kigi_agent::plugins::SharedPluginRegistryHandle {
&self.plugin_registry_handle &self.plugin_registry_handle
} }
/// `true` when the agent runs in writeback storage mode.
pub(crate) fn is_writeback_storage(&self) -> bool {
matches!(self.storage_mode, StorageMode::Writeback)
}
/// Resolved cli-chat-proxy base for session features (via /// Resolved cli-chat-proxy base for session features (via
/// `proxy_url`). Not for the deployment-config fetch. /// `proxy_url`). Not for the deployment-config fetch.
pub(crate) fn cli_chat_proxy_base_url(&self) -> String { pub(crate) fn coding_api_base_url(&self) -> String {
self.cfg.borrow().endpoints.proxy_url() self.cfg.borrow().endpoints.proxy_url()
} }
pub(crate) fn alpha_test_key(&self) -> Option<String> { pub(crate) fn alpha_test_key(&self) -> Option<String> {
@@ -635,54 +592,14 @@ impl MvpAgent {
pub(crate) fn deployment_key(&self) -> Option<String> { pub(crate) fn deployment_key(&self) -> Option<String> {
self.cfg.borrow().endpoints.deployment_key.clone() self.cfg.borrow().endpoints.deployment_key.clone()
} }
/// Re-fetch remote settings and re-init the telemetry client. /// Re-resolve eagerly-resolved config fields from the local config.
///
/// Called unconditionally from both auth handlers so that:
/// - First install / expired OIDC token: settings are fetched for
/// the first time (the early prefetch had no auth to use).
/// - Reauth / account switch: settings are refreshed to reflect
/// the new user's remote settings targeting attributes.
///
/// This only refreshes `cfg.remote_settings` and re-inits the
/// telemetry client (the only global static). Other settings
/// derived from `remote_settings` (`web_fetch_enabled`, etc.) are
/// resolved lazily per-turn from `cfg` and pick up the new values
/// automatically.
/// Agent-level fields materialised at startup (`worktree_type`,
/// `restore_code`) are NOT re-resolved here; that requires a
/// broader refactor of the init path.
pub(super) async fn refresh_remote_settings(&self, auth: &crate::auth::KimiAuth) {
if !crate::util::config::resolve_remote_fetch_enabled() {
tracing::debug!("post-auth settings refresh skipped: remote_fetch disabled");
return;
}
let Some(settings) = self.fetch_remote_settings(auth.clone()).await else {
tracing::warn!("post-auth settings refresh failed (HTTP or parse error)");
return;
};
tracing::info!("post-auth settings refreshed");
{
let mut cfg = self.cfg.borrow_mut();
cfg.remote_settings = Some(settings);
crate::util::config::sync_campaign_fields(&mut cfg);
crate::agent::config::apply_remote_settings_side_effects(
cfg.remote_settings.as_ref(),
);
}
}
/// Refresh remote settings settings and re-resolve eagerly-resolved config fields.
/// ///
/// Called on `/new` session creation so feature flags reflect the latest /// Called on `/new` session creation so feature flags reflect the latest
/// remote settings state without requiring a TUI restart. Extends /// on-disk config without requiring a TUI restart. (Formerly this also
/// [`refresh_remote_settings`] by also re-running [`resolve_runtime_fields`] /// re-fetched the xAI proxy's remote settings; that endpoint is gone.)
/// with the fresh settings.
/// ///
/// In-flight sessions are unaffected — they snapshot config at creation. /// In-flight sessions are unaffected — they snapshot config at creation.
pub(super) async fn refresh_settings_and_reapply( pub(super) async fn refresh_settings_and_reapply(&self) {
&self,
auth: &crate::auth::KimiAuth,
) {
self.refresh_remote_settings(auth).await;
let cwd = std::env::current_dir().ok(); let cwd = std::env::current_dir().ok();
{ {
let mut cfg = self.cfg.borrow_mut(); let mut cfg = self.cfg.borrow_mut();
@@ -698,36 +615,6 @@ impl MvpAgent {
} }
self.emit_settings_update_notification(); self.emit_settings_update_notification();
} }
/// Shared fetch half of every settings refresh: endpoint fields from a
/// scoped `cfg` borrow, `fetch_settings_blocking` off-executor (it already
/// retries transient errors internally), failures normalized to `None`.
/// Callers own their miss logging.
pub(super) async fn fetch_remote_settings(
&self,
auth: crate::auth::KimiAuth,
) -> Option<crate::util::config::RemoteSettings> {
if !crate::util::config::resolve_remote_fetch_enabled() {
tracing::debug!("settings fetch skipped: remote_fetch disabled");
return None;
}
let (base_url, alpha_test_key) = {
let cfg = self.cfg.borrow();
(cfg.endpoints.proxy_url(), cfg.endpoints.alpha_test_key.clone())
};
match tokio::task::spawn_blocking(move || crate::remote::fetch_settings_blocking(
&base_url,
&auth,
alpha_test_key.as_deref(),
))
.await
{
Ok(settings) => settings,
Err(e) => {
tracing::warn!(error = % e, "settings fetch task panicked");
None
}
}
}
pub(super) async fn send_model_auto_switched( pub(super) async fn send_model_auto_switched(
&self, &self,
session_id: &acp::SessionId, session_id: &acp::SessionId,
@@ -828,26 +715,9 @@ impl MvpAgent {
), ),
); );
} }
let cfg = self.cfg.borrow(); let alpha_test_key = self.cfg.borrow().endpoints.alpha_test_key.clone();
let alpha_test_key = cfg.endpoints.alpha_test_key.clone(); let mut config =
let client_version = cfg.client_version.clone(); crate::agent::config::sampling_config_for_model(model, credentials, alpha_test_key);
let deployment_id = crate::managed_config::resolve_deployment_id(
cfg.endpoints.deployment_key.as_deref(),
);
drop(cfg);
let user_id = self
.auth_manager
.current_or_expired()
.filter(|a| a.is_session_auth())
.map(|a| a.user_id);
let mut config = crate::agent::config::sampling_config_for_model(
model,
credentials,
alpha_test_key,
client_version,
deployment_id,
user_id,
);
config.origin_client = origin_client; config.origin_client = origin_client;
config config
} }
@@ -912,13 +782,7 @@ impl MvpAgent {
.unwrap_or_else(|| kigi_version::VERSION.to_string()); .unwrap_or_else(|| kigi_version::VERSION.to_string());
let alpha_test_key = cfg.endpoints.alpha_test_key.clone(); let alpha_test_key = cfg.endpoints.alpha_test_key.clone();
let mut headers = indexmap::IndexMap::new(); let mut headers = indexmap::IndexMap::new();
headers.insert("user-agent".to_string(), format!("xai-grok-build/{version}")); headers.insert("user-agent".to_string(), format!("kigi/{version}"));
inject_proxy_headers(
&mut headers,
cfg.client_version.as_deref(),
alpha_test_key.as_deref(),
&base_url,
);
ImageGenConfig::Enabled { ImageGenConfig::Enabled {
api_key: api_key.clone(), api_key: api_key.clone(),
base_url, base_url,
@@ -961,13 +825,7 @@ impl MvpAgent {
.unwrap_or_else(|| kigi_version::VERSION.to_string()); .unwrap_or_else(|| kigi_version::VERSION.to_string());
let alpha_test_key = cfg.endpoints.alpha_test_key.clone(); let alpha_test_key = cfg.endpoints.alpha_test_key.clone();
let mut headers = indexmap::IndexMap::new(); let mut headers = indexmap::IndexMap::new();
headers.insert("user-agent".to_string(), format!("xai-grok-build/{version}")); headers.insert("user-agent".to_string(), format!("kigi/{version}"));
inject_proxy_headers(
&mut headers,
cfg.client_version.as_deref(),
alpha_test_key.as_deref(),
&base_url,
);
VideoGenConfig::Enabled { VideoGenConfig::Enabled {
api_key, api_key,
base_url, base_url,
@@ -987,15 +845,8 @@ impl MvpAgent {
&models, &models,
session.as_ref().map(|a| a.key.as_str()), session.as_ref().map(|a| a.key.as_str()),
alpha_test_key.clone(), alpha_test_key.clone(),
client_version,
&self.cfg.borrow().endpoints, &self.cfg.borrow().endpoints,
)?; )?;
inject_proxy_headers(
&mut cfg.extra_headers,
cfg.client_version.as_deref(),
alpha_test_key.as_deref(),
&cfg.base_url,
);
Some(cfg) Some(cfg)
} }
/// Returns `Err` with a user-facing message on invalid config; the caller at /// Returns `Err` with a user-facing message on invalid config; the caller at
@@ -1112,15 +963,6 @@ impl MvpAgent {
.map(|(name, p)| p.render_io_summary(name)) .map(|(name, p)| p.render_io_summary(name))
.collect(), .collect(),
models_manager, models_manager,
chat_modes: {
let chat_modes = crate::agent::chat_modes::ChatModesManager::new(
auth_manager.clone(),
);
if crate::agent::chat_modes::process_chat_mode_enabled() {
chat_modes.warm_in_background();
}
chat_modes
},
cfg: RefCell::new(cfg.clone()), cfg: RefCell::new(cfg.clone()),
auth_method_id: crate::agent::auth_method::new_shared_auth_method_id(None), auth_method_id: crate::agent::auth_method::new_shared_auth_method_id(None),
sampling_config: RefCell::new(sampling_config), sampling_config: RefCell::new(sampling_config),
@@ -1159,7 +1001,6 @@ impl MvpAgent {
subagent_event_rx: RefCell::new(Some(subagent_event_rx)), subagent_event_rx: RefCell::new(Some(subagent_event_rx)),
subagent_coordinator: RefCell::new(subagent_coordinator), subagent_coordinator: RefCell::new(subagent_coordinator),
monitor_event_buffer: kigi_tools::implementations::grok_build::task::types::MonitorEventBuffer::default(), monitor_event_buffer: kigi_tools::implementations::grok_build::task::types::MonitorEventBuffer::default(),
bundle_sync_in_flight: Arc::new(std::sync::atomic::AtomicBool::new(false)),
workspace_ops: RefCell::new(None), workspace_ops: RefCell::new(None),
require_gateway_sessions: Rc::new( require_gateway_sessions: Rc::new(
RefCell::new(std::collections::HashSet::new()), RefCell::new(std::collections::HashSet::new()),
@@ -2221,19 +2062,9 @@ impl MvpAgent {
let auto_update = self.cfg.borrow().cli.auto_update; let auto_update = self.cfg.borrow().cli.auto_update;
let client_type = *self.client_type.borrow(); let client_type = *self.client_type.borrow();
let buffering_settings = self.buffering_settings.borrow().clone(); let buffering_settings = self.buffering_settings.borrow().clone();
let ( let feedback_base_url = self.feedback_base_url();
feedback_proxy_url,
feedback_user_token,
feedback_alpha_test_key,
deployment_key,
) = if let Some((url, token, alpha, deploy)) = self.feedback_credentials() {
(Some(url), token, alpha, deploy)
} else {
(None, None, None, None)
};
tracing::info!( tracing::info!(
session_id = % session_info.id.0, feedback_url = ? feedback_proxy_url, session_id = % session_info.id.0, feedback_url = ? feedback_base_url,
authenticated = feedback_user_token.is_some(),
"Initializing feedback manager for session" "Initializing feedback manager for session"
); );
let skills = self.cfg.borrow().skills.clone(); let skills = self.cfg.borrow().skills.clone();
@@ -2489,7 +2320,6 @@ impl MvpAgent {
self.auth_type(), self.auth_type(),
), ),
alpha_test_key: self.alpha_test_key(), alpha_test_key: self.alpha_test_key(),
client_version: sampling_config.client_version.clone(),
}; };
let attribution_callback: Option< let attribution_callback: Option<
kigi_sampler::SharedAttributionCallback, kigi_sampler::SharedAttributionCallback,
@@ -2599,10 +2429,7 @@ impl MvpAgent {
self.codebase_indexes.clone(), self.codebase_indexes.clone(),
client_code_nav_enabled, client_code_nav_enabled,
fs_watch_caps, fs_watch_caps,
feedback_proxy_url, feedback_base_url,
feedback_user_token,
feedback_alpha_test_key,
deployment_key,
client_terminal, client_terminal,
client_fs_read && client_fs_write, client_fs_read && client_fs_write,
gateway_enabled, gateway_enabled,
@@ -2617,7 +2444,6 @@ impl MvpAgent {
persisted_goal_mode, persisted_goal_mode,
persisted_announcement_state, persisted_announcement_state,
self.memory_config.clone(), self.memory_config.clone(),
loc_tracking_enabled,
feedback_flags, feedback_flags,
self.managed_mcp_cache.clone(), self.managed_mcp_cache.clone(),
managed_mcp_expires_at, managed_mcp_expires_at,
@@ -406,17 +406,11 @@ struct SettingsUpdateNotification {
sharing_enabled: Option<bool>, sharing_enabled: Option<bool>,
session_picker_grouped: Option<bool>, session_picker_grouped: Option<bool>,
tips: Option<Vec<String>>, tips: Option<Vec<String>>,
gate_message: Option<String>,
gate_url: Option<String>,
gate_label: Option<String>,
allow_access: Option<bool>,
subscription_tier_display: Option<String>,
auto_permission_mode_enabled: Option<bool>, auto_permission_mode_enabled: Option<bool>,
/// Soft-default permission mode for the pager (post-auth / `/new` refresh). /// Soft-default permission mode for the pager (post-auth / `/new` refresh).
permission_mode: Option<String>, permission_mode: Option<String>,
group_tool_verbs: Option<bool>, group_tool_verbs: Option<bool>,
collapsed_edit_blocks: Option<bool>, collapsed_edit_blocks: Option<bool>,
subscription_watch_interval_secs: Option<u64>,
} }
/// Reason why a client is not eligible to use codebase indexing. /// Reason why a client is not eligible to use codebase indexing.
/// ///
@@ -509,9 +503,6 @@ pub struct MvpAgent {
pub(crate) sampling_config: RefCell<SamplingConfig>, pub(crate) sampling_config: RefCell<SamplingConfig>,
pub(crate) auth_manager: Arc<AuthManager>, pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) models_manager: crate::agent::models::ModelsManager, pub(crate) models_manager: crate::agent::models::ModelsManager,
/// grok.com chat-product catalog (`/rest/modes`) for chat sessions; distinct
/// from `models_manager` (the build `/v1/models` catalog).
pub(crate) chat_modes: crate::agent::chat_modes::ChatModesManager,
/// Forwards pasted codes from `handle_auth_submit_code` to the auth flow. /// Forwards pasted codes from `handle_auth_submit_code` to the auth flow.
pub(crate) auth_code_tx: RefCell<Option<tokio::sync::mpsc::Sender<String>>>, pub(crate) auth_code_tx: RefCell<Option<tokio::sync::mpsc::Sender<String>>>,
/// Receives the auth URL from the auth flow; read by `handle_auth_get_url`. /// Receives the auth URL from the auth flow; read by `handle_auth_get_url`.
@@ -672,20 +663,6 @@ pub struct MvpAgent {
/// this flag keeps that to a single discovery walk. /// this flag keeps that to a single discovery walk.
plugin_registry_initialized: std::cell::Cell<bool>, plugin_registry_initialized: std::cell::Cell<bool>,
persona_io_summaries: Vec<String>, persona_io_summaries: Vec<String>,
/// Single-flight guard for the proactive bundle sync background task.
///
/// `maybe_sync_bundle_in_background` is invoked from each post-auth path
/// (initialize, cached-token reauth, oidc) and a rapid reconnect can fire
/// all three within the TTL window, giving us multiple concurrent
/// `tokio::task::spawn_local` tasks racing to extract the tar archive,
/// rewrite `manifest.json`, and prune stale files. The non-atomic
/// per-file write/prune semantics in `bundle::extract_bundle_archive`
/// make that race observable as a partially-written cache.
///
/// We use an `Arc<AtomicBool>` so the spawned task can clear the flag
/// on completion without re-borrowing `&self`. `Send` is required
/// because the inner `sync_bundle_to_root` now uses `spawn_blocking`.
bundle_sync_in_flight: Arc<std::sync::atomic::AtomicBool>,
/// Local workspace ops, built lazily via [`Self::ensure_local_workspace_ops`]. /// Local workspace ops, built lazily via [`Self::ensure_local_workspace_ops`].
/// The agent never opens Computer Hub as a harness/client; remote cloud /// The agent never opens Computer Hub as a harness/client; remote cloud
/// sandboxes are gateway-owned (`gateway_bridge` / `computer_sessions`). /// sandboxes are gateway-owned (`gateway_bridge` / `computer_sessions`).
@@ -944,50 +921,6 @@ impl AuthRequestMeta {
.unwrap_or_default() .unwrap_or_default()
} }
} }
/// Inject standard proxy headers into an `extra_headers` map.
///
/// Every authenticated request to cli-chat-proxy (web search, image gen, and
/// any future tools that go through the proxy) must carry these headers.
/// Centralising them here means new tool code paths only need one call instead
/// of remembering which headers the proxy expects.
///
/// Headers injected:
/// - `x-grok-client-version` -- required by the proxy's version-gate check.
/// Uses `client_version` when provided, otherwise falls back to cli-chat-proxy
/// compile-time `CARGO_PKG_VERSION`.
/// - `X-XAI-Token-Auth` / `x-authenticateresponse` -- required by the
/// cli-chat-proxy auth middleware when the `base_url` is a known proxy URL.
/// - optional extra access header -- only set when the corresponding key is
/// `Some` *and* the `base_url` points at a matching non-production host
/// (requires the optional non-production feature).
///
/// Existing entries are never overwritten so callers can pre-set a value.
fn inject_proxy_headers(
headers: &mut indexmap::IndexMap<String, String>,
client_version: Option<&str>,
alpha_test_key: Option<&str>,
base_url: &str,
) {
headers
.entry("x-grok-client-version".to_string())
.or_insert_with(|| {
client_version
.map(String::from)
.unwrap_or_else(|| kigi_version::VERSION.to_string())
});
if crate::util::is_cli_chat_proxy_url(base_url) {
headers
.entry("X-XAI-Token-Auth".to_string())
.or_insert_with(|| "xai-grok-cli".to_string());
headers
.entry("x-authenticateresponse".to_string())
.or_insert_with(|| "authenticate-response".to_string());
headers
.entry(crate::http::CLIENT_MODE_HEADER.to_string())
.or_insert_with(|| crate::http::process_client_mode().to_string());
}
let _ = (alpha_test_key, base_url);
}
fn resolve_inference_idle_timeout_secs( fn resolve_inference_idle_timeout_secs(
models: &indexmap::IndexMap<String, crate::agent::config::ModelEntry>, models: &indexmap::IndexMap<String, crate::agent::config::ModelEntry>,
model: &str, model: &str,
@@ -1580,47 +1513,6 @@ impl MvpAgent {
}); });
AuthenticateResponse::new().meta(meta) AuthenticateResponse::new().meta(meta)
} }
/// Fetch remote settings after authentication when early prefetch had none.
/// Notifies the pager so soft-default permission_mode applies post-login.
pub(super) async fn maybe_fetch_post_auth_settings(&self) {
if self.cfg.borrow().remote_settings.is_some() {
return;
}
let Some(auth) = self.auth_manager.current() else {
return;
};
let is_session_auth = auth.is_session_auth();
let Some(settings) = self.fetch_remote_settings(auth).await else {
return;
};
tracing::info!("post-auth remote_settings fetch succeeded");
{
let mut cfg = self.cfg.borrow_mut();
cfg.remote_settings = Some(settings);
crate::agent::config::apply_remote_settings_side_effects(
cfg.remote_settings.as_ref(),
);
if cfg.storage_mode == StorageMode::Local
&& cfg.mode != crate::agent::config::AgentMode::Generic
{
cfg.storage_mode = StorageMode::resolve(
None,
cfg.remote_settings.as_ref(),
);
if cfg.storage_mode == StorageMode::Writeback && !is_session_auth {
cfg.storage_mode = StorageMode::Local;
}
}
if let Some(v) = cfg
.remote_settings
.as_ref()
.and_then(|s| s.path_not_found_hints)
{
cfg.path_not_found_hints = v;
}
}
self.emit_settings_update_notification();
}
/// Fire-and-forget `x.ai/settings/update` from the current remote snapshot. /// Fire-and-forget `x.ai/settings/update` from the current remote snapshot.
pub(super) fn emit_settings_update_notification(&self) { pub(super) fn emit_settings_update_notification(&self) {
let payload = { let payload = {
@@ -1631,20 +1523,12 @@ impl MvpAgent {
sharing_enabled: rs.and_then(|s| s.sharing_enabled), sharing_enabled: rs.and_then(|s| s.sharing_enabled),
session_picker_grouped: rs.and_then(|s| s.session_picker_grouped), session_picker_grouped: rs.and_then(|s| s.session_picker_grouped),
tips: rs.and_then(|s| s.tips.clone()), tips: rs.and_then(|s| s.tips.clone()),
gate_message: rs.and_then(|s| s.gate_message.clone()),
gate_url: rs.and_then(|s| s.gate_url.clone()),
gate_label: rs.and_then(|s| s.gate_label.clone()),
allow_access: rs.and_then(|s| s.allow_access),
subscription_tier_display: rs
.and_then(|s| s.subscription_tier_display.clone()),
auto_permission_mode_enabled: crate::util::config::remote_auto_mode_enabled( auto_permission_mode_enabled: crate::util::config::remote_auto_mode_enabled(
rs, rs,
), ),
permission_mode: rs.and_then(|s| s.permission_mode.clone()), permission_mode: rs.and_then(|s| s.permission_mode.clone()),
group_tool_verbs: rs.and_then(|s| s.group_tool_verbs), group_tool_verbs: rs.and_then(|s| s.group_tool_verbs),
collapsed_edit_blocks: rs.and_then(|s| s.collapsed_edit_blocks), collapsed_edit_blocks: rs.and_then(|s| s.collapsed_edit_blocks),
subscription_watch_interval_secs: rs
.and_then(|s| s.subscription_watch_interval_secs),
} }
}; };
if let Ok(params) = serde_json::value::to_raw_value(&payload) { if let Ok(params) = serde_json::value::to_raw_value(&payload) {
@@ -1719,78 +1603,6 @@ impl MvpAgent {
}); });
} }
} }
/// Spawn a best-effort bundle sync. Re-fires on every call site (init,
/// cached_token, grok.com/oidc); the cheap pre-checks below absorb repeats
/// so reconnects are cheap.
///
/// Pre-spawn gating order (cheapest first, all synchronous):
/// 1. Auth gate — avoid spawning a no-op task on every init.
/// 2. Freshness check — skip the sender snapshot + spawn entirely on
/// cache hits, which is the steady-state on every reconnect.
/// 3. Single-flight guard — if a previous sync is still in flight (e.g.,
/// initialize + cached_token + oidc fired in quick succession before
/// the first sync's tar extract finished), drop this call to avoid
/// racing concurrent extracts that would interleave per-file writes
/// against `~/.kigi/bundled/` and the manifest.
pub(crate) fn maybe_sync_bundle_in_background(&self, force: bool) {
use crate::extensions::bundle::{
BUNDLE_SYNC_TTL, bundle_cache_is_fresh, has_bundle_credentials,
maybe_sync_bundle_to_root,
};
use std::sync::atomic::Ordering;
let am = self.auth_manager.clone();
let deployment_key = self.deployment_key();
if !has_bundle_credentials(Some(&am), deployment_key.as_deref()) {
return;
}
let root = crate::bundle::bundled_root();
if !force && bundle_cache_is_fresh(&root, BUNDLE_SYNC_TTL) {
tracing::debug!("proactive bundle sync skipped pre-spawn: cache is fresh");
return;
}
let in_flight = self.bundle_sync_in_flight.clone();
if in_flight
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
tracing::debug!(
"proactive bundle sync skipped: another sync is already in flight"
);
return;
}
let proxy_base_url = self.cli_chat_proxy_base_url();
let alpha_test_key = self.alpha_test_key();
let senders: Vec<
tokio::sync::mpsc::UnboundedSender<crate::session::SessionCommand>,
> = self.sessions.borrow().values().map(|h| h.cmd_tx.clone()).collect();
tokio::task::spawn_local(async move {
let result = maybe_sync_bundle_to_root(
&root,
&proxy_base_url,
Some(&am),
deployment_key.as_deref(),
alpha_test_key.as_deref(),
force,
BUNDLE_SYNC_TTL,
)
.await;
in_flight.store(false, Ordering::Release);
match result {
Ok(Some(res)) => {
tracing::info!(
version = % res.version, personas = res.personas_count, roles =
res.roles_count, agents = res.agents_count, skills = res
.skills_count, "proactive bundle sync complete"
);
Self::broadcast_refresh_skill_baseline(senders);
}
Ok(None) => {}
Err(err) => {
tracing::warn!(error = % err, "proactive bundle sync failed");
}
}
});
}
} }
/// Parse `_meta.agentProfile` as a JSON object or string name. /// Parse `_meta.agentProfile` as a JSON object or string name.
/// Returns `None` if absent or invalid. /// Returns `None` if absent or invalid.
@@ -402,7 +402,7 @@ impl MvpAgent {
client_hooks: Default::default(), client_hooks: Default::default(),
sampling_config: self.sampling_config.borrow().clone(), sampling_config: self.sampling_config.borrow().clone(),
managed_mcp_proxy_base_url: parent_managed_mcp_proxy_base_url managed_mcp_proxy_base_url: parent_managed_mcp_proxy_base_url
.unwrap_or_else(|| self.cli_chat_proxy_base_url()), .unwrap_or_else(|| self.coding_api_base_url()),
alpha_test_key: self.alpha_test_key(), alpha_test_key: self.alpha_test_key(),
auth_method_id: self auth_method_id: self
.auth_method_id .auth_method_id
@@ -1822,18 +1822,6 @@ fn orphaned_tasks_filters_rewind_dead_branches() {
); );
} }
#[test] #[test]
fn allow_access_from_remote_settings() {
let json = serde_json::json!({ "allow_access" : true });
let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
assert_eq!(rs.allow_access, Some(true));
let json = serde_json::json!({ "allow_access" : false });
let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
assert_eq!(rs.allow_access, Some(false));
let json = serde_json::json!({});
let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
assert_eq!(rs.allow_access, None);
}
#[test]
fn on_demand_enabled_from_remote_settings() { fn on_demand_enabled_from_remote_settings() {
let json = serde_json::json!({ "on_demand_enabled" : false }); let json = serde_json::json!({ "on_demand_enabled" : false });
let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap(); let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
@@ -708,7 +708,6 @@ pub(crate) async fn handle_subagent_request(
api_key: effective_sampling_config.api_key.clone(), api_key: effective_sampling_config.api_key.clone(),
auth_type: inherited_auth_type, auth_type: inherited_auth_type,
alpha_test_key: ctx.alpha_test_key.clone(), alpha_test_key: ctx.alpha_test_key.clone(),
client_version: effective_sampling_config.client_version.clone(),
}; };
kigi_log::unified_log::info( kigi_log::unified_log::info(
"subagent spawn credentials", "subagent spawn credentials",
@@ -1020,9 +1019,6 @@ pub(crate) async fn handle_subagent_request(
false, false,
subagent_fs_watch, subagent_fs_watch,
None, None,
None,
None,
None,
false, false,
false, false,
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
@@ -1057,7 +1053,6 @@ pub(crate) async fn handle_subagent_request(
} else { } else {
ctx.memory_config.clone() ctx.memory_config.clone()
}, },
false,
Default::default(), Default::default(),
ctx.managed_mcp_state.clone(), ctx.managed_mcp_state.clone(),
None, None,
@@ -898,15 +898,11 @@ async fn read_parent_sampling_config(
auth_scheme, auth_scheme,
extra_headers, extra_headers,
context_window: cfg.context_window.get(), context_window: cfg.context_window.get(),
client_version: creds.client_version,
reasoning_effort: cfg.reasoning_effort, reasoning_effort: cfg.reasoning_effort,
force_http1: false, force_http1: false,
max_retries: None, max_retries: None,
stream_tool_calls: cfg.stream_tool_calls.unwrap_or(false), stream_tool_calls: cfg.stream_tool_calls.unwrap_or(false),
idle_timeout_secs: None, idle_timeout_secs: None,
client_identifier: ctx.sampling_config.client_identifier.clone(),
deployment_id: ctx.sampling_config.deployment_id.clone(),
user_id: ctx.sampling_config.user_id.clone(),
origin_client: ctx.sampling_config.origin_client.clone(), origin_client: ctx.sampling_config.origin_client.clone(),
attribution_callback: ctx.attribution_callback.clone(), attribution_callback: ctx.attribution_callback.clone(),
bearer_resolver: None, bearer_resolver: None,
@@ -997,14 +993,7 @@ fn resolve_model_override_to_config(
let mut credentials = resolve_credentials(&entry, session_key); let mut credentials = resolve_credentials(&entry, session_key);
credentials.auth_type = subagent_auth_type(Some(&entry), &ctx.auth_method_id); credentials.auth_type = subagent_auth_type(Some(&entry), &ctx.auth_method_id);
let resolved_auth_type = credentials.auth_type; let resolved_auth_type = credentials.auth_type;
let config = sampling_config_for_model( let config = sampling_config_for_model(&entry, credentials, ctx.alpha_test_key.clone());
&entry,
credentials,
ctx.alpha_test_key.clone(),
ctx.sampling_config.client_version.clone(),
ctx.sampling_config.deployment_id.clone(),
ctx.sampling_config.user_id.clone(),
);
kigi_log::unified_log::debug( kigi_log::unified_log::debug(
"subagent resolve_model_override_to_config", "subagent resolve_model_override_to_config",
None, None,
+5 -3
View File
@@ -29,12 +29,14 @@ pub(crate) fn ascii_header_value(value: &str) -> String {
} }
} }
/// The three device-identity headers sent on every OAuth call. /// The three device-identity headers sent on every OAuth call and, via
/// `agent::config::inject_url_derived_headers`, on every first-party
/// inference request (mirroring kimi-cli src/kimi_cli/llm.py:317-323).
/// ///
/// Errors when the persistent device id cannot be created (e.g. read-only /// Errors when the persistent device id cannot be created (e.g. read-only
/// `~/.kigi`): the OAuth endpoints require `X-Msh-Device-Id`, so login cannot /// `~/.kigi`): the OAuth endpoints require `X-Msh-Device-Id`, so login cannot
/// proceed without it. /// proceed without it. Inference callers treat the error as skip-with-warning.
pub(crate) fn device_headers() -> anyhow::Result<[(&'static str, String); 3]> { pub fn device_headers() -> anyhow::Result<[(&'static str, String); 3]> {
Ok([ Ok([
("X-Msh-Device-Name", ascii_header_value(&device_name())), ("X-Msh-Device-Name", ascii_header_value(&device_name())),
("X-Msh-Device-Model", ascii_header_value(device_model())), ("X-Msh-Device-Model", ascii_header_value(device_model())),
@@ -150,6 +150,11 @@ async fn complete_device_code_login(
/// caller can decide how to notify the user (eprintln on CLI, nothing on TUI /// caller can decide how to notify the user (eprintln on CLI, nothing on TUI
/// where the URL is already rendered in the widget). /// where the URL is already rendered in the widget).
async fn open_browser_detached(url: &str) -> bool { async fn open_browser_detached(url: &str) -> bool {
// Unit tests drive the full login flow against mock servers — their
// fixture URLs must never reach a real browser.
if cfg!(test) {
return false;
}
let url = url.to_owned(); let url = url.to_owned();
match tokio::task::spawn_blocking(move || webbrowser::open(&url)).await { match tokio::task::spawn_blocking(move || webbrowser::open(&url)).await {
Ok(Ok(())) => true, Ok(Ok(())) => true,
@@ -171,13 +176,16 @@ mod tests {
use wiremock::matchers::{body_string_contains, method, path}; use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
/// Fixture mirroring the live `device_authorization` payload (verified
/// against auth.kimi.com): verification URLs are passed through verbatim
/// by the login flow, so they use the real shape.
fn device_auth_json(code: &str) -> serde_json::Value { fn device_auth_json(code: &str) -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"user_code": "ABCD-1234", "user_code": "WXYZ-6789",
"device_code": code, "device_code": code,
"verification_uri": "https://auth.kimi.com/device", "verification_uri": "https://www.kimi.com/code/authorize_device",
"verification_uri_complete": "https://auth.kimi.com/device?code=ABCD-1234", "verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=WXYZ-6789",
"expires_in": 600, "expires_in": 1800,
"interval": 0, // floored to 1s by the poll loop "interval": 0, // floored to 1s by the poll loop
}) })
} }
@@ -359,11 +359,11 @@ mod tests {
"client_id={KIMI_CODE_CLIENT_ID}" "client_id={KIMI_CODE_CLIENT_ID}"
))) )))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_code": "ABCD-1234", "user_code": "WXYZ-6789",
"device_code": "dev-code-1", "device_code": "dev-code-1",
"verification_uri": "https://auth.kimi.com/device", "verification_uri": "https://www.kimi.com/code/authorize_device",
"verification_uri_complete": "https://auth.kimi.com/device?code=ABCD-1234", "verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=WXYZ-6789",
"expires_in": 600, "expires_in": 1800,
"interval": 7, "interval": 7,
}))) })))
.expect(1) .expect(1)
@@ -371,13 +371,13 @@ mod tests {
.await; .await;
let auth = request_device_authorization(&server.uri()).await.unwrap(); let auth = request_device_authorization(&server.uri()).await.unwrap();
assert_eq!(auth.user_code, "ABCD-1234"); assert_eq!(auth.user_code, "WXYZ-6789");
assert_eq!(auth.device_code, "dev-code-1"); assert_eq!(auth.device_code, "dev-code-1");
assert_eq!(auth.interval, 7); assert_eq!(auth.interval, 7);
assert_eq!(auth.expires_in, Some(600)); assert_eq!(auth.expires_in, Some(1800));
assert_eq!( assert_eq!(
auth.verification_uri_complete, auth.verification_uri_complete,
"https://auth.kimi.com/device?code=ABCD-1234" "https://www.kimi.com/code/authorize_device?user_code=WXYZ-6789"
); );
} }
@@ -392,7 +392,7 @@ mod tests {
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_code": "AAAA", "user_code": "AAAA",
"device_code": "d", "device_code": "d",
"verification_uri_complete": "https://auth.kimi.com/device?code=AAAA", "verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=AAAA",
"interval": 5, "interval": 5,
}))) })))
.expect(1) .expect(1)
@@ -409,7 +409,7 @@ mod tests {
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_code": "AAAA", "user_code": "AAAA",
"device_code": "d", "device_code": "d",
"verification_uri_complete": "https://auth.kimi.com/device?code=AAAA", "verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=AAAA",
}))) })))
.mount(&server) .mount(&server)
.await; .await;
@@ -1,17 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Access-gate copy resolved from remote settings (message + optional CTA).
/// Auth no longer produces gates (tier gating was an xAI concept); the pager
/// still renders one when remote settings carry a gate message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateInfo {
pub message: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub label: Option<String>,
}
/// Typed auth metadata passed from the shell to the pager via ACP. /// Typed auth metadata passed from the shell to the pager via ACP.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuthMeta { pub struct AuthMeta {
+2 -1
View File
@@ -20,9 +20,10 @@ pub use flow::{
run_auth_flow_with_stderr_bridge, run_cli_login, run_cli_logout, try_ensure_fresh_auth, run_auth_flow_with_stderr_bridge, run_cli_login, run_cli_logout, try_ensure_fresh_auth,
}; };
mod meta; mod meta;
pub use device::device_headers;
pub use error::{AuthError, RefreshTokenError, RefreshTokenFailedReason}; pub use error::{AuthError, RefreshTokenError, RefreshTokenFailedReason};
pub use manager::{AuthManager, shared_api_key_provider}; pub use manager::{AuthManager, shared_api_key_provider};
pub use meta::{AuthMeta, GateInfo}; pub use meta::AuthMeta;
pub use model::{AuthMode, KimiAuth, lookup_auth}; pub use model::{AuthMode, KimiAuth, lookup_auth};
pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix}; pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix};
pub use storage::{ pub use storage::{
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1094,11 +1094,11 @@ fn apply_requirements_inner(
config.endpoints.xai_api_base_url = val.to_owned(); config.endpoints.xai_api_base_url = val.to_owned();
push("endpoints.xai_api_base_url", val.to_owned()); push("endpoints.xai_api_base_url", val.to_owned());
} }
if let Some(val) = req_str(req, "endpoints", "cli_chat_proxy_base_url") if let Some(val) = req_str(req, "endpoints", "coding_api_base_url")
&& config.endpoints.cli_chat_proxy_base_url.as_deref() != Some(val) && config.endpoints.coding_api_base_url.as_deref() != Some(val)
{ {
config.endpoints.cli_chat_proxy_base_url = Some(val.to_owned()); config.endpoints.coding_api_base_url = Some(val.to_owned());
push("endpoints.cli_chat_proxy_base_url", val.to_owned()); push("endpoints.coding_api_base_url", val.to_owned());
} }
enforce_str!( enforce_str!(
"endpoints", "endpoints",
@@ -2564,7 +2564,7 @@ fn config_layers_user_overrides_managed() {
fn enterprise_two_file_merge_routes_deployment_key_to_proxy() { fn enterprise_two_file_merge_routes_deployment_key_to_proxy() {
for k in [ for k in [
"KIGI_MANAGED_CONFIG_URL", "KIGI_MANAGED_CONFIG_URL",
"KIGI_CLI_CHAT_PROXY_BASE_URL", "KIGI_CODE_BASE_URL",
"KIGI_TRACE_UPLOAD_ENDPOINT_URL", "KIGI_TRACE_UPLOAD_ENDPOINT_URL",
] { ] {
unsafe { std::env::remove_var(k) }; unsafe { std::env::remove_var(k) };
@@ -2573,7 +2573,7 @@ fn enterprise_two_file_merge_routes_deployment_key_to_proxy() {
r#" r#"
[endpoints] [endpoints]
xai_api_base_url = "https://inference.acme-corp.example/xai/v1" xai_api_base_url = "https://inference.acme-corp.example/xai/v1"
cli_chat_proxy_base_url = "https://cli-chat-proxy.kigi.com/v1" coding_api_base_url = "https://cli-chat-proxy.kigi.com/v1"
[model.kigi-build] [model.kigi-build]
base_url = "https://inference.acme-corp.example/xai/v1" base_url = "https://inference.acme-corp.example/xai/v1"
@@ -2668,13 +2668,13 @@ fn config_layers_system_managed_lowest_priority() {
#[test] #[test]
fn apply_requirements_value_overrides_user_settings() { fn apply_requirements_value_overrides_user_settings() {
let raw_config: toml::Value = toml::from_str( let raw_config: toml::Value = toml::from_str(
"[cli]\nauto_update = true\nchannel = \"beta\"\n\n[features]\nfeedback = true\nlsp_tools = true\nweb_fetch = true\nwrite_file = true\n\n[ui]\nyolo = true\n\n[models]\ndefault = \"user-model\"\nweb_search = \"user-ws-model\"\n\n[endpoints]\ncli_chat_proxy_base_url = \"https://user-proxy.example/v1\"\nxai_api_base_url = \"https://user-api.example/v1\"\nmodels_base_url = \"https://user-models.example/v1\"\nmodels_list_url = \"https://user-models.example/v1/models\"\n", "[cli]\nauto_update = true\nchannel = \"beta\"\n\n[features]\nfeedback = true\nlsp_tools = true\nweb_fetch = true\nwrite_file = true\n\n[ui]\nyolo = true\n\n[models]\ndefault = \"user-model\"\nweb_search = \"user-ws-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://user-proxy.example/v1\"\nxai_api_base_url = \"https://user-api.example/v1\"\nmodels_base_url = \"https://user-models.example/v1\"\nmodels_list_url = \"https://user-models.example/v1/models\"\n",
) )
.unwrap(); .unwrap();
let mut cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap(); let mut cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
cfg.default_yolo_mode = true; cfg.default_yolo_mode = true;
let requirements: toml::Value = toml::from_str( let requirements: toml::Value = toml::from_str(
"[cli]\nauto_update = false\nchannel = \"stable\"\n\n[features]\nfeedback = false\nlsp_tools = false\nweb_fetch = false\nwrite_file = false\nremote_fetch = false\n\n[ui]\nyolo = false\n\n[models]\ndefault = \"managed-model\"\nweb_search = \"managed-ws-model\"\n\n[endpoints]\ncli_chat_proxy_base_url = \"https://managed-proxy.example/v1\"\nxai_api_base_url = \"https://managed-api.example/v1\"\nmodels_base_url = \"https://managed-models.example/v1\"\nmodels_list_url = \"https://managed-models.example/v1/models\"\ndeployment_key = \"enterprise-deploy-key-should-not-log\"\n", "[cli]\nauto_update = false\nchannel = \"stable\"\n\n[features]\nfeedback = false\nlsp_tools = false\nweb_fetch = false\nwrite_file = false\nremote_fetch = false\n\n[ui]\nyolo = false\n\n[models]\ndefault = \"managed-model\"\nweb_search = \"managed-ws-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://managed-proxy.example/v1\"\nxai_api_base_url = \"https://managed-api.example/v1\"\nmodels_base_url = \"https://managed-models.example/v1\"\nmodels_list_url = \"https://managed-models.example/v1/models\"\ndeployment_key = \"enterprise-deploy-key-should-not-log\"\n",
) )
.unwrap(); .unwrap();
let source = RequirementSource::Requirements { let source = RequirementSource::Requirements {
@@ -2697,7 +2697,7 @@ fn apply_requirements_value_overrides_user_settings() {
assert_eq!(Some("managed-ws-model"), cfg.models.web_search.as_deref()); assert_eq!(Some("managed-ws-model"), cfg.models.web_search.as_deref());
assert_eq!(Some("stable"), cfg.cli.channel.as_deref()); assert_eq!(Some("stable"), cfg.cli.channel.as_deref());
assert_eq!( assert_eq!(
Some("https://managed-proxy.example/v1"), cfg.endpoints.cli_chat_proxy_base_url Some("https://managed-proxy.example/v1"), cfg.endpoints.coding_api_base_url
.as_deref() .as_deref()
); );
assert_eq!("https://managed-api.example/v1", cfg.endpoints.xai_api_base_url); assert_eq!("https://managed-api.example/v1", cfg.endpoints.xai_api_base_url);
@@ -1,8 +1,11 @@
//! `x.ai/billing` extension handler. //! `x.ai/billing` extension handler — Kimi Code usage/quota.
//! //!
//! Fetches the authenticated user's Grok Build billing configuration //! Port of kimi-cli's `/usage` command (kimi-cli `src/kimi_cli/ui/shell/usage.py`):
//! (credit limit, usage, on-demand cap, billing period, history) from //! `GET {coding_api_base_url}/usages` with the OAuth Bearer token, parsed into
//! the backend. Used by the pager/desktop to display credits and usage. //! display rows (`{usage: {...}, limits: [{detail, window, ...}]}` payload
//! shape). The TUI renders the rows as label + remaining-quota bar +
//! reset hint. The xAI credits/auto-topup surface this file used to serve is
//! gone with the xAI proxy.
use agent_client_protocol as acp; use agent_client_protocol as acp;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -10,593 +13,396 @@ use serde::{Deserialize, Serialize};
use super::{ExtResult, to_raw_response}; use super::{ExtResult, to_raw_response};
use crate::agent::MvpAgent; use crate::agent::MvpAgent;
/// Billing period cycle identifier. /// One usage row: a named quota with `used`/`limit` and an optional
#[derive(Debug, Clone, Serialize, Deserialize)] /// human-readable reset hint (e.g. "resets in 2h 5m").
#[serde(rename_all = "camelCase")]
pub struct BillingCycle {
pub year: i32,
pub month: i32,
}
/// Cent value from the billing API (USD cents).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cent {
/// proto3 JSON omits zero-valued scalars, so a `$0` Cent arrives as `{}`;
/// default to 0 rather than failing the whole parse.
#[serde(default)]
pub val: i64,
}
/// A usage period (weekly or monthly) from the newer credits config.
/// ///
/// `start`/`end` are RFC 3339 timestamps. `period_type` is the proto enum name /// `Deserialize` because the TUI parses this back out of the
/// (e.g. `USAGE_PERIOD_TYPE_WEEKLY`); kept so callers can distinguish weekly /// `x.ai/billing` ext response.
/// vs monthly cycles. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageRow {
pub label: String,
pub used: i64,
pub limit: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub reset_hint: Option<String>,
}
/// Response for `x.ai/billing`: the parsed usage rows, in display order
/// (summary row first when the payload carries one).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UsagePeriod { pub struct UsageResponse {
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] pub rows: Vec<UsageRow>,
pub period_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end: Option<String>,
} }
/// Usage summary for one past billing period. /// Error from the usages fetch, mapped to the same user-facing messages
#[derive(Debug, Clone, Serialize, Deserialize)] /// kimi-cli shows (usage.py error handling).
#[serde(rename_all = "camelCase")] #[derive(Debug, thiserror::Error)]
pub struct BillingPeriodUsage { pub enum UsageError {
#[serde(skip_serializing_if = "Option::is_none")] #[error("Authorization failed. Please check your credentials.")]
pub billing_cycle: Option<BillingCycle>, Unauthorized,
#[serde(skip_serializing_if = "Option::is_none")] #[error("Usage endpoint not available. Try Kimi for Coding.")]
pub included_used: Option<Cent>, NotFound,
#[serde(skip_serializing_if = "Option::is_none")] #[error("Failed to fetch usage (HTTP {status}).")]
pub on_demand_used: Option<Cent>, Http { status: u16 },
#[serde(skip_serializing_if = "Option::is_none")] #[error("Failed to fetch usage: {0}")]
pub total_used: Option<Cent>, Network(#[from] reqwest::Error),
} #[error("Failed to parse usage response: {0}")]
Parse(#[from] serde_json::Error),
/// Current billing configuration for Grok Build coding credits.
///
/// Carries both the newer credits-config fields (`credit_usage_percent`,
/// `current_period`) and the deprecated `GrokBuildBillingConfig` fields
/// (`monthly_limit`, `used`, `billing_period_*`). Consumers should prefer the
/// new fields and fall back to the deprecated ones, so the same struct works
/// against both the new `GetGrokCreditsConfig` and the legacy
/// `GetGrokBuildBillingConfig` backend responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BillingConfig {
/// Included credit usage as a percentage of the allowance (0.0100.0).
/// Preferred over deriving from `monthly_limit`/`used`.
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_usage_percent: Option<f64>,
/// Current usage period (weekly or monthly). Preferred over
/// `billing_period_start`/`billing_period_end`.
#[serde(skip_serializing_if = "Option::is_none")]
pub current_period: Option<UsagePeriod>,
/// Deprecated: included monthly credit budget. Use `credit_usage_percent`.
#[serde(skip_serializing_if = "Option::is_none")]
pub monthly_limit: Option<Cent>,
/// Deprecated: credits used this period. Use `credit_usage_percent`.
#[serde(skip_serializing_if = "Option::is_none")]
pub used: Option<Cent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_demand_cap: Option<Cent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_demand_used: Option<Cent>,
/// Remaining prepaid (purchased) credit balance, positive — the "bought
/// credits" the user has topped up. Populated from the credits config
/// (`GetGrokCreditsConfig.prepaid_balance`); absent in the legacy billing
/// shape.
#[serde(skip_serializing_if = "Option::is_none")]
pub prepaid_balance: Option<Cent>,
/// Whether this user is on unified usage billing (shared weekly/monthly
/// pool). From `GrokCreditsConfig.is_unified_billing_user`, which billing
/// sets from remote settings `unified_consumer_billing_enabled`. `None` when
/// absent (legacy `GetGrokBuildBillingConfig` shape or older servers).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_unified_billing_user: Option<bool>,
/// Deprecated: use `current_period.start`.
#[serde(skip_serializing_if = "Option::is_none")]
pub billing_period_start: Option<String>,
/// Deprecated: use `current_period.end`.
#[serde(skip_serializing_if = "Option::is_none")]
pub billing_period_end: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<BillingPeriodUsage>,
}
/// Top-level response (primarily from `GET /rest/grok/credits` + auto-topup-rule).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BillingConfigResponse {
pub config: Option<BillingConfig>,
/// Whether on-demand credit usage is enabled. When `false`, the pager
/// should hide on-demand controls. Populated from `RemoteSettings`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_demand_enabled: Option<bool>,
/// User-friendly subscription tier name (e.g. "SuperGrok Heavy").
/// Populated from `RemoteSettings` so the pager can update its cached
/// tier on every billing fetch without an extra request.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subscription_tier: Option<String>,
}
/// Auto top-up configuration (from GetAutoTopupRule).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AutoTopupRule {
/// proto3 JSON omits `false`, so a disabled rule arrives without this field;
/// default to `false` rather than failing the parse (which would otherwise
/// keep a stale cached rule in the pager).
#[serde(default)]
pub enabled: bool,
pub min_before_hitting_sl: Option<Cent>,
pub topup_amount: Option<Cent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_amount_per_month: Option<Cent>,
}
/// Wrapper for the auto top-up rule response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetAutoTopupRuleResponse {
#[serde(default)]
pub rule: Option<AutoTopupRule>,
} }
#[tracing::instrument(skip_all, fields(method = %args.method))] #[tracing::instrument(skip_all, fields(method = %args.method))]
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
match args.method.as_ref() { match args.method.as_ref() {
"x.ai/billing" => { "x.ai/billing" => {
tracing::info!("handling billing config request"); tracing::info!("handling usage request");
handle_get_billing(agent).await handle_get_usage(agent).await
}
"x.ai/auto-topup-rule" => {
tracing::info!("handling auto top-up rule request");
handle_get_auto_topup_rule(agent).await
} }
_ => Err(acp::Error::method_not_found()), _ => Err(acp::Error::method_not_found()),
} }
} }
/// Structured context for unified-log entries from a successful billing fetch. async fn handle_get_usage(agent: &MvpAgent) -> ExtResult {
/// let auth = super::auth_gate::require_xai_auth(
/// Keeps history to a count + the most recent period so `~/.kigi/logs/unified.jsonl` &agent.auth_manager,
/// stays useful without dumping unbounded period arrays. "Authentication required to fetch usage data",
fn billing_unified_log_ctx(billing: &BillingConfigResponse) -> serde_json::Value { "Usage data requires a Kimi Code subscription session. Run `kigi login` to authenticate.",
let history_len = billing )?;
.config
.as_ref()
.map(|c| c.history.len())
.unwrap_or(0);
let latest_history = billing
.config
.as_ref()
.and_then(|c| c.history.last())
.and_then(|p| serde_json::to_value(p).ok());
let mut config_value = billing let base = agent.cfg.borrow().endpoints.proxy_url();
.config let usage = fetch_usage(&crate::http::shared_client(), &base, &auth.key)
.as_ref() .await
.and_then(|c| serde_json::to_value(c).ok()) .map_err(|e| {
.unwrap_or(serde_json::Value::Null); tracing::warn!(error = %e, "usage fetch failed");
if let Some(obj) = config_value.as_object_mut() { kigi_log::unified_log::warn(
// Drop full history array; surface length + latest entry instead. "usage: fetch failed",
obj.remove("history"); None,
obj.insert("historyLen".into(), serde_json::json!(history_len)); Some(serde_json::json!({ "error": e.to_string() })),
if let Some(latest) = latest_history { );
obj.insert("latestHistory".into(), latest); acp::Error::internal_error().data(e.to_string())
})?;
kigi_log::unified_log::info(
"usage: fetched quota rows",
None,
serde_json::to_value(&usage).ok(),
);
to_raw_response(&usage)
}
/// `GET {base}/usages` with a Bearer token, parsed per kimi-cli usage.py.
pub(crate) async fn fetch_usage(
http: &reqwest::Client,
base_url: &str,
token: &str,
) -> Result<UsageResponse, UsageError> {
let url = format!("{}/usages", base_url.trim_end_matches('/'));
let response = http
.get(&url)
.bearer_auth(token)
.timeout(std::time::Duration::from_secs(15))
.send()
.await?;
match response.status().as_u16() {
200..=299 => {}
401 => return Err(UsageError::Unauthorized),
404 => return Err(UsageError::NotFound),
status => return Err(UsageError::Http { status }),
}
let payload: serde_json::Value = serde_json::from_str(&response.text().await?)?;
Ok(parse_usage_payload(&payload))
}
/// Port of usage.py `_parse_usage_payload`: `usage` (summary) + `limits[]`.
fn parse_usage_payload(payload: &serde_json::Value) -> UsageResponse {
let mut rows = Vec::new();
if let Some(usage) = payload.get("usage").filter(|v| v.is_object())
&& let Some(row) = to_usage_row(usage, "Weekly limit")
{
rows.push(row);
}
if let Some(limits) = payload.get("limits").and_then(|v| v.as_array()) {
for (idx, item) in limits.iter().enumerate() {
if !item.is_object() {
continue;
}
let detail = match item.get("detail") {
Some(d) if d.is_object() => d,
_ => item,
};
let empty = serde_json::json!({});
let window = match item.get("window") {
Some(w) if w.is_object() => w,
_ => &empty,
};
let label = limit_label(item, detail, window, idx);
if let Some(row) = to_usage_row(detail, &label) {
rows.push(row);
}
} }
} }
serde_json::json!({ UsageResponse { rows }
"config": config_value, }
"onDemandEnabled": billing.on_demand_enabled,
"subscriptionTier": billing.subscription_tier, /// Port of usage.py `_to_usage_row`: `used`/`limit`, with
/// `used = limit - remaining` fallback; row dropped when both absent.
fn to_usage_row(data: &serde_json::Value, default_label: &str) -> Option<UsageRow> {
let limit = to_int(data.get("limit"));
let used = to_int(data.get("used")).or_else(|| match (to_int(data.get("remaining")), limit) {
(Some(remaining), Some(limit)) => Some(limit - remaining),
_ => None,
});
if used.is_none() && limit.is_none() {
return None;
}
let label = data
.get("name")
.and_then(non_empty_str)
.or_else(|| data.get("title").and_then(non_empty_str))
.map(str::to_owned)
.unwrap_or_else(|| default_label.to_owned());
Some(UsageRow {
label,
used: used.unwrap_or(0),
limit: limit.unwrap_or(0),
reset_hint: reset_hint(data),
}) })
} }
async fn handle_get_billing(agent: &MvpAgent) -> ExtResult { /// Port of usage.py `_limit_label`: name/title/scope, else the window
let auth = super::auth_gate::require_xai_auth( /// duration ("5h limit"), else "Limit #N".
&agent.auth_manager, fn limit_label(
"Authentication required to fetch billing data", item: &serde_json::Value,
"Billing data requires auth with grok.com. Run `grok login` to authenticate.", detail: &serde_json::Value,
)?; window: &serde_json::Value,
idx: usize,
let proxy_base = agent.cli_chat_proxy_base_url(); ) -> String {
let base = proxy_base.trim_end_matches('/'); for key in ["name", "title", "scope"] {
if let Some(val) = item
// Credits balance / usage (new billing system) via the CLI proxy, which .get(key)
// forwards to the backend `GetGrokCreditsConfig`. .and_then(non_empty_str)
let credits_url = format!("{}/billing?format=credits", base); .or_else(|| detail.get(key).and_then(non_empty_str))
let credits_resp = crate::http::shared_client() {
.get(&credits_url) return val.to_owned();
.header("Authorization", format!("Bearer {}", auth.key)) }
.header("x-userid", &auth.user_id)
.header("x-grok-client-version", kigi_version::VERSION)
.header(
crate::http::CLIENT_MODE_HEADER,
crate::http::process_client_mode(),
)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| {
tracing::error!(error = %e, "billing: upstream request failed");
kigi_log::unified_log::warn(
"billing: upstream request failed",
None,
Some(serde_json::json!({ "error": e.to_string() })),
);
acp::Error::internal_error().data(format!("Failed to fetch billing data: {e}"))
})?;
if !credits_resp.status().is_success() {
let status = credits_resp.status().as_u16();
let body = credits_resp.text().await.unwrap_or_default();
tracing::warn!(status, url = %credits_url, "billing: upstream error");
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or_else(|| format!("HTTP {status}"));
kigi_log::unified_log::warn(
"billing: upstream error",
None,
Some(serde_json::json!({
"status": status,
"detail": detail,
})),
);
return Err(acp::Error::internal_error().data(format!("Billing service error: {detail}")));
} }
let mut billing: BillingConfigResponse = credits_resp.json().await.map_err(|e| { let duration = to_int(window.get("duration"))
tracing::error!(error = %e, "billing: failed to parse response"); .or_else(|| to_int(item.get("duration")))
kigi_log::unified_log::warn( .or_else(|| to_int(detail.get("duration")));
"billing: failed to parse response", let time_unit = window
None, .get("timeUnit")
Some(serde_json::json!({ "error": e.to_string() })), .and_then(non_empty_str)
); .or_else(|| item.get("timeUnit").and_then(non_empty_str))
acp::Error::internal_error().data(format!("Failed to parse billing data: {e}")) .or_else(|| detail.get("timeUnit").and_then(non_empty_str))
})?; .unwrap_or("");
if let Some(duration) = duration.filter(|&d| d != 0) {
if time_unit.contains("MINUTE") {
if duration >= 60 && duration % 60 == 0 {
return format!("{}h limit", duration / 60);
}
return format!("{duration}m limit");
}
if time_unit.contains("HOUR") {
return format!("{duration}h limit");
}
if time_unit.contains("DAY") {
return format!("{duration}d limit");
}
return format!("{duration}s limit");
}
// Enrich with fields from remote settings. format!("Limit #{}", idx + 1)
let rs = agent.cfg.borrow().remote_settings.clone();
billing.on_demand_enabled = rs.as_ref().and_then(|rs| rs.on_demand_enabled);
billing.subscription_tier = rs.as_ref().and_then(|rs| {
rs.subscription_tier_display
.clone()
.or_else(|| rs.subscription_tier.clone())
});
// Every prompt / /usage / poll path hits `x.ai/billing`; log the fetched
// credits snapshot so support can correlate limit UX with real balances.
kigi_log::unified_log::info(
"billing: fetched credits config",
None,
Some(billing_unified_log_ctx(&billing)),
);
to_raw_response(&billing)
} }
async fn handle_get_auto_topup_rule(agent: &MvpAgent) -> ExtResult { /// Port of usage.py `_reset_hint`: absolute reset keys first, then
let auth = super::auth_gate::require_xai_auth( /// seconds-until keys.
&agent.auth_manager, fn reset_hint(data: &serde_json::Value) -> Option<String> {
"Authentication required to fetch auto top-up rule", for key in ["reset_at", "resetAt", "reset_time", "resetTime"] {
"Auto top-up data requires auth with grok.com. Run `grok login` to authenticate.", if let Some(val) = data.get(key).and_then(non_empty_str) {
)?; return Some(format_reset_time(val));
let proxy_base = agent.cli_chat_proxy_base_url();
let base = proxy_base.trim_end_matches('/');
// Auto top-up rule via the CLI proxy, which forwards to the backend
// `GetAutoTopupRule`.
let url = format!("{}/auto-topup-rule", base);
let response = crate::http::shared_client()
.get(&url)
.header("Authorization", format!("Bearer {}", auth.key))
.header("x-userid", &auth.user_id)
.header("x-grok-client-version", kigi_version::VERSION)
.header(
crate::http::CLIENT_MODE_HEADER,
crate::http::process_client_mode(),
)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| {
tracing::error!(error = %e, "auto-topup: upstream request failed");
acp::Error::internal_error().data(format!("Failed to fetch auto top-up rule: {e}"))
})?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
tracing::warn!(status, url = %url, "auto-topup: upstream error");
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or_else(|| format!("HTTP {status}"));
return Err(
acp::Error::internal_error().data(format!("Auto top-up service error: {detail}"))
);
} }
}
for key in ["reset_in", "resetIn", "ttl", "window"] {
if let Some(seconds) = to_int(data.get(key)).filter(|&s| s != 0) {
return Some(format!(
"resets in {}",
format_duration(seconds.max(0) as u64)
));
}
}
None
}
// Return the upstream response body verbatim (as a JSON value) so /usage /// Port of usage.py `_format_reset_time`: ISO timestamp → "resets in …" /
// can print the exact data from this request unformatted. /// "reset" (already past) / "resets at <raw>" when unparseable.
let body_text = response.text().await.unwrap_or_default(); fn format_reset_time(val: &str) -> String {
let value: serde_json::Value = match chrono::DateTime::parse_from_rfc3339(val) {
serde_json::from_str(&body_text).unwrap_or(serde_json::json!({"raw": body_text})); Ok(dt) => {
to_raw_response(&value) let delta = dt.with_timezone(&chrono::Utc) - chrono::Utc::now();
let seconds = delta.num_seconds();
if seconds <= 0 {
"reset".to_owned()
} else {
format!("resets in {}", format_duration(seconds as u64))
}
}
Err(_) => format!("resets at {val}"),
}
}
/// Port of kimi-cli `utils/datetime.py` `format_duration`: short units,
/// seconds shown only for sub-minute durations.
fn format_duration(seconds: u64) -> String {
let days = seconds / 86_400;
let hours = (seconds % 86_400) / 3_600;
let minutes = (seconds % 3_600) / 60;
let secs = seconds % 60;
let mut parts = Vec::new();
if days > 0 {
parts.push(format!("{days}d"));
}
if hours > 0 {
parts.push(format!("{hours}h"));
}
if minutes > 0 {
parts.push(format!("{minutes}m"));
}
if secs > 0 && parts.is_empty() {
parts.push(format!("{secs}s"));
}
if parts.is_empty() {
"0s".to_owned()
} else {
parts.join(" ")
}
}
fn non_empty_str(v: &serde_json::Value) -> Option<&str> {
v.as_str().filter(|s| !s.is_empty())
}
/// Port of usage.py `_to_int`: ints and int-shaped floats/strings; anything
/// else is `None`.
fn to_int(value: Option<&serde_json::Value>) -> Option<i64> {
let value = value?;
if let Some(i) = value.as_i64() {
return Some(i);
}
if let Some(f) = value.as_f64() {
return Some(f as i64);
}
value.as_str()?.trim().parse::<i64>().ok()
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use wiremock::matchers::{bearer_token, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test] /// Happy path: GET /usages with Bearer, kimi payload shape → rows with
fn auto_topup_disabled_rule_omits_enabled_field() { /// summary first, remaining-derived `used`, and window-derived labels.
// proto3 JSON omits `false` / `0`, so a disabled rule arrives without #[tokio::test]
// `enabled` (and zero Cents as `{}`). It must still deserialize (as async fn fetch_usage_parses_kimi_payload() {
// disabled) rather than erroring — otherwise the pager keeps a stale let server = MockServer::start().await;
// cached rule. Mock::given(method("GET"))
let json = serde_json::json!({ .and(path("/usages"))
"rule": { "topupAmount": {"val": 500}, "minBeforeHittingSl": {} } .and(bearer_token("tok-42"))
}); .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
let resp: GetAutoTopupRuleResponse = serde_json::from_value(json).unwrap(); "usage": { "limit": 1000, "used": 250, "reset_at": "2099-01-01T00:00:00Z" },
let rule = resp.rule.expect("rule present"); "limits": [
assert!(!rule.enabled);
assert_eq!(rule.topup_amount.unwrap().val, 500);
assert_eq!(rule.min_before_hitting_sl.unwrap().val, 0);
}
#[test]
fn billing_config_response_deserializes_from_backend_json() {
let json = serde_json::json!({
"config": {
"monthlyLimit": {"val": 2000},
"used": {"val": 1234},
"onDemandCap": {"val": 500},
"billingPeriodStart": "2025-04-01T00:00:00Z",
"billingPeriodEnd": "2025-05-01T00:00:00Z",
"history": [
{ {
"billingCycle": {"year": 2025, "month": 3}, "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
"includedUsed": {"val": 1800}, "detail": { "limit": 50, "remaining": 30, "resetIn": 1800 }
"onDemandUsed": {"val": 0}, },
"totalUsed": {"val": 1800} { "name": "RPM", "limit": 60, "used": 12 }
}
] ]
} })))
}); .expect(1)
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap(); .mount(&server)
let config = resp.config.unwrap(); .await;
assert_eq!(config.monthly_limit.unwrap().val, 2000);
assert_eq!(config.used.unwrap().val, 1234);
assert_eq!(config.on_demand_cap.unwrap().val, 500);
assert_eq!(
config.billing_period_start.as_deref(),
Some("2025-04-01T00:00:00Z")
);
assert_eq!(config.history.len(), 1);
let period = &config.history[0];
let cycle = period.billing_cycle.as_ref().unwrap();
assert_eq!(cycle.year, 2025);
assert_eq!(cycle.month, 3);
assert_eq!(period.included_used.as_ref().unwrap().val, 1800);
assert_eq!(period.total_used.as_ref().unwrap().val, 1800);
}
#[test] let usage = fetch_usage(&reqwest::Client::new(), &server.uri(), "tok-42")
fn billing_unified_log_ctx_includes_credits_and_collapses_history() { .await
let resp = BillingConfigResponse { .expect("usage fetch should succeed");
config: Some(BillingConfig {
credit_usage_percent: Some(42.5), assert_eq!(usage.rows.len(), 3);
current_period: Some(UsagePeriod { assert_eq!(usage.rows[0].label, "Weekly limit");
period_type: Some("USAGE_PERIOD_TYPE_WEEKLY".into()), assert_eq!(usage.rows[0].used, 250);
start: Some("2025-04-01T00:00:00Z".into()), assert_eq!(usage.rows[0].limit, 1000);
end: Some("2025-04-08T00:00:00Z".into()),
}),
monthly_limit: Some(Cent { val: 2000 }),
used: Some(Cent { val: 850 }),
on_demand_cap: Some(Cent { val: 500 }),
on_demand_used: Some(Cent { val: 0 }),
prepaid_balance: Some(Cent { val: 100 }),
is_unified_billing_user: Some(true),
billing_period_start: None,
billing_period_end: None,
history: vec![
BillingPeriodUsage {
billing_cycle: Some(BillingCycle {
year: 2025,
month: 2,
}),
included_used: Some(Cent { val: 1000 }),
on_demand_used: Some(Cent { val: 0 }),
total_used: Some(Cent { val: 1000 }),
},
BillingPeriodUsage {
billing_cycle: Some(BillingCycle {
year: 2025,
month: 3,
}),
included_used: Some(Cent { val: 1800 }),
on_demand_used: Some(Cent { val: 0 }),
total_used: Some(Cent { val: 1800 }),
},
],
}),
on_demand_enabled: Some(true),
subscription_tier: Some("SuperGrok".into()),
};
let ctx = billing_unified_log_ctx(&resp);
assert_eq!(ctx["onDemandEnabled"], true);
assert_eq!(ctx["subscriptionTier"], "SuperGrok");
let config = ctx["config"].as_object().expect("config object");
assert!( assert!(
config.get("history").is_none(), usage.rows[0]
"full history must be collapsed" .reset_hint
.as_deref()
.is_some_and(|h| h.starts_with("resets in")),
"absolute reset_at renders a relative hint: {:?}",
usage.rows[0].reset_hint
); );
assert_eq!(config["historyLen"], 2); // 300 minutes → "5h limit"; used derived from remaining (50-30=20).
assert_eq!( assert_eq!(usage.rows[1].label, "5h limit");
config["latestHistory"]["billingCycle"]["month"], 3, assert_eq!(usage.rows[1].used, 20);
"latest history period retained" assert_eq!(usage.rows[1].limit, 50);
); assert_eq!(usage.rows[1].reset_hint.as_deref(), Some("resets in 30m"));
assert_eq!(config["creditUsagePercent"], 42.5); // Item-level fields when there is no `detail` object.
assert_eq!(config["prepaidBalance"]["val"], 100); assert_eq!(usage.rows[2].label, "RPM");
assert_eq!(usage.rows[2].used, 12);
assert_eq!(usage.rows[2].limit, 60);
}
/// Auth failure: 401 maps to the typed `Unauthorized` error (kimi-cli's
/// "Authorization failed" path).
#[tokio::test]
async fn fetch_usage_maps_401_to_unauthorized() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/usages"))
.respond_with(ResponseTemplate::new(401))
.expect(1)
.mount(&server)
.await;
let err = fetch_usage(&reqwest::Client::new(), &server.uri(), "bad")
.await
.expect_err("401 must fail");
assert!(matches!(err, UsageError::Unauthorized));
}
/// 404 maps to the "endpoint not available" error (kimi-cli parity).
#[tokio::test]
async fn fetch_usage_maps_404_to_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/usages"))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&server)
.await;
let err = fetch_usage(&reqwest::Client::new(), &server.uri(), "tok")
.await
.expect_err("404 must fail");
assert!(matches!(err, UsageError::NotFound));
}
/// Empty payload parses to zero rows (TUI shows "No usage data").
#[test]
fn parse_usage_payload_empty_object_yields_no_rows() {
let usage = parse_usage_payload(&serde_json::json!({}));
assert!(usage.rows.is_empty());
} }
#[test] #[test]
fn billing_config_response_roundtrips_through_json() { fn format_duration_matches_kimi_semantics() {
let config = BillingConfig { assert_eq!(format_duration(0), "0s");
credit_usage_percent: None, assert_eq!(format_duration(45), "45s");
current_period: None, assert_eq!(format_duration(90), "1m");
monthly_limit: Some(Cent { val: 5000 }), assert_eq!(format_duration(3_661), "1h 1m");
used: Some(Cent { val: 123 }), assert_eq!(format_duration(90_000), "1d 1h");
on_demand_cap: Some(Cent { val: 0 }),
on_demand_used: Some(Cent { val: 50 }),
prepaid_balance: Some(Cent { val: 750 }),
is_unified_billing_user: None,
billing_period_start: Some("2025-04-01T00:00:00Z".to_string()),
billing_period_end: Some("2025-05-01T00:00:00Z".to_string()),
history: vec![BillingPeriodUsage {
billing_cycle: Some(BillingCycle {
year: 2025,
month: 3,
}),
included_used: Some(Cent { val: 4500 }),
on_demand_used: Some(Cent { val: 100 }),
total_used: Some(Cent { val: 4600 }),
}],
};
let resp = BillingConfigResponse {
config: Some(config),
on_demand_enabled: None,
subscription_tier: None,
};
let json = serde_json::to_value(&resp).unwrap();
let roundtripped: BillingConfigResponse = serde_json::from_value(json).unwrap();
let rt_config = roundtripped.config.unwrap();
assert_eq!(rt_config.monthly_limit.unwrap().val, 5000);
assert_eq!(rt_config.used.unwrap().val, 123);
assert_eq!(rt_config.prepaid_balance.unwrap().val, 750);
assert_eq!(rt_config.history.len(), 1);
}
#[test]
fn billing_config_response_handles_null_config() {
let json = serde_json::json!({"config": null});
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
assert!(resp.config.is_none());
}
#[test]
fn billing_config_response_handles_empty_history() {
let json = serde_json::json!({
"config": {
"monthlyLimit": {"val": 1000},
"used": {"val": 0}
}
});
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
let config = resp.config.unwrap();
assert_eq!(config.monthly_limit.unwrap().val, 1000);
assert!(config.history.is_empty());
}
#[test]
fn billing_config_serializes_camel_case() {
let config = BillingConfig {
credit_usage_percent: None,
current_period: None,
monthly_limit: Some(Cent { val: 100 }),
used: None,
on_demand_cap: None,
on_demand_used: None,
prepaid_balance: None,
is_unified_billing_user: None,
billing_period_start: None,
billing_period_end: None,
history: vec![],
};
let json = serde_json::to_value(&config).unwrap();
assert!(json.get("monthlyLimit").is_some());
// Fields with None are skipped
assert!(json.get("creditUsagePercent").is_none());
assert!(json.get("currentPeriod").is_none());
assert!(json.get("used").is_none());
assert!(json.get("onDemandCap").is_none());
assert!(json.get("onDemandUsed").is_none());
assert!(json.get("prepaidBalance").is_none());
assert!(json.get("billingPeriodStart").is_none());
// Empty history is skipped
assert!(json.get("history").is_none());
}
#[test]
fn billing_config_deserializes_credits_config_shape() {
// Newer `GetGrokCreditsConfig` response: percentage-based usage,
// a typed current period, and history keyed by `period`.
let json = serde_json::json!({
"config": {
"creditUsagePercent": 42.5,
"currentPeriod": {
"type": "USAGE_PERIOD_TYPE_WEEKLY",
"start": "2026-06-01T00:00:00Z",
"end": "2026-06-08T00:00:00Z"
},
"onDemandCap": {"val": 5000},
"onDemandUsed": {"val": 300},
"prepaidBalance": {"val": 1250},
"isUnifiedBillingUser": true,
"productUsage": [
{"product": "PRODUCT_GROK_BUILD", "usagePercent": 61.2}
],
"history": [
{
"period": {
"type": "USAGE_PERIOD_TYPE_WEEKLY",
"start": "2026-05-25T00:00:00Z",
"end": "2026-06-01T00:00:00Z"
},
"onDemandUsed": {"val": 120}
}
]
}
});
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
let config = resp.config.unwrap();
assert_eq!(config.credit_usage_percent, Some(42.5));
let period = config.current_period.as_ref().unwrap();
assert_eq!(
period.period_type.as_deref(),
Some("USAGE_PERIOD_TYPE_WEEKLY")
);
assert_eq!(period.end.as_deref(), Some("2026-06-08T00:00:00Z"));
// Deprecated fields are absent in the credits shape.
assert!(config.monthly_limit.is_none());
assert!(config.billing_period_end.is_none());
assert_eq!(config.on_demand_cap.unwrap().val, 5000);
assert_eq!(config.on_demand_used.unwrap().val, 300);
// Bought (prepaid) credit balance is parsed from the credits config.
assert_eq!(config.prepaid_balance.unwrap().val, 1250);
assert_eq!(config.is_unified_billing_user, Some(true));
// productUsage is still unused by the CLI billing surface.
assert_eq!(config.history.len(), 1);
assert_eq!(config.history[0].on_demand_used.as_ref().unwrap().val, 120);
}
#[test]
fn cent_serializes_as_val_field() {
let c = Cent { val: 4299 };
let json = serde_json::to_value(&c).unwrap();
assert_eq!(json, serde_json::json!({"val": 4299}));
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,15 @@
//! `x.ai/feedback`, `x.ai/feedback/dismiss`, `x.ai/btw`, and `x.ai/review/*` //! `x.ai/feedback`, `x.ai/feedback/dismiss`, `x.ai/btw`, and `x.ai/review/*`
//! extension handlers. //! extension handlers.
//! //!
//! - `feedback`/`feedback/dismiss`: persist user ratings/text locally and //! - `feedback`/`feedback/dismiss`: persist user ratings/text locally; text
//! forward to cli-chat-proxy. //! feedback from subscription (OAuth) sessions is forwarded to the Kimi
//! Code feedback endpoint (`POST {base}/feedback`, kimi-cli slash.py
//! parity). Without a subscription session the record stays local and the
//! response points at the GitHub issue tracker.
//! - `btw`: dispatch a side question to the active session via //! - `btw`: dispatch a side question to the active session via
//! `SessionCommand::SideQuestion` and return the answer. //! `SessionCommand::SideQuestion` and return the answer.
//! - `review/comment` and `review/comment/delete`: record inline code review //! - `review/comment` and `review/comment/delete`: record inline code review
//! events to cloud storage. //! events locally.
use std::sync::Arc; use std::sync::Arc;
@@ -15,6 +18,7 @@ use tokio::sync::oneshot;
use super::{ExtResult, parse_params}; use super::{ExtResult, parse_params};
use crate::agent::MvpAgent; use crate::agent::MvpAgent;
use crate::agent::feedback_client::FEEDBACK_ISSUES_URL;
use crate::session::persistence::{LocalFeedbackEntry, UserFeedbackEntry}; use crate::session::persistence::{LocalFeedbackEntry, UserFeedbackEntry};
use crate::session::{ use crate::session::{
ClientFeedbackInput, CommentDeleteRequest, CommentDeleteResponse, CommentRequest, ClientFeedbackInput, CommentDeleteRequest, CommentDeleteResponse, CommentRequest,
@@ -34,7 +38,7 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
} }
m if m.starts_with("x.ai/review") => { m if m.starts_with("x.ai/review") => {
tracing::info!("handling review comment"); tracing::info!("handling review comment");
handle_review(agent, args).await handle_review(args).await
} }
_ => Err(acp::Error::method_not_found()), _ => Err(acp::Error::method_not_found()),
} }
@@ -97,8 +101,7 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
let simple: crate::session::FeedbackRequest = parse_params(args)?; let simple: crate::session::FeedbackRequest = parse_params(args)?;
ClientFeedbackInput { ClientFeedbackInput {
session_id: simple.session_id, session_id: simple.session_id,
client_type: client_type: crate::session::feedback_types::ClientType::Tui,
prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Tui,
rating_type: None, rating_type: None,
rating_value: None, rating_value: None,
feedback_text: Some(simple.feedback_text), feedback_text: Some(simple.feedback_text),
@@ -151,7 +154,7 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
submission.merge_metadata(user_meta); submission.merge_metadata(user_meta);
} }
// Enrich with session context for Slack notifications (best-effort). // Enrich with session context (persisted alongside the record).
if let Some(ref session_handle) = session_handle { if let Some(ref session_handle) = session_handle {
let (tx, rx) = tokio::sync::oneshot::channel(); let (tx, rx) = tokio::sync::oneshot::channel();
let _ = session_handle let _ = session_handle
@@ -174,7 +177,7 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
if let (Some(session_handle), Some(rating_value)) = if let (Some(session_handle), Some(rating_value)) =
(&session_handle, feedback_input.rating_value) (&session_handle, feedback_input.rating_value)
{ {
use prod_mc_cli_chat_proxy_types::feedback_types::RatingType; use crate::session::feedback_types::RatingType;
let (is_positive, is_negative) = match feedback_input.rating_type { let (is_positive, is_negative) = match feedback_input.rating_type {
// Thumbs: -1 = down, 0 = neutral, 1 = up // Thumbs: -1 = down, 0 = neutral, 1 = up
Some(RatingType::Thumbs) | None => (rating_value > 0, rating_value < 0), Some(RatingType::Thumbs) | None => (rating_value > 0, rating_value < 0),
@@ -208,8 +211,8 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
let client = agent.feedback_client(); let client = agent.feedback_client();
if client.is_none() { if client.is_none() {
tracing::warn!( tracing::info!(
"no feedback client available (missing proxy credentials); feedback saved locally only" "no subscription session; feedback saved locally — submit at {FEEDBACK_ISSUES_URL}"
); );
} }
let outcome = crate::session::feedback_manager::submit_feedback_workflow( let outcome = crate::session::feedback_manager::submit_feedback_workflow(
@@ -222,15 +225,17 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
match &outcome { match &outcome {
crate::session::feedback_manager::SubmitOutcome::Submitted => { crate::session::feedback_manager::SubmitOutcome::Submitted => {
tracing::info!("feedback submitted to proxy successfully"); tracing::info!("feedback submitted to the Kimi Code endpoint");
} }
crate::session::feedback_manager::SubmitOutcome::LocalOnly => { crate::session::feedback_manager::SubmitOutcome::LocalOnly => {
tracing::warn!("feedback saved locally only (no proxy client)"); tracing::info!("feedback saved locally only");
} }
crate::session::feedback_manager::SubmitOutcome::Failed(e) => { crate::session::feedback_manager::SubmitOutcome::Failed(e) => {
tracing::error!(error = %e, "feedback submission to proxy failed"); tracing::error!(error = %e, "feedback submission failed");
return Err(acp::Error::internal_error() return Err(acp::Error::internal_error().data(format!(
.data(format!("Feedback submission failed: {e}"))); "Feedback submission failed: {e}. \
Please submit feedback at {FEEDBACK_ISSUES_URL}"
)));
} }
} }
@@ -281,37 +286,15 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
} }
} }
let request_id = dismiss_input.request_id.clone(); let value = serde_json::to_value(serde_json::json!({
let client = agent "requestId": dismiss_input.request_id,
.feedback_client() "status": "dismissed",
.ok_or_else(|| acp::Error::internal_error().data("No credentials for feedback"))?; }))
let feedback_base_url = agent.cfg.borrow().endpoints.resolve_feedback_base_url();
match client.dismiss_request(&request_id).await {
Ok(response) => {
tracing::info!(
request_id = %response.request_id,
status = %response.status,
feedback_url = %feedback_base_url,
"Feedback request dismissed"
);
let value = serde_json::to_value(&response)
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from)) .map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
.expect("to work") .expect("to work")
.expect("to work"); .expect("to work");
Ok(acp::ExtResponse::new(value)) Ok(acp::ExtResponse::new(value))
} }
Err(e) => {
tracing::warn!(
error = %e,
request_id = %request_id,
feedback_url = %feedback_base_url,
"Failed to dismiss feedback request"
);
Err(acp::Error::internal_error()
.data(format!("Failed to dismiss feedback request: {e}")))
}
}
}
_ => Err(acp::Error::method_not_found()), _ => Err(acp::Error::method_not_found()),
} }
} }
@@ -319,9 +302,9 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
/// Record inline code review events. /// Record inline code review events.
/// ///
/// Methods: /// Methods:
/// - `x.ai/review/comment`: record a new inline code comment to cloud storage /// - `x.ai/review/comment`: record a new inline code comment
/// - `x.ai/review/comment/delete`: record a tombstone event for a deleted comment /// - `x.ai/review/comment/delete`: record a tombstone event for a deleted comment
async fn handle_review(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { async fn handle_review(args: &acp::ExtRequest) -> ExtResult {
match args.method.as_ref() { match args.method.as_ref() {
"x.ai/review/comment" => { "x.ai/review/comment" => {
let request: CommentRequest = parse_params(args)?; let request: CommentRequest = parse_params(args)?;
@@ -1,7 +1,6 @@
pub mod auth; pub mod auth;
pub(crate) mod auth_gate; pub(crate) mod auth_gate;
pub mod billing; pub mod billing;
pub mod bundle;
pub mod chat_conversation_history; pub mod chat_conversation_history;
pub mod code_nav; pub mod code_nav;
pub mod debug; pub mod debug;
@@ -28,7 +27,6 @@ pub mod search;
pub mod session_admin; pub mod session_admin;
pub mod session_search; pub mod session_search;
pub mod session_updates; pub mod session_updates;
pub mod share;
pub mod skills; pub mod skills;
pub mod suggest; pub mod suggest;
pub mod task; pub mod task;
@@ -4,8 +4,8 @@
//! persistent or shared agent state but are not part of the per-turn prompt //! persistent or shared agent state but are not part of the per-turn prompt
//! lifecycle: //! lifecycle:
//! //!
//! - `x.ai/session/rename` rename a session locally + remote //! - `x.ai/session/rename` rename a session locally
//! - `x.ai/session/delete` delete a session locally + remote //! - `x.ai/session/delete` delete a session locally
//! - `x.ai/session/update_mcp_servers` mid-session MCP server swap //! - `x.ai/session/update_mcp_servers` mid-session MCP server swap
//! - `x.ai/session/fork` fork a session into a new one //! - `x.ai/session/fork` fork a session into a new one
//! - `x.ai/internal/reload_all_mcp_servers` config hot-reload, all sessions //! - `x.ai/internal/reload_all_mcp_servers` config hot-reload, all sessions
@@ -77,7 +77,8 @@ async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
} }
if req.kind == SessionKind::Chat { if req.kind == SessionKind::Chat {
return rename_chat_conversation(agent, &req.session_id, &req.title).await; return Err(acp::Error::invalid_request()
.data("chat conversations are not available in kigi (local sessions only)"));
} }
let session_id = acp::SessionId::new(Arc::from(req.session_id.as_str())); let session_id = acp::SessionId::new(Arc::from(req.session_id.as_str()));
@@ -111,22 +112,6 @@ async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
// Send a SessionSummaryGenerated notification so the TUI updates its title // Send a SessionSummaryGenerated notification so the TUI updates its title
notify_session_title(agent, session_id, &req.title).await; notify_session_title(agent, session_id, &req.title).await;
if agent.is_writeback_storage() && agent.current_auth().is_some() {
use crate::remote::client::BackendClient;
use crate::session::export::ExportedMetadata;
let mut metadata = ExportedMetadata::from_summary(summary);
metadata.title = Some(req.title.clone());
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
if let Err(e) = BackendClient::new()
.with_auth_manager(agent.auth_manager.clone())
.save_session_data(&req.session_id, &[], Some(&metadata))
.await
{
tracing::warn!(?e, session_id = %req.session_id, "failed to sync renamed title to backend");
}
}
// Hook 2: update session replica with summary (fire-and-forget) // Hook 2: update session replica with summary (fire-and-forget)
if let Some(client) = agent.session_registry_client() { if let Some(client) = agent.session_registry_client() {
let sid = req.session_id.to_string(); let sid = req.session_id.to_string();
@@ -169,49 +154,6 @@ async fn notify_session_title(agent: &MvpAgent, session_id: acp::SessionId, titl
} }
} }
async fn rename_chat_conversation(
agent: &MvpAgent,
conversation_id: &str,
title: &str,
) -> ExtResult {
use crate::remote::{ConvError, UpdateConversationBody};
let Some(client) = agent.conversations_client() else {
return Err(acp::Error::invalid_request()
.data("chat session rename requires the conversations lane (OIDC + chat feature)"));
};
let body = UpdateConversationBody {
title: Some(title.to_owned()),
starred: None,
};
client
.update_conversation(conversation_id, &body)
.await
.map_err(|e| match e {
ConvError::NoOauth => acp::Error::invalid_request()
.data("chat session rename requires xAI OAuth credentials"),
ConvError::Http { status: 404 } => acp::Error::invalid_request()
.data(format!("conversation not found: {conversation_id}")),
other => acp::Error::internal_error()
.data(format!("chat conversation rename failed: {other}")),
})?;
// If this conversation is open live, notify clients of the new title.
let session_id = acp::SessionId::new(Arc::from(conversation_id));
if agent.sessions.borrow().contains_key(&session_id) {
notify_session_title(agent, session_id, title).await;
}
tracing::info!(
session_id = %conversation_id,
title = %title,
"Chat conversation renamed"
);
to_raw_response(&serde_json::json!({ "success": true }))
}
// session/delete // session/delete
/// Delete a session from history. /// Delete a session from history.
@@ -229,31 +171,17 @@ async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
let req: DeleteRequest = parse_params(args)?; let req: DeleteRequest = parse_params(args)?;
if req.kind == SessionKind::Chat { if req.kind == SessionKind::Chat {
return soft_delete_chat_conversation(agent, &req.session_id).await; return Err(acp::Error::invalid_request()
.data("chat conversations are not available in kigi (local sessions only)"));
} }
let session_id = acp::SessionId::new(Arc::from(req.session_id.as_str())); let session_id = acp::SessionId::new(Arc::from(req.session_id.as_str()));
// For writeback storage (non-ZDR): remote delete is authoritative for // Local disk + FTS eviction. Mirrored by the `kigi sessions delete <id>`
// the cloud history and runs first; on failure no local bits are // CLI path.
// touched so the pager does not remove the row or toast success. crate::session::persistence::delete_session_history(&req.session_id, req.cwd.as_deref())
let needs_remote = agent.is_writeback_storage() && agent.current_auth().is_some();
// Shared delete: remote-first, then local disk + FTS eviction.
// Mirrored by the `grok sessions delete <id>` CLI path.
crate::session::persistence::delete_session_history(
&req.session_id,
req.cwd.as_deref(),
needs_remote,
agent.auth_manager.clone(),
)
.await .await
.map_err(|e| { .map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
if let crate::session::persistence::DeleteSessionError::Remote(_) = &e {
tracing::warn!(?e, session_id = %req.session_id, "failed to delete remote session data");
}
acp::Error::internal_error().data(e.to_string())
})?;
// If an in-memory live session exists for this id (e.g. the user // If an in-memory live session exists for this id (e.g. the user
// deleted history for a session that is still open in another agent // deleted history for a session that is still open in another agent
@@ -269,35 +197,6 @@ async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
to_raw_response(&serde_json::json!({ "success": true })) to_raw_response(&serde_json::json!({ "success": true }))
} }
async fn soft_delete_chat_conversation(agent: &MvpAgent, conversation_id: &str) -> ExtResult {
use crate::remote::ConvError;
let Some(client) = agent.conversations_client() else {
return Err(acp::Error::invalid_request()
.data("chat session delete requires the conversations lane (OIDC + chat feature)"));
};
client
.soft_delete_conversation(conversation_id)
.await
.map_err(|e| match e {
ConvError::NoOauth => acp::Error::invalid_request()
.data("chat session delete requires xAI OAuth credentials"),
other => acp::Error::internal_error()
.data(format!("chat conversation soft-delete failed: {other}")),
})?;
let session_id = acp::SessionId::new(Arc::from(conversation_id));
if agent.sessions.borrow().contains_key(&session_id) {
agent.request_session_shutdown(&session_id);
agent.remove_session(&session_id);
}
tracing::info!(session_id = %conversation_id, "Chat conversation soft-deleted");
to_raw_response(&serde_json::json!({ "success": true }))
}
// session/update_mcp_servers // session/update_mcp_servers
async fn handle_update_mcp_servers(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { async fn handle_update_mcp_servers(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
@@ -708,8 +607,7 @@ async fn handle_session_fork(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtRes
let request: ForkSessionRequest = parse_params(args)?; let request: ForkSessionRequest = parse_params(args)?;
let agent_id = crate::util::agent_id::agent_id(); let response = fork_session(request)
let response = fork_session(request, &agent_id, Some(agent.auth_manager.clone()))
.await .await
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?; .map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
@@ -1,204 +0,0 @@
//! `x.ai/share_session` extension handler.
//!
//! Loads a local session, exports it, and asks the backend for a public
//! share URL.
use agent_client_protocol as acp;
use super::{ExtResult, parse_params, to_raw_response};
use crate::agent::MvpAgent;
use crate::remote::client::BackendClient;
use crate::session::export::ExportedSession;
use crate::session::info::Info as SessionInfo;
use crate::session::persistence::list_summaries;
use crate::session::share::{ShareSessionRequest, ShareSessionResponse};
#[tracing::instrument(skip_all, fields(method = %args.method))]
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
match args.method.as_ref() {
"x.ai/share_session" => {
tracing::info!("handling share session request");
handle_share_session(agent, args).await
}
_ => Err(acp::Error::method_not_found()),
}
}
async fn handle_share_session(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let request: ShareSessionRequest = parse_params(args)?;
// Get auth - required for sharing.
let auth = require_xai_auth_for_share(&agent.auth_manager)?;
// Remote settings / feature-flag gate: sharing_enabled defaults to false
// and is only enabled for eligible accounts.
let sharing_enabled = agent
.cfg
.borrow()
.remote_settings
.as_ref()
.and_then(|rs| rs.sharing_enabled)
.unwrap_or(false);
if !sharing_enabled {
return Err(
acp::Error::invalid_params().data("Session sharing is not available for your account.")
);
}
// Find session info by searching through summaries
let summaries = list_summaries(None).await.map_err(|e| {
acp::Error::internal_error().data(format!("Failed to list sessions: {}", e))
})?;
let summary = summaries
.iter()
.find(|s| s.info.id.0.as_ref() == request.session_id.as_str())
.ok_or_else(|| acp::Error::resource_not_found(Some("Session not found".into())))?;
let info = SessionInfo {
id: acp::SessionId::new(request.session_id.clone()),
cwd: summary.info.cwd.clone(),
};
// Load and export session
let exported = ExportedSession::from_local_session(&info)
.await
.map_err(|e| acp::Error::internal_error().data(format!("Failed to load session: {}", e)))?;
// Check for empty session
if exported.messages.is_empty() {
return Err(acp::Error::invalid_params().data("No messages to share yet"));
}
// Upload to backend and get share URL.
let client = BackendClient::new().with_auth_manager(agent.auth_manager.clone());
let agent_id = crate::util::agent_id::agent_id();
let share_url = client
.share_session(&exported, &agent_id)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to share session with backend");
acp::Error::internal_error().data(format!("Failed to share session: {}", e))
})?;
let response = ShareSessionResponse { share_url };
to_raw_response(&response)
}
fn require_xai_auth_for_share(
auth_manager: &crate::auth::AuthManager,
) -> Result<crate::auth::KimiAuth, acp::Error> {
super::auth_gate::require_xai_auth(
auth_manager,
"Authentication required to share session",
"Share session is disabled. Run `grok login` to authenticate.",
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::KimiCodeConfig;
use crate::auth::{AuthMode, KimiAuth};
use chrono::{Duration, Utc};
use std::sync::Arc;
use tempfile::tempdir;
fn make_auth_manager_with_token_expiring_in(
ttl: Duration,
) -> (Arc<crate::auth::AuthManager>, tempfile::TempDir) {
let dir = tempdir().expect("tempdir for share auth test");
let mgr = Arc::new(crate::auth::AuthManager::new(
dir.path(),
KimiCodeConfig::default(),
));
let expires_at = Utc::now() + ttl;
// We must explicitly set oidc_issuer to a first-party xAI issuer.
// Only OIDC tokens against https://auth.x.ai (or the local-dev equivalent)
// return true from is_xai_auth(). This is required for the share tests to
// exercise the happy path through require_xai_auth_for_share.
let auth = KimiAuth {
auth_mode: AuthMode::OAuth,
key: "test-key".into(),
expires_at: Some(expires_at),
create_time: Utc::now() - Duration::hours(1),
..Default::default()
};
mgr.hot_swap(auth);
(mgr, dir)
}
#[test]
fn share_works_outside_the_5m_early_invalidation_window() {
let (mgr, _dir) = make_auth_manager_with_token_expiring_in(Duration::minutes(10));
assert!(mgr.current().is_some());
assert!(require_xai_auth_for_share(&mgr).is_ok());
}
#[test]
fn share_succeeds_inside_the_5m_early_invalidation_window() {
let (mgr, _dir) = make_auth_manager_with_token_expiring_in(Duration::seconds(1));
// This is exactly the state that triggered the user bug:
assert!(
mgr.current().is_none(),
"current() drops the token inside the buffer"
);
assert!(mgr.expired_auth().is_some());
// Now that we use current_or_expired(), this passes.
let res = require_xai_auth_for_share(&mgr);
assert!(
res.is_ok(),
"require_xai_auth_for_share must succeed for a still-valid buffered xAI token"
);
}
#[test]
fn share_fails_with_no_auth_at_all() {
let dir = tempdir().expect("tempdir");
let mgr = Arc::new(crate::auth::AuthManager::new(
dir.path(),
KimiCodeConfig::default(),
));
assert!(require_xai_auth_for_share(&mgr).is_err());
}
#[test]
fn share_rejects_non_xai_auth_with_actionable_grok_login_message() {
let dir = tempdir().expect("tempdir");
let mgr = Arc::new(crate::auth::AuthManager::new(
dir.path(),
KimiCodeConfig::default(),
));
// API key is the simplest non-xAI credential (External and enterprise OIDC
// are also rejected the same way).
let non_xai = KimiAuth {
auth_mode: AuthMode::ApiKey,
key: "xai-test-key".into(),
create_time: Utc::now(),
..Default::default()
};
mgr.hot_swap(non_xai);
let err = require_xai_auth_for_share(&mgr)
.expect_err("non-xAI accounts (API key, External, enterprise IdP) must be rejected");
// This is the key assertion the review asked for: we must test the *exact*
// actionable data string for the non-xAI path (distinct from the generic
// "Authentication required to share session" path).
let serialized =
serde_json::to_value(&err).expect("acp::Error serializes to JSON-RPC shape");
let data = serialized
.get("data")
.and_then(|v| v.as_str())
.expect("auth_required error carries a data string");
assert_eq!(
data,
"Share session is disabled. Run `grok login` to authenticate."
);
}
}
-1
View File
@@ -28,7 +28,6 @@ pub mod managed_config;
pub mod mcp_doctor; pub mod mcp_doctor;
pub use kigi_models as models; pub use kigi_models as models;
pub mod plugin; pub mod plugin;
pub mod remote;
pub mod sampling; pub mod sampling;
pub mod session; pub mod session;
pub mod terminal; pub mod terminal;
@@ -1,326 +0,0 @@
//! Remote sandbox client for cli-chat-proxy.
//!
//! This module provides an HTTP client to interact with cli-chat-proxy
//! for managing sandbox sessions and environments via REST API.
use std::sync::Arc;
use crate::auth::{AuthManager, KimiCodeConfig};
use anyhow::{Context, Result, bail};
use serde::de::DeserializeOwned;
// Re-export sandbox API types from cli-chat-proxy-types for convenience.
// Sorted alphabetically; see sandbox_types.rs for logical grouping.
pub use prod_mc_cli_chat_proxy_types::{
SandboxCreateEnvironmentRequest, SandboxEnvironment, SandboxEnvironmentResponse,
SandboxEnvironmentVariable, SandboxEnvironmentWithMetadata, SandboxForkRequest,
SandboxForkResponse, SandboxForkedSession, SandboxHibernateResponse,
SandboxListEnvironmentsRequest, SandboxListEnvironmentsResponse,
SandboxListPreinstalledPackagesResponse, SandboxLogsExitCodes, SandboxLogsResponse,
SandboxMode, SandboxPreinstalledPackage, SandboxRestoreRequest, SandboxRestoreResponse,
SandboxSecretInput, SandboxStartRequest, SandboxStartResponse, SandboxStatusResponse,
SandboxTerminateRequest, SandboxUpdateEnvironmentRequest,
};
// ============================================================================
// Sandbox Client
// ============================================================================
/// HTTP client for interacting with the sandbox API via cli-chat-proxy.
///
/// Path parameters (`session_id`, `environment_id`) are interpolated directly
/// into URLs without percent-encoding. This is safe because these IDs are
/// UUIDs in practice. If ID formats ever change to include URL-unsafe
/// characters, the `format!()` calls should be updated to use percent-encoding.
pub struct SandboxClient {
client: reqwest::Client,
base_url: String,
auth_manager: Arc<AuthManager>,
}
impl SandboxClient {
pub fn new(base_url: impl Into<String>, auth_manager: Arc<AuthManager>) -> Self {
Self {
client: crate::http::shared_client(),
base_url: base_url.into(),
auth_manager,
}
}
/// Returns the base URL.
pub fn base_url(&self) -> &str {
&self.base_url
}
// Do not set Content-Type — callers use .json() and reqwest .header() appends.
async fn auth_headers(
&self,
builder: reqwest::RequestBuilder,
) -> Result<reqwest::RequestBuilder> {
let auth = self
.auth_manager
.auth()
.await
.context("failed to resolve sandbox auth")?;
let mut builder = builder
.header("Authorization", format!("Bearer {}", auth.key))
.header("x-userid", &auth.user_id)
.header("x-grok-client-version", kigi_version::VERSION);
if let Some(email) = &auth.email {
builder = builder.header("x-email", email);
}
builder = builder
.header(
"x-grok-client-identifier",
crate::http::process_client_identifier(),
)
.header(
crate::http::CLIENT_MODE_HEADER,
crate::http::process_client_mode(),
);
Ok(kigi_file_utils::trace_context::inject_trace_context_into_request(builder))
}
/// Check an HTTP response for errors, then deserialize the JSON body.
async fn parse_response<T: DeserializeOwned>(
response: reqwest::Response,
operation: &str,
) -> Result<T> {
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
bail!("{operation} failed: {status} - {body}");
}
response
.json()
.await
.with_context(|| format!("failed to parse {operation} response"))
}
/// Check an HTTP response for errors, discarding the body.
async fn check_response(response: reqwest::Response, operation: &str) -> Result<()> {
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
bail!("{operation} failed: {status} - {body}");
}
Ok(())
}
/// Fork an existing sandbox session.
pub async fn fork_session(&self, request: &SandboxForkRequest) -> Result<SandboxForkResponse> {
let url = format!("{}/sandbox/sessions/fork", self.base_url);
let response = self
.auth_headers(self.client.post(&url))
.await?
.json(request)
.send()
.await
.context("failed to send fork session request")?;
Self::parse_response(response, "fork session").await
}
/// Terminate a sandbox session.
pub async fn terminate_session(
&self,
session_id: &str,
request: &SandboxTerminateRequest,
) -> Result<()> {
let mut url = format!("{}/sandbox/sessions/{}", self.base_url, session_id);
if let Some(env_id) = &request.environment_id {
url = format!("{}?environmentId={}", url, env_id);
}
let response = self
.auth_headers(self.client.delete(&url))
.await?
.send()
.await
.context("failed to send terminate session request")?;
if response.status().as_u16() == 404 {
bail!("session not found: {session_id}");
}
Self::check_response(response, "terminate session").await
}
// ========================================================================
// Session Lifecycle
// ========================================================================
/// Start a sandbox session (non-TUI).
pub async fn start_session(
&self,
request: &SandboxStartRequest,
) -> Result<SandboxStartResponse> {
let url = format!("{}/sandbox/sessions/start", self.base_url);
let response = self
.auth_headers(self.client.post(&url))
.await?
.json(request)
.send()
.await
.context("failed to send start session request")?;
Self::parse_response(response, "start session").await
}
/// Get sandbox session status.
pub async fn get_session_status(&self, session_id: &str) -> Result<SandboxStatusResponse> {
let url = format!("{}/sandbox/sessions/{}/status", self.base_url, session_id);
let response = self
.auth_headers(self.client.get(&url))
.await?
.send()
.await
.context("failed to send get session status request")?;
Self::parse_response(response, "get session status").await
}
/// Get sandbox session logs.
pub async fn get_session_logs(&self, session_id: &str) -> Result<SandboxLogsResponse> {
let url = format!("{}/sandbox/sessions/{}/logs", self.base_url, session_id);
let response = self
.auth_headers(self.client.get(&url))
.await?
.send()
.await
.context("failed to send get session logs request")?;
Self::parse_response(response, "get session logs").await
}
/// Hibernate a sandbox session (snapshot rootfs to GCS and terminate).
pub async fn hibernate_session(&self, session_id: &str) -> Result<SandboxHibernateResponse> {
let url = format!(
"{}/sandbox/sessions/{}/hibernate",
self.base_url, session_id
);
let response = self
.auth_headers(self.client.post(&url))
.await?
.send()
.await
.context("failed to send hibernate session request")?;
Self::parse_response(response, "hibernate session").await
}
/// Restore a previously hibernated sandbox session from its snapshot.
pub async fn restore_session(
&self,
session_id: &str,
request: &SandboxRestoreRequest,
) -> Result<SandboxRestoreResponse> {
let url = format!("{}/sandbox/sessions/{}/restore", self.base_url, session_id);
let response = self
.auth_headers(self.client.post(&url))
.await?
.json(request)
.send()
.await
.context("failed to send restore session request")?;
Self::parse_response(response, "restore session").await
}
// ========================================================================
// Environment CRUD
// ========================================================================
/// List sandbox environments.
pub async fn list_environments(
&self,
request: &SandboxListEnvironmentsRequest,
) -> Result<SandboxListEnvironmentsResponse> {
let url = format!("{}/sandbox/environments", self.base_url);
let mut builder = self.auth_headers(self.client.get(&url)).await?;
if let Some(page) = request.page {
builder = builder.query(&[("page", page)]);
}
if let Some(page_size) = request.page_size {
builder = builder.query(&[("pageSize", page_size)]);
}
let response = builder
.send()
.await
.context("failed to send list environments request")?;
Self::parse_response(response, "list environments").await
}
/// Create a new sandbox environment.
pub async fn create_environment(
&self,
request: &SandboxCreateEnvironmentRequest,
) -> Result<SandboxEnvironmentResponse> {
let url = format!("{}/sandbox/environments", self.base_url);
let response = self
.auth_headers(self.client.post(&url))
.await?
.json(request)
.send()
.await
.context("failed to send create environment request")?;
Self::parse_response(response, "create environment").await
}
/// Get a sandbox environment by ID.
pub async fn get_environment(
&self,
environment_id: &str,
) -> Result<SandboxEnvironmentResponse> {
let url = format!("{}/sandbox/environments/{}", self.base_url, environment_id);
let response = self
.auth_headers(self.client.get(&url))
.await?
.send()
.await
.context("failed to send get environment request")?;
Self::parse_response(response, "get environment").await
}
/// Update a sandbox environment.
pub async fn update_environment(
&self,
environment_id: &str,
request: &SandboxUpdateEnvironmentRequest,
) -> Result<SandboxEnvironmentResponse> {
let url = format!("{}/sandbox/environments/{}", self.base_url, environment_id);
let response = self
.auth_headers(self.client.put(&url))
.await?
.json(request)
.send()
.await
.context("failed to send update environment request")?;
Self::parse_response(response, "update environment").await
}
/// Delete a sandbox environment.
pub async fn delete_environment(&self, environment_id: &str) -> Result<()> {
let url = format!("{}/sandbox/environments/{}", self.base_url, environment_id);
let response = self
.auth_headers(self.client.delete(&url))
.await?
.send()
.await
.context("failed to send delete environment request")?;
Self::check_response(response, "delete environment").await
}
/// List preinstalled packages available for sandbox environments.
pub async fn list_preinstalled_packages(
&self,
) -> Result<SandboxListPreinstalledPackagesResponse> {
let url = format!(
"{}/sandbox/environments/preinstalled-packages",
self.base_url
);
let response = self
.auth_headers(self.client.get(&url))
.await?
.send()
.await
.context("failed to send list preinstalled packages request")?;
Self::parse_response(response, "list preinstalled packages").await
}
}
@@ -1,202 +0,0 @@
//! grok.com chat-product model catalog (`POST /rest/modes`) — the models
//! grok-web's chat picker shows, distinct from the CLI `/v1/models` build
//! catalog. Transport only; cache + ACP mapping live in
//! [`crate::agent::chat_modes`].
use std::sync::Arc;
use serde::Deserialize;
use crate::auth::AuthManager;
const KIGI_WEB_URL: &str = "https://grok.com";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Mode {
#[serde(default)]
pub id: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub badge_text: Option<String>,
#[serde(default)]
pub availability: ModeAvailability,
#[serde(default)]
pub icon_hint: String,
#[serde(default)]
pub tags: Vec<String>,
}
impl Mode {
pub fn is_available(&self) -> bool {
self.availability.available.is_some()
}
}
/// proto3-JSON oneof: exactly one field is present.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModeAvailability {
#[serde(default)]
pub available: Option<serde_json::Value>,
#[serde(default)]
pub unavailable: Option<serde_json::Value>,
#[serde(default)]
pub requires_upgrade: Option<serde_json::Value>,
#[serde(default)]
pub coming_soon: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListModesResponse {
#[serde(default)]
pub modes: Vec<Mode>,
#[serde(default)]
pub default_mode_id: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ChatModelsError {
#[error("no grok.com credentials")]
NoAuth,
#[error("request timed out")]
Timeout,
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
#[error("request failed: {status}")]
Http { status: u16 },
#[error("parse error: {0}")]
Parse(#[from] serde_json::Error),
}
/// Stateless transport for `POST /rest/modes`; caching lives in
/// [`crate::agent::chat_modes::ChatModesManager`].
pub struct ChatModelsClient {
http: reqwest::Client,
base_url: String,
auth: Arc<AuthManager>,
}
impl ChatModelsClient {
pub fn new(auth: Arc<AuthManager>) -> Self {
let base_url = std::env::var("KIGI_MODES_BASE_URL")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
std::env::var("KIGI_CONVERSATIONS_BASE_URL")
.ok()
.filter(|s| !s.is_empty())
})
.or_else(|| {
std::env::var("KIGI_CODE_WEB_URL")
.ok()
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| KIGI_WEB_URL.to_string());
Self {
http: crate::http::shared_client(),
base_url,
auth,
}
}
/// Gated only on a valid grok.com bearer — deliberately NOT `is_xai_auth()`
/// (unlike workspaces/conversations), since `/rest/modes` is the public chat
/// endpoint and that gate would exclude API-key / cached-token chat users.
pub async fn list_modes(&self, locale: &str) -> Result<ListModesResponse, ChatModelsError> {
let auth = self
.auth
.auth()
.await
.map_err(|_| ChatModelsError::NoAuth)?;
let url = format!("{}/rest/modes", self.base_url);
let body = serde_json::json!({ "locale": locale });
let mut builder = self
.http
.post(&url)
.json(&body)
.header("Authorization", format!("Bearer {}", auth.key))
.header("x-userid", &auth.user_id)
.header("x-grok-client-version", kigi_version::VERSION)
.header(
"x-grok-client-identifier",
crate::http::process_client_identifier(),
)
.header(
crate::http::CLIENT_MODE_HEADER,
crate::http::process_client_mode(),
)
.header(reqwest::header::ACCEPT, "application/json");
if let Some(email) = &auth.email {
builder = builder.header("x-email", email);
}
let builder = kigi_file_utils::trace_context::inject_trace_context_into_request(builder);
let response = builder.send().await?;
let status = response.status();
if !status.is_success() {
return Err(ChatModelsError::Http {
status: status.as_u16(),
});
}
let bytes = response.bytes().await?;
let resp: ListModesResponse = serde_json::from_slice(&bytes)?;
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modes_parse_camelcase_wire() {
let json = serde_json::json!({
"modes": [{
"id": "auto",
"title": "Auto",
"description": "Picks the best model",
"badgeText": "New",
"availability": { "available": {} },
"iconHint": "rocket",
"tags": ["TAG_PRIMARY"]
}, {
"id": "heavy",
"title": "Heavy",
"availability": { "requiresUpgrade": { "message": "Upgrade" } }
}],
"defaultModeId": "auto"
});
let resp: ListModesResponse = serde_json::from_value(json).unwrap();
assert_eq!(resp.modes.len(), 2);
assert_eq!(resp.default_mode_id, "auto");
let auto = &resp.modes[0];
assert_eq!(auto.id, "auto");
assert_eq!(auto.title, "Auto");
assert_eq!(auto.badge_text.as_deref(), Some("New"));
assert_eq!(auto.icon_hint, "rocket");
assert_eq!(auto.tags, vec!["TAG_PRIMARY".to_string()]);
assert!(auto.is_available());
assert!(!resp.modes[1].is_available());
}
#[test]
fn missing_fields_default_gracefully() {
let json = serde_json::json!({ "modes": [{ "id": "m1" }] });
let resp: ListModesResponse = serde_json::from_value(json).unwrap();
let m = &resp.modes[0];
assert_eq!(m.id, "m1");
assert!(m.title.is_empty());
assert!(m.description.is_empty());
assert!(m.badge_text.is_none());
// No availability field on the wire → not selectable.
assert!(!m.is_available());
assert!(resp.default_mode_id.is_empty());
}
}
File diff suppressed because it is too large Load Diff
@@ -1,305 +0,0 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::auth::{AuthManager, KimiAuth};
const KIGI_WEB_URL: &str = "https://grok.com";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Conversation {
#[serde(default)]
pub conversation_id: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub starred: bool,
#[serde(default)]
pub create_time: Option<String>,
#[serde(default)]
pub modify_time: Option<String>,
#[serde(default)]
pub workspaces: Vec<Workspace>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Workspace {
#[serde(default)]
pub workspace_id: String,
}
#[derive(Debug, Clone, Default)]
pub struct ConvQuery {
pub page_size: i64,
pub page_token: Option<String>,
pub search_query: Option<String>,
pub workspace_id: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ListConversationsPage {
pub conversations: Vec<Conversation>,
pub next_page_token: Option<String>,
}
/// Body for `PUT /rest/app-chat/conversations/{id}` (grok-web `chatUpdateConversation`).
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateConversationBody {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub starred: Option<bool>,
}
#[derive(Debug, thiserror::Error)]
pub enum ConvError {
#[error("no OAuth credentials for conversations:read")]
NoOauth,
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
#[error("request failed: {status}")]
Http { status: u16 },
#[error("parse error: {0}")]
Parse(#[from] serde_json::Error),
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ListConversationsResponseWire {
#[serde(default)]
conversations: Vec<Conversation>,
#[serde(default)]
next_page_token: Option<String>,
#[serde(default)]
text_search_matches: Vec<ListConversationsMatchWire>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ListConversationsMatchWire {
#[serde(default)]
conversation: Option<Conversation>,
}
pub struct ConversationsClient {
http: reqwest::Client,
base_url: String,
auth: Arc<AuthManager>,
}
impl ConversationsClient {
pub fn new(auth: Arc<AuthManager>) -> Self {
let base_url = std::env::var("KIGI_CONVERSATIONS_BASE_URL")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
std::env::var("KIGI_CODE_WEB_URL")
.ok()
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| KIGI_WEB_URL.to_string());
Self {
http: crate::http::shared_client(),
base_url,
auth,
}
}
async fn require_xai_auth(&self) -> Result<KimiAuth, ConvError> {
let auth = self.auth.auth().await.map_err(|_| ConvError::NoOauth)?;
if !auth.is_session_auth() {
return Err(ConvError::NoOauth);
}
Ok(auth)
}
fn apply_auth_headers(
&self,
builder: reqwest::RequestBuilder,
auth: &KimiAuth,
) -> reqwest::RequestBuilder {
let mut builder = builder
.header("Authorization", format!("Bearer {}", auth.key))
.header("x-userid", &auth.user_id)
.header("x-grok-client-version", kigi_version::VERSION)
.header(
"x-grok-client-identifier",
crate::http::process_client_identifier(),
)
.header(
crate::http::CLIENT_MODE_HEADER,
crate::http::process_client_mode(),
)
.header(reqwest::header::ACCEPT, "application/json");
if let Some(email) = &auth.email {
builder = builder.header("x-email", email);
}
kigi_file_utils::trace_context::inject_trace_context_into_request(builder)
}
pub async fn list_conversations(
&self,
q: &ConvQuery,
) -> Result<ListConversationsPage, ConvError> {
let auth = self.require_xai_auth().await?;
let url = format!("{}/rest/app-chat/conversations", self.base_url);
let mut query: Vec<(&str, String)> = vec![("pageSize", q.page_size.to_string())];
if let Some(token) = q.page_token.as_deref().filter(|s| !s.is_empty()) {
query.push(("pageToken", token.to_owned()));
}
if let Some(search) = q.search_query.as_deref().filter(|s| !s.is_empty()) {
query.push(("searchQuery", search.to_owned()));
}
if let Some(workspace) = q.workspace_id.as_deref().filter(|s| !s.is_empty()) {
query.push(("workspaceId", workspace.to_owned()));
}
let builder = self.apply_auth_headers(self.http.get(&url).query(&query), &auth);
let response = builder.send().await?;
let status = response.status();
if !status.is_success() {
return Err(ConvError::Http {
status: status.as_u16(),
});
}
let bytes = response.bytes().await?;
let wire: ListConversationsResponseWire = serde_json::from_slice(&bytes)?;
let searching = q.search_query.as_deref().is_some_and(|s| !s.is_empty());
// During an active search, results come exclusively from
// `text_search_matches`. Never fall back to `wire.conversations` here:
// an empty match set means "no hits", and the server may return
// recent/unfiltered conversations in `conversations` that are NOT search
// matches — surfacing those would be wrong.
let conversations = if searching {
wire.text_search_matches
.into_iter()
.filter_map(|m| m.conversation)
.collect()
} else {
wire.conversations
};
Ok(ListConversationsPage {
conversations,
next_page_token: wire.next_page_token.filter(|t| !t.is_empty()),
})
}
/// `PUT /rest/app-chat/conversations/{conversation_id}` — rename and/or star.
pub async fn update_conversation(
&self,
conversation_id: &str,
body: &UpdateConversationBody,
) -> Result<(), ConvError> {
let auth = self.require_xai_auth().await?;
let url = format!(
"{}/rest/app-chat/conversations/{}",
self.base_url,
urlencoding::encode(conversation_id)
);
let builder = self
.apply_auth_headers(self.http.put(&url), &auth)
.json(body);
let response = builder.send().await?;
let status = response.status();
if !status.is_success() {
return Err(ConvError::Http {
status: status.as_u16(),
});
}
Ok(())
}
/// `DELETE /rest/app-chat/conversations/soft/{conversation_id}` — soft-delete.
pub async fn soft_delete_conversation(&self, conversation_id: &str) -> Result<(), ConvError> {
let auth = self.require_xai_auth().await?;
let url = format!(
"{}/rest/app-chat/conversations/soft/{}",
self.base_url,
urlencoding::encode(conversation_id)
);
let builder = self.apply_auth_headers(self.http.delete(&url), &auth);
let response = builder.send().await?;
let status = response.status();
// 404 = already soft-deleted; keep deletion idempotent like the
// build path's `classify_remote_delete`.
if !status.is_success() && status.as_u16() != 404 {
return Err(ConvError::Http {
status: status.as_u16(),
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conversation_parses_camelcase_wire() {
let json = serde_json::json!({
"conversations": [{
"conversationId": "conv_abc",
"title": "Compare GPU vendors",
"starred": true,
"createTime": "2026-06-18T17:30:00Z",
"modifyTime": "2026-06-18T18:02:00Z",
"workspaces": [{ "workspaceId": "ws_9f3a" }]
}],
"nextPageToken": "tok2"
});
let wire: ListConversationsResponseWire = serde_json::from_value(json).unwrap();
assert_eq!(wire.conversations.len(), 1);
let c = &wire.conversations[0];
assert_eq!(c.conversation_id, "conv_abc");
assert_eq!(c.title, "Compare GPU vendors");
assert!(c.starred);
assert_eq!(c.modify_time.as_deref(), Some("2026-06-18T18:02:00Z"));
assert_eq!(c.workspaces[0].workspace_id, "ws_9f3a");
assert_eq!(wire.next_page_token.as_deref(), Some("tok2"));
}
#[test]
fn missing_fields_default_gracefully() {
let json = serde_json::json!({ "conversations": [{ "conversationId": "c1" }] });
let wire: ListConversationsResponseWire = serde_json::from_value(json).unwrap();
let c = &wire.conversations[0];
assert_eq!(c.conversation_id, "c1");
assert!(c.title.is_empty());
assert!(c.modify_time.is_none());
assert!(c.create_time.is_none());
assert!(c.workspaces.is_empty());
assert!(wire.next_page_token.is_none());
}
#[test]
fn update_body_serializes_only_set_fields() {
let title_only = UpdateConversationBody {
title: Some("New title".into()),
starred: None,
};
assert_eq!(
serde_json::to_value(&title_only).unwrap(),
serde_json::json!({ "title": "New title" })
);
let both = UpdateConversationBody {
title: Some("T".into()),
starred: Some(true),
};
assert_eq!(
serde_json::to_value(&both).unwrap(),
serde_json::json!({ "title": "T", "starred": true })
);
}
}
@@ -1,37 +0,0 @@
//! Remote storage client for the backend.
pub mod agent;
pub mod chat_models_client;
pub mod client;
pub mod conversations_client;
pub mod pull;
#[cfg(test)]
mod pull_smoke_test;
pub mod sync;
pub mod workspaces_client;
pub use agent::{
SandboxClient, SandboxCreateEnvironmentRequest, SandboxEnvironment, SandboxEnvironmentResponse,
SandboxEnvironmentVariable, SandboxEnvironmentWithMetadata, SandboxForkRequest,
SandboxForkResponse, SandboxForkedSession, SandboxHibernateResponse,
SandboxListEnvironmentsRequest, SandboxListEnvironmentsResponse,
SandboxListPreinstalledPackagesResponse, SandboxLogsExitCodes, SandboxLogsResponse,
SandboxMode, SandboxPreinstalledPackage, SandboxRestoreRequest, SandboxRestoreResponse,
SandboxSecretInput, SandboxStartRequest, SandboxStartResponse, SandboxStatusResponse,
SandboxTerminateRequest, SandboxUpdateEnvironmentRequest,
};
pub use chat_models_client::{
ChatModelsClient, ChatModelsError, ListModesResponse, Mode, ModeAvailability,
};
pub use client::{
BackendClient, BackendError, FetchModelsResult, FetchedBundle, fetch_bundle,
fetch_login_device_flow, fetch_settings_blocking, fetch_subagent_bundle, share_url,
};
pub(crate) use client::{DEFAULT_CONTEXT_WINDOW, fetch_models_blocking, models_fetch_origin};
pub use conversations_client::{
ConvError, ConvQuery, Conversation, ConversationsClient, ListConversationsPage,
UpdateConversationBody,
};
pub use pull::{PullResult, pull_session_to_local};
pub use sync::RemoteSync;
pub use workspaces_client::{ListWorkspacesPage, Workspace, WorkspacesClient, WsError, WsQuery};
@@ -1,772 +0,0 @@
//! Pull-on-miss: fetch a session from the backend and hydrate local JSONL storage.
use crate::remote::client::{BackendClient, BackendError};
#[derive(Debug)]
pub enum PullResult {
/// Written to local storage. The [`Info`] cwd comes from the backend (may differ from caller's).
Hydrated(crate::session::info::Info),
/// Not found on the backend.
NotFound,
}
/// Fetch a session from the backend and hydrate local JSONL storage.
pub async fn pull_session_to_local(
session_id: &str,
client: &BackendClient,
) -> Result<PullResult, BackendError> {
let loaded = match client.load_session_data(session_id).await {
Ok(resp) => resp,
Err(BackendError::SessionNotFound { .. }) => return Ok(PullResult::NotFound),
Err(e) => return Err(e),
};
let remote = match loaded.session.as_ref() {
Some(s) => s,
None => return Ok(PullResult::NotFound),
};
// cwd required for local dir placement; null means pre-writeback session.
let cwd = match remote.cwd.as_ref() {
Some(cwd) => cwd,
None => {
tracing::warn!(session_id, "Cannot pull session: backend has cwd=null");
return Ok(PullResult::NotFound);
}
};
let info = crate::session::info::Info {
id: agent_client_protocol::SessionId::new(std::sync::Arc::from(session_id)),
cwd: cwd.clone(),
};
let dir = crate::session::persistence::session_dir(&info);
let num_messages = hydrate::write_to_dir(&dir, &loaded)?;
tracing::info!(session_id, %cwd, num_messages, "Pulled session from backend");
Ok(PullResult::Hydrated(info))
}
pub(crate) mod hydrate {
use std::path::Path;
use std::sync::Arc;
use crate::remote::client::{BackendError, LoadDataResponse, LoadedMessage, SessionInfo};
use crate::session::info::Info;
use crate::session::persistence::{CHAT_FORMAT_VERSION, Summary, default_model_id};
fn io_err(path: &Path, source: std::io::Error) -> BackendError {
BackendError::Hydration {
path: path.to_path_buf(),
source,
}
}
/// Write all session files to `dir`.
pub(super) fn write_to_dir(
dir: &Path,
loaded: &LoadDataResponse,
) -> Result<usize, BackendError> {
let remote = loaded
.session
.as_ref()
.expect("caller checked session.is_some()");
let info = Info {
id: agent_client_protocol::SessionId::new(Arc::from(remote.session_id.as_str())),
cwd: remote.cwd.clone().expect("caller verified cwd is Some"),
};
std::fs::create_dir_all(dir).map_err(|e| io_err(dir, e))?;
let num_messages = loaded.messages.as_ref().map_or(0, |m| m.len());
let mut num_chat_messages = 0;
if let Some(ref messages) = loaded.messages {
write_updates(dir, messages)?;
num_chat_messages = rebuild_chat_history(dir)?;
}
write_summary(dir, &info, remote, num_messages, num_chat_messages)?;
write_remote_origin_marker(dir);
Ok(num_messages)
}
fn write_summary(
dir: &Path,
info: &Info,
remote: &SessionInfo,
num_messages: usize,
num_chat_messages: usize,
) -> Result<(), BackendError> {
let meta = remote.metadata.as_ref();
let model_id = meta
.and_then(|m| m.get("modelId"))
.and_then(|v| v.as_str())
.map(agent_client_protocol::ModelId::new)
.unwrap_or_else(default_model_id);
let parent_session_id = meta
.and_then(|m| m.get("parentSessionId"))
.and_then(|v| v.as_str())
.map(String::from);
let summary = Summary {
info: info.clone(),
session_summary: remote.title.clone().unwrap_or_default(),
created_at: parse_rfc3339_or_now(remote.created_at.as_deref()),
updated_at: parse_rfc3339_or_now(remote.updated_at.as_deref()),
num_messages,
num_chat_messages,
current_model_id: model_id,
parent_session_id,
forked_at: None,
collection_id: None,
next_trace_turn: 0,
chat_format_version: CHAT_FORMAT_VERSION,
prompt_display_cwd: None,
session_kind: None,
fork_context_source: None,
fork_parent_prompt_id: None,
inherited_prefix_len: None,
hidden: None,
source_workspace_dir: None,
git_root_dir: None,
git_remotes: Vec::new(),
head_commit: None,
head_branch: None,
request_id: None,
// Record the *local* kigi_home (where this hydrated copy lives),
// not the original remote session's, since reconstruction runs locally.
kigi_home: crate::session::persistence::kigi_home_string(),
last_active_at: None,
generated_title: None,
title_is_manual: false,
worktree_label: None,
agent_name: None,
// Hydrated locally — record the profile this process runs under.
sandbox_profile: kigi_sandbox::configured_profile_name().map(String::from),
reasoning_effort: None,
};
let json = serde_json::to_string_pretty(&summary)?;
write_file(&dir.join("summary.json"), json.as_bytes())
}
/// Convert backend JSON-RPC messages to local updates.jsonl (replayable methods only).
pub(super) fn write_updates(
dir: &Path,
messages: &[LoadedMessage],
) -> Result<(), BackendError> {
use std::io::Write;
let path = dir.join("updates.jsonl");
let file = std::fs::File::create(&path).map_err(|e| io_err(&path, e))?;
let mut w = std::io::BufWriter::new(file);
for msg in messages {
let parsed = match serde_json::from_str::<serde_json::Value>(&msg.content) {
Ok(v) => v,
Err(_) => continue,
};
if !is_session_update(&parsed) {
continue;
}
if let Some(line) = to_envelope_line(&parsed) {
let _ = w.write_all(line.as_bytes());
let _ = w.write_all(b"\n");
}
}
w.flush().map_err(|e| io_err(&path, e))
}
/// Rebuild `chat_history.jsonl` from `updates.jsonl` so pulled sessions are continuable.
fn rebuild_chat_history(dir: &Path) -> Result<usize, BackendError> {
use crate::session::storage::UpdatesIterator;
use std::io::{Seek, Write};
let updates_path = dir.join("updates.jsonl");
let Some(iter) =
UpdatesIterator::open(&updates_path).map_err(|e| io_err(&updates_path, e))?
else {
return Ok(0);
};
let chat_path = dir.join("chat_history.jsonl");
let file = std::fs::File::create(&chat_path).map_err(|e| io_err(&chat_path, e))?;
let mut writer = std::io::BufWriter::new(file);
let mut reducer = ChatReducer::new();
for result in iter {
let update = match result {
Ok(u) => u,
Err(_) => continue,
};
for item in reducer.process(&update) {
if let Ok(line) = serde_json::to_string(&item) {
let _ = writer.write_all(line.as_bytes());
let _ = writer.write_all(b"\n");
}
}
// CompactionCheckpoint: truncate file and reset
if reducer.should_truncate() {
reducer.clear_truncate_flag();
let _ = writer.seek(std::io::SeekFrom::Start(0));
let _ = writer.get_mut().set_len(0);
}
}
// Flush trailing state
for item in reducer.flush() {
if let Ok(line) = serde_json::to_string(&item) {
let _ = writer.write_all(line.as_bytes());
let _ = writer.write_all(b"\n");
}
}
writer.flush().map_err(|e| io_err(&chat_path, e))?;
Ok(reducer.count())
}
use crate::sampling::{AssistantItem, ContentPart, ConversationItem, ToolCall};
use agent_client_protocol as acp;
use std::collections::{HashMap, HashSet};
/// Reduces ACP session updates into conversation items.
///
/// Turn boundaries: User→Agent flushes user, Agent→User flushes agent,
/// tool completion flushes agent before emitting result.
struct ChatReducer {
user_parts: Vec<ContentPart>,
agent_text: String,
agent_tool_calls: Vec<ToolCall>,
in_user_turn: bool,
has_agent_content: bool,
needs_truncate: bool,
tool_args: HashMap<String, String>,
emitted_tool_results: HashSet<String>,
item_count: usize,
}
impl ChatReducer {
fn new() -> Self {
Self {
user_parts: Vec::new(),
agent_text: String::new(),
agent_tool_calls: Vec::new(),
in_user_turn: false,
has_agent_content: false,
needs_truncate: false,
tool_args: HashMap::new(),
emitted_tool_results: HashSet::new(),
item_count: 0,
}
}
fn process(
&mut self,
update: &crate::session::storage::SessionUpdate,
) -> Vec<ConversationItem> {
use crate::session::storage::SessionUpdate;
match update {
SessionUpdate::Acp(n) => self.handle_acp(&n.update),
SessionUpdate::Xai(n) => self.handle_xai(&n.update),
}
}
fn handle_acp(&mut self, update: &acp::SessionUpdate) -> Vec<ConversationItem> {
match update {
acp::SessionUpdate::UserMessageChunk(chunk) => self.on_user_chunk(chunk),
acp::SessionUpdate::AgentMessageChunk(chunk) => self.on_agent_chunk(chunk),
acp::SessionUpdate::ToolCall(tc) => self.on_tool_call(tc),
acp::SessionUpdate::ToolCallUpdate(tc) => self.on_tool_call_update(tc),
_ => Vec::new(), // AgentThoughtChunk, Retry, Plan not needed
}
}
fn handle_xai(
&mut self,
update: &crate::extensions::notification::SessionUpdate,
) -> Vec<ConversationItem> {
use crate::extensions::notification::SessionUpdate as XaiUpdate;
match update {
XaiUpdate::CompactionCheckpoint(_) => {
self.reset();
self.needs_truncate = true;
Vec::new()
}
_ => Vec::new(), // DiffReview, MemoryFlush, etc. not needed
}
}
fn on_user_chunk(&mut self, chunk: &acp::ContentChunk) -> Vec<ConversationItem> {
let mut out = Vec::new();
if !self.in_user_turn {
out.extend(self.flush_agent());
self.in_user_turn = true;
}
match &chunk.content {
acp::ContentBlock::Text(t) => {
self.user_parts.push(ContentPart::Text {
text: std::sync::Arc::<str>::from(t.text.clone()),
});
}
acp::ContentBlock::Image(img) => {
if let Some(uri) = &img.uri {
self.user_parts.push(ContentPart::Image {
url: std::sync::Arc::<str>::from(uri.clone()),
});
}
}
_ => {} // Audio, Resource, etc. not needed for chat replay
}
out
}
fn on_agent_chunk(&mut self, chunk: &acp::ContentChunk) -> Vec<ConversationItem> {
let mut out = Vec::new();
if self.in_user_turn {
out.extend(self.flush_user());
self.in_user_turn = false;
}
if let acp::ContentBlock::Text(t) = &chunk.content {
self.agent_text.push_str(&t.text);
self.has_agent_content = true;
}
out
}
fn on_tool_call(&mut self, tc: &acp::ToolCall) -> Vec<ConversationItem> {
let id = tc.tool_call_id.0.to_string();
let args = tc
.raw_input
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
self.tool_args.insert(id.clone(), args.clone());
self.agent_tool_calls.push(ToolCall {
id: std::sync::Arc::<str>::from(id),
name: tc.title.clone(),
arguments: std::sync::Arc::<str>::from(args),
});
Vec::new()
}
fn on_tool_call_update(&mut self, tc: &acp::ToolCallUpdate) -> Vec<ConversationItem> {
let id = tc.tool_call_id.0.to_string();
self.maybe_backfill_args(&id, &tc.fields);
if Self::is_completed(&tc.fields) && self.emitted_tool_results.insert(id.clone()) {
return self.emit_tool_result(&id, &tc.fields);
}
Vec::new()
}
/// Backfill tool arguments from ToolCallUpdate if ToolCall didn't have them.
fn maybe_backfill_args(&mut self, id: &str, fields: &acp::ToolCallUpdateFields) {
let Some(raw) = &fields.raw_input else { return };
let needs_backfill = self.tool_args.get(id).is_none_or(String::is_empty);
if !needs_backfill {
return;
}
let args = raw.to_string();
self.tool_args.insert(id.to_string(), args.clone());
if let Some(call) = self
.agent_tool_calls
.iter_mut()
.find(|c| c.id.as_ref() == id)
{
call.arguments = std::sync::Arc::<str>::from(args);
}
}
fn is_completed(fields: &acp::ToolCallUpdateFields) -> bool {
matches!(
fields.status,
Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed)
)
}
fn emit_tool_result(
&mut self,
id: &str,
fields: &acp::ToolCallUpdateFields,
) -> Vec<ConversationItem> {
let mut out = Vec::new();
out.extend(self.flush_agent());
let content = extract_tool_result_text(fields);
let item = ConversationItem::tool_result(id.to_string(), content);
self.item_count += 1;
out.push(item);
out
}
fn flush_user(&mut self) -> Option<ConversationItem> {
if self.user_parts.is_empty() {
return None;
}
let item = ConversationItem::user_with_parts(std::mem::take(&mut self.user_parts));
self.item_count += 1;
Some(item)
}
fn flush_agent(&mut self) -> Option<ConversationItem> {
if !self.has_agent_content && self.agent_tool_calls.is_empty() {
return None;
}
let item = ConversationItem::Assistant(AssistantItem {
content: std::sync::Arc::<str>::from(std::mem::take(&mut self.agent_text)),
tool_calls: std::mem::take(&mut self.agent_tool_calls),
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
});
self.has_agent_content = false;
self.item_count += 1;
Some(item)
}
fn flush(&mut self) -> Vec<ConversationItem> {
let mut out = Vec::new();
out.extend(self.flush_user());
out.extend(self.flush_agent());
out
}
fn reset(&mut self) {
self.user_parts.clear();
self.agent_text.clear();
self.agent_tool_calls.clear();
self.tool_args.clear();
self.emitted_tool_results.clear();
self.in_user_turn = false;
self.has_agent_content = false;
self.item_count = 0;
}
fn should_truncate(&self) -> bool {
self.needs_truncate
}
fn clear_truncate_flag(&mut self) {
self.needs_truncate = false;
}
fn count(&self) -> usize {
self.item_count
}
}
/// Extract displayable text from a completed ToolCallUpdate.
fn extract_tool_result_text(fields: &agent_client_protocol::ToolCallUpdateFields) -> String {
if let Some(content) = &fields.content {
let text: String = content
.iter()
.filter_map(|c| match c {
agent_client_protocol::ToolCallContent::Content(
agent_client_protocol::Content {
content: agent_client_protocol::ContentBlock::Text(t),
..
},
) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
if !text.is_empty() {
return text;
}
}
if let Some(raw) = &fields.raw_output {
return raw.to_string();
}
String::new()
}
fn write_remote_origin_marker(dir: &Path) {
let _ = std::fs::write(
dir.join(".remote_origin"),
format!("pulled_at={}\n", chrono::Utc::now().to_rfc3339()),
);
}
/// Replayable JSON-RPC methods (excludes metadata like `prompt_complete`).
const REPLAYABLE_METHODS: &[&str] = &["session/update", "_x.ai/session/update"];
fn is_session_update(json_rpc: &serde_json::Value) -> bool {
json_rpc
.get("method")
.and_then(|v| v.as_str())
.is_some_and(|m| REPLAYABLE_METHODS.contains(&m))
}
fn to_envelope_line(json_rpc: &serde_json::Value) -> Option<String> {
let method = json_rpc.get("method").and_then(|v| v.as_str())?;
let params = json_rpc.get("params").cloned().unwrap_or_default();
serde_json::to_string(&serde_json::json!({
"timestamp": 0u64,
"method": method,
"params": params,
}))
.ok()
}
fn parse_rfc3339_or_now(s: Option<&str>) -> chrono::DateTime<chrono::Utc> {
s.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(chrono::Utc::now)
}
fn write_file(path: &Path, data: &[u8]) -> Result<(), BackendError> {
std::fs::write(path, data).map_err(|e| io_err(path, e))
}
}
#[cfg(test)]
mod tests {
use crate::remote::client::LoadedMessage;
#[test]
fn hydrate_writes_valid_updates_jsonl() {
let tmp = tempfile::TempDir::new().unwrap();
let messages = vec![
LoadedMessage {
id: "1".into(),
content: r#"{"method":"session/update","params":{"update":"hello"}}"#.into(),
timestamp: None,
},
LoadedMessage {
id: "2".into(),
content: r#"{"method":"session/update","params":{"update":"world"}}"#.into(),
timestamp: None,
},
];
super::hydrate::write_updates(tmp.path(), &messages).unwrap();
let content = std::fs::read_to_string(tmp.path().join("updates.jsonl")).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
for line in &lines {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(v["timestamp"], 0);
assert_eq!(v["method"], "session/update");
assert!(v["params"].is_object());
}
}
#[test]
fn rebuild_chat_history_merges_chunks() {
use crate::session::export::ExportedMessage;
use agent_client_protocol::{ContentBlock, ContentChunk, SessionUpdate, TextContent};
use std::sync::Arc;
// Build ACP notifications matching the RemoteSync path
let sid = agent_client_protocol::SessionId::new(Arc::from("test"));
let notifications = [
agent_client_protocol::SessionNotification::new(
sid.clone(),
SessionUpdate::UserMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new("hello "),
))),
),
agent_client_protocol::SessionNotification::new(
sid.clone(),
SessionUpdate::UserMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new("world"),
))),
),
agent_client_protocol::SessionNotification::new(
sid.clone(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new("hi back"),
))),
),
];
// Serialize through ExportedMessage (writeback path)
let messages: Vec<LoadedMessage> = notifications
.iter()
.map(|n| {
let exported = ExportedMessage::from_notification(n);
LoadedMessage {
id: "x".into(),
content: exported.content,
timestamp: None,
}
})
.collect();
let data = crate::remote::client::LoadDataResponse {
messages: Some(messages),
session: Some(crate::remote::client::SessionInfo {
session_id: "test".into(),
title: None,
cwd: Some("/tmp".into()),
status: None,
created_at: None,
updated_at: None,
metadata: None,
}),
};
let tmp = tempfile::TempDir::new().unwrap();
super::hydrate::write_to_dir(tmp.path(), &data).unwrap();
let chat = std::fs::read_to_string(tmp.path().join("chat_history.jsonl")).unwrap();
let items: Vec<crate::sampling::ConversationItem> = chat
.lines()
.filter(|l| !l.is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
assert_eq!(items.len(), 2, "should have 1 user + 1 agent item");
assert!(matches!(
&items[0],
crate::sampling::ConversationItem::User(_)
));
assert!(matches!(
&items[1],
crate::sampling::ConversationItem::Assistant(_)
));
if let crate::sampling::ConversationItem::User(u) = &items[0] {
let text: String = u
.content
.iter()
.filter_map(|p| match p {
crate::sampling::ContentPart::Text { text } => Some(text.as_ref()),
_ => None,
})
.collect();
assert_eq!(text, "hello world");
}
}
#[test]
fn rebuild_chat_history_preserves_user_images() {
use crate::session::export::ExportedMessage;
use agent_client_protocol::{
ContentBlock, ContentChunk, ImageContent, SessionUpdate, TextContent,
};
use std::sync::Arc;
let sid = agent_client_protocol::SessionId::new(Arc::from("test"));
let notifications = [
agent_client_protocol::SessionNotification::new(
sid.clone(),
SessionUpdate::UserMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new("look at this"),
))),
),
agent_client_protocol::SessionNotification::new(
sid.clone(),
SessionUpdate::UserMessageChunk(ContentChunk::new(ContentBlock::Image(
ImageContent::new(String::new(), String::new())
.uri(Some("data:image/png;base64,abc".into())),
))),
),
agent_client_protocol::SessionNotification::new(
sid.clone(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new("I see an image"),
))),
),
];
let messages: Vec<LoadedMessage> = notifications
.iter()
.map(|n| LoadedMessage {
id: "x".into(),
content: ExportedMessage::from_notification(n).content,
timestamp: None,
})
.collect();
let data = crate::remote::client::LoadDataResponse {
messages: Some(messages),
session: Some(crate::remote::client::SessionInfo {
session_id: "test".into(),
title: None,
cwd: Some("/tmp".into()),
status: None,
created_at: None,
updated_at: None,
metadata: None,
}),
};
let tmp = tempfile::TempDir::new().unwrap();
super::hydrate::write_to_dir(tmp.path(), &data).unwrap();
let chat = std::fs::read_to_string(tmp.path().join("chat_history.jsonl")).unwrap();
let items: Vec<crate::sampling::ConversationItem> = chat
.lines()
.filter(|l| !l.is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
assert_eq!(items.len(), 2);
if let crate::sampling::ConversationItem::User(u) = &items[0] {
assert_eq!(u.content.len(), 2, "should have text + image parts");
assert!(matches!(
&u.content[0],
crate::sampling::ContentPart::Text { .. }
));
assert!(matches!(
&u.content[1],
crate::sampling::ContentPart::Image { .. }
));
} else {
panic!("expected User item");
}
}
#[test]
fn hydrate_skips_invalid_messages() {
let tmp = tempfile::TempDir::new().unwrap();
let messages = vec![
LoadedMessage {
id: "1".into(),
content: r#"{"method":"session/update","params":{}}"#.into(),
timestamp: None,
},
LoadedMessage {
id: "bad".into(),
content: "not valid json".into(),
timestamp: None,
},
LoadedMessage {
id: "3".into(),
content: r#"{"method":"session/update","params":{"x":1}}"#.into(),
timestamp: None,
},
];
super::hydrate::write_updates(tmp.path(), &messages).unwrap();
let content = std::fs::read_to_string(tmp.path().join("updates.jsonl")).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2, "invalid message should be skipped");
}
}
@@ -1,127 +0,0 @@
//! Push → pull round-trip smoke test against the live backend.
//!
//! Run with: `cargo test -p kigi-shell -- pull_smoke --ignored --nocapture`
#[cfg(test)]
mod tests {
use crate::auth::KimiAuth;
use crate::remote::client::BackendClient;
use crate::session::storage::{JsonlStorageAdapter, StorageAdapter};
use std::collections::BTreeMap;
use std::sync::Arc;
fn load_prod_auth() -> Option<KimiAuth> {
let path = crate::util::kigi_home::kigi_home().join("auth.json");
let contents = std::fs::read_to_string(&path).ok()?;
let store: BTreeMap<String, KimiAuth> = serde_json::from_str(&contents).ok()?;
let scope = crate::auth::KimiCodeConfig::default().auth_scope();
crate::auth::lookup_auth(&store, &scope)
}
/// Full round-trip using the real RemoteSync production code path:
/// create RemoteSync → queue ACP notifications → flush → verify on
/// backend → pull back → verify local hydration + storage adapter load.
#[tokio::test]
#[ignore]
async fn smoke_push_pull_round_trip() {
use crate::remote::sync::RemoteSync;
use crate::session::export::ExportedMetadata;
use agent_client_protocol::{
ContentBlock, ContentChunk, SessionNotification, SessionUpdate, TextContent,
};
let auth = load_prod_auth().expect("No auth.json — run `grok login`");
let am = Arc::new(crate::auth::AuthManager::new(
&crate::util::kigi_home::kigi_home(),
crate::auth::KimiCodeConfig::default(),
));
am.hot_swap(auth);
let client = BackendClient::new().with_auth_manager(am.clone());
let session_id = format!("test-rt-{}", uuid::Uuid::new_v4());
let test_cwd = "/tmp/smoke-test".to_string();
let test_title = "Push-Pull Round Trip Test";
// PUSH via RemoteSync (real production path)
let metadata = ExportedMetadata {
title: Some(test_title.into()),
cwd: test_cwd.clone(),
model_id: Some("grok-3".into()),
created_at: Some(chrono::Utc::now().to_rfc3339()),
updated_at: Some(chrono::Utc::now().to_rfc3339()),
total_messages: None,
parent_session_id: None,
session_kind: None,
subagent_type: None,
subagent_persona: None,
subagent_role: None,
fork_context_source: None,
subagent_depth: None,
};
let sync = RemoteSync::new(
session_id.clone(),
metadata,
BackendClient::new().with_auth_manager(am.clone()),
);
let sid = agent_client_protocol::SessionId::new(Arc::from(session_id.as_str()));
sync.queue(SessionNotification::new(
sid.clone(),
SessionUpdate::UserMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new("Hello from smoke test — user".to_string()),
))),
));
sync.queue(SessionNotification::new(
sid.clone(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new("Hello from smoke test — agent".to_string()),
))),
));
sync.flush();
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
// Verify backend has cwd, title, messages
let loaded = client
.load_session_data(&session_id)
.await
.expect("load after push failed");
let remote = loaded.session.as_ref().expect("no session row");
assert_eq!(remote.cwd.as_deref(), Some(test_cwd.as_str()));
assert_eq!(remote.title.as_deref(), Some(test_title));
assert!(loaded.messages.as_ref().map_or(0, |m| m.len()) >= 2);
// PULL back to local
let result = crate::remote::pull_session_to_local(&session_id, &client)
.await
.expect("pull failed");
let pulled = match result {
crate::remote::PullResult::Hydrated(info) => info,
crate::remote::PullResult::NotFound => panic!("pull returned NotFound"),
};
assert_eq!(pulled.cwd, test_cwd);
// Verify local storage loads
let local_dir = crate::session::persistence::session_dir(&pulled);
assert!(local_dir.join("summary.json").exists());
assert!(local_dir.join("updates.jsonl").exists());
let storage = JsonlStorageAdapter::default();
let data = storage
.load_session_without_updates(&pulled)
.await
.expect("storage load failed");
assert_eq!(data.summary.session_summary, test_title);
// Verify chat_history has both turns
let chat =
std::fs::read_to_string(local_dir.join("chat_history.jsonl")).unwrap_or_default();
assert!(chat.contains("user"), "chat_history missing user turn");
assert!(chat.contains("agent"), "chat_history missing agent turn");
// Cleanup
drop(sync);
let _ = client.delete_session_data(&session_id).await;
let _ = std::fs::remove_dir_all(&local_dir);
}
}
@@ -1,167 +0,0 @@
//! Writeback push: async queue that flushes session updates to the backend.
//!
//! `RemoteSync` runs a background tokio task that buffers ACP notifications
//! and flushes them to the backend via [`BackendClient::save_session_data()`].
//!
//! ## Backpressure
//!
//! When the buffer exceeds [`MAX_PENDING`], the task attempts an emergency
//! flush. If that also fails (network down), the oldest messages are dropped
//! to prevent unbounded memory growth.
//!
//! ## Drop behavior
//!
//! When `RemoteSync` is dropped, the sender half of the channel closes and
//! the background task exits. **Pending buffered messages are lost.** This
//! is acceptable because the local JSONL files are the source of truth —
//! writeback is best-effort.
use crate::remote::BackendClient;
use crate::session::export::{ExportedMessage, ExportedMetadata};
use agent_client_protocol as acp;
use tokio::sync::mpsc;
/// Max buffered notifications before triggering an emergency flush.
/// Sized to keep memory under ~50MB even with large notifications.
const MAX_PENDING: usize = 512;
/// How many oldest messages to drop when an emergency flush fails.
/// Dropping a batch (not one-by-one) avoids repeated failed flushes.
const DROP_BATCH_SIZE: usize = 64;
enum SyncMsg {
Queue(Box<acp::SessionNotification>),
Flush,
SetTitle(String),
SetModelId(String),
}
#[derive(Clone)]
pub struct RemoteSync {
tx: mpsc::UnboundedSender<SyncMsg>,
}
impl RemoteSync {
/// Metadata is included on every flush to keep the backend session row current.
pub(crate) fn new(
session_id: String,
metadata: ExportedMetadata,
client: BackendClient,
) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(sync_task(session_id, metadata, client, rx));
Self { tx }
}
pub fn queue(&self, notification: acp::SessionNotification) {
let _ = self.tx.send(SyncMsg::Queue(Box::new(notification)));
}
pub fn flush(&self) {
let _ = self.tx.send(SyncMsg::Flush);
}
pub fn set_title(&self, title: String) {
let _ = self.tx.send(SyncMsg::SetTitle(title));
}
pub fn set_model_id(&self, model_id: String) {
let _ = self.tx.send(SyncMsg::SetModelId(model_id));
}
}
async fn do_flush(
client: &BackendClient,
session_id: &str,
metadata: &ExportedMetadata,
pending: &mut Vec<acp::SessionNotification>,
) -> bool {
if pending.is_empty() {
return true;
}
let messages: Vec<ExportedMessage> = pending
.iter()
.map(ExportedMessage::from_notification)
.collect();
match client
.save_session_data(session_id, &messages, Some(metadata))
.await
{
Ok(()) => {
tracing::debug!(count = pending.len(), "Writeback: synced");
pending.clear();
// Link session to agent so the relay can route requests to it.
if let Err(e) = client
.upsert_session(session_id, metadata, &crate::util::agent_id::agent_id())
.await
{
tracing::warn!(error = %e, "Writeback: failed to upsert session");
}
true
}
Err(e) => {
tracing::warn!(error = %e, pending = pending.len(), "Writeback: flush failed");
false
}
}
}
async fn sync_task(
session_id: String,
mut metadata: ExportedMetadata,
client: BackendClient,
mut rx: mpsc::UnboundedReceiver<SyncMsg>,
) {
let mut pending: Vec<acp::SessionNotification> = Vec::new();
while let Some(msg) = rx.recv().await {
match msg {
SyncMsg::Queue(n) => {
if pending.len() >= MAX_PENDING {
tracing::warn!(
pending = pending.len(),
"Writeback: buffer full, attempting emergency flush"
);
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
if !do_flush(&client, &session_id, &metadata, &mut pending).await {
let dropped = pending.drain(0..DROP_BATCH_SIZE.min(pending.len())).count();
tracing::error!(
dropped = dropped,
"Writeback: emergency flush failed, dropping oldest messages"
);
}
}
pending.push(*n);
}
SyncMsg::Flush => {
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
do_flush(&client, &session_id, &metadata, &mut pending).await;
}
SyncMsg::SetTitle(title) => {
metadata.title = Some(title);
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
if let Err(e) = client
.save_session_data(&session_id, &[], Some(&metadata))
.await
{
tracing::warn!(?e, "Writeback: failed to sync title to backend");
}
}
SyncMsg::SetModelId(id) => {
metadata.model_id = Some(id);
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
if let Err(e) = client
.save_session_data(&session_id, &[], Some(&metadata))
.await
{
tracing::warn!(?e, "Writeback: failed to sync model_id to backend");
}
}
}
}
}
@@ -1,176 +0,0 @@
use std::sync::Arc;
use serde::Deserialize;
use crate::auth::AuthManager;
const KIGI_WEB_URL: &str = "https://grok.com";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Workspace {
#[serde(default)]
pub workspace_id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub create_time: Option<String>,
#[serde(default)]
pub kind: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct WsQuery {
pub page_size: i64,
pub page_token: Option<String>,
pub query: Option<String>,
pub kind: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ListWorkspacesPage {
pub workspaces: Vec<Workspace>,
pub next_page_token: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum WsError {
#[error("no OAuth credentials for workspaces:read")]
NoOauth,
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
#[error("request failed: {status}")]
Http { status: u16 },
#[error("parse error: {0}")]
Parse(#[from] serde_json::Error),
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ListWorkspacesResponseWire {
#[serde(default)]
workspaces: Vec<Workspace>,
#[serde(default)]
next_page_token: Option<String>,
}
pub struct WorkspacesClient {
http: reqwest::Client,
base_url: String,
auth: Arc<AuthManager>,
}
impl WorkspacesClient {
pub fn new(auth: Arc<AuthManager>) -> Self {
let base_url = first_nonempty_env(&[
"KIGI_WORKSPACES_BASE_URL",
"KIGI_CONVERSATIONS_BASE_URL",
"KIGI_CODE_WEB_URL",
])
.unwrap_or_else(|| KIGI_WEB_URL.to_string());
Self {
http: crate::http::shared_client(),
base_url,
auth,
}
}
pub async fn list_workspaces(&self, q: &WsQuery) -> Result<ListWorkspacesPage, WsError> {
let auth = self.auth.auth().await.map_err(|_| WsError::NoOauth)?;
if !auth.is_session_auth() {
return Err(WsError::NoOauth);
}
let url = format!("{}/rest/workspaces", self.base_url);
let mut query: Vec<(&str, String)> = vec![("pageSize", q.page_size.to_string())];
if let Some(token) = q.page_token.as_deref().filter(|s| !s.is_empty()) {
query.push(("pageToken", token.to_owned()));
}
if let Some(search) = q.query.as_deref().filter(|s| !s.is_empty()) {
query.push(("query", search.to_owned()));
}
if let Some(kind) = q.kind.as_deref().filter(|s| !s.is_empty()) {
query.push(("kind", kind.to_owned()));
}
let mut builder = self
.http
.get(&url)
.query(&query)
.header("Authorization", format!("Bearer {}", auth.key))
.header("x-userid", &auth.user_id)
.header("x-grok-client-version", kigi_version::VERSION)
.header(
"x-grok-client-identifier",
crate::http::process_client_identifier(),
)
.header(
crate::http::CLIENT_MODE_HEADER,
crate::http::process_client_mode(),
)
.header(reqwest::header::ACCEPT, "application/json");
if let Some(email) = &auth.email {
builder = builder.header("x-email", email);
}
let builder = kigi_file_utils::trace_context::inject_trace_context_into_request(builder);
let response = builder.send().await?;
let status = response.status();
if !status.is_success() {
return Err(WsError::Http {
status: status.as_u16(),
});
}
let bytes = response.bytes().await?;
let wire: ListWorkspacesResponseWire = serde_json::from_slice(&bytes)?;
Ok(ListWorkspacesPage {
workspaces: wire.workspaces,
next_page_token: wire.next_page_token.filter(|t| !t.is_empty()),
})
}
}
fn first_nonempty_env(keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|k| std::env::var(k).ok().filter(|s| !s.is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_parses_camelcase_wire() {
let json = serde_json::json!({
"workspaces": [{
"workspaceId": "ws_9f3a",
"name": "GPU vendor research",
"createTime": "2026-06-18T17:30:00Z",
"kind": "WORKSPACE_KIND_IMAGINE"
}],
"nextPageToken": "tok2"
});
let wire: ListWorkspacesResponseWire = serde_json::from_value(json).unwrap();
assert_eq!(wire.workspaces.len(), 1);
let w = &wire.workspaces[0];
assert_eq!(w.workspace_id, "ws_9f3a");
assert_eq!(w.name, "GPU vendor research");
assert_eq!(w.create_time.as_deref(), Some("2026-06-18T17:30:00Z"));
assert_eq!(w.kind.as_deref(), Some("WORKSPACE_KIND_IMAGINE"));
assert_eq!(wire.next_page_token.as_deref(), Some("tok2"));
}
#[test]
fn missing_fields_default_gracefully() {
let json = serde_json::json!({ "workspaces": [{ "workspaceId": "w1" }] });
let wire: ListWorkspacesResponseWire = serde_json::from_value(json).unwrap();
let w = &wire.workspaces[0];
assert_eq!(w.workspace_id, "w1");
assert!(w.name.is_empty());
assert!(w.create_time.is_none());
assert!(w.kind.is_none());
assert!(wire.next_page_token.is_none());
}
}
+28 -27
View File
@@ -17,26 +17,36 @@ use agent_client_protocol as acp;
/// see this code and show a user-friendly upgrade message instead. /// see this code and show a user-friendly upgrade message instead.
pub const RATE_LIMITED_ERROR_CODE: i32 = -32003; pub const RATE_LIMITED_ERROR_CODE: i32 = -32003;
/// OAuth / session rate-limit copy (personal plan upgrade path). /// Subscription (OAuth) rate-limit copy. PRD Q3: the official Kimi CLI and
pub const RATE_LIMITED_USER_MESSAGE_OAUTH: &str = /// Kigi draw on the SAME subscription quota, so the message says so — a user
"You\u{2019}ve hit the rate limit for your plan. Upgrade your account or try again later."; /// who also runs `kimi` should understand why the limit arrived early.
/// Deliberately promises no reset duration; the quota window is server-side.
pub static RATE_LIMITED_USER_MESSAGE_OAUTH: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| {
format!(
"You\u{2019}ve hit the usage limit of your Kimi subscription. Note that Kigi and the \
official Kimi CLI share the same subscription quota. Upgrade your plan at {} or try \
again later.",
kigi_env::upgrade_page_url()
)
});
/// API key / team rate-limit copy. Personal grok.com upgrades do not raise API /// Moonshot API-key rate-limit copy. Platform keys are tier-limited (RPM/TPM);
/// team limits; admins purchase credits or a higher spend-based tier. /// raising the tier happens in the Moonshot Open Platform console, not via a
/// See https://docs.x.ai/developers/rate-limits#rate-limit-tiers /// Kimi subscription.
pub const RATE_LIMITED_USER_MESSAGE_API_KEY: &str = "You\u{2019}ve hit your team\u{2019}s API rate limit. Ask a team admin to purchase more credits for higher limits, or try again later. See https://docs.x.ai/developers/rate-limits#rate-limit-tiers"; pub const RATE_LIMITED_USER_MESSAGE_API_KEY: &str = "You\u{2019}ve hit the rate limit for your Moonshot API key. Check your tier\u{2019}s limits in the Moonshot Open Platform console (platform.moonshot.ai or platform.moonshot.cn), or try again later.";
/// Pick rate-limit copy from the *active* auth method. /// Pick rate-limit copy from the *active* auth method.
/// ///
/// Pass the real `is_api_key_auth` flag (pager `AppView`, `AuthMethodKind::is_api_key` /// Pass the real `is_api_key_auth` flag (pager `AppView`, `AuthMethodKind::is_api_key`
/// for the selected method). Do **not** decide from `has_xai_api_key_env()` alone: /// for the selected method). Do **not** decide from the key env var alone:
/// when both an env key and a cached OAuth session exist, auth prefers the /// when both an env key and a cached OAuth session exist, auth prefers the
/// cached session over the API key. /// cached session over the API key.
pub fn rate_limited_user_message(is_api_key_auth: bool) -> &'static str { pub fn rate_limited_user_message(is_api_key_auth: bool) -> &'static str {
if is_api_key_auth { if is_api_key_auth {
RATE_LIMITED_USER_MESSAGE_API_KEY RATE_LIMITED_USER_MESSAGE_API_KEY
} else { } else {
RATE_LIMITED_USER_MESSAGE_OAUTH RATE_LIMITED_USER_MESSAGE_OAUTH.as_str()
} }
} }
@@ -318,20 +328,20 @@ mod tests {
fn rate_limited_user_message_oauth_vs_api_key() { fn rate_limited_user_message_oauth_vs_api_key() {
assert_eq!( assert_eq!(
rate_limited_user_message(false), rate_limited_user_message(false),
RATE_LIMITED_USER_MESSAGE_OAUTH RATE_LIMITED_USER_MESSAGE_OAUTH.as_str()
); );
assert_eq!( assert_eq!(
rate_limited_user_message(true), rate_limited_user_message(true),
RATE_LIMITED_USER_MESSAGE_API_KEY RATE_LIMITED_USER_MESSAGE_API_KEY
); );
assert!(RATE_LIMITED_USER_MESSAGE_OAUTH.contains("Upgrade your account")); // PRD Q3: the subscription copy must state the shared quota with the
assert!(RATE_LIMITED_USER_MESSAGE_API_KEY.contains("team")); // official Kimi CLI and point at the upgrade page.
assert!(RATE_LIMITED_USER_MESSAGE_API_KEY.contains("credits")); assert!(RATE_LIMITED_USER_MESSAGE_OAUTH.contains("official Kimi CLI"));
assert!( assert!(RATE_LIMITED_USER_MESSAGE_OAUTH.contains("same subscription quota"));
RATE_LIMITED_USER_MESSAGE_API_KEY assert!(RATE_LIMITED_USER_MESSAGE_OAUTH.contains(kigi_env::upgrade_page_url()));
.contains("https://docs.x.ai/developers/rate-limits#rate-limit-tiers") // API-key copy points at the Moonshot platform, not the subscription.
); assert!(RATE_LIMITED_USER_MESSAGE_API_KEY.contains("Moonshot"));
assert!(!RATE_LIMITED_USER_MESSAGE_API_KEY.contains("Upgrade your account")); assert!(!RATE_LIMITED_USER_MESSAGE_API_KEY.contains("subscription quota"));
} }
#[test] #[test]
@@ -341,7 +351,6 @@ mod tests {
message: "Rate limit exceeded".into(), message: "Rate limit exceeded".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
let acp_err = map_sampling_err_to_acp(err); let acp_err = map_sampling_err_to_acp(err);
assert_eq!(acp_err.code, acp::ErrorCode::from(RATE_LIMITED_ERROR_CODE)); assert_eq!(acp_err.code, acp::ErrorCode::from(RATE_LIMITED_ERROR_CODE));
@@ -359,7 +368,6 @@ mod tests {
message: "Rate limit exceeded".into(), message: "Rate limit exceeded".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: Some(60), retry_after_secs: Some(60),
should_retry: None,
}; };
assert_eq!(err.retry_after(), Some(60)); assert_eq!(err.retry_after(), Some(60));
let acp_err = map_sampling_err_to_acp(err); let acp_err = map_sampling_err_to_acp(err);
@@ -374,14 +382,12 @@ mod tests {
message: "limited".into(), message: "limited".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
let server_err = SamplingError::Api { let server_err = SamplingError::Api {
status: StatusCode::INTERNAL_SERVER_ERROR, status: StatusCode::INTERNAL_SERVER_ERROR,
message: "oops".into(), message: "oops".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
let rate_acp = map_sampling_err_to_acp(rate_err); let rate_acp = map_sampling_err_to_acp(rate_err);
let server_acp = map_sampling_err_to_acp(server_err); let server_acp = map_sampling_err_to_acp(server_err);
@@ -398,7 +404,6 @@ mod tests {
message: "bad token".into(), message: "bad token".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
let acp_err = map_sampling_err_to_acp(err); let acp_err = map_sampling_err_to_acp(err);
assert_eq!(acp_err.code, acp::Error::auth_required().code); assert_eq!(acp_err.code, acp::Error::auth_required().code);
@@ -420,7 +425,6 @@ mod tests {
.into(), .into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
let acp_err = map_sampling_err_to_acp(err); let acp_err = map_sampling_err_to_acp(err);
assert_ne!( assert_ne!(
@@ -476,7 +480,6 @@ mod tests {
message: "The model 'grok-build' requires a Grok subscription.".into(), message: "The model 'grok-build' requires a Grok subscription.".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
let acp_err = map_sampling_err_to_acp(err); let acp_err = map_sampling_err_to_acp(err);
let data = acp_err.data.unwrap(); let data = acp_err.data.unwrap();
@@ -501,7 +504,6 @@ mod tests {
message: "The model 'grok-build' requires a Grok subscription.".into(), message: "The model 'grok-build' requires a Grok subscription.".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
let acp_err = map_sampling_err_to_acp(err); let acp_err = map_sampling_err_to_acp(err);
let data = acp_err.data.unwrap(); let data = acp_err.data.unwrap();
@@ -522,7 +524,6 @@ mod tests {
message: "Content violates usage guidelines.".into(), message: "Content violates usage guidelines.".into(),
model_metadata: None, model_metadata: None,
retry_after_secs: None, retry_after_secs: None,
should_retry: None,
}; };
let acp_err = map_sampling_err_to_acp(err); let acp_err = map_sampling_err_to_acp(err);
let data = acp_err.data.unwrap(); let data = acp_err.data.unwrap();
@@ -679,8 +679,6 @@ pub(crate) struct SessionActor {
pub(crate) origin_client: Option<crate::http::OriginClientInfo>, pub(crate) origin_client: Option<crate::http::OriginClientInfo>,
/// Feedback manager for signal tracking and feedback request heuristics /// Feedback manager for signal tracking and feedback request heuristics
pub(crate) feedback_manager: Arc<FeedbackManager>, pub(crate) feedback_manager: Arc<FeedbackManager>,
/// Cancellation token for the feedback sync loop (None if no feedback client)
pub(crate) sync_loop_cancel: Option<tokio_util::sync::CancellationToken>,
/// The fully-built Agent: owns the ToolBridge, system prompt, policies, /// The fully-built Agent: owns the ToolBridge, system prompt, policies,
/// and the AgentDefinition. Replaces the old `tool_bridge` + `agent_definition` fields. /// and the AgentDefinition. Replaces the old `tool_bridge` + `agent_definition` fields.
/// Wrapped in `RefCell` for mid-session mutation (skill refresh, prompt regen). /// Wrapped in `RefCell` for mid-session mutation (skill refresh, prompt regen).
@@ -1,5 +1,5 @@
use super::*; use super::*;
use crate::remote::DEFAULT_CONTEXT_WINDOW; use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
use kigi_chat_state::conversation_util::replace_or_insert_system_head; use kigi_chat_state::conversation_util::replace_or_insert_system_head;
impl SessionActor { impl SessionActor {
pub(super) async fn handle_set_session_model( pub(super) async fn handle_set_session_model(
@@ -72,7 +72,6 @@ impl SessionActor {
existing.auth_type, existing.auth_type,
), ),
alpha_test_key: existing.alpha_test_key, alpha_test_key: existing.alpha_test_key,
client_version: sampling_config.client_version.clone(),
}); });
self.model_auth_facts.replace(None); self.model_auth_facts.replace(None);
self.signals_handle() self.signals_handle()
@@ -691,7 +691,6 @@ impl SessionActor {
crate::agent::config::finalize_image_describe_sampler_config( crate::agent::config::finalize_image_describe_sampler_config(
resolved_describe, resolved_describe,
&active_session_config, &active_session_config,
self.client_identifier.clone(),
Some(self.max_retries), Some(self.max_retries),
); );
let client = kigi_sampler::SamplingClient::new(sampler_config).map_err(|e| { let client = kigi_sampler::SamplingClient::new(sampler_config).map_err(|e| {
@@ -3,7 +3,7 @@
use super::*; use super::*;
use crate::remote::DEFAULT_CONTEXT_WINDOW; use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
impl SessionActor { impl SessionActor {
/// Handle a /btw side question — single-turn model call using the /// Handle a /btw side question — single-turn model call using the
@@ -134,7 +134,7 @@ pub(super) fn build_todo_gate_reminder(pending: &[&str], unbacked_in_progress: &
/// (which is disabled). Extracted from `spawn_session_actor` so the /// (which is disabled). Extracted from `spawn_session_actor` so the
/// precedence rules are unit-testable. Named `resolve_*` to match the /// precedence rules are unit-testable. Named `resolve_*` to match the
/// sibling precedence helpers in `crate::util::config` /// sibling precedence helpers in `crate::util::config`
/// (`resolve_zdr_access_enabled`, `resolve_restore_code`, …). /// (`resolve_restore_code`, …).
pub(crate) fn resolve_reminder_policy( pub(crate) fn resolve_reminder_policy(
remote: Option<&crate::util::config::RemoteSettings>, remote: Option<&crate::util::config::RemoteSettings>,
todo_gate: bool, todo_gate: bool,
@@ -205,8 +205,7 @@ pub(super) async fn run_session(
.emit_buffered(notification). await; } .emit_buffered(notification). await; }
if let Some(tx) = respond_to { let _ = if let Some(tx) = respond_to { let _ =
tx.send(()); } } } } } maybe_completion = completion_rx.recv() => { let tx.send(()); } } } } } maybe_completion = completion_rx.recv() => { let
Some((prompt_id, result)) = maybe_completion else { if let Some(cancel) = & Some((prompt_id, result)) = maybe_completion else { cleanup_session_scratch(&
session.sync_loop_cancel { cancel.cancel(); } cleanup_session_scratch(&
session); return; }; if let Some(notification) = replay_buffer.flush() { session); return; }; if let Some(notification) = replay_buffer.flush() {
session.emit_buffered(notification). await; } let (turn_succeeded, session.emit_buffered(notification). await; } let (turn_succeeded,
infra_pause_message) = SessionActor::post_turn_goal_degradation_plan(& infra_pause_message) = SessionActor::post_turn_goal_degradation_plan(&
@@ -265,8 +264,7 @@ pub(super) async fn run_session(
{ let { let
model_id = session.current_model_id(). await; if let Some(signals) = session model_id = session.current_model_id(). await; if let Some(signals) = session
.signals_handle().snapshot(). await { .signals_handle().snapshot(). await {
} } if let } } session
Some(cancel) = & session.sync_loop_cancel { cancel.cancel(); } session
.feedback_manager.shutdown(). await; if ! session .feedback_manager.shutdown(). await; if ! session
.startup_hints.is_subagent { session.persist_background_task_manifest(). .startup_hints.is_subagent { session.persist_background_task_manifest().
await; } cleanup_session_scratch(& session); return; }; match cmd { await; } cleanup_session_scratch(& session); return; }; match cmd {
@@ -327,8 +325,7 @@ pub(super) async fn run_session(
::agent::config::try_resolve_model_credentials(model_name.as_str(), existing ::agent::config::try_resolve_model_credentials(model_name.as_str(), existing
.api_key.as_deref()) { session.chat_state_handle .api_key.as_deref()) { session.chat_state_handle
.update_credentials(kigi_chat_state::Credentials { api_key : r.api_key, .update_credentials(kigi_chat_state::Credentials { api_key : r.api_key,
auth_type : r.auth_type, alpha_test_key : existing.alpha_test_key, auth_type : r.auth_type, alpha_test_key : existing.alpha_test_key, }); } session.model_auth_facts
client_version : existing.client_version, }); } session.model_auth_facts
.replace(None); } } SessionCommand::GetCurrentModel { responds_to } => { let .replace(None); } } SessionCommand::GetCurrentModel { responds_to } => { let
model = session.chat_state_handle.get_sampling_config(). await .map(| c | c model = session.chat_state_handle.get_sampling_config(). await .map(| c | c
.model).unwrap_or_default(); let _ = responds_to.send(model); } .model).unwrap_or_default(); let _ = responds_to.send(model); }
@@ -697,7 +694,7 @@ pub(super) async fn run_session(
await; session.send_hook_execution("session_start", None, None, & results). await; session.send_hook_execution("session_start", None, None, & results).
await; } } SessionCommand::GetFeedbackContext { turn_number, responds_to } => await; } } SessionCommand::GetFeedbackContext { turn_number, responds_to } =>
{ let s = session.clone(); tokio::task::spawn_local(async move { use { let s = session.clone(); tokio::task::spawn_local(async move { use
prod_mc_cli_chat_proxy_types::feedback_types::FeedbackToolOutcome; let crate::session::feedback_types::FeedbackToolOutcome; let
turn_idx = turn_number.and_then(| n | usize::try_from(n).ok()); let turn_idx = turn_number.and_then(| n | usize::try_from(n).ok()); let
(last_user_message, last_assistant_message) = match turn_idx { Some(n) => { (last_user_message, last_assistant_message) = match turn_idx { Some(n) => {
let conv = s.chat_state_handle.get_conversation(). await; let conv = s.chat_state_handle.get_conversation(). await;
@@ -809,8 +806,7 @@ pub(super) async fn run_session(
"MEMORY_SUBAGENT_SKIP: skipping on_session_end for subagent session"); } "MEMORY_SUBAGENT_SKIP: skipping on_session_end for subagent session"); }
session.maybe_run_dream(). await; let telem = session.memory session.maybe_run_dream(). await; let telem = session.memory
.telemetry_snapshot(); session.emit_memory_session_summary(& telem, .telemetry_snapshot(); session.emit_memory_session_summary(& telem,
total_chunks_at_end, session_end_result); if let Some(cancel) = & session total_chunks_at_end, session_end_result); session.feedback_manager
.sync_loop_cancel { cancel.cancel(); } session.feedback_manager
.shutdown(). await; if ! session.startup_hints .shutdown(). await; if ! session.startup_hints
.is_subagent { session.persist_background_task_manifest(). await; } .is_subagent { session.persist_background_task_manifest(). await; }
cleanup_session_scratch(& session); return; } } } cleanup_session_scratch(& session); return; } } }
@@ -49,7 +49,7 @@ impl SessionTokenAuthGate {
is_session_based: auth_method_id is_session_based: auth_method_id
.is_some_and(crate::agent::auth_method::is_session_based_method), .is_some_and(crate::agent::auth_method::is_session_based_method),
model_byok, model_byok,
endpoint_is_first_party: crate::util::is_first_party_xai_url(base_url), endpoint_is_first_party: crate::util::is_first_party_url(base_url),
} }
} }
fn active(self) -> bool { fn active(self) -> bool {
@@ -314,22 +314,11 @@ impl SessionActor {
auth_scheme, auth_scheme,
extra_headers, extra_headers,
context_window: cfg.context_window.get(), context_window: cfg.context_window.get(),
client_version: creds.client_version,
reasoning_effort: cfg.reasoning_effort, reasoning_effort: cfg.reasoning_effort,
force_http1: false, force_http1: false,
max_retries: Some(self.max_retries), max_retries: Some(self.max_retries),
stream_tool_calls: cfg.stream_tool_calls.unwrap_or(false), stream_tool_calls: cfg.stream_tool_calls.unwrap_or(false),
idle_timeout_secs: None, idle_timeout_secs: None,
client_identifier: self.client_identifier.clone(),
deployment_id: crate::managed_config::resolve_deployment_id(
crate::managed_config::resolve_deployment_key().as_deref(),
),
user_id: self
.auth_manager
.as_ref()
.and_then(|am| am.current_or_expired())
.filter(|a| a.is_session_auth())
.map(|a| a.user_id),
origin_client: self.origin_client.clone(), origin_client: self.origin_client.clone(),
attribution_callback: self.attribution_callback.clone(), attribution_callback: self.attribution_callback.clone(),
bearer_resolver: if use_bearer_resolver { bearer_resolver: if use_bearer_resolver {
@@ -482,7 +471,6 @@ impl SessionActor {
&endpoints, &endpoints,
session_key.as_deref(), session_key.as_deref(),
creds.alpha_test_key.clone(), creds.alpha_test_key.clone(),
creds.client_version.clone(),
) )
} }
/// Resolve a dedicated sampler for the Auto-mode classifier model `slug`, /// Resolve a dedicated sampler for the Auto-mode classifier model `slug`,
@@ -499,7 +487,6 @@ impl SessionActor {
crate::agent::config::stamp_session_local_sampler_fields( crate::agent::config::stamp_session_local_sampler_fields(
&mut cfg, &mut cfg,
&active_session_config, &active_session_config,
self.client_identifier.clone(),
Some(self.max_retries), Some(self.max_retries),
); );
let model = cfg.model.clone(); let model = cfg.model.clone();
@@ -323,10 +323,10 @@ impl SessionActor {
/// Check if the session has been idle and proactively refresh model metadata. /// Check if the session has been idle and proactively refresh model metadata.
/// ///
/// Called at the start of each turn. If idle > `IDLE_REFRESH_THRESHOLD_SECS`, /// Called at the start of each turn. If idle > `IDLE_REFRESH_THRESHOLD_SECS`,
/// fetches `/models-v2` from cli-chat-proxy and updates the cached /// fetches `/models` from cli-chat-proxy and updates the cached
/// context_window / max_completion_tokens if remote settings changed them. /// context_window / max_completion_tokens if remote settings changed them.
/// ///
/// Skipped for BYOK users (no remote settings, no `/models-v2`). /// Skipped for BYOK users (no remote settings, no `/models`).
pub(super) async fn maybe_refresh_model_metadata_on_resume(&self) { pub(super) async fn maybe_refresh_model_metadata_on_resume(&self) {
if !self.is_session_based_auth() { if !self.is_session_based_auth() {
return; return;
@@ -353,7 +353,7 @@ impl SessionActor {
tracing::info!( tracing::info!(
idle_secs, idle_secs,
threshold_secs = Self::IDLE_REFRESH_THRESHOLD_SECS, threshold_secs = Self::IDLE_REFRESH_THRESHOLD_SECS,
"Session resumed after idle — refreshing model metadata from cli-chat-proxy" "Session resumed after idle — refreshing model metadata"
); );
let creds = self.chat_state_handle.get_credentials().await; let creds = self.chat_state_handle.get_credentials().await;
let Some(ref am) = self.auth_manager else { let Some(ref am) = self.auth_manager else {
@@ -370,27 +370,21 @@ impl SessionActor {
); );
let middleware_client = let middleware_client =
crate::http::with_auth_retry(crate::http::shared_client(), provider); crate::http::with_auth_retry(crate::http::shared_client(), provider);
let url = format!("{}/models-v2", base_url); let url = format!("{}/models", base_url);
let parse_models_response = let parse_models_response =
|json: serde_json::Value| -> Option<(std::num::NonZeroU64, Option<u32>)> { |json: serde_json::Value| -> Option<(std::num::NonZeroU64, Option<u32>)> {
let data = json.get("data")?.as_array()?; let data = json.get("data")?.as_array()?;
for entry in data { for entry in data {
let parsed = crate::remote::client::parse_remote_model_value(entry, base_url)?; let parsed =
crate::agent::models_fetch::parse_remote_model_value(entry, base_url)?;
if parsed.model == *current_model { if parsed.model == *current_model {
return Some((parsed.context_window, parsed.max_completion_tokens)); return Some((parsed.context_window, parsed.max_completion_tokens));
} }
} }
None None
}; };
#[allow(unused_mut)] let request = middleware_client
let mut request = middleware_client
.get(&url) .get(&url)
.header("X-XAI-Token-Auth", "xai-grok-cli")
.header("x-grok-client-version", kigi_version::VERSION)
.header(
crate::http::CLIENT_MODE_HEADER,
crate::http::process_client_mode(),
)
.timeout(std::time::Duration::from_secs(5)); .timeout(std::time::Duration::from_secs(5));
let response = match request.send().await { let response = match request.send().await {
Ok(r) => r, Ok(r) => r,
@@ -828,7 +828,7 @@ impl SessionActor {
); );
let model_id = sampling_config.map(|c| c.model); let model_id = sampling_config.map(|c| c.model);
let resolved_model_id = model_metadata.resolved_model_id; let resolved_model_id = model_metadata.resolved_model_id;
let client_version = credentials.client_version; let client_version = Some(kigi_version::VERSION.to_string());
use crate::session::feedback_manager::{SessionFeedbackData, SubmitOutcome}; use crate::session::feedback_manager::{SessionFeedbackData, SubmitOutcome};
let outcome = self let outcome = self
@@ -3,7 +3,7 @@
//! the MCP auto-restart wiring (`SessionRestartActions`). //! the MCP auto-restart wiring (`SessionRestartActions`).
#![allow(clippy::items_after_test_module)] #![allow(clippy::items_after_test_module)]
use super::*; use super::*;
use crate::remote::DEFAULT_CONTEXT_WINDOW; use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
/// Partition CLI `--allow` rules under the pin: blanket catch-all allows /// Partition CLI `--allow` rules under the pin: blanket catch-all allows
/// (`Allow(Any)` `*` / `**`, plus bare/match-all Bash/MCP/WebFetch grants — see /// (`Allow(Any)` `*` / `**`, plus bare/match-all Bash/MCP/WebFetch grants — see
/// `resolution::is_catchall_allow`) substitute for the blocked `--yolo`, so drop them when /// `resolution::is_catchall_allow`) substitute for the blocked `--yolo`, so drop them when
@@ -123,10 +123,7 @@ pub(crate) async fn spawn_session_actor(
codebase_indexes: std::sync::Arc<parking_lot::Mutex<CodebaseIndexManager>>, codebase_indexes: std::sync::Arc<parking_lot::Mutex<CodebaseIndexManager>>,
code_nav_enabled: bool, code_nav_enabled: bool,
fs_watch_caps: fs_watch::FsWatchCapabilities, fs_watch_caps: fs_watch::FsWatchCapabilities,
feedback_proxy_url: Option<String>, feedback_base_url: Option<String>,
feedback_user_token: Option<String>,
feedback_alpha_test_key: Option<String>,
deployment_key: Option<String>,
client_terminal_capable: bool, client_terminal_capable: bool,
client_fs_capable: bool, client_fs_capable: bool,
gateway_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>, gateway_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
@@ -141,7 +138,6 @@ pub(crate) async fn spawn_session_actor(
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>, persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>, persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
memory_config: Option<crate::config::MemoryConfig>, memory_config: Option<crate::config::MemoryConfig>,
loc_tracking_enabled: bool,
feedback_flags: crate::session::feedback_manager::FeedbackFlags, feedback_flags: crate::session::feedback_manager::FeedbackFlags,
managed_mcp_handle: crate::session::managed_mcp::ManagedMcpStateHandle, managed_mcp_handle: crate::session::managed_mcp::ManagedMcpStateHandle,
managed_mcp_expires_at: Option<chrono::DateTime<chrono::Utc>>, managed_mcp_expires_at: Option<chrono::DateTime<chrono::Utc>>,
@@ -879,36 +875,30 @@ pub(crate) async fn spawn_session_actor(
} }
persist_chat_history_jsonl_sync(&session_info, &conversation); persist_chat_history_jsonl_sync(&session_info, &conversation);
chat_state_handle.replace_conversation(conversation); chat_state_handle.replace_conversation(conversation);
let feedback_client = feedback_proxy_url.map(|base_url| { let feedback_client = match (feedback_base_url, auth_manager.as_ref()) {
let mut client = (Some(base_url), Some(am)) => Some(
crate::agent::feedback_client::FeedbackClient::new(base_url, feedback_user_token) crate::agent::feedback_client::FeedbackClient::new(base_url, am.clone())
.with_alpha_test_key(feedback_alpha_test_key) .with_session_id(session_info.id.0.to_string()),
.with_deployment_key(deployment_key); ),
if let Some(am) = auth_manager.as_ref() { _ => None,
client = client.with_auth_manager(am.clone()); };
}
client
});
let has_feedback_client = feedback_client.is_some(); let has_feedback_client = feedback_client.is_some();
tracing::info!( tracing::info!(
session_id = % session_info.id.0, has_feedback_client = has_feedback_client, session_id = % session_info.id.0, has_feedback_client = has_feedback_client,
"Creating feedback manager" "Creating feedback manager"
); );
let feedback_client_type = match client_type { let feedback_client_type = match client_type {
ClientType::GrokTUI => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Tui, ClientType::GrokTUI => crate::session::feedback_types::ClientType::Tui,
ClientType::GrokWeb => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Web, ClientType::GrokWeb => crate::session::feedback_types::ClientType::Web,
ClientType::Nebula => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Nebula, ClientType::Nebula => crate::session::feedback_types::ClientType::Nebula,
ClientType::Extension => { ClientType::Extension => crate::session::feedback_types::ClientType::Extension,
prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Extension ClientType::Generic => crate::session::feedback_types::ClientType::Agent,
} ClientType::Desktop => crate::session::feedback_types::ClientType::Desktop,
ClientType::Generic => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Agent, ClientType::GrokPager => crate::session::feedback_types::ClientType::Tui,
ClientType::Desktop => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Desktop,
ClientType::GrokPager => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Tui,
}; };
let feedback_config = FeedbackManagerConfig { let feedback_config = FeedbackManagerConfig {
feedback_enabled: feedback_flags.enabled, feedback_enabled: feedback_flags.enabled,
client_type: feedback_client_type, client_type: feedback_client_type,
loc_tracking_enabled,
..Default::default() ..Default::default()
}; };
let feedback_manager = Arc::new(FeedbackManager::new( let feedback_manager = Arc::new(FeedbackManager::new(
@@ -930,11 +920,6 @@ pub(crate) async fn spawn_session_actor(
} }
signals_handle.set_primary_model(&primary_model_id); signals_handle.set_primary_model(&primary_model_id);
signals_handle.set_tracing_config(inference_idle_timeout_secs); signals_handle.set_tracing_config(inference_idle_timeout_secs);
let sync_loop_cancel = if has_feedback_client {
Some(tokio_util::sync::CancellationToken::new())
} else {
None
};
let force_compact = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let force_compact = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let resolved_workspace_root = kigi_workspace::session::git::find_git_root_from_path( let resolved_workspace_root = kigi_workspace::session::git::find_git_root_from_path(
std::path::Path::new(&session_info.cwd), std::path::Path::new(&session_info.cwd),
@@ -1141,7 +1126,6 @@ pub(crate) async fn spawn_session_actor(
client_identifier: session_client_identifier.clone(), client_identifier: session_client_identifier.clone(),
origin_client: origin_client.clone(), origin_client: origin_client.clone(),
feedback_manager: feedback_manager.clone(), feedback_manager: feedback_manager.clone(),
sync_loop_cancel: sync_loop_cancel.clone(),
agent: std::cell::RefCell::new(agent), agent: std::cell::RefCell::new(agent),
last_reported_branch: Arc::new(Mutex::new(None)), last_reported_branch: Arc::new(Mutex::new(None)),
git_head_enabled: fs_watch_caps.git_head, git_head_enabled: fs_watch_caps.git_head,
@@ -1375,18 +1359,6 @@ pub(crate) async fn spawn_session_actor(
} }
}); });
} }
if let Some(cancel) = sync_loop_cancel {
tracing::info!(session_id = % session_info.id.0, "Spawning feedback sync loop");
let fm = feedback_manager.clone();
tokio::spawn(async move {
fm.run_sync_loop(cancel).await;
});
} else {
tracing::debug!(
session_id = % session_info.id.0,
"No feedback client available, skipping sync loop"
);
}
{ {
use agent_client_protocol::Client as _; use agent_client_protocol::Client as _;
use kigi_tools::implementations::grok_build::ask_user_question::{ use kigi_tools::implementations::grok_build::ask_user_question::{
@@ -1600,10 +1572,7 @@ pub(crate) async fn spawn_session_on_thread(
codebase_indexes: std::sync::Arc<parking_lot::Mutex<CodebaseIndexManager>>, codebase_indexes: std::sync::Arc<parking_lot::Mutex<CodebaseIndexManager>>,
code_nav_enabled: bool, code_nav_enabled: bool,
fs_watch_caps: fs_watch::FsWatchCapabilities, fs_watch_caps: fs_watch::FsWatchCapabilities,
feedback_proxy_url: Option<String>, feedback_base_url: Option<String>,
feedback_user_token: Option<String>,
feedback_alpha_test_key: Option<String>,
deployment_key: Option<String>,
client_terminal_capable: bool, client_terminal_capable: bool,
client_fs_capable: bool, client_fs_capable: bool,
gateway_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>, gateway_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
@@ -1618,7 +1587,6 @@ pub(crate) async fn spawn_session_on_thread(
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>, persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>, persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
memory_config: Option<crate::config::MemoryConfig>, memory_config: Option<crate::config::MemoryConfig>,
loc_tracking_enabled: bool,
feedback_flags: crate::session::feedback_manager::FeedbackFlags, feedback_flags: crate::session::feedback_manager::FeedbackFlags,
managed_mcp_handle: crate::session::managed_mcp::ManagedMcpStateHandle, managed_mcp_handle: crate::session::managed_mcp::ManagedMcpStateHandle,
managed_mcp_expires_at: Option<chrono::DateTime<chrono::Utc>>, managed_mcp_expires_at: Option<chrono::DateTime<chrono::Utc>>,
@@ -1751,10 +1719,7 @@ pub(crate) async fn spawn_session_on_thread(
codebase_indexes, codebase_indexes,
code_nav_enabled, code_nav_enabled,
fs_watch_caps, fs_watch_caps,
feedback_proxy_url, feedback_base_url,
feedback_user_token,
feedback_alpha_test_key,
deployment_key,
client_terminal_capable, client_terminal_capable,
client_fs_capable, client_fs_capable,
gateway_enabled, gateway_enabled,
@@ -1769,7 +1734,6 @@ pub(crate) async fn spawn_session_on_thread(
persisted_goal_mode, persisted_goal_mode,
persisted_announcement_state, persisted_announcement_state,
memory_config, memory_config,
loc_tracking_enabled,
feedback_flags, feedback_flags,
managed_mcp_handle, managed_mcp_handle,
managed_mcp_expires_at, managed_mcp_expires_at,
@@ -850,15 +850,11 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
auth_scheme: Default::default(), auth_scheme: Default::default(),
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 256_000, context_window: 256_000,
client_version: None,
force_http1: false, force_http1: false,
max_retries: None, max_retries: None,
stream_tool_calls: false, stream_tool_calls: false,
idle_timeout_secs: None, idle_timeout_secs: None,
client_identifier: None,
reasoning_effort: None, reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None, origin_client: None,
attribution_callback: None, attribution_callback: None,
bearer_resolver: None, bearer_resolver: None,
@@ -47,15 +47,11 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
auth_scheme: Default::default(), auth_scheme: Default::default(),
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 100_000, context_window: 100_000,
client_version: None,
force_http1: false, force_http1: false,
max_retries: None, max_retries: None,
stream_tool_calls: false, stream_tool_calls: false,
idle_timeout_secs: None, idle_timeout_secs: None,
client_identifier: None,
reasoning_effort: None, reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None, origin_client: None,
attribution_callback: None, attribution_callback: None,
bearer_resolver: None, bearer_resolver: None,
@@ -193,7 +189,6 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")), feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await), agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -338,15 +333,11 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
api_backend: Default::default(), api_backend: Default::default(),
auth_scheme: Default::default(), auth_scheme: Default::default(),
context_window: 100_000, context_window: 100_000,
client_version: None,
force_http1: false, force_http1: false,
max_retries: None, max_retries: None,
stream_tool_calls: false, stream_tool_calls: false,
idle_timeout_secs: None, idle_timeout_secs: None,
client_identifier: None,
reasoning_effort: None, reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None, origin_client: None,
attribution_callback: None, attribution_callback: None,
bearer_resolver: None, bearer_resolver: None,
@@ -470,15 +461,11 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
api_backend: Default::default(), api_backend: Default::default(),
auth_scheme: Default::default(), auth_scheme: Default::default(),
context_window: 100_000, context_window: 100_000,
client_version: None,
force_http1: false, force_http1: false,
max_retries: None, max_retries: None,
stream_tool_calls: false, stream_tool_calls: false,
idle_timeout_secs: None, idle_timeout_secs: None,
client_identifier: None,
reasoning_effort: None, reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None, origin_client: None,
attribution_callback: None, attribution_callback: None,
bearer_resolver: None, bearer_resolver: None,
@@ -641,7 +628,6 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")), feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await), agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -890,7 +876,6 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")), feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(agent), agent: std::cell::RefCell::new(agent),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -1729,15 +1714,11 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
auth_scheme: Default::default(), auth_scheme: Default::default(),
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 100_000, context_window: 100_000,
client_version: None,
force_http1: false, force_http1: false,
max_retries: Some(0), max_retries: Some(0),
stream_tool_calls: false, stream_tool_calls: false,
idle_timeout_secs: Some(60), idle_timeout_secs: Some(60),
client_identifier: None,
reasoning_effort: None, reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None, origin_client: None,
attribution_callback: None, attribution_callback: None,
bearer_resolver: None, bearer_resolver: None,
@@ -1876,7 +1857,6 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")), feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(agent), agent: std::cell::RefCell::new(agent),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -43,7 +43,7 @@ async fn test_last_api_request_at_idle_detection() {
/// End-to-end test for `maybe_refresh_model_metadata_on_resume`. /// End-to-end test for `maybe_refresh_model_metadata_on_resume`.
/// ///
/// Simulates a session idle for >10 minutes, then verifies the function /// Simulates a session idle for >10 minutes, then verifies the function
/// fetches `/models-v2`, parses the response, and updates `context_window` /// fetches `/models`, parses the response, and updates `context_window`
/// and `max_completion_tokens` in the sampling config. /// and `max_completion_tokens` in the sampling config.
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn test_e2e_idle_resume_refreshes_model_metadata() { async fn test_e2e_idle_resume_refreshes_model_metadata() {
@@ -52,7 +52,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
local local
.run_until(async { .run_until(async {
let app = axum::Router::new().route( let app = axum::Router::new().route(
"/v1/models-v2", "/v1/models",
get(|| async { get(|| async {
axum::Json(serde_json::json!( axum::Json(serde_json::json!(
{ "data" : [{ "model" : "test-model", "name" : "Test Model", { "data" : [{ "model" : "test-model", "name" : "Test Model",
@@ -117,7 +117,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
api_key: Some("test-key".to_string()), api_key: Some("test-key".to_string()),
auth_type: Default::default(), auth_type: Default::default(),
alpha_test_key: None, alpha_test_key: None,
client_version: None,
}); });
tokio::time::sleep(std::time::Duration::from_millis(50)).await; tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let actor = SessionActor { let actor = SessionActor {
@@ -219,7 +218,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")), feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await), agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -321,12 +319,12 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
assert_eq!( assert_eq!(
cfg_after.context_window, cfg_after.context_window,
std::num::NonZeroU64::new(300_000).unwrap(), std::num::NonZeroU64::new(300_000).unwrap(),
"context_window should be updated to 300K from /models-v2" "context_window should be updated to 300K from /models"
); );
assert_eq!( assert_eq!(
cfg_after.max_completion_tokens, cfg_after.max_completion_tokens,
Some(16384), Some(16384),
"max_completion_tokens should be updated to 16384 from /models-v2" "max_completion_tokens should be updated to 16384 from /models"
); );
}) })
.await; .await;
@@ -152,7 +152,6 @@ async fn create_test_actor(
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")), feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await), agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -591,7 +590,6 @@ async fn create_test_actor_with_memory(
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-memory")), feedback_manager: Arc::new(FeedbackManager::local_only("test-memory")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await), agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -1168,7 +1166,7 @@ async fn test_compact_on_error_no_trigger_when_tokens_within_new_window() {
/// End-to-end test for `maybe_refresh_model_metadata_on_resume`. /// End-to-end test for `maybe_refresh_model_metadata_on_resume`.
/// ///
/// Simulates a session idle for >10 minutes, then verifies the function /// Simulates a session idle for >10 minutes, then verifies the function
/// fetches `/models-v2`, parses the response, and updates `context_window` /// fetches `/models`, parses the response, and updates `context_window`
/// and `max_completion_tokens` in the sampling config. /// and `max_completion_tokens` in the sampling config.
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn test_e2e_idle_resume_refreshes_model_metadata() { async fn test_e2e_idle_resume_refreshes_model_metadata() {
@@ -1177,7 +1175,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
local local
.run_until(async { .run_until(async {
let app = axum::Router::new().route( let app = axum::Router::new().route(
"/v1/models-v2", "/v1/models",
get(|| async { get(|| async {
axum::Json(serde_json::json!( axum::Json(serde_json::json!(
{ "data" : [{ "model" : "test-model", "name" : "Test Model", { "data" : [{ "model" : "test-model", "name" : "Test Model",
@@ -1241,7 +1239,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
api_key: Some("test-key".to_string()), api_key: Some("test-key".to_string()),
auth_type: Default::default(), auth_type: Default::default(),
alpha_test_key: None, alpha_test_key: None,
client_version: None,
}); });
tokio::time::sleep(std::time::Duration::from_millis(50)).await; tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let actor = SessionActor { let actor = SessionActor {
@@ -1346,7 +1343,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")), feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await), agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -1448,12 +1444,12 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
assert_eq!( assert_eq!(
cfg_after.context_window, cfg_after.context_window,
std::num::NonZeroU64::new(300_000).unwrap(), std::num::NonZeroU64::new(300_000).unwrap(),
"context_window should be updated to 300K from /models-v2" "context_window should be updated to 300K from /models"
); );
assert_eq!( assert_eq!(
cfg_after.max_completion_tokens, cfg_after.max_completion_tokens,
Some(16384), Some(16384),
"max_completion_tokens should be updated to 16384 from /models-v2" "max_completion_tokens should be updated to 16384 from /models"
); );
}) })
.await; .await;
@@ -211,7 +211,6 @@ async fn create_test_actor_with_memory(
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-memory")), feedback_manager: Arc::new(FeedbackManager::local_only("test-memory")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await), agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -166,7 +166,7 @@ async fn actor_with_proxy(
let cfg = crate::agent::config::Config { let cfg = crate::agent::config::Config {
endpoints: crate::agent::config::EndpointsConfig { endpoints: crate::agent::config::EndpointsConfig {
cli_chat_proxy_base_url: Some(proxy_base.to_string()), coding_api_base_url: Some(proxy_base.to_string()),
..Default::default() ..Default::default()
}, },
..Default::default() ..Default::default()
@@ -157,7 +157,6 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")), feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await), agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -271,7 +271,6 @@ pub(crate) async fn create_test_actor_ex(
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")), feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await), agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -64,9 +64,6 @@ async fn web_search_uses_model_override_from_config_end_to_end() {
entry, entry,
crate::agent::config::resolve_credentials(entry, None), crate::agent::config::resolve_credentials(entry, None),
None, None,
None,
None,
None,
); );
let web_search_sampling = crate::tools::config::web_search_sampling_config(resolved); let web_search_sampling = crate::tools::config::web_search_sampling_config(resolved);
@@ -88,11 +88,11 @@ pub struct ClientFeedbackInput {
pub session_id: String, pub session_id: String,
/// Type of client submitting feedback /// Type of client submitting feedback
pub client_type: prod_mc_cli_chat_proxy_types::feedback_types::ClientType, pub client_type: crate::session::feedback_types::ClientType,
/// Rating type (thumbs, stars, nps) /// Rating type (thumbs, stars, nps)
#[serde(default)] #[serde(default)]
pub rating_type: Option<prod_mc_cli_chat_proxy_types::feedback_types::RatingType>, pub rating_type: Option<crate::session::feedback_types::RatingType>,
/// Rating value (interpretation depends on rating_type): /// Rating value (interpretation depends on rating_type):
/// - thumbs: -1 (down), 0 (neutral), 1 (up) /// - thumbs: -1 (down), 0 (neutral), 1 (up)
@@ -113,7 +113,7 @@ pub struct ClientFeedbackInput {
/// Context type for the feedback /// Context type for the feedback
#[serde(default)] #[serde(default)]
pub context_type: Option<prod_mc_cli_chat_proxy_types::feedback_types::ContextType>, pub context_type: Option<crate::session::feedback_types::ContextType>,
/// 0-based turn number this feedback is about. /// 0-based turn number this feedback is about.
#[serde(default, alias = "turnNumber")] #[serde(default, alias = "turnNumber")]
@@ -134,7 +134,7 @@ pub struct ClientFeedbackInput {
/// Terminal environment snapshot from the client. /// Terminal environment snapshot from the client.
#[serde(default)] #[serde(default)]
pub terminal_info: Option<prod_mc_cli_chat_proxy_types::feedback_types::FeedbackTerminalInfo>, pub terminal_info: Option<crate::session::feedback_types::FeedbackTerminalInfo>,
} }
impl ClientFeedbackInput { impl ClientFeedbackInput {
@@ -144,10 +144,10 @@ impl ClientFeedbackInput {
/// - stars: 1 to 5 /// - stars: 1 to 5
/// - nps: 0 to 10 /// - nps: 0 to 10
fn clamp_rating_value( fn clamp_rating_value(
rating_type: Option<prod_mc_cli_chat_proxy_types::feedback_types::RatingType>, rating_type: Option<crate::session::feedback_types::RatingType>,
rating_value: Option<i32>, rating_value: Option<i32>,
) -> Option<i32> { ) -> Option<i32> {
use prod_mc_cli_chat_proxy_types::feedback_types::RatingType; use crate::session::feedback_types::RatingType;
match (rating_type, rating_value) { match (rating_type, rating_value) {
(Some(RatingType::Thumbs), Some(v)) => Some(v.clamp(-1, 1)), (Some(RatingType::Thumbs), Some(v)) => Some(v.clamp(-1, 1)),
@@ -175,8 +175,8 @@ impl ClientFeedbackInput {
resolved_model_id: Option<String>, resolved_model_id: Option<String>,
model_fingerprint: Option<String>, model_fingerprint: Option<String>,
turn_number: Option<i64>, turn_number: Option<i64>,
) -> prod_mc_cli_chat_proxy_types::feedback_types::FeedbackSubmission { ) -> crate::session::feedback_types::FeedbackSubmission {
use prod_mc_cli_chat_proxy_types::feedback_types::FeedbackContent; use crate::session::feedback_types::FeedbackContent;
let clamped_rating_value = Self::clamp_rating_value(self.rating_type, self.rating_value); let clamped_rating_value = Self::clamp_rating_value(self.rating_type, self.rating_value);
let content = match ( let content = match (
@@ -577,7 +577,7 @@ pub struct SessionInfoResponse {
pub struct FeedbackContext { pub struct FeedbackContext {
pub last_user_message: Option<String>, pub last_user_message: Option<String>,
pub last_assistant_message: Option<String>, pub last_assistant_message: Option<String>,
pub tool_outcomes: Vec<prod_mc_cli_chat_proxy_types::feedback_types::FeedbackToolOutcome>, pub tool_outcomes: Vec<crate::session::feedback_types::FeedbackToolOutcome>,
pub compaction_count: i64, pub compaction_count: i64,
pub context_window_usage: u8, pub context_window_usage: u8,
pub context_tokens_used: u64, pub context_tokens_used: u64,
@@ -655,14 +655,14 @@ mod tests {
let input: ClientFeedbackInput = serde_json::from_str(json).unwrap(); let input: ClientFeedbackInput = serde_json::from_str(json).unwrap();
assert_eq!( assert_eq!(
input.client_type, input.client_type,
prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Desktop crate::session::feedback_types::ClientType::Desktop
); );
assert_eq!(input.session_id, "sess-1"); assert_eq!(input.session_id, "sess-1");
let submission = input.to_submission(Some("grok-3".into()), None, None, Some(5)); let submission = input.to_submission(Some("grok-3".into()), None, None, Some(5));
assert_eq!( assert_eq!(
submission.client_type, submission.client_type,
prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Desktop crate::session::feedback_types::ClientType::Desktop
); );
assert_eq!(submission.client_type.to_string(), "desktop"); assert_eq!(submission.client_type.to_string(), "desktop");
} }
@@ -7,7 +7,7 @@
//! lives alongside the primary one in `acp_session.rs`. //! lives alongside the primary one in `acp_session.rs`.
use super::SessionActor; use super::SessionActor;
use super::is_project_instructions; use super::is_project_instructions;
use crate::remote::DEFAULT_CONTEXT_WINDOW; use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
use crate::session::compaction_config::{ use crate::session::compaction_config::{
AsyncCompactionCache, SUPPRESS_NONE, SUPPRESS_STICKY, SUPPRESS_TURN, SUPPRESS_UNTIL_SUCCESS, AsyncCompactionCache, SUPPRESS_NONE, SUPPRESS_STICKY, SUPPRESS_TURN, SUPPRESS_UNTIL_SUCCESS,
}; };
@@ -2242,7 +2242,6 @@ mod inline_auto_compact_flow_tests {
client_identifier: None, client_identifier: None,
origin_client: None, origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")), feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await), agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)), last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false, git_head_enabled: false,
@@ -10,9 +10,7 @@ use super::signals::SessionSignals;
use crate::util::probabilistic_sample; use crate::util::probabilistic_sample;
// Re-export shared feedback API wire types to avoid duplication // Re-export shared feedback API wire types to avoid duplication
pub use prod_mc_cli_chat_proxy_types::feedback_types::{ pub use crate::session::feedback_types::{FeedbackHeuristicsConfig, FeedbackMode, TierConfig};
FeedbackHeuristicsConfig, FeedbackMode, TierConfig,
};
/// Feedback request tier with associated probability and criteria. /// Feedback request tier with associated probability and criteria.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -288,7 +286,7 @@ impl FeedbackHeuristics {
/// Create a heuristics evaluator from a remote feedback-heuristics config. /// Create a heuristics evaluator from a remote feedback-heuristics config.
pub fn from_config(config: &FeedbackHeuristicsConfig) -> Self { pub fn from_config(config: &FeedbackHeuristicsConfig) -> Self {
use prod_mc_cli_chat_proxy_types::feedback_types::parse_feedback_mode_str; use crate::session::feedback_types::parse_feedback_mode_str;
Self { Self {
enabled: config.enabled, enabled: config.enabled,
@@ -343,7 +341,7 @@ impl FeedbackHeuristics {
/// Update the heuristics configuration from a loaded config. /// Update the heuristics configuration from a loaded config.
/// Preserves the triggered_tiers state and request tracking. /// Preserves the triggered_tiers state and request tracking.
pub fn update_config(&mut self, config: &FeedbackHeuristicsConfig) { pub fn update_config(&mut self, config: &FeedbackHeuristicsConfig) {
use prod_mc_cli_chat_proxy_types::feedback_types::parse_feedback_mode_str; use crate::session::feedback_types::parse_feedback_mode_str;
self.enabled = config.enabled; self.enabled = config.enabled;

Some files were not shown because too many files have changed in this diff Show More