调整 LLM 泥点后置扣费与执行状态展示
LLM Router 计费改为每开始 10000 token 扣 1 点且至少 1 点。 钱包在同一事务内按可消费余额扣光并记录赠送差额,成功响应不再因余额不足中断。 Responses 流式终态延后到结算后发送,泥点不足映射为稳定错误。 执行状态改为具体工具标题,详情默认单行折叠并增加扫过高亮动画。 补充计费公式、幂等扣费与状态展示回归测试。
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 '陶泥儿正在处理';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+30
-2
@@ -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 ? (
|
||||
|
||||
@@ -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` 的无限额度 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 环境隔离与测试门禁
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user