Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/statsai-adapters/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ pub(crate) const CLAUDE_SCAN_CACHE_PARSER_REVISION: &str = "activity-invocations
// activity-invocations.v20: session rows report their message count as requests and
// are priced per message, so long-context tiers stop being decided session-wide.
pub(crate) const OPENCODE_SCAN_CACHE_PARSER_REVISION: &str = "activity-invocations.v20";
pub(crate) const GROK_BUILD_SCAN_CACHE_PARSER_REVISION: &str = "activity-invocations.v22";
// Revisit Grok sessions whose Fast request was left unpriced because modelsUsed
// also retained a selected standard model that made no inference.
pub(crate) const GROK_BUILD_SCAN_CACHE_PARSER_REVISION: &str = "grok-fast-pricing.v23";

pub(crate) fn scan_candidate(
path: PathBuf,
Expand Down
3 changes: 2 additions & 1 deletion crates/statsai-adapters/src/grok/parse/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub(crate) fn estimate_grok_inference_sample_costs(
for sample in samples {
let Some(model) = resolve_grok_inference_sample_model(
sample,
samples.len(),
prompt_models,
turn_models,
session_models_used,
Expand Down Expand Up @@ -242,7 +243,7 @@ pub(crate) fn parse_grok_summary(
&session_models_used,
&summary.observed_at,
)
} else if unique_grok_normalized_models(
} else if unique_grok_model_keys(
session_models_used
.iter()
.map(String::as_str)
Expand Down
45 changes: 34 additions & 11 deletions crates/statsai-adapters/src/grok/parse/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,27 @@ pub(crate) fn grok_normalized_model_id(model_id: &str) -> String {
normalize_model_name(model_id)
}

fn grok_model_equivalence_key(model_id: &str) -> String {
let model_id = model_id.trim();
let normalized = grok_normalized_model_id(model_id);
if model_id.to_ascii_lowercase().ends_with("-fast") {
format!("{normalized}:fast")
} else {
normalized
}
}

pub(crate) fn grok_models_equivalent(left: &str, right: &str) -> bool {
grok_normalized_model_id(left) == grok_normalized_model_id(right)
grok_model_equivalence_key(left) == grok_model_equivalence_key(right)
}

pub(crate) fn unique_grok_normalized_models<'a>(
pub(crate) fn unique_grok_model_keys<'a>(
ids: impl IntoIterator<Item = &'a str>,
) -> HashSet<String> {
ids.into_iter()
.map(str::trim)
.filter(|model_id| !model_id.is_empty())
.map(grok_normalized_model_id)
.map(grok_model_equivalence_key)
.collect()
}

Expand Down Expand Up @@ -95,6 +105,7 @@ pub(crate) fn last_grok_model_at_or_before(

pub(crate) fn resolve_grok_inference_sample_model(
sample: &GrokInferenceSample,
sample_count: usize,
prompt_models: &[GrokModelObservation],
turn_models: &[GrokModelObservation],
session_models_used: &[String],
Expand All @@ -108,14 +119,26 @@ pub(crate) fn resolve_grok_inference_sample_model(
.iter()
.map(|observation| observation.model_id.as_str()),
);
let assignable = unique_grok_normalized_models(assignable_ids);
let assignable = unique_grok_model_keys(assignable_ids);
if assignable.len() == 1 {
let models_used =
unique_grok_normalized_models(session_models_used.iter().map(String::as_str));
// A lone prompt/turn observation cannot cover every inference when
// modelsUsed reports another model: request-level attribution is
// incomplete, so do not silently price the missing model as this one.
if !models_used.is_empty() && models_used != assignable {
let models_used = unique_grok_model_keys(session_models_used.iter().map(String::as_str));
// modelsUsed may include a selected model that made no request. When
// there is exactly one inference, matching prompt and turn observations
// before it identify its model despite that extra selection. With more
// inferences, one observed model cannot account for an unobserved one.
let confirmed_single_sample = sample_count == 1
&& sample.observed_at.is_some_and(|at| {
prompt_models.iter().any(|observation| {
observation
.observed_at
.is_some_and(|observed_at| observed_at <= at)
}) && turn_models.iter().any(|observation| {
observation
.observed_at
.is_some_and(|observed_at| observed_at <= at)
})
});
if !models_used.is_empty() && models_used != assignable && !confirmed_single_sample {
return None;
}
let model_id = prompt_models
Expand Down Expand Up @@ -148,7 +171,7 @@ pub(crate) fn resolve_grok_inference_sample_model(
.iter()
.map(String::as_str)
.chain(grok_current_model_id(current_model));
if unique_grok_normalized_models(session_ids).len() == 1 {
if unique_grok_model_keys(session_ids).len() == 1 {
return current_model.cloned().or_else(|| {
session_models_used
.first()
Expand Down
2 changes: 1 addition & 1 deletion crates/statsai-adapters/src/grok/tests/parse/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ fn grok_request_level_pricing_upgrade_advances_parser_revision() {
.and_then(|(_, value)| value.parse::<u32>().ok())
.expect("Grok parser revision");

assert!(revision > 19);
assert!(revision > 22);
}

#[test]
Expand Down
188 changes: 187 additions & 1 deletion crates/statsai-adapters/src/grok/tests/parse/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,7 @@ fn grok_inference_model_resolution_joins_prompt_model_id_by_timestamp() {
usage: UsageCounts::default(),
observed_at: Some(first),
},
2,
&prompt_models,
&[],
&["grok-4.5".to_string(), "grok-4.6".to_string()],
Expand All @@ -587,6 +588,7 @@ fn grok_inference_model_resolution_joins_prompt_model_id_by_timestamp() {
usage: UsageCounts::default(),
observed_at: Some(second),
},
2,
&prompt_models,
&[],
&["grok-4.5".to_string(), "grok-4.6".to_string()],
Expand All @@ -598,6 +600,7 @@ fn grok_inference_model_resolution_joins_prompt_model_id_by_timestamp() {
usage: UsageCounts::default(),
observed_at: Some(first),
},
2,
&[],
&[],
&["grok-4.5".to_string(), "grok-4.6".to_string()],
Expand All @@ -611,6 +614,187 @@ fn grok_inference_model_resolution_joins_prompt_model_id_by_timestamp() {
assert_eq!(unresolved, None);
}

#[test]
fn grok_inference_model_resolution_distinguishes_grok_4_7_fast_by_timestamp() {
let first = DateTime::parse_from_rfc3339("2026-09-22T10:00:10Z")
.expect("first")
.with_timezone(&Utc);
let second = DateTime::parse_from_rfc3339("2026-09-22T10:01:10Z")
.expect("second")
.with_timezone(&Utc);
let prompt_models = [
GrokModelObservation {
model_id: "grok-4.7".to_string(),
observed_at: Some(
DateTime::parse_from_rfc3339("2026-09-22T10:00:00Z")
.expect("standard")
.with_timezone(&Utc),
),
},
GrokModelObservation {
model_id: "grok-4.7-build-fast".to_string(),
observed_at: Some(
DateTime::parse_from_rfc3339("2026-09-22T10:01:00Z")
.expect("fast")
.with_timezone(&Utc),
),
},
];
let current = model_info("grok-4.7-build-fast");

let standard = resolve_grok_inference_sample_model(
&GrokInferenceSample {
usage: UsageCounts {
input_tokens: Some(100_000),
requests: Some(1),
..UsageCounts::default()
},
observed_at: Some(first),
},
2,
&prompt_models,
&[],
&["grok-4.7".to_string(), "grok-4.7-build-fast".to_string()],
Some(&current),
)
.expect("standard model");
let fast = resolve_grok_inference_sample_model(
&GrokInferenceSample {
usage: UsageCounts {
input_tokens: Some(100_000),
requests: Some(1),
..UsageCounts::default()
},
observed_at: Some(second),
},
2,
&prompt_models,
&[],
&["grok-4.7".to_string(), "grok-4.7-build-fast".to_string()],
Some(&current),
)
.expect("fast model");

assert_eq!(standard.name.as_deref(), Some("grok-4.7"));
assert_eq!(fast.name.as_deref(), Some("grok-4.7-build-fast"));

let standard_cost = statsai_pricing::estimate_cost(
GROK_BUILD_PROVIDER,
Some(&standard),
&UsageCounts {
input_tokens: Some(100_000),
requests: Some(1),
..UsageCounts::default()
},
);
let fast_cost = statsai_pricing::estimate_cost(
GROK_BUILD_PROVIDER,
Some(&fast),
&UsageCounts {
input_tokens: Some(100_000),
requests: Some(1),
..UsageCounts::default()
},
);

assert_eq!(
standard_cost.estimated_api_equivalent_micro_usd,
Some(200_000)
);
assert_eq!(fast_cost.estimated_api_equivalent_micro_usd, Some(400_000));
}

#[test]
fn grok_build_prices_single_fast_inference_when_signals_list_both_speeds() {
let dir = tempfile::tempdir().expect("tempdir");
let session = dir
.path()
.join("sessions")
.join("%2Fworkspace")
.join("session-fast-only");
std::fs::create_dir_all(&session).expect("session dir");
std::fs::create_dir_all(dir.path().join("logs")).expect("logs dir");
std::fs::write(
session.join("summary.json"),
serde_json::json!({
"info": {"id": "session-fast-only", "cwd": dir.path()},
"updated_at": "2026-09-22T20:32:05Z",
"current_model_id": "grok-4.7",
"chat_format_version": 1
})
.to_string(),
)
.expect("summary");
std::fs::write(
session.join("signals.json"),
serde_json::json!({
"modelsUsed": ["grok-4.7", "grok-4.7-build-fast"],
"primaryModelId": "grok-4.7",
"turnCount": 1
})
.to_string(),
)
.expect("signals");
std::fs::write(
session.join("updates.jsonl"),
serde_json::json!({
"timestamp": 1_790_109_116,
"params": {
"update": {"_meta": {"modelId": "grok-4.7-build-fast", "promptIndex": 0}},
"_meta": {"agentTimestampMs": 1_790_109_116_000i64}
}
})
.to_string(),
)
.expect("updates");
std::fs::write(
session.join("events.jsonl"),
serde_json::json!({
"ts": "2026-09-22T20:31:56Z",
"type": "turn_started",
"model_id": "grok-4.7-build-fast"
})
.to_string(),
)
.expect("events");
std::fs::write(
dir.path().join("logs/unified.jsonl"),
serde_json::json!({
"ts": "2026-09-22T20:31:58Z",
"sid": "session-fast-only",
"msg": "shell.turn.inference_done",
"ctx": {
"prompt_tokens": 19_323,
"cached_prompt_tokens": 1_152,
"completion_tokens": 39,
"reasoning_tokens": 29
}
})
.to_string(),
)
.expect("unified log");
let source = SourceLocation::local_adapter(
GROK_BUILD_PROVIDER,
"test",
"0",
dir.path(),
LocationOrigin::Configured,
);

let scan = scan_grok_build_source(&GrokBuildAdapter, &source, &options()).expect("scan");
let summary = &scan.summaries[0];

assert_eq!(summary.usage.requests, Some(1));
assert_eq!(
summary.cost.estimated_api_equivalent_micro_usd,
Some(74_652)
);
assert_eq!(
summary.cost.pricing_source.as_deref(),
Some("xai_api_pricing:grok-4.7:fast:unified_log_inference_usage")
);
}

#[test]
fn grok_inference_model_resolution_rejects_partial_observation_when_models_used_is_mixed() {
let observed_at = DateTime::parse_from_rfc3339("2026-08-16T18:32:10Z")
Expand All @@ -632,21 +816,23 @@ fn grok_inference_model_resolution_rejects_partial_observation_when_models_used_

let mixed = resolve_grok_inference_sample_model(
&sample,
2,
&prompt_models,
&[],
&["grok-4.5".to_string(), "grok-4.6".to_string()],
Some(&current),
);
let matching = resolve_grok_inference_sample_model(
&sample,
2,
&prompt_models,
&[],
&["grok-4.5".to_string()],
Some(&current),
)
.expect("matching modelsUsed");
let empty_used =
resolve_grok_inference_sample_model(&sample, &prompt_models, &[], &[], Some(&current))
resolve_grok_inference_sample_model(&sample, 2, &prompt_models, &[], &[], Some(&current))
.expect("empty modelsUsed");

assert_eq!(mixed, None);
Expand Down
10 changes: 5 additions & 5 deletions crates/statsai-pricing/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ pub(crate) fn pricing_for_effective_speed(
"claude-opus-4-6" | "claude-opus-4-7" => {
pricing_with_cache_creation(30.0, 37.5, 3.0, 150.0)
}
// Cursor's fast tier for Grok 4.6 is a flat 2x on input, cached input, and output.
"grok-4.6" => pricing(4.0, 1.0, 12.0),
// Grok 4.6/4.7 Fast is 2x the standard short-context token rates.
"grok-4.6" | "grok-4.7" => pricing(4.0, 1.0, 12.0),
// Grok 4.5 fast doubles input and cached input but triples output.
// Applied as multipliers against the standard rates above rather than as
// absolutes: xAI documents 4.5 cached input at $0.30/M, not 4.6's $0.50/M
Expand Down Expand Up @@ -190,9 +190,9 @@ pub(crate) fn pricing_for_model_on(
| "grok-4.20-0309-reasoning"
| "grok-4.20-0309-non-reasoning" => Some(pricing(1.25, 0.2, 2.5)),
"grok-4.5" => Some(pricing(2.0, 0.3, 6.0)),
// Official Grok 4.6 cached-input rate is $0.50/M below 200k prompt tokens
// ($1.00/M at or above 200k): https://docs.x.ai/developers/models/grok-4.6
"grok-4.6" => Some(pricing(2.0, 0.5, 6.0)),
// Official Grok 4.6/4.7 short-context rates are $2/M input,
// $0.50/M cached input, and $6/M output.
"grok-4.6" | "grok-4.7" => Some(pricing(2.0, 0.5, 6.0)),
_ => None,
}
}
Expand Down
Loading
Loading