37189c9252
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 7m38s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 7m40s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 7m45s
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 8m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m24s
Project CI / AI game creator shell Rust crates (push) Successful in 3m8s
Project CI / Repository checks (push) Successful in 7m53s
Project CI / Frontend tests (push) Successful in 8m48s
Project CI / Native shell tests (push) Successful in 11m14s
Project CI / Backend tests (push) Successful in 12m54s
Project CI / AI game creator shell web tests (push) Successful in 5m53s
在项目 .agent 中保存请求模型与响应确认型号,区分目录标识和真实模型 为旧项目补录带来源标记的模型信息,保留历史并确保重复打开幂等 在 Direct Codex 代理中提取模型元数据,异步落盘且不阻塞响应转发 补充模型追溯边界测试并同步技术方案与团队约定
659 lines
26 KiB
Rust
659 lines
26 KiB
Rust
//! 随项目保存主模型来源;只接收白名单元数据,不接收提示词、响应正文或连接配置。
|
|
|
|
use super::{
|
|
append_jsonl_line_unlocked, open_project_private_regular_file, project_append_lock_for,
|
|
read_agent_db_records_bounded, resolve_local_project_path,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::BTreeSet;
|
|
use std::fs;
|
|
use std::io::Read;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
const MODEL_USAGE_PATH: &str = ".agent/model-usage.jsonl";
|
|
const MODEL_USAGE_LABEL: &str = "项目主模型记录";
|
|
const MODEL_USAGE_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
|
const MODEL_USAGE_MAX_LINE_BYTES: usize = 4096;
|
|
const MODEL_USAGE_MAX_RECORDS: usize = 32_768;
|
|
const MODEL_USAGE_MAX_FIELD_BYTES: usize = 256;
|
|
const MODEL_HISTORY_MAX_BYTES: u64 = 2 * 1024 * 1024;
|
|
const MODEL_HISTORY_MAX_TURN_LOGS: usize = 32;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct ProjectModelUsageContext {
|
|
pub root: PathBuf,
|
|
pub client_turn_id: Option<String>,
|
|
pub thread_id: Option<String>,
|
|
pub requested_model: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct ModelUsageRecord {
|
|
schema_version: u32,
|
|
recorded_at_ms: u64,
|
|
source: String,
|
|
requested_model: String,
|
|
model_name: Option<String>,
|
|
client_turn_id: Option<String>,
|
|
thread_id: Option<String>,
|
|
response_id: Option<String>,
|
|
historical_model_confirmed: bool,
|
|
}
|
|
|
|
fn safe_identifier(value: &str) -> bool {
|
|
!value.is_empty()
|
|
&& value.len() <= MODEL_USAGE_MAX_FIELD_BYTES
|
|
// 与自定义目录的标识校验同口径,保留供应商使用的 @、+ 等字符。
|
|
&& !value.chars().any(|character| character.is_control() || character.is_whitespace())
|
|
&& !value.contains("://")
|
|
&& !value.contains('\\')
|
|
&& !value.starts_with('/')
|
|
&& !value.to_ascii_lowercase().starts_with("sk-")
|
|
}
|
|
|
|
fn is_channel_placeholder(value: &str) -> bool {
|
|
matches!(
|
|
value.to_ascii_lowercase().as_str(),
|
|
"platform-default" | "codex-app-server" | "codex-cli" | "direct-codex" | "direct codex"
|
|
)
|
|
}
|
|
|
|
fn validate_record(record: &ModelUsageRecord) -> Result<(), String> {
|
|
if record.schema_version != 1
|
|
|| !matches!(
|
|
record.source.as_str(),
|
|
"turn-request" | "provider-response" | "current-config-backfill" | "history-backfill"
|
|
)
|
|
|| !safe_identifier(&record.requested_model)
|
|
|| record
|
|
.model_name
|
|
.as_deref()
|
|
.is_some_and(|value| !safe_identifier(value) || is_channel_placeholder(value))
|
|
|| [
|
|
record.client_turn_id.as_deref(),
|
|
record.thread_id.as_deref(),
|
|
record.response_id.as_deref(),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.any(|value| !safe_identifier(value))
|
|
|| (matches!(
|
|
record.source.as_str(),
|
|
"provider-response" | "history-backfill"
|
|
) && (record.model_name.is_none() || !record.historical_model_confirmed))
|
|
|| (record.source == "current-config-backfill" && record.historical_model_confirmed)
|
|
{
|
|
return Err("项目主模型记录字段无效".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn new_record(context: &ProjectModelUsageContext, source: &str) -> ModelUsageRecord {
|
|
ModelUsageRecord {
|
|
schema_version: 1,
|
|
recorded_at_ms: SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
|
|
.unwrap_or_default(),
|
|
source: source.to_string(),
|
|
requested_model: context.requested_model.clone(),
|
|
model_name: None,
|
|
client_turn_id: context.client_turn_id.clone(),
|
|
thread_id: context.thread_id.clone(),
|
|
response_id: None,
|
|
historical_model_confirmed: false,
|
|
}
|
|
}
|
|
|
|
fn read_private_bytes(path: &Path, max_bytes: u64) -> Result<Option<Vec<u8>>, String> {
|
|
match fs::symlink_metadata(path) {
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(_) => return Err("读取项目模型记录元数据失败".to_string()),
|
|
Ok(_) => {}
|
|
}
|
|
let (file, metadata) = open_project_private_regular_file(path, MODEL_USAGE_LABEL)?;
|
|
if metadata.len() > max_bytes {
|
|
return Err("项目模型记录超过读取上限".to_string());
|
|
}
|
|
let mut content = Vec::new();
|
|
file.take(max_bytes + 1)
|
|
.read_to_end(&mut content)
|
|
.map_err(|_| "读取项目模型记录失败".to_string())?;
|
|
if content.len() as u64 > max_bytes {
|
|
return Err("项目模型记录超过读取上限".to_string());
|
|
}
|
|
Ok(Some(content))
|
|
}
|
|
|
|
fn read_usage_records(path: &Path) -> Result<(Vec<ModelUsageRecord>, u64), String> {
|
|
let Some(content) = read_private_bytes(path, MODEL_USAGE_MAX_BYTES)? else {
|
|
return Ok((Vec::new(), 0));
|
|
};
|
|
// 既有追加器会修复截断尾行;此处先拒绝所有非完整 JSONL,确保模型历史永不被修剪。
|
|
if !content.is_empty() && content.last() != Some(&b'\n') {
|
|
return Err("项目模型记录存在不完整尾行,已保留原文件".to_string());
|
|
}
|
|
let mut records = Vec::new();
|
|
for line in content.split(|byte| *byte == b'\n') {
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
if line.len() > MODEL_USAGE_MAX_LINE_BYTES || records.len() >= MODEL_USAGE_MAX_RECORDS {
|
|
return Err("项目模型记录超过记录上限".to_string());
|
|
}
|
|
let record: ModelUsageRecord = serde_json::from_slice(line)
|
|
.map_err(|_| "项目模型记录损坏,已保留原文件".to_string())?;
|
|
validate_record(&record)?;
|
|
records.push(record);
|
|
}
|
|
Ok((records, content.len() as u64))
|
|
}
|
|
|
|
fn same_observation(left: &ModelUsageRecord, right: &ModelUsageRecord) -> bool {
|
|
left.source == right.source
|
|
&& left.requested_model == right.requested_model
|
|
&& left.model_name == right.model_name
|
|
&& left.client_turn_id == right.client_turn_id
|
|
&& left.thread_id == right.thread_id
|
|
&& left.response_id == right.response_id
|
|
}
|
|
|
|
fn append_records_unlocked(
|
|
path: &Path,
|
|
existing: &[ModelUsageRecord],
|
|
mut bytes: u64,
|
|
records: &[ModelUsageRecord],
|
|
) -> Result<(), String> {
|
|
let mut lines = Vec::new();
|
|
for (index, record) in records.iter().enumerate() {
|
|
validate_record(record)?;
|
|
if existing.iter().any(|other| same_observation(other, record))
|
|
|| records[..index]
|
|
.iter()
|
|
.any(|other| same_observation(other, record))
|
|
{
|
|
continue;
|
|
}
|
|
let line =
|
|
serde_json::to_string(record).map_err(|_| "序列化项目模型记录失败".to_string())?;
|
|
bytes = bytes.saturating_add(line.len() as u64 + 1);
|
|
if line.len() > MODEL_USAGE_MAX_LINE_BYTES
|
|
|| bytes > MODEL_USAGE_MAX_BYTES
|
|
|| existing.len() + lines.len() >= MODEL_USAGE_MAX_RECORDS
|
|
{
|
|
return Err("项目模型记录超过写入上限".to_string());
|
|
}
|
|
lines.push(line);
|
|
}
|
|
for line in lines {
|
|
append_jsonl_line_unlocked(path, &line, MODEL_USAGE_LABEL)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn append_record(root: &Path, record: ModelUsageRecord) -> Result<(), String> {
|
|
validate_record(&record)?;
|
|
let path = resolve_local_project_path(root, MODEL_USAGE_PATH)?;
|
|
let append_lock = project_append_lock_for(&path)?;
|
|
let _guard = append_lock.lock(MODEL_USAGE_LABEL)?;
|
|
let (existing, bytes) = read_usage_records(&path)?;
|
|
append_records_unlocked(&path, &existing, bytes, &[record])
|
|
}
|
|
|
|
pub(crate) fn record_project_model_request_at(
|
|
context: &ProjectModelUsageContext,
|
|
custom_enabled: bool,
|
|
) -> Result<(), String> {
|
|
let mut record = new_record(context, "turn-request");
|
|
if custom_enabled && !is_channel_placeholder(&context.requested_model) {
|
|
record.model_name = Some(context.requested_model.clone());
|
|
}
|
|
append_record(&context.root, record)
|
|
}
|
|
|
|
pub(crate) fn record_project_model_response_at(
|
|
context: &ProjectModelUsageContext,
|
|
model: &str,
|
|
response_id: Option<&str>,
|
|
) -> Result<(), String> {
|
|
let mut record = new_record(context, "provider-response");
|
|
record.model_name = Some(model.to_string());
|
|
record.response_id = response_id.map(str::to_string);
|
|
record.historical_model_confirmed = true;
|
|
append_record(&context.root, record)
|
|
}
|
|
|
|
fn historical_record(root: &Path, value: &Value) -> Option<ModelUsageRecord> {
|
|
// 只接受 Direct 主模型审计的显式型号;普通 model 字段可能来自目录、素材或工具参数。
|
|
if !matches!(
|
|
value.get("recordType").and_then(Value::as_str),
|
|
Some("direct.codex.turn" | "direct.codex.turn_start" | "direct.codex.model")
|
|
) || !(value
|
|
.get("historicalModelConfirmed")
|
|
.and_then(Value::as_bool)
|
|
== Some(true)
|
|
|| value.get("modelSource").and_then(Value::as_str) == Some("provider-response"))
|
|
{
|
|
return None;
|
|
}
|
|
let model = value.get("modelName")?.as_str()?;
|
|
let context = ProjectModelUsageContext {
|
|
root: root.to_path_buf(),
|
|
client_turn_id: value
|
|
.get("clientTurnId")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string),
|
|
thread_id: value
|
|
.get("threadId")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string),
|
|
requested_model: value
|
|
.get("requestedModel")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(model)
|
|
.to_string(),
|
|
};
|
|
let mut record = new_record(&context, "history-backfill");
|
|
record.model_name = Some(model.to_string());
|
|
record.response_id = value
|
|
.get("responseId")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string);
|
|
record.historical_model_confirmed = true;
|
|
validate_record(&record).ok()?;
|
|
Some(record)
|
|
}
|
|
|
|
fn collect_historical_records(root: &Path) -> Result<Vec<ModelUsageRecord>, String> {
|
|
let (summaries, _) = read_agent_db_records_bounded(root, MODEL_HISTORY_MAX_BYTES)?;
|
|
let mut records = Vec::new();
|
|
let mut turn_logs = BTreeSet::new();
|
|
for value in summaries.iter().rev() {
|
|
if let Some(record) = historical_record(root, value) {
|
|
records.push(record);
|
|
}
|
|
if value.get("recordType").and_then(Value::as_str) != Some("direct.codex.turn") {
|
|
continue;
|
|
}
|
|
let Some(relative) = value.get("turnLog").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
let Some(name) = relative.strip_prefix(".agent/runtime/direct-codex/turns/") else {
|
|
continue;
|
|
};
|
|
if name.len() <= MODEL_USAGE_MAX_FIELD_BYTES
|
|
&& name.ends_with(".jsonl")
|
|
&& !name.contains(['/', '\\'])
|
|
&& !name.starts_with('.')
|
|
&& turn_logs.len() < MODEL_HISTORY_MAX_TURN_LOGS
|
|
{
|
|
turn_logs.insert(relative.to_string());
|
|
}
|
|
}
|
|
let mut remaining = MODEL_HISTORY_MAX_BYTES;
|
|
for relative in turn_logs {
|
|
let path = resolve_local_project_path(root, &relative)?;
|
|
let content = match read_private_bytes(&path, remaining) {
|
|
Ok(Some(content)) => content,
|
|
Ok(None) => continue,
|
|
// 历史扫描只是有界证据恢复;预算用尽不阻止带来源标注的当前配置补录。
|
|
Err(error) if error == "项目模型记录超过读取上限" => break,
|
|
Err(error) => return Err(error),
|
|
};
|
|
remaining = remaining.saturating_sub(content.len() as u64);
|
|
for line in content.split(|byte| *byte == b'\n') {
|
|
if line.len() > MODEL_USAGE_MAX_LINE_BYTES {
|
|
continue;
|
|
}
|
|
if let Ok(value) = serde_json::from_slice::<Value>(line) {
|
|
if let Some(record) = historical_record(root, &value) {
|
|
records.push(record);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(records)
|
|
}
|
|
|
|
pub(crate) fn backfill_project_model_usage_at(
|
|
root: &Path,
|
|
llm: &crate::GameCreatorLlmConfig,
|
|
) -> Result<(), String> {
|
|
let path = resolve_local_project_path(root, MODEL_USAGE_PATH)?;
|
|
let append_lock = project_append_lock_for(&path)?;
|
|
let _guard = append_lock.lock(MODEL_USAGE_LABEL)?;
|
|
let (existing, bytes) = read_usage_records(&path)?;
|
|
if !existing.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let mut records = collect_historical_records(root)?;
|
|
if records.is_empty() {
|
|
let context = ProjectModelUsageContext {
|
|
root: root.to_path_buf(),
|
|
client_turn_id: None,
|
|
thread_id: None,
|
|
requested_model: llm.model.clone(),
|
|
};
|
|
let mut record = new_record(&context, "current-config-backfill");
|
|
if llm.custom_enabled && !is_channel_placeholder(&llm.model) {
|
|
record.model_name = Some(llm.model.clone());
|
|
}
|
|
records.push(record);
|
|
}
|
|
append_records_unlocked(&path, &existing, bytes, &records)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn context(root: &Path, turn: &str, model: &str) -> ProjectModelUsageContext {
|
|
ProjectModelUsageContext {
|
|
root: root.to_path_buf(),
|
|
client_turn_id: Some(turn.to_string()),
|
|
thread_id: Some("thread-model-test".to_string()),
|
|
requested_model: model.to_string(),
|
|
}
|
|
}
|
|
|
|
fn config(custom_enabled: bool, model: &str) -> crate::GameCreatorLlmConfig {
|
|
crate::GameCreatorLlmConfig {
|
|
custom_enabled,
|
|
visible_models: vec![model.to_string()],
|
|
api_key: "credential-must-not-appear".to_string(),
|
|
base_url: "https://private-provider.example/v1".to_string(),
|
|
model: model.to_string(),
|
|
api_kind: "openai_responses".to_string(),
|
|
reasoning_effort: "high".to_string(),
|
|
stream: true,
|
|
web_search_enabled: false,
|
|
context_window_tokens: 128_000,
|
|
auto_compact_token_limit: 64_000,
|
|
tool_output_token_limit: 12_000,
|
|
request_timeout_ms: 10_000,
|
|
max_retries: 0,
|
|
retry_backoff_ms: 100,
|
|
}
|
|
}
|
|
|
|
fn records(root: &Path) -> Vec<Value> {
|
|
fs::read_to_string(root.join(MODEL_USAGE_PATH))
|
|
.expect("read model usage")
|
|
.lines()
|
|
.map(|line| serde_json::from_str(line).expect("valid record"))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_preserves_switched_models_and_deduplicates_response_events() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let first = context(root.path(), "turn-first", "quality");
|
|
record_project_model_request_at(&first, false).unwrap();
|
|
record_project_model_response_at(&first, "gpt-6-astra", Some("response-first")).unwrap();
|
|
record_project_model_response_at(&first, "gpt-6-astra", Some("response-first")).unwrap();
|
|
let second = context(root.path(), "turn-second", "fast");
|
|
record_project_model_request_at(&second, false).unwrap();
|
|
record_project_model_response_at(&second, "gpt-5.6-luna", Some("response-second")).unwrap();
|
|
let saved = records(root.path());
|
|
assert_eq!(saved.len(), 4);
|
|
assert_eq!(saved[0]["requestedModel"], "quality");
|
|
assert!(saved[0]["modelName"].is_null());
|
|
assert_eq!(saved[1]["modelName"], "gpt-6-astra");
|
|
assert_eq!(saved[3]["modelName"], "gpt-5.6-luna");
|
|
assert_eq!(saved[1]["clientTurnId"], "turn-first");
|
|
assert_eq!(saved[3]["clientTurnId"], "turn-second");
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_backfill_is_idempotent_and_does_not_claim_official_history() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
backfill_project_model_usage_at(root.path(), &config(false, "catalog-new-id")).unwrap();
|
|
let original = fs::read(root.path().join(MODEL_USAGE_PATH)).unwrap();
|
|
backfill_project_model_usage_at(root.path(), &config(true, "different-model")).unwrap();
|
|
assert_eq!(
|
|
fs::read(root.path().join(MODEL_USAGE_PATH)).unwrap(),
|
|
original
|
|
);
|
|
let saved = records(root.path());
|
|
assert_eq!(saved.len(), 1);
|
|
assert_eq!(saved[0]["source"], "current-config-backfill");
|
|
assert_eq!(saved[0]["requestedModel"], "catalog-new-id");
|
|
assert!(saved[0]["modelName"].is_null());
|
|
assert_eq!(saved[0]["historicalModelConfirmed"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_custom_configuration_keeps_model_without_connection_or_credentials() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
backfill_project_model_usage_at(root.path(), &config(true, "vendor/model.v1:latest"))
|
|
.unwrap();
|
|
record_project_model_request_at(
|
|
&context(root.path(), "turn-custom", "vendor/model.v2"),
|
|
true,
|
|
)
|
|
.unwrap();
|
|
let saved = records(root.path());
|
|
assert_eq!(saved[0]["modelName"], "vendor/model.v1:latest");
|
|
assert_eq!(saved[1]["modelName"], "vendor/model.v2");
|
|
let raw = fs::read_to_string(root.path().join(MODEL_USAGE_PATH)).unwrap();
|
|
for forbidden in [
|
|
"credential-must-not-appear",
|
|
"private-provider",
|
|
"apiKey",
|
|
"baseUrl",
|
|
"prompt",
|
|
"content",
|
|
"root",
|
|
] {
|
|
assert!(!raw.contains(forbidden), "forbidden field: {forbidden}");
|
|
}
|
|
assert_eq!(saved[0].as_object().unwrap().len(), 9);
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_preserves_custom_model_punctuation() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let model = "@vendor/model+vision:release-2026";
|
|
record_project_model_request_at(&context(root.path(), "turn-punctuation", model), true)
|
|
.unwrap();
|
|
record_project_model_response_at(
|
|
&context(root.path(), "turn-punctuation", model),
|
|
model,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let saved = records(root.path());
|
|
assert_eq!(saved[0]["requestedModel"], model);
|
|
assert_eq!(saved[0]["modelName"], model);
|
|
assert_eq!(saved[1]["modelName"], model);
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_backfill_prefers_confirmed_direct_audit_and_ignores_other_models() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
super::super::append_agent_db_record(root.path(), json!({
|
|
"recordType": "asset.generated", "modelName": "gpt-image-2", "historicalModelConfirmed": true
|
|
})).unwrap();
|
|
super::super::append_agent_db_record(
|
|
root.path(),
|
|
json!({
|
|
"recordType": "direct.codex.turn", "clientTurnId": "turn-old",
|
|
"requestedModel": "quality", "modelName": "gpt-old-confirmed",
|
|
"historicalModelConfirmed": true, "content": "private text must not be copied"
|
|
}),
|
|
)
|
|
.unwrap();
|
|
backfill_project_model_usage_at(root.path(), &config(true, "new-config-model")).unwrap();
|
|
let saved = records(root.path());
|
|
assert_eq!(saved.len(), 1);
|
|
assert_eq!(saved[0]["source"], "history-backfill");
|
|
assert_eq!(saved[0]["modelName"], "gpt-old-confirmed");
|
|
assert_eq!(saved[0]["historicalModelConfirmed"], true);
|
|
assert!(!fs::read_to_string(root.path().join(MODEL_USAGE_PATH))
|
|
.unwrap()
|
|
.contains("private text"));
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_backfill_rejects_channels_and_unqualified_model_fields() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
for audit in [
|
|
json!({"recordType":"direct.codex.turn", "model":"gpt-claimed"}),
|
|
json!({"recordType":"direct.codex.turn", "modelName":"codex-app-server", "historicalModelConfirmed":true}),
|
|
json!({"recordType":"design.response", "modelName":"gpt-design", "historicalModelConfirmed":true}),
|
|
json!({"recordType":"direct.codex.turn", "toolResult":{"modelName":"gpt-image-2", "historicalModelConfirmed":true}}),
|
|
] {
|
|
super::super::append_agent_db_record(root.path(), audit).unwrap();
|
|
}
|
|
backfill_project_model_usage_at(root.path(), &config(false, "platform-default")).unwrap();
|
|
let saved = records(root.path());
|
|
assert_eq!(saved.len(), 1);
|
|
assert_eq!(saved[0]["source"], "current-config-backfill");
|
|
assert!(saved[0]["modelName"].is_null());
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_history_scan_budget_does_not_prevent_explicit_configuration_backfill() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let relative = ".agent/runtime/direct-codex/turns/turn-large.jsonl";
|
|
let path = root.path().join(relative);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
let file = fs::File::create(&path).unwrap();
|
|
file.set_len(MODEL_HISTORY_MAX_BYTES + 1).unwrap();
|
|
drop(file);
|
|
super::super::append_agent_db_record(
|
|
root.path(),
|
|
json!({
|
|
"recordType": "direct.codex.turn", "clientTurnId": "turn-large", "turnLog": relative
|
|
}),
|
|
)
|
|
.unwrap();
|
|
backfill_project_model_usage_at(root.path(), &config(false, "quality")).unwrap();
|
|
let saved = records(root.path());
|
|
assert_eq!(saved[0]["source"], "current-config-backfill");
|
|
assert!(saved[0]["modelName"].is_null());
|
|
assert_eq!(saved[0]["historicalModelConfirmed"], false);
|
|
assert_eq!(
|
|
fs::metadata(path).unwrap().len(),
|
|
MODEL_HISTORY_MAX_BYTES + 1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_concurrent_backfill_writes_once_and_preserves_turn_evidence() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let handles: Vec<_> = (0..4)
|
|
.map(|_| {
|
|
let path = root.path().to_path_buf();
|
|
std::thread::spawn(move || {
|
|
backfill_project_model_usage_at(&path, &config(false, "quality"))
|
|
})
|
|
})
|
|
.collect();
|
|
for handle in handles {
|
|
handle.join().unwrap().unwrap();
|
|
}
|
|
assert_eq!(records(root.path()).len(), 1);
|
|
record_project_model_response_at(
|
|
&context(root.path(), "turn-later", "quality"),
|
|
"gpt-current",
|
|
Some("response-later"),
|
|
)
|
|
.unwrap();
|
|
let before = fs::read(root.path().join(MODEL_USAGE_PATH)).unwrap();
|
|
backfill_project_model_usage_at(root.path(), &config(false, "changed-catalog-id")).unwrap();
|
|
assert_eq!(
|
|
fs::read(root.path().join(MODEL_USAGE_PATH)).unwrap(),
|
|
before
|
|
);
|
|
assert_eq!(records(root.path()).len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_corrupt_or_oversized_file_is_never_repaired_or_overwritten() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
fs::create_dir(root.path().join(".agent")).unwrap();
|
|
let path = root.path().join(MODEL_USAGE_PATH);
|
|
for original in [b"{broken}\n".as_slice(), b"{\"unfinished\":".as_slice()] {
|
|
fs::write(&path, original).unwrap();
|
|
assert!(
|
|
backfill_project_model_usage_at(root.path(), &config(true, "new-model")).is_err()
|
|
);
|
|
assert!(record_project_model_request_at(
|
|
&context(root.path(), "turn-bad", "quality"),
|
|
false
|
|
)
|
|
.is_err());
|
|
assert_eq!(fs::read(&path).unwrap(), original);
|
|
}
|
|
let file = fs::OpenOptions::new().write(true).open(&path).unwrap();
|
|
file.set_len(MODEL_USAGE_MAX_BYTES + 1).unwrap();
|
|
drop(file);
|
|
assert!(record_project_model_request_at(
|
|
&context(root.path(), "turn-big", "quality"),
|
|
false
|
|
)
|
|
.is_err());
|
|
assert_eq!(
|
|
fs::metadata(&path).unwrap().len(),
|
|
MODEL_USAGE_MAX_BYTES + 1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_rejects_response_placeholders_secrets_and_unbounded_fields() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let context = context(root.path(), "turn-validation", "quality");
|
|
for model in [
|
|
"codex-app-server",
|
|
"codex-cli",
|
|
"platform-default",
|
|
"Direct Codex",
|
|
"sk-secret",
|
|
"https://provider.example/model",
|
|
"response body with spaces",
|
|
] {
|
|
assert!(record_project_model_response_at(&context, model, None).is_err());
|
|
}
|
|
assert!(record_project_model_response_at(&context, &"m".repeat(257), None).is_err());
|
|
assert!(!root.path().join(MODEL_USAGE_PATH).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn model_usage_rejects_hard_link_without_touching_external_file() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let outside = tempfile::tempdir().unwrap();
|
|
fs::create_dir(root.path().join(".agent")).unwrap();
|
|
let external = outside.path().join("original.jsonl");
|
|
fs::write(&external, "preserve external bytes\n").unwrap();
|
|
fs::hard_link(&external, root.path().join(MODEL_USAGE_PATH)).unwrap();
|
|
assert!(record_project_model_request_at(
|
|
&context(root.path(), "turn-link", "quality"),
|
|
false
|
|
)
|
|
.is_err());
|
|
assert_eq!(
|
|
fs::read_to_string(&external).unwrap(),
|
|
"preserve external bytes\n"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn model_usage_rejects_agent_directory_symlink() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let outside = tempfile::tempdir().unwrap();
|
|
std::os::unix::fs::symlink(outside.path(), root.path().join(".agent")).unwrap();
|
|
assert!(record_project_model_request_at(
|
|
&context(root.path(), "turn-link", "quality"),
|
|
false
|
|
)
|
|
.is_err());
|
|
assert!(!outside.path().join("model-usage.jsonl").exists());
|
|
}
|
|
}
|