AGC 官方 LLM Router 账号链路与流式联网输出 #242
+4
-5
@@ -8,11 +8,10 @@ LLM_BASE_URL="https://api.vectorengine.cn/v1"
|
||||
# but it should not be relied on by browser code.
|
||||
LLM_API_KEY=""
|
||||
|
||||
# Temporary server-private AGC Router fallback until Router provisioning exists.
|
||||
# Never expose this value to AGC clients or browser code.
|
||||
GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY=""
|
||||
GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY_FILE=""
|
||||
GENARRATIVE_AGC_ROUTER_FALLBACK_ACCOUNT_ID="server-fallback"
|
||||
# Router account provisioning secret (server-side only). Prefer the protected
|
||||
# file form in production; never expose either value to clients or commit it.
|
||||
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET=""
|
||||
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=""
|
||||
|
||||
# Optional frontend override for the local proxy path.
|
||||
VITE_LLM_PROXY_BASE_URL="/api/llm"
|
||||
|
||||
@@ -3374,11 +3374,10 @@ pub(crate) fn scrub_locked_game_creator_config_file(config: &mut GameCreatorAppC
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), String> {
|
||||
if !path.is_file() {
|
||||
if !validate_game_creator_config_file_entry(path)? {
|
||||
return Ok(());
|
||||
}
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|error| format!("读取客户端配置失败:{}: {error}", path.display()))?;
|
||||
let content = read_game_creator_private_file_to_string(path, "客户端配置文件", 256 * 1024)?;
|
||||
let mut config = serde_json::from_str::<GameCreatorAppConfigFile>(&content)
|
||||
.map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?;
|
||||
let mut changed = false;
|
||||
|
||||
@@ -519,6 +519,7 @@ async fn chat_with_game_creator_role_agent_stream_falls_back_once_before_first_d
|
||||
"baseUrl": {base_url:?},
|
||||
"model": "art-chat-model",
|
||||
"apiKind": "openai_chat",
|
||||
"webSearchEnabled": false,
|
||||
"maxRetries": 0
|
||||
}}
|
||||
}}
|
||||
|
||||
Vendored
+1
@@ -84,6 +84,7 @@ GENARRATIVE_LLM_API_KEY=
|
||||
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
|
||||
# LLM Router 正式账号链路:production 固定使用官方地址/模型;管理员 Token 只读受保护文件。
|
||||
GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1
|
||||
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=/etc/genarrative/secrets/llm-router-provisioning.secret
|
||||
GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE=/etc/genarrative/secrets/llm-router-api-key-encryption.secret
|
||||
GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE=/etc/genarrative/secrets/llm-router-admin.token
|
||||
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn
|
||||
|
||||
@@ -1373,3 +1373,4 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`;
|
||||
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
|
||||
- 说明:视觉小说公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。
|
||||
- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。
|
||||
> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 热路径只读取本地已完成的账号密钥,控制面不可用时请求失败关闭。provisioning secret 仅从部署侧受保护环境变量或 secret file 读取,不再内置源码常量。
|
||||
|
||||
@@ -215,7 +215,7 @@ SpacetimeDB procedure:
|
||||
|
||||
### LLM Router 账号 Key 与 Router 代理
|
||||
|
||||
普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;每次密码、手机号或微信认证成功后,api-server 都必须幂等准备该用户对应的 Router 账号和 API Key,只有准备成功后才发放登录会话,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与固定版本 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是账号认证成功、显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为:
|
||||
普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;LLM 请求解析阶段仍要求本地存在合法的已完成 provisioning 凭据,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为:
|
||||
|
||||
```text
|
||||
AGC loopback Provider Proxy(Bearer=平台 access token)
|
||||
@@ -224,7 +224,7 @@ AGC loopback Provider Proxy(Bearer=平台 access token)
|
||||
-> POST https://router.genarrative.world/v1/responses(Bearer=<router-api-key>)
|
||||
```
|
||||
|
||||
`/api/llm/responses` 强制覆盖 `model=gpt-5.6-sol`,透传 Responses JSON/SSE 响应;Router 成功返回后按 usage 以临时规则“每开始 1000 token 扣 1 泥点、至少 1 点”写入钱包流水,ledger id 由幂等键(缺省为请求 ID)稳定派生,Router 失败不扣费。Router 明确返回 `401/403` 时,服务端将该用途 Key 标记撤销;网络超时或 provisioning 未取得确定响应时不自动重试。客户端不保存 Router Key,也不把它放进 argv、环境变量、manifest、trace、聊天或普通 IPC。AGC 的运行状态接口与 `/llm-status`、`/llm-routes` 只返回登录/账号凭据状态、官方路由锁定状态和运行参数,不返回 Router 地址、模型、协议名称或任何凭据字段。
|
||||
`/api/llm/responses` 强制覆盖 `model=gpt-5.6-sol`,透传 Responses JSON/SSE 响应;Router 成功返回后按 usage 以临时规则“每开始 10,000 token 扣 1 泥点、至少 1 点”写入钱包流水,ledger id 由端点、客户端幂等键和请求指纹稳定派生,Router 失败不扣费。Router 明确返回 `401/403` 时,服务端将该用途 Key 标记撤销;网络超时或 provisioning 未取得确定响应时不自动重试。客户端不保存 Router Key,也不把它放进 argv、环境变量、manifest、trace、聊天或普通 IPC。AGC 的运行状态接口与 `/llm-status`、`/llm-routes` 只返回登录/账号凭据状态、官方路由锁定状态和运行参数,不返回 Router 地址、模型、协议名称或任何凭据字段。
|
||||
|
||||
Windows 私有路径由同一套正式准备入口复用:AGC 自有 AppData、凭据目录和 `.agent` 运行态使用 managed 范围;原生文件选择器明确选中的项目根/文件使用 user-selected 范围。两类入口在发现 owner/DACL 权限不足时均允许一次性 UAC helper;helper 只接管严格复核后的普通文件/目录,并写入当前 TokenUser owner、禁止继承且仅含当前用户 ACE 的 DACL。reparse/symlink、非普通对象、祖先类型冲突和未经过正式选择/项目根入口的路径仍失败关闭。
|
||||
|
||||
@@ -291,3 +291,4 @@ docs/openapi/genarrative-external-v1.openapi.json
|
||||
- 外部项目接口覆盖当前已有项目管理操作:项目列表、最近项目、创建、读取、重命名、删除和默认画布保存。
|
||||
- 外部素材生成接口覆盖当前已有编辑器素材操作:生图、重绘 / 调整、规范图生成、宣发素材生成、图标素材生成与拆分、UI 设计图生成与拆分、角色动画、视频、音效和背景音乐。
|
||||
- 修改 SpacetimeDB schema 后运行 `npm run spacetime:generate` 与 `npm run check:spacetime-schema`。
|
||||
> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 请求只使用本地已完成的账号密钥。LLM Router 计费按每开始 10,000 token 至少 1 点结算,客户端幂等键同时绑定端点与规范化请求指纹,禁止跨请求复用账本。账号密码派生根仅从部署侧受保护 secret/file 读取。
|
||||
|
||||
@@ -186,6 +186,8 @@ pub struct AppConfig {
|
||||
/// LLM Router 账号级代理固定配置;普通客户端不能覆盖这些值。
|
||||
pub llm_router_base_url: String,
|
||||
pub llm_router_model: String,
|
||||
/// Router 用户密码派生根,仅从部署侧 secret/env/file 读取。
|
||||
pub llm_router_provisioning_secret: Option<String>,
|
||||
pub llm_router_api_key_encryption_secret: Option<String>,
|
||||
pub llm_router_admin_token: Option<String>,
|
||||
pub dashscope_base_url: String,
|
||||
@@ -492,6 +494,7 @@ impl Default for AppConfig {
|
||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||
llm_router_base_url: OFFICIAL_LLM_ROUTER_BASE_URL.to_string(),
|
||||
llm_router_model: OFFICIAL_LLM_ROUTER_MODEL.to_string(),
|
||||
llm_router_provisioning_secret: None,
|
||||
llm_router_api_key_encryption_secret: None,
|
||||
llm_router_admin_token: None,
|
||||
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
|
||||
@@ -1209,6 +1212,10 @@ impl AppConfig {
|
||||
config.llm_retry_backoff_ms = llm_retry_backoff_ms;
|
||||
}
|
||||
|
||||
config.llm_router_provisioning_secret = read_secret_env_or_file(
|
||||
&["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET"],
|
||||
&["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE"],
|
||||
);
|
||||
config.llm_router_api_key_encryption_secret = read_secret_env_or_file(
|
||||
&["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET"],
|
||||
&["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE"],
|
||||
|
||||
@@ -56,11 +56,6 @@ const EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR: &str = "external-editor";
|
||||
const EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER: &str = "llm-router";
|
||||
const LLM_ROUTER_RECONCILIATION_ERROR_PREFIX: &str = "llm-router-reconciliation-required:";
|
||||
const LLM_ROUTER_PROVISIONING_CREDENTIAL_VERSION: u32 = 1;
|
||||
// 临时方案:公共 Router、各部署数据库独立时,所有能操作同一 Router 的部署必须
|
||||
// 使用同一派生根。后续迁移到部署密钥管理时先兼容该 v1 派生根;若更换 secret,
|
||||
// 必须递增版本并完成账号迁移,不能静默造成已有账号密码漂移。
|
||||
const LLM_ROUTER_PROVISIONING_SECRET_V1: &[u8] =
|
||||
b"Jq67uCuRN1fs1l08e9ayImp4Uzc4TAY1IaP0UmHz5qTM7pQnEgwzFYhF_SPmK1Bm";
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
@@ -347,7 +342,11 @@ pub(crate) async fn ensure_llm_router_account(
|
||||
// safely replaced with the current deterministic values. Unknown remote
|
||||
// outcomes with a known account id remain reconciliation-blocked above.
|
||||
let expected_username = router_username_for_owner(owner_user_id);
|
||||
let expected_password = generate_router_account_password(owner_user_id)?;
|
||||
let provisioning_secret = llm_router_provisioning_secret(state)?;
|
||||
let expected_password = generate_router_account_password_with_secret(
|
||||
owner_user_id,
|
||||
provisioning_secret.as_bytes(),
|
||||
)?;
|
||||
let stale_pending_credentials = persisted.as_ref().is_some_and(|account| {
|
||||
account.router_account_id.is_none()
|
||||
&& bundle.as_ref().is_some_and(|value| {
|
||||
@@ -1066,11 +1065,10 @@ fn router_user_id_from_record(record: &ExternalApiKeyRecord) -> Option<i64> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs the idempotent Router-account preparation after every successful
|
||||
/// authentication. Callers must complete this step before issuing a session
|
||||
/// token, so a successfully authenticated user always has a persisted
|
||||
/// `llm-router` account key. This is the mandatory account provisioning step
|
||||
/// for authenticated users.
|
||||
/// Starts best-effort Router-account preparation after successful
|
||||
/// authentication. Router control-plane availability must not gate the main
|
||||
/// site session; the LLM hot path validates the locally persisted credential
|
||||
/// when a request is actually made.
|
||||
pub(crate) async fn ensure_llm_router_account_after_auth_success(
|
||||
state: &AppState,
|
||||
_request_context: &RequestContext,
|
||||
@@ -1084,9 +1082,33 @@ pub(crate) async fn ensure_llm_router_account_after_auth_success(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ensure_llm_router_account(state, owner_user_id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
#[cfg(test)]
|
||||
{
|
||||
if let Err(error) = ensure_llm_router_account(state, owner_user_id).await {
|
||||
tracing::warn!(
|
||||
user_id = %owner_user_id,
|
||||
error = %error,
|
||||
"测试环境登录后 LLM Router 账号准备失败;已允许主站登录"
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = ensure_llm_router_account(&state, owner_user_id.as_str()).await {
|
||||
tracing::warn!(
|
||||
user_id = %owner_user_id,
|
||||
error = %error,
|
||||
"登录后 LLM Router 账号准备失败;已允许主站登录,后续 LLM 请求将按需失败关闭"
|
||||
);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an already-provisioned Router credential without contacting the
|
||||
@@ -1327,7 +1349,10 @@ async fn provision_router_account_via_new_api(
|
||||
.or_else(|| pending_login.map(|login| (login.username.clone(), login.password.clone())))
|
||||
.unwrap_or((
|
||||
router_username_for_owner(owner_user_id),
|
||||
generate_router_account_password(owner_user_id)?,
|
||||
generate_router_account_password_with_secret(
|
||||
owner_user_id,
|
||||
llm_router_provisioning_secret(state)?.as_bytes(),
|
||||
)?,
|
||||
));
|
||||
let admin_token = state
|
||||
.config
|
||||
@@ -1951,13 +1976,47 @@ fn router_username_for_owner(owner_user_id: &str) -> String {
|
||||
format!("agc_user_{short_code}")
|
||||
}
|
||||
|
||||
fn llm_router_provisioning_secret(state: &AppState) -> Result<String, String> {
|
||||
if let Some(secret) = state
|
||||
.config
|
||||
.llm_router_provisioning_secret
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Ok(secret.to_string());
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
return Ok("test-only-router-provisioning-secret".to_string());
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
Err("LLM Router 账号密码派生密钥未配置".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn generate_router_account_password(owner_user_id: &str) -> Result<String, String> {
|
||||
generate_router_account_password_with_secret(
|
||||
owner_user_id,
|
||||
b"test-only-router-provisioning-secret",
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_router_account_password_with_secret(
|
||||
owner_user_id: &str,
|
||||
provisioning_secret: &[u8],
|
||||
) -> Result<String, String> {
|
||||
let owner_user_id = owner_user_id.trim();
|
||||
if owner_user_id.is_empty() {
|
||||
return Err("LLM Router 账号密码派生参数缺失".to_string());
|
||||
}
|
||||
|
||||
let mut signer = HmacSha256::new_from_slice(LLM_ROUTER_PROVISIONING_SECRET_V1)
|
||||
if provisioning_secret.is_empty() {
|
||||
return Err("LLM Router 账号密码派生密钥未配置".to_string());
|
||||
}
|
||||
let mut signer = HmacSha256::new_from_slice(provisioning_secret)
|
||||
.map_err(|_| "LLM Router 账号密码派生密钥无效".to_string())?;
|
||||
signer.update(
|
||||
format!(
|
||||
|
||||
@@ -82,6 +82,7 @@ pub async fn proxy_llm_chat_completions(
|
||||
};
|
||||
let api_kind = LlmApiKind::OpenAiResponses;
|
||||
|
||||
let request_fingerprint = serde_json::to_vec(&payload).unwrap_or_default();
|
||||
let request = LlmRunRequest {
|
||||
model: None,
|
||||
api_kind,
|
||||
@@ -100,7 +101,12 @@ pub async fn proxy_llm_chat_completions(
|
||||
};
|
||||
|
||||
if payload.stream {
|
||||
let billing_key = request_idempotency_key(&headers, request_context.request_id());
|
||||
let billing_key = request_billing_key(
|
||||
&headers,
|
||||
request_context.request_id(),
|
||||
"chat-completions",
|
||||
request_fingerprint.as_slice(),
|
||||
);
|
||||
return Ok(stream_llm_chat_completions(
|
||||
llm_client.clone(),
|
||||
request,
|
||||
@@ -132,7 +138,12 @@ pub async fn proxy_llm_chat_completions(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
request_context.request_id(),
|
||||
request_idempotency_key(&headers, request_context.request_id()),
|
||||
request_billing_key(
|
||||
&headers,
|
||||
request_context.request_id(),
|
||||
"chat-completions",
|
||||
request_fingerprint.as_slice(),
|
||||
),
|
||||
response.usage.as_ref(),
|
||||
)
|
||||
.await
|
||||
@@ -239,7 +250,7 @@ pub async fn proxy_llm_responses(
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.read_timeout(std::time::Duration::from_secs(180))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
@@ -294,7 +305,13 @@ pub async fn proxy_llm_responses(
|
||||
}
|
||||
}
|
||||
let upstream_headers = upstream.headers().clone();
|
||||
let billing_key = request_idempotency_key(&headers, request_context.request_id());
|
||||
let request_fingerprint = serde_json::to_vec(&payload).unwrap_or_default();
|
||||
let billing_key = request_billing_key(
|
||||
&headers,
|
||||
request_context.request_id(),
|
||||
"responses",
|
||||
request_fingerprint.as_slice(),
|
||||
);
|
||||
let is_stream = payload
|
||||
.get("stream")
|
||||
.and_then(Value::as_bool)
|
||||
@@ -462,15 +479,33 @@ fn insufficient_mud_points_error() -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn request_idempotency_key(headers: &HeaderMap, fallback: &str) -> String {
|
||||
headers
|
||||
fn request_billing_key(
|
||||
headers: &HeaderMap,
|
||||
fallback: &str,
|
||||
endpoint: &str,
|
||||
request_bytes: &[u8],
|
||||
) -> String {
|
||||
let client_key = headers
|
||||
.get("idempotency-key")
|
||||
.or_else(|| headers.get("x-idempotency-key"))
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
.to_string()
|
||||
.filter(|value| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 256
|
||||
&& value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
|
||||
});
|
||||
let Some(client_key) = client_key else {
|
||||
return fallback.to_string();
|
||||
};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"llm-router-billing-key:v2\n");
|
||||
hasher.update(endpoint.as_bytes());
|
||||
hasher.update(b"\n");
|
||||
hasher.update(client_key.as_bytes());
|
||||
hasher.update(b"\n");
|
||||
hasher.update(request_bytes);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn llm_router_ledger_id(owner_user_id: &str, idempotency_key: &str) -> String {
|
||||
@@ -678,6 +713,7 @@ fn stream_responses_with_billing(
|
||||
) -> impl futures_util::Stream<Item = Result<Bytes, std::io::Error>> {
|
||||
async_stream::stream! {
|
||||
let mut pending = String::new();
|
||||
let mut utf8_pending = Vec::new();
|
||||
let mut usage = None;
|
||||
let mut terminal_event = None;
|
||||
let mut completed_seen = false;
|
||||
@@ -688,7 +724,7 @@ fn stream_responses_with_billing(
|
||||
while let Some(chunk) = upstream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
pending.push_str(String::from_utf8_lossy(bytes.as_ref()).as_ref());
|
||||
append_utf8_chunk(&mut pending, &mut utf8_pending, bytes.as_ref());
|
||||
pending = pending.replace("\r\n", "\n");
|
||||
while let Some(separator) = pending.find("\n\n") {
|
||||
let raw_event = pending[..separator + 2].to_string();
|
||||
@@ -700,6 +736,15 @@ fn stream_responses_with_billing(
|
||||
if is_responses_terminal_sse_event(&event) {
|
||||
completed_seen = true;
|
||||
terminal_event = Some(raw_event);
|
||||
if 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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_responses_done_sse_event(&event) {
|
||||
@@ -808,6 +853,32 @@ fn stream_responses_with_billing(
|
||||
}
|
||||
}
|
||||
|
||||
fn append_utf8_chunk(pending: &mut String, carry: &mut Vec<u8>, bytes: &[u8]) {
|
||||
carry.extend_from_slice(bytes);
|
||||
loop {
|
||||
match std::str::from_utf8(carry.as_slice()) {
|
||||
Ok(text) => {
|
||||
pending.push_str(text);
|
||||
carry.clear();
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
let valid = error.valid_up_to();
|
||||
if valid > 0 {
|
||||
pending.push_str(std::str::from_utf8(&carry[..valid]).unwrap_or_default());
|
||||
carry.drain(..valid);
|
||||
}
|
||||
if let Some(error_len) = error.error_len() {
|
||||
pending.push('\u{FFFD}');
|
||||
carry.drain(..error_len.min(carry.len()));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_responses_terminal_sse_event(event: &str) -> bool {
|
||||
if event.lines().any(|line| {
|
||||
line.strip_prefix("event:")
|
||||
@@ -1645,6 +1716,36 @@ mod tests {
|
||||
assert!(!first.contains("request-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_billing_key_binds_client_key_to_endpoint_and_payload() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("idempotency-key", HeaderValue::from_static("fixed"));
|
||||
let first = request_billing_key(&headers, "request-1", "responses", br#"{"input":"a"}"#);
|
||||
let replay = request_billing_key(&headers, "request-2", "responses", br#"{"input":"a"}"#);
|
||||
let different_payload =
|
||||
request_billing_key(&headers, "request-3", "responses", br#"{"input":"b"}"#);
|
||||
let different_endpoint = request_billing_key(
|
||||
&headers,
|
||||
"request-4",
|
||||
"chat-completions",
|
||||
br#"{"input":"a"}"#,
|
||||
);
|
||||
assert_eq!(first, replay);
|
||||
assert_ne!(first, different_payload);
|
||||
assert_ne!(first, different_endpoint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_utf8_chunk_preserves_multibyte_characters_split_across_chunks() {
|
||||
let mut text = String::new();
|
||||
let mut carry = Vec::new();
|
||||
append_utf8_chunk(&mut text, &mut carry, &[0xe4, 0xb8]);
|
||||
assert_eq!(text, "");
|
||||
append_utf8_chunk(&mut text, &mut carry, &[0xad]);
|
||||
assert_eq!(text, "中");
|
||||
assert!(carry.is_empty());
|
||||
}
|
||||
|
||||
fn spawn_mock_server(responses: Vec<MockResponse>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||
let address = listener.local_addr().expect("listener should have addr");
|
||||
|
||||
Reference in New Issue
Block a user