diff --git a/packages/shared/src/contracts/generated/AgcAgentMode.ts b/packages/shared/src/contracts/generated/AgcAgentMode.ts new file mode 100644 index 000000000..a23cf0075 --- /dev/null +++ b/packages/shared/src/contracts/generated/AgcAgentMode.ts @@ -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"; diff --git a/packages/shared/src/contracts/generated/AgcModelProtocol.ts b/packages/shared/src/contracts/generated/AgcModelProtocol.ts new file mode 100644 index 000000000..c6fa1e618 --- /dev/null +++ b/packages/shared/src/contracts/generated/AgcModelProtocol.ts @@ -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"; diff --git a/packages/shared/src/contracts/generated/LlmModelSummary.ts b/packages/shared/src/contracts/generated/LlmModelSummary.ts new file mode 100644 index 000000000..be2e3c30c --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelSummary.ts @@ -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, }; diff --git a/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts b/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts new file mode 100644 index 000000000..a9a55039a --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts @@ -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"; diff --git a/packages/shared/src/contracts/generated/LlmModelsResponse.ts b/packages/shared/src/contracts/generated/LlmModelsResponse.ts new file mode 100644 index 000000000..ce1a75fef --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelsResponse.ts @@ -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, +/** + * 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。 + */ +unavailableModels: Array, +/** + * 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。 + */ +revision: number, }; diff --git a/packages/shared/src/contracts/generated/LlmUnavailableModel.ts b/packages/shared/src/contracts/generated/LlmUnavailableModel.ts new file mode 100644 index 000000000..f41401bbc --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmUnavailableModel.ts @@ -0,0 +1,9 @@ +// 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"; +import type { LlmModelUnavailableReason } from "./LlmModelUnavailableReason"; + +/** + * 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。 + */ +export type LlmUnavailableModel = { displayName: string, id: string, agentMode: AgcAgentMode, protocol: AgcModelProtocol, reason: LlmModelUnavailableReason, }; diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 385dd21cf..f25a64ab5 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -14,7 +14,7 @@ use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest}; use serde_json::{Value, json}; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, - LlmModelSummary, LlmModelsResponse, + LlmModelSummary, LlmModelsResponse, LlmUnavailableModel, }; use spacetime_client::SpacetimeClientError; use std::convert::Infallible; @@ -52,7 +52,7 @@ mod model_catalog_tests { use super::*; #[test] - fn public_catalog_exposes_stable_id_and_upstream_alias() { + fn public_catalog_splits_available_and_disabled_models() { let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], 7, @@ -72,15 +72,21 @@ mod model_catalog_tests { payload["models"], json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses"}]) ); + // 后台停用的模型仍下发到不可用桶,带 disabled 原因。 + assert_eq!( + payload["unavailableModels"], + json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses", "reason": "disabled"}]) + ); assert_eq!(payload["defaultModelId"], "gpt-5-6-sol"); assert_eq!(payload["revision"], json!(7)); assert!(payload.get("defaultModel").is_none()); assert!(payload["models"][0].get("enabled").is_none()); assert!(payload["models"][0].get("modelId").is_none()); + assert!(payload["unavailableModels"][0].get("modelId").is_none()); } #[test] - fn public_catalog_filters_full_models_for_basic_plan() { + fn public_catalog_puts_full_models_in_plan_required_bucket_for_basic_plan() { let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], 0, @@ -100,6 +106,10 @@ mod model_catalog_tests { payload["models"], json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses"}]) ); + assert_eq!( + payload["unavailableModels"], + json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses", "reason": "plan_required"}]) + ); assert_eq!(payload["defaultModelId"], "gpt-5-6-terra"); } @@ -268,20 +278,29 @@ fn public_model_catalog( .to_string(); let models = catalog .available_models_for(access) - .map(|model| { - let protocol = model.resolved_protocol().as_str().to_string(); - LlmModelSummary { - id: model.id.clone(), - // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 - display_name: model.alias.clone(), - agent_mode: model.agent_mode.as_str().to_string(), - protocol, - } + .map(|model| LlmModelSummary { + id: model.id.clone(), + // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 + display_name: model.alias.clone(), + agent_mode: model.agent_mode, + protocol: model.resolved_protocol(), + }) + .collect(); + // 不可用桶只是展示信息:含后台停用与本档档位不够,保持目录顺序。 + let unavailable_models = catalog + .unavailable_models_for(access) + .map(|(model, reason)| LlmUnavailableModel { + id: model.id.clone(), + display_name: model.alias.clone(), + agent_mode: model.agent_mode, + protocol: model.resolved_protocol(), + reason, }) .collect(); Ok(LlmModelsResponse { default_model_id, models, + unavailable_models, revision: catalog.revision, }) } diff --git a/server-rs/crates/module-runtime/src/agc_model_access.rs b/server-rs/crates/module-runtime/src/agc_model_access.rs index 76559aaed..051475330 100644 --- a/server-rs/crates/module-runtime/src/agc_model_access.rs +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::{ AGC_MODEL_PLATFORM_DEFAULT, AgcModel, AgcModelCatalog, RuntimeProfileMembershipModelAccess, }; +use shared_contracts::llm_catalog::LlmModelUnavailableReason; /// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。 pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN"; @@ -105,6 +106,26 @@ impl AgcModelCatalog { .filter(move |model| model.is_available_for(access)) } + /// 该档位**不可用**的模型(含后台停用与本档档位不够),保持目录顺序。 + /// + /// 与 [`Self::available_models_for`] 互补:两桶并集是目录全集、互不相交。同时停用且 + /// 档位不够时取 [`LlmModelUnavailableReason::Disabled`]——升级也解锁不了,不能标 + /// `plan_required` 误导用户。 + pub fn unavailable_models_for( + &self, + access: AgcModelAccess, + ) -> impl Iterator { + self.models.iter().filter_map(move |model| { + if !model.enabled { + Some((model, LlmModelUnavailableReason::Disabled)) + } else if !access.allows(model.access) { + Some((model, LlmModelUnavailableReason::PlanRequired)) + } else { + None + } + }) + } + /// 该档位的默认模型标识:优先目录登记的 `default_model_id`,不在该档可用集合里时 /// 回退到目录顺序里第一个可用项;一个可用模型都没有时返回 `None`。 pub fn default_model_id_for(&self, access: AgcModelAccess) -> Option<&str> { @@ -287,4 +308,40 @@ mod tests { "MODEL_UNAVAILABLE" ); } + + #[test] + fn unavailable_models_for_splits_disabled_and_plan_gated_in_catalog_order() { + let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 0); + mark_full(&mut catalog, "model-b"); + mark_full(&mut catalog, "model-c"); + catalog.models[2].enabled = false; + + let unavailable = catalog + .unavailable_models_for(AgcModelAccess::Basic) + .map(|(model, reason)| (model.id.as_str(), reason)) + .collect::>(); + // model-c 同时停用且 Full:reason 取 disabled(升级也解锁不了)。 + assert_eq!( + unavailable, + vec![ + ("model-b", LlmModelUnavailableReason::PlanRequired), + ("model-c", LlmModelUnavailableReason::Disabled), + ] + ); + + // Full 档只剩停用的 model-c;可用集合与不可用集合互补。 + let unavailable_full = catalog + .unavailable_models_for(AgcModelAccess::Full) + .map(|(model, reason)| (model.id.as_str(), reason)) + .collect::>(); + assert_eq!( + unavailable_full, + vec![("model-c", LlmModelUnavailableReason::Disabled)] + ); + let available = catalog + .available_models_for(AgcModelAccess::Basic) + .map(|model| model.id.as_str()) + .collect::>(); + assert_eq!(available, vec!["model-a"]); + } } diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index 05e115db8..31479f3d8 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -14,99 +14,9 @@ pub const AGC_MODEL_ID_MAX_BYTES: usize = 64; /// 目录项数上限,与后台「AGC 模型」页的新增上限保持一致。 pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AgcAgentMode { - Codex, - /// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`; - /// 协议本身由 `AgcModel.protocol` 表达。 - #[serde(alias = "anthropic")] - Cc, -} - -impl Default for AgcAgentMode { - fn default() -> Self { - Self::Codex - } -} - -impl AgcAgentMode { - pub const fn as_str(self) -> &'static str { - match self { - Self::Codex => "codex", - Self::Cc => "cc", - } - } - - pub fn parse(value: &str) -> Result { - match value { - "codex" => Ok(Self::Codex), - "cc" => Ok(Self::Cc), - _ => Err("Agent 模式只允许 codex 或 cc".into()), - } - } - - /// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的: - /// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走 - /// Anthropic Messages。 - pub const fn supports(self, protocol: AgcModelProtocol) -> bool { - match self { - Self::Codex => matches!( - protocol, - AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat - ), - Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic), - } - } - - /// 存量目录缺少协议字段时按执行器的默认线协议解释。 - pub const fn default_protocol(self) -> AgcModelProtocol { - match self { - Self::Codex => AgcModelProtocol::OpenAiResponses, - Self::Cc => AgcModelProtocol::Anthropic, - } - } -} - -/// 上游线协议,与执行器正交。 -/// -/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 -/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的 -/// `/api/llm/anthropic/{*path}`。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum AgcModelProtocol { - #[serde(rename = "openai_responses")] - OpenAiResponses, - #[serde(rename = "openai_chat")] - OpenAiChat, - #[serde(rename = "anthropic")] - Anthropic, -} - -impl Default for AgcModelProtocol { - fn default() -> Self { - Self::OpenAiResponses - } -} - -impl AgcModelProtocol { - pub const fn as_str(self) -> &'static str { - match self { - Self::OpenAiResponses => "openai_responses", - Self::OpenAiChat => "openai_chat", - Self::Anthropic => "anthropic", - } - } - - pub fn parse(value: &str) -> Result { - match value { - "openai_responses" => Ok(Self::OpenAiResponses), - "openai_chat" => Ok(Self::OpenAiChat), - "anthropic" => Ok(Self::Anthropic), - _ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()), - } - } -} +// 执行器与线协议枚举已迁到 `shared-contracts`(前后端 + ts-rs 单一真源); +// 这里 re-export,保持 `module_runtime::AgcAgentMode` / `AgcModelProtocol` 路径可用。 +pub use shared_contracts::llm_catalog::{AgcAgentMode, AgcModelProtocol}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index ed92100fb..08ce1bec6 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -23,5 +23,6 @@ pub mod game_creation_app; pub mod game_distribution; pub mod hyper3d; pub mod llm; +pub mod llm_catalog; pub mod payment; pub mod runtime; diff --git a/server-rs/crates/shared-contracts/src/llm.rs b/server-rs/crates/shared-contracts/src/llm.rs index a02708cf7..a729f0994 100644 --- a/server-rs/crates/shared-contracts/src/llm.rs +++ b/server-rs/crates/shared-contracts/src/llm.rs @@ -60,22 +60,9 @@ mod tests { assert_eq!(payload["messages"][0]["role"], json!("system")); } } -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmModelSummary { - pub display_name: String, - pub id: String, - /// 执行器:`codex` 或 `cc`。 - pub agent_mode: String, - /// 上游线协议,与执行器正交:`openai_responses` / `openai_chat` / `anthropic`。 - pub protocol: String, -} - -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmModelsResponse { - pub default_model_id: String, - pub models: Vec, - /// 模型目录版本,客户端据此做条件刷新。 - pub revision: u64, -} +// 目录 DTO 与枚举已迁到 `llm_catalog`(同一 ts-rs 生成目录);这里 re-export 保持既有 +// `shared_contracts::llm::*` 路径可用。 +pub use crate::llm_catalog::{ + AgcAgentMode, AgcModelProtocol, LlmModelSummary, LlmModelUnavailableReason, LlmModelsResponse, + LlmUnavailableModel, +}; diff --git a/server-rs/crates/shared-contracts/src/llm_catalog.rs b/server-rs/crates/shared-contracts/src/llm_catalog.rs new file mode 100644 index 000000000..72a79bfce --- /dev/null +++ b/server-rs/crates/shared-contracts/src/llm_catalog.rs @@ -0,0 +1,226 @@ +//! AGC LLM 模型目录的对外契约与枚举(前后端唯一真源)。 +//! +//! `GET /api/llm/models` 返回两个平行数组:`models` 只放本档可用模型; +//! `unavailableModels` 放目录里其余全部(含后台停用与本档档位不够),元素带 `reason`。 +//! 整个 DTO 与枚举经 ts-rs 导出到 `packages/shared/src/contracts/generated/`, +//! AGC 前端不再手写第二份类型。 + +use serde::{Deserialize, Serialize}; + +/// 客户端启动的执行器。与线协议正交。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum AgcAgentMode { + Codex, + /// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`; + /// 协议本身由 [`AgcModelProtocol`] 表达。 + #[serde(alias = "anthropic")] + Cc, +} + +impl Default for AgcAgentMode { + fn default() -> Self { + Self::Codex + } +} + +impl AgcAgentMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Cc => "cc", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "codex" => Ok(Self::Codex), + "cc" => Ok(Self::Cc), + _ => Err("Agent 模式只允许 codex 或 cc".into()), + } + } + + /// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的: + /// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走 + /// Anthropic Messages。 + pub const fn supports(self, protocol: AgcModelProtocol) -> bool { + match self { + Self::Codex => matches!( + protocol, + AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat + ), + Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic), + } + } + + /// 存量目录缺少协议字段时按执行器的默认线协议解释。 + pub const fn default_protocol(self) -> AgcModelProtocol { + match self { + Self::Codex => AgcModelProtocol::OpenAiResponses, + Self::Cc => AgcModelProtocol::Anthropic, + } + } +} + +/// 上游线协议,与执行器正交。 +/// +/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 +/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的 +/// `/api/llm/anthropic/{*path}`。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum AgcModelProtocol { + #[serde(rename = "openai_responses")] + OpenAiResponses, + #[serde(rename = "openai_chat")] + OpenAiChat, + #[serde(rename = "anthropic")] + Anthropic, +} + +impl Default for AgcModelProtocol { + fn default() -> Self { + Self::OpenAiResponses + } +} + +impl AgcModelProtocol { + pub const fn as_str(self) -> &'static str { + match self { + Self::OpenAiResponses => "openai_responses", + Self::OpenAiChat => "openai_chat", + Self::Anthropic => "anthropic", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "openai_responses" => Ok(Self::OpenAiResponses), + "openai_chat" => Ok(Self::OpenAiChat), + "anthropic" => Ok(Self::Anthropic), + _ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()), + } + } +} + +/// 本档可用的一条模型摘要。不包含上游真实模型名。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmModelSummary { + pub display_name: String, + pub id: String, + /// 执行器;旧服务端缺字段时按 `codex` 兼容。 + #[serde(default)] + pub agent_mode: AgcAgentMode, + /// 上游线协议;旧服务端缺字段时按 `openai_responses` 兼容。 + #[serde(default)] + pub protocol: AgcModelProtocol, +} + +/// 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmUnavailableModel { + pub display_name: String, + pub id: String, + #[serde(default)] + pub agent_mode: AgcAgentMode, + #[serde(default)] + pub protocol: AgcModelProtocol, + pub reason: LlmModelUnavailableReason, +} + +/// 模型对当前账号不可用的原因。 +/// +/// `Unknown` 是前向兜底:后端将来新增取值时旧客户端不会解析失败。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum LlmModelUnavailableReason { + /// 会员档位不够(升级可解锁)。 + PlanRequired, + /// 后台人工停用(升级也解锁不了)。 + Disabled, + /// 未知原因的前向兜底;当前服务端不会主动产出。 + #[serde(other)] + Unknown, +} + +/// `GET /api/llm/models` 的响应。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmModelsResponse { + pub default_model_id: String, + /// 本档可用,保持目录顺序。 + pub models: Vec, + /// 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。 + #[serde(default)] + pub unavailable_models: Vec, + /// 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub revision: u64, +}