调整 LLM 泥点后置扣费与执行状态展示

LLM Router 计费改为每开始 10000 token 扣 1 点且至少 1 点。

钱包在同一事务内按可消费余额扣光并记录赠送差额,成功响应不再因余额不足中断。

Responses 流式终态延后到结算后发送,泥点不足映射为稳定错误。

执行状态改为具体工具标题,详情默认单行折叠并增加扫过高亮动画。

补充计费公式、幂等扣费与状态展示回归测试。
This commit is contained in:
2026-09-02 14:17:27 +08:00
parent 700d7940a7
commit 69c11c3285
10 changed files with 540 additions and 83 deletions
@@ -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"
)
+16 -10
View File
@@ -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 '陶泥儿正在处理';
+7 -4
View File
@@ -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';
@@ -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)) ? (
<section
className="project-supervisor-process-card"
className={`project-supervisor-process-card${runtimePanelProps.controlBusy ? ' is-active' : ''}`}
aria-label="陶泥儿执行过程"
aria-live="polite"
aria-atomic="false"
@@ -166,7 +172,29 @@ export function ProjectSupervisorView({
<strong>{directActivity || '陶泥儿正在处理'}</strong>
</header>
{transientReply ? (
<p aria-label="陶泥儿实时回复">{transientReply}</p>
<div className="project-supervisor-process-detail">
<p
className={
processDetailExpanded ? 'is-expanded' : undefined
}
aria-label="陶泥儿正在执行的内容"
>
{transientReply}
</p>
{transientReply.includes('\n') ||
transientReply.length > 96 ? (
<button
type="button"
className="project-supervisor-process-toggle"
aria-expanded={processDetailExpanded}
onClick={() =>
setProcessDetailExpanded((expanded) => !expanded)
}
>
{processDetailExpanded ? '收起' : '展开'}
</button>
) : null}
</div>
) : null}
</section>
) : transientReply ? (
+58
View File
@@ -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 {
@@ -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` 的无限额度 TokenToken/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 环境隔离与测试门禁
+262 -52
View File
@@ -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<LlmTokenUsage> {
@@ -501,6 +539,60 @@ fn extract_llm_usage_from_value(value: &Value) -> Option<LlmTokenUsage> {
})
}
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<String>,
billing_result: Option<&Result<(), AppError>>,
request_id: &str,
owner_user_id: &str,
) -> Option<Bytes> {
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<Item = Result<Bytes, reqwest::Error>> + 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::<Event, Infallible>(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::<Event, Infallible>(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::<Event, Infallible>(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::<Event, Infallible>(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]
@@ -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");
+20
View File
@@ -3630,6 +3630,11 @@ fn parse_responses_sse_event(data: &str) -> Result<Option<ParsedStreamEvent>, 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();
@@ -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::<JsonValue>(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::<JsonValue>(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,