AGC 官方 LLM Router 账号链路与流式联网输出 #242

Merged
kdletters merged 59 commits from feat/agc-llm-router-official-chain into master 2026-09-06 16:49:13 +08:00
17 changed files with 8 additions and 398 deletions
Showing only changes of commit cbc73f111c - Show all commits
-1
View File
@@ -950,7 +950,6 @@ function buildExternalApiKeyQuery(query: AdminExternalApiKeyListQuery) {
appendQueryParam(params, 'createdAfter', query.createdAfter);
appendQueryParam(params, 'createdBefore', query.createdBefore);
appendQueryParam(params, 'status', query.status);
appendQueryParam(params, 'purpose', query.purpose);
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(Math.floor(query.limit)));
}
-3
View File
@@ -284,7 +284,6 @@ export interface AdminExternalApiKeyListQuery {
createdAfter?: string;
createdBefore?: string;
status?: 'active' | 'revoked';
purpose?: 'external-editor' | 'llm-router';
limit?: number;
offset?: number;
sortColumn?:
@@ -292,7 +291,6 @@ export interface AdminExternalApiKeyListQuery {
| 'ownerUserId'
| 'name'
| 'keyPrefix'
| 'purpose'
| 'createdAt'
| 'lastUsedAt'
| 'updatedAt';
@@ -304,7 +302,6 @@ export interface AdminExternalApiKeyPayload {
ownerUserId: string;
name: string;
keyPrefix: string;
purpose: string;
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
@@ -109,7 +109,6 @@ test('external_api_key 使用专用安全查询且详情不展示原始 JSON', a
ownerUserId: 'user-1',
name: 'agc_auto_generate',
keyPrefix: 'tnr_sk_fixture',
purpose: 'llm-router',
scopes: ['llm:responses'],
createdAt: '2026-08-29T00:00:00Z',
lastUsedAt: null,
@@ -135,7 +134,7 @@ test('external_api_key 使用专用安全查询且详情不展示原始 JSON', a
await waitFor(() => {
expect(getAdminExternalApiKeys).toHaveBeenLastCalledWith(
'admin-token',
expect.objectContaining({ ownerUserId: 'user-1', purpose: undefined }),
expect.objectContaining({ ownerUserId: 'user-1' }),
);
});
expect(await screen.findByText('tnr_sk_fixture')).toBeTruthy();
@@ -47,7 +47,6 @@ type ExternalApiKeySortColumn =
| 'ownerUserId'
| 'name'
| 'keyPrefix'
| 'purpose'
| 'createdAt'
| 'lastUsedAt'
| 'updatedAt';
@@ -1219,9 +1218,6 @@ function AdminExternalApiKeysPanel({
const [createdAfter, setCreatedAfter] = useState('');
const [createdBefore, setCreatedBefore] = useState('');
const [status, setStatus] = useState<'' | 'active' | 'revoked'>('');
const [purpose, setPurpose] = useState<'' | 'external-editor' | 'llm-router'>(
'',
);
const [limit, setLimit] = useState('100');
const [sortColumn, setSortColumn] = useState<ExternalApiKeySortColumn>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
@@ -1269,7 +1265,6 @@ function AdminExternalApiKeysPanel({
createdAfter: createdAfter.trim() || undefined,
createdBefore: createdBefore.trim() || undefined,
status: status || undefined,
purpose: purpose || undefined,
limit: parseLimit(limit),
offset: nextOffset,
sortColumn: sortColumn || undefined,
@@ -1294,7 +1289,6 @@ function AdminExternalApiKeysPanel({
setCreatedAfter('');
setCreatedBefore('');
setStatus('');
setPurpose('');
setLimit('100');
setSortColumn('');
setSortDirection('desc');
@@ -1359,19 +1353,6 @@ function AdminExternalApiKeysPanel({
onChange={(event) => setName(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<select
value={purpose}
onChange={(event) =>
setPurpose(event.target.value as typeof purpose)
}
>
<option value=""></option>
<option value="external-editor">external-editor</option>
<option value="llm-router">llm-router</option>
</select>
</label>
<label className="admin-field">
<span></span>
<select
@@ -1422,7 +1403,6 @@ function AdminExternalApiKeysPanel({
<option value="ownerUserId">Owner</option>
<option value="name"></option>
<option value="keyPrefix">Key </option>
<option value="purpose"></option>
<option value="createdAt"></option>
<option value="lastUsedAt">使</option>
<option value="updatedAt"></option>
@@ -1483,7 +1463,6 @@ function AdminExternalApiKeysPanel({
<th>Owner</th>
<th></th>
<th></th>
<th></th>
<th>Scope</th>
<th></th>
<th></th>
@@ -1498,7 +1477,6 @@ function AdminExternalApiKeysPanel({
<td>{key.ownerUserId}</td>
<td>{key.name}</td>
<td>{key.keyPrefix}</td>
<td>{key.purpose}</td>
<td>{key.scopes.join(', ')}</td>
<td>{formatSafeDate(key.createdAt)}</td>
<td>{key.status}</td>
@@ -1516,7 +1494,7 @@ function AdminExternalApiKeysPanel({
))
) : (
<tr>
<td colSpan={9}>
<td colSpan={8}>
{result ? '暂无数据' : '请先提供精确范围并查询'}
</td>
</tr>
@@ -1597,8 +1575,6 @@ function AdminExternalApiKeysPanel({
<dd>{selectedKey.name}</dd>
<dt></dt>
<dd>{selectedKey.keyPrefix}</dd>
<dt></dt>
<dd>{selectedKey.purpose}</dd>
<dt>Scope</dt>
<dd>{selectedKey.scopes.join(', ') || '-'}</dd>
<dt></dt>
@@ -631,7 +631,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- Rust 结构体:`ExternalApiKey`
- 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs`
- 说明:本表只承载普通外部 OpenAPI/MCP API Key。明文只在 `/api/profile/api-keys` 创建接口返回一次,服务端保存 `key_hash``key_prefix`、作用域、用途和撤销状态;本需求不向该表增加 Router 字段,也不写入 `purpose=llm-router` 数据。`purpose=external-editor` 是默认用途,v1 默认作用域为 `editor:project``editor:canvas``editor:image-generate``editor:asset`
- 说明:本表只承载普通外部 OpenAPI/MCP API Key。明文只在 `/api/profile/api-keys` 创建接口返回一次,服务端保存 `key_hash``key_prefix`、作用域和撤销状态;本需求不向该表增加 Router 字段,也不写入 Router 账号数据。v1 默认作用域为 `editor:project``editor:canvas``editor:image-generate``editor:asset`
- 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。
- 2026-08-31 修订:Router 账号状态、API Key 核心字段、账号元数据和加密凭据统一写入 `llm_router_account``external_api_key` 保持普通外部 OpenAPI/MCP Key 的原有链路不变。公共 Router、独立数据库的部署必须共享同一版本 provisioning secret,数据库缺行时才能恢复同一远端账号。Responses 请求成功后再按 usage 写入钱包扣费流水,失败请求不扣费。
- 2026-09-01 修订:Router provisioning 允许所有环境使用官方固定 Router 控制面,以便独立开发数据库通过完整 owner `user_id` 的稳定派生凭据和 `agc_auto_generate` Token 复用同一远端账号/Key。由于 New API 的 `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` 保留权威映射。非官方公网地址仍拒绝,loopback 仅用于本地 fixture。使用共享官方 Router 的非生产环境启动时告警,提醒会触及线上账号与额度。api-server 不再提供任何 fallback Key 路径;没有已完成 provisioning 的账号行时,LLM 请求必须在本地解析阶段失败关闭。
@@ -642,7 +642,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
### `llm_router_account`
- 当前 AGC Router 需求由表单独承载:API Key 核心字段、加密凭据、Router 账号元数据、生命周期与 provisioning 状态均在表;不依赖 `external_api_key`。api-server 首次需要上游调用时解密凭据,并按 owner + route 使用 10 分钟进程内缓存;轮换或撤销时清理缓存。
- 当前 AGC Router 需求由 `llm_router_account` 表单独承载:API Key 核心字段、加密凭据、Router 账号元数据、生命周期与 provisioning 状态均在表;不依赖 `external_api_key`。api-server 首次需要上游调用时解密凭据,并按 owner + route 使用 10 分钟进程内缓存;轮换或撤销时清理缓存。
- Rust 结构体:`LlmRouterAccount`
- 源码:`server-rs/crates/spacetime-module/src/llm_router_account.rs`
+2 -46
View File
@@ -440,7 +440,6 @@ struct ValidatedAdminExternalApiKeyQuery {
created_after_micros: Option<i64>,
created_before_micros: Option<i64>,
status: Option<String>,
purpose: Option<String>,
limit: u32,
offset: u32,
sort_column: Option<String>,
@@ -475,13 +474,6 @@ fn validate_admin_external_api_key_query(
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("status 只能是 active 或 revoked"));
}
let purpose = normalized_non_empty(query.purpose.as_deref()).map(str::to_owned);
if let Some(purpose) = purpose.as_deref()
&& !matches!(purpose, "external-editor" | "llm-router")
{
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("purpose 只能是 external-editor 或 llm-router"));
}
let created_after_micros = normalized_non_empty(query.created_after.as_deref())
.map(|value| {
parse_timestamp_text_to_micros(value).ok_or_else(|| {
@@ -518,7 +510,6 @@ fn validate_admin_external_api_key_query(
| "ownerUserId"
| "name"
| "keyPrefix"
| "purpose"
| "createdAt"
| "lastUsedAt"
| "updatedAt"
@@ -542,7 +533,6 @@ fn validate_admin_external_api_key_query(
created_after_micros,
created_before_micros,
status,
purpose,
limit: query.limit.unwrap_or(100).clamp(1, 500),
offset: query.offset.unwrap_or(0),
sort_column,
@@ -574,7 +564,6 @@ async fn fetch_admin_external_api_keys(
query.key_id.as_deref(),
query.key_prefix.as_deref(),
query.status.as_deref(),
query.purpose.as_deref(),
);
let payload = fetch_spacetime_sql_json_limited(
&client,
@@ -629,12 +618,6 @@ async fn fetch_admin_external_api_keys(
.contains(&value.to_ascii_lowercase())
})
})
.filter(|entry| {
query
.purpose
.as_deref()
.is_none_or(|value| entry.purpose == value)
})
.filter(|entry| match query.status.as_deref() {
Some("active") => entry.status == "active",
Some("revoked") => entry.status == "revoked",
@@ -659,7 +642,6 @@ async fn fetch_admin_external_api_keys(
"ownerUserId" => left.owner_user_id.cmp(&right.owner_user_id),
"name" => left.name.cmp(&right.name),
"keyPrefix" => left.key_prefix.cmp(&right.key_prefix),
"purpose" => left.purpose.cmp(&right.purpose),
"lastUsedAt" => left.last_used_at.cmp(&right.last_used_at),
"updatedAt" => left.updated_at.cmp(&right.updated_at),
_ => left.created_at.cmp(&right.created_at),
@@ -696,7 +678,6 @@ fn build_admin_external_api_key_sql(
key_id: Option<&str>,
key_prefix: Option<&str>,
status: Option<&str>,
purpose: Option<&str>,
) -> String {
let mut conditions = Vec::new();
if let Some(owner) = owner_user_id {
@@ -713,9 +694,6 @@ fn build_admin_external_api_key_sql(
// filter below. Name/time filters deliberately stay in the bounded scan
// layer because LIKE/Timestamp literal syntax varies across standalone
// versions; the response advertises when that bounded result is partial.
if let Some(value) = purpose {
conditions.push(format!("purpose = {}", quote_sql_string(value)));
}
if let Some(value) = status {
conditions.push(match value {
"active" => "revoked_at IS NULL".to_string(),
@@ -729,7 +707,7 @@ fn build_admin_external_api_key_sql(
format!(" WHERE {}", conditions.join(" AND "))
};
format!(
"SELECT key_id,owner_user_id,name,key_prefix,scopes_json,created_at,last_used_at,revoked_at,updated_at,purpose FROM external_api_key{where_clause} LIMIT {}",
"SELECT key_id,owner_user_id,name,key_prefix,scopes_json,created_at,last_used_at,revoked_at,updated_at FROM external_api_key{where_clause} LIMIT {}",
ADMIN_EXTERNAL_API_KEY_SCAN_LIMIT + 1
)
}
@@ -798,8 +776,6 @@ fn parse_admin_external_api_key_row(
let owner_user_id = value_to_string(get("owner_user_id")?)?;
let name = value_to_string(get("name")?)?;
let key_prefix = value_to_string(get("key_prefix")?)?;
let purpose = value_to_string(get("purpose").unwrap_or(&Value::Null))
.unwrap_or_else(|| "external-editor".to_string());
let scopes = value_to_string(get("scopes_json").unwrap_or(&Value::Null))
.and_then(|text| serde_json::from_str::<Vec<String>>(&text).ok())
.unwrap_or_default();
@@ -812,7 +788,6 @@ fn parse_admin_external_api_key_row(
owner_user_id,
name,
key_prefix,
purpose,
scopes,
created_at,
last_used_at,
@@ -5771,7 +5746,6 @@ mod tests {
"last_used_at".to_string(),
"revoked_at".to_string(),
"updated_at".to_string(),
"purpose".to_string(),
];
let entry = parse_admin_external_api_key_row(
&json!([
@@ -5783,15 +5757,13 @@ mod tests {
"2026-08-29T00:00:00Z",
null,
null,
"2026-08-29T00:00:00Z",
"llm-router"
"2026-08-29T00:00:00Z"
]),
&columns,
)
.expect("safe API key metadata");
assert_eq!(entry.key_id, "external-api-key-1");
assert_eq!(entry.purpose, "llm-router");
assert_eq!(entry.scopes, vec!["llm:responses"]);
assert_eq!(entry.status, "active");
let serialized = serde_json::to_string(&entry).expect("serialize safe metadata");
@@ -5806,12 +5778,10 @@ mod tests {
None,
Some("tnr_sk_abc"),
Some("active"),
Some("llm-router"),
);
assert!(sql.contains("owner_user_id = 'user-''1'"));
assert!(sql.contains("key_prefix = 'tnr_sk_abc'"));
assert!(sql.contains("revoked_at IS NULL"));
assert!(sql.contains("purpose = 'llm-router'"));
assert!(!sql.contains("key_hash"));
assert!(!sql.contains("secret_ciphertext"));
assert!(sql.contains("LIMIT 5001"));
@@ -5847,18 +5817,6 @@ mod tests {
.contains("status")
);
let invalid_purpose = AdminExternalApiKeyListQuery {
owner_user_id: Some("owner-1".to_string()),
purpose: Some("router-admin".to_string()),
..Default::default()
};
assert!(
validate_admin_external_api_key_query(&invalid_purpose)
.expect_err("invalid purpose")
.message()
.contains("purpose")
);
let invalid_sort = AdminExternalApiKeyListQuery {
owner_user_id: Some("owner-1".to_string()),
sort_column: Some("keyHash".to_string()),
@@ -5907,7 +5865,6 @@ mod tests {
created_after: Some("2026-08-29T00:00:00Z".to_string()),
created_before: Some("2026-08-29T23:59:59Z".to_string()),
status: Some("active".to_string()),
purpose: Some("llm-router".to_string()),
limit: Some(5000),
offset: Some(77),
sort_column: Some("createdAt".to_string()),
@@ -5920,7 +5877,6 @@ mod tests {
assert_eq!(query.name.as_deref(), Some("AGC"));
assert_eq!(query.key_prefix.as_deref(), Some("tnr_sk_"));
assert_eq!(query.status.as_deref(), Some("active"));
assert_eq!(query.purpose.as_deref(), Some("llm-router"));
assert_eq!(query.limit, 500);
assert_eq!(query.offset, 77);
assert_eq!(query.sort_direction.as_deref(), Some("asc"));
@@ -53,8 +53,6 @@ const LLM_ROUTER_TOKEN_GROUP: &str = "default";
const LLM_ROUTER_API_KEY_SCOPES: [&str; 1] = ["llm:responses"];
const LLM_ROUTER_SUBSCRIPTION_PLAN_ID: i64 = 1;
const LLM_ROUTER_SUBSCRIPTION_RENEWAL_THRESHOLD_SECONDS: i64 = 24 * 60 * 60;
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;
const LLM_ROUTER_CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(600);
@@ -126,7 +124,6 @@ pub struct ExternalApiKeyPayload {
name: String,
key_prefix: String,
scopes: Vec<String>,
purpose: String,
created_at: String,
last_used_at: Option<String>,
revoked_at: Option<String>,
@@ -163,10 +160,6 @@ pub async fn list_external_api_keys(
.await
.map_err(map_external_api_key_error)?
.into_iter()
// LLM Router credentials are an internal server-side implementation
// detail. The ordinary profile API-key surface must never expose
// even their safe metadata or allow users to manage their lifecycle.
.filter(|record| record.purpose == EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR)
.map(external_api_key_payload_from_record)
.collect();
@@ -198,13 +191,6 @@ pub async fn create_external_api_key(
key_hash: hash_external_api_key(raw_key.as_str()),
scopes: default_external_api_key_scopes(),
now_micros: current_utc_micros(),
purpose: EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR.to_string(),
secret_ciphertext: None,
provider_account_id: None,
provider_base_url: None,
provider_model: None,
provider_account_json: None,
credential_version: 1,
})
.await
.map_err(map_external_api_key_error)?;
1
@@ -2246,7 +2232,6 @@ fn external_api_key_payload_from_record(record: ExternalApiKeyRecord) -> Externa
name: record.name,
key_prefix: record.key_prefix,
scopes: record.scopes,
purpose: record.purpose,
created_at: record.created_at,
last_used_at: record.last_used_at,
revoked_at: record.revoked_at,
@@ -2265,7 +2250,6 @@ fn external_api_key_payload_from_router_account(
.iter()
.map(|scope| (*scope).to_string())
.collect(),
purpose: EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER.to_string(),
created_at: account.created_at,
last_used_at: account.last_used_at,
revoked_at: account.revoked_at,
@@ -735,7 +735,6 @@ pub struct AdminExternalApiKeyListQuery {
pub created_after: Option<String>,
pub created_before: Option<String>,
pub status: Option<String>,
pub purpose: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
pub sort_column: Option<String>,
@@ -750,7 +749,6 @@ pub struct AdminExternalApiKeyPayload {
pub owner_user_id: String,
pub name: String,
pub key_prefix: String,
pub purpose: String,
pub scopes: Vec<String>,
pub created_at: String,
pub last_used_at: Option<String>,
@@ -1,24 +1,16 @@
use super::*;
use std::fmt;
#[derive(Clone, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ExternalApiKeyRecord {
pub key_id: String,
pub owner_user_id: String,
pub name: String,
pub key_prefix: String,
pub scopes: Vec<String>,
pub purpose: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub revoked_at: Option<String>,
pub updated_at: String,
pub secret_ciphertext: Option<String>,
pub provider_account_id: Option<String>,
pub provider_base_url: Option<String>,
pub provider_model: Option<String>,
pub provider_account_json: Option<String>,
pub credential_version: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -30,39 +22,6 @@ pub struct ExternalApiKeyCreateRecordInput {
pub key_hash: String,
pub scopes: Vec<String>,
pub now_micros: i64,
pub purpose: String,
pub secret_ciphertext: Option<String>,
pub provider_account_id: Option<String>,
pub provider_base_url: Option<String>,
pub provider_model: Option<String>,
pub provider_account_json: Option<String>,
pub credential_version: u32,
}
impl fmt::Debug for ExternalApiKeyRecord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ExternalApiKeyRecord")
.field("key_id", &self.key_id)
.field("owner_user_id", &self.owner_user_id)
.field("name", &self.name)
.field("key_prefix", &self.key_prefix)
.field("scopes", &self.scopes)
.field("purpose", &self.purpose)
.field(
"secret_ciphertext",
&self.secret_ciphertext.as_ref().map(|_| "<redacted>"),
)
.field("provider_account_id", &self.provider_account_id)
.field("provider_base_url", &self.provider_base_url)
.field("provider_model", &self.provider_model)
.field("provider_account_json", &self.provider_account_json)
.field("credential_version", &self.credential_version)
.field("created_at", &self.created_at)
.field("last_used_at", &self.last_used_at)
.field("revoked_at", &self.revoked_at)
.field("updated_at", &self.updated_at)
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -88,13 +47,6 @@ impl From<ExternalApiKeyCreateRecordInput> for crate::module_bindings::ExternalA
key_hash: input.key_hash,
scopes_json: serde_json::to_string(&input.scopes).unwrap_or_else(|_| "[]".to_string()),
now_micros: input.now_micros,
purpose: input.purpose,
secret_ciphertext: input.secret_ciphertext,
provider_account_id: input.provider_account_id,
provider_base_url: input.provider_base_url,
provider_model: input.provider_model,
provider_account_json: input.provider_account_json,
credential_version: input.credential_version,
}
}
}
@@ -158,16 +110,9 @@ fn map_external_api_key_snapshot(
key_prefix: snapshot.key_prefix,
scopes: serde_json::from_str::<Vec<String>>(snapshot.scopes_json.as_str())
.map_err(SpacetimeClientError::validation_failed)?,
purpose: snapshot.purpose,
created_at: format_timestamp_micros(snapshot.created_at_micros),
last_used_at: snapshot.last_used_at_micros.map(format_timestamp_micros),
revoked_at: snapshot.revoked_at_micros.map(format_timestamp_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
secret_ciphertext: snapshot.secret_ciphertext,
provider_account_id: snapshot.provider_account_id,
provider_base_url: snapshot.provider_base_url,
provider_model: snapshot.provider_model,
provider_account_json: snapshot.provider_account_json,
credential_version: snapshot.credential_version,
})
}
@@ -67,31 +67,6 @@ impl SpacetimeClient {
.await
}
pub async fn revoke_llm_router_api_key(
&self,
input: ExternalApiKeyRevokeRecordInput,
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"revoke_llm_router_api_key_and_return",
move |connection, sender| {
connection
.procedures()
.revoke_llm_router_api_key_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_external_api_key_single_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn authenticate_external_api_key(
&self,
input: ExternalApiKeyAuthenticateRecordInput,
@@ -682,7 +682,6 @@ pub mod resolve_editor_reference_and_return_procedure;
pub mod resolve_profile_recharge_refund_manual_review_and_return_procedure;
pub mod revoke_database_migration_operator_procedure;
pub mod revoke_external_api_key_and_return_procedure;
pub mod revoke_llm_router_api_key_and_return_procedure;
pub mod rollback_editor_canvas_layout_and_return_procedure;
pub mod rotate_editor_generation_runtime_service_identity_and_return_procedure;
pub mod rpg_agent_draft_card_kind_type;
@@ -1583,7 +1582,6 @@ pub use resolve_editor_reference_and_return_procedure::resolve_editor_reference_
pub use resolve_profile_recharge_refund_manual_review_and_return_procedure::resolve_profile_recharge_refund_manual_review_and_return;
pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator;
pub use revoke_external_api_key_and_return_procedure::revoke_external_api_key_and_return;
pub use revoke_llm_router_api_key_and_return_procedure::revoke_llm_router_api_key_and_return;
pub use rollback_editor_canvas_layout_and_return_procedure::rollback_editor_canvas_layout_and_return;
pub use rotate_editor_generation_runtime_service_identity_and_return_procedure::rotate_editor_generation_runtime_service_identity_and_return;
pub use rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind;
@@ -14,13 +14,6 @@ pub struct ExternalApiKeyCreateInput {
pub key_hash: String,
pub scopes_json: String,
pub now_micros: i64,
pub purpose: String,
pub secret_ciphertext: Option<String>,
pub provider_account_id: Option<String>,
pub provider_base_url: Option<String>,
pub provider_model: Option<String>,
pub provider_account_json: Option<String>,
pub credential_version: u32,
}
impl __sdk::InModule for ExternalApiKeyCreateInput {
@@ -12,17 +12,10 @@ pub struct ExternalApiKeySnapshot {
pub name: String,
pub key_prefix: String,
pub scopes_json: String,
pub purpose: String,
pub created_at_micros: i64,
pub last_used_at_micros: Option<i64>,
pub revoked_at_micros: Option<i64>,
pub updated_at_micros: i64,
pub secret_ciphertext: Option<String>,
pub provider_account_id: Option<String>,
pub provider_base_url: Option<String>,
pub provider_model: Option<String>,
pub provider_account_json: Option<String>,
pub credential_version: u32,
}
impl __sdk::InModule for ExternalApiKeySnapshot {
@@ -17,13 +17,6 @@ pub struct ExternalApiKey {
pub last_used_at: Option<__sdk::Timestamp>,
pub revoked_at: Option<__sdk::Timestamp>,
pub updated_at: __sdk::Timestamp,
pub purpose: String,
pub secret_ciphertext: Option<String>,
pub provider_account_id: Option<String>,
pub provider_base_url: Option<String>,
pub provider_model: Option<String>,
pub provider_account_json: Option<String>,
pub credential_version: u32,
}
impl __sdk::InModule for ExternalApiKey {
@@ -44,13 +37,6 @@ pub struct ExternalApiKeyCols {
pub last_used_at: __sdk::__query_builder::Col<ExternalApiKey, Option<__sdk::Timestamp>>,
pub revoked_at: __sdk::__query_builder::Col<ExternalApiKey, Option<__sdk::Timestamp>>,
pub updated_at: __sdk::__query_builder::Col<ExternalApiKey, __sdk::Timestamp>,
pub purpose: __sdk::__query_builder::Col<ExternalApiKey, String>,
pub secret_ciphertext: __sdk::__query_builder::Col<ExternalApiKey, Option<String>>,
pub provider_account_id: __sdk::__query_builder::Col<ExternalApiKey, Option<String>>,
pub provider_base_url: __sdk::__query_builder::Col<ExternalApiKey, Option<String>>,
pub provider_model: __sdk::__query_builder::Col<ExternalApiKey, Option<String>>,
pub provider_account_json: __sdk::__query_builder::Col<ExternalApiKey, Option<String>>,
pub credential_version: __sdk::__query_builder::Col<ExternalApiKey, u32>,
}
impl __sdk::__query_builder::HasCols for ExternalApiKey {
@@ -67,19 +53,6 @@ impl __sdk::__query_builder::HasCols for ExternalApiKey {
last_used_at: __sdk::__query_builder::Col::new(table_name, "last_used_at"),
revoked_at: __sdk::__query_builder::Col::new(table_name, "revoked_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
purpose: __sdk::__query_builder::Col::new(table_name, "purpose"),
secret_ciphertext: __sdk::__query_builder::Col::new(table_name, "secret_ciphertext"),
provider_account_id: __sdk::__query_builder::Col::new(
table_name,
"provider_account_id",
),
provider_base_url: __sdk::__query_builder::Col::new(table_name, "provider_base_url"),
provider_model: __sdk::__query_builder::Col::new(table_name, "provider_model"),
provider_account_json: __sdk::__query_builder::Col::new(
table_name,
"provider_account_json",
),
credential_version: __sdk::__query_builder::Col::new(table_name, "credential_version"),
}
}
}
@@ -1,59 +0,0 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::external_api_key_procedure_result_type::ExternalApiKeyProcedureResult;
use super::external_api_key_revoke_input_type::ExternalApiKeyRevokeInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct RevokeLlmRouterApiKeyAndReturnArgs {
pub input: ExternalApiKeyRevokeInput,
}
impl __sdk::InModule for RevokeLlmRouterApiKeyAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `revoke_llm_router_api_key_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait revoke_llm_router_api_key_and_return {
fn revoke_llm_router_api_key_and_return(&self, input: ExternalApiKeyRevokeInput) {
self.revoke_llm_router_api_key_and_return_then(input, |_, _| {});
}
fn revoke_llm_router_api_key_and_return_then(
&self,
input: ExternalApiKeyRevokeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalApiKeyProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl revoke_llm_router_api_key_and_return for super::RemoteProcedures {
fn revoke_llm_router_api_key_and_return_then(
&self,
input: ExternalApiKeyRevokeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalApiKeyProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>(
"revoke_llm_router_api_key_and_return",
RevokeLlmRouterApiKeyAndReturnArgs { input },
__callback,
);
}
}
@@ -2,8 +2,6 @@ use crate::*;
const EXTERNAL_API_KEY_MAX_NAME_CHARS: usize = 80;
const EXTERNAL_API_KEY_DEFAULT_NAME: &str = "外部 API Key";
pub const EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR: &str = "external-editor";
pub const EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER: &str = "llm-router";
const EXTERNAL_API_KEY_DEFAULT_SCOPES_JSON: &str =
"[\"editor:project\",\"editor:canvas\",\"editor:image-generate\",\"editor:asset\"]";
@@ -24,24 +22,6 @@ pub struct ExternalApiKey {
pub last_used_at: Option<Timestamp>,
pub revoked_at: Option<Timestamp>,
pub updated_at: Timestamp,
#[default(EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR)]
pub purpose: String,
/// LLM Router 凭据仅保存服务端可解密的密文;普通外部 Key 保持 None。
#[default(None)]
pub secret_ciphertext: Option<String>,
/// Router 侧账号标识及固定路由元数据,仅用于 `llm-router` purpose。
#[default(None)]
pub provider_account_id: Option<String>,
#[default(None)]
pub provider_base_url: Option<String>,
#[default(None)]
pub provider_model: Option<String>,
/// LLM Router 账号脱敏 JSON 元数据;普通外部 Key 保持 None。
#[default(None)]
pub provider_account_json: Option<String>,
/// LLM Router 凭据契约版本,用于后续迁移;历史行默认为 1。
#[default(1u32)]
pub credential_version: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -53,13 +33,6 @@ pub struct ExternalApiKeyCreateInput {
pub key_hash: String,
pub scopes_json: String,
pub now_micros: i64,
pub purpose: String,
pub secret_ciphertext: Option<String>,
pub provider_account_id: Option<String>,
pub provider_base_url: Option<String>,
pub provider_model: Option<String>,
pub provider_account_json: Option<String>,
pub credential_version: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -87,17 +60,10 @@ pub struct ExternalApiKeySnapshot {
pub name: String,
pub key_prefix: String,
pub scopes_json: String,
pub purpose: String,
pub created_at_micros: i64,
pub last_used_at_micros: Option<i64>,
pub revoked_at_micros: Option<i64>,
pub updated_at_micros: i64,
pub secret_ciphertext: Option<String>,
pub provider_account_id: Option<String>,
pub provider_base_url: Option<String>,
pub provider_model: Option<String>,
pub provider_account_json: Option<String>,
pub credential_version: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -135,23 +101,7 @@ pub fn revoke_external_api_key_and_return(
ctx: &mut ProcedureContext,
input: ExternalApiKeyRevokeInput,
) -> ExternalApiKeyProcedureResult {
match ctx.try_with_tx(|tx| revoke_external_api_key(tx, input.clone(), false)) {
Ok(key) => external_api_key_single_ok(key),
Err(message) => external_api_key_error(message),
}
}
/// Internal server-side path for invalidating an LLM Router credential.
///
/// The public profile API must never be able to revoke LLM Router credentials, but
/// api-server needs a separate trusted procedure to mark a Router key revoked
/// after a deterministic 401/403 before issuing a replacement key.
#[spacetimedb::procedure]
pub fn revoke_llm_router_api_key_and_return(
ctx: &mut ProcedureContext,
input: ExternalApiKeyRevokeInput,
) -> ExternalApiKeyProcedureResult {
match ctx.try_with_tx(|tx| revoke_external_api_key(tx, input.clone(), true)) {
match ctx.try_with_tx(|tx| revoke_external_api_key(tx, input.clone())) {
Ok(key) => external_api_key_single_ok(key),
Err(message) => external_api_key_error(message),
}
@@ -201,13 +151,6 @@ fn create_external_api_key(
last_used_at: None,
revoked_at: None,
updated_at: now,
purpose: normalize_key_purpose(input.purpose.as_str()),
secret_ciphertext: normalize_optional_secret(input.secret_ciphertext),
provider_account_id: normalize_optional_metadata(input.provider_account_id),
provider_base_url: normalize_optional_metadata(input.provider_base_url),
provider_model: normalize_optional_metadata(input.provider_model),
provider_account_json: normalize_optional_metadata(input.provider_account_json),
credential_version: input.credential_version.max(1),
});
ctx.db
.external_api_key()
@@ -241,7 +184,6 @@ fn list_external_api_keys(
fn revoke_external_api_key(
ctx: &ReducerContext,
input: ExternalApiKeyRevokeInput,
allow_llm_router: bool,
) -> Result<ExternalApiKeySnapshot, String> {
let key_id = normalize_required(&input.key_id, "external_api_key.key_id")?;
let owner_user_id = normalize_required(&input.owner_user_id, "external_api_key.owner_user_id")?;
@@ -254,9 +196,6 @@ fn revoke_external_api_key(
if row.owner_user_id != owner_user_id {
return Err("无权访问该 API Key".to_string());
}
if is_llm_router_purpose(row.purpose.as_str()) && !allow_llm_router {
return Err("LLM Router Key 由服务端管理,不能通过通用 API Key 接口撤销".to_string());
}
let now = Timestamp::from_micros_since_unix_epoch(input.revoked_at_micros);
ctx.db.external_api_key().key_id().delete(&key_id);
ctx.db.external_api_key().insert(ExternalApiKey {
@@ -308,7 +247,6 @@ fn external_api_key_snapshot_from_row(row: ExternalApiKey) -> ExternalApiKeySnap
name: row.name,
key_prefix: row.key_prefix,
scopes_json: row.scopes_json,
purpose: row.purpose,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
last_used_at_micros: row
.last_used_at
@@ -317,12 +255,6 @@ fn external_api_key_snapshot_from_row(row: ExternalApiKey) -> ExternalApiKeySnap
.revoked_at
.map(|value| value.to_micros_since_unix_epoch()),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
secret_ciphertext: row.secret_ciphertext,
provider_account_id: row.provider_account_id,
provider_base_url: row.provider_base_url,
provider_model: row.provider_model,
provider_account_json: row.provider_account_json,
credential_version: row.credential_version,
}
}
@@ -352,29 +284,6 @@ fn normalize_scopes_json(value: String) -> Result<String, String> {
Ok(normalized.to_string())
}
fn normalize_key_purpose(value: &str) -> String {
match value.trim() {
EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER => EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER.to_string(),
_ => EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR.to_string(),
}
}
fn is_llm_router_purpose(value: &str) -> bool {
value.trim() == EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER
}
fn normalize_optional_secret(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn normalize_optional_metadata(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn external_api_key_single_ok(key: ExternalApiKeySnapshot) -> ExternalApiKeyProcedureResult {
ExternalApiKeyProcedureResult {
ok: true,
@@ -1418,32 +1418,6 @@ where
fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value {
let mut next_value = value.clone();
if table_name == "external_api_key" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:API Key 用途字段晚于外部 API Key 表加入,旧迁移包按外部编辑器 Key 兼容。
object
.entry("purpose".to_string())
.or_insert_with(|| serde_json::Value::from("external-editor"));
object
.entry("secret_ciphertext".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("provider_account_id".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("provider_base_url".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("provider_model".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("provider_account_json".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("credential_version".to_string())
.or_insert_with(|| serde_json::Value::from(1));
}
}
if table_name == "profile_wallet_config" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:旧迁移包没有每日免费额度字段,导入时保持原有每日 20 泥点语义。