Merge pull request 'Feat/pricing plan limit' (#603) from feat/pricing-plan-limit into feat/pricing-plan
Project CI / Backend tests (pull_request) Failing after 33s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m38s
Project CI / Native shell tests (pull_request) Failing after 1m34s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 3m52s
Project CI / Repository checks (pull_request) Failing after 34s
Project CI / Frontend tests (pull_request) Failing after 1m26s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m13s
Project CI / Backend tests (pull_request) Failing after 33s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m38s
Project CI / Native shell tests (pull_request) Failing after 1m34s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 3m52s
Project CI / Repository checks (pull_request) Failing after 34s
Project CI / Frontend tests (pull_request) Failing after 1m26s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m13s
Reviewed-on: #603
This commit was merged in pull request #603.
This commit is contained in:
+8
-4
@@ -326,12 +326,16 @@ _Avoid_: 旧的月卡 / 季卡 / 年卡命名、把档位写成自由字符串
|
||||
_Avoid_: 把价格写死在代码、按档位名字硬编码权益
|
||||
|
||||
**模型权限**:
|
||||
会员档位允许选择的模型范围;`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。
|
||||
_Avoid_: 计价分辨率档、模型定价档位
|
||||
会员档位允许用户选择的 Agent 模型范围,分「基础」与「高性能」两档;每个模型属于哪一档由模型目录的权限标注决定,`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。
|
||||
_Avoid_: 计价分辨率档、模型定价档位、把档位写成自由字符串
|
||||
|
||||
**并发上限**:
|
||||
同一账号可同时运行的独立生成任务数上限;`Max` 不设上限。
|
||||
_Avoid_: 旧的排队展示字段、把「不设上限」写成空值
|
||||
同一账号可同时处于执行中的服务端生成任务数上限;排队等待中的任务不计入,`Max` 不设上限。
|
||||
_Avoid_: 旧的排队展示字段、把「不设上限」写成空值、把排队中的任务计入
|
||||
|
||||
**独立生成任务**:
|
||||
用户提交、由服务端排队并执行的单条生成工作单元(图片 / 视频 / 音频等),带独立生命周期与计点;并发上限只对处于执行中的该任务计数,与用户本地的 Agent 回合无关。
|
||||
_Avoid_: 本地 Agent 回合、前端提交节流、直连同步生成请求(当前不计入并发)
|
||||
|
||||
## Relationships
|
||||
|
||||
|
||||
@@ -1174,6 +1174,7 @@ export interface AdminWalletRestrictionResponse {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
}
|
||||
export type AdminAgcAgentMode = 'codex' | 'cc';
|
||||
export type AdminAgcModelAccess = 'basic' | 'full';
|
||||
export type AdminAgcModelProtocol =
|
||||
| 'openai_responses'
|
||||
| 'openai_chat'
|
||||
@@ -1187,6 +1188,8 @@ export interface AdminAgcModel {
|
||||
agentMode: AdminAgcAgentMode;
|
||||
/** 上游线协议,与执行器正交。 */
|
||||
protocol?: AdminAgcModelProtocol;
|
||||
/** 该模型要求的会员档位,缺省 `basic`(失败开放)。 */
|
||||
access?: AdminAgcModelAccess;
|
||||
}
|
||||
|
||||
export interface AdminAgcModelCatalog {
|
||||
|
||||
@@ -101,10 +101,46 @@ test('new models default to Codex and save the selected Agent mode', async () =>
|
||||
modelId: '',
|
||||
enabled: true,
|
||||
agentMode: 'cc',
|
||||
access: 'basic',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('权限档可按模型单独选择并随目录保存', async () => {
|
||||
const catalog = {
|
||||
revision: 3,
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{
|
||||
id: 'quality',
|
||||
alias: '高质量',
|
||||
modelId: 'gpt-6-astra',
|
||||
enabled: true,
|
||||
agentMode: 'codex' as const,
|
||||
access: 'basic' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
|
||||
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
|
||||
...input,
|
||||
revision: 4,
|
||||
}));
|
||||
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByDisplayValue('gpt-6-astra');
|
||||
|
||||
const access = screen.getByLabelText('模型 1 权限档') as HTMLSelectElement;
|
||||
expect(access.value).toBe('basic');
|
||||
fireEvent.change(access, { target: { value: 'full' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
|
||||
const payload = calls[calls.length - 1]?.[1];
|
||||
expect(payload?.models[0]).toMatchObject({ access: 'full' });
|
||||
});
|
||||
});
|
||||
|
||||
test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => {
|
||||
const catalog = {
|
||||
revision: 3,
|
||||
|
||||
@@ -118,6 +118,7 @@ export function AdminAgcModelsPage({
|
||||
{ key: 'modelId', label: '实际模型名' },
|
||||
{ key: 'agentMode', label: 'Agent 模式' },
|
||||
{ key: 'protocol', label: '协议' },
|
||||
{ key: 'access', label: '权限档' },
|
||||
{ key: 'enabled', label: '启用' },
|
||||
{ key: 'default', label: '默认' },
|
||||
{ key: 'actions', label: '操作' },
|
||||
@@ -159,6 +160,7 @@ export function AdminAgcModelsPage({
|
||||
enabled: true,
|
||||
agentMode: 'codex',
|
||||
protocol: 'openai_responses',
|
||||
access: 'basic',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -246,6 +248,21 @@ export function AdminAgcModelsPage({
|
||||
<option value="anthropic">Anthropic Messages</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`模型 ${index + 1} 权限档`}
|
||||
value={model.access ?? 'basic'}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, {
|
||||
access: e.target.value as AdminAgcModel['access'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="basic">基础模型</option>
|
||||
<option value="full">高性能模型</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 启用`}
|
||||
|
||||
@@ -6,40 +6,12 @@ use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER;
|
||||
use shared_contracts::llm::{LlmModelSummary, LlmModelsResponse};
|
||||
use shared_contracts::llm::LlmModelsResponse;
|
||||
use std::time::Duration;
|
||||
|
||||
const API_RESPONSE_ENVELOPE_VERSION: &str = "v1";
|
||||
const MODEL_CATALOG_MAX_BYTES: usize = 1024 * 1024;
|
||||
|
||||
fn default_codex_agent_mode() -> String {
|
||||
"codex".to_string()
|
||||
}
|
||||
|
||||
fn default_model_protocol() -> String {
|
||||
"openai_responses".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireModelSummary {
|
||||
id: String,
|
||||
display_name: String,
|
||||
#[serde(default = "default_codex_agent_mode")]
|
||||
agent_mode: String,
|
||||
/// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。
|
||||
#[serde(default = "default_model_protocol")]
|
||||
protocol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireModelCatalog {
|
||||
default_model_id: String,
|
||||
models: Vec<WireModelSummary>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiErrorPayload {
|
||||
code: Option<String>,
|
||||
@@ -49,27 +21,12 @@ struct ApiErrorPayload {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiEnvelope {
|
||||
ok: bool,
|
||||
data: Option<WireModelCatalog>,
|
||||
/// 直接复用 shared-contracts 的目录 DTO:`agentMode` / `protocol` / `unavailableModels`
|
||||
/// 都带 `#[serde(default)]`,旧服务端缺字段时按 Codex + OpenAI Responses + 空桶兼容。
|
||||
data: Option<LlmModelsResponse>,
|
||||
error: Option<ApiErrorPayload>,
|
||||
}
|
||||
|
||||
fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse {
|
||||
LlmModelsResponse {
|
||||
default_model_id: catalog.default_model_id,
|
||||
models: catalog
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|model| LlmModelSummary {
|
||||
id: model.id,
|
||||
display_name: model.display_name,
|
||||
agent_mode: model.agent_mode,
|
||||
protocol: model.protocol,
|
||||
})
|
||||
.collect(),
|
||||
revision: catalog.revision,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_catalog_payload(bytes: &[u8]) -> Result<LlmModelsResponse, String> {
|
||||
let value: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?;
|
||||
@@ -98,12 +55,10 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result<LlmModelsResponse, String> {
|
||||
}
|
||||
return envelope
|
||||
.data
|
||||
.map(map_catalog)
|
||||
.ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string());
|
||||
}
|
||||
|
||||
serde_json::from_value::<WireModelCatalog>(value)
|
||||
.map(map_catalog)
|
||||
serde_json::from_value::<LlmModelsResponse>(value)
|
||||
.map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())
|
||||
}
|
||||
|
||||
@@ -293,6 +248,48 @@ mod tests {
|
||||
.contains("x-genarrative-response-envelope: v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_maps_unknown_unavailable_reason_to_unknown() {
|
||||
let body = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": {
|
||||
"defaultModelId": "quality",
|
||||
"models": [{
|
||||
"id": "quality",
|
||||
"displayName": "高质量",
|
||||
"agentMode": "codex",
|
||||
"protocol": "openai_responses"
|
||||
}],
|
||||
"unavailableModels": [{
|
||||
"id": "pro",
|
||||
"displayName": "专业版",
|
||||
"agentMode": "cc",
|
||||
"protocol": "anthropic",
|
||||
"reason": "some_future_reason"
|
||||
}],
|
||||
"revision": 8
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let catalog = parse_catalog_payload(body.as_bytes()).expect("catalog should parse");
|
||||
assert_eq!(catalog.models.len(), 1);
|
||||
assert_eq!(catalog.unavailable_models.len(), 1);
|
||||
assert_eq!(
|
||||
catalog.unavailable_models[0].reason,
|
||||
shared_contracts::llm::LlmModelUnavailableReason::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.unavailable_models[0].agent_mode,
|
||||
shared_contracts::llm::AgcAgentMode::Cc
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.unavailable_models[0].protocol,
|
||||
shared_contracts::llm::AgcModelProtocol::Anthropic
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture");
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
catalog: {
|
||||
defaultModelId: 'basic-model',
|
||||
models: [
|
||||
{
|
||||
id: 'basic-model',
|
||||
displayName: '基础模型',
|
||||
agentMode: 'codex' as const,
|
||||
protocol: 'openai_responses' as const,
|
||||
},
|
||||
],
|
||||
unavailableModels: [
|
||||
{
|
||||
id: 'pro-model',
|
||||
displayName: '专业模型',
|
||||
agentMode: 'codex' as const,
|
||||
protocol: 'openai_responses' as const,
|
||||
reason: 'plan_required' as const,
|
||||
},
|
||||
{
|
||||
id: 'retired-model',
|
||||
displayName: '下线模型',
|
||||
agentMode: 'cc' as const,
|
||||
protocol: 'anthropic' as const,
|
||||
reason: 'disabled' as const,
|
||||
},
|
||||
],
|
||||
revision: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../app/tauri', () => ({
|
||||
resolveTauriInvoke: () => mocks.invoke,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/llmModelCatalog', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../services/llmModelCatalog')>();
|
||||
return {
|
||||
...actual,
|
||||
cachedLlmModelCatalog: () => mocks.catalog,
|
||||
refreshLlmModelCatalog: () => Promise.resolve(mocks.catalog),
|
||||
};
|
||||
});
|
||||
|
||||
import { ConversationModelSelect } from './ConversationModelSelect';
|
||||
|
||||
function mockConfig(selectedModelId: string, isDefault: boolean) {
|
||||
mocks.invoke.mockImplementation(async (command: string) => {
|
||||
if (command === 'read_game_creator_app_config') {
|
||||
return {
|
||||
config: {
|
||||
selectedModelId,
|
||||
selectedModelIsDefault: isDefault,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === 'select_game_creator_model') {
|
||||
return {
|
||||
config: {
|
||||
selectedModelId: 'basic-model',
|
||||
selectedModelIsDefault: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mocks.invoke.mockReset();
|
||||
});
|
||||
|
||||
describe('ConversationModelSelect 锁定模型', () => {
|
||||
it('不可用模型渲染为不可点、可聚焦的原因项', async () => {
|
||||
mockConfig('basic-model', true);
|
||||
render(<ConversationModelSelect disabled={false} />);
|
||||
// 等挂载时的目录同步落地,避免把异步状态更新留到断言之后。
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith('read_game_creator_app_config'),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
|
||||
const planLocked = screen.getByText('专业模型').closest('[role="option"]');
|
||||
expect(planLocked?.tagName).toBe('DIV');
|
||||
expect(planLocked?.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(planLocked?.getAttribute('tabindex')).toBe('0');
|
||||
expect(planLocked?.getAttribute('data-reason')).toBe('plan_required');
|
||||
expect(planLocked?.getAttribute('data-tooltip')).toBe('订阅计划不支持');
|
||||
|
||||
const disabledLocked = screen
|
||||
.getByText('下线模型')
|
||||
.closest('[role="option"]');
|
||||
expect(disabledLocked?.getAttribute('data-tooltip')).toBe('该模型已下线');
|
||||
});
|
||||
|
||||
it('已选模型落进不可用桶时按 reason 回退默认并提示', async () => {
|
||||
mockConfig('retired-model', false);
|
||||
render(<ConversationModelSelect disabled={false} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('status').textContent).toBe(
|
||||
'所选模型已下线,已切换为默认模型',
|
||||
),
|
||||
);
|
||||
expect(mocks.invoke).toHaveBeenCalledWith(
|
||||
'select_game_creator_model',
|
||||
expect.objectContaining({ modelId: 'basic-model', isDefault: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
+44
-2
@@ -1,4 +1,4 @@
|
||||
import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
|
||||
import { Check, ChevronDown, Lock, RefreshCcw } from 'lucide-react';
|
||||
import type { Ref } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
@@ -14,10 +14,15 @@ import {
|
||||
cachedLlmModelCatalog,
|
||||
type ClientLlmModel,
|
||||
type ClientLlmModelCatalog,
|
||||
type ClientLlmUnavailableModel,
|
||||
LLM_CONFIG_CHANGED_EVENT,
|
||||
LlmModelCatalogConfigError,
|
||||
refreshLlmModelCatalog,
|
||||
} from '../../services/llmModelCatalog';
|
||||
import {
|
||||
unavailableModelFallbackNotice,
|
||||
unavailableModelReasonLabel,
|
||||
} from './modelAvailabilityCopy';
|
||||
|
||||
/**
|
||||
* 线协议与执行器正交;目录没下发时按执行器推导默认协议,保证发往后端的组合始终合法。
|
||||
@@ -76,6 +81,9 @@ export function ConversationModelSelect({
|
||||
const [models, setModels] = useState<ClientLlmModel[]>(
|
||||
initialCatalog?.models ?? [],
|
||||
);
|
||||
const [unavailableModels, setUnavailableModels] = useState<
|
||||
ClientLlmUnavailableModel[]
|
||||
>(initialCatalog?.unavailableModels ?? []);
|
||||
const [selected, setSelected] = useState('');
|
||||
const [defaultModelId, setDefaultModelId] = useState(
|
||||
initialCatalog?.defaultModelId ?? '',
|
||||
@@ -136,6 +144,9 @@ export function ConversationModelSelect({
|
||||
showBusy: boolean,
|
||||
epochAtRequest: number,
|
||||
) => {
|
||||
// 目录契约的 `unavailableModels` 是加法字段:旧缓存 / 局部数据可能缺失。
|
||||
// 统一归一化一次,避免下面 setState 与按 reason 取文案时解引用 undefined。
|
||||
const catalogUnavailableModels = catalog.unavailableModels ?? [];
|
||||
if (
|
||||
mountedRef.current &&
|
||||
(appliedRevisionRef.current !== catalog.revision ||
|
||||
@@ -143,6 +154,7 @@ export function ConversationModelSelect({
|
||||
) {
|
||||
appliedRevisionRef.current = catalog.revision;
|
||||
setModels(catalog.models);
|
||||
setUnavailableModels(catalogUnavailableModels);
|
||||
setDefaultModelId(catalog.defaultModelId);
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
@@ -178,7 +190,13 @@ export function ConversationModelSelect({
|
||||
if (!next && defaultEnabled) {
|
||||
next = catalog.defaultModelId;
|
||||
nextIsDefault = true;
|
||||
if (saved) nextNotice = '所选模型已停用,已切换为默认模型';
|
||||
if (saved) {
|
||||
// 命中不可用桶时按 reason 给具体原因;两桶都没有(后台直接删除)走泛化文案。
|
||||
nextNotice = unavailableModelFallbackNotice(
|
||||
catalogUnavailableModels.find((model) => model.id === saved)
|
||||
?.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (next && (next !== saved || nextIsDefault !== followsDefault)) {
|
||||
let persisted: GameCreatorAppConfigView;
|
||||
@@ -253,6 +271,7 @@ export function ConversationModelSelect({
|
||||
appliedRevisionRef.current = null;
|
||||
selectedRef.current = '';
|
||||
setModels([]);
|
||||
setUnavailableModels([]);
|
||||
setSelected('');
|
||||
setDefaultModelId('');
|
||||
setError(modelCatalogErrorMessage(error));
|
||||
@@ -315,6 +334,7 @@ export function ConversationModelSelect({
|
||||
selectedRef.current = '';
|
||||
setSelected('');
|
||||
setModels([]);
|
||||
setUnavailableModels([]);
|
||||
void syncCatalog(true);
|
||||
}
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
@@ -455,6 +475,28 @@ export function ConversationModelSelect({
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{/* 目录里存在但当前档位不可用的模型:不可点,仅 hover / 键盘 focus 出原因。 */}
|
||||
{unavailableModels.map((model) => {
|
||||
const reasonLabel = unavailableModelReasonLabel(model.reason);
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
aria-disabled="true"
|
||||
aria-label={`${model.displayName}:${reasonLabel}`}
|
||||
tabIndex={0}
|
||||
className="conversation-model-menu-option conversation-model-menu-option-locked"
|
||||
data-reason={model.reason}
|
||||
data-tooltip={reasonLabel}
|
||||
>
|
||||
<span className="conversation-model-menu-option-main">
|
||||
<span>{model.displayName}</span>
|
||||
</span>
|
||||
<Lock size={13} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="conversation-model-menu-divider" />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
unavailableModelFallbackNotice,
|
||||
unavailableModelReasonLabel,
|
||||
} from './modelAvailabilityCopy';
|
||||
|
||||
describe('modelAvailabilityCopy', () => {
|
||||
it('锁定项文案按 reason 取值,unknown 兜底', () => {
|
||||
expect(unavailableModelReasonLabel('plan_required')).toBe('订阅计划不支持');
|
||||
expect(unavailableModelReasonLabel('disabled')).toBe('该模型已下线');
|
||||
expect(unavailableModelReasonLabel('unknown')).toBe('暂不可用');
|
||||
});
|
||||
|
||||
it('降级文案按 reason 取值,两桶都没有时泛化', () => {
|
||||
expect(unavailableModelFallbackNotice('plan_required')).toBe(
|
||||
'所选模型需要更高订阅计划,已切换为默认模型',
|
||||
);
|
||||
expect(unavailableModelFallbackNotice('disabled')).toBe(
|
||||
'所选模型已下线,已切换为默认模型',
|
||||
);
|
||||
expect(unavailableModelFallbackNotice('unknown')).toBe(
|
||||
'所选模型已不可用,已切回默认模型',
|
||||
);
|
||||
expect(unavailableModelFallbackNotice(undefined)).toBe(
|
||||
'所选模型已不可用,已切回默认模型',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ClientLlmModelUnavailableReason } from '../../services/llmModelCatalog';
|
||||
|
||||
/**
|
||||
* 锁定项 hover / 键盘 focus 的原因文案。
|
||||
*
|
||||
* `unknown` 是契约的前向兜底,后端将来新增 `reason` 时旧客户端仍给出可读文案。
|
||||
*/
|
||||
export function unavailableModelReasonLabel(
|
||||
reason: ClientLlmModelUnavailableReason,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case 'plan_required':
|
||||
return '订阅计划不支持';
|
||||
case 'disabled':
|
||||
return '该模型已下线';
|
||||
default:
|
||||
return '暂不可用';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 已选模型不可用时的降级提示。
|
||||
*
|
||||
* `unavailableReason` 命中目录不可用桶时按原因给具体文案;两桶都没有(后台直接删除)
|
||||
* 传 `undefined`,用泛化文案。
|
||||
*/
|
||||
export function unavailableModelFallbackNotice(
|
||||
unavailableReason: ClientLlmModelUnavailableReason | undefined,
|
||||
): string {
|
||||
switch (unavailableReason) {
|
||||
case 'plan_required':
|
||||
return '所选模型需要更高订阅计划,已切换为默认模型';
|
||||
case 'disabled':
|
||||
return '所选模型已下线,已切换为默认模型';
|
||||
default:
|
||||
return '所选模型已不可用,已切回默认模型';
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,28 @@
|
||||
import type {
|
||||
AgcAgentMode,
|
||||
AgcModelProtocol,
|
||||
LlmModelsResponse,
|
||||
LlmModelSummary,
|
||||
LlmModelUnavailableReason,
|
||||
LlmUnavailableModel,
|
||||
} from '@genarrative/shared';
|
||||
|
||||
import { resolveTauriInvoke } from '../app/tauri';
|
||||
|
||||
export type ClientLlmModel = {
|
||||
displayName: string;
|
||||
id: string;
|
||||
/** 执行器:codex 或 cc。线协议是与之正交的独立维度。 */
|
||||
agentMode: 'codex' | 'cc';
|
||||
/** 上游线协议;服务端未下发时按执行器的默认协议解释。 */
|
||||
protocol?: 'openai_responses' | 'openai_chat' | 'anthropic';
|
||||
};
|
||||
|
||||
export type ClientLlmModelCatalog = {
|
||||
defaultModelId: string;
|
||||
models: ClientLlmModel[];
|
||||
revision: number;
|
||||
};
|
||||
|
||||
type ClientLlmModelWire = Omit<ClientLlmModel, 'agentMode'> & {
|
||||
agentMode?: ClientLlmModel['agentMode'] | null;
|
||||
};
|
||||
|
||||
type ClientLlmModelCatalogWire = Omit<ClientLlmModelCatalog, 'models'> & {
|
||||
models: ClientLlmModelWire[];
|
||||
};
|
||||
/**
|
||||
* 模型目录类型直接复用 Rust `shared-contracts::llm_catalog` 的 ts-rs 生成物:
|
||||
* 不再手写第二份 `ClientLlmModel`,`agentMode` / `protocol` / `reason` 的取值以契约为准。
|
||||
*/
|
||||
export type ClientLlmModel = LlmModelSummary;
|
||||
export type ClientLlmUnavailableModel = LlmUnavailableModel;
|
||||
export type ClientLlmModelUnavailableReason = LlmModelUnavailableReason;
|
||||
export type ClientLlmAgentMode = AgcAgentMode;
|
||||
export type ClientLlmModelProtocol = AgcModelProtocol;
|
||||
export type ClientLlmModelCatalog = LlmModelsResponse;
|
||||
|
||||
export async function loadClientLlmModels(): Promise<ClientLlmModelCatalog> {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) throw new Error('本地模型服务不可用');
|
||||
const catalog = await invoke<ClientLlmModelCatalogWire>(
|
||||
'load_game_creator_llm_models',
|
||||
);
|
||||
return {
|
||||
...catalog,
|
||||
models: catalog.models.map((model) => ({
|
||||
...model,
|
||||
agentMode: model.agentMode ?? 'codex',
|
||||
})),
|
||||
};
|
||||
// Rust typed command 已经把响应解析成 `LlmModelsResponse`(含 unavailableModels)。
|
||||
return invoke<ClientLlmModelCatalog>('load_game_creator_llm_models');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ import { resolveTauriInvoke } from '../app/tauri';
|
||||
import type { GameCreatorAppConfigView } from '../app/types';
|
||||
import { type ClientLlmModelCatalog, loadClientLlmModels } from './clientApi';
|
||||
|
||||
export type { ClientLlmModel, ClientLlmModelCatalog } from './clientApi';
|
||||
export type {
|
||||
ClientLlmAgentMode,
|
||||
ClientLlmModel,
|
||||
ClientLlmModelCatalog,
|
||||
ClientLlmModelProtocol,
|
||||
ClientLlmModelUnavailableReason,
|
||||
ClientLlmUnavailableModel,
|
||||
} from './clientApi';
|
||||
|
||||
let cached: ClientLlmModelCatalog | null = null;
|
||||
let inFlight: Promise<ClientLlmModelCatalog> | null = null;
|
||||
@@ -52,7 +59,9 @@ async function loadEffectiveCatalog(epoch: number) {
|
||||
id,
|
||||
displayName: id,
|
||||
agentMode: 'codex' as const,
|
||||
protocol: 'openai_responses' as const,
|
||||
})),
|
||||
unavailableModels: [],
|
||||
revision: --localRevision,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11066,6 +11066,52 @@ button.design-workspace-tree__entry:hover,
|
||||
font-style: normal;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.conversation-model-menu-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
padding: 0 9px;
|
||||
border-radius: 6px;
|
||||
color: var(--platform-text-soft, #6b7280);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.conversation-model-menu-option-locked {
|
||||
position: relative;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.conversation-model-menu-option-locked:hover,
|
||||
.conversation-model-menu-option-locked:focus-visible {
|
||||
background: var(--platform-button-ghost-fill, #f3f4f6);
|
||||
outline: none;
|
||||
}
|
||||
/* 锁定项的原因提示:仅 hover / 键盘 focus 出现(AGC 桌面端,无移动端适配)。 */
|
||||
.conversation-model-menu-option-locked[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 4px);
|
||||
z-index: 30;
|
||||
display: none;
|
||||
width: max-content;
|
||||
max-width: 220px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
background: rgb(17 24 39 / 92%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
}
|
||||
.conversation-model-menu-option-locked[data-tooltip]:hover::after,
|
||||
.conversation-model-menu-option-locked[data-tooltip]:focus-visible::after {
|
||||
display: block;
|
||||
}
|
||||
.conversation-model-menu-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -315,7 +315,7 @@ test('falls back to the default model when the saved selection was removed', asy
|
||||
}));
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await screen.findByText('所选模型已停用,已切换为默认模型');
|
||||
await screen.findByText('所选模型已不可用,已切回默认模型');
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
expect(screen.queryByText('private-old-model')).toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
|
||||
@@ -521,12 +521,22 @@ test('pre-send validation falls back when the selected model is disabled', async
|
||||
vi.mocked(loadClientLlmModels).mockResolvedValue({
|
||||
defaultModelId: 'quality',
|
||||
models: [{ id: 'quality', displayName: '高质量', agentMode: 'codex' }],
|
||||
// `fast` 仍在下线桶里:降级文案要按 `disabled` 取「该模型已下线」。
|
||||
unavailableModels: [
|
||||
{
|
||||
id: 'fast',
|
||||
displayName: '快速',
|
||||
agentMode: 'cc',
|
||||
protocol: 'anthropic',
|
||||
reason: 'disabled',
|
||||
},
|
||||
],
|
||||
revision: 2,
|
||||
});
|
||||
await act(async () => {
|
||||
await expect(ref.current?.ensureUsable()).resolves.toBe(true);
|
||||
});
|
||||
expect(screen.getByText('所选模型已停用,已切换为默认模型')).not.toBeNull();
|
||||
expect(screen.getByText('所选模型已下线,已切换为默认模型')).not.toBeNull();
|
||||
expect(savedModelId).toBe('quality');
|
||||
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
|
||||
modelId: 'quality',
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
- [会员账期按开通日自然月](./adr/【ADR】会员账期按开通日自然月-2026-10-02.md):北京时间开通日锚点、自然月推进与月末夹取、年付 12 期的期数表达与取整口径。
|
||||
- [会员订单复用充值订单与补差升级幂等锚点](./adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md):会员订单复用 `profile_recharge_order`、`MembershipUpgradeGrant` 账本幂等、后端只读报价与退款人工复核边界。
|
||||
- [会员档位以枚举为权威](./adr/【ADR】会员档位以枚举为权威-2026-10-02.md):档位身份改为 Rust 枚举、目录表 `profile_membership_plan` 以枚举为主键、删除两处 `tier` 列与旧档位枚举、并发用哨兵值 `128` 表示不设上限。
|
||||
- [模型权限与并发上限的强制边界](./adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md):并发上限只在认领事务按账号过滤队列 job、只算 `running`;模型权限只约束 AGC LLM 模型、缺省 `Basic` 由后台人工标 `Full`;raw gpt-image-2 等直连同步生成暂不计入并发。
|
||||
|
||||
## 后端、运维与测试
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 模型权限与并发上限的强制边界
|
||||
|
||||
## Status
|
||||
|
||||
accepted(2026-10-03)
|
||||
|
||||
## 背景与决策
|
||||
|
||||
`profile_membership_plan` 的 `model_access` 与 `concurrent_job_limit` 此前只落字段、只用于展示,服务端不做拦截。2026-10-03 决定升级为服务端强制,并划清本期边界:
|
||||
|
||||
- **并发上限**只在已有的 `claim_external_generation_jobs_tx`(唯一的 `pending → running` 事务点)按账号过滤,只统计 `status = running` 的 `external_generation_job`;不新建队列、不设排队深度上限、不新增计数表(加 `(owner_user_id, status)` 组合索引现算)。上限读后台目录 `profile_membership_plan`,缺行失败关闭到 `Normal`(=1),`128` 哨兵表示不设上限。
|
||||
- **模型权限**只约束 AGC 的 LLM 模型:`AgcModel` 末位加权限档,**缺省 `Basic`(失败开放)**,由人工在后台「AGC 模型」页手动标 `Full`;在 `GET /api/llm/models` 与代理解析处校验,越权返回 4xx `MODEL_NOT_AVAILABLE_FOR_PLAN`。
|
||||
- **直连同步生成暂不计入并发**:`/api/raw/v1/images/edit`(gpt-image-2)、`editor_project` 直连图片 / 图标 / 背景、角色资源与音频等路径不产生 job 行,`running` 计数覆盖不到;统一「账号在飞生成」口径留作后续设计。
|
||||
- 不设灰度开关,直接对所有账号生效。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- 新增 SpacetimeDB「在飞租约表」统一统计队列 job 与直连同步生成:更完整,但需要租约、心跳与回收语义,本期未做。
|
||||
- 把直连同步生成统一改走 `external_generation_job`:最一致,但要重做 raw / editor 直连接口,风险最大。
|
||||
- 模型档位缺省 `Full`(失败关闭):更安全,但上线即需先标注全部基础模型,运营成本高。
|
||||
|
||||
## 后果
|
||||
|
||||
- 模型限制在人工标注 `Full` 之前不会拦截任何请求。
|
||||
- `raw` gpt-image-2 与其它直连同步生成不受 `concurrent_job_limit` 约束(TODO)。
|
||||
- 无灰度开关;出问题只能回滚发版。
|
||||
|
||||
## 修订:列表改为可用 / 不可用分桶(2026-10-03)
|
||||
|
||||
原实现让 `GET /api/llm/models` 只返回本档可用模型(`Basic` 看不到 `Full`),降级用户在客户端只会看到「所选模型已停用」的笼统提示,且产品无法在列表里露出可升级的高档模型。修订为:
|
||||
|
||||
- 返回两个平行数组:`models` 只放本档可用(`enabled` 且档位允许);`unavailableModels` 放目录里其余全部,元素额外带 `reason` 枚举。
|
||||
- `reason` 取值 `plan_required`(档位不够)/ `disabled`(后台停用)/ `unknown`(前向兜底);同时停用且档位不够时取 `disabled`(升级也解锁不了,不能标 `plan_required`)。
|
||||
- 上游真实模型名(`modelId`)两桶都不下发;`defaultModelId` 必须落在 `models` 内。
|
||||
- 分桶只是展示层的信息补充,**不放松服务端强制**:`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 仍按档 4xx。
|
||||
- `unavailableModels` 是加法字段:旧客户端忽略后行为不变;`reason` 在 wire 侧带 `unknown` 兜底,后端新增取值不会让旧客户端解析失败。
|
||||
- 目录 DTO 与枚举改为 ts-rs 单一真源(whole DTO 导出),`agentMode` / `protocol` 由 `String` 收敛为 Rust 枚举。
|
||||
@@ -17,6 +17,8 @@
|
||||
2. 拉不到就报错、不写替代目录,并在下一次启动继续重试,直到目录里有数据。
|
||||
3. **保持既有格式与契约不变**:目录字段(`id`/`alias`/`modelId`/`defaultModelId`)、后台页面与 DTO、`GET /api/llm/models` 形状、客户端模型标识校验都不变,不引入不兼容变更。
|
||||
|
||||
> 2026-10-03 修订:本条「`GET /api/llm/models` 形状不变」只约束本里程碑自身;后续「模型权限」变更已把该接口改为可用 / 不可用分桶(新增 `unavailableModels` 与 `reason` 枚举),见 `docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md` 的修订节。
|
||||
|
||||
## 不在本里程碑内
|
||||
|
||||
- 不改目录字段语义与后台维护方式,不删别名/稳定标识概念。
|
||||
|
||||
@@ -9621,6 +9621,44 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 验证:`cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web` → 26 files / 248 tests passed(新增「会员订单在发放泥点列展示会员变更快照」及用户详情会员断言)。
|
||||
- 边界:`/admin/api/*` 不在 `/api/external/v1` OpenAPI 门禁内,本次未补接口级契约测试,会员快照只在管理端页面用例层面取证。
|
||||
|
||||
|
||||
## 2026-10-03 模型权限与并发上限的强制边界(拷问定案)
|
||||
|
||||
- 背景:`model_access` 与 `concurrent_job_limit` 此前只落字段、只用于展示,服务端零拦截;用户点出 raw gpt-image-2 这类同步生成,确认「只统计 job 的 `running`」覆盖不到它,因此先把「并发单位」定义清楚再强制。
|
||||
- 决策(并发上限只约束队列 job):强制点放在唯一的 `pending → running` 事务点 `claim_external_generation_jobs_tx`,认领时按账号统计 `running` 数、达上限则跳过(任务留在 `pending`,天然排队,不新建队列)。只算 `running`(含过期待回收的 `expired_running`);失败退回 `pending` 重试释放名额;自身过期 `running` 回收豁免;上限读 `profile_membership_plan.concurrent_job_limit`,缺行失败关闭到 `Normal`(=1),`128` 哨兵为不设上限;计数用新增 `(owner_user_id, status)` 组合索引现算,不建计数表;`/api/external/v1` 任务同样计入;不设排队深度上限。
|
||||
- 决策(模型权限只约束 AGC LLM 模型):`AgcModel` 末位加权限档、缺省 `Basic`(失败开放),由人工在后台「AGC 模型」页手动标 `Full`;`GET /api/llm/models` 按档过滤并把 `defaultModelId` 取可用集合首个,`/api/llm/responses` 经 `resolve_requested` 后校验、越权返 `MODEL_NOT_AVAILABLE_FOR_PLAN`;本地残留旧选择明确 4xx,不静默回退;不设灰度开关。
|
||||
- 边界(本轮不做):直连同步生成(raw gpt-image-2、`editor_project` 直连图片 / 图标 / 背景、角色资源、音频)没有 job 行,`running` 计数覆盖不到;统一「账号在飞生成」口径(在飞租约表 / 或统一改走 job)留作后续 TODO。
|
||||
- 影响范围:`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md`(新增 §11,修订 §9/§10)、`CONTEXT.md`(并发上限 / 新增「独立生成任务」)、`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`、`docs/README.md`。未改代码。
|
||||
- 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。
|
||||
|
||||
## 2026-10-03 模型权限与并发上限落地实现
|
||||
|
||||
- 背景:承接同日「模型权限与并发上限的强制边界(拷问定案)」,把定案按 §11.4 编码约定落地;本文记实现拆分与验证,不再重复决策理由。
|
||||
- 实现(`module-runtime`,`b78003143`):新增 `agc_model_access.rs`(`AgcModelAccess::Basic/Full`、`MODEL_NOT_AVAILABLE_FOR_PLAN`、`AgcModelResolveError::{Unavailable, NotAvailableForPlan}`、`RuntimeProfileMembershipModelAccess → AgcModelAccess`);`AgcModel` 末位追加 `#[serde(default)] access`(存量 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 增加 `available_models_for / default_model_id_for / resolve_requested_for`,默认模型优先取登记项、不在档内则回退目录顺序首个可用项、无可用项返回 `Unavailable`。
|
||||
- 实现(`spacetime-module`,`db8d467b8`):`external_generation_job` 追加 btree 组合索引 `(owner_user_id, status)`;`claim_external_generation_jobs_tx` 在 `pending → running` 前按 owner 现算 `running` 行数并过滤,达上限留在 `pending`;只算 `running`(含过期待回收),自身过期 `running` 回收豁免,`pending` 不占名额;`effective_profile_concurrent_job_limit` 走 `profile_membership.plan → concurrent_job_limit`,缺行失败关闭到 `Normal`(=1),`128` 为不限;抽取纯函数 `external_generation_claim_within_concurrency_limit` 并补单测。
|
||||
- 实现(后台,`919b4b01d`):`AdminAgcModel` 新增 `access`(缺省 `basic`),api-server 保存时只接受 `basic/full`,`GET` 回读 / `PUT` 写入;后台「AGC 模型」页新增「权限档」列与选择器,fake API 与页面用例覆盖。
|
||||
- 实现(`api-server`,`9b1a538d2`):新增 `llm/model_access.rs`,用现有 `get_profile_recharge_center` 解析账号档位到 `AgcModelAccess`,缺会员行 / 缺目录行 / 读失败统一失败关闭到 `Basic`;`GET /api/llm/models` 按档过滤且默认项取该档可用集合;`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全改按档解析;越权映射 403 + `MODEL_NOT_AVAILABLE_FOR_PLAN`,目录外 / 停用仍为 422。
|
||||
- 术语口径:`Basic` / `Full` 是模型对会员档的要求,`Normal` 等是会员档位;缺省语义统一「失败开放到基础档、失败关闭到 1 并发」——模型档缺省 `Basic` 是失败开放(不拦),并发上限缺行失败关闭到 `Normal`(=1)。
|
||||
- 验证:`cargo check -p api-server`、`cargo check -p api-server --tests`;`cargo test -p api-server llm` 49 passed、`model_catalog_tests` 3 passed(含 Basic 档过滤 Full 模型、403 错误码映射);`npm run check:spacetime-schema` 的失败项全部是分支既有 `profile_membership` / `profile_recharge_product_config` 字段变更(对照 `origin/master` merge-base `25beb3ad5`),与本次仅新增 btree 索引无关,guard 不比较索引;`npm run check:encoding`、`git diff --check`。
|
||||
- 边界 / TODO:账号档位仍复用带周期刷新写入的 `get_profile_recharge_center`,后续替换为轻量专用读;AGC 客户端仍靠「目录按档过滤 + 本地选择对账回退默认项」,未加专门的 403 重选提示;统一「账号在飞生成」口径(含无 job 行的同步生成)仍为后续 TODO。
|
||||
|
||||
## 2026-10-03 模型列表改为可用 / 不可用分桶(修订)
|
||||
|
||||
- 背景:`GET /api/llm/models` 原按档过滤,降级用户在客户端只看到「所选模型已停用」的笼统提示,且无法在列表里露出可升级的高档模型。
|
||||
- 决策(契约):返回 `models`(本档可用)与 `unavailableModels`(目录里其余全部,带 `reason` 枚举)两个平行数组,**不新增** `available` 布尔;`reason` 取 `plan_required` / `disabled` / `unknown`,同时停用且档位不够取 `disabled`;`unavailableModels` 保持目录顺序、恒定下发(无内容为空数组);上游真实模型名两桶都不下发;`defaultModelId` 必须落在 `models` 内;某档一个可用模型都没有时保留 503 `MODEL_UNAVAILABLE`。
|
||||
- 决策(兼容):`unavailableModels` 是加法字段,旧客户端忽略后行为不变,无需能力协商;`reason` 在 wire 侧带 `unknown` 兜底,后端将来新增取值不会让旧客户端解析失败;服务端按档强制不变。
|
||||
- 决策(客户端):AGC 桌面选择器把 `unavailableModels` 渲染为不可点项,仅 hover / 键盘 focus 出 tooltip(`plan_required`→「订阅计划不支持」、`disabled`→「该模型已下线」、`unknown`→「暂不可用」);降级仍自动回退默认,提示按 `reason` 取;后台直接删除(两桶都没有)用泛化文案「所选模型已不可用,已切回默认模型」;不再新增「当前已选不可用」的 API 字段,前端自行从 `unavailableModels` 推断。
|
||||
- 决策(ts-rs 单一真源):整个目录 DTO 与枚举导出到 `packages/shared/src/contracts/generated/`;新增 barrel `packages/shared/src/llm/modelCatalog.ts` 并由 `packages/shared/src/index.ts` 转发;`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts`(`module-runtime` re-export);`revision: u64` 标 `#[ts(as = "f64")]`;AGC 前端删除手写 `ClientLlmModel`,改从 `@genarrative/shared` 根导入。
|
||||
- 影响范围:`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`(修订节)、技术设计 §11.2/§11.4、`docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`、`docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md`、本文件。
|
||||
- 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。
|
||||
|
||||
## 2026-10-03 模型权限改走只读 procedure(性能收口)
|
||||
|
||||
- 背景:`api-server` 的 AGC 模型权限解析原复用 `get_profile_recharge_center`,它带幂等的账期 / 免费点刷新与钱包 / 商品等无关快照字段,位于每个 `/api/llm/*` 请求热路径;同日「落地实现」条目已把它列为 TODO。
|
||||
- 决策:新增只读 procedure `get_profile_agc_model_access_and_return`(输入沿用 `RuntimeProfileMembershipGetInput`,结果 `RuntimeProfileAgcModelAccessSnapshot { user_id, plan, model_access }`,仅 editor generation runtime service identity 可调):只读会员账期投影 + `profile_membership_plan.model_access`,缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`,不刷新、不写库;`api-server` 改调它,失败关闭语义不变。
|
||||
- 影响范围:`module-runtime`(领域类型 `9e397fe9c`)、`spacetime-module`(procedure,同提交)、生成绑定、`spacetime-client`(`get_profile_agc_model_access`)、`api-server/src/llm/model_access.rs`(`b0dc94b46`)、技术设计 §11.4。
|
||||
- 验证:`cargo check -p spacetime-module`、`cargo check -p spacetime-client`、`cargo check -p api-server --tests`;`cargo test -p api-server llm` 51 passed;`npm run check:encoding`、`git diff --check`;`npm run check:spacetime-schema` 失败项仍为分支既有 `profile_membership` / `profile_recharge_product_config` 表字段变更,与本次仅新增 procedure、未改表无关。
|
||||
|
||||
## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error)
|
||||
|
||||
- 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
- 上游变化不自动跟随:目录只在未初始化时重建;上游新增或移除模型由 owner 在后台增删条目或调整启用、默认项。
|
||||
- `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。
|
||||
- `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。
|
||||
- 2026-10-03 修订(模型权限):`GET /api/llm/models` 改为两个平行数组——`models` 只放本档可用;`unavailableModels` 放目录里其余(含后台停用与本档档位不够),元素带 `reason` 枚举(`plan_required` / `disabled` / `unknown`,同时停用且档位不够取 `disabled`)。`unavailableModels` 是加法字段,旧客户端忽略后行为不变,且不放松服务端按档强制;`defaultModelId` 仍必须落在 `models` 内。客户端把 `unavailableModels` 渲染为不可点项并 hover / focus 出原因(`plan_required`→「订阅计划不支持」、`disabled`→「该模型已下线」、`unknown`→「暂不可用」);已选模型落进不可用桶时自动回退默认并给出对应原因文案,后台直接删除(两桶都没有)用泛化文案。
|
||||
- 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。
|
||||
- 手动刷新立即显示进行中状态;真实刷新成功后显示完成反馈,即使 `revision` 未变化也有反馈。失败沿用有效缓存时仍显示失败,不能报告刷新成功;HTTP 状态和超时使用可辨认的提示。
|
||||
- 模型目录与其它客户端 JSON API 的成功、失败响应体读取均复用 `readClientHttpResponseText` 的 15 秒上限;响应头已返回但响应体卡住时必须结束本次等待、释放目录在途请求并允许重试,迟到的响应不得覆盖新目录。
|
||||
|
||||
@@ -511,6 +511,8 @@ flowchart TD
|
||||
| 14 | 下单接口 | 复用 `POST /api/profile/recharge/orders` |
|
||||
| 15 | `walletBalance` | 用户面不暴露 |
|
||||
|
||||
> **2026-10-03 更新**:上表第 6、7 行的「本期不实现限制」由 §11 取代——`model_access` 与 `concurrent_job_limit` 进入服务端强制。
|
||||
|
||||
## 10. 评审输入与实施清单
|
||||
|
||||
### 10.1 已确认输入(本轮拍板)
|
||||
@@ -558,10 +560,60 @@ flowchart TD
|
||||
|
||||
未做(依决策保留,不是遗漏):
|
||||
|
||||
- 模型权限与并发上限**只落字段**,服务端不做拦截。
|
||||
- 模型权限与并发上限**只落字段**,服务端不做拦截。(2026-10-03 改:已由 §11 升级为服务端强制,本条作废。)
|
||||
- 不提供 `availableTotalPoints`;合计由前端派生。
|
||||
- 会员购买 / 升级的 C 端 UI 未做(会员侧未上线,充值弹层只陈列泥点商品)。
|
||||
- 永久泥点仍是「总额 − 每日免费 − 月度」的推导余数,独立存储见
|
||||
[`【ADR】泥点三池以单一总额为权威`](../../adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md) 的 TODO 与触发条件。
|
||||
|
||||
## 11. 模型权限与并发上限的强制(2026-10-03 定案)
|
||||
|
||||
> 承接 §9 第 6/7 行与 §10「未做」:把 `model_access` 与 `concurrent_job_limit` 从「只落字段、只用于展示」升级为**服务端强制**。
|
||||
> 范围:`module-runtime`(目录与判定纯函数)、`spacetime-module`(认领事务 + schema 索引)、`api-server`(模型代理)、`shared-contracts`、后台「AGC 模型」页。**不含 C 端 UI、不含灰度开关。**
|
||||
|
||||
### 11.1 并发上限(只约束队列 job)
|
||||
|
||||
- **强制点唯一**:`claim_external_generation_jobs_tx`(`spacetime-module/src/external_generation.rs`)是唯一的 `pending → running` 事务点。认领时按账号统计在飞数,未达上限才置 `running`;达上限则**跳过**该账号的候选任务——任务保持 `pending`,天然形成排队,无需新建队列。
|
||||
- **计入口径**:只算 `status = running`(含 lease 过期待回收的 `expired_running`)。失败退回 `pending` 等待重试的任务**释放**名额;`pending`(含 `available_at` 延时重试)不计入。
|
||||
- **回收豁免**:同一账号 lease 已过期的 `running` 被回收重认领时**不受自身上限限制**,否则超限账号的卡死任务永远无法回收。
|
||||
- **上限来源**:`profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`(后台权威);缺会员行 / 缺目录行**失败关闭**到 `Normal`(=1);`128` 哨兵 = 不设上限(复用 `is_unlimited_concurrency`)。
|
||||
- **计数实现**:给 `external_generation_job` 追加 `(owner_user_id, status)` 组合索引,事务内现算 `running` 行数;**不**新增计数表(`running` 行即真相)。
|
||||
- **覆盖范围**:本期只约束 `external_generation_job`;`/api/external/v1` 触发、挂在账号下的任务**同样计入**。
|
||||
- **排队深度**:**不设**单账号 `pending` 深度上限。
|
||||
- **⚠️ TODO(用户保留,后续单独设计)**:**直连同步生成不计入本期并发**——`/api/raw/v1/images/edit`(gpt-image-2,见 [`【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07`](./【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md))、`editor_project` 直连图片 / 图标 / 背景、`character_visual_assets` / `character_animation_assets`、`vector_engine_audio_generation` 等路径**没有** `external_generation_job` 行,`running` 计数覆盖不到。统一「账号在飞生成」口径(在飞租约表 / 或统一改走 job)留作后续设计。当前全局护栏仍只有 worker 并发、`max_concurrent_requests` 与 raw 解码信号量。
|
||||
|
||||
### 11.2 模型权限(只约束 AGC LLM 模型)
|
||||
|
||||
- **字段**:`AgcModel`(`module-runtime/src/agc_models.rs`)末位追加权限档字段(`Basic` / `Full`),**缺省 `Basic`(失败开放)**;存量目录 JSON 缺该字段时按 `Basic` 解析。目录仍是一行 JSON + `revision` 乐观锁,非表。
|
||||
- **标注**:由**人工在后台「AGC 模型」页手动**把高性能模型标为 `Full`;代码里**不**写死清单、不播种。
|
||||
- **档位解析**:`load_llm_catalog(owner)` 已带 owner;由 `profile_membership.plan` → 目录 `model_access` 解析;缺档位失败关闭到 `Normal`(`Basic`)。
|
||||
- **生效点**:
|
||||
- `GET /api/llm/models`:返回 `models`(本档可用)与 `unavailableModels`(目录里其余,元素带 `reason` 枚举 `plan_required` / `disabled` / `unknown`;同时停用且档位不够取 `disabled`),`defaultModelId` 取该档可用集合的默认项(见 §11.4)。分桶只补充展示信息,不放松下述强校验。
|
||||
- `POST /api/llm/responses`:`catalog.resolve_requested()` 映射后校验档位,越权返回 4xx 专用错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。
|
||||
- `POST /api/llm/chat/completions`(`model` 恒为默认)与 anthropic bridge:只需保证解析出的默认模型落在该档可用集合内。
|
||||
- 客户端本地残留旧选择(`select_game_creator_model` 持久化)→ **明确 4xx**,由客户端提示重选;**不静默回退**。
|
||||
- **覆盖范围**:只约束 AGC 对话 / Agent 的 LLM 模型;图片 / 视频 / 音频等生成模型不纳入;`/api/external/v1` 不纳入。
|
||||
|
||||
### 11.3 共同约定
|
||||
|
||||
- **不设灰度开关**:直接对所有账号生效(2026-10-03 定案)。
|
||||
- 因为模型档位缺省 `Basic`,**上线后若不人工标 `Full`,模型限制不会产生任何拦截**——这是有意的失败开放,操作责任在后台。
|
||||
|
||||
### 11.4 实现落点(2026-10-03 编码约定)
|
||||
|
||||
- **`module-runtime`**:
|
||||
- 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、越权错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN`、解析错误 `AgcModelResolveError::{Unavailable, NotAvailableForPlan}`,以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。
|
||||
- `AgcModel` 末位追加 `#[serde(default)] access: AgcModelAccess`(存量目录 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 新增 `available_models_for(access)`、`default_model_id_for(access)`、`resolve_requested_for(requested, access)`。
|
||||
- **默认模型语义**:优先取目录登记的 `default_model_id`;当它不在该档可用集合里时,回退到目录顺序里第一个可用项;该档一个可用模型都没有时返回 `Unavailable`。
|
||||
- **`spacetime-module`**:`external_generation_job` 追加 btree 索引 `(owner_user_id, status)`(`by_external_generation_job_owner_status`);`claim_external_generation_jobs_tx` 按账号解析 `concurrent_job_limit` 并在认领时过滤;上限解析走 `profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`,缺会员行 / 缺目录行失败关闭到 `Normal`(=1)。
|
||||
- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。
|
||||
- **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。
|
||||
- **性能说明(2026-10-03 已落地)**:账号档位走专用只读 procedure `get_profile_agc_model_access_and_return`(`9e397fe9c`),不再触发 `get_profile_recharge_center` 的账期 / 免费点刷新;`api-server` 于 `b0dc94b46` 切到该读。认领按账号折叠并发查询见 `c3c3fc5a2`。缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`。
|
||||
- **列表契约(2026-10-03 修订)**:`shared-contracts` 新增 `llm_catalog.rs` 承载 `LlmModelsResponse` / `LlmModelSummary` / `LlmUnavailableModel` / `LlmModelUnavailableReason`(`plan_required` / `disabled` / `unknown`);`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts` 并由 `module-runtime` re-export;整个目录 DTO 与枚举用 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 导出到 `packages/shared/src/contracts/generated/`,`revision` 标 `#[ts(as = "f64")]`,AGC 前端从 `@genarrative/shared` 根导入,删掉手写的 `ClientLlmModel`。
|
||||
- **分桶来源**:`module-runtime` 新增 `AgcModelCatalog::unavailable_models_for(access)`(保持目录顺序,返回 `(&AgcModel, LlmModelUnavailableReason)`),`api-server` 只做 DTO 映射;`models` 仍走 `available_models_for`;`unavailableModels` 恒定下发(无内容时为空数组)。
|
||||
- **客户端**:AGC 桌面选择器把 `unavailableModels` 渲染为不可点项,hover / 键盘 focus 出 tooltip(`plan_required` → 「订阅计划不支持」、`disabled` → 「该模型已下线」、`unknown` → 「暂不可用」);降级仍自动回退默认,提示按 `reason` 取;后台直接删除(两桶都没有)用泛化文案「所选模型已不可用,已切回默认模型」。
|
||||
- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `da9602839`;`spacetime-module` 并发索引与认领过滤 `df7682816`;后台 `access` 标注与页面 `54b02e228`;`api-server` 按档过滤 / 校验 `3aae77bc5`;认领按账号折叠并发查询 `c3c3fc5a2`。AGC 客户端侧原以「目录按档过滤 + 本地选择对账回退默认项」为主,本次修订为可用 / 不可用分桶。账号 AGC 模型权限改走只读 procedure `9e397fe9c`,`api-server` 切读 `b0dc94b46`。
|
||||
|
||||
---
|
||||
|
||||
> 约束来源:`scripts/check-spacetime-schema-guard.mjs` 只做源码静态比对,破坏性变更必须显式带 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 才会放行;而 `module-runtime` 与 `spacetime-module` 相互依赖,任一侧单独清退都会让另一侧无法编译,所以领域层清退必须与 schema 变更同批提交。
|
||||
|
||||
@@ -286,6 +286,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- Rust 结构体:`ExternalGenerationJob`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||
- 现役覆盖:worker claim 只允许 `source_module = editor-canvas`;任务领取、续租、阶段更新和结果完成必须携带并校验同一 `worker_id + lease_token`。
|
||||
- 2026-10-03 会员并发上限:新增 btree 组合索引 `by_external_generation_job_owner_status (owner_user_id, status)`。`claim_external_generation_jobs_tx` 在 `pending → running` 前按任务 owner 的 `profile_membership.plan → profile_membership_plan.concurrent_job_limit` 现算该 owner `status = running` 的行数(含租约过期待回收的 `expired_running`),达上限的 pending 保持排队;账号自身过期的 running 回收豁免上限,避免超限账号的卡死任务无法回收。`pending` 不占名额,失败退回 `pending` 会自然释放名额;不新增计数表。缺会员行按 `Normal`(上限 1)失败关闭,目录行缺失同样按 1;`128` 哨兵由 `is_unlimited_concurrency` 解释为不限。
|
||||
- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
- 2026-08-06 收口覆盖:上一条用途描述中“当前先 CAS、单事务仍是目标”的旧句已作废。现役编辑器生成不再组合调用 object confirm、resource create、asset create、canvas save 和 job complete。`api-server` 只准备稳定候选,再经 `spacetime-client` 调用 `persist_editor_generation_result_and_return`;procedure 在同一 `try_with_tx` 内写入可选 `asset_object`、全部 `editor_project_resource`、`editor_asset`、可选 `asset_entity_binding`、可选 canvas V2 CAS、queue job 终态和 `editor_generation_operation` receipt。结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 仍经既有 V2 布局验证分流,前端不直接发明正式完成态。queue 首次提交在同一快照验证 owner、job kind、request fingerprint 和有效 `job_id + worker_id + lease_token`;统一 procedure 已完成 job 后 worker 不得再单独 complete。
|
||||
- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。未登记的 source module 不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 客户端启动的执行器。与线协议正交。
|
||||
*/
|
||||
export type AgcAgentMode = "codex" | "cc";
|
||||
@@ -0,0 +1,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 上游线协议,与执行器正交。
|
||||
*
|
||||
* `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与
|
||||
* `/api/llm/v1/chat/completions`;`anthropic` 走独立的
|
||||
* `/api/llm/anthropic/{*path}`。
|
||||
*/
|
||||
export type AgcModelProtocol = "openai_responses" | "openai_chat" | "anthropic";
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AgcAgentMode } from "./AgcAgentMode";
|
||||
import type { AgcModelProtocol } from "./AgcModelProtocol";
|
||||
|
||||
/**
|
||||
* 本档可用的一条模型摘要。不包含上游真实模型名。
|
||||
*/
|
||||
export type LlmModelSummary = { displayName: string, id: string,
|
||||
/**
|
||||
* 执行器;旧服务端缺字段时按 `codex` 兼容。
|
||||
*/
|
||||
agentMode: AgcAgentMode,
|
||||
/**
|
||||
* 上游线协议;旧服务端缺字段时按 `openai_responses` 兼容。
|
||||
*/
|
||||
protocol: AgcModelProtocol, };
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 模型对当前账号不可用的原因。
|
||||
*
|
||||
* `Unknown` 是前向兜底:后端将来新增取值时旧客户端不会解析失败。
|
||||
*/
|
||||
export type LlmModelUnavailableReason = "plan_required" | "disabled" | "unknown";
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { LlmModelSummary } from "./LlmModelSummary";
|
||||
import type { LlmUnavailableModel } from "./LlmUnavailableModel";
|
||||
|
||||
/**
|
||||
* `GET /api/llm/models` 的响应。
|
||||
*/
|
||||
export type LlmModelsResponse = { defaultModelId: string,
|
||||
/**
|
||||
* 本档可用,保持目录顺序。
|
||||
*/
|
||||
models: Array<LlmModelSummary>,
|
||||
/**
|
||||
* 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。
|
||||
*/
|
||||
unavailableModels: Array<LlmUnavailableModel>,
|
||||
/**
|
||||
* 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。
|
||||
*/
|
||||
revision: number, };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user