diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index bae479e11..2f40200da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -58,11 +58,14 @@ impl DirectGameCreatorTurnUpdateEmitter { matches!( activity, "request-accepted" - | "understanding" - | "project-inspection" - | "file-change" + | "preparing" + | "file-read" + | "file-write" + | "game-verify" + | "command-exec" | "controlled-tool" - | "validation" + | "web-search" + | "context-compaction" | "response-finalization" | "none" ) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7df62abd1..584a85994 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -258,18 +258,24 @@ function directCodexActivityText(activity: string | null | undefined) { switch (activity) { case 'request-accepted': return '已接收需求'; - case 'understanding': - return '正在理解需求'; - case 'project-inspection': - return '正在检查项目'; - case 'file-change': - return '正在修改项目文件'; + case 'preparing': + return '正在准备'; + case 'file-read': + return '读取文件'; + case 'file-write': + return '写入文件'; + case 'game-verify': + return '验证游戏'; + case 'command-exec': + return '执行命令'; case 'controlled-tool': - return '正在执行受控工具'; - case 'validation': - return '正在验证结果'; + return '执行工具'; + case 'web-search': + return '搜索资料'; + case 'context-compaction': + return '整理上下文'; case 'response-finalization': - return '正在整理回复'; + return '整理回复'; case 'none': default: return '陶泥儿正在处理'; diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 6b5794624..c48093ff8 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -921,11 +921,14 @@ export type GameCreatorDirectTurnUpdateStatus = export type GameCreatorDirectTurnActivity = | 'request-accepted' - | 'understanding' - | 'project-inspection' - | 'file-change' + | 'preparing' + | 'file-read' + | 'file-write' + | 'game-verify' + | 'command-exec' | 'controlled-tool' - | 'validation' + | 'web-search' + | 'context-compaction' | 'response-finalization' | 'none'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index dba7534dc..8b3746fe5 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -7,6 +7,7 @@ import type { SetStateAction, UIEventHandler, } from 'react'; +import { useEffect, useState } from 'react'; import type { AgentStatusCard, @@ -103,6 +104,11 @@ export function ProjectSupervisorView({ onMakeGameFromApprovedGdd, ...runtimePanelProps }: ProjectSupervisorViewProps) { + const [processDetailExpanded, setProcessDetailExpanded] = useState(false); + useEffect(() => { + setProcessDetailExpanded(false); + }, [directActivity, transientReply]); + const submitLabel = needsUserInput ? '等待回答' : runtimePanelProps.controlBusy @@ -154,7 +160,7 @@ export function ProjectSupervisorView({ {directCodex && (runtimePanelProps.controlBusy || Boolean(transientReply)) ? (
{directActivity || '陶泥儿正在处理'} {transientReply ? ( -

{transientReply}

+
+

+ {transientReply} +

+ {transientReply.includes('\n') || + transientReply.length > 96 ? ( + + ) : null} +
) : null}
) : transientReply ? ( diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 250193272..4ff66385e 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -2358,6 +2358,8 @@ textarea { } .project-supervisor-process-card { + position: relative; + isolation: isolate; display: grid; gap: 9px; margin-top: 12px; @@ -2366,6 +2368,28 @@ textarea { border-radius: 8px; background: #f1f8f3; color: #1f3d2a; + overflow: hidden; +} + +.project-supervisor-process-card::after { + content: ''; + position: absolute; + z-index: -1; + inset: 0 auto 0 -35%; + width: 28%; + background: linear-gradient(90deg, transparent, rgb(255 255 255 / 28%), transparent); + pointer-events: none; + opacity: 0; +} + +.project-supervisor-process-card.is-active::after { + opacity: 1; + animation: project-supervisor-process-sweep 1.8s ease-in-out infinite; +} + +@keyframes project-supervisor-process-sweep { + from { transform: translateX(0); } + to { transform: translateX(520%); } } .project-supervisor-process-card > header { @@ -2395,6 +2419,10 @@ textarea { .project-supervisor-process-card > header > span { animation: none; } + + .project-supervisor-process-card.is-active::after { + animation: none; + } } .project-supervisor-process-card p { @@ -2402,8 +2430,38 @@ textarea { color: #263142; font-size: 13px; line-height: 1.55; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + max-height: 1.55em; white-space: pre-wrap; overflow-wrap: anywhere; + overflow: hidden; +} + +.project-supervisor-process-card p.is-expanded { + display: block; + max-height: 180px; + overflow: auto; +} + +.project-supervisor-process-detail { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 8px; +} + +.project-supervisor-process-toggle { + align-self: start; + padding: 0; + border: 0; + background: transparent; + color: #2f6f4b; + font: inherit; + font-size: 12px; + cursor: pointer; + white-space: nowrap; } .game-workbench-chat .project-supervisor-process-card { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6fd963eb7..979e58232 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7891,9 +7891,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-31 LLM Router 独立账号与后置扣费修订 -- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置 `group=taonier`,登录、创建无限额度 token 并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息保存到 `llm_router_account.account_json`,并在 `external_api_key` 中保存正式 `purpose=llm-router` 行与 `credential_version`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 +- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `default` 分组;发现旧 Token 为其它分组时先更新为 `default`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息保存到 `llm_router_account.account_json`,并在 `external_api_key` 中保存正式 `purpose=llm-router` 行与 `credential_version`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 - 该账号 provisioning 使用持久 saga 状态:远端注册、登录、token 或 Key 签发结果不确定时进入 `unknown` / `reconciliation_required`,禁止重复注册;远端 Key 已确定签发但本地 `external_api_key` 写入失败时保持 `key_issued`,后续使用确定 key id 重试落库。Router 确定返回 401/403 时撤销当前 Key 并把账号状态置为 `retryable`,复用已保存的账号密码重新签发替代 Key。 -- AGC 调用固定为客户端 access token -> api-server -> Router。Router 成功返回后才扣泥点,按临时规则每开始 1000 token 扣 1 点且至少 1 点;ledger id 由 `Idempotency-Key`(缺省请求 ID)哈希得到,Router 失败不扣费。扣费失败只记录日志,不影响已成功的 LLM 响应;该计费规则是过渡实现,待产品定价确认后替换,不能视为 Router 真实成本结算。 +- AGC 调用固定为客户端 access token -> api-server -> Router。Router 成功返回后再扣泥点,按临时规则每开始 10,000 token 扣 1 点且至少 1 点;ledger id 由 `Idempotency-Key`(缺省请求 ID)哈希得到,Router 失败不扣费。余额足够时全额扣除;余额不足时按当前可消费余额扣光,差额记为赠送并继续返回已成功的 LLM 响应。该计费规则是过渡实现,待产品定价确认后替换,不能视为 Router 真实成本结算。 - AGC 状态面收口:Tauri `check_game_creator_llm_config`、`/llm-status` 与 `/llm-routes` 只返回账号凭据状态、官方路由锁定状态和运行参数;不序列化 Router 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。 ## 2026-09-01 LLM Router provisioning 环境隔离与测试门禁 diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 03849b45f..7550efcc2 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -15,6 +15,7 @@ use sha2::{Digest, Sha256}; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, }; +use spacetime_client::SpacetimeClientError; use std::convert::Infallible; #[cfg(test)] @@ -122,6 +123,9 @@ pub async fn proxy_llm_chat_completions( ) .await { + if is_mud_points_insufficient_app_error(&error) { + return Err(llm_error_response(&request_context, error)); + } tracing::error!( request_id = request_context.request_id(), user_id = %authenticated.claims().user_id(), @@ -293,6 +297,9 @@ pub async fn proxy_llm_responses( ) .await { + if is_mud_points_insufficient_app_error(&error) { + return Err(llm_error_response(&request_context, error)); + } tracing::error!( request_id = request_context.request_id(), user_id = %authenticated.claims().user_id(), @@ -406,15 +413,48 @@ fn llm_router_ledger_id(owner_user_id: &str, idempotency_key: &str) -> String { fn llm_router_points_for_usage(usage: Option<&LlmTokenUsage>) -> u64 { let total_tokens = usage.map(|value| value.total_tokens).unwrap_or(0); // Temporary product default until Router pricing is wired to the account - // service: one mud point per started 1,000 tokens, with a one-point minimum - // for a successful response whose gateway omitted usage. + // service: one mud point per started 10,000 tokens, with a one-point minimum + // for a successful response whose gateway omitted usage. Keep the unit in + // one constant so the product can tune it without changing the ledger + // semantics or idempotency contract. + const LLM_ROUTER_BILLING_TOKEN_UNIT: u64 = 10_000; total_tokens - .saturating_add(999) - .checked_div(1000) + .saturating_add(LLM_ROUTER_BILLING_TOKEN_UNIT - 1) + .checked_div(LLM_ROUTER_BILLING_TOKEN_UNIT) .unwrap_or(1) .max(1) } +fn is_insufficient_mud_points_error(error: &SpacetimeClientError) -> bool { + match error { + SpacetimeClientError::Procedure(message) + | SpacetimeClientError::Runtime(message) + | SpacetimeClientError::Build(message) => { + message.contains("泥点余额不足") || message.contains("可消费泥点不足:") + } + SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => false, + } +} + +fn map_llm_router_billing_error(error: SpacetimeClientError) -> AppError { + if is_insufficient_mud_points_error(&error) { + return AppError::from_status(StatusCode::CONFLICT) + .with_code("MUD_POINTS_INSUFFICIENT") + .with_message("泥点余额不足") + .with_details(json!({ + "reason": "insufficient-mud-points", + })); + } + + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_code("LLM_BILLING_FAILED") + .with_message("LLM 已返回,但泥点扣费未完成") +} + +fn is_mud_points_insufficient_app_error(error: &AppError) -> bool { + error.code() == "MUD_POINTS_INSUFFICIENT" +} + async fn settle_llm_router_usage( state: &AppState, owner_user_id: &str, @@ -446,10 +486,12 @@ async fn settle_llm_router_usage( }); let metadata = json!({ "operation": "llm-router", + "billingMode": "llm-router-best-effort", + "requestedPoints": points, "requestId": request_id, "idempotencyKey": idempotency_key, "usage": usage_json, - "billingRule": "temporary-1-point-per-started-1000-tokens", + "billingRule": "temporary-1-point-per-started-10000-tokens", }); state .spacetime_client() @@ -462,11 +504,7 @@ async fn settle_llm_router_usage( ) .await .map(|_| ()) - .map_err(|error| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(format!( - "LLM 已返回,但泥点扣费未完成,请稍后按相同请求重试:{error}" - )) - }) + .map_err(map_llm_router_billing_error) } fn extract_llm_usage_from_json_bytes(body: &[u8]) -> Option { @@ -501,6 +539,60 @@ fn extract_llm_usage_from_value(value: &Value) -> Option { }) } +fn llm_router_insufficient_mud_points_sse_event() -> Bytes { + let error = json!({ + "code": "insufficient_mud_points", + "message": "泥点余额不足", + }); + let payload = json!({ + "type": "response.failed", + "error": error, + "response": { + "status": "failed", + "error": { + "code": "insufficient_mud_points", + "message": "泥点余额不足", + }, + }, + }); + Bytes::from(format!("event: response.failed\ndata: {}\n\n", payload)) +} + +fn llm_router_done_sse_event() -> Bytes { + Bytes::from_static(b"data: [DONE]\n\n") +} + +fn is_responses_done_sse_event(event: &str) -> bool { + event + .lines() + .any(|line| matches!(line.trim(), "data: [DONE]" | "[DONE]")) +} + +fn finalize_llm_router_terminal_event( + terminal_event: &mut Option, + billing_result: Option<&Result<(), AppError>>, + request_id: &str, + owner_user_id: &str, +) -> Option { + let billing_result = billing_result?; + match billing_result { + Ok(()) => terminal_event.take().map(Bytes::from), + Err(error) if is_mud_points_insufficient_app_error(error) => { + terminal_event.take(); + Some(llm_router_insufficient_mud_points_sse_event()) + } + Err(error) => { + tracing::error!( + request_id = %request_id, + user_id = %owner_user_id, + error = %error, + "LLM Router 流式响应已完成但泥点扣费未完成" + ); + terminal_event.take().map(Bytes::from) + } + } +} + fn stream_responses_with_billing( mut upstream: impl futures_util::Stream> + Unpin, state: AppState, @@ -511,14 +603,19 @@ fn stream_responses_with_billing( async_stream::stream! { let mut pending = String::new(); let mut usage = None; + let mut terminal_event = None; let mut completed_seen = false; - let mut billed = false; + let mut billing_result = None; + let mut done_seen = false; + let mut pending_done_event = None; + while let Some(chunk) = upstream.next().await { match chunk { Ok(bytes) => { pending.push_str(String::from_utf8_lossy(bytes.as_ref()).as_ref()); pending = pending.replace("\r\n", "\n"); while let Some(separator) = pending.find("\n\n") { + let raw_event = pending[..separator + 2].to_string(); let event = pending[..separator].to_string(); pending.drain(..separator + 2); if let Some(event_usage) = extract_llm_usage_from_sse_event(&event) { @@ -526,35 +623,57 @@ fn stream_responses_with_billing( } if is_responses_terminal_sse_event(&event) { completed_seen = true; - if !billed { - if let Err(error) = settle_llm_router_usage( + terminal_event = Some(raw_event); + continue; + } + if is_responses_done_sse_event(&event) { + done_seen = true; + if completed_seen && billing_result.is_none() { + billing_result = Some(settle_llm_router_usage( &state, owner_user_id.as_str(), request_id.as_str(), idempotency_key.clone(), usage.as_ref(), - ).await { - tracing::error!(request_id = %request_id, user_id = %owner_user_id, error = %error, "LLM Router 流式响应已完成但泥点扣费失败"); - } - billed = true; + ).await); } + if terminal_event.is_some() { + if let Some(final_event) = finalize_llm_router_terminal_event( + &mut terminal_event, + billing_result.as_ref(), + request_id.as_str(), + owner_user_id.as_str(), + ) { + yield Ok(final_event); + } + } + yield Ok(Bytes::from(raw_event)); + continue; } + yield Ok(Bytes::from(raw_event)); } - // Preserve the Router SSE byte stream. Completion is - // detected from parsed events, never by delaying or - // rewriting the provider's [DONE] marker. - yield Ok(bytes); } Err(error) => { - if completed_seen && !billed { - if let Err(billing_error) = settle_llm_router_usage( + if completed_seen && billing_result.is_none() { + billing_result = Some(settle_llm_router_usage( &state, owner_user_id.as_str(), request_id.as_str(), idempotency_key.clone(), usage.as_ref(), - ).await { - tracing::error!(request_id = %request_id, user_id = %owner_user_id, error = %billing_error, "LLM Router 流式响应已完成但泥点扣费失败"); + ).await); + } + if terminal_event.is_some() { + if let Some(final_event) = finalize_llm_router_terminal_event( + &mut terminal_event, + billing_result.as_ref(), + request_id.as_str(), + owner_user_id.as_str(), + ) { + yield Ok(final_event); + if !done_seen { + yield Ok(llm_router_done_sse_event()); + } } } yield Err(std::io::Error::other(format!("LLM Router 响应流读取失败:{error}"))); @@ -562,23 +681,32 @@ fn stream_responses_with_billing( } } } + pending = pending.replace("\r\n", "\n"); if !pending.trim().is_empty() { - if let Some(event_usage) = extract_llm_usage_from_sse_event(pending.trim()) { + let event = pending.trim().to_string(); + if let Some(event_usage) = extract_llm_usage_from_sse_event(&event) { usage = Some(event_usage); } - completed_seen |= is_responses_terminal_sse_event(pending.trim()); + if is_responses_terminal_sse_event(&event) { + completed_seen = true; + terminal_event = Some(pending.clone()); + } else if is_responses_done_sse_event(&event) { + done_seen = true; + pending_done_event = Some(pending.clone()); + } else { + yield Ok(Bytes::from(pending.clone())); + } } - if completed_seen && !billed { - if let Err(error) = settle_llm_router_usage( + + if completed_seen && billing_result.is_none() { + billing_result = Some(settle_llm_router_usage( &state, owner_user_id.as_str(), request_id.as_str(), - idempotency_key.clone(), + idempotency_key, usage.as_ref(), - ).await { - tracing::error!(request_id = %request_id, user_id = %owner_user_id, error = %error, "LLM Router 流式响应已完成但泥点扣费失败"); - } + ).await); } else if !completed_seen { tracing::warn!( request_id = %request_id, @@ -586,6 +714,21 @@ fn stream_responses_with_billing( "LLM Router 流式响应未收到 response.completed/incomplete,跳过泥点扣费" ); } + if terminal_event.is_some() { + if let Some(final_event) = finalize_llm_router_terminal_event( + &mut terminal_event, + billing_result.as_ref(), + request_id.as_str(), + owner_user_id.as_str(), + ) { + yield Ok(final_event); + } + } + if let Some(done_event) = pending_done_event { + yield Ok(Bytes::from(done_event)); + } else if completed_seen && !done_seen { + yield Ok(llm_router_done_sse_event()); + } } } @@ -809,22 +952,42 @@ fn stream_llm_chat_completions( ) .await { - tracing::error!( - request_id = %request_id, - user_id = %owner_user_id, - error = %error, - "LLM Router Chat 流式响应已完成但泥点扣费未完成" - ); + if is_mud_points_insufficient_app_error(&error) { + yield Ok::(llm_sse_json_event_or_error( + "error", + json!({ + "code": "MUD_POINTS_INSUFFICIENT", + "message": "泥点余额不足", + }), + )); + } else { + tracing::error!( + request_id = %request_id, + user_id = %owner_user_id, + error = %error, + "LLM Router Chat 流式响应已完成但泥点扣费未完成" + ); + yield Ok::(llm_sse_json_event_or_error( + "complete", + json!(LlmChatCompletionResponse { + id: response.response_id, + model: response.model, + content: response.text, + finish_reason: response.finish_reason, + }), + )); + } + } else { + yield Ok::(llm_sse_json_event_or_error( + "complete", + json!(LlmChatCompletionResponse { + id: response.response_id, + model: response.model, + content: response.text, + finish_reason: response.finish_reason, + }), + )); } - yield Ok::(llm_sse_json_event_or_error( - "complete", - json!(LlmChatCompletionResponse { - id: response.response_id, - model: response.model, - content: response.text, - finish_reason: response.finish_reason, - }), - )); } Err(error) => { revoke_llm_router_key_after_auth_failure( @@ -1232,7 +1395,7 @@ mod tests { } #[test] - fn llm_router_billing_rounds_started_thousand_tokens_and_has_minimum() { + fn llm_router_billing_rounds_started_ten_thousand_tokens_and_has_minimum() { assert_eq!(llm_router_points_for_usage(None), 1); assert_eq!( llm_router_points_for_usage(Some(&LlmTokenUsage { @@ -1246,18 +1409,65 @@ mod tests { llm_router_points_for_usage(Some(&LlmTokenUsage { prompt_tokens: 900, completion_tokens: 100, - total_tokens: 1_000, + total_tokens: 10_000, })), 1 ); assert_eq!( llm_router_points_for_usage(Some(&LlmTokenUsage { - prompt_tokens: 1_001, + prompt_tokens: 10_001, completion_tokens: 0, - total_tokens: 1_001, + total_tokens: 10_001, })), 2 ); + assert_eq!( + llm_router_points_for_usage(Some(&LlmTokenUsage { + prompt_tokens: 30_001, + completion_tokens: 0, + total_tokens: 30_001, + })), + 4 + ); + } + + #[test] + fn llm_router_billing_insufficient_balance_has_stable_public_error() { + let error = map_llm_router_billing_error(SpacetimeClientError::Procedure( + "可消费泥点不足:需要 31,扣除退款占用后可用 11".to_string(), + )); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + assert_eq!(error.code(), "MUD_POINTS_INSUFFICIENT"); + assert_eq!(error.message(), "泥点余额不足"); + assert_eq!( + error + .details() + .and_then(|details| details.get("reason")) + .and_then(Value::as_str), + Some("insufficient-mud-points") + ); + } + + #[test] + fn llm_router_stream_terminal_is_replaced_only_for_insufficient_balance() { + let mut terminal = Some( + "event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n".to_string(), + ); + let billing = Err(map_llm_router_billing_error( + SpacetimeClientError::Procedure("泥点余额不足".to_string()), + )); + let event = finalize_llm_router_terminal_event( + &mut terminal, + Some(&billing), + "request-1", + "user-1", + ) + .expect("insufficient balance should emit a failure event"); + let event_text = String::from_utf8(event.to_vec()).expect("failure event should be utf8"); + assert!(event_text.contains("response.failed")); + assert!(event_text.contains("insufficient_mud_points")); + assert!(!event_text.contains("response.completed")); + assert!(terminal.is_none()); } #[test] diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index e956bf723..5a855f99a 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -2124,6 +2124,21 @@ pub fn validate_runtime_profile_wallet_debit_availability( Ok(spendable_points.saturating_sub(debit_points)) } +/// LLM Router settles after a successful upstream response. It may consume +/// only the currently spendable portion of the wallet; any remainder of the +/// requested amount is intentionally waived by the product policy. +pub fn calculate_runtime_profile_wallet_best_effort_debit( + requested_points: i64, + wallet_total_points: u64, + active_held_points: u64, +) -> i64 { + requested_points.max(0).min( + wallet_total_points + .saturating_sub(active_held_points) + .min(i64::MAX as u64) as i64, + ) +} + pub fn build_runtime_profile_recharge_refund_settlement_plan( current_successful_refund_count: u32, current_cumulative_success_refund_cents: u64, @@ -2587,6 +2602,26 @@ fn parse_optional_json_value( mod tests { use super::*; + #[test] + fn llm_router_best_effort_debit_only_consumes_current_spendable_balance() { + assert_eq!( + calculate_runtime_profile_wallet_best_effort_debit(31, 100, 20), + 31 + ); + assert_eq!( + calculate_runtime_profile_wallet_best_effort_debit(31, 11, 0), + 11 + ); + assert_eq!( + calculate_runtime_profile_wallet_best_effort_debit(31, 31, 20), + 11 + ); + assert_eq!( + calculate_runtime_profile_wallet_best_effort_debit(31, 20, 20), + 0 + ); + } + #[test] fn feature_gate_denies_anonymous_when_enabled() { let gate = test_gate("creation-entry:puzzle"); diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index e1ca4e30e..38bea0e8b 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -3630,6 +3630,11 @@ fn parse_responses_sse_event(data: &str) -> Result, Ll .get("error") .and_then(|error| error.get("message")) .and_then(serde_json::Value::as_str) + .or_else(|| { + parsed + .pointer("/response/error/message") + .and_then(serde_json::Value::as_str) + }) .or_else(|| parsed.get("message").and_then(serde_json::Value::as_str)) .unwrap_or("LLM Responses SSE 返回失败事件") .to_string(); @@ -4740,6 +4745,21 @@ mod tests { assert_eq!(events[2].finish_reason.as_deref(), Some("completed")); } + #[test] + fn responses_sse_parser_reads_failure_message_from_response_error() { + let error = parse_responses_sse_event( + r#"{"type":"response.failed","response":{"status":"failed","error":{"code":"insufficient_mud_points","message":"泥点余额不足"}}}"#, + ) + .expect_err("response.failed should become an upstream error"); + assert_eq!( + error, + LlmError::Upstream { + status_code: 502, + message: "泥点余额不足".to_string(), + } + ); + } + #[test] fn decode_utf8_stream_chunk_preserves_incomplete_multibyte_suffix() { let full_bytes = "你好".as_bytes(); diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 4fd3e8818..1a5196824 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9529,8 +9529,58 @@ fn apply_profile_wallet_adjustment( ) .map_err(|error| error.to_string())?; let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); - let unsigned_delta = convert_runtime_profile_wallet_unsigned_delta(validated_input.amount) - .map_err(|error| error.to_string())?; + let requested_unsigned_delta = + convert_runtime_profile_wallet_unsigned_delta(validated_input.amount) + .map_err(|error| error.to_string())?; + let best_effort_llm_router = consume + && source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationConsume + && metadata_json_is_llm_router_best_effort(&validated_input.metadata_json); + + // LLM Router billing is deliberately best effort after the upstream + // response has completed: the request is charged up to the user's current + // spendable balance and any remainder is waived. Reusing the same ledger + // id must stay idempotent even when the originally requested amount was + // larger than the amount that could actually be charged, so resolve an + // existing best-effort ledger before calculating the current debit. + if best_effort_llm_router { + if let Some(existing) = ctx + .db + .profile_wallet_ledger() + .wallet_ledger_id() + .find(&validated_input.ledger_id.to_string()) + { + if existing.user_id != validated_input.user_id + || existing.source_type + != RuntimeProfileWalletLedgerSourceType::AssetOperationConsume + { + return Err("LLM Router 钱包幂等流水身份不匹配".to_string()); + } + return get_profile_dashboard_snapshot( + ctx, + RuntimeProfileDashboardGetInput { + user_id: validated_input.user_id, + }, + ); + } + } + + let unsigned_delta = if best_effort_llm_router { + refresh_profile_wallet_expiring_points(ctx, &validated_input.user_id, ctx.timestamp); + let current = ctx + .db + .profile_dashboard_state() + .user_id() + .find(&validated_input.user_id.to_string()); + let wallet_total = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); + let held_points = active_profile_recharge_refund_hold_points(ctx, &validated_input.user_id); + calculate_runtime_profile_wallet_best_effort_debit( + requested_unsigned_delta, + wallet_total, + held_points, + ) + } else { + requested_unsigned_delta + }; if consume { if !validated_input .ledger_id @@ -9538,12 +9588,14 @@ fn apply_profile_wallet_adjustment( { return Err("资产操作扣费流水缺少合法前缀".to_string()); } - require_asset_operation_consume_unsettled( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - )?; + if unsigned_delta > 0 { + require_asset_operation_consume_unsettled( + ctx, + &validated_input.user_id, + unsigned_delta, + &validated_input.ledger_id, + )?; + } } else { if !validated_input .ledger_id @@ -9596,6 +9648,11 @@ fn apply_profile_wallet_adjustment( unsigned_delta }; + let settlement_metadata_json = if best_effort_llm_router { + metadata_json_with_llm_router_charge_summary(&validated_input.metadata_json, unsigned_delta) + } else { + validated_input.metadata_json.clone() + }; apply_profile_wallet_signed_delta( ctx, &validated_input.user_id, @@ -9604,7 +9661,7 @@ fn apply_profile_wallet_adjustment( &validated_input.ledger_id, created_at, true, - &validated_input.metadata_json, + &settlement_metadata_json, )?; if !consume { record_asset_operation_wallet_settlement( @@ -9623,6 +9680,43 @@ fn apply_profile_wallet_adjustment( ) } +fn metadata_json_is_llm_router_best_effort(metadata_json: &str) -> bool { + serde_json::from_str::(metadata_json) + .ok() + .and_then(|metadata| { + metadata + .get("billingMode") + .and_then(JsonValue::as_str) + .map(|mode| mode == "llm-router-best-effort") + }) + .unwrap_or(false) +} + +fn metadata_json_with_llm_router_charge_summary( + metadata_json: &str, + charged_points: i64, +) -> String { + let Ok(mut metadata) = serde_json::from_str::(metadata_json) else { + return metadata_json.to_string(); + }; + let Some(object) = metadata.as_object_mut() else { + return metadata_json.to_string(); + }; + let requested_points = object + .get("requestedPoints") + .and_then(JsonValue::as_u64) + .unwrap_or(charged_points.max(0) as u64); + object.insert( + "chargedPoints".to_string(), + JsonValue::from(charged_points.max(0)), + ); + object.insert( + "waivedPoints".to_string(), + JsonValue::from(requested_points.saturating_sub(charged_points.max(0) as u64)), + ); + serde_json::to_string(&metadata).unwrap_or_else(|_| metadata_json.to_string()) +} + pub(crate) fn settle_external_generation_attempt_refund( ctx: &ReducerContext, job_id: &str,