From 37189c925260d90819b9c4dd5b76e988b97f2bec Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:02:44 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=A1=B9=E7=9B=AE=E4=B8=BB?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=BD=BF=E7=94=A8=E5=8E=86=E5=8F=B2=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=BD=95=E6=97=A7=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在项目 .agent 中保存请求模型与响应确认型号,区分目录标识和真实模型 为旧项目补录带来源标记的模型信息,保留历史并确保重复打开幂等 在 Direct Codex 代理中提取模型元数据,异步落盘且不阻塞响应转发 补充模型追溯边界测试并同步技术方案与团队约定 --- .../src/agent/codex_app_server/mod.rs | 23 + .../src/agent/codex_provider_proxy.rs | 391 ++++++++++- .../agent/codex_provider_proxy/model_usage.rs | 320 +++++++++ .../src-tauri/src/commands.rs | 13 +- .../src-tauri/src/project.rs | 2 + .../src-tauri/src/project/model_usage.rs | 658 ++++++++++++++++++ .../shared-memory/team-conventions.md | 2 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 18 + 8 files changed, 1422 insertions(+), 5 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy/model_usage.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index a45af869a..d8f96460b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -3002,6 +3002,29 @@ impl CodexAppServerConnection { self.inner.workspace_mode, direct_client_turn_id, ); + // 在发送前冻结本轮模型和归属;目录标识不能冒充上游返回的实际型号。 + // 记录失败仅留安全诊断,不阻断回合或重试付费请求。 + let _model_usage_guard = + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if backfill_project_model_usage_at(history_root, llm).is_err() { + app_log!("project.model_usage.backfill_failed"); + } + let context = ProjectModelUsageContext { + root: history_root.to_path_buf(), + client_turn_id: direct_tool_call_turn_id.clone(), + thread_id: Some(thread_id.clone()), + requested_model: model.to_string(), + }; + if record_project_model_request_at(&context, llm.custom_enabled).is_err() { + app_log!("project.model_usage.request_write_failed"); + } + self.inner + ._provider_proxy + .as_ref() + .map(|proxy| proxy.begin_model_usage(context)) + } else { + None + }; apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { params["outputSchema"] = schema; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs index a38811148..17fd5ae7b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs @@ -4,7 +4,13 @@ use axum::http::{HeaderMap, HeaderName, Request, Response, StatusCode}; use axum::routing::any; use axum::Router; use futures::StreamExt; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; + +mod model_usage; + +use model_usage::ModelResponseObserver; + +type ActiveModelUsage = Arc>>>; pub(crate) const CODEX_PROVIDER_PROXY_PROTOCOL: &str = "genarrative-codex-provider-proxy.v1"; @@ -19,12 +25,34 @@ struct CodexProviderProxyState { downstream_bearer_token: String, main_site_upstream: bool, client: reqwest::Client, + model_usage: ActiveModelUsage, } pub(crate) struct CodexProviderProxy { base_url: String, downstream_bearer_token: String, task: tokio::task::JoinHandle<()>, + model_usage: ActiveModelUsage, +} + +pub(crate) struct CodexProviderModelUsageGuard { + active: ActiveModelUsage, + registration: Arc, +} + +impl Drop for CodexProviderModelUsageGuard { + fn drop(&mut self) { + let mut active = self + .active + .lock() + .unwrap_or_else(|error| error.into_inner()); + if active + .as_ref() + .is_some_and(|context| Arc::ptr_eq(context, &self.registration)) + { + *active = None; + } + } } impl CodexProviderProxy { @@ -35,6 +63,21 @@ impl CodexProviderProxy { pub(crate) fn downstream_bearer_token(&self) -> &str { &self.downstream_bearer_token } + + pub(crate) fn begin_model_usage( + &self, + context: crate::project::ProjectModelUsageContext, + ) -> CodexProviderModelUsageGuard { + let registration = Arc::new(context); + *self + .model_usage + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some(Arc::clone(®istration)); + CodexProviderModelUsageGuard { + active: Arc::clone(&self.model_usage), + registration, + } + } } impl Drop for CodexProviderProxy { @@ -128,6 +171,12 @@ async fn proxy_codex_provider_request( if request.method() != axum::http::Method::POST || request.uri().path() != "/responses" { return proxy_error(StatusCode::NOT_FOUND, "provider proxy route not found"); } + // 在读请求体或等待上游之前冻结归属,迟到响应不能使用下一回合的项目上下文。 + let model_usage = state + .model_usage + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); let upstream_url = format!("{}{path_and_query}", state.upstream_base_url); let (parts, body) = request.into_parts(); let body = match to_bytes(body, CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES).await { @@ -171,9 +220,28 @@ async fn proxy_codex_provider_request( }; let status = upstream.status(); let upstream_headers = upstream.headers().clone(); - let stream = upstream - .bytes_stream() - .map(|chunk| chunk.map_err(|_| std::io::Error::other("provider response stream failed"))); + let observer = ModelResponseObserver::new(model_usage, status, &upstream_headers); + let stream = futures::stream::unfold( + (upstream.bytes_stream().boxed(), observer), + |(mut upstream, mut observer)| async move { + match upstream.next().await { + Some(chunk) => { + match &chunk { + Ok(bytes) => observer.observe(bytes), + Err(_) => observer.failed(), + } + Some(( + chunk.map_err(|_| std::io::Error::other("provider response stream failed")), + (upstream, observer), + )) + } + None => { + observer.finish(); + None + } + } + }, + ); let mut response = Response::builder().status(status); if let Some(headers) = response.headers_mut() { let mut stripped_limit_headers = 0_usize; @@ -227,12 +295,14 @@ pub(crate) async fn start_codex_provider_proxy( let address = listener .local_addr() .map_err(|error| format!("读取 Codex Provider 代理地址失败:{error}"))?; + let model_usage = Arc::new(Mutex::new(None)); let state = Arc::new(CodexProviderProxyState { upstream_base_url, upstream_bearer_token: upstream_bearer_token.to_string(), downstream_bearer_token: downstream_bearer_token.clone(), main_site_upstream, client, + model_usage: Arc::clone(&model_usage), }); let app = Router::new() .fallback(any(proxy_codex_provider_request)) @@ -244,6 +314,7 @@ pub(crate) async fn start_codex_provider_proxy( base_url: format!("http://127.0.0.1:{}", address.port()), downstream_bearer_token, task, + model_usage, }) } @@ -253,6 +324,318 @@ mod tests { use axum::routing::post; use std::sync::atomic::{AtomicUsize, Ordering}; + #[derive(Clone)] + struct ModelFixture { + status: StatusCode, + content_type: &'static str, + bytes: Vec, + entered: Option>, + release: Option>, + } + + async fn model_fixture_response(State(fixture): State) -> Response { + if let Some(entered) = fixture.entered { + entered.notify_one(); + } + if let Some(release) = fixture.release { + release.notified().await; + } + // 刻意拆开 UTF-8 与 CRLF;代理仍必须逐字节保留完整响应。 + let chunks = fixture + .bytes + .into_iter() + .map(|byte| Ok::<_, std::io::Error>(axum::body::Bytes::from(vec![byte]))); + Response::builder() + .status(fixture.status) + .header("content-type", fixture.content_type) + .body(Body::from_stream(futures::stream::iter(chunks))) + .expect("model fixture response") + } + + async fn start_model_fixture( + fixture: ModelFixture, + ) -> (CodexProviderProxy, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind model upstream"); + let address = listener.local_addr().expect("model upstream address"); + let app = Router::new() + .route("/responses", post(model_fixture_response)) + .with_state(fixture); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let proxy = start_codex_provider_proxy( + &format!("http://127.0.0.1:{}", address.port()), + "fixture-provider-key", + false, + ) + .await + .expect("start model provider proxy"); + (proxy, task) + } + + fn model_context( + root: &std::path::Path, + turn: &str, + ) -> crate::project::ProjectModelUsageContext { + crate::project::ProjectModelUsageContext { + root: root.to_path_buf(), + client_turn_id: Some(turn.to_string()), + thread_id: Some("thread_fixture".to_string()), + requested_model: "requested-alias".to_string(), + } + } + + fn model_records(root: &std::path::Path) -> Vec { + let path = root.join(".agent/model-usage.jsonl"); + if !path.exists() { + return Vec::new(); + } + std::fs::read_to_string(path) + .expect("model usage file") + .lines() + .map(|line| serde_json::from_str(line).expect("model usage json")) + .collect() + } + + async fn wait_for_model_records( + root: &std::path::Path, + count: usize, + ) -> Vec { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + // 写入线程可能正写一行,只有完整 JSONL 才是测试的落盘证据。 + if let Ok(text) = std::fs::read_to_string(root.join(".agent/model-usage.jsonl")) { + let records: Result, _> = + text.lines().map(serde_json::from_str).collect(); + if let Ok(records) = records { + if records.len() >= count { + return records; + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("model records persisted") + } + + async fn fetch_model_fixture(proxy: &CodexProviderProxy) -> Vec { + reqwest::Client::new() + .post(format!("{}/responses", proxy.base_url())) + .bearer_auth(proxy.downstream_bearer_token()) + .body("{\"input\":\"private prompt fixture\"}") + .send() + .await + .expect("model fixture response") + .bytes() + .await + .expect("model fixture body") + .to_vec() + } + + #[tokio::test] + async fn model_usage_proxy_records_json_model_and_passes_original_bytes() { + let root = tempfile::tempdir().expect("model project"); + let bytes = br#"{"id":"resp_json","model":"actual-main-v1","output":[{"text":"private output fixture","model":"not-main"}]}"#.to_vec(); + let (proxy, task) = start_model_fixture(ModelFixture { + status: StatusCode::OK, + content_type: "application/json; charset=utf-8", + bytes: bytes.clone(), + entered: None, + release: None, + }) + .await; + let _guard = proxy.begin_model_usage(model_context(root.path(), "turn_json")); + assert_eq!(fetch_model_fixture(&proxy).await, bytes); + let records = wait_for_model_records(root.path(), 1).await; + assert_eq!(records.len(), 1); + assert_eq!(records[0]["requestedModel"], "requested-alias"); + assert_eq!(records[0]["modelName"], "actual-main-v1"); + assert_eq!(records[0]["clientTurnId"], "turn_json"); + assert_eq!(records[0]["responseId"], "resp_json"); + assert_eq!(records[0]["source"], "provider-response"); + assert_eq!(records[0]["historicalModelConfirmed"], true); + let persisted = serde_json::to_string(&records).expect("serialize records"); + for forbidden in [ + "private prompt", + "private output", + "fixture-provider-key", + "not-main", + ] { + assert!(!persisted.contains(forbidden)); + } + task.abort(); + } + + #[tokio::test] + async fn model_usage_proxy_records_sse_models_once_and_passes_original_bytes() { + let root = tempfile::tempdir().expect("model project"); + let bytes = concat!( + "event: response.created\r\n", + "data: {\"type\":\"response.created\",\r\n", + "data: \"response\":{\"id\":\"resp_sse\",\"model\":\"actual-main-v2\",\"text\":\"隐私正文\"}}\r\n\r\n", + "data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_sse\",\"model\":\"actual-main-v2\"}}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"response\":{\"model\":\"not-main\"}}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_sse\",\"model\":\"actual-main-v3\"}}\n\n", + "data: [DONE]\n\n" + ).as_bytes().to_vec(); + let (proxy, task) = start_model_fixture(ModelFixture { + status: StatusCode::OK, + content_type: "text/event-stream", + bytes: bytes.clone(), + entered: None, + release: None, + }) + .await; + let _guard = proxy.begin_model_usage(model_context(root.path(), "turn_sse")); + assert_eq!(fetch_model_fixture(&proxy).await, bytes); + let records = wait_for_model_records(root.path(), 2).await; + assert_eq!(records.len(), 2); + assert_eq!(records[0]["modelName"], "actual-main-v2"); + assert_eq!(records[1]["modelName"], "actual-main-v3"); + assert!(records + .iter() + .all(|record| record["responseId"] == "resp_sse")); + assert!(!serde_json::to_string(&records) + .unwrap() + .contains("隐私正文")); + task.abort(); + } + + #[tokio::test] + async fn model_usage_proxy_freezes_request_owner_and_old_guard_preserves_new_owner() { + let old_root = tempfile::tempdir().expect("old model project"); + let new_root = tempfile::tempdir().expect("new model project"); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let bytes = br#"{"id":"resp_late","model":"actual-late"}"#.to_vec(); + let (proxy, task) = start_model_fixture(ModelFixture { + status: StatusCode::OK, + content_type: "application/json", + bytes: bytes.clone(), + entered: Some(Arc::clone(&entered)), + release: Some(Arc::clone(&release)), + }) + .await; + let old_guard = proxy.begin_model_usage(model_context(old_root.path(), "turn_old")); + let proxy = Arc::new(proxy); + let requester = Arc::clone(&proxy); + let request = tokio::spawn(async move { fetch_model_fixture(&requester).await }); + tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified()) + .await + .expect("upstream entered"); + let new_guard = proxy.begin_model_usage(model_context(new_root.path(), "turn_new")); + drop(old_guard); + release.notify_one(); + assert_eq!(request.await.expect("old response"), bytes); + assert_eq!( + wait_for_model_records(old_root.path(), 1).await[0]["clientTurnId"], + "turn_old" + ); + assert!(model_records(new_root.path()).is_empty()); + + release.notify_one(); + assert_eq!(fetch_model_fixture(&proxy).await, bytes); + assert_eq!( + wait_for_model_records(new_root.path(), 1).await[0]["clientTurnId"], + "turn_new" + ); + drop(new_guard); + assert!(proxy.model_usage.lock().expect("active model").is_none()); + task.abort(); + } + + #[tokio::test] + async fn model_usage_proxy_passes_response_while_record_file_is_locked() { + let root = tempfile::tempdir().expect("locked model project"); + std::fs::create_dir(root.path().join(".agent")).expect("create agent directory"); + let path = + crate::project::resolve_local_project_path(root.path(), ".agent/model-usage.jsonl") + .expect("model usage path"); + let lock = crate::project::project_append_lock_for(&path).expect("model usage lock"); + let held = lock + .lock("model usage fixture") + .expect("hold model usage lock"); + let bytes = br#"{"id":"resp_locked","model":"actual-main"}"#.to_vec(); + let (proxy, task) = start_model_fixture(ModelFixture { + status: StatusCode::OK, + content_type: "application/json", + bytes: bytes.clone(), + entered: None, + release: None, + }) + .await; + let _guard = proxy.begin_model_usage(model_context(root.path(), "turn_locked")); + let response = tokio::time::timeout( + std::time::Duration::from_secs(2), + fetch_model_fixture(&proxy), + ) + .await + .expect("record lock must not block the response"); + assert_eq!(response, bytes); + assert!(model_records(root.path()).is_empty()); + drop(held); + assert_eq!( + wait_for_model_records(root.path(), 1).await[0]["modelName"], + "actual-main" + ); + task.abort(); + } + + #[tokio::test] + async fn model_usage_observer_keeps_confirmed_sse_on_stream_error_but_rejects_partial_json() { + let root = tempfile::tempdir().expect("stream error project"); + let context = Arc::new(model_context(root.path(), "turn_failed")); + let mut headers = HeaderMap::new(); + headers.insert("content-type", "text/event-stream".parse().unwrap()); + let mut observer = + ModelResponseObserver::new(Some(Arc::clone(&context)), StatusCode::OK, &headers); + observer.observe(b"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_early\",\"model\":\"confirmed-before-error\"}}\n\n"); + observer.failed(); + observer.observe( + b"data: {\"type\":\"response.completed\",\"response\":{\"model\":\"after-error\"}}\n\n", + ); + observer.finish(); + let records = wait_for_model_records(root.path(), 1).await; + assert_eq!(records.len(), 1); + assert_eq!(records[0]["modelName"], "confirmed-before-error"); + + headers.insert("content-type", "application/json".parse().unwrap()); + let mut observer = + ModelResponseObserver::new(Some(Arc::clone(&context)), StatusCode::OK, &headers); + observer.observe(br#"{"id":"resp_partial","model":"partial-json"}"#); + observer.failed(); + observer.finish(); + assert_eq!(model_records(root.path()).len(), 1); + } + + #[tokio::test] + async fn model_usage_proxy_does_not_invent_model_for_error_or_missing_field() { + for (status, content_type, bytes) in [ + (StatusCode::BAD_REQUEST, "application/json", br#"{"model":"error-model"}"#.to_vec()), + (StatusCode::OK, "application/json", br#"{"output":[{"model":"nested-model"}]}"#.to_vec()), + (StatusCode::OK, "application/json", br#"{"model":"incomplete""#.to_vec()), + (StatusCode::OK, "text/event-stream", b"data: {\"type\":\"response.failed\",\"response\":{\"model\":\"failed-model\"}}\n\n".to_vec()), + ] { + let root = tempfile::tempdir().expect("model project"); + let (proxy, task) = start_model_fixture(ModelFixture { + status, + content_type, + bytes: bytes.clone(), + entered: None, + release: None, + }) + .await; + let _guard = proxy.begin_model_usage(model_context(root.path(), "turn_empty")); + assert_eq!(fetch_model_fixture(&proxy).await, bytes); + assert!(model_records(root.path()).is_empty()); + task.abort(); + } + } + async fn fake_upstream( State(calls): State>, headers: HeaderMap, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy/model_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy/model_usage.rs new file mode 100644 index 000000000..c5d9e75ff --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy/model_usage.rs @@ -0,0 +1,320 @@ +use axum::http::{HeaderMap, StatusCode}; +use serde_json::Value; +use std::collections::HashSet; +use std::sync::Arc; + +const MAX_OBSERVATION_BYTES: usize = 1024 * 1024; +const MAX_MODELS_PER_RESPONSE: usize = 64; +const MAX_IDENTIFIER_BYTES: usize = 256; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct ModelObservation { + model: String, + response_id: Option, +} + +enum ResponseParser { + Json(Vec), + Sse(SseParser), + Disabled, +} + +pub(super) struct ModelResponseObserver { + context: Option>, + parser: ResponseParser, + seen: HashSet, + writer: Option>, +} + +impl ModelResponseObserver { + pub(super) fn new( + context: Option>, + status: StatusCode, + headers: &HeaderMap, + ) -> Self { + let content_type = headers + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .split(';') + .next() + .unwrap_or_default() + .trim(); + let parser = if !status.is_success() || context.is_none() { + ResponseParser::Disabled + } else if content_type.eq_ignore_ascii_case("text/event-stream") { + ResponseParser::Sse(SseParser::default()) + } else if content_type.eq_ignore_ascii_case("application/json") { + ResponseParser::Json(Vec::new()) + } else { + ResponseParser::Disabled + }; + Self { + context, + parser, + seen: HashSet::new(), + writer: None, + } + } + + pub(super) fn observe(&mut self, bytes: &[u8]) { + match &mut self.parser { + ResponseParser::Json(buffer) => { + if bytes.len() > MAX_OBSERVATION_BYTES.saturating_sub(buffer.len()) { + self.parser = ResponseParser::Disabled; + } else { + buffer.extend_from_slice(bytes); + } + } + ResponseParser::Sse(parser) => { + // 逐字节识别换行,UTF-8 只在完整事件中解析;不改写转发的原始块。 + for byte in bytes { + if let Some(observation) = parser.push(*byte) { + Self::record(&self.context, &mut self.seen, &mut self.writer, observation); + if self.seen.len() >= MAX_MODELS_PER_RESPONSE { + self.parser = ResponseParser::Disabled; + break; + } + } + } + } + ResponseParser::Disabled => {} + } + } + + pub(super) fn failed(&mut self) { + // SSE 已确认的初始事件已排队写入;不完整 JSON 不构成型号证据。 + self.parser = ResponseParser::Disabled; + } + + pub(super) fn finish(&mut self) { + if let ResponseParser::Json(bytes) = + std::mem::replace(&mut self.parser, ResponseParser::Disabled) + { + if let Ok(value) = serde_json::from_slice::(&bytes) { + if let Some(observation) = response_observation(&value) { + Self::record(&self.context, &mut self.seen, &mut self.writer, observation); + } + } + } + } + + fn record( + context: &Option>, + seen: &mut HashSet, + writer: &mut Option>, + observation: ModelObservation, + ) { + if seen.len() >= MAX_MODELS_PER_RESPONSE || !seen.insert(observation.clone()) { + return; + } + let Some(context) = context else { + return; + }; + let writer = writer.get_or_insert_with(|| { + let (sender, mut receiver) = + tokio::sync::mpsc::channel::(MAX_MODELS_PER_RESPONSE); + let context = Arc::clone(context); + // 单响应顺序写入;项目追加锁和磁盘 I/O 不得阻塞上游响应转发。 + tokio::spawn(async move { + while let Some(observation) = receiver.recv().await { + let context = Arc::clone(&context); + let result = tokio::task::spawn_blocking(move || { + crate::project::record_project_model_response_at( + &context, + &observation.model, + observation.response_id.as_deref(), + ) + }) + .await; + if !matches!(result, Ok(Ok(()))) { + app_log!("agent.direct_codex.model_usage.response_record_failed"); + } + } + }); + sender + }); + if writer.try_send(observation).is_err() { + app_log!("agent.direct_codex.model_usage.response_record_queue_unavailable"); + } + } +} + +fn identifier(value: Option<&Value>) -> Option { + let value = value?.as_str()?.trim(); + (!value.is_empty() + && value.len() <= MAX_IDENTIFIER_BYTES + && !value.chars().any(char::is_control)) + .then(|| value.to_string()) +} + +fn response_observation(value: &Value) -> Option { + Some(ModelObservation { + model: identifier(value.get("model"))?, + response_id: identifier(value.get("id")), + }) +} + +fn known_event(value: &str) -> bool { + matches!( + value, + "response.created" | "response.in_progress" | "response.completed" + ) +} + +#[derive(Default)] +struct SseParser { + line: Vec, + line_bytes: usize, + data: Vec, + event: Option, + event_bytes: usize, + discarded: bool, + skip_lf: bool, +} + +impl SseParser { + fn push(&mut self, byte: u8) -> Option { + if self.skip_lf { + self.skip_lf = false; + if byte == b'\n' { + return None; + } + } + if byte == b'\r' || byte == b'\n' { + self.skip_lf = byte == b'\r'; + return self.finish_line(); + } + self.line_bytes = self.line_bytes.saturating_add(1); + self.event_bytes = self.event_bytes.saturating_add(1); + if self.event_bytes > MAX_OBSERVATION_BYTES { + self.discarded = true; + self.line.clear(); + self.data.clear(); + self.event = None; + } else if !self.discarded { + self.line.push(byte); + } + None + } + + fn finish_line(&mut self) -> Option { + if self.line_bytes == 0 { + let observation = (!self.discarded).then(|| self.parse_event()).flatten(); + self.line.clear(); + self.data.clear(); + self.event = None; + self.event_bytes = 0; + self.discarded = false; + return observation; + } + self.line_bytes = 0; + if !self.discarded { + // 换行同样计入事件预算,避免无限 data 空行绕过有界缓冲。 + self.event_bytes = self.event_bytes.saturating_add(1); + if self.event_bytes > MAX_OBSERVATION_BYTES { + self.discarded = true; + self.data.clear(); + self.event = None; + } else if let Some((name, value)) = self + .line + .iter() + .position(|byte| *byte == b':') + .map(|colon| (&self.line[..colon], &self.line[colon + 1..])) + { + let value = value.strip_prefix(b" ").unwrap_or(value); + match name { + b"data" => { + self.data.extend_from_slice(value); + self.data.push(b'\n'); + } + b"event" => { + self.event = Some( + std::str::from_utf8(value) + .ok() + .filter(|value| known_event(value)) + .unwrap_or("") + .to_string(), + ); + } + _ => {} + } + } + } + self.line.clear(); + None + } + + fn parse_event(&self) -> Option { + let value: Value = serde_json::from_slice(&self.data).ok()?; + let payload_type = value.get("type").and_then(Value::as_str); + let event = payload_type.or(self.event.as_deref())?; + if !known_event(event) + || self + .event + .as_deref() + .is_some_and(|header_event| header_event != event) + { + return None; + } + response_observation(value.get("response")?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_sse(bytes: &[u8]) -> Vec { + let mut parser = SseParser::default(); + bytes.iter().filter_map(|byte| parser.push(*byte)).collect() + } + + #[test] + fn model_usage_parser_accepts_split_utf8_crlf_multiline_and_cr_events() { + let event = concat!( + ": keepalive\r\n", + "event: response.created\r\n", + "data: {\"type\":\"response.created\",\r\n", + "data: \"response\":{\"id\":\"resp_1\",\"model\":\"模型-v1\"}}\r\n\r\n", + "event: response.completed\r", + "data: {\"response\":{\"id\":\"resp_1\",\"model\":\"模型-v1\"}}\r\r" + ); + let parsed = parse_sse(event.as_bytes()); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].model, "模型-v1"); + assert_eq!(parsed[0].response_id.as_deref(), Some("resp_1")); + assert_eq!(parsed[0], parsed[1]); + } + + #[test] + fn model_usage_parser_rejects_untrusted_locations_and_invalid_events() { + for event in [ + "data: {\"type\":\"response.output_text.delta\",\"response\":{\"model\":\"fake\"}}\n\n", + "data: {\"type\":\"response.failed\",\"response\":{\"model\":\"fake\"}}\n\n", + "data: {\"type\":\"response.created\",\"model\":\"fake\"}\n\n", + "data: {\"type\":\"response.created\",\"response\":{\"output\":[{\"model\":\"fake\"}]}}\n\n", + "event: response.failed\ndata: {\"type\":\"response.created\",\"response\":{\"model\":\"fake\"}}\n\n", + "data: [DONE]\n\n", + "data: invalid json\n\n", + "data: {\"type\":\"response.created\",\"response\":{\"model\":\"fake\"}}", + ] { + assert!(parse_sse(event.as_bytes()).is_empty()); + } + assert!( + response_observation(&serde_json::json!({"output": [{"model": "fake"}]})).is_none() + ); + } + + #[test] + fn model_usage_parser_recovers_after_oversize_and_invalid_utf8_events() { + let valid = b"data: {\"type\":\"response.created\",\"response\":{\"model\":\"real\"}}\n\n"; + let mut bytes = b"data: ".to_vec(); + bytes.extend(std::iter::repeat_n(b'x', MAX_OBSERVATION_BYTES + 1)); + bytes.extend_from_slice(b"\n\ndata: \xff\n\n"); + bytes.extend_from_slice(valid); + let parsed = parse_sse(&bytes); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].model, "real"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 5f846760e..a5361290a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -981,7 +981,18 @@ pub(crate) fn get_local_game_manifest_sync( return Err(format!("不支持通过 manifest 执行命令:{command_id}")); } enforce_project_permission_policy(root, command_id)?; - read_manifest_for_project_with_godot_root_calibration(root) + let manifest = read_manifest_for_project_with_godot_root_calibration(root)?; + if command_id == "project.status" { + match load_game_creator_app_config() { + Ok(config) => { + if backfill_project_model_usage_at(root, &config.llm).is_err() { + app_log!("project.model_usage.backfill_failed"); + } + } + Err(_) => app_log!("project.model_usage.backfill_config_unavailable"), + } + } + Ok(manifest) } /// 读取资源画布的持久化布局(`.agent/workbench/resource-layouts/*.json`)。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 33e24e898..b66b1de9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -14,6 +14,7 @@ mod external_editor_bindings; mod filesystem; mod manifest; mod memory; +mod model_usage; mod resource_dependency_graph; mod resource_editor; mod resource_layout; @@ -32,6 +33,7 @@ pub(crate) use external_editor_bindings::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; +pub(crate) use model_usage::*; pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs new file mode 100644 index 000000000..7090b48d6 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs @@ -0,0 +1,658 @@ +//! 随项目保存主模型来源;只接收白名单元数据,不接收提示词、响应正文或连接配置。 + +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, + pub thread_id: Option, + 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, + client_turn_id: Option, + thread_id: Option, + response_id: Option, + 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>, 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, 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 { + // 只接受 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, 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::(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 { + 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()); + } +} diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 70f149c58..e594cbfd6 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -16,6 +16,8 @@ ## 开发中 +- AGC 主模型追溯保存在项目 `.agent/model-usage.jsonl`,请求目录标识与响应确认的型号分别记录;旧项目当前配置补录必须标注来源,不冒充历史事实。仅保存有界模型与回合身份字段,不保存配置、凭据或对话正文,不增加 UI 展示。详见 AGC 实施计划“项目主模型使用记录”。 + - Agent 提示词正文与工具说明放在所属组件的 `prompts/`;AGC 通过现有 Prompt Bundle 编译加载,服务端独立 crate 编译包含自己的提示词文件。代码负责变量填充、结构化 schema 与执行校验。 - AGC 思考与执行入口共用共享单行摘要骨架;Markdown 在展开正文走既有安全渲染,折叠预览使用纯文本。耗时统一复用中文时分秒格式(不足一分钟一位小数,达到分钟后整数秒),格式化与各层计时边界分离。过程行在运行中和完成后的折叠层内保持同一紧凑间距;失败状态按明确终态与非零退出码呈现红色。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a031b9bf9..f76128599 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,23 @@ # AI 游戏创作智能体 App 实施计划 +## 项目主模型使用记录 + +- 项目在 `.agent/model-usage.jsonl` 保存主模型记录,供复制或压缩完整项目后查询,不新增界面展示。每条只保存版本、记录时间、来源、请求模型标识、可确认的模型名称,以及可用的客户端回合和线程身份;不保存提示词、响应正文、连接地址或凭据。 +- DirectProject 每次提交回合前保存本次请求的模型快照,来源为 `turn-request`。官方目录标识与真实型号分开:官方标识只写 `requestedModel`,不能把 `platform-default`、目录 ID、`Direct Codex` 或 `codex-app-server` 当成模型名;自定义路由保存明确提交的型号。响应返回的模型名以 `provider-response` 追加,使用请求发出时冻结的项目/回合归属,后续切模型不会覆盖历史。 +- 模型名从成功 Responses JSON 的顶层 `model` 或 SSE `response.created` / `response.in_progress` / `response.completed` 中的 `response.model` 提取;只读取白名单字段,有界处理分块和异常数据,响应内容仍原样转发。上游没有返回型号时保留请求证据,不推测实际模型。 +- 旧项目通过 `project.status` 读取 manifest 成功后补录一次:在有界范围内优先恢复项目内可明确识别的主模型运行记录,超出历史扫描预算时跳过对应候选;没有找到可信历史型号时写入当前配置快照并标注 `current-config-backfill`,不声称其为历史事实。官方配置只有目录标识时保留标识,模型名为空;以后真实回合仍继续追加。补录幂等,不覆盖或改写原项目历史。 +- 记录复用项目受控路径与追加锁;无写权限、损坏或超限时保留已有文件并写安全诊断,不因此中断项目打开、模型响应或触发额外付费重试。响应记录在阻塞工作线程中追加,文件锁等待不阻塞响应原始块转发。补录仅发生在项目 `project.status` 命令和本次回合入口,不扫描机器上的其它项目或私人会话。 +- 验收覆盖:请求与响应的模型差异、跨回合/项目隔离、切模型保留历史、旧项目补录及幂等、分块 SSE/JSON、不含模型/失败响应不伪造型号,以及凭据/正文不落盘。使用本地 HTTP fixture 验证代理透传和落盘,真实供应商调用另行报告。 + +| 合同 | 自动化证据入口 | +| --- | --- | +| 来源区分、切模型保留历史、补录幂等、损坏/超限保护 | `project::model_usage::tests` | +| JSON/SSE 字节透传、模型落盘与跨回合归属 | `agent::codex_provider_proxy::tests::model_usage_proxy_*` | +| 分块 UTF-8、换行、多行事件和有界解析 | `agent::codex_provider_proxy::model_usage::tests` | +| 文件锁争用不阻塞响应、流中断仍保留已确认型号 | Provider 代理的锁争用与流错误 fixture | + +记录格式版本为 `schemaVersion: 1`。`recordedAtMs` 是记录时间,`historicalModelConfirmed` 仅在响应观测或可信历史恢复时为 `true`;它在请求快照和当前配置补录时为 `false`。上述本地 fixture 不替代真实供应商或安装包验收。 + ## 2026-09-17 GameCreationApp 资源 kind:唯一词汇表、严格解析与 `app_log!` 留痕 本节覆盖 2026-09-15 节里关于「canonical 字符串列表 / legacy 别名表 / `tracing` 留痕 / ts-rs 生成路径」的表述;枚举成员集合、「不迁移、不静默转换」的总体口径不变。