@@ -1167,6 +1200,27 @@ watch(() => route.query, () => {
margin-right: 50px;
}
+/* RENG-38: per-expert LLM snapshot tag (provider/model). Mono so model IDs
+ stay scannable; ellipsized when a provider ships a very long model name. */
+.llm-tag {
+ font-family: var(--font-mono, monospace);
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* RENG-38: history list LLM column cell. */
+.llm-cell {
+ font-family: var(--font-mono, monospace);
+ font-size: 12px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: inline-block;
+ max-width: 100%;
+}
+
.expert-content {
padding: 8px 0;
display: flex;
diff --git a/migrations/0002_llm_snapshot.sql b/migrations/0002_llm_snapshot.sql
new file mode 100644
index 00000000..869f5f47
--- /dev/null
+++ b/migrations/0002_llm_snapshot.sql
@@ -0,0 +1,15 @@
+-- 0.10.2 (RENG-38):评审历史的 LLM 使用快照。
+-- 报告侧冗余名称快照(不做外键、不做软删):llm_providers 是整表
+-- DELETE+INSERT 语义,行 id 每次保存都会重生成,外键引用必然悬空;
+-- 历史展示要的是"当时实际用了哪个 provider/model",存名称快照即可。
+-- 方言约束与 0001 一致:占位符 `?`(由 store 层重写)、JSON 一律 TEXT。
+-- 旧行三列均为 NULL,前端显示「未知」/不显示,不做回填。
+
+-- 每条专家报告实际使用的 LLM(命中 fallback 链中第几个 config 就记哪个)。
+ALTER TABLE expert_reports ADD COLUMN llm_provider TEXT;
+ALTER TABLE expert_reports ADD COLUMN llm_model TEXT;
+
+-- 评审级去重后的 provider/model 对列表(JSON 数组 TEXT,
+-- 形如 [{"provider":"xiaomi","model":"mimo-v2.5-pro"}]),
+-- 供历史列表页免解析 reviews.result 直接展示。
+ALTER TABLE reviews ADD COLUMN llm_summary TEXT;
diff --git a/src/actions/repo_review/mod.rs b/src/actions/repo_review/mod.rs
index 0c399551..a12c47ff 100644
--- a/src/actions/repo_review/mod.rs
+++ b/src/actions/repo_review/mod.rs
@@ -462,6 +462,8 @@ pub async fn run_repo_review(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}];
let dropped =
crate::team::verifier::verify_findings(&mut reports, &[], local_path, llm_configs, max_file_bytes)
diff --git a/src/cli/handlers/ask.rs b/src/cli/handlers/ask.rs
index 9e246495..d2ae50f2 100644
--- a/src/cli/handlers/ask.rs
+++ b/src/cli/handlers/ask.rs
@@ -51,6 +51,8 @@ pub async fn run_ask(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
@@ -103,6 +105,8 @@ async fn run_ask_with_diff(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
@@ -176,6 +180,8 @@ pub async fn run_ask_local_repo(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
diff --git a/src/cli/handlers/changelog.rs b/src/cli/handlers/changelog.rs
index 7a7ee931..8d01e27b 100644
--- a/src/cli/handlers/changelog.rs
+++ b/src/cli/handlers/changelog.rs
@@ -59,6 +59,8 @@ pub async fn run_update_changelog(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
diff --git a/src/cli/handlers/describe.rs b/src/cli/handlers/describe.rs
index f1ef5e16..0b52c841 100644
--- a/src/cli/handlers/describe.rs
+++ b/src/cli/handlers/describe.rs
@@ -54,6 +54,8 @@ pub async fn run_describe(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
@@ -114,6 +116,8 @@ pub async fn run_describe_local_diff(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
@@ -177,6 +181,8 @@ pub async fn run_describe_local_repo(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
diff --git a/src/cli/handlers/improve.rs b/src/cli/handlers/improve.rs
index 79ab6f34..d833f768 100644
--- a/src/cli/handlers/improve.rs
+++ b/src/cli/handlers/improve.rs
@@ -52,6 +52,8 @@ pub async fn run_improve(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
@@ -110,6 +112,8 @@ pub async fn run_improve_local_diff(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
@@ -171,6 +175,8 @@ pub async fn run_improve_local_repo(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
diff --git a/src/cli/handlers/output.rs b/src/cli/handlers/output.rs
index 053e749a..93ca02ab 100644
--- a/src/cli/handlers/output.rs
+++ b/src/cli/handlers/output.rs
@@ -237,6 +237,8 @@ mod tests {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
}],
aggregated: None,
dropped_findings: vec![],
diff --git a/src/cli/handlers/review.rs b/src/cli/handlers/review.rs
index e6b69f3f..20eaed5c 100644
--- a/src/cli/handlers/review.rs
+++ b/src/cli/handlers/review.rs
@@ -394,6 +394,8 @@ pub async fn run_local_path(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_model: None,
+ llm_provider: None,
});
}
diff --git a/src/expert/mod.rs b/src/expert/mod.rs
index 8782684c..5b41b72e 100644
--- a/src/expert/mod.rs
+++ b/src/expert/mod.rs
@@ -41,7 +41,11 @@ pub async fn run_single_expert(
let config = crate::llm::select_llm_config(expert, llm_configs);
let result = llm_client.complete_with_fallback(&config, &system, &user).await?;
- Ok(parser::parse_llm_response(&expert.name, &result.content))
+ let mut report = parser::parse_llm_response(&expert.name, &result.content);
+ // RENG-38: snapshot the LLM that actually produced this report.
+ report.llm_provider = Some(result.provider.clone());
+ report.llm_model = Some(result.model.clone());
+ Ok(report)
}
/// Execute the aggregator expert to merge multiple expert reports.
@@ -65,5 +69,10 @@ pub async fn run_aggregator_expert(
let config = crate::llm::select_llm_config(aggregator, llm_configs);
let result = llm_client.complete_with_fallback(&config, &system, &user).await?;
- parser::parse_aggregator_response(&result.content)
+ parser::parse_aggregator_response(&result.content).map(|mut agg| {
+ // RENG-38: snapshot the aggregator's actual LLM.
+ agg.llm_provider = Some(result.provider.clone());
+ agg.llm_model = Some(result.model.clone());
+ agg
+ })
}
diff --git a/src/llm/client/mod.rs b/src/llm/client/mod.rs
index 5df34eef..eb6c4384 100644
--- a/src/llm/client/mod.rs
+++ b/src/llm/client/mod.rs
@@ -85,6 +85,18 @@ impl LLMClient {
std::time::Duration::from_millis(base_ms.min(30_000) + jitter_ms.min(1000))
}
+ /// Attribute a successful completion to the hitting config's `provider`
+ /// (RENG-38): the provider instance name usually equals it (registry is
+ /// keyed by config.provider), but the config entry is the source of truth
+ /// for history snapshots — e.g. an empty `config.provider` falls back to
+ /// the registry name instead of recording an empty string.
+ fn attribute_provider(mut result: CompletionResult, config: &LLMConfig) -> CompletionResult {
+ if !config.provider.is_empty() {
+ result.provider = config.provider.clone();
+ }
+ result
+ }
+
/// Complete using a specific LLM config (backward-compatible API).
pub async fn complete(
&self,
@@ -105,14 +117,14 @@ impl LLMClient {
};
let result = provider.complete(¶ms).await;
Self::record_llm_metrics(&config.provider, &config.model, result.is_ok());
- return result;
+ return result.map(|r| Self::attribute_provider(r, config));
}
}
// Fallback: use the direct OpenAI-compatible HTTP approach (original behavior)
let result = self.complete_direct(config, system_prompt, user_prompt).await;
Self::record_llm_metrics(&config.provider, &config.model, result.is_ok());
- result
+ result.map(|r| Self::attribute_provider(r, config))
}
/// Direct HTTP-based completion (backward compat, OpenAI-compatible only).
@@ -209,6 +221,7 @@ impl LLMClient {
content,
total_tokens,
model,
+ provider: config.provider.clone(),
})
}
@@ -249,7 +262,10 @@ impl LLMClient {
attempt_dur,
_cf_start.elapsed()
);
- return Ok(r);
+ // RENG-38: attribute the hit to THIS config's provider
+ // — the fallback chain may have succeeded on a later
+ // entry than the caller's primary.
+ return Ok(Self::attribute_provider(r, config));
}
Err(e) => {
let err_str = e.to_string();
diff --git a/src/llm/client/tests.rs b/src/llm/client/tests.rs
index ca62e927..ee4107b9 100644
--- a/src/llm/client/tests.rs
+++ b/src/llm/client/tests.rs
@@ -292,6 +292,7 @@ impl super::super::provider::LLMProvider for MockProvider {
content: "success".to_string(),
total_tokens: 10,
model: "mock".to_string(),
+ provider: self.name.clone(),
})
}
}
@@ -420,8 +421,45 @@ async fn test_complete_with_fallback_fallback_to_next_provider() {
disable_thinking: None,
},
];
-
let result = client.complete_with_fallback(&configs, "system", "user").await;
assert!(result.is_ok());
assert_eq!(result.unwrap().content, "success");
}
+
+/// RENG-38: the completion carries the hitting config's provider — when the
+/// fallback chain succeeds on the SECOND entry, the result is attributed to
+/// that entry, not the primary. This is the attribution the history snapshot
+/// (`expert_reports.llm_provider`) is built from.
+#[tokio::test]
+async fn test_fallback_result_is_attributed_to_the_hitting_provider() {
+ let client = LLMClient::new();
+ let mut registry = ProviderRegistry::new();
+ registry.register(Box::new(MockProvider::new("first", 999, "500")));
+ registry.register(Box::new(MockProvider::new("second", 0, "unused")));
+ let client = client.with_registry(Arc::new(registry));
+
+ let config = |provider: &str| LLMConfig {
+ provider: provider.to_string(),
+ model: format!("{provider}-model"),
+ api_key: "test".to_string(),
+ api_base: format!("https://api.{provider}.com/v1"),
+ max_tokens: 4096,
+ temperature: 0.3,
+ disable_thinking: None,
+ };
+
+ // Fallback hit: second config wins.
+ let result = client
+ .complete_with_fallback(&[config("first"), config("second")], "system", "user")
+ .await
+ .unwrap();
+ assert_eq!(
+ result.provider, "second",
+ "the hitting config's provider must be recorded"
+ );
+
+ // Direct hit: attributed to the (only) config.
+ let result = client.complete(&config("second"), "system", "user").await.unwrap();
+ assert_eq!(result.provider, "second");
+ assert_eq!(result.model, "mock", "mock provider's reported model is preserved");
+}
diff --git a/src/llm/provider.rs b/src/llm/provider.rs
index 3c825a2c..2d2fa40b 100644
--- a/src/llm/provider.rs
+++ b/src/llm/provider.rs
@@ -22,6 +22,11 @@ pub struct CompletionResult {
pub total_tokens: u64,
/// Actual model identifier used (may differ from request if provider remapped).
pub model: String,
+ /// Provider name that produced this completion. Filled by the provider
+ /// itself (`LLMProvider::name`); [`LLMClient`](super::client::LLMClient)
+ /// overwrites it with the hitting config's `provider` so fallback-chain
+ /// hits are attributed to the configured entry (RENG-38).
+ pub provider: String,
}
/// Parameters for LLM completion requests.
@@ -154,6 +159,7 @@ impl LLMProvider for OpenAIProvider {
content,
total_tokens,
model,
+ provider: self.name().to_string(),
})
}
}
@@ -300,6 +306,7 @@ impl LLMProvider for AnthropicProvider {
content,
total_tokens,
model,
+ provider: self.name().to_string(),
})
}
}
diff --git a/src/models/finding.rs b/src/models/finding.rs
index face7e7b..e9ceadfe 100644
--- a/src/models/finding.rs
+++ b/src/models/finding.rs
@@ -26,6 +26,26 @@ pub struct ExpertReport {
/// raw exchange was not persisted to disk.
#[serde(default)]
pub raw_dump_path: Option
,
+ /// Name snapshot of the LLM provider that actually produced this report
+ /// (the fallback-chain entry that succeeded — RENG-38). `None` for
+ /// reports produced before 0.10.2 or by non-LLM paths.
+ #[serde(default)]
+ pub llm_provider: Option,
+ /// Model identifier snapshot paired with [`Self::llm_provider`].
+ #[serde(default)]
+ pub llm_model: Option,
+}
+
+/// One LLM `(provider, model)` pair observed during a review (RENG-38).
+///
+/// Serialized into `reviews.llm_summary` (TEXT JSON) so the history list can
+/// render the compact `provider/model` form without parsing `reviews.result`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LlmUsage {
+ /// Provider name snapshot (e.g. `"xiaomi"`).
+ pub provider: String,
+ /// Model identifier snapshot (e.g. `"mimo-v2.5-pro"`).
+ pub model: String,
}
/// A single finding / issue identified during a code review.
@@ -174,6 +194,13 @@ pub struct AggregatedReport {
/// Path of the dumped raw LLM prompt + response with `--verbose`.
#[serde(default)]
pub raw_dump_path: Option,
+ /// Name snapshot of the LLM provider that produced this aggregated report
+ /// (RENG-38). `None` for pre-0.10.2 records.
+ #[serde(default)]
+ pub llm_provider: Option,
+ /// Model identifier snapshot paired with [`Self::llm_provider`].
+ #[serde(default)]
+ pub llm_model: Option,
}
impl ReviewOutput {
@@ -208,6 +235,36 @@ impl ReviewOutput {
self.consolidated = Some(consolidated);
self
}
+
+ /// De-duplicated `(provider, model)` pairs that actually produced this
+ /// review's reports (RENG-38), in first-seen order: per-expert reports
+ /// first, then the aggregator. Entries missing either side of the pair
+ /// (pre-0.10.2 records, non-LLM paths) are skipped. Persisted as
+ /// `reviews.llm_summary` by the store layer.
+ pub fn llm_usages(&self) -> Vec {
+ let mut usages: Vec = Vec::new();
+ let mut push = |provider: &Option, model: &Option| {
+ if let (Some(p), Some(m)) = (provider, model) {
+ if p.is_empty() || m.is_empty() {
+ return;
+ }
+ let usage = LlmUsage {
+ provider: p.clone(),
+ model: m.clone(),
+ };
+ if !usages.contains(&usage) {
+ usages.push(usage);
+ }
+ }
+ };
+ for report in &self.reports {
+ push(&report.llm_provider, &report.llm_model);
+ }
+ if let Some(agg) = &self.aggregated {
+ push(&agg.llm_provider, &agg.llm_model);
+ }
+ usages
+ }
}
#[cfg(test)]
@@ -345,4 +402,73 @@ mod tests {
assert_eq!(back.category, f.category);
assert_eq!(back.fingerprint(), f.fingerprint());
}
+
+ fn bare_report(expert: &str, provider: Option<&str>, model: Option<&str>) -> ExpertReport {
+ ExpertReport {
+ expert_name: expert.to_string(),
+ findings: Vec::new(),
+ markdown: String::new(),
+ raw_llm_response: String::new(),
+ parse_error: None,
+ raw_dump_path: None,
+ llm_provider: provider.map(str::to_string),
+ llm_model: model.map(str::to_string),
+ }
+ }
+
+ /// RENG-38: `llm_usages` dedups (provider, model) pairs in first-seen
+ /// order across expert reports + the aggregator, skipping entries that
+ /// lack either side (pre-0.10.2 records, empty strings).
+ #[test]
+ fn llm_usages_dedups_in_first_seen_order() {
+ let mut output = ReviewOutput::new(vec![
+ bare_report("a", Some("xiaomi"), Some("mimo-v2.5-pro")),
+ bare_report("b", Some("xiaomi"), Some("mimo-v2.5-pro")),
+ bare_report("c", Some("deepseek"), Some("deepseek-v4")),
+ bare_report("d", None, None), // no snapshot: skipped
+ bare_report("e", Some(""), Some("model")), // blank provider: skipped
+ bare_report("f", Some("xiaomi"), Some("mimo-v2-pro")),
+ ]);
+ output.aggregated = Some(AggregatedReport {
+ findings: Vec::new(),
+ markdown: String::new(),
+ raw_llm_response: String::new(),
+ parse_error: None,
+ raw_dump_path: None,
+ llm_provider: Some("deepseek".to_string()),
+ llm_model: Some("deepseek-v4".to_string()),
+ });
+
+ let usages = output.llm_usages();
+ assert_eq!(
+ usages,
+ vec![
+ LlmUsage {
+ provider: "xiaomi".into(),
+ model: "mimo-v2.5-pro".into()
+ },
+ LlmUsage {
+ provider: "deepseek".into(),
+ model: "deepseek-v4".into()
+ },
+ LlmUsage {
+ provider: "xiaomi".into(),
+ model: "mimo-v2-pro".into()
+ },
+ ]
+ );
+
+ // No snapshots anywhere → empty list (→ NULL column upstream).
+ let bare = ReviewOutput::new(vec![bare_report("a", None, None)]);
+ assert!(bare.llm_usages().is_empty());
+ }
+
+ /// Pre-0.10.2 report JSON (no llm_* keys) must still deserialize.
+ #[test]
+ fn expert_report_without_llm_fields_deserializes() {
+ let json = r#"{"expert_name":"a","findings":[],"markdown":"","raw_llm_response":""}"#;
+ let report: ExpertReport = serde_json::from_str(json).unwrap();
+ assert!(report.llm_provider.is_none());
+ assert!(report.llm_model.is_none());
+ }
}
diff --git a/src/output/parser.rs b/src/output/parser.rs
index 700175cc..2c883f89 100644
--- a/src/output/parser.rs
+++ b/src/output/parser.rs
@@ -68,6 +68,8 @@ fn fallback_report(expert_name: &str, yaml_text: &str) -> ExpertReport {
// failure so the report can surface a ⚠️ instead of a false clean bill.
parse_error: Some("LLM response could not be parsed into a valid review; treated as no findings".to_string()),
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}
}
@@ -90,6 +92,8 @@ pub fn parse_aggregator_response(yaml_text: &str) -> Result {
raw_llm_response: yaml_text.to_string(),
parse_error: Some("aggregator LLM response could not be parsed; treated as empty".to_string()),
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
});
}
@@ -108,6 +112,8 @@ pub fn parse_aggregator_response(yaml_text: &str) -> Result {
raw_llm_response: yaml_text.to_string(),
parse_error: Some("aggregator LLM response could not be parsed; treated as empty".to_string()),
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
});
}
v
@@ -132,6 +138,8 @@ pub fn parse_aggregator_response(yaml_text: &str) -> Result {
"aggregator LLM response could not be parsed; treated as empty".to_string(),
),
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
});
}
}
@@ -143,6 +151,8 @@ pub fn parse_aggregator_response(yaml_text: &str) -> Result {
raw_llm_response: yaml_text.to_string(),
parse_error: Some("aggregator LLM response could not be parsed; treated as empty".to_string()),
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
});
}
}
@@ -157,6 +167,8 @@ pub fn parse_aggregator_response(yaml_text: &str) -> Result {
raw_llm_response: yaml_text.to_string(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
})
}
@@ -171,6 +183,8 @@ fn build_expert_report(expert_name: &str, raw_response: &str, value: &serde_yaml
raw_llm_response: raw_response.to_string(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
})
}
diff --git a/src/output/team_renderer/tests.rs b/src/output/team_renderer/tests.rs
index 06149e26..22ac4de0 100644
--- a/src/output/team_renderer/tests.rs
+++ b/src/output/team_renderer/tests.rs
@@ -39,6 +39,8 @@ fn test_render_team_report_with_findings() {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}];
let metrics = vec![ExpertMetrics {
name: "security".to_string(),
@@ -79,6 +81,8 @@ fn test_render_team_report_with_custom_scoring() {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}];
let metrics = vec![ExpertMetrics {
name: "security".to_string(),
@@ -124,6 +128,8 @@ fn test_render_team_report_backward_compatible() {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}];
let metrics = vec![ExpertMetrics {
name: "security".to_string(),
@@ -354,6 +360,8 @@ fn test_render_expert_section_plain_report_unchanged() {
raw_llm_response: "raw".to_string(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
};
assert_eq!(render_expert_section(&report), report.markdown);
}
@@ -367,6 +375,8 @@ fn test_render_expert_section_parse_error_surfaces_instead_of_no_issues() {
raw_llm_response: "review:\n findings: [unclosed".to_string(),
parse_error: Some("YAML parse failed".to_string()),
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
};
let section = render_expert_section(&report);
assert!(
@@ -389,6 +399,8 @@ fn test_render_expert_section_raw_dump_path_referenced() {
raw_llm_response: "x".repeat(1200),
parse_error: None,
raw_dump_path: Some("/tmp/report.raw/security.1.response.txt".to_string()),
+ llm_provider: None,
+ llm_model: None,
};
let section = render_expert_section(&report);
assert!(section.contains("Raw LLM response"), "raw section must be present");
diff --git a/src/repo/experts/aggregator.rs b/src/repo/experts/aggregator.rs
index 2c07c1bc..26a9d6e9 100644
--- a/src/repo/experts/aggregator.rs
+++ b/src/repo/experts/aggregator.rs
@@ -262,6 +262,8 @@ fn consolidate_chunk_findings(
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
};
let consolidator = match app_config {
Some(c) => ConsolidatorConfig {
diff --git a/src/repo/experts/llm_experts/tests.rs b/src/repo/experts/llm_experts/tests.rs
index 56a0e163..94c5bbc1 100644
--- a/src/repo/experts/llm_experts/tests.rs
+++ b/src/repo/experts/llm_experts/tests.rs
@@ -409,6 +409,7 @@ impl LLMProvider for ScriptedProvider {
content: self.bodies[i].clone(),
total_tokens: 1,
model: "mock".to_string(),
+ provider: "mock".to_string(),
})
}
}
diff --git a/src/server/api/dashboard.rs b/src/server/api/dashboard.rs
index 060f2626..76d1a4a8 100644
--- a/src/server/api/dashboard.rs
+++ b/src/server/api/dashboard.rs
@@ -229,6 +229,7 @@ mod tests {
},
progress: None,
expert_name: None,
+ llm_summary: None,
}
}
diff --git a/src/server/api/repo.rs b/src/server/api/repo.rs
index 02a36719..be7f70af 100644
--- a/src/server/api/repo.rs
+++ b/src/server/api/repo.rs
@@ -153,6 +153,7 @@ async fn submit_repo_scan(State(state): State>, Json(body): Json ReviewDetail {
} else {
Some(report.raw_llm_response.clone())
},
+ llm_provider: report.llm_provider.clone(),
+ llm_model: report.llm_model.clone(),
})
.collect();
let raw_comment = output
@@ -128,6 +130,12 @@ pub(crate) fn build_review_list_item(entry: &TaskEntry) -> ReviewListItem {
duration_ms: entry.duration_ms(),
created_at: entry.created_at.to_rfc3339(),
gitlab_mr_url: meta.gitlab_mr_url.clone(),
+ // RENG-38: the snapshot column is TEXT JSON; a corrupt value degrades
+ // to None (the list shows "unknown") instead of failing the page.
+ llm_summary: entry
+ .llm_summary
+ .as_deref()
+ .and_then(|s| serde_json::from_str::>(s).ok()),
}
}
@@ -384,6 +392,7 @@ mod tests {
source_meta: SourceMeta::default(),
progress: None,
expert_name: None,
+ llm_summary: None,
}
}
@@ -417,6 +426,8 @@ mod tests {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}
}
diff --git a/src/server/api/review/tests.rs b/src/server/api/review/tests.rs
index 1d70c33c..2f3ac533 100644
--- a/src/server/api/review/tests.rs
+++ b/src/server/api/review/tests.rs
@@ -150,6 +150,8 @@ fn make_report(name: &str, findings: Vec) -> crate::mode
raw_llm_response: format!("raw {}", name),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}
}
diff --git a/src/server/api/types.rs b/src/server/api/types.rs
index c538c682..1356d4ad 100644
--- a/src/server/api/types.rs
+++ b/src/server/api/types.rs
@@ -150,6 +150,11 @@ pub struct ExpertResultDetail {
pub score: Option,
pub summary: String,
pub details: Option,
+ /// RENG-38: name snapshot of the LLM provider that actually produced this
+ /// expert's report (the fallback-chain hit). `null` for pre-0.10.2 records.
+ pub llm_provider: Option,
+ /// Model identifier snapshot paired with `llm_provider`.
+ pub llm_model: Option,
}
/// Author of the reviewed MR/PR, matching `ReviewDetail.author`.
@@ -212,4 +217,8 @@ pub struct ReviewListItem {
pub duration_ms: Option,
pub created_at: String,
pub gitlab_mr_url: Option,
+ /// RENG-38: deduplicated LLM `(provider, model)` pairs used by this
+ /// review, decoded from the `reviews.llm_summary` snapshot column.
+ /// `null` for pre-0.10.2 records and non-completed tasks.
+ pub llm_summary: Option>,
}
diff --git a/src/server/github.rs b/src/server/github.rs
index d38b69aa..34ac46e7 100644
--- a/src/server/github.rs
+++ b/src/server/github.rs
@@ -598,6 +598,8 @@ mod tests {
raw_llm_response: "raw".to_string(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
};
let outcome: anyhow::Result = Ok(crate::models::ReviewOutput::new(vec![report]));
diff --git a/src/server/gitlab/hooks.rs b/src/server/gitlab/hooks.rs
index e2b303be..955a7374 100644
--- a/src/server/gitlab/hooks.rs
+++ b/src/server/gitlab/hooks.rs
@@ -863,6 +863,8 @@ mod tests {
raw_llm_response: "raw".to_string(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
};
let output = crate::models::ReviewOutput::new(vec![report]);
let outcome: anyhow::Result = Ok(output);
diff --git a/src/server/mod.rs b/src/server/mod.rs
index a31be2f5..375824ac 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -382,6 +382,8 @@ mod tests {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}];
let agg = Some(AggregatedReport {
findings: vec![],
@@ -389,6 +391,8 @@ mod tests {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
});
let output = build_review_output_from_reports(reports, agg);
assert!(
@@ -406,6 +410,8 @@ mod tests {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}];
let output = build_review_output_from_reports(reports, None);
assert!(
@@ -445,6 +451,8 @@ mod tests {
raw_llm_response: "---\n".to_string(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
};
let reports = vec![ExpertReport {
expert_name: "security".to_string(),
@@ -453,6 +461,8 @@ mod tests {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}];
let output = build_review_output_from_reports(reports, Some(agg_report));
@@ -472,6 +482,8 @@ mod tests {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}];
let output = build_review_output_from_reports(reports, None);
assert!(output.aggregated.is_none());
@@ -492,6 +504,8 @@ mod tests {
raw_llm_response: String::new(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
}];
let output = build_review_output_from_reports(reports, None);
assert!(output.aggregated.is_none());
diff --git a/src/server/task_queue.rs b/src/server/task_queue.rs
index f7649cfa..409d9269 100644
--- a/src/server/task_queue.rs
+++ b/src/server/task_queue.rs
@@ -90,6 +90,12 @@ pub struct TaskEntry {
pub source_meta: SourceMeta,
pub progress: Option, // 0-100
pub expert_name: Option, // current active expert
+ /// RENG-38: JSON snapshot of the deduplicated `[{provider, model}]` LLM
+ /// pairs that produced this review's reports (see
+ /// [`crate::models::ReviewOutput::llm_usages`]). Filled on terminal
+ /// `update` from `result` so the in-memory path (db=None) and the
+ /// write-through row carry the same value.
+ pub llm_summary: Option,
}
/// A real-time event broadcast to SSE subscribers when a task's state changes.
@@ -220,6 +226,7 @@ impl TaskStore {
source_meta: source_meta.unwrap_or_default(),
progress: None,
expert_name: None,
+ llm_summary: None,
};
self.inner.write().await.insert(id, entry.clone());
let _ = self.tx.send(TaskEvent {
@@ -303,6 +310,9 @@ impl TaskStore {
entry.state = new_state.clone();
entry.result = result;
entry.error = error.clone();
+ // RENG-38: refresh the LLM-usage snapshot from the new result
+ // (terminal transitions carry it; mid-flight updates clear it).
+ entry.llm_summary = entry.result.as_ref().and_then(crate::store::rows::llm_summary_json);
if new_state == TaskState::Completed || new_state == TaskState::Failed || new_state == TaskState::Cancelled
{
entry.completed_at = Some(chrono::Utc::now());
@@ -820,6 +830,7 @@ mod tests {
source_meta: SourceMeta::default(),
progress: None,
expert_name: None,
+ llm_summary: None,
};
assert_eq!(entry.duration_ms(), Some(0), "inverted span must clamp, not wrap");
@@ -952,6 +963,8 @@ mod tests {
raw_llm_response: "raw".to_string(),
parse_error: None,
raw_dump_path: None,
+ llm_provider: None,
+ llm_model: None,
};
crate::models::ReviewOutput {
reports: vec![report("security"), report("performance")],
diff --git a/src/store/mod.rs b/src/store/mod.rs
index 29500a1e..ca8c919c 100644
--- a/src/store/mod.rs
+++ b/src/store/mod.rs
@@ -334,7 +334,29 @@ mod tests {
.fetch_one(store.pool())
.await
.unwrap();
- assert_eq!(applied, 1, "only 0001_init should be recorded");
+ assert_eq!(applied, 2, "0001_init + 0002_llm_snapshot should be recorded");
+
+ // 0002 (RENG-38): the snapshot columns exist on both history tables.
+ let er_cols: Vec = ::sqlx::query_scalar("SELECT name FROM pragma_table_info('expert_reports')")
+ .fetch_all(store.pool())
+ .await
+ .unwrap();
+ assert!(
+ er_cols.iter().any(|c| c == "llm_provider"),
+ "expert_reports missing llm_provider"
+ );
+ assert!(
+ er_cols.iter().any(|c| c == "llm_model"),
+ "expert_reports missing llm_model"
+ );
+ let rv_cols: Vec = ::sqlx::query_scalar("SELECT name FROM pragma_table_info('reviews')")
+ .fetch_all(store.pool())
+ .await
+ .unwrap();
+ assert!(
+ rv_cols.iter().any(|c| c == "llm_summary"),
+ "reviews missing llm_summary"
+ );
}
/// 验证点 A(b): `?` placeholder INSERT + SELECT round trip on SQLite
diff --git a/src/store/rows.rs b/src/store/rows.rs
index 1edab914..51da592e 100644
--- a/src/store/rows.rs
+++ b/src/store/rows.rs
@@ -197,6 +197,10 @@ pub(crate) struct ReviewRow {
pub result: Option,
pub error: Option,
pub progress: Option,
+ /// RENG-38: deduplicated `[{provider, model}]` JSON snapshot of the LLMs
+ /// that produced this review (`ReviewOutput::llm_usages`), materialized
+ /// at write time so the history list never parses `result` (§8.1).
+ pub llm_summary: Option,
pub created_at: String,
pub started_at: Option,
pub completed_at: Option,
@@ -209,6 +213,22 @@ fn opt_json(value: &Option, what: &str) -> Result