diff --git a/scripts/shots/mock/commands.ts b/scripts/shots/mock/commands.ts index 6cbaa290..f5527ad6 100644 --- a/scripts/shots/mock/commands.ts +++ b/scripts/shots/mock/commands.ts @@ -14,7 +14,7 @@ import type { Adopted, ConnView, Tested } from "@/connection/api"; import type { AppInfo, LanguageView, MenubarStyle, NoticeMode, ThemeView } from "@/settings/api"; import type { Offer, UpdateView } from "@/updateFlow"; import type { UpstreamStats } from "@/upstreams/api"; -import type { ClientsResponse, CoreStatus, Dashboard, KeyUsage, McpTargetView, PlanView, ScanReport } from "@/types"; +import type { ClientsResponse, CoreStatus, Dashboard, ImportLink, KeyUsage, McpTargetView, PlanView, ScanReport } from "@/types"; import { CORE, CHATGPT_WINDOWS } from "./core"; import { LOCAL_GATEWAY, status } from "./config"; import { IN_FLIGHT, costBucketsBy, costBy, dashboard, upstreamLatency, upstreamTokenRate } from "./traffic"; @@ -45,7 +45,7 @@ interface Commands { core_state: [void, string]; core_status: [void, CoreStatus]; take_pending_view: [void, string | null]; - take_import_link: [void, null]; + take_import_link: [void, ImportLink | null]; import_link_closed: [void, null]; connections: [void, ConnView]; test_connection: [void, Tested]; diff --git a/src-tauri/src/import_link.rs b/src-tauri/src/import_link.rs index 0162b85f..6810131d 100644 --- a/src-tauri/src/import_link.rs +++ b/src-tauri/src/import_link.rs @@ -1,31 +1,44 @@ -//! 导入链接:`thinkwatch://import?name=…&url=…&protocol=…&key=…&models=…`。 +//! 导入链接:`thinkwatch://import?service=…&name=…&url=…&key=…&…`(第二版,第一版的链接照样能用)。 //! -//! 中转站、服务商把这样一条链接交给用户,点开之后应用弹出「导入上游」,预填一个 -//! **新的**上游。官网的 `https://thinkwat.ch/import#<同样的参数>` 读完片段之后打开的 -//! 也是这条链接。 +//! 中转站、服务商、企业网关把这样一条链接交给用户,点开之后应用打开**新建上游对话框**,按链接 +//! 预填。官网的 `https://thinkwat.ch/import#<同样的参数>` 读完片段之后打开的也是这条链接。 //! //! **每一条链接都当作恶意的来处理**:任何网页都能触发它。所以: //! -//! - 只认这里列出的五个参数。多一个不认识的、同一个参数出现两次、编码不规整,整条作废; -//! 每一项和整条都有长度上限。 -//! - 接口地址只收 `https://`(`http://` 只给本机回环地址),不带账号密码、查询串、片段; -//! 主机名一律转成 ASCII(IDN 转 punycode)给人看,防形近字。 -//! - 密钥只收常见的密钥字符。**`$`、`{`、`}` 一律不收**:core 会把密钥里的 `${变量名}` -//! 换成这台机器上的环境变量(`tw_secret::expand`,`$${` 是它的转义),一条 -//! `key=${OPENAI_API_KEY}` 的链接就能把用户自己的密钥发到对方的主机上。 -//! - 链接只产生一份**提议**。点「创建」之前不写配置、不发任何网络请求(不取模型列表、 -//! 不检测连接:链接不该能让应用去探内网)。创建只新建一个上游,不改、不覆盖、不删已有的, +//! - 只认这里列出的参数([`Param`]),每一项都是新建对话框里**这种服务、这种认证方式下 +//! 显示的一栏**([`SERVICES`],和界面的服务类型对得上由 `tests/ts_bindings.rs` 生成的 +//! `src/generated/import-services.json` 和界面那边的测试核对)。对话框自己填的一项,链接给的 +//! 得和它填的一样。同一个参数出现两次、编码不规整、值不合规矩,整条作废;每一项和整条都有 +//! 长度上限。 +//! - 永远不收的([`NEVER`]):出站代理(链接不知道这台机器的网络)、AWS profile 和 `${变量名}` +//! (会让应用读这台机器上的凭据、环境变量,发给链接的主机)、别名、路由、默认上游、权重 +//! (会把已经接好的客户端引到别处)、停用、只用于检测的 OAuth access token。 +//! - **不认识的参数不再悄悄作废**:交给界面一个「无法导入」([`ImportLink::Unsupported`]), +//! 有新版本时给更新。参数名只在是 `^[a-z0-9_]{1,32}$` 时拿给人看。 +//! - 接口地址只收 `https://`;`http://` 只给本机回环地址和内网(私有网段的 IP、单段的主机名、 +//! `.lan`、`.corp` 这类内网后缀,见 [`plain_http_ok`])—— 公司内网的企业网关常是明文的, +//! 手动添加时密钥照样明文发过去,拒掉链接不多一分安全;对话框写明这个地址不加密。不带账号 +//! 密码、查询串、片段;主机名一律转成 ASCII(IDN 转 punycode)给人看,防形近字。 +//! - **任何一个值都不收 `$`、`{`、`}`**:core 会把 `${变量名}` 换成这台机器上的环境变量 +//! (`tw_secret::expand`,`$${` 是它的转义),一条 `key=${OPENAI_API_KEY}` 的链接就能把 +//! 用户自己的密钥发到对方的主机上。控制字符、看不见的字符也不收。 +//! - 链接只产生一份**提议**。点「下一步」(检测连接)或开始登录之前不写配置、不发任何网络 +//! 请求(链接不该能让应用去探内网)。创建只新建一个上游,不改、不覆盖、不删已有的, //! 也不设成默认、不加进路由。 //! - 同一时间只处理一条:对话框开着时再来的链接直接丢掉,不再把窗口拉到前面。 //! - 链接里的内容不进 shell、`open`、子进程、文件路径;界面拿到的是这里校验完的结构, -//! 保存走白名单里那个新建上游的控制面端点(`CreateProvider`)。 +//! 保存走白名单里的控制面端点。 use std::sync::Mutex; use std::time::{Duration, Instant}; -use tw_api::Protocol; +use serde::Serialize; +use tw_api::{BalanceSetting, Billing, Protocol, ZaiFamily}; -use crate::wire::ImportProposal; +use crate::wire::{ + ImportAuth, ImportAwsKeys, ImportHeader, ImportLink, ImportOAuth, ImportPrice, ImportProposal, + ImportService, ImportSheet, ImportSpec, +}; /// 链接的前缀。主机名那一段不分大小写 pub const PREFIX: &str = "thinkwatch://import"; @@ -35,8 +48,17 @@ pub const MAX_LINK: usize = 8192; pub const MAX_NAME: usize = 64; pub const MAX_URL: usize = 2048; pub const MAX_KEY: usize = 512; +/// Refresh token、AWS 会话令牌 +pub const MAX_LONG_KEY: usize = 4096; pub const MAX_MODEL: usize = 128; +/// 模型清单、启用范围、规格、价格各自最多几项 pub const MAX_MODELS: usize = 64; +pub const MAX_HEADERS: usize = 16; +pub const MAX_HEADER_NAME: usize = 64; +pub const MAX_HEADER_VALUE: usize = 1024; +pub const MAX_CONCURRENCY: u32 = 1000; +/// 规格里上下文窗口、输出上限的上限 +pub const MAX_SPEC_TOKENS: u32 = 10_000_000; /// 对话框关掉之后这么久之内再来的链接不理:一个反复触发链接的网页不能在用户点了 /// 「取消」之后马上又把对话框弹回来 @@ -44,6 +66,119 @@ const COOLDOWN: Duration = Duration::from_secs(3); /// 交出去之后界面一直没来取(网页没挂上):过了这么久,下一条链接可以顶替它 const STALE: Duration = Duration::from_secs(60); +// ---------------------------------------------------------- 参数 + +/// 认识的参数 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Param { + Service, + Auth, + Site, + Name, + Url, + Protocol, + Key, + OauthEndpoint, + OauthRefresh, + OauthClientId, + OauthClientSecret, + Region, + AwsAccessKeyId, + AwsSecretAccessKey, + AwsSessionToken, + Headers, + ClientIdentity, + Concurrency, + Balance, + Models, + ModelsOnly, + Specs, + Billing, + PriceMultiplier, + Prices, +} + +impl Param { + pub const ALL: &[Self] = &[ + Self::Service, + Self::Auth, + Self::Site, + Self::Name, + Self::Url, + Self::Protocol, + Self::Key, + Self::OauthEndpoint, + Self::OauthRefresh, + Self::OauthClientId, + Self::OauthClientSecret, + Self::Region, + Self::AwsAccessKeyId, + Self::AwsSecretAccessKey, + Self::AwsSessionToken, + Self::Headers, + Self::ClientIdentity, + Self::Concurrency, + Self::Balance, + Self::Models, + Self::ModelsOnly, + Self::Specs, + Self::Billing, + Self::PriceMultiplier, + Self::Prices, + ]; + + /// 链接里的写法 + pub fn name(self) -> &'static str { + match self { + Self::Service => "service", + Self::Auth => "auth", + Self::Site => "site", + Self::Name => "name", + Self::Url => "url", + Self::Protocol => "protocol", + Self::Key => "key", + Self::OauthEndpoint => "oauth_endpoint", + Self::OauthRefresh => "oauth_refresh", + Self::OauthClientId => "oauth_client_id", + Self::OauthClientSecret => "oauth_client_secret", + Self::Region => "region", + Self::AwsAccessKeyId => "aws_access_key_id", + Self::AwsSecretAccessKey => "aws_secret_access_key", + Self::AwsSessionToken => "aws_session_token", + Self::Headers => "headers", + Self::ClientIdentity => "client_identity", + Self::Concurrency => "concurrency", + Self::Balance => "balance", + Self::Models => "models", + Self::ModelsOnly => "models_only", + Self::Specs => "specs", + Self::Billing => "billing", + Self::PriceMultiplier => "price_multiplier", + Self::Prices => "prices", + } + } + + fn from_name(n: &str) -> Option { + Self::ALL.iter().copied().find(|p| p.name() == n) + } +} + +/// 认得、但永远不收的参数:和值不合规矩一样整条作废,不说「无法导入」。三方约定的那一份名单, +/// 别的不认识的名字一律说「无法导入」 +pub const NEVER: &[&str] = &[ + "proxy", + "on_proxy_fail", + "aws_profile", + "alias", + "aliases", + "route", + "routes", + "default", + "weight", + "disabled", + "oauth_access", +]; + /// 链接为什么被拒。**不带链接里的内容**:写进日志的只有这个 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Rejected { @@ -51,17 +186,319 @@ pub enum Rejected { TooLong, Malformed, Encoding, - UnknownParam, DuplicateParam, Empty, - MissingUrl, - Name, - Url, - Protocol, - Key, - Models, + /// 永远不收的参数([`NEVER`]) + NeverAccepted, + /// 这一项的值不合规矩 + Invalid(Param), + /// 这种服务、这种认证方式的对话框里没有这一栏,或者这一栏是对话框自己填的、链接给的不一样 + NotOffered(Param), + /// 少了必填的一项 + Missing(Param), +} + +// ---------------------------------------------------------- 服务类型 + +/// 链接里的四种协议。ChatGPT 账号要登录、Bedrock 要它自己的凭据,都由服务类型定 +pub const LINK_PROTOCOLS: &[Protocol] = &[ + Protocol::Anthropic, + Protocol::OpenaiChat, + Protocol::OpenaiResponses, + Protocol::Gemini, +]; + +/// 地址那一栏 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Address { + /// 没有固定地址(中转、企业网关、自定义):给了 `service` 时链接得带 `url` + Required, + /// 这种服务自己的地址。链接给的话得一模一样 + Own { address: &'static str }, + /// Z.ai / BigModel:按站点定。链接给的话是两个标准地址之一,和站点对得上 + Site, + /// Bedrock:不给按区域生成标准地址;给了是 VPC 端点、代理这类,照用 + Endpoint { default: &'static str }, + /// Ollama:不给是本机的默认地址,给了照用 + Local { default: &'static str }, +} + +/// 协议那一栏 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Protocols { + /// 这种服务定了,不显示:链接给的话得一样。`None` 是自动识别(企业网关) + Fixed { protocol: Option }, + /// 在这几种里选(「全部」时是链接收的那四种) + Choice { protocols: &'static [Protocol] }, +} + +/// API 密钥那一栏 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum KeyField { + Required, + Optional, + /// 不显示(本机的 Ollama):链接不能给 + None, +} + +/// 一种服务的新建对话框显示哪几栏。和 `presets.ts` 的 `PRESETS`、`upstreamForm.ts` 的 +/// `fieldRules` 说的是同一件事,由界面那边的测试拿生成的 JSON 核对 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Service { + pub id: ImportService, + /// 链接能选的认证方式,第一个是默认 + pub auth: &'static [ImportAuth], + pub address: Address, + pub protocols: Protocols, + pub key: KeyField, + /// 「转发客户端身份」 + pub client_identity: bool, + /// 余额从哪里读由链接写(只有「自定义」:别的服务自己定) + pub balance: bool, + /// 计费方式的默认值 + pub billing: Billing, +} + +const KEY: &[ImportAuth] = &[ImportAuth::Key]; + +const fn vendor(id: ImportService, address: &'static str, protocol: Protocol) -> Service { + Service { + id, + auth: KEY, + address: Address::Own { address }, + protocols: Protocols::Fixed { + protocol: Some(protocol), + }, + key: KeyField::Required, + client_identity: false, + balance: false, + billing: Billing::PerToken, + } +} + +const fn relay(id: ImportService) -> Service { + Service { + id, + auth: KEY, + address: Address::Required, + protocols: Protocols::Choice { + protocols: LINK_PROTOCOLS, + }, + key: KeyField::Required, + client_identity: true, + balance: false, + billing: Billing::PerToken, + } +} + +/// Z.ai / BigModel 的两个标准地址(`presets.ts` 的 `ZAI_ENDPOINTS`) +pub const ZAI_ZAI: &str = "https://api.z.ai/api/anthropic"; +pub const ZAI_BIGMODEL: &str = "https://open.bigmodel.cn/api/anthropic"; + +/// 不给区域时 Bedrock 的地址(`presets.ts` 里那一格的地址) +pub const BEDROCK_DEFAULT_REGION: &str = "us-east-1"; + +/// 新建对话框第一步的那几格,顺序和 `PRESETS` 一样 +pub const SERVICES: &[Service] = &[ + vendor( + ImportService::Anthropic, + "https://api.anthropic.com", + Protocol::Anthropic, + ), + Service { + auth: &[ImportAuth::Key, ImportAuth::Account], + protocols: Protocols::Choice { + protocols: &[Protocol::OpenaiChat, Protocol::OpenaiResponses], + }, + ..vendor( + ImportService::Openai, + "https://api.openai.com", + Protocol::OpenaiChat, + ) + }, + vendor( + ImportService::Gemini, + "https://generativelanguage.googleapis.com", + Protocol::Gemini, + ), + vendor( + ImportService::Deepseek, + "https://api.deepseek.com/anthropic", + Protocol::Anthropic, + ), + Service { + auth: &[ImportAuth::Key, ImportAuth::Account], + address: Address::Site, + ..vendor(ImportService::Zai, ZAI_ZAI, Protocol::Anthropic) + }, + Service { + auth: &[ImportAuth::Key, ImportAuth::AwsKeys], + address: Address::Endpoint { + default: "https://bedrock-runtime.us-east-1.amazonaws.com", + }, + protocols: Protocols::Fixed { + protocol: Some(Protocol::Bedrock), + }, + ..vendor(ImportService::Bedrock, "", Protocol::Bedrock) + }, + vendor( + ImportService::Openrouter, + "https://openrouter.ai/api", + Protocol::OpenaiChat, + ), + Service { + protocols: Protocols::Fixed { protocol: None }, + client_identity: false, + ..relay(ImportService::Thinkwatch) + }, + relay(ImportService::Sub2api), + relay(ImportService::Newapi), + Service { + address: Address::Local { + default: "http://127.0.0.1:11434", + }, + key: KeyField::None, + billing: Billing::Free, + ..vendor(ImportService::Ollama, "", Protocol::OpenaiChat) + }, + Service { + auth: &[ImportAuth::Key, ImportAuth::Oauth], + key: KeyField::Optional, + balance: true, + ..relay(ImportService::Custom) + }, +]; + +impl ImportService { + fn spec(self) -> &'static Service { + SERVICES + .iter() + .find(|s| s.id == self) + .expect("每一种服务都在 SERVICES 里") + } + + /// 链接里的写法,也是 `presets.ts` 里那一格的 ID + pub fn slug(self) -> &'static str { + match self { + Self::Anthropic => "anthropic", + Self::Openai => "openai", + Self::Gemini => "gemini", + Self::Deepseek => "deepseek", + Self::Zai => "zai", + Self::Bedrock => "bedrock", + Self::Openrouter => "openrouter", + Self::Thinkwatch => "thinkwatch", + Self::Sub2api => "sub2api", + Self::Newapi => "newapi", + Self::Ollama => "ollama", + Self::Custom => "custom", + } + } + + fn from_slug(s: &str) -> Option { + SERVICES.iter().map(|x| x.id).find(|id| id.slug() == s) + } +} + +/// 官方地址的主机 → 服务类型(`presets.ts` 的 `serviceOf`:只认主机,路径不一样照样是这一家) +pub const HOSTS: &[(&str, ImportService)] = &[ + ("api.anthropic.com", ImportService::Anthropic), + ("api.openai.com", ImportService::Openai), + ("generativelanguage.googleapis.com", ImportService::Gemini), + ("api.deepseek.com", ImportService::Deepseek), + ("openrouter.ai", ImportService::Openrouter), + ("api.z.ai", ImportService::Zai), + ("open.bigmodel.cn", ImportService::Zai), +]; + +/// 没写 `service` 时按地址认,和编辑对话框认一个已保存的地址一样(`serviceOf`):官方地址按 +/// 主机,标准 Bedrock 地址,本机 Ollama 的 11434 端口,别的是「自定义」。ChatGPT 的后端也是 +/// 「自定义」 +fn recognize(u: &url::Url) -> ImportService { + let host = u.host_str().unwrap_or_default().to_ascii_lowercase(); + if host == "chatgpt.com" { + return ImportService::Custom; + } + if let Some((_, s)) = HOSTS.iter().find(|(h, _)| *h == host) { + return *s; + } + if bedrock_region_of(&host).is_some() { + return ImportService::Bedrock; + } + if u.port() == Some(11434) { + return ImportService::Ollama; + } + ImportService::Custom } +/// 标准 Bedrock 推理地址的主机里的区域(`labels.ts` 的 `bedrockRegionOf`) +fn bedrock_region_of(host: &str) -> Option<&str> { + let rest = host + .strip_prefix("bedrock-runtime-fips.") + .or_else(|| host.strip_prefix("bedrock-runtime."))?; + let region = rest.strip_suffix(".amazonaws.com")?; + (!region.is_empty() + && region + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')) + .then_some(region) +} + +/// Z.ai / BigModel 的哪一个站点:地址正是那一边的标准地址 +fn zai_site_of(base: &str) -> Option { + match base { + ZAI_ZAI => Some(ZaiFamily::Zai), + ZAI_BIGMODEL => Some(ZaiFamily::Bigmodel), + _ => None, + } +} + +/// 生成给界面核对的那一份(`src/generated/import-services.json`,`tests/ts_bindings.rs`) +pub fn table_json() -> String { + #[derive(Serialize)] + struct Table { + services: &'static [Service], + hosts: Vec, + zai: Zai, + protocols: &'static [Protocol], + bedrock_default_region: &'static str, + } + #[derive(Serialize)] + struct Host { + host: &'static str, + service: ImportService, + } + #[derive(Serialize)] + struct Zai { + zai: &'static str, + bigmodel: &'static str, + } + let t = Table { + services: SERVICES, + hosts: HOSTS + .iter() + .map(|(host, service)| Host { + host, + service: *service, + }) + .collect(), + zai: Zai { + zai: ZAI_ZAI, + bigmodel: ZAI_BIGMODEL, + }, + protocols: LINK_PROTOCOLS, + bedrock_default_region: BEDROCK_DEFAULT_REGION, + }; + let mut s = serde_json::to_string_pretty(&t).expect("服务表能写成 JSON"); + s.push('\n'); + s +} + +// ---------------------------------------------------------- 解析 + /// 是不是一条导入链接(包括写坏了的)。是的话就只由这里处理 —— 不落到授权回调那一路, /// 那一路会把窗口拉到前面 pub fn is_import(link: &str) -> bool { @@ -73,8 +510,23 @@ pub fn is_import(link: &str) -> bool { ) } +/// 解码之后的参数,每个认识的参数至多一份 +struct Params(Vec<(Param, String)>); + +impl Params { + fn get(&self, p: Param) -> Option<&str> { + self.0 + .iter() + .find(|(k, _)| *k == p) + .map(|(_, v)| v.as_str()) + } + fn has(&self, p: Param) -> bool { + self.get(p).is_some() + } +} + /// 解析并逐项校验一条导入链接 -pub fn parse(link: &str) -> Result { +pub fn parse(link: &str) -> Result { if link.len() > MAX_LINK { return Err(Rejected::TooLong); } @@ -86,50 +538,376 @@ pub fn parse(link: &str) -> Result { let rest = rest.strip_prefix('/').unwrap_or(rest); let query = match rest.strip_prefix('?') { Some(q) => q, - None if rest.is_empty() => return Err(Rejected::MissingUrl), + None if rest.is_empty() => return Err(Rejected::Missing(Param::Url)), None => return Err(Rejected::Malformed), }; if query.contains('#') { return Err(Rejected::Malformed); } - let mut name = None; - let mut url = None; - let mut protocol = None; - let mut key = None; - let mut models = None; + // 先把整条拆开、解码:写坏了的整条作废,不管里面有没有不认识的参数 + let mut pairs: Vec<(String, String)> = Vec::new(); for pair in query.split('&') { let (k, v) = pair.split_once('=').ok_or(Rejected::Malformed)?; let k = decode(k)?; let v = decode(v)?; - let slot = match k.as_str() { - "name" => &mut name, - "url" => &mut url, - "protocol" => &mut protocol, - "key" => &mut key, - "models" => &mut models, - _ => return Err(Rejected::UnknownParam), - }; - if slot.is_some() { + if pairs.iter().any(|(x, _)| *x == k) { return Err(Rejected::DuplicateParam); } if v.is_empty() { return Err(Rejected::Empty); } - *slot = Some(v); + pairs.push((k, v)); + } + + // 这一版不认识的参数:说「无法导入」,不再悄悄作废 + if let Some((k, _)) = pairs + .iter() + .find(|(k, _)| Param::from_name(k).is_none() && !NEVER.contains(&k.as_str())) + { + return Ok(ImportLink::Unsupported { + param: shown_param(k), + }); + } + if pairs.iter().any(|(k, _)| NEVER.contains(&k.as_str())) { + return Err(Rejected::NeverAccepted); + } + let params = Params( + pairs + .into_iter() + .filter_map(|(k, v)| Param::from_name(&k).map(|p| (p, v))) + .collect(), + ); + + // 任何一个值都不带 `$`、`{`、`}`、控制字符、看不见的字符。请求头按行查:行与行之间 + // 的换行是分隔符 + for (p, v) in ¶ms.0 { + let bad = |s: &str| { + s.chars() + .any(|c| unsafe_char(c) || matches!(c, '$' | '{' | '}')) + }; + let unsafe_value = if *p == Param::Headers { + v.split('\n').any(bad) + } else { + bad(v) + }; + if unsafe_value { + return Err(Rejected::Invalid(*p)); + } } - let (base_url, host) = check_url(&url.ok_or(Rejected::MissingUrl)?)?; + let url = params.get(Param::Url).map(check_url).transpose()?; + let proposal = match params.get(Param::Service) { + Some(s) => { + let service = ImportService::from_slug(s).ok_or(Rejected::Invalid(Param::Service))?; + build(service, ¶ms, url.as_deref())? + } + // 没写服务类型(第一版的链接都是这样):按地址认。认出来的那种服务不收链接里的 + // 某一项(地址带着路径、协议不是它那一种),就按「自定义」—— 第一版的链接建出来的 + // 本来就是「自定义」 + None => { + let base = url.as_deref().ok_or(Rejected::Missing(Param::Url))?; + let parsed = url::Url::parse(base).map_err(|_| Rejected::Invalid(Param::Url))?; + match recognize(&parsed) { + ImportService::Custom => build(ImportService::Custom, ¶ms, Some(base))?, + s => build(s, ¶ms, Some(base)) + .or_else(|_| build(ImportService::Custom, ¶ms, Some(base)))?, + } + } + }; + Ok(ImportLink::Proposal { + proposal: Box::new(proposal), + }) +} + +/// 不认识的参数名能不能拿给人看 +fn shown_param(k: &str) -> Option { + (!k.is_empty() + && k.len() <= 32 + && k.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')) + .then(|| k.to_string()) +} + +/// 按选定的服务类型逐项查,拼成提议。`url`:链接给的、规范化过的地址 +fn build( + service: ImportService, + params: &Params, + url: Option<&str>, +) -> Result { + let spec = service.spec(); + let offered = |p: Param, ok: bool| { + if ok { + Ok(()) + } else { + Err(Rejected::NotOffered(p)) + } + }; + let has = |p: Param| params.has(p); + + // 认证方式 + let auth = match params.get(Param::Auth) { + None => ImportAuth::Key, + Some("key") => ImportAuth::Key, + Some("oauth") => ImportAuth::Oauth, + Some("account") => ImportAuth::Account, + Some("aws-keys") => ImportAuth::AwsKeys, + Some(_) => return Err(Rejected::Invalid(Param::Auth)), + }; + offered(Param::Auth, spec.auth.contains(&auth))?; + let account = auth == ImportAuth::Account; + + // 站点 + let site_param = match params.get(Param::Site) { + None => None, + Some("bigmodel") => Some(ZaiFamily::Bigmodel), + Some("zai") => Some(ZaiFamily::Zai), + Some(_) => return Err(Rejected::Invalid(Param::Site)), + }; + offered( + Param::Site, + site_param.is_none() || service == ImportService::Zai, + )?; + + // 地址。登录账号的地址由登录定 + offered(Param::Url, url.is_none() || !account)?; + let mut site = None; + match spec.address { + Address::Required if !account && url.is_none() => { + return Err(Rejected::Missing(Param::Url)); + } + Address::Own { address } => { + offered(Param::Url, url.is_none_or(|u| u == address))?; + } + Address::Site => { + let from_url = match url { + Some(u) => Some(zai_site_of(u).ok_or(Rejected::NotOffered(Param::Url))?), + None => None, + }; + if let (Some(a), Some(b)) = (from_url, site_param) + && a != b + { + return Err(Rejected::Invalid(Param::Site)); + } + site = Some(from_url.or(site_param).unwrap_or(ZaiFamily::Bigmodel)); + } + _ => {} + } + + // 协议 + let protocol = match params.get(Param::Protocol) { + None => None, + Some(p) => { + let p = Protocol::from_slug(p) + .filter(|p| LINK_PROTOCOLS.contains(p)) + .ok_or(Rejected::Invalid(Param::Protocol))?; + let ok = !account + && match spec.protocols { + Protocols::Fixed { protocol } => protocol == Some(p), + Protocols::Choice { protocols } => protocols.contains(&p), + }; + offered(Param::Protocol, ok)?; + Some(p) + } + }; + + // 密钥 + let key = params + .get(Param::Key) + .map(|k| check_key(k, MAX_KEY, Param::Key)) + .transpose()?; + offered( + Param::Key, + key.is_none() || (auth == ImportAuth::Key && spec.key != KeyField::None), + )?; + + // OAuth + for p in [ + Param::OauthEndpoint, + Param::OauthRefresh, + Param::OauthClientId, + Param::OauthClientSecret, + ] { + offered(p, !has(p) || auth == ImportAuth::Oauth)?; + } + let oauth = if auth == ImportAuth::Oauth { + let endpoint = params + .get(Param::OauthEndpoint) + .ok_or(Rejected::Missing(Param::OauthEndpoint))?; + let refresh = params + .get(Param::OauthRefresh) + .ok_or(Rejected::Missing(Param::OauthRefresh))?; + Some(ImportOAuth { + endpoint: check_endpoint(endpoint)?, + refresh: check_key(refresh, MAX_LONG_KEY, Param::OauthRefresh)?, + client_id: params + .get(Param::OauthClientId) + .map(|v| check_key(v, MAX_KEY, Param::OauthClientId)) + .transpose()?, + client_secret: params + .get(Param::OauthClientSecret) + .map(|v| check_key(v, MAX_KEY, Param::OauthClientSecret)) + .transpose()?, + }) + } else { + None + }; + + // Bedrock:区域和访问密钥 + let bedrock = service == ImportService::Bedrock; + let region = match params.get(Param::Region) { + None => None, + Some(r) => { + offered(Param::Region, bedrock)?; + let ok = (1..=32).contains(&r.len()) + && r.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if !ok { + return Err(Rejected::Invalid(Param::Region)); + } + // 标准地址里的区域就是那一栏(对话框按地址填);别的地址只在签名时才有区域那一栏 + if let Some(u) = url { + let own = url::Url::parse(u).ok().and_then(|x| { + x.host_str() + .and_then(|h| bedrock_region_of(h).map(str::to_string)) + }); + match own { + Some(own) if own != r => return Err(Rejected::Invalid(Param::Region)), + Some(_) => {} + None => offered(Param::Region, auth != ImportAuth::Key)?, + } + } + Some(r.to_string()) + } + }; + for p in [ + Param::AwsAccessKeyId, + Param::AwsSecretAccessKey, + Param::AwsSessionToken, + ] { + offered(p, !has(p) || auth == ImportAuth::AwsKeys)?; + } + let aws = if auth == ImportAuth::AwsKeys { + let id = params + .get(Param::AwsAccessKeyId) + .ok_or(Rejected::Missing(Param::AwsAccessKeyId))?; + let secret = params + .get(Param::AwsSecretAccessKey) + .ok_or(Rejected::Missing(Param::AwsSecretAccessKey))?; + let id_ok = (16..=128).contains(&id.len()) + && id + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()); + if !id_ok { + return Err(Rejected::Invalid(Param::AwsAccessKeyId)); + } + Some(ImportAwsKeys { + access_key_id: id.to_string(), + secret_access_key: check_key(secret, MAX_KEY, Param::AwsSecretAccessKey)?, + session_token: params + .get(Param::AwsSessionToken) + .map(|v| check_key(v, MAX_LONG_KEY, Param::AwsSessionToken)) + .transpose()?, + }) + } else { + None + }; + + // 「高级设置」:登录账号那一步没有 + let headers = match params.get(Param::Headers) { + None => Vec::new(), + Some(h) => { + offered(Param::Headers, !account)?; + check_headers(h)? + } + }; + let client_identity = match params.get(Param::ClientIdentity) { + None => false, + Some("on") => { + offered(Param::ClientIdentity, spec.client_identity && !account)?; + true + } + Some(_) => return Err(Rejected::Invalid(Param::ClientIdentity)), + }; + let concurrency = match params.get(Param::Concurrency) { + None => None, + Some(c) => { + offered(Param::Concurrency, !account)?; + Some(check_concurrency(c)?) + } + }; + let balance = match params.get(Param::Balance) { + None => None, + Some(b) => { + offered(Param::Balance, spec.balance)?; + Some(match b { + "off" => BalanceSetting::Off, + "openrouter" => BalanceSetting::Openrouter, + "deepseek" => BalanceSetting::Deepseek, + "moonshot" => BalanceSetting::Moonshot, + "sub2api" => BalanceSetting::Sub2api, + "newapi" => BalanceSetting::Newapi, + "thinkwatch" => BalanceSetting::Thinkwatch, + _ => return Err(Rejected::Invalid(Param::Balance)), + }) + } + }; + + // 模型、计费:每种服务、每种认证方式都有这两步 + let models = params + .get(Param::Models) + .map(|m| check_models(m, false, Param::Models)) + .transpose()? + .unwrap_or_default(); + let models_only = params + .get(Param::ModelsOnly) + .map(|m| check_models(m, true, Param::ModelsOnly)) + .transpose()?; + let specs = params + .get(Param::Specs) + .map(check_specs) + .transpose()? + .unwrap_or_default(); + let billing = match params.get(Param::Billing) { + None => None, + Some("per-token") => Some(Billing::PerToken), + Some("free") => Some(Billing::Free), + Some(_) => return Err(Rejected::Invalid(Param::Billing)), + }; + // 价目表只在按量计费时用得上(Ollama 默认不计费) + let per_token = billing.unwrap_or(spec.billing) == Billing::PerToken; + let multiplier = params + .get(Param::PriceMultiplier) + .map(check_multiplier) + .transpose()?; + let prices = params.get(Param::Prices).map(check_prices).transpose()?; + offered(Param::PriceMultiplier, multiplier.is_none() || per_token)?; + offered(Param::Prices, prices.is_none() || per_token)?; + let price_sheet = (multiplier.is_some() || prices.is_some()).then(|| ImportSheet { + multiplier: multiplier.unwrap_or(1.0), + prices: prices.unwrap_or_default(), + }); + Ok(ImportProposal { - name: name.map(|n| check_name(&n)).transpose()?, - base_url, - host, - protocol: protocol.map(|p| check_protocol(&p)).transpose()?, - key: key.map(|k| check_key(&k)).transpose()?, - models: models - .map(|m| check_models(&m)) - .transpose()? - .unwrap_or_default(), + service, + auth, + site, + name: params.get(Param::Name).map(check_name).transpose()?, + base_url: url.map(str::to_string), + protocol, + key, + oauth, + region, + aws, + headers, + client_identity, + concurrency, + balance, + models, + models_only, + specs, + billing, + price_sheet, }) } @@ -189,13 +967,39 @@ fn check_name(n: &str) -> Result { unsafe_char(c) || matches!(c, '/' | '\\' | '$' | '{' | '}' | '<' | '>' | '"' | '`') }); if bad { - return Err(Rejected::Name); + return Err(Rejected::Invalid(Param::Name)); } Ok(n.to_string()) } -/// 接口地址。返回规范化之后的地址和给人看的主机 -fn check_url(raw: &str) -> Result<(String, String), Rejected> { +/// 接口地址,规范化之后:主机名是 ASCII,末尾没有 `/` +fn check_url(raw: &str) -> Result { + let u = web_url(raw).ok_or(Rejected::Invalid(Param::Url))?; + let base = u.as_str().trim_end_matches('/').to_string(); + if base.len() > MAX_URL { + return Err(Rejected::Invalid(Param::Url)); + } + Ok(base) +} + +/// OAuth 的 Token 端点:和接口地址一样的规矩,路径原样留着(末尾只有一个根路径的 `/` 时去掉) +fn check_endpoint(raw: &str) -> Result { + let u = web_url(raw).ok_or(Rejected::Invalid(Param::OauthEndpoint))?; + let s = u.as_str(); + let s = if u.path() == "/" { + s.trim_end_matches('/') + } else { + s + }; + if s.len() > MAX_URL { + return Err(Rejected::Invalid(Param::OauthEndpoint)); + } + Ok(s.to_string()) +} + +/// 一个网址:`https://`(`http://` 只给本机和内网,[`plain_http_ok`]),不带账号密码、查询串、 +/// 片段,主机名是 ASCII(IDN 转成 punycode) +fn web_url(raw: &str) -> Option { if raw.len() > MAX_URL || raw.chars().any(|c| { unsafe_char(c) @@ -218,7 +1022,7 @@ fn check_url(raw: &str) -> Result<(String, String), Rejected> { ) }) { - return Err(Rejected::Url); + return None; } // 写全 scheme:`https:example.com` 这种 url 库会宽容地补成 `https://`,这里不宽容 let lower = raw @@ -226,85 +1030,280 @@ fn check_url(raw: &str) -> Result<(String, String), Rejected> { .map(str::to_ascii_lowercase) .unwrap_or_default(); if !(lower.starts_with("https://") || lower.starts_with("http://")) { - return Err(Rejected::Url); + return None; } - let u = url::Url::parse(raw).map_err(|_| Rejected::Url)?; + let u = url::Url::parse(raw).ok()?; if !u.username().is_empty() || u.password().is_some() || u.query().is_some() || u.fragment().is_some() { - return Err(Rejected::Url); + return None; } - let host = u.host().ok_or(Rejected::Url)?; - let loopback = match &host { - url::Host::Domain(d) => d.eq_ignore_ascii_case("localhost"), - url::Host::Ipv4(ip) => *ip == std::net::Ipv4Addr::LOCALHOST, - url::Host::Ipv6(ip) => *ip == std::net::Ipv6Addr::LOCALHOST, - }; + let host = u.host()?; match u.scheme() { "https" => {} - "http" if loopback => {} - _ => return Err(Rejected::Url), + "http" if plain_http_ok(&host) => {} + _ => return None, } // url 库给出的主机名已经是 ASCII(IDN 转成了 punycode),IPv6 带方括号 - let host_str = u.host_str().ok_or(Rejected::Url)?; + let host_str = u.host_str()?; if host_str.is_empty() || !host_str.is_ascii() { - return Err(Rejected::Url); + return None; } - let shown = match u.port() { - Some(p) => format!("{host_str}:{p}"), - None => host_str.to_string(), - }; - let base = u.as_str().trim_end_matches('/').to_string(); - if base.len() > MAX_URL { - return Err(Rejected::Url); - } - Ok((base, shown)) + Some(u) } -/// 协议只收这几种。ChatGPT 账号要登录、Bedrock 要 AWS 的凭据,都不是一条链接能给的 -fn check_protocol(p: &str) -> Result { - match Protocol::from_slug(p) { - Some( - v @ (Protocol::Anthropic - | Protocol::OpenaiChat - | Protocol::OpenaiResponses - | Protocol::Gemini), - ) => Ok(v), - _ => Err(Rejected::Protocol), +/// 内网的主机名后缀:`http://` 收这几种 +const INTRANET_SUFFIXES: &[&str] = &[ + ".local", + ".lan", + ".internal", + ".intranet", + ".corp", + ".home.arpa", + ".localhost", +]; + +/// `http://` 收不收这个主机:本机回环地址(`localhost`、`127.0.0.1`、`[::1]`);私有网段的 +/// IPv4(`10/8`、`172.16/12`、`192.168/16`、`100.64/10`、`169.254/16`)和 IPv6(`fc00::/7`、 +/// `fe80::/10`);没有点的单段主机名,或者以内网后缀结尾的([`INTRANET_SUFFIXES`])。别的一律 +/// 只收 `https://` +fn plain_http_ok(host: &url::Host<&str>) -> bool { + match host { + url::Host::Domain(d) => { + let d = d.to_ascii_lowercase(); + d == "localhost" || !d.contains('.') || INTRANET_SUFFIXES.iter().any(|s| d.ends_with(s)) + } + url::Host::Ipv4(ip) => { + let [a, b, ..] = ip.octets(); + *ip == std::net::Ipv4Addr::LOCALHOST + || a == 10 + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 168) + || (a == 100 && (64..=127).contains(&b)) + || (a == 169 && b == 254) + } + url::Host::Ipv6(ip) => { + let first = ip.segments()[0]; + *ip == std::net::Ipv6Addr::LOCALHOST + || first & 0xfe00 == 0xfc00 + || first & 0xffc0 == 0xfe80 + } } } -/// 密钥:只收常见的密钥字符。没有 `$`、`{`、`}`,存进配置就是字面值,不会被展开 -fn check_key(k: &str) -> Result { - let ok = k.len() <= MAX_KEY +/// 密钥一类:只收常见的密钥字符。没有 `$`、`{`、`}`,存进配置就是字面值,不会被展开 +fn check_key(k: &str, max: usize, p: Param) -> Result { + let ok = k.len() <= max && k.bytes() .all(|b| b.is_ascii_alphanumeric() || b"-_.~+/=:".contains(&b)); if !ok { - return Err(Rejected::Key); + return Err(Rejected::Invalid(p)); } Ok(k.to_string()) } -/// 模型清单:逗号分隔,每一项是一个模型 ID。重复的只留一个 -fn check_models(m: &str) -> Result, Rejected> { +/// 一个模型 ID(`glob`:启用范围里还可以是通配规则) +fn model_ok(id: &str, glob: bool) -> bool { + !id.is_empty() + && id.len() <= MAX_MODEL + && id.bytes().all(|b| { + b.is_ascii_alphanumeric() || b"-_.:/@+".contains(&b) || (glob && b"*?".contains(&b)) + }) +} + +/// 模型清单(启用范围):逗号分隔。重复的只留一个 +fn check_models(m: &str, glob: bool, p: Param) -> Result, Rejected> { let mut out: Vec = Vec::new(); for id in m.split(',') { - let ok = !id.is_empty() - && id.len() <= MAX_MODEL - && id - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"-_.:/@+".contains(&b)); - if !ok { - return Err(Rejected::Models); + if !model_ok(id, glob) { + return Err(Rejected::Invalid(p)); } if !out.iter().any(|x| x == id) { out.push(id.to_string()); } } if out.len() > MAX_MODELS { - return Err(Rejected::Models); + return Err(Rejected::Invalid(p)); + } + Ok(out) +} + +/// 不会被当成鉴权、连接本身、浏览器专用的请求头才收。前缀 `proxy-`、`sec-` 也不收 +const DENIED_HEADERS: &[&str] = &[ + "authorization", + "proxy-authorization", + "x-api-key", + "x-goog-api-key", + "api-key", + "cookie", + "host", + "content-length", + "content-type", + "content-encoding", + "transfer-encoding", + "connection", + "keep-alive", + "upgrade", + "te", + "trailer", + "user-agent", +]; + +/// 请求头:一行一个 `Name: Value` +fn check_headers(h: &str) -> Result, Rejected> { + let bad = Rejected::Invalid(Param::Headers); + let lines: Vec<&str> = h.split('\n').collect(); + if lines.len() > MAX_HEADERS { + return Err(bad); + } + let mut out: Vec = Vec::new(); + for line in lines { + let (name, value) = line.split_once(':').ok_or(bad)?; + // RFC 7230 的 token + let token = !name.is_empty() + && name.len() <= MAX_HEADER_NAME + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)); + let value = value.trim_matches(' '); + let printable = !value.is_empty() + && value.len() <= MAX_HEADER_VALUE + && value.bytes().all(|b| (0x20..=0x7E).contains(&b)); + if !token || !printable { + return Err(bad); + } + let lower = name.to_ascii_lowercase(); + if DENIED_HEADERS.contains(&lower.as_str()) + || lower.starts_with("proxy-") + || lower.starts_with("sec-") + || out.iter().any(|x| x.name.eq_ignore_ascii_case(name)) + { + return Err(bad); + } + out.push(ImportHeader { + name: name.to_string(), + value: value.to_string(), + }); + } + Ok(out) +} + +/// 一个十进制正整数,不带符号、不带前导零 +fn whole(s: &str, max: u32) -> Option { + let ok = !s.is_empty() + && s.len() <= 10 + && s.bytes().all(|b| b.is_ascii_digit()) + && !s.starts_with('0'); + ok.then(|| s.parse::().ok()) + .flatten() + .filter(|n| (1..=max).contains(n)) +} + +fn check_concurrency(c: &str) -> Result { + whole(c, MAX_CONCURRENCY).ok_or(Rejected::Invalid(Param::Concurrency)) +} + +/// 模型规格:`;` 分隔,每一项 `模型|上下文窗口|输出上限|推理|图片输入` +fn check_specs(s: &str) -> Result, Rejected> { + let bad = Rejected::Invalid(Param::Specs); + let entries: Vec<&str> = s.split(';').collect(); + if entries.len() > MAX_MODELS { + return Err(bad); + } + let mut out: Vec = Vec::new(); + for e in entries { + let f: Vec<&str> = e.split('|').collect(); + let [model, context, output, reasoning, image] = f[..] else { + return Err(bad); + }; + if !model_ok(model, false) || out.iter().any(|x| x.model == model) { + return Err(bad); + } + let tokens = |v: &str| match v { + "" => Ok(None), + v => whole(v, MAX_SPEC_TOKENS).map(Some).ok_or(bad), + }; + let flag = |v: &str| match v { + "" => Ok(None), + "y" => Ok(Some(true)), + "n" => Ok(Some(false)), + _ => Err(bad), + }; + let spec = ImportSpec { + model: model.to_string(), + context_window: tokens(context)?, + max_output_tokens: tokens(output)?, + reasoning: flag(reasoning)?, + image_input: flag(image)?, + }; + if spec.context_window.is_none() + && spec.max_output_tokens.is_none() + && spec.reasoning.is_none() + && spec.image_input.is_none() + { + return Err(bad); + } + out.push(spec); + } + Ok(out) +} + +/// `^\d{int}(\.\d{1,6})?$` 写的一个十进制数 +fn decimal(s: &str, int: usize) -> Option { + let (i, f) = match s.split_once('.') { + Some((i, f)) => (i, Some(f)), + None => (s, None), + }; + let digits = + |x: &str, max: usize| (1..=max).contains(&x.len()) && x.bytes().all(|b| b.is_ascii_digit()); + if !digits(i, int) || f.is_some_and(|f| !digits(f, 6)) { + return None; + } + s.parse().ok() +} + +/// 价目表的倍率:大于 0、不超过 100 +fn check_multiplier(m: &str) -> Result { + decimal(m, 3) + .filter(|x| *x > 0.0 && *x <= 100.0) + .ok_or(Rejected::Invalid(Param::PriceMultiplier)) +} + +/// 价格:`;` 分隔,每一项 `模型|输入|输出|缓存读|5 分钟缓存写|1 小时缓存写`,美元 / 百万 tokens。 +/// 输入、输出必填;缓存那三项没写(空着或者整个省掉)就按输入价 +fn check_prices(s: &str) -> Result, Rejected> { + let bad = Rejected::Invalid(Param::Prices); + let entries: Vec<&str> = s.split(';').collect(); + if entries.len() > MAX_MODELS { + return Err(bad); + } + let mut out: Vec = Vec::new(); + for e in entries { + let f: Vec<&str> = e.split('|').collect(); + if !(3..=6).contains(&f.len()) { + return Err(bad); + } + let model = f[0]; + if !model_ok(model, false) || out.iter().any(|x| x.model == model) { + return Err(bad); + } + let price = |v: &str| decimal(v, 6).ok_or(bad); + let input = price(f[1])?; + let output = price(f[2])?; + let cache = |i: usize| match f.get(i).copied() { + None | Some("") => Ok(input), + Some(v) => price(v), + }; + out.push(ImportPrice { + model: model.to_string(), + input, + output, + cache_read: cache(3)?, + cache_write_5m: cache(4)?, + cache_write_1h: cache(5)?, + }); } Ok(out) } @@ -318,15 +1317,15 @@ enum State { }, /// 交出去了,等界面来取 Pending { - proposal: ImportProposal, + link: ImportLink, at: Instant, }, /// 界面取走了,对话框开着 Open, } -/// 同一时间最多一条提议。**对话框开着、或者刚关掉的那几秒里来的链接都丢掉**, -/// 也就不会一次次把窗口拉到前面 +/// 同一时间最多一条。**对话框开着、或者刚关掉的那几秒里来的链接都丢掉**, +/// 也就不会一次次把窗口拉到前面。「无法导入」也算一条 #[derive(Debug)] pub struct Gate(Mutex); @@ -335,8 +1334,8 @@ impl Gate { Self(Mutex::new(State::Idle { closed_at: None })) } - /// 收下一份提议。收下了(要打开窗口)是 true - pub fn offer(&self, proposal: ImportProposal, now: Instant) -> bool { + /// 收下一条。收下了(要打开窗口)是 true + pub fn offer(&self, link: ImportLink, now: Instant) -> bool { let Ok(mut g) = self.0.lock() else { return false; }; @@ -348,16 +1347,16 @@ impl Gate { State::Open => false, }; if free { - *g = State::Pending { proposal, at: now }; + *g = State::Pending { link, at: now }; } free } /// 界面来取。取走之后算对话框开着 - pub fn take(&self) -> Option { + pub fn take(&self) -> Option { let mut g = self.0.lock().ok()?; match std::mem::replace(&mut *g, State::Open) { - State::Pending { proposal, .. } => Some(proposal), + State::Pending { link, .. } => Some(link), other => { *g = other; None @@ -365,7 +1364,8 @@ impl Gate { } } - /// 对话框关了(取消、创建完、网页重新加载、窗口被销毁)。还没取走的那一份不动 + /// 对话框关了(取消、创建完、网页重新加载、窗口被销毁),或者界面没打开它就丢了(已经 + /// 开着一个新建、编辑的对话框)。还没取走的那一份不动 pub fn close(&self, now: Instant) { if let Ok(mut g) = self.0.lock() && matches!(*g, State::Open) @@ -385,20 +1385,20 @@ impl Default for Gate { static GATE: Gate = Gate::new(); -/// Tauri 事件:有一份提议等着取(不带内容,内容由 `take_import_link` 取) +/// Tauri 事件:有一条等着取(不带内容,内容由 `take_import_link` 取) pub const EVENT: &str = "import-link"; /// 收到一条导入链接 pub fn receive(app: &tauri::AppHandle, link: &str) { - let proposal = match parse(link) { - Ok(p) => p, + let link = match parse(link) { + Ok(l) => l, Err(why) => { // 链接里可能有密钥:日志只记原因 tracing::warn!(?why, "导入链接未通过校验,已忽略"); return; } }; - if !GATE.offer(proposal, Instant::now()) { + if !GATE.offer(link, Instant::now()) { tracing::info!("已有一条导入链接在处理,这一条已忽略"); return; } @@ -410,13 +1410,14 @@ pub fn receive(app: &tauri::AppHandle, link: &str) { }); } -/// 界面取走等着的那一份提议 +/// 界面取走等着的那一条 #[tauri::command] -pub fn take_import_link() -> Option { +pub fn take_import_link() -> Option { GATE.take() } -/// 导入对话框关了。界面挂上时也调一次:网页重新加载过,之前开着的对话框已经不在了 +/// 导入的对话框关了(或者没打开就丢了)。界面挂上时也调一次:网页重新加载过,之前开着的 +/// 对话框已经不在了 #[tauri::command] pub fn import_link_closed() { GATE.close(Instant::now()); diff --git a/src-tauri/src/import_link/tests.rs b/src-tauri/src/import_link/tests.rs index 216bc592..f7f57dd8 100644 --- a/src-tauri/src/import_link/tests.rs +++ b/src-tauri/src/import_link/tests.rs @@ -2,14 +2,43 @@ use std::time::{Duration, Instant}; -use tw_api::Protocol; +use tw_api::{BalanceSetting, Billing, Protocol, ZaiFamily}; use super::*; const OK: &str = "thinkwatch://import?name=relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-abc123&models=gpt-5,gpt-5-mini"; +/// 和网页上的 `encodeURIComponent` 一样编码 +fn enc(v: &str) -> String { + let mut out = String::new(); + for b in v.bytes() { + if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// 按参数拼一条链接,每个值都编码 +fn link(params: &[(&str, &str)]) -> String { + let q: Vec = params + .iter() + .map(|(k, v)| format!("{k}={}", enc(v))) + .collect(); + format!("thinkwatch://import?{}", q.join("&")) +} + fn p(link: &str) -> Result { - parse(link) + match parse(link)? { + ImportLink::Proposal { proposal } => Ok(*proposal), + ImportLink::Unsupported { param } => panic!("没想到是不认识的参数:{param:?}"), + } +} + +fn pp(params: &[(&str, &str)]) -> Result { + p(&link(params)) } fn with_url(url: &str) -> Result { @@ -28,32 +57,67 @@ fn with_name(name: &str) -> Result { )) } +/// 一个中转站(Sub2API)的链接,后面接上要测的那一项 +fn relay(extra: &[(&str, &str)]) -> Result { + let mut all = vec![ + ("service", "sub2api"), + ("url", "https://relay.example"), + ("key", "sk-relay"), + ]; + all.extend_from_slice(extra); + pp(&all) +} + +/// 什么都没写的那一份(自定义、API 密钥):测试里按它改几项 +fn blank(base_url: &str) -> ImportProposal { + ImportProposal { + service: ImportService::Custom, + auth: ImportAuth::Key, + site: None, + name: None, + base_url: Some(base_url.into()), + protocol: None, + key: None, + oauth: None, + region: None, + aws: None, + headers: vec![], + client_identity: false, + concurrency: None, + balance: None, + models: vec![], + models_only: None, + specs: vec![], + billing: None, + price_sheet: None, + } +} + +// ---------------------------------------------------------- 第一版的链接 + #[test] -fn a_well_formed_link_becomes_a_proposal() { - let got = p(OK).unwrap(); +fn a_well_formed_v1_link_becomes_a_custom_proposal() { assert_eq!( - got, + p(OK).unwrap(), ImportProposal { name: Some("relay".into()), - base_url: "https://api.relay.example/v1".into(), - host: "api.relay.example".into(), protocol: Some(Protocol::OpenaiChat), key: Some("sk-abc123".into()), models: vec!["gpt-5".into(), "gpt-5-mini".into()], + ..blank("https://api.relay.example/v1") } ); } #[test] -fn only_url_is_required() { +fn only_url_is_required_without_a_service() { let got = with_url("https://relay.example/").unwrap(); - assert_eq!(got.base_url, "https://relay.example"); - assert_eq!(got.name, None); - assert_eq!(got.key, None); - assert_eq!(got.protocol, None); - assert!(got.models.is_empty()); - assert_eq!(p("thinkwatch://import"), Err(Rejected::MissingUrl)); - assert_eq!(p("thinkwatch://import?name=x"), Err(Rejected::MissingUrl)); + assert_eq!(got, blank("https://relay.example")); + assert_eq!(p("thinkwatch://import"), Err(Rejected::Missing(Param::Url))); + assert_eq!( + p("thinkwatch://import?name=x"), + Err(Rejected::Missing(Param::Url)) + ); } #[test] @@ -75,29 +139,279 @@ fn other_thinkwatch_links_are_not_imports() { p("thinkwatch://import/other?url=https://x.example"), Err(Rejected::Malformed) ); + assert_eq!( + parse("https://thinkwat.ch/import#url=x"), + Err(Rejected::NotImport) + ); } +/// 第一版的链接都没写服务类型:按地址认出来的那种服务不收它的某一项时按「自定义」,和第一版 +/// 建出来的一样 #[test] -fn an_unknown_parameter_rejects_the_whole_link() { - for extra in [ - "headers=x-a%3A1", - "proxy=system", - "pricing=mine", - "default=true", - "route=all", - "mcp=x", - "icon=https://x.example/i.png", - "Name=x", - "key%00=x", +fn v1_links_stay_valid_when_the_address_is_a_vendors() { + // 正是官方地址:认成那一种服务 + let a = with_url("https://api.anthropic.com").unwrap(); + assert_eq!(a.service, ImportService::Anthropic); + assert_eq!(a.base_url.as_deref(), Some("https://api.anthropic.com")); + let z = with_url("https%3A%2F%2Fapi.z.ai%2Fapi%2Fanthropic").unwrap(); + assert_eq!( + (z.service, z.site), + (ImportService::Zai, Some(ZaiFamily::Zai)) + ); + let b = with_url("https://bedrock-runtime.eu-west-1.amazonaws.com").unwrap(); + assert_eq!(b.service, ImportService::Bedrock); + let o = with_url("http://127.0.0.1:11434").unwrap(); + assert_eq!(o.service, ImportService::Ollama); + // 带着路径、协议不是它那一种、Ollama 带密钥:按「自定义」 + for (url, extra) in [ + ("https://api.openai.com/v1", ""), + ("https://api.anthropic.com", "&protocol=openai-chat"), + ("https://api.deepseek.com", "&protocol=openai-chat"), + ( + "https://api.z.ai/api/coding/paas/v4", + "&protocol=openai-chat", + ), + ("https://open.bigmodel.cn/api/anthropic", "&protocol=gemini"), + ( + "https://bedrock-runtime.us-east-1.amazonaws.com", + "&protocol=anthropic", + ), + ("http://127.0.0.1:11434", "&key=k"), + ("https://chatgpt.com/backend-api", ""), + ] { + let got = p(&format!("thinkwatch://import?url={}{extra}", enc(url))).unwrap(); + assert_eq!(got.service, ImportService::Custom, "{url}{extra}"); + assert_eq!(got.base_url.as_deref(), Some(url), "{url}{extra}"); + assert_eq!(got.site, None); + } + // 认得出的那一种和「自定义」都不收的,照样作废 + assert_eq!( + with_url("https://api.openai.com&auth=account"), + Err(Rejected::NotOffered(Param::Auth)) + ); +} + +/// 官网文档里的示例和链接生成器写出来的链接(`encodeURIComponent` 编码)都要能用 +#[test] +fn the_links_in_the_site_docs_parse() { + let a = p("thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE").unwrap(); + assert_eq!(a.base_url.as_deref(), Some("https://api.relay.example")); + assert_eq!(a.protocol, Some(Protocol::Anthropic)); + let b = p("thinkwatch://import?name=example-openai&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&models=gpt-5%2Cgpt-5-mini").unwrap(); + assert_eq!(b.base_url.as_deref(), Some("https://api.relay.example/v1")); + assert_eq!(b.models, ["gpt-5", "gpt-5-mini"]); + let c = p("thinkwatch://import?url=http%3A%2F%2F%5B%3A%3A1%5D%3A9&key=a%2Bb%3D").unwrap(); + assert_eq!(c.base_url.as_deref(), Some("http://[::1]:9")); + assert_eq!(c.key.as_deref(), Some("a+b=")); +} + +/// 企业版「添加到 ThinkWatch Lite」生成的链接(约定里的写法) +#[test] +fn the_enterprise_link_opens_a_thinkwatch_upstream() { + let got = pp(&[ + ("service", "thinkwatch"), + ("name", "thinkwatch"), + ("url", "https://gateway.corp.example"), + ("key", "tw-k_0123456789abcdef"), + ]) + .unwrap(); + assert_eq!( + got, + ImportProposal { + service: ImportService::Thinkwatch, + name: Some("thinkwatch".into()), + key: Some("tw-k_0123456789abcdef".into()), + ..blank("https://gateway.corp.example") + } + ); + // 企业网关按客户端发来的格式转发:协议不给选 + assert_eq!( + relay_like("thinkwatch", &[("protocol", "anthropic")]), + Err(Rejected::NotOffered(Param::Protocol)) + ); +} + +fn relay_like(service: &str, extra: &[(&str, &str)]) -> Result { + let mut all = vec![("service", service), ("url", "https://relay.example")]; + all.extend_from_slice(extra); + pp(&all) +} + +/// 这种服务最少要给的(没有固定地址的带上地址),后面接上要测的那一项 +fn for_service(s: &Service, extra: &[(&str, &str)]) -> Result { + if s.address == Address::Required { + relay_like(s.id.slug(), extra) + } else { + let mut all = vec![("service", s.id.slug())]; + all.extend_from_slice(extra); + pp(&all) + } +} + +/// 中转站一次给齐「新建上游」里能填的 +#[test] +fn a_full_relay_link() { + let got = relay(&[ + ("name", "hk-relay"), + ("protocol", "openai-responses"), + ("headers", "X-Group: vip\nX-Region: hk"), + ("client_identity", "on"), + ("concurrency", "8"), + ("models", "gpt-5,gpt-5-mini,gpt-5"), + ("models_only", "gpt-5*,o4-mini"), + ("specs", "gpt-5|400000|128000|y|y;gpt-5-mini||32000||"), + ("billing", "per-token"), + ("price_multiplier", "1.2"), + ( + "prices", + "gpt-5|1.25|10|0.125;gpt-5-mini|0.25|2|0.025|0.3|0.5", + ), + ]) + .unwrap(); + assert_eq!( + got, + ImportProposal { + service: ImportService::Sub2api, + name: Some("hk-relay".into()), + protocol: Some(Protocol::OpenaiResponses), + key: Some("sk-relay".into()), + headers: vec![ + ImportHeader { + name: "X-Group".into(), + value: "vip".into() + }, + ImportHeader { + name: "X-Region".into(), + value: "hk".into() + }, + ], + client_identity: true, + concurrency: Some(8), + models: vec!["gpt-5".into(), "gpt-5-mini".into()], + models_only: Some(vec!["gpt-5*".into(), "o4-mini".into()]), + specs: vec![ + ImportSpec { + model: "gpt-5".into(), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some(true), + image_input: Some(true), + }, + ImportSpec { + model: "gpt-5-mini".into(), + context_window: None, + max_output_tokens: Some(32_000), + reasoning: None, + image_input: None, + }, + ], + billing: Some(Billing::PerToken), + price_sheet: Some(ImportSheet { + multiplier: 1.2, + prices: vec![ + ImportPrice { + model: "gpt-5".into(), + input: 1.25, + output: 10.0, + cache_read: 0.125, + // 没写的缓存价按输入价 + cache_write_5m: 1.25, + cache_write_1h: 1.25, + }, + ImportPrice { + model: "gpt-5-mini".into(), + input: 0.25, + output: 2.0, + cache_read: 0.025, + cache_write_5m: 0.3, + cache_write_1h: 0.5, + }, + ], + }), + ..blank("https://relay.example") + } + ); +} + +// ---------------------------------------------------------- 不认识的、永远不收的参数 + +#[test] +fn an_unknown_parameter_asks_for_a_newer_app() { + for (extra, shown) in [ + ("pricing=mine", Some("pricing")), + ("mcp=x", Some("mcp")), + ("icon=https://x.example/i.png", Some("icon")), + ("future_option_2=1", Some("future_option_2")), + ("a=1", Some("a")), + // 约定里只有 `weight`:复数的写法不认识 + ("weights=1", Some("weights")), + // 写法不像一个参数名的不拿给人看 + ("Name=x", None), + ("key%00=x", None), + ("x-y=1", None), + ("%E5%90%8D=x", None), + ("%3Cb%3E=x", None), + ( + "abcdefghijklmnopqrstuvwxyz0123456=1", + None, // 33 个字符 + ), ] { assert_eq!( - p(&format!("{OK}&{extra}")), - Err(Rejected::UnknownParam), + parse(&format!("{OK}&{extra}")), + Ok(ImportLink::Unsupported { + param: shown.map(str::to_string) + }), "{extra}" ); } + assert_eq!( + parse(&format!("{OK}&abcdefghijklmnopqrstuvwxyz012345=1")), + Ok(ImportLink::Unsupported { + param: Some("abcdefghijklmnopqrstuvwxyz012345".into()) + }) + ); + // 不认识的参数先说:其余各项写错了也一样(新版本里它们可能是对的) + assert_eq!( + parse("thinkwatch://import?service=nope&newthing=1"), + Ok(ImportLink::Unsupported { + param: Some("newthing".into()) + }) + ); + // 写坏了的链接照样悄悄作废,不弹「无法导入」 + assert_eq!( + parse("thinkwatch://import?newthing=1&newthing=2"), + Err(Rejected::DuplicateParam) + ); + assert_eq!(parse("thinkwatch://import?newthing="), Err(Rejected::Empty)); + assert_eq!( + parse("thinkwatch://import?newthing=%zz"), + Err(Rejected::Encoding) + ); +} + +#[test] +fn never_accepted_parameters_reject_silently() { + for name in NEVER { + assert_eq!( + parse(&format!("{OK}&{name}=x")), + Err(Rejected::NeverAccepted), + "{name}" + ); + } + // 和不认识的参数在一起时,不认识的先说 + assert!(matches!( + parse(&format!("{OK}&proxy=system&newthing=1")), + Ok(ImportLink::Unsupported { .. }) + )); + // AWS profile 也不能写成认证方式 + assert_eq!( + pp(&[("service", "bedrock"), ("auth", "aws-profile")]), + Err(Rejected::Invalid(Param::Auth)) + ); } +// ---------------------------------------------------------- 整条链接的写法 + #[test] fn a_duplicated_parameter_rejects_the_whole_link() { assert_eq!( @@ -121,37 +435,1031 @@ fn malformed_query_strings_are_rejected() { assert_eq!(p(&format!("{OK}&")), Err(Rejected::Malformed)); assert_eq!(p(&format!("{OK}&&")), Err(Rejected::Malformed)); assert_eq!(p(&format!("{OK}#frag")), Err(Rejected::Malformed)); - assert_eq!( - p("thinkwatch://import?url=https://a.example&name="), - Err(Rejected::Empty) - ); + for k in Param::ALL { + assert_eq!( + p(&format!( + "thinkwatch://import?url=https://a.example&{}=", + k.name() + )), + if *k == Param::Url { + Err(Rejected::DuplicateParam) + } else { + Err(Rejected::Empty) + }, + "{}", + k.name() + ); + } } #[test] fn oversize_links_and_fields_are_rejected() { let long = "a".repeat(MAX_LINK); assert_eq!(p(&format!("{OK}&models={long}")), Err(Rejected::TooLong)); - assert_eq!(with_name(&"n".repeat(MAX_NAME + 1)), Err(Rejected::Name)); + // 正好到上限的整条可以 + let base = "thinkwatch://import?url=https://relay.example&name="; + let fill = "n".repeat(MAX_LINK - base.len()); + assert_eq!( + p(&format!("{base}{fill}")), + Err(Rejected::Invalid(Param::Name)) + ); + assert_eq!( + with_name(&"n".repeat(MAX_NAME + 1)), + Err(Rejected::Invalid(Param::Name)) + ); assert!(with_name(&"n".repeat(MAX_NAME)).is_ok()); - assert_eq!(with_key(&"k".repeat(MAX_KEY + 1)), Err(Rejected::Key)); + // 名称按字符数,不按字节 + assert!(with_name(&enc(&"中".repeat(MAX_NAME))).is_ok()); + assert_eq!( + with_key(&"k".repeat(MAX_KEY + 1)), + Err(Rejected::Invalid(Param::Key)) + ); + assert!(with_key(&"k".repeat(MAX_KEY)).is_ok()); assert_eq!( with_url(&format!("https://relay.example/{}", "p".repeat(MAX_URL))), - Err(Rejected::Url) + Err(Rejected::Invalid(Param::Url)) ); let many: Vec = (0..=MAX_MODELS).map(|i| format!("m{i}")).collect(); assert_eq!( - p(&format!( - "thinkwatch://import?url=https://a.example&models={}", - many.join(",") - )), - Err(Rejected::Models) + relay(&[("models", &many.join(","))]), + Err(Rejected::Invalid(Param::Models)) ); + assert!(relay(&[("models", &many[..MAX_MODELS].join(","))]).is_ok()); assert_eq!( - p(&format!( - "thinkwatch://import?url=https://a.example&models={}", - "m".repeat(MAX_MODEL + 1) - )), - Err(Rejected::Models) + relay(&[("models", &"m".repeat(MAX_MODEL + 1))]), + Err(Rejected::Invalid(Param::Models)) + ); + assert!(relay(&[("models", &"m".repeat(MAX_MODEL))]).is_ok()); +} + +// ---------------------------------------------------------- 每一个值的规矩 + +#[test] +fn no_value_may_contain_dollar_or_braces() { + // core 把 `${NAME}` 换成环境变量:带着它的链接会把用户自己的东西发给对方 + for p in Param::ALL { + for bad in ["%24", "%7B", "%7D", "%24%7BHOME%7D", "%24%24%7BX%7D"] { + let value = if *p == Param::Headers { + format!("X-A%3A%20a{bad}") + } else { + format!("a{bad}") + }; + assert_eq!( + parse(&format!("thinkwatch://import?{}={value}", p.name())), + Err(Rejected::Invalid(*p)), + "{}={value}", + p.name() + ); + } + } +} + +#[test] +fn no_value_may_contain_control_or_invisible_characters() { + for p in Param::ALL { + for bad in [ + "%00", + "%0D", + "%09", + "%1B", + "%7F", + "%E2%80%AE", + "%E2%80%8B", + "%EF%BB%BF", + ] { + let value = if *p == Param::Headers { + format!("X-A%3A%20a{bad}b") + } else { + format!("a{bad}b") + }; + assert_eq!( + parse(&format!("thinkwatch://import?{}={value}", p.name())), + Err(Rejected::Invalid(*p)), + "{}={value}", + p.name() + ); + } + // 换行只在请求头里是行与行的分隔 + if *p != Param::Headers { + assert_eq!( + parse(&format!("thinkwatch://import?{}=a%0Ab", p.name())), + Err(Rejected::Invalid(*p)), + "{}", + p.name() + ); + } + } +} + +#[test] +fn keys_that_core_would_expand_are_rejected() { + for key in [ + "${HOME}", + "%24%7BHOME%7D", + "%24%7BOPENAI_API_KEY%7D", + "sk-%24%7BAWS_SECRET_ACCESS_KEY%7D", + "%24%24%7BX%7D", + "$HOME", + "%24HOME", + "%7B%7Baccess_token%7D%7D", + "sk-%7Bx%7D", + ] { + assert_eq!(with_key(key), Err(Rejected::Invalid(Param::Key)), "{key}"); + } +} + +#[test] +fn double_encoding_stays_literal() { + // 只解一层:`%2524%257B` 解出来是字面的 `%24%7B`,不会再解成 `${` + assert_eq!( + with_key("%2524%257BHOME%257D"), + Err(Rejected::Invalid(Param::Key)) + ); + assert_eq!( + with_name("%252F..%252F").unwrap().name.as_deref(), + Some("%2F..%2F") + ); +} + +#[test] +fn bad_percent_encoding_is_rejected() { + assert_eq!(with_key("sk-%4"), Err(Rejected::Encoding)); + assert_eq!(with_key("sk-%zz"), Err(Rejected::Encoding)); + assert_eq!(with_name("%FF%FE"), Err(Rejected::Encoding)); + assert_eq!(with_name("%C0%AF"), Err(Rejected::Encoding)); +} + +#[test] +fn service_must_be_one_of_the_presets() { + for s in SERVICES { + assert_eq!(ImportService::from_slug(s.id.slug()), Some(s.id)); + } + for bad in [ + "Custom", + "chatgpt", + "openai-chat", + "aws", + "relay", + " custom", + ] { + assert_eq!( + pp(&[("service", bad), ("url", "https://relay.example")]), + Err(Rejected::Invalid(Param::Service)), + "{bad}" + ); + } +} + +/// 每一种服务 × 每一种认证方式:只收对话框里有的那几种。拼的是那种认证方式最少要给的几项 +#[test] +fn every_service_takes_exactly_its_own_auth_methods() { + for s in SERVICES { + for auth in ["key", "oauth", "account", "aws-keys"] { + let mut ps: Vec<(&str, &str)> = vec![("service", s.id.slug()), ("auth", auth)]; + if s.address == Address::Required && auth != "account" { + ps.push(("url", "https://relay.example")); + } + match auth { + "oauth" => ps.extend([ + ("oauth_endpoint", "https://auth.example/oauth/token"), + ("oauth_refresh", "rt-1"), + ]), + "aws-keys" => ps.extend([ + ("aws_access_key_id", "AKIAIOSFODNN7EXAMPLE"), + ("aws_secret_access_key", "wJalrXUtnFEMI/K7MDENG"), + ]), + _ => {} + } + let want = match auth { + "key" => ImportAuth::Key, + "oauth" => ImportAuth::Oauth, + "account" => ImportAuth::Account, + _ => ImportAuth::AwsKeys, + }; + let got = pp(&ps); + if s.auth.contains(&want) { + let got = got.unwrap_or_else(|e| panic!("{} {auth}: {e:?}", s.id.slug())); + assert_eq!((got.service, got.auth), (s.id, want)); + } else { + assert_eq!( + got, + Err(Rejected::NotOffered(Param::Auth)), + "{} {auth}", + s.id.slug() + ); + } + } + // 没写认证方式就是 API 密钥:每一种服务都有 + assert_eq!(s.auth[0], ImportAuth::Key); + } + for bad in ["aws-profile", "Key", "token", "api-key", "none"] { + assert_eq!( + relay(&[("auth", bad)]), + Err(Rejected::Invalid(Param::Auth)), + "{bad}" + ); + } +} + +#[test] +fn account_links_carry_no_credentials_or_connection_fields() { + let chatgpt = pp(&[ + ("service", "openai"), + ("auth", "account"), + ("name", "work"), + ("models", "gpt-6-luna"), + ("models_only", "gpt-5*"), + ("specs", "gpt-6-luna|272000|||"), + ("billing", "free"), + ]) + .unwrap(); + assert_eq!(chatgpt.auth, ImportAuth::Account); + assert_eq!(chatgpt.base_url, None); + assert_eq!(chatgpt.billing, Some(Billing::Free)); + for (k, v) in [ + ("url", "https://api.openai.com"), + ("key", "sk-1"), + ("headers", "X-A: 1"), + ("protocol", "openai-chat"), + ("concurrency", "4"), + ("oauth_refresh", "r"), + ] { + let want = match k { + "url" => Param::Url, + "key" => Param::Key, + "headers" => Param::Headers, + "protocol" => Param::Protocol, + "concurrency" => Param::Concurrency, + _ => Param::OauthRefresh, + }; + assert_eq!( + pp(&[("service", "openai"), ("auth", "account"), (k, v)]), + Err(Rejected::NotOffered(want)), + "{k}" + ); + assert_eq!( + pp(&[("service", "zai"), ("auth", "account"), (k, v)]), + Err(Rejected::NotOffered(want)), + "zai {k}" + ); + } +} + +#[test] +fn z_ai_site_comes_from_the_link_or_the_address() { + let site = |extra: &[(&str, &str)]| { + let mut all = vec![("service", "zai")]; + all.extend_from_slice(extra); + pp(&all).map(|x| x.site) + }; + assert_eq!(site(&[]), Ok(Some(ZaiFamily::Bigmodel))); + assert_eq!(site(&[("site", "zai")]), Ok(Some(ZaiFamily::Zai))); + assert_eq!(site(&[("site", "bigmodel")]), Ok(Some(ZaiFamily::Bigmodel))); + assert_eq!(site(&[("url", ZAI_ZAI)]), Ok(Some(ZaiFamily::Zai))); + assert_eq!( + site(&[("url", ZAI_BIGMODEL), ("site", "bigmodel")]), + Ok(Some(ZaiFamily::Bigmodel)) + ); + assert_eq!( + site(&[("url", ZAI_ZAI), ("site", "bigmodel")]), + Err(Rejected::Invalid(Param::Site)) + ); + assert_eq!( + site(&[("url", "https://api.z.ai/api/paas/v4")]), + Err(Rejected::NotOffered(Param::Url)) + ); + assert_eq!( + site(&[("auth", "account"), ("site", "zai")]), + Ok(Some(ZaiFamily::Zai)) + ); + assert_eq!( + site(&[("site", "ZAI")]), + Err(Rejected::Invalid(Param::Site)) + ); + assert_eq!( + site(&[("site", "china")]), + Err(Rejected::Invalid(Param::Site)) + ); + // 只有 Z.ai / BigModel 有站点 + for s in SERVICES.iter().filter(|s| s.id != ImportService::Zai) { + assert_eq!( + for_service(s, &[("site", "zai")]), + Err(Rejected::NotOffered(Param::Site)), + "{}", + s.id.slug() + ); + } + // 别的服务没有站点这一项 + assert_eq!(relay(&[]).unwrap().site, None); +} + +#[test] +fn the_url_rule_follows_the_service() { + // 没有固定地址的四种:给了 `service` 就得带地址 + for s in ["custom", "thinkwatch", "sub2api", "newapi"] { + assert_eq!( + pp(&[("service", s)]), + Err(Rejected::Missing(Param::Url)), + "{s}" + ); + assert!(relay_like(s, &[]).is_ok(), "{s}"); + } + // 有自己地址的:不给就是它,给了得一模一样 + for s in SERVICES { + if let Address::Own { address } = s.address { + let got = pp(&[("service", s.id.slug())]).unwrap(); + assert_eq!(got.base_url, None, "{}", s.id.slug()); + assert!(pp(&[("service", s.id.slug()), ("url", address)]).is_ok()); + assert!(pp(&[("service", s.id.slug()), ("url", &format!("{address}/"))]).is_ok()); + for other in [ + format!("{address}/v1"), + "https://relay.example".to_string(), + address.replace("https://", "https://x."), + ] { + assert_eq!( + pp(&[("service", s.id.slug()), ("url", &other)]), + Err(Rejected::NotOffered(Param::Url)), + "{} {other}", + s.id.slug() + ); + } + } + } + // Bedrock:VPC 端点、代理照用;Ollama:别的端口、别的本机地址照用 + for (s, url) in [ + ( + "bedrock", + "https://vpce-0abc.bedrock-runtime.us-east-1.vpce.amazonaws.com", + ), + ( + "bedrock", + "https://bedrock-runtime.ap-northeast-1.amazonaws.com", + ), + ("ollama", "http://localhost:11500"), + ("ollama", "https://ollama.lan.example"), + ] { + assert_eq!( + pp(&[("service", s), ("url", url)]) + .unwrap() + .base_url + .as_deref(), + Some(url) + ); + } + // 地址的写法照接口地址的规矩:内网另一台机器上的 Ollama 可以是明文的,公网上的不行 + assert!(pp(&[("service", "ollama"), ("url", "http://192.168.1.5:11434")]).is_ok()); + assert_eq!( + pp(&[ + ("service", "ollama"), + ("url", "http://ollama.example.com:11434") + ]), + Err(Rejected::Invalid(Param::Url)) + ); +} + +#[test] +fn protocol_is_accepted_only_where_the_dialog_offers_it() { + for s in SERVICES { + for proto in LINK_PROTOCOLS { + let got = for_service(s, &[("protocol", proto.slug())]); + let offered = match s.protocols { + Protocols::Fixed { protocol } => protocol == Some(*proto), + Protocols::Choice { protocols } => protocols.contains(proto), + }; + if offered { + assert_eq!( + got.map(|x| x.protocol), + Ok(Some(*proto)), + "{} {proto}", + s.id.slug() + ); + } else { + assert_eq!( + got, + Err(Rejected::NotOffered(Param::Protocol)), + "{} {proto}", + s.id.slug() + ); + } + } + } + // 只收那四种写法 + for bad in [ + "chatgpt", + "bedrock", + "Anthropic", + "openai", + "auto", + "openai_chat", + ] { + assert_eq!( + relay(&[("protocol", bad)]), + Err(Rejected::Invalid(Param::Protocol)), + "{bad}" + ); + } +} + +#[test] +fn key_is_only_for_api_key_auth_and_not_for_ollama() { + assert_eq!( + pp(&[("service", "ollama"), ("key", "k")]), + Err(Rejected::NotOffered(Param::Key)) + ); + assert_eq!( + pp(&[ + ("service", "custom"), + ("url", "https://relay.example"), + ("auth", "oauth"), + ("oauth_endpoint", "https://relay.example/token"), + ("oauth_refresh", "r"), + ("key", "k"), + ]), + Err(Rejected::NotOffered(Param::Key)) + ); + // Bedrock 的 API 密钥就是 `key` + assert_eq!( + pp(&[("service", "bedrock"), ("key", "ABSKabc=")]) + .unwrap() + .key + .as_deref(), + Some("ABSKabc=") + ); + for ok in ["sk-abc_123.~+/=:", "a", "ABSK/xyz=="] { + assert!(with_key(&enc(ok)).is_ok(), "{ok}"); + } + for bad in [ + "sk%20a", + "sk+a", + "sk%21", + "sk%40a", + "sk%2Ca", + "sk%22", + "%E4%B8%AD", + ] { + assert_eq!(with_key(bad), Err(Rejected::Invalid(Param::Key)), "{bad}"); + } +} + +#[test] +fn oauth_needs_an_endpoint_and_a_refresh_token() { + let oauth = |extra: &[(&str, &str)]| { + let mut all = vec![ + ("service", "custom"), + ("url", "https://api.relay.example"), + ("auth", "oauth"), + ]; + all.extend_from_slice(extra); + pp(&all) + }; + let got = oauth(&[ + ("oauth_endpoint", "https://auth.relay.example/oauth/token"), + ("oauth_refresh", &"r".repeat(MAX_LONG_KEY)), + ("oauth_client_id", "cli"), + ("oauth_client_secret", "sec"), + ]) + .unwrap(); + assert_eq!( + got.oauth, + Some(ImportOAuth { + endpoint: "https://auth.relay.example/oauth/token".into(), + refresh: "r".repeat(MAX_LONG_KEY), + client_id: Some("cli".into()), + client_secret: Some("sec".into()), + }) + ); + assert_eq!( + oauth(&[("oauth_refresh", "r")]), + Err(Rejected::Missing(Param::OauthEndpoint)) + ); + assert_eq!( + oauth(&[("oauth_endpoint", "https://a.example/t")]), + Err(Rejected::Missing(Param::OauthRefresh)) + ); + let both = |extra: (&str, &str)| { + oauth(&[ + ("oauth_endpoint", "https://a.example/t"), + ("oauth_refresh", "r"), + extra, + ]) + }; + assert_eq!( + both(("oauth_client_id", &"c".repeat(MAX_KEY + 1))), + Err(Rejected::Invalid(Param::OauthClientId)) + ); + assert_eq!( + both(("oauth_client_secret", "a b")), + Err(Rejected::Invalid(Param::OauthClientSecret)) + ); + assert_eq!( + oauth(&[ + ("oauth_endpoint", "https://a.example/t"), + ("oauth_refresh", &"r".repeat(MAX_LONG_KEY + 1)), + ]), + Err(Rejected::Invalid(Param::OauthRefresh)) + ); + // 端点和接口地址一样的规矩,路径原样留着 + for bad in [ + "http://auth.example/token", + "https://auth.example/token?x=1", + "https://u:p@auth.example/token", + "javascript:alert(1)", + "auth.example/token", + ] { + assert_eq!( + oauth(&[("oauth_endpoint", bad), ("oauth_refresh", "r")]), + Err(Rejected::Invalid(Param::OauthEndpoint)), + "{bad}" + ); + } + assert_eq!( + oauth(&[ + ("oauth_endpoint", "https://auth.example/token/"), + ("oauth_refresh", "r") + ]) + .unwrap() + .oauth + .unwrap() + .endpoint, + "https://auth.example/token/" + ); + // 不是 OAuth 的不收这几项 + for k in [ + "oauth_endpoint", + "oauth_refresh", + "oauth_client_id", + "oauth_client_secret", + ] { + let want = Param::from_name(k).unwrap(); + assert_eq!( + relay(&[(k, "https://a.example/t")]), + Err(Rejected::NotOffered(want)), + "{k}" + ); + } +} + +#[test] +fn bedrock_region_and_access_keys() { + let bedrock = |extra: &[(&str, &str)]| { + let mut all = vec![("service", "bedrock")]; + all.extend_from_slice(extra); + pp(&all) + }; + let got = bedrock(&[ + ("auth", "aws-keys"), + ("region", "eu-central-1"), + ("aws_access_key_id", "AKIAIOSFODNN7EXAMPLE"), + ( + "aws_secret_access_key", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ), + ("aws_session_token", &"T".repeat(MAX_LONG_KEY)), + ]) + .unwrap(); + assert_eq!(got.region.as_deref(), Some("eu-central-1")); + assert_eq!(got.base_url, None); + assert_eq!( + got.aws, + Some(ImportAwsKeys { + access_key_id: "AKIAIOSFODNN7EXAMPLE".into(), + secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(), + session_token: Some("T".repeat(MAX_LONG_KEY)), + }) + ); + let keys = [ + ("auth", "aws-keys"), + ("aws_access_key_id", "AKIAIOSFODNN7EXAMPLE"), + ("aws_secret_access_key", "s"), + ]; + assert_eq!( + bedrock(&keys[..2]), + Err(Rejected::Missing(Param::AwsSecretAccessKey)) + ); + assert_eq!( + bedrock(&[keys[0], keys[2]]), + Err(Rejected::Missing(Param::AwsAccessKeyId)) + ); + for bad in [ + "AKIA", + "akiaiosfodnn7example", + "AKIA-IOSFODNN7EXAMPLE", + &"A".repeat(129), + ] { + assert_eq!( + bedrock(&[keys[0], ("aws_access_key_id", bad), keys[2]]), + Err(Rejected::Invalid(Param::AwsAccessKeyId)), + "{bad}" + ); + } + assert!(bedrock(&[keys[0], ("aws_access_key_id", &"A".repeat(16)), keys[2]]).is_ok()); + assert!(bedrock(&[keys[0], ("aws_access_key_id", &"7".repeat(128)), keys[2]]).is_ok()); + assert_eq!( + bedrock(&[ + keys[0], + keys[1], + ("aws_secret_access_key", &"s".repeat(MAX_KEY + 1)) + ]), + Err(Rejected::Invalid(Param::AwsSecretAccessKey)) + ); + assert_eq!( + bedrock(&[ + keys[0], + keys[1], + keys[2], + ("aws_session_token", &"t".repeat(MAX_LONG_KEY + 1)) + ]), + Err(Rejected::Invalid(Param::AwsSessionToken)) + ); + // 访问密钥只给 aws-keys + assert_eq!( + bedrock(&[keys[1]]), + Err(Rejected::NotOffered(Param::AwsAccessKeyId)) + ); + assert_eq!( + relay(&[("aws_session_token", "t")]), + Err(Rejected::NotOffered(Param::AwsSessionToken)) + ); + // 区域:写法、只有 Bedrock 有 + for ok in ["us-east-1", "a", &"a".repeat(32)] { + assert!(bedrock(&[("region", ok)]).is_ok(), "{ok}"); + } + for bad in ["US-EAST-1", "us_east_1", &"a".repeat(33), "us east"] { + assert_eq!( + bedrock(&[("region", bad)]), + Err(Rejected::Invalid(Param::Region)), + "{bad}" + ); + } + assert_eq!( + relay(&[("region", "us-east-1")]), + Err(Rejected::NotOffered(Param::Region)) + ); + // 标准地址里的区域就是那一栏:链接给的得一样 + let std = ("url", "https://bedrock-runtime.eu-west-1.amazonaws.com"); + assert!(bedrock(&[std, ("region", "eu-west-1")]).is_ok()); + assert_eq!( + bedrock(&[std, ("region", "us-east-1")]), + Err(Rejected::Invalid(Param::Region)) + ); + // 别的地址(VPC 端点):只在访问密钥签名时有区域那一栏 + let vpc = ( + "url", + "https://vpce-1.bedrock-runtime.us-east-1.vpce.amazonaws.com", + ); + assert_eq!( + bedrock(&[vpc, ("region", "us-east-1")]), + Err(Rejected::NotOffered(Param::Region)) + ); + assert!(bedrock(&[vpc, ("region", "us-east-1"), keys[0], keys[1], keys[2]]).is_ok()); +} + +#[test] +fn headers_follow_the_rules() { + let h = |v: &str| relay(&[("headers", v)]); + assert_eq!( + h("X-A:1").unwrap().headers, + [ImportHeader { + name: "X-A".into(), + value: "1".into() + }] + ); + assert_eq!( + h("X-A: spaced value ").unwrap().headers[0].value, + "spaced value" + ); + let sixteen: Vec = (0..MAX_HEADERS).map(|i| format!("X-H{i}: v")).collect(); + assert_eq!(h(&sixteen.join("\n")).unwrap().headers.len(), MAX_HEADERS); + let seventeen: Vec = (0..=MAX_HEADERS).map(|i| format!("X-H{i}: v")).collect(); + let token = "!#%&'*+-.^_`|~0aZ"; + assert_eq!(h(&format!("{token}: v")).unwrap().headers[0].name, token); + for bad in [ + seventeen.join("\n"), + "X-A".into(), + ": v".into(), + "X-A:".into(), + "X-A: ".into(), + "X A: v".into(), + "X-A : v".into(), + "X-(A): v".into(), + "X-A: v\n".into(), + "\nX-A: v".into(), + "X-A: 1\nx-a: 2".into(), + format!("{}: v", "h".repeat(MAX_HEADER_NAME + 1)), + format!("X-A: {}", "v".repeat(MAX_HEADER_VALUE + 1)), + "X-A: caf\u{e9}".into(), + "X-Ä: v".into(), + "X-A: a\r\nX-B: b".into(), + ] { + assert_eq!(h(&bad), Err(Rejected::Invalid(Param::Headers)), "{bad:?}"); + } + assert!(h(&format!("{}: v", "h".repeat(MAX_HEADER_NAME))).is_ok()); + assert!(h(&format!("X-A: {}", "v".repeat(MAX_HEADER_VALUE))).is_ok()); +} + +#[test] +fn credential_and_transport_headers_are_denied() { + for name in DENIED_HEADERS + .iter() + .map(|s| s.to_string()) + .chain(DENIED_HEADERS.iter().map(|s| s.to_ascii_uppercase())) + .chain([ + "Authorization".to_string(), + "X-Api-Key".to_string(), + "proxy-connection".to_string(), + "Proxy-Anything".to_string(), + "sec-fetch-mode".to_string(), + "Sec-CH-UA".to_string(), + ]) + { + assert_eq!( + relay(&[("headers", &format!("X-Ok: 1\n{name}: v"))]), + Err(Rejected::Invalid(Param::Headers)), + "{name}" + ); + } + for ok in [ + "X-Proxy", + "Secret", + "X-Sec-Token", + "Authorization-Extra", + "Hostname", + "Te-Extra", + ] { + assert!(relay(&[("headers", &format!("{ok}: v"))]).is_ok(), "{ok}"); + } +} + +#[test] +fn client_identity_only_where_offered() { + for s in SERVICES { + let got = for_service(s, &[("client_identity", "on")]); + if s.client_identity { + assert_eq!(got.map(|x| x.client_identity), Ok(true), "{}", s.id.slug()); + } else { + assert_eq!( + got, + Err(Rejected::NotOffered(Param::ClientIdentity)), + "{}", + s.id.slug() + ); + } + } + for bad in ["off", "true", "1", "ON", "yes"] { + assert_eq!( + relay(&[("client_identity", bad)]), + Err(Rejected::Invalid(Param::ClientIdentity)), + "{bad}" + ); + } +} + +#[test] +fn concurrency_is_a_plain_whole_number_up_to_a_thousand() { + for (v, n) in [("1", 1), ("8", 8), ("999", 999), ("1000", 1000)] { + assert_eq!(relay(&[("concurrency", v)]).unwrap().concurrency, Some(n)); + } + for bad in [ + "0", + "1001", + "01", + "+1", + "-1", + "1.0", + "1e3", + "4294967296", + " 8", + "八", + ] { + assert_eq!( + relay(&[("concurrency", bad)]), + Err(Rejected::Invalid(Param::Concurrency)), + "{bad}" + ); + } +} + +#[test] +fn balance_is_only_for_custom() { + let custom = |v: &str| relay_like("custom", &[("balance", v)]); + for (v, want) in [ + ("off", BalanceSetting::Off), + ("openrouter", BalanceSetting::Openrouter), + ("deepseek", BalanceSetting::Deepseek), + ("moonshot", BalanceSetting::Moonshot), + ("sub2api", BalanceSetting::Sub2api), + ("newapi", BalanceSetting::Newapi), + ("thinkwatch", BalanceSetting::Thinkwatch), + ] { + assert_eq!(custom(v).unwrap().balance, Some(want), "{v}"); + } + for bad in ["auto", "Off", "kimi", "none"] { + assert_eq!(custom(bad), Err(Rejected::Invalid(Param::Balance)), "{bad}"); + } + for s in SERVICES.iter().filter(|s| s.id != ImportService::Custom) { + assert_eq!( + for_service(s, &[("balance", "off")]), + Err(Rejected::NotOffered(Param::Balance)), + "{}", + s.id.slug() + ); + } + // 没写服务类型、地址认不出来:就是「自定义」 + assert_eq!( + p("thinkwatch://import?url=https://relay.example&balance=newapi") + .unwrap() + .balance, + Some(BalanceSetting::Newapi) + ); +} + +#[test] +fn model_lists_are_validated_and_deduplicated() { + let got = relay(&[("models", "claude-sonnet-5,openai/gpt-5,claude-sonnet-5")]).unwrap(); + assert_eq!(got.models, ["claude-sonnet-5", "openai/gpt-5"]); + assert_eq!( + relay(&[("models", "a:b@c+d_e.f-g/h")]).unwrap().models, + ["a:b@c+d_e.f-g/h"] + ); + for m in [ + "a,,b", "a,", ",a", "a b", "a;b", "", - host: "xn--pple-43d.example", - protocol: "anthropic", - key: "sk-[click](javascript:alert(1))", - models: ["__bold__", "x"], -}; - -function render(p: ImportProposal) { - return renderToStaticMarkup( {}} onCreated={() => {}} />); -} - -describe("导入确认框", () => { - it("把链接里的内容都当纯文本画", () => { - const html = render(hostile); - expect(html).not.toMatch(/]/i); - expect(html).not.toMatch(/|/i); - expect(html).not.toMatch(/\s(href|src|onerror)="/i); - expect(html).toContain("<script>alert(1)</script>"); - expect(html).toContain("__bold__"); - expect(html).toContain("<a href='https://evil.example'>x</a>"); - }); - - it("写明请求和密钥发往哪台主机(ASCII)", () => { - const html = render(hostile); - expect(html).toContain("请求内容与 API 密钥将发送至"); - expect(html).toMatch(/data-testid="import-host"[^>]*>xn--pple-43d\.example { - const html = render(hostile); - expect(html).toMatch(/type="password"[^>]*value="sk-\[click\]\(javascript:alert\(1\)\)"/); - }); - - it("名称已被使用时不能创建", () => { - const html = render({ ...hostile, name: "taken" }); - expect(html).toContain("名称「taken」已被其他上游使用"); - expect(html).toMatch(/]*disabled=""[^>]*>创建/); - }); - - it("没给名称就按地址起一个", () => { - const html = render({ base_url: "https://api.relay.example/v1", host: "api.relay.example", models: [] }); - expect(html).toMatch(/id="import-name"[^>]*value="relay"/); - expect(html).toContain("未提供"); - }); - - it("源码里没有注入 HTML 的口子", () => { - const src = readFileSync(new URL("./ImportDialog.tsx", import.meta.url), "utf8"); - expect(src).not.toContain("dangerouslySetInnerHTML"); - expect(src).not.toMatch(/ void; - /** 创建成功:外面重读概览、落到上游页 */ - onCreated: (name: string) => void; -}) { - const t = useText(importDialogText); - const c = useText(commonText); - const taken = ov.providers.map((p) => p.name); - // 链接给了名称就用它(重名要改);没给按地址起一个不重名的,和新建对话框一样 - const [name, setName] = useState( - () => proposal.name ?? freeName(nameFromUrl(proposal.base_url) || proposal.host, taken), - ); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const form: UpstreamForm = { - ...blankForm(), - name, - baseUrl: proposal.base_url, - protocol: proposal.protocol ?? "", - key: proposal.key ?? "", - manualModels: proposal.models, - }; - const blocking = connectionMissing(form, null, taken); - - async function create() { - setSaving(true); - setError(null); - try { - await api.createProvider({ provider: toInput(form), base_version: ov.config_version }); - onCreated(form.name); - } catch (e) { - setError(errorText(e)); - setSaving(false); - } - } - - return ( - !open && onClose()}> - e.preventDefault()} - > - - {t.title} - {t.desc} - - -
- - - {proposal.host} - - {t.trust} - - - - setName(e.target.value)} - /> - - - -

- {proposal.base_url} -

-
- - -

{proposal.protocol ? protocolLabel(proposal.protocol) : t.auto}

-
- - - {proposal.key ? ( - - ) : ( -

{t.noKey}

- )} -
- - {proposal.models.length > 0 && ( - -

- {proposal.models.join(", ")} -

-
- )} -
- - - - - {blocking && {blocking}} - - - -
-
- ); -} diff --git a/src/import/link.ts b/src/import/link.ts new file mode 100644 index 00000000..416a62ec --- /dev/null +++ b/src/import/link.ts @@ -0,0 +1,23 @@ +/** + * 导入链接(`thinkwatch://import?…`)在界面这一侧的两个口子。 + * + * 链接在 Rust 侧逐项校验(`src-tauri/src/import_link.rs`),**同一时间只留一条**:一份新建上游 + * 的提议,或者一句「无法导入」(链接里有这一版不认识的参数)。界面取走它,打开对应的对话框; + * 对话框怎么关都要报一声(`releaseImportLink`),没打开就丢掉的也要报,否则之后的链接一直 + * 进不来。 + */ +import { invoke } from "@tauri-apps/api/core"; +import type { ImportLink } from "@/types"; + +/** Rust 侧有一条等着取时发的事件(不带内容) */ +export const IMPORT_EVENT = "import-link"; + +/** 取走等着的那一条。没有是 null */ +export function takeImportLink(): Promise { + return invoke("take_import_link"); +} + +/** 导入的对话框关了,或者没打开就丢了:下一条可以进来 */ +export function releaseImportLink(): Promise { + return invoke("import_link_closed").catch(() => {}); +} diff --git a/src/import/services.test.ts b/src/import/services.test.ts new file mode 100644 index 00000000..81391e72 --- /dev/null +++ b/src/import/services.test.ts @@ -0,0 +1,155 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; +import TABLE from "@/generated/import-services.json"; +import type { ImportAuth, ImportProposal, ImportService, Protocol } from "@/types"; +import { PROTOCOLS, bedrockUrl } from "@/upstreams/labels"; +import { PRESETS, SERVICE_HOSTS, ZAI_ENDPOINTS, serviceOf } from "@/upstreams/presets"; +import { authOptions, fieldRules, formFromImport } from "@/upstreams/upstreamForm"; + +/** + * 导入链接按服务类型收哪几项,Rust 侧有一张表(`src-tauri/src/import_link.rs` 的 `SERVICES`): + * 「对话框里显示这一栏,链接才能给」。`src/generated/import-services.json` 是它生成的一份 + * (`tests/ts_bindings.rs` 核对文件和表一致)。这里拿它和新建对话框自己的规矩对:服务类型 + * (`PRESETS`)、显示哪几栏(`fieldRules`)、认证方式(`authOptions`)、按地址认服务 + * (`serviceOf`)。**任何一边改了另一边没跟上,这里就过不了** + */ + +beforeAll(() => setLang("zh")); + +type Address = + | { kind: "required" } + | { kind: "own"; address: string } + | { kind: "site" } + | { kind: "endpoint"; default: string } + | { kind: "local"; default: string }; +type Protocols = { kind: "fixed"; protocol: Protocol | null } | { kind: "choice"; protocols: Protocol[] }; +interface Service { + id: ImportService; + auth: ImportAuth[]; + address: Address; + protocols: Protocols; + key: "required" | "optional" | "none"; + client_identity: boolean; + balance: boolean; + billing: "per-token" | "free"; +} +const T = TABLE as unknown as { + services: Service[]; + hosts: { host: string; service: ImportService }[]; + zai: { zai: string; bigmodel: string }; + protocols: Protocol[]; + bedrock_default_region: string; +}; + +/** 这种服务、这种认证方式最少的一份提议(和 Rust 侧那几条测试拼的一样) */ +function minimal(s: Service, auth: ImportAuth): ImportProposal { + return { + service: s.id, + auth, + site: s.id === "zai" ? "bigmodel" : null, + name: null, + base_url: s.address.kind === "required" && auth !== "account" ? "https://relay.example" : null, + protocol: null, + key: null, + oauth: auth === "oauth" ? { endpoint: "https://relay.example/token", refresh: "r", client_id: null, client_secret: null } : null, + region: null, + aws: auth === "aws-keys" ? { access_key_id: "AKIAIOSFODNN7EXAMPLE", secret_access_key: "s", session_token: null } : null, + headers: [], + client_identity: false, + concurrency: null, + balance: null, + models: [], + models_only: null, + specs: [], + billing: null, + price_sheet: null, + }; +} + +describe("导入链接的服务表和新建对话框对得上", () => { + it("同样的服务类型,同样的顺序", () => { + expect(T.services.map((s) => s.id)).toEqual(PRESETS.map((p) => p.id)); + }); + + it("每一种服务:认证方式、地址、协议、密钥、转发客户端身份、余额来源、计费方式", () => { + for (const [i, s] of T.services.entries()) { + const p = PRESETS[i]!; + const at = `${s.id}`; + // AWS profile 读这台机器上的凭证文件:链接不能选它 + expect(p.auth.filter((a) => a !== "aws-profile"), at).toEqual(s.auth); + expect(p.key, at).toBe(s.key); + expect(!!p.clientIdentity, at).toBe(s.client_identity); + expect(p.billing ?? "per-token", at).toBe(s.billing); + expect(s.balance, at).toBe(p.id === "custom"); + if (s.protocols.kind === "fixed") { + expect(p.protocols, at).toBeUndefined(); + expect(p.protocol || null, at).toBe(s.protocols.protocol); + } else { + expect(s.protocols.protocols, at).toEqual(p.protocols === "all" ? T.protocols : p.protocols); + } + switch (s.address.kind) { + case "required": + expect(p.baseUrl, at).toBe(""); + break; + case "own": + expect(p.baseUrl, at).toBe(s.address.address); + expect(p.fixedUrl, at).toBeFalsy(); + break; + case "site": + expect(p.fixedUrl, at).toBe(true); + break; + case "endpoint": + expect(p.id, at).toBe("bedrock"); + expect(p.baseUrl, at).toBe(s.address.default); + expect(bedrockUrl(T.bedrock_default_region)).toBe(s.address.default); + break; + case "local": + expect(p.id, at).toBe("ollama"); + expect(p.baseUrl, at).toBe(s.address.default); + break; + } + } + }); + + it("链接能给的每一栏,对话框都显示", () => { + for (const s of T.services) { + for (const auth of s.auth) { + const at = `${s.id} ${auth}`; + const form = formFromImport(minimal(s, auth), []); + expect(form.preset, at).toBe(s.id); + expect(authOptions(form), at).toContain(auth); + // 登录账号那一步只有名称和出站代理,连接的几栏都不显示(Rust 侧也都不收) + if (auth === "account") continue; + const rules = fieldRules(form); + expect(rules.key, at).toBe(auth === "key" && s.key !== "none"); + expect(rules.clientIdentity, at).toBe(s.client_identity); + expect(rules.balance, at).toBe(s.balance); + // 由站点定地址的那一种,标准地址时不显示地址那一栏:链接给的只能是标准地址 + expect(rules.url, at).toBe(s.address.kind !== "site"); + if (s.protocols.kind === "fixed") { + expect(rules.protocols, at).toBeNull(); + } else { + const offered = rules.protocols === "all" ? PROTOCOLS.map((x) => x.id) : rules.protocols; + for (const proto of s.protocols.protocols) expect(offered, at).toContain(proto); + } + } + } + }); + + it("没写服务类型时按地址认:和编辑对话框同一张表", () => { + expect(Object.fromEntries(T.hosts.map((h) => [h.host, h.service]))).toEqual(SERVICE_HOSTS); + expect(T.zai).toEqual(ZAI_ENDPOINTS); + const of = (baseUrl: string) => serviceOf({ baseUrl, protocol: "", balance: null }).preset; + for (const h of T.hosts) expect(of(`https://${h.host}/v1`)).toBe(h.service); + expect(of("https://bedrock-runtime.eu-west-1.amazonaws.com")).toBe("bedrock"); + expect(of("https://bedrock-runtime-fips.us-east-1.amazonaws.com")).toBe("bedrock"); + expect(of("http://127.0.0.1:11434")).toBe("ollama"); + expect(of("https://chatgpt.com/backend-api")).toBe("custom"); + expect(of("https://relay.example")).toBe("custom"); + }); + + it("链接里的四种协议都是新建表单能选的", () => { + const ids = PROTOCOLS.map((p) => p.id); + for (const p of T.protocols) expect(ids).toContain(p); + }); +}); diff --git a/src/nav.tsx b/src/nav.tsx index e2249fc0..486bc447 100644 --- a/src/nav.tsx +++ b/src/nav.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useEffect, useRef } from "react"; import type { Filter } from "./requestTable"; -import type { BedrockDraft } from "./types"; +import type { BedrockDraft, ImportProposal } from "./types"; import type { LogFocus } from "./security/SecurityPage"; /** @@ -40,7 +40,7 @@ export type Surface = (typeof SURFACES)[number]; * * · `edit` / `editRoute` / `editGroup` / `detail`:打开这一项的编辑或详情(点那一行)。 * · `create`:新建。`upstreams` 的新建分上游、代理、价目表;新建上游可以带一份 `draft` - * 预填(接管确认框里的「新建 Bedrock 上游」)。 + * 预填(接管确认框里的「新建 Bedrock 上游」),或者一条导入链接的提议(`imported`)。 * · `upstreams.test`:推理测速、链路测速;`routing.dryRun`:试算。 * · `clients.setup`:手动配置(未安装的、不能接管的客户端,点那一行就是它)。 * · `plugins.add`:添加插件;`plugins.review`:审核这个插件变了的文件。 @@ -58,6 +58,11 @@ export interface NavParams { create?: "upstream" | "proxy" | "sheet"; /** 新建上游时按它预填:客户端原来直连 Bedrock 时的设置(接管确认框里带过来的) */ draft?: BedrockDraft; + /** + * 新建上游时按它预填:一条导入链接(Rust 侧校验过)。**页面上已经开着对话框时不打开**, + * 报一声「关了」放下一条进来(`releaseImportLink`) + */ + imported?: ImportProposal; test?: "speed" | "link"; }; routing: { editRoute?: string; editGroup?: string; create?: "route" | "group"; dryRun?: boolean }; diff --git a/src/shell/Workspace.tsx b/src/shell/Workspace.tsx index b6d2fa6c..fc16e968 100644 --- a/src/shell/Workspace.tsx +++ b/src/shell/Workspace.tsx @@ -2,18 +2,17 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { SearchIcon } from "lucide-react"; import { subscribe } from "@/lib/tauriEvent"; -import { useLang, textOf, useText } from "@/i18n"; +import { useLang, useText } from "@/i18n"; import { appText } from "@/App.i18n"; import { EMPTY_FILTER } from "@/requestTable"; import type { ConfigFocus } from "@/configLocate"; -import { importDialogText } from "@/import/ImportDialog.i18n"; -import { notify } from "@/ui/notify"; +import { IMPORT_EVENT, releaseImportLink, takeImportLink } from "@/import/link"; import type { LogFocus } from "@/security/SecurityPage"; import { useTrafficView } from "@/traffic/view"; import { Notices } from "@/Notices"; import { Tip, TooltipRoot } from "@/ui/tip"; import { isMac, isMod } from "@/platform"; -import type { ImportProposal } from "@/types"; +import type { ImportLink, ImportProposal } from "@/types"; import { Button } from "@/ui/button"; import { Toaster } from "@/ui/sonner"; import { trouble } from "@/launch/trouble"; @@ -33,13 +32,15 @@ import { SourceList, drag } from "./SourceList"; import type { CoreLink } from "./useCoreLink"; /* - 只在打开时才用得到的几块:命令面板(cmdk)、配置文件和版本历史、导入链接的确认框。 + 只在打开时才用得到的几块:命令面板(cmdk)、配置文件和版本历史、导入链接的「无法导入」。 **用到时才载入**,交接之后空闲下来时预先载入(见下面 `warmUp`)。 */ const Palette = lazyPart(() => import("@/palette/Palette").then((m) => m.Palette)); const ConfigFileDialog = lazyPart(() => import("@/ConfigDialogs").then((m) => m.ConfigFileDialog)); const VersionHistoryDialog = lazyPart(() => import("@/ConfigDialogs").then((m) => m.VersionHistoryDialog)); -const ImportDialog = lazyPart(() => import("@/import/ImportDialog").then((m) => m.ImportDialog)); +const CannotImportDialog = lazyPart(() => + import("@/import/CannotImportDialog").then((m) => m.CannotImportDialog), +); /** 交接之后等这么久再预先载入其余各块:先让眼前这一页画完、取完数 */ const WARM_UP_MS = 1_500; @@ -47,7 +48,7 @@ const WARM_UP_MS = 1_500; /** 其余各块一块接一块地载入,不挤在同一刻:每一块的求值各占一小段,不卡住界面 */ async function warmUp(alive: () => boolean) { preloadPages(); - for (const part of [Palette, ConfigFileDialog, ImportDialog]) { + for (const part of [Palette, ConfigFileDialog, CannotImportDialog]) { if (!alive()) return; await part.preload().catch(() => {}); } @@ -176,29 +177,35 @@ export default memo(function Workspace({ openRef.current = open; /* - 导入链接(`thinkwatch://import?…`):Rust 那边校验过、一次只留一份,这里取走、弹确认框。 - 和落页一样,窗口可能是为它新建的,所以挂上时先取一次。**挂上时先报「关了」**:网页 - 重新加载过的话,之前开着的那个对话框已经不在了,不报的话之后的链接一直进不来 + 导入链接(`thinkwatch://import?…`):Rust 那边校验过、一次只留一条,这里取走。和落页一样, + 窗口可能是为它新建的,所以挂上时先取一次。**挂上时先报「关了」**:网页重新加载过的话, + 之前开着的那个对话框已经不在了,不报的话之后的链接一直进不来。 + + · 一份提议:等连上 core、拿到概览,打开上游页的新建对话框(和接管确认框里「新建 Bedrock + 上游」同一条路,见 `UpstreamsPage`)。**开着对话框时不打开**:新建、编辑上游的表单, + 或者别的页上填了一半的表单,换页就没了 —— 这一条丢掉,放下一条进来。 + · 「无法导入」:一个小对话框,不换页,什么都不替换。 */ const [importing, setImporting] = useState(null); + const [unsupported, setUnsupported] = useState<{ param: string | null } | null>(null); useEffect(() => { + const got = (l: ImportLink | null) => { + if (l?.kind === "proposal") setImporting((cur) => cur ?? l.proposal); + else if (l?.kind === "unsupported") setUnsupported((cur) => cur ?? { param: l.param }); + }; const take = () => - invoke("take_import_link") - .then((p) => { - if (p) setImporting((cur) => cur ?? p); - }) + takeImportLink() + .then(got) .catch(() => {}); - void invoke("import_link_closed") - .catch(() => {}) - .then(take); - const un = subscribe("import-link", () => void take()); + void releaseImportLink().then(take); + const un = subscribe(IMPORT_EVENT, () => void take()); return () => { un(); }; }, []); - const closeImport = useCallback(() => { - setImporting(null); - void invoke("import_link_closed").catch(() => {}); + const closeUnsupported = useCallback(() => { + setUnsupported(null); + void releaseImportLink(); }, []); /* @@ -342,8 +349,23 @@ export default memo(function Workspace({ // 几个浮层:开着才载入那一块,载入完当场画(载入的那一下什么都不画) const ConfigFileView = usePart(ConfigFileDialog, configFile !== null); const HistoryView = usePart(VersionHistoryDialog, historyOpen); - const importReady = importing !== null && ov !== null && !remoteLost; - const ImportView = usePart(ImportDialog, importReady); + const CannotImportView = usePart(CannotImportDialog, unsupported !== null); + /* + 提议交给上游页:连上了、概览到了、配置能用(不是安全模式)才交。交出去之后对话框归那一页, + 关掉时由它报「关了」 + */ + const importReady = importing !== null && linked && ov !== null && !remoteLost && broken === null; + useEffect(() => { + if (!importReady || !importing) return; + setImporting(null); + if (modalOpen()) { + void releaseImportLink(); + return; + } + setPalette(false); + setShortcuts(false); + open("upstreams", { create: "upstream", imported: importing }); + }, [importReady, importing, open]); const openConfigFile = useCallback((focus: ConfigFocus | null) => setConfigFile({ focus }), []); const openHistory = useCallback(() => setHistoryOpen(true), []); @@ -540,19 +562,8 @@ export default memo(function Workspace({ }} /> )} - {/* 等连上 core、拿到概览再弹:名称是否重名、保存基于哪一版都要它 */} - {importReady && importing && ov && ImportView && ( - { - closeImport(); - notify.success(textOf(importDialogText).created(name)); - changed(); - open("upstreams", { upstream: name }); - }} - /> + {unsupported && CannotImportView && ( + )} {PaletteView && ( `${n} 个模型无法计价,这些模型的请求无法计算费用。`, setPrices: "设置价格…", + /** 导入链接带来的新价目表,在下拉里 */ + linkSheet: (name: string) => `新价目表「${name}」`, + unnamed: "未命名", + linkSheetDetail: (multiplier: number, overrides: number) => + `随上游一起创建 · 倍率 ×${multiplier} · ${overrides} 个模型单独定价`, }, { newUpstream: "(new upstream)", @@ -37,5 +42,9 @@ export const billingSectionText = messages( ? "1 model is unpriced; the cost of its requests cannot be calculated." : `${n} models are unpriced; the cost of their requests cannot be calculated.`, setPrices: "Set prices…", + linkSheet: (name: string) => `New price sheet “${name}”`, + unnamed: "untitled", + linkSheetDetail: (multiplier: number, overrides: number) => + `Created with the upstream · multiplier ×${multiplier} · ${overrides === 1 ? "1 model" : `${overrides} models`} priced separately`, }, ); diff --git a/src/upstreams/BillingSection.tsx b/src/upstreams/BillingSection.tsx index 3f3289c1..418389ab 100644 --- a/src/upstreams/BillingSection.tsx +++ b/src/upstreams/BillingSection.tsx @@ -15,10 +15,12 @@ import type { Overview, ResolvedPrice } from "@/types"; import { billingSectionText } from "./BillingSection.i18n"; import { BILLINGS, PRICE_COLUMNS, perMillion, priceSourceLabel } from "./labels"; import { Boxed, FormItem, Note, UpstreamChips } from "./parts"; -import type { UpstreamForm } from "./upstreamForm"; +import { newSheetName, sheetMissing, type UpstreamForm } from "./upstreamForm"; /** 「新建价目表…」在下拉里的占位值。名称首尾不能有空白,不会和真实名称重复 */ const NEW_SHEET = " new-sheet"; +/** 导入链接带来的新价目表在下拉里的值,同上 */ +const LINK_SHEET = " link-sheet"; export function BillingSection({ form, @@ -29,6 +31,7 @@ export function BillingSection({ prices, onNewSheet, onEditSheet, + onEditNewSheet, onPriceModels, }: { form: UpstreamForm; @@ -41,13 +44,18 @@ export function BillingSection({ prices: Record; onNewSheet: () => void; onEditSheet: (name: string) => void; + /** 改导入链接带来的那张新价目表(草稿) */ + onEditNewSheet: () => void; /** 为无法计价的模型设置价格:已选自定义价目表就在其中覆盖,否则新建一张 */ onPriceModels: (models: string[]) => void; }) { const t = useText(billingSectionText); const perToken = form.billing === "per-token"; const unpriced = models.filter((m) => prices[m] && !prices[m].price); - const sheet = ov.price_sheets.find((s) => s.name === form.pricing); + /** 选的是导入链接带来的新价目表 */ + const linkSheet = form.newSheet?.chosen ? form.newSheet : null; + const sheet = linkSheet ? undefined : ov.price_sheets.find((s) => s.name === form.pricing); + const sheetProblem = sheetMissing(form, ov.price_sheets); const users = sheet ? [ ...new Set([ @@ -75,10 +83,13 @@ export function BillingSection({ - e.target.value === NEW_SHEET ? onNewSheet() : set({ pricing: e.target.value }) - } + value={linkSheet ? LINK_SHEET : form.pricing} + onChange={(e) => { + const v = e.target.value; + if (v === NEW_SHEET) onNewSheet(); + else if (v === LINK_SHEET) set({ newSheet: form.newSheet && { ...form.newSheet, chosen: true } }); + else set({ pricing: v, newSheet: form.newSheet && { ...form.newSheet, chosen: false } }); + }} > {t.defaultSheet} {ov.price_sheets.map((s) => ( @@ -86,15 +97,32 @@ export function BillingSection({ {s.name} ))} - {form.pricing && !sheet && ( + {form.pricing && !sheet && !ov.price_sheets.some((s) => s.name === form.pricing) && ( {form.pricing} )} + {form.newSheet && ( + {t.linkSheet(newSheetName(form) || t.unnamed)} + )} {t.newSheet} )} + {perToken && linkSheet && ( +
+ + {t.linkSheetDetail(linkSheet.multiplier, Object.keys(linkSheet.models).length)} + + {sheetProblem && {sheetProblem}} +
+ +
+ )} + {perToken && sheet && (
{t.usedBy} diff --git a/src/upstreams/ConnectionSection.i18n.ts b/src/upstreams/ConnectionSection.i18n.ts index 938d7a18..afa00215 100644 --- a/src/upstreams/ConnectionSection.i18n.ts +++ b/src/upstreams/ConnectionSection.i18n.ts @@ -40,6 +40,9 @@ export const connectionSectionText = messages( failWithError: "返回错误", fallBackDirect: "改为直连", concurrency: "并发上限", + balanceSource: "余额来源", + balanceAuto: "自动识别", + balanceOff: "不读取", noLimit: "不限", concurrencyDesc: "同时发往此上游的请求数上限,用于限制并发的中转站或账号。", badConcurrency: "须为 1 到 1000 之间的整数。", @@ -96,6 +99,9 @@ export const connectionSectionText = messages( failWithError: "Return an error", fallBackDirect: "Connect directly", concurrency: "Concurrency limit", + balanceSource: "Balance source", + balanceAuto: "Auto-detect", + balanceOff: "Do not read", noLimit: "No limit", concurrencyDesc: "The most requests sent to this upstream at once, for relays and accounts that limit concurrency.", badConcurrency: "A whole number from 1 to 1000.", diff --git a/src/upstreams/ConnectionSection.tsx b/src/upstreams/ConnectionSection.tsx index 3ccde662..2f3af8d5 100644 --- a/src/upstreams/ConnectionSection.tsx +++ b/src/upstreams/ConnectionSection.tsx @@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react"; import { ChevronRightIcon, CircleAlertIcon, CircleCheckIcon, PlugIcon } from "lucide-react"; import { Button, DISCLOSURE } from "@/ui/button"; import { Input } from "@/ui/input"; +import { Label } from "@/ui/label"; import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { SecretInput } from "@/ui/secret-input"; import { Segmented } from "@/ui/segmented"; @@ -11,10 +12,18 @@ import { Tip } from "@/ui/tip"; import { cn } from "@/lib/utils"; import { ms } from "@/format"; import { useText } from "@/i18n"; -import type { Overview, Protocol, ProviderPreview, ProviderTestResult, ProviderView, ZaiFamily } from "@/types"; +import type { + BalanceSetting, + Overview, + Protocol, + ProviderPreview, + ProviderTestResult, + ProviderView, + ZaiFamily, +} from "@/types"; import { AccountKeyPanel } from "./AccountPanel"; import { connectionSectionText } from "./ConnectionSection.i18n"; -import { balanceBrief } from "./balance"; +import { balanceBrief, sourceLabel } from "./balance"; import { useSystemProxyLabel } from "@/connection/Remote"; import { useRemote } from "@/connection/useRemote"; import { remoteText } from "@/connection/remote.i18n"; @@ -306,8 +315,12 @@ export function ConnectionSection({ ); } +/** 「余额来源」能选的:自动识别、不读取,然后是认得的几种来源 */ +const BALANCE_SETTINGS: BalanceSetting[] = ["auto", "off", "openrouter", "deepseek", "moonshot", "sub2api", "newapi", "thinkwatch"]; + /** - * 「高级设置」:请求头、转发客户端身份(中转站和自定义才有)、代理不可用时、并发上限。 + * 「高级设置」:请求头、转发客户端身份(中转站和自定义才有)、代理不可用时、并发上限、余额来源 + * (只有自定义:别的服务由服务类型定)。 * * **默认收起**,收起时那一行写着里面有哪几项;有一项不是默认值就自动展开(`advancedNonDefault`) * —— 收着的一节里藏着一项在起作用的设置,等于看不见它。展开与否在这一节出现时定,之后由 @@ -327,9 +340,13 @@ function AdvancedSettings({ }) { const t = useText(connectionSectionText); const [open, setOpen] = useState(() => advancedNonDefault(form)); - const inside = [t.headers, rules.clientIdentity ? t.clientIdentity : null, t.onProxyFail, t.concurrency].filter( - (x): x is string => x !== null, - ); + const inside = [ + t.headers, + rules.clientIdentity ? t.clientIdentity : null, + t.onProxyFail, + t.concurrency, + rules.balance ? t.balanceSource : null, + ].filter((x): x is string => x !== null); return (
{/* - 展开时左右两栏:左边请求头,右边转发客户端身份、代理不可用时和并发上限 —— 上下排开 - 放不进一屏 + 展开时左右两栏:左边请求头(自定义的余额来源接在下面,和请求头一样是这个服务自己的事), + 右边转发客户端身份、代理不可用时和并发上限 —— 上下排开放不进一屏 */} {open && (
-
{children}
+
+ {children} + {/* 标签和下拉在一行:一屏放得下展开的这一节 */} + {rules.balance && ( +
+ + set({ balance: e.target.value as BalanceSetting })} + > + {BALANCE_SETTINGS.map((b) => ( + + {b === "auto" ? t.balanceAuto : b === "off" ? t.balanceOff : sourceLabel(b)} + + ))} + +
+ )} +
{/* 只有中转站和自定义的服务会只接受特定客户端;各家官方的接口不看这个 */} {rules.clientIdentity && ( diff --git a/src/upstreams/ModelsSection.tsx b/src/upstreams/ModelsSection.tsx index a3baac25..ca6af27d 100644 --- a/src/upstreams/ModelsSection.tsx +++ b/src/upstreams/ModelsSection.tsx @@ -94,6 +94,7 @@ export function modelCount(hasList: boolean, listed: number, added: number): str */ export function ModelsSection({ head, + note: extra, form, set, catalog, @@ -107,6 +108,8 @@ export function ModelsSection({ }: { /** 顶上那一行:新建时检测通过的结果,或者登录的账号。不给就写清单的来历 */ head?: ReactNode; + /** 顶上那一行下面的一句说明(导入链接带的模型规格) */ + note?: ReactNode; form: UpstreamForm; set: (patch: Partial) => void; catalog: ModelCatalog | null; @@ -217,6 +220,7 @@ export function ModelsSection({
)} + {extra && {extra}} {note && {note.text}} {!catalog && !loading && {t.notFetched}} diff --git a/src/upstreams/PriceSheetDialog.i18n.ts b/src/upstreams/PriceSheetDialog.i18n.ts index cf9be08d..bcf501b5 100644 --- a/src/upstreams/PriceSheetDialog.i18n.ts +++ b/src/upstreams/PriceSheetDialog.i18n.ts @@ -52,6 +52,10 @@ export const priceSheetDialogText = messages( deleteSheet: "删除价目表", inUse: (n: number) => `正在被 ${n} 个上游使用`, create: "创建", + draftTitle: "新价目表", + draftUsedBy: "随正在新建的上游一起创建,由它使用。", + nameTaken: (name: string) => `价目表名称「${name}」已被使用`, + done: "完成", deleteTitle: (name: string) => `删除价目表「${name}」`, deleteDesc: "删除后此价目表的倍率与模型覆盖将从配置文件中移除,可在版本历史中恢复。", }, @@ -107,6 +111,10 @@ export const priceSheetDialogText = messages( deleteSheet: "Delete price sheet", inUse: (n: number) => (n === 1 ? "In use by 1 upstream" : `In use by ${n} upstreams`), create: "Create", + draftTitle: "New price sheet", + draftUsedBy: "Created together with the new upstream, which uses it.", + nameTaken: (name: string) => `The price sheet name “${name}” is already in use`, + done: "Done", deleteTitle: (name: string) => `Delete price sheet “${name}”`, deleteDesc: "The price sheet's multiplier and model overrides will be removed from the config file. It can be restored from version history.", }, diff --git a/src/upstreams/PriceSheetDialog.tsx b/src/upstreams/PriceSheetDialog.tsx index 46235377..3d438301 100644 --- a/src/upstreams/PriceSheetDialog.tsx +++ b/src/upstreams/PriceSheetDialog.tsx @@ -54,7 +54,12 @@ export type PriceSheetDialogMode = | { kind: "edit"; name: string; add?: string[] } | { kind: "duplicate"; from: string } /** 默认价目表只能看,不能改 */ - | { kind: "default" }; + | { kind: "default" } + /** + * 导入链接带来的新价目表(上游对话框里的草稿):改完交回 `onDraft`,不写配置 —— 它和上游 + * 一起创建。`add`:打开后为这几个模型各加一条覆盖 + */ + | { kind: "draft"; sheet: PriceSheetInput; add?: string[] }; type Filter = "related" | "overridden" | "all"; @@ -92,6 +97,7 @@ export function PriceSheetDialog({ onClose, onSaved, onDeleted, + onDraft, }: { mode: PriceSheetDialogMode; ov: Overview; @@ -107,10 +113,13 @@ export function PriceSheetDialog({ onSaved: (name: string, version: string) => void; /** 删掉了。`version`:删完之后的配置版本(从上游对话框里删的,那边接着用它) */ onDeleted?: (version: string) => void; + /** 草稿改完了(`draft`) */ + onDraft?: (sheet: PriceSheetInput) => void; }) { const t = useText(priceSheetDialogText); const common = useText(commonText); const readOnly = mode.kind === "default"; + const drafting = mode.kind === "draft"; /** * 保存时带的版本号:打开那一刻的(见 UpstreamDialog 的 `base`)。编辑时价目表的内容 * 是打开之后才读的:中间要是改过,那也是一次冲突,不会被旧的覆盖 @@ -118,9 +127,9 @@ export function PriceSheetDialog({ const [base] = useState(configVersion); const original = mode.kind === "edit" ? (ov.price_sheets.find((s) => s.name === mode.name) ?? null) : null; - const [name, setName] = useState(""); - const [multiplier, setMultiplier] = useState("1.00"); - const [overrides, setOverrides] = useState>({}); + const [name, setName] = useState(mode.kind === "draft" ? mode.sheet.name : ""); + const [multiplier, setMultiplier] = useState(mode.kind === "draft" ? multiplierText(mode.sheet.multiplier) : "1.00"); + const [overrides, setOverrides] = useState>(mode.kind === "draft" ? mode.sheet.models : {}); const [ratios, setRatios] = useState>({}); const [expanded, setExpanded] = useState>(new Set()); const [usedBy, setUsedBy] = useState(() => @@ -171,8 +180,9 @@ export function PriceSheetDialog({ // 从「无法计价」进来:那几个模型先各加一条覆盖 useEffect(() => { - if (mode.kind !== "create" || !mode.prefill) return; - for (const m of mode.prefill.models) void addOverride(m, null); + const add = mode.kind === "create" ? mode.prefill?.models : mode.kind === "draft" ? mode.add : undefined; + if (!add?.length) return; + for (const m of add) void addOverride(m, null); setFilter("overridden"); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -311,9 +321,16 @@ export function PriceSheetDialog({ ? t.enterName : !(Number.isFinite(mult) && mult > 0) ? t.multiplierPositive - : null; + : // 草稿和上游一起创建:名称被占用要先改一个(别的几种由 core 说) + drafting && ov.price_sheets.some((s) => s.name === name.trim()) + ? t.nameTaken(name.trim()) + : null; async function save() { + if (drafting) { + onDraft?.({ name: name.trim(), multiplier: mult, models: overrides }); + return; + } setSaving(true); setError(null); try { @@ -354,7 +371,9 @@ export function PriceSheetDialog({ ? t.editTitle : mode.kind === "duplicate" ? t.duplicateTitle - : t.createTitle; + : drafting + ? t.draftTitle + : t.createTitle; return ( !open && onClose()}> @@ -624,7 +643,10 @@ export function PriceSheetDialog({
{t.usedByTitle} - {readOnly ? ( + {drafting ? ( + // 和上游一起创建:用它的只有正在新建的这一个 + {t.draftUsedBy} + ) : readOnly ? ( // 和价目表列表「使用上游」那一列一样:名字带标志 ) : perTokenProviders.length === 0 ? ( @@ -692,7 +714,7 @@ export function PriceSheetDialog({ {common.cancel} )} @@ -745,6 +767,12 @@ function PriceInput({ ); } +/** 倍率写进格子:两位小数写得下就写两位(1.20),写不下的照它真实的精度(1.055) */ +function multiplierText(m: number): string { + const two = m.toFixed(2); + return Math.abs(Number(two) - m) < 1e-9 ? two : String(m); +} + function round(v: number): number { return Math.round(v * 1e6) / 1e6; } diff --git a/src/upstreams/UpstreamDialog.i18n.ts b/src/upstreams/UpstreamDialog.i18n.ts index 6095cb41..e73cce14 100644 --- a/src/upstreams/UpstreamDialog.i18n.ts +++ b/src/upstreams/UpstreamDialog.i18n.ts @@ -1,5 +1,18 @@ import { messages } from "@/i18n"; +/** 至多列出这么多个模型名,多出来的说一共几个 */ +const LISTED = 3; + +function modelsZh(models: string[]): string { + const head = models.slice(0, LISTED).join("、"); + return models.length > LISTED ? `${head} 等 ${models.length} 个模型` : head; +} + +function modelsEn(models: string[]): string { + const head = models.slice(0, LISTED).join(", "); + return models.length > LISTED ? `${head} and ${models.length - LISTED} more` : head; +} + export const upstreamDialogText = messages( { sections: { @@ -31,6 +44,15 @@ export const upstreamDialogText = messages( name: "名称", nameTaken: "这个名称已被占用", nameReplaces: "将替换该上游的密钥", + /** 导入链接打开的对话框,每一步顶上那一句:「来自导入链接。请求和密钥将发往 a 和 b。」 */ + importFrom: "来自导入链接。", + importSendsTo: "请求和密钥将发往 ", + importAnd: " 和 ", + importEnd: "。", + importPlain: "这个地址不加密,密钥和请求内容以明文传输。", + specsKept: (n: number) => `${n} 个模型的规格来自导入链接,随上游一起保存。`, + specsSkipped: (models: string[]) => `${modelsZh(models)} 不在模型列表中,其规格不保存。`, + specsFailed: (models: string[]) => `${modelsZh(models)} 的规格未能保存`, }, { sections: { @@ -59,5 +81,19 @@ export const upstreamDialogText = messages( name: "Name", nameTaken: "This name is already in use", nameReplaces: "The key of that upstream will be replaced", + importFrom: "From an import link. ", + importSendsTo: "Requests and keys will be sent to ", + importAnd: " and ", + importEnd: ".", + importPlain: "This address is not encrypted; the key and request content travel in plain text.", + specsKept: (n: number) => + n === 1 + ? "The specs of 1 model come from the import link and are saved with the upstream." + : `The specs of ${n} models come from the import link and are saved with the upstream.`, + specsSkipped: (models: string[]) => + models.length === 1 + ? `${modelsEn(models)} is not on the model list; its specs are not saved.` + : `${modelsEn(models)} are not on the model list; their specs are not saved.`, + specsFailed: (models: string[]) => `The specs of ${modelsEn(models)} could not be saved`, }, ); diff --git a/src/upstreams/UpstreamDialog.tsx b/src/upstreams/UpstreamDialog.tsx index 6dd00c36..96c8c72f 100644 --- a/src/upstreams/UpstreamDialog.tsx +++ b/src/upstreams/UpstreamDialog.tsx @@ -1,6 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type Ref } from "react"; +import { LinkIcon } from "lucide-react"; import { Input } from "@/ui/input"; import { Banner } from "@/ui/banner"; +import { focusSelf } from "@/ui/dialog-focus"; import { Button } from "@/ui/button"; import { Dialog, @@ -13,8 +15,10 @@ import { import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { latestOnly } from "@/lib/latestOnly"; +import { notify } from "@/ui/notify"; import type { BedrockDraft, + ImportProposal, Overview, ProviderPreview, ProviderTestResult, @@ -57,16 +61,24 @@ import { describeModelList, fieldRules, formFromDraft, + formFromImport, formFromView, + importHosts, + importPlainText, inScope, modelsMissing, + newSheetName, retarget, + sheetMissing, + sheetToCreate, siteLocked, toInput, withAuth, + withImportedService, withManualAdded, withSite, type AuthMode, + type NewSheet, type UpstreamForm, } from "./upstreamForm"; @@ -93,8 +105,11 @@ const CREATE_SECTIONS: Section[] = ["service", "connection", "models", "billing" const ACCOUNT_SECTIONS: Section[] = ["account", "models", "billing"]; export type UpstreamDialogMode = - /** `draft`:按客户端原来直连 Bedrock 时的设置预填(接管确认框里的「新建 Bedrock 上游」) */ - | { kind: "create"; draft?: BedrockDraft } + /** + * `draft`:按客户端原来直连 Bedrock 时的设置预填(接管确认框里的「新建 Bedrock 上游」)。 + * `imported`:按一条导入链接预填(`import_link.rs` 校验过的那一份) + */ + | { kind: "create"; draft?: BedrockDraft; imported?: ImportProposal } | { kind: "edit"; name: string; section?: Section }; /** 登录账号建出来的上游:登录时选的服务类型和站点(之后表单换成了那个上游的定义)、登上的账号 */ @@ -123,6 +138,12 @@ interface SignedIn { * 写进配置,直接进「模型」,顶上写登的是哪个账号;之后的「模型」「计费」两步改的就是这个 * 上游,走编辑那条路,最后一步「完成」只在有改动时写一次。登录之后回不到第一步:上游已经 * 建好了。 + * + * **导入链接打开的**(`imported`):服务类型、认证方式是链接选的,直接落在第二步,第一步算 + * 走过(可以退回去换一种);每一步顶上一句说明请求和密钥发往哪台主机。和别的新建一样,按 + * 「下一步」检测连接、或者开始登录之前,不写配置、不向任何主机发请求(只问本机 core 自动识别 + * 的结果)。链接带的模型规格在上游建好之后逐个写,不在最终模型清单里的不写;链接带的新价目表 + * 在「创建」时先建,上游用它。只新建:不改、不替换已有的上游 */ export function UpstreamDialog({ mode, @@ -153,12 +174,16 @@ export function UpstreamDialog({ const editing: ProviderView | null = mode.kind === "edit" ? (ov.providers.find((p) => p.name === mode.name) ?? null) : null; const taken = ov.providers.map((p) => p.name); + /** 导入链接带来的那一份 */ + const imported = mode.kind === "create" ? (mode.imported ?? null) : null; const [form, setForm] = useState(() => editing ? formFromView(editing) - : mode.kind === "create" && mode.draft - ? formFromDraft(mode.draft, taken) - : blankForm(), + : imported + ? formFromImport(imported, taken) + : mode.kind === "create" && mode.draft + ? formFromDraft(mode.draft, taken) + : blankForm(), ); /** * 保存时带的版本号:**表单填进来的那一版**,不是保存那一刻的。 @@ -202,8 +227,8 @@ export function UpstreamDialog({ id, label: id === "connection" && accountStep ? t.sections.account : t.sections[id], })); - // 按客户端原来的 Bedrock 设置新建:类型已经定了,直接落到「连接」 - const drafted = mode.kind === "create" && mode.draft != null; + // 按客户端原来的 Bedrock 设置、按导入链接新建:类型已经定了,直接落到第二步 + const drafted = mode.kind === "create" && (mode.draft != null || imported != null); const [section, setSection] = useState
(() => { if (mode.kind !== "edit") return drafted ? "connection" : "service"; const own = account ? ACCOUNT_SECTIONS : SECTIONS; @@ -219,6 +244,21 @@ export function UpstreamDialog({ const [visited, setVisited] = useState>( () => new Set
(drafted ? ["service", "connection"] : ["service"]), ); + /** + * 导入的对话框顶上那一句占的高度(连同它和步骤条之间的间距)。**加在对话框的高度上,不从 + * 中间那一块里扣**:没有它时放得下的每一步,有它时照样放得下 + */ + // 对话框画在 portal 里,挂上得晚一步:等那一句真的挂上了再量 + const [noteEl, setNoteEl] = useState(null); + const [noteHeight, setNoteHeight] = useState(0); + useLayoutEffect(() => { + if (!noteEl) return; + const measure = () => setNoteHeight(noteEl.offsetHeight + NOTE_GAP); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(noteEl); + return () => ro.disconnect(); + }, [noteEl]); /** 第一步的搜索框。放在这里:它画在步骤条右边,不在那一节里 */ const [query, setQuery] = useState(""); @@ -236,6 +276,8 @@ export function UpstreamDialog({ | { kind: "proxy" } /** `name` 空 = 新建;`add` = 打开后为这几个模型各加一条覆盖 */ | { kind: "sheet"; name: string | null; add?: string[] } + /** 导入链接带来的新价目表:改的是草稿,不写配置 */ + | { kind: "draft-sheet"; add?: string[] } >(null); /** @@ -246,7 +288,8 @@ export function UpstreamDialog({ if (!signedIn || adoptedFrom === signedIn.provider) return; const p = ov.providers.find((x) => x.name === signedIn.provider); if (!p) return; - setForm(formFromView(p)); + // 导入链接写的模型、计费两步的值留着:「完成」时写进这个上游 + setForm((f) => (imported ? withModelsAndBilling(formFromView(p), f) : formFromView(p))); setBase(ov.config_version); setCatalogLoading(true); setAdoptedFrom(p.name); @@ -340,15 +383,21 @@ export function UpstreamDialog({ return { rest: r.rest, problem: r.problem, patch }; }); - // 按所选价目表查价。**界面不自己算** —— 覆盖、倍率、跨平台估算都在 core - const sheetKey = form.pricing; + // 按所选价目表查价。**界面不自己算** —— 覆盖、倍率、跨平台估算都在 core。导入链接带来的 + // 新价目表还没建:拿草稿去问 + const draftSheet = sheetToCreate(form); + const sheetKey = draftSheet ? JSON.stringify(draftSheet) : form.pricing; const modelsKey = models.join("\n"); useEffect(() => { if (models.length === 0) { setPrices({}); return; } - const sheet: SheetRef = sheetKey ? { kind: "named", name: sheetKey } : { kind: "default" }; + const sheet: SheetRef = draftSheet + ? { kind: "draft", sheet: draftSheet } + : sheetKey + ? { kind: "named", name: sheetKey } + : { kind: "default" }; let alive = true; const t = setTimeout(() => { api @@ -435,9 +484,15 @@ export function UpstreamDialog({ } } - /** 第一步选了一种服务类型:预设填进表单,进到下一步 */ + /** + * 第一步选了一种服务类型:预设填进表单,进到下一步。导入的对话框里退回来换了一种:链接里 + * 新的这种服务也收的几项留着(`withImportedService`) + */ function pickService(id: string) { - setForm((f) => applyPreset(f, id, taken)); + setForm((f) => { + const next = applyPreset(f, id, taken); + return imported ? withImportedService(f, next, imported) : next; + }); enter("connection"); } @@ -451,8 +506,19 @@ export function UpstreamDialog({ : connectionMissing(form, original, taken) : section === "models" ? modelsMissing(form) - : null; - const blocking = connectionMissing(form, original, taken) ?? modelsMissing(form); + : section === "billing" + ? sheetMissing(form, ov.price_sheets) + : null; + const blocking = + connectionMissing(form, original, taken) ?? modelsMissing(form) ?? sheetMissing(form, ov.price_sheets); + + /** + * 导入链接带的模型规格:最终的模型清单(上游列出的接上手动添加的)里有的才写,别的不写, + * 「模型」一步说明是哪几个 + */ + const linkSpecs = imported?.specs ?? []; + const specsKept = linkSpecs.filter((s) => models.includes(s.model)); + const specsSkipped = linkSpecs.filter((s) => !models.includes(s.model)).map((s) => s.model); async function save() { // 手动添加的输入框里还有没按回车的:先加上;写错了就不存,回到「模型」一节,毛病在那里说 @@ -462,26 +528,65 @@ export function UpstreamDialog({ return; } const f = added ? { ...form, ...added.patch } : form; + const sheet = sheetToCreate(f); + // 规格按保存时的清单挑:输入框里刚加上的也算 + const final = new Set(unionModels(listed ?? [], f.manualModels).map((m) => m.id)); + const specs = linkSpecs.filter((s) => final.has(s.model)); // 登录建出的上游:「模型」「计费」两步没改过什么就不再写一次 - if (signedIn && saved && !editing && JSON.stringify(toInput(f)) === JSON.stringify(toInput(formFromView(saved)))) { + if ( + signedIn && + saved && + !editing && + !sheet && + specs.length === 0 && + JSON.stringify(toInput(f)) === JSON.stringify(toInput(formFromView(saved))) + ) { onSaved(saved.name); return; } setSaving(true); setError(null); + let version = base; + /** 这一次建出来的新价目表:上游没建成时删掉,不留一张没人用的 */ + let createdSheet: string | null = null; try { + if (sheet) { + version = (await api.createPriceSheet({ sheet, base_version: version })).version; + createdSheet = sheet.name; + } const save = { - provider: toInput(f), - base_version: base, + provider: { ...toInput(f), ...(sheet ? { pricing: sheet.name } : {}) }, + base_version: version, }; - if (saved) await api.updateProvider(saved.name, save); - else await api.createProvider(save); - onSaved(f.name); + version = (saved ? await api.updateProvider(saved.name, save) : await api.createProvider(save)).version; } catch (e) { + if (createdSheet) await api.deletePriceSheet(createdSheet, version).catch(() => {}); setError(errorText(e)); - } finally { setSaving(false); + return; } + // 上游建好了:链接带的规格逐个写。写不进去的说一声,上游照样在 + const failed: string[] = []; + for (const s of specs) { + try { + version = ( + await api.setModelSpec({ + provider: f.name, + model: s.model, + context_window: s.context_window, + max_output_tokens: s.max_output_tokens, + reasoning: s.reasoning, + image_input: s.image_input, + base_version: version, + }) + ).version; + } catch { + failed.push(s.model); + } + } + setSaving(false); + if (failed.length > 0) notify.error(t.specsFailed(failed)); + onSaved(f.name); } /** 到某一步,记成走过 */ @@ -578,7 +683,10 @@ export function UpstreamDialog({ */} 0 ? { height: `calc(min(88vh, 760px) + ${noteHeight}px)` } : undefined} onInteractOutside={(e) => e.preventDefault()} + // 导入链接打开的不是点出来的:焦点放在对话框上,不落到第一个按钮上画一圈焦点框 + onOpenAutoFocus={imported ? focusSelf : undefined} > {/* 新建:第一步还没选,标题旁没有标志;选定之后每一步都带着 */} @@ -646,6 +754,14 @@ export function UpstreamDialog({ )}
+ {imported && ( + + )} +
{section === "account" && editing && ( setNested({ kind: "proxy" })} @@ -711,9 +828,18 @@ export function UpstreamDialog({ {section === "models" && ( 0 ? ( + <> + {specsKept.length > 0 && t.specsKept(specsKept.length)} + {specsKept.length > 0 && specsSkipped.length > 0 && " "} + {specsSkipped.length > 0 && t.specsSkipped(specsSkipped)} + + ) : undefined + } form={form} set={set} - catalog={catalog} + catalog={catalog && linkSpecs.length > 0 ? withLinkContext(catalog, linkSpecs) : catalog} entry={entry} prices={prices} perToken={form.billing === "per-token"} @@ -733,8 +859,11 @@ export function UpstreamDialog({ prices={prices} onNewSheet={() => setNested({ kind: "sheet", name: null })} onEditSheet={(name) => setNested({ kind: "sheet", name })} + onEditNewSheet={() => setNested({ kind: "draft-sheet" })} onPriceModels={(models) => - setNested({ kind: "sheet", name: form.pricing || null, add: models }) + draftSheet + ? setNested({ kind: "draft-sheet", add: models }) + : setNested({ kind: "sheet", name: form.pricing || null, add: models }) } /> )} @@ -857,7 +986,7 @@ export function UpstreamDialog({ onClose={() => setNested(null)} onSaved={(name, version) => { setNested(null); - set({ pricing: name }); + set({ pricing: name, newSheet: form.newSheet && { ...form.newSheet, chosen: false } }); setBase(version); onChanged(); }} @@ -871,11 +1000,105 @@ export function UpstreamDialog({ }} /> )} + {nested?.kind === "draft-sheet" && form.newSheet && ( + setNested(null)} + onSaved={() => setNested(null)} + onDraft={(sheet) => { + setNested(null); + const was = form.newSheet; + // 名称没改:照旧跟着上游的名称 + const name = was?.name == null && sheet.name.trim() === newSheetName(form) ? null : sheet.name.trim(); + const next: NewSheet = { name, multiplier: sheet.multiplier, models: sheet.models, chosen: true }; + set({ newSheet: next }); + }} + /> + )}
); } +/** 那一句和步骤条之间的间距:对话框各块之间是 16px,它往上提了 8px(`-mt-2`) */ +const NOTE_GAP = 8; + +/** + * 导入的对话框每一步顶上那一句:来自导入链接,请求和密钥发往哪台主机(OAuth 的 Token 端点在 + * 别的主机上时两台都写)。主机名是 ASCII,IDN 已转成 punycode。只陈述,不是警告。 + * + * `plain`:地址是内网的明文 `http://`,下面再一行说不加密 + */ +function ImportNote({ hosts, plain, ref }: { hosts: string[]; plain: boolean; ref?: Ref }) { + const t = useText(upstreamDialogText); + return ( +
+

+ + + {t.importFrom} + {hosts.length > 0 && ( + <> + {t.importSendsTo} + {hosts.map((h, i) => ( + + {i > 0 && t.importAnd} + {h} + + ))} + {t.importEnd} + + )} + +

+ {plain &&

{t.importPlain}

} +
+ ); +} + +/** 登录之后表单换成了那个上游的定义:顶上那一句和认证方式那一行照登录时的样子说 */ +function signedInForm(form: UpstreamForm, s: SignedIn): UpstreamForm { + return { + ...form, + preset: s.preset, + authMode: "account", + zaiFamily: s.family, + baseUrl: ZAI_ENDPOINTS[s.family], + saved: null, + }; +} + +/** 登录建出的上游换进表单时,导入链接写的「模型」「计费」两步的值留着 */ +function withModelsAndBilling(adopted: UpstreamForm, f: UpstreamForm): UpstreamForm { + return { + ...adopted, + manualModels: f.manualModels, + scope: f.scope, + scopeList: f.scopeList, + billing: f.billing, + newSheet: f.newSheet, + }; +} + +/** 链接写的上下文窗口在模型表里先显示出来(创建后写进规格)。core 记着手写的照旧优先 */ +function withLinkContext(c: ModelCatalog, specs: ImportProposal["specs"]): ModelCatalog { + const fromLink = Object.fromEntries( + specs.flatMap((s) => (s.context_window != null ? [[s.model, s.context_window]] : [])), + ); + return { ...c, manualContext: { ...fromLink, ...c.manualContext } }; +} + /** * 第二步顶上:认证方式(这种服务有几种时)和 Z.ai / BigModel 的站点,两样都有时并排、各占 * 一半。登录进行中和登录之后定住不能改 —— 登录用的就是这几样,上游已经按它们建好了。编辑时 @@ -897,16 +1120,7 @@ function ConnectionHead({ onSite: (family: ZaiFamily) => void; }) { // 登录之后表单换成了那个上游的定义:这一行照登录时的样子画 - const shown: UpstreamForm = signedIn - ? { - ...form, - preset: signedIn.preset, - authMode: "account", - zaiFamily: signedIn.family, - baseUrl: ZAI_ENDPOINTS[signedIn.family], - saved: null, - } - : form; + const shown: UpstreamForm = signedIn ? signedInForm(form, signedIn) : form; const rules = fieldRules(shown); const auth = rules.auth.length > 1; if (!auth && !rules.site) return null; @@ -936,11 +1150,17 @@ function AccountStep({ signedIn, account, onRelogin, + replaceable, }: { form: UpstreamForm; set: (patch: Partial) => void; ov: Overview; login: AccountLogin; + /** + * 同名、同一站点的 Z.ai / BigModel 上游可以换一把密钥。导入链接打开的不可以:链接只新建, + * 不改已有的上游,同名的要换一个名称 + */ + replaceable: boolean; frozen: boolean; family: ZaiFamily; onNewProxy: () => void; @@ -952,6 +1172,7 @@ function AccountStep({ const name = form.name.trim(); const existing = login.phase.at === "idle" && !signedIn ? ov.providers.find((p) => p.name === name) : undefined; const replaces = + replaceable && login.kind === "zai" && existing != null && existing.base_url.replace(/\/+$/, "") === ZAI_ENDPOINTS[family].replace(/\/+$/, ""); diff --git a/src/upstreams/UpstreamsPage.tsx b/src/upstreams/UpstreamsPage.tsx index 7e2651a7..9826e443 100644 --- a/src/upstreams/UpstreamsPage.tsx +++ b/src/upstreams/UpstreamsPage.tsx @@ -45,6 +45,7 @@ import { upstreamsPageText } from "./UpstreamsPage.i18n"; import { CostFigure } from "@/CostFigure"; import { UpstreamTable, problemsOf } from "./UpstreamTable"; import { NextClientsHint } from "@/guide/PageHints"; +import { releaseImportLink } from "@/import/link"; import { AliasesTab, AliasTabLabel, type AliasDialogMode } from "@/aliases/AliasesTab"; import { AliasDialog } from "@/aliases/AliasDialog"; import { aliasesText } from "@/aliases/aliases.i18n"; @@ -155,9 +156,12 @@ export default function UpstreamsPage({ if (p.edit && ov.providers.some((x) => x.name === p.edit)) { setTab("upstreams"); setDialog({ kind: "upstream", mode: { kind: "edit", name: p.edit } }); + } else if (p.create === "upstream" && p.imported && dialog !== null) { + // 导入链接来的时候这一页开着对话框:表单不换掉,这一条丢掉 + void releaseImportLink(); } else if (p.create === "upstream") { setTab("upstreams"); - setDialog({ kind: "upstream", mode: { kind: "create", draft: p.draft } }); + setDialog({ kind: "upstream", mode: { kind: "create", draft: p.draft, imported: p.imported } }); } else if (p.create) { setTab(p.create === "proxy" ? "proxies" : "pricing"); setDialog({ kind: p.create, mode: { kind: "create" } }); @@ -167,6 +171,16 @@ export default function UpstreamsPage({ } }); + /* + 导入链接打开的新建对话框,怎么关都算关了(取消、×、Esc、创建完、换成重新登录的对话框、 + 离开这一页):报一声,下一条导入链接可以进来 + */ + const importOpen = dialog?.kind === "upstream" && dialog.mode.kind === "create" && dialog.mode.imported != null; + useEffect(() => { + if (!importOpen) return; + return () => void releaseImportLink(); + }, [importOpen]); + /** * **打开这一页时补问模型清单**:还没有的、没问到的、过期的。 * diff --git a/src/upstreams/importForm.test.ts b/src/upstreams/importForm.test.ts new file mode 100644 index 00000000..fbd07d88 --- /dev/null +++ b/src/upstreams/importForm.test.ts @@ -0,0 +1,384 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; +import type { ImportProposal, ProviderView } from "@/types"; +import { ZAI_ENDPOINTS } from "./presets"; +import { + advancedNonDefault, + applyPreset, + authModeOf, + blankForm, + connectionMissing, + fieldRules, + formFromImport, + formFromView, + importHosts, + importPlainText, + newSheetName, + retarget, + sheetMissing, + sheetToCreate, + toInput, + withImportedService, +} from "./upstreamForm"; + +// 断言按中文写:不随跑测试那台机器的系统语言变 +beforeAll(() => setLang("zh")); + +/** 一份提议:Rust 侧校验过的形状(`import_link::parse`),没写的照它的默认 */ +function proposal(patch: Partial): ImportProposal { + return { + service: "custom", + auth: "key", + site: null, + name: null, + base_url: null, + protocol: null, + key: null, + oauth: null, + region: null, + aws: null, + headers: [], + client_identity: false, + concurrency: null, + balance: null, + models: [], + models_only: null, + specs: [], + billing: null, + price_sheet: null, + ...patch, + }; +} + +const relay = proposal({ + service: "sub2api", + name: "hk-relay", + base_url: "https://relay.example", + protocol: "openai-responses", + key: "sk-relay", + headers: [{ name: "X-Group", value: "vip" }], + client_identity: true, + concurrency: 8, + models: ["gpt-5", "gpt-5-mini"], + models_only: ["gpt-5*"], + specs: [{ model: "gpt-5", context_window: 400000, max_output_tokens: null, reasoning: true, image_input: null }], + billing: "per-token", + price_sheet: { + multiplier: 1.2, + prices: [{ model: "gpt-5", input: 1.25, output: 10, cache_read: 0.125, cache_write_5m: 1.25, cache_write_1h: 1.25 }], + }, +}); + +describe("按导入链接预填新建对话框", () => { + it("企业网关:名称、地址、密钥照链接", () => { + const f = formFromImport( + proposal({ service: "thinkwatch", name: "thinkwatch", base_url: "https://gw.corp.example", key: "tw-k" }), + [], + ); + expect(f).toMatchObject({ + preset: "thinkwatch", + name: "thinkwatch", + baseUrl: "https://gw.corp.example", + protocol: "", + authMode: "key", + key: "tw-k", + saved: null, + }); + // 余额来源由服务类型定 + expect(toInput(f).balance).toBe("thinkwatch"); + expect(connectionMissing(f, null, [])).toBeNull(); + }); + + it("中转站给齐的几项都进表单,交给 core 的也是这几项", () => { + const f = formFromImport(relay, []); + expect(f).toMatchObject({ + preset: "sub2api", + maxConcurrent: "8", + forwardClientIdentity: true, + manualModels: ["gpt-5", "gpt-5-mini"], + scope: "some", + scopeList: ["gpt-5*"], + billing: "per-token", + }); + expect(advancedNonDefault(f)).toBe(true); + const input = toInput(f); + expect(input).toMatchObject({ + name: "hk-relay", + base_url: "https://relay.example", + key: "sk-relay", + protocol: "openai-responses", + headers: [{ name: "X-Group", value: "vip" }], + forward_client_identity: true, + max_concurrent: 8, + models: ["gpt-5", "gpt-5-mini"], + models_only: ["gpt-5*"], + billing: "per-token", + balance: "sub2api", + }); + // 新价目表不在交给 core 的上游定义里:「创建」时先建它,再让上游用它 + expect(input.pricing).toBeUndefined(); + }); + + it("链接给的名称被占用时照样填,由人改一个;没给的按新建时的规矩起", () => { + const taken = ["hk-relay", "relay", "anthropic", "chatgpt"]; + const f = formFromImport(relay, taken); + expect(f.name).toBe("hk-relay"); + expect(connectionMissing(f, null, taken)).toBe("名称「hk-relay」已被其他上游使用"); + expect(formFromImport(proposal({ base_url: "https://api.relay.example/v1" }), taken).name).toBe("relay-2"); + expect(formFromImport(proposal({ service: "anthropic" }), taken).name).toBe("anthropic-2"); + expect(formFromImport(proposal({ service: "openai", auth: "account" }), taken).name).toBe("chatgpt-2"); + expect(formFromImport(proposal({ service: "zai", auth: "account", site: "zai" }), taken).name).toBe("zai"); + }); + + it("有自己地址的服务:没给地址就是它的,协议、计费照这一格", () => { + const a = formFromImport(proposal({ service: "anthropic", key: "sk-ant" }), []); + expect(a).toMatchObject({ baseUrl: "https://api.anthropic.com", protocol: "anthropic", billing: "per-token" }); + const o = formFromImport(proposal({ service: "ollama" }), []); + expect(o).toMatchObject({ baseUrl: "http://127.0.0.1:11434", billing: "free" }); + expect(fieldRules(o).key).toBe(false); + }); + + it("Bedrock:区域定标准地址;VPC 端点另写签名的区域;访问密钥原样", () => { + const keys = { access_key_id: "AKIAIOSFODNN7EXAMPLE", secret_access_key: "s3cr3t", session_token: "tok" }; + const std = formFromImport(proposal({ service: "bedrock", auth: "aws-keys", region: "eu-central-1", aws: keys }), []); + expect(std).toMatchObject({ + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + awsRegion: "", + authMode: "aws-keys", + awsKeyId: keys.access_key_id, + awsSecret: keys.secret_access_key, + awsToken: "tok", + }); + expect(toInput(std).aws).toEqual({ ...keys, region: undefined }); + const vpc = formFromImport( + proposal({ + service: "bedrock", + auth: "aws-keys", + base_url: "https://vpce-1.bedrock-runtime.us-east-1.vpce.amazonaws.com", + region: "us-east-1", + aws: keys, + }), + [], + ); + expect(vpc.awsRegion).toBe("us-east-1"); + expect(toInput(vpc).aws?.region).toBe("us-east-1"); + const plain = formFromImport(proposal({ service: "bedrock", key: "ABSK" }), []); + expect(plain.baseUrl).toBe("https://bedrock-runtime.us-east-1.amazonaws.com"); + expect(toInput(plain).key).toBe("ABSK"); + }); + + it("自定义的 OAuth:凭据照链接,检测用的 access token 不在链接里", () => { + const f = formFromImport( + proposal({ + base_url: "https://api.relay.example", + auth: "oauth", + oauth: { endpoint: "https://auth.relay.example/oauth/token", refresh: "rt", client_id: "cid", client_secret: null }, + }), + [], + ); + expect(authModeOf(f)).toBe("oauth"); + expect(toInput(f).oauth).toEqual({ + mode: "set", + refresh: "rt", + endpoint: "https://auth.relay.example/oauth/token", + client_id: "cid", + client_secret: undefined, + access: undefined, + }); + }); + + it("账号登录:落在登录那一步,没有凭据;Z.ai / BigModel 按站点", () => { + const c = formFromImport(proposal({ service: "openai", auth: "account" }), []); + expect(c).toMatchObject({ preset: "openai", authMode: "account", name: "chatgpt", key: "" }); + expect(connectionMissing(c, null, [])).toBe("登录后继续"); + const z = formFromImport(proposal({ service: "zai", auth: "account", site: "zai" }), []); + expect(z).toMatchObject({ zaiFamily: "zai", baseUrl: ZAI_ENDPOINTS.zai, name: "zai" }); + const k = formFromImport(proposal({ service: "zai", site: "bigmodel", key: "zk" }), []); + expect(k).toMatchObject({ zaiFamily: "bigmodel", baseUrl: ZAI_ENDPOINTS.bigmodel, name: "bigmodel" }); + expect(fieldRules(k).url).toBe(false); + }); + + it("自定义:余额来源照链接,显示在高级设置里", () => { + const f = formFromImport(proposal({ base_url: "https://relay.example", balance: "newapi" }), []); + expect(fieldRules(f).balance).toBe(true); + expect(advancedNonDefault(f)).toBe(true); + expect(toInput(f).balance).toBe("newapi"); + }); +}); + +describe("导入的对话框顶上那一句说的主机", () => { + it("请求和密钥发往的主机;Token 端点在别的主机上时也写", () => { + expect(importHosts(formFromImport(relay, []))).toEqual(["relay.example"]); + const oauth = (endpoint: string) => + importHosts( + formFromImport( + proposal({ + base_url: "https://api.relay.example", + auth: "oauth", + oauth: { endpoint, refresh: "r", client_id: null, client_secret: null }, + }), + [], + ), + ); + expect(oauth("https://auth.relay.example/token")).toEqual(["api.relay.example", "auth.relay.example"]); + expect(oauth("https://api.relay.example/token")).toEqual(["api.relay.example"]); + }); + + it("带端口的写上端口,IDN 写成 ASCII", () => { + expect(importHosts(formFromImport(proposal({ base_url: "http://[::1]:9" }), []))).toEqual(["[::1]:9"]); + expect(importHosts(formFromImport(proposal({ base_url: "https://例子.example" }), []))).toEqual([ + "xn--fsqu00a.example", + ]); + }); + + it("登录账号:登录之后请求发往的那一边;地址清空了就只说来自导入链接", () => { + expect(importHosts(formFromImport(proposal({ service: "openai", auth: "account" }), []))).toEqual(["chatgpt.com"]); + expect(importHosts(formFromImport(proposal({ service: "zai", auth: "account", site: "bigmodel" }), []))).toEqual([ + "open.bigmodel.cn", + ]); + expect(importHosts({ ...formFromImport(relay, []), baseUrl: "" })).toEqual([]); + }); +}); + +describe("内网的明文地址", () => { + const at = (base_url: string, endpoint?: string) => + importPlainText( + formFromImport( + proposal({ + base_url, + ...(endpoint + ? { auth: "oauth", oauth: { endpoint, refresh: "r", client_id: null, client_secret: null } } + : {}), + }), + [], + ), + ); + + it("不是本机的 http:// 多说一句不加密", () => { + expect(at("http://10.0.0.5:8080")).toBe(true); + expect(at("http://gw.corp")).toBe(true); + expect(at("http://[fd00::1]")).toBe(true); + expect(at("https://gw.corp", "http://sso.corp/token")).toBe(true); + }); + + it("https、本机回环地址、登录账号不说", () => { + expect(at("https://relay.example")).toBe(false); + expect(at("http://127.0.0.1:8317")).toBe(false); + expect(at("http://localhost:3000")).toBe(false); + expect(at("http://[::1]:9")).toBe(false); + expect(at("https://gw.corp", "https://sso.corp/token")).toBe(false); + expect(importPlainText(formFromImport(proposal({ service: "openai", auth: "account" }), []))).toBe(false); + }); +}); + +describe("导入的对话框里退回第一步换一种服务", () => { + const pick = (id: string, p = relay) => { + const f = formFromImport(p, []); + return withImportedService(f, applyPreset(f, id, []), p); + }; + + it("换成另一种没有固定地址的:地址、能选的协议留着", () => { + const f = pick("custom"); + expect(f).toMatchObject({ preset: "custom", baseUrl: "https://relay.example", protocol: "openai-responses" }); + expect(toInput(f).key).toBe("sk-relay"); + }); + + it("换成有自己地址的:地址、协议是那一格的,链接写明的计费方式留着;它不收的几项不交给 core", () => { + const f = pick("anthropic", { ...relay, billing: "free", price_sheet: null }); + expect(f).toMatchObject({ baseUrl: "https://api.anthropic.com", protocol: "anthropic", billing: "free" }); + // 转发客户端身份 Anthropic 不给开:不交 + expect(toInput(f).forward_client_identity).toBe(false); + expect(toInput(pick("ollama")).key).toBeUndefined(); + }); + + it("余额来源只有自定义收", () => { + const custom = proposal({ base_url: "https://relay.example", balance: "off" }); + expect(pick("newapi", custom).balance).toBeNull(); + expect(toInput(pick("newapi", custom)).balance).toBe("newapi"); + expect(pick("custom", custom).balance).toBe("off"); + }); +}); + +describe("导入链接带来的新价目表", () => { + it("名称跟着上游,改过就用改的;只在选着它、按量计费时创建", () => { + const f = formFromImport(relay, []); + expect(sheetToCreate(f)).toEqual({ + name: "hk-relay", + multiplier: 1.2, + models: { "gpt-5": { input: 1.25, output: 10, cache_read: 0.125, cache_write_5m: 1.25, cache_write_1h: 1.25 } }, + }); + expect(newSheetName({ ...f, name: "renamed" })).toBe("renamed"); + expect(newSheetName({ ...f, newSheet: { ...f.newSheet!, name: "fee" } })).toBe("fee"); + expect(sheetToCreate({ ...f, billing: "free" })).toBeNull(); + expect(sheetToCreate({ ...f, newSheet: { ...f.newSheet!, chosen: false } })).toBeNull(); + expect(sheetToCreate(formFromImport({ ...relay, price_sheet: null }, []))).toBeNull(); + }); + + it("名称空着、被占用:由人改一个", () => { + const f = formFromImport(relay, []); + expect(sheetMissing(f, [])).toBeNull(); + expect(sheetMissing(f, [{ name: "hk-relay" }])).toBe("价目表名称「hk-relay」已被使用"); + expect(sheetMissing({ ...f, name: "" }, [])).toBe("填写价目表名称"); + expect(sheetMissing({ ...f, billing: "free" }, [{ name: "hk-relay" }])).toBeNull(); + }); +}); + +describe("余额来源", () => { + const view = (patch: Partial): ProviderView => + ({ + name: "relay", + base_url: "https://relay.example", + key: "k", + auth_header: "authorization", + headers: [], + oauth: null, + protocol: "openai-chat", + protocol_explicit: true, + forward_client_identity: false, + proxy: "direct", + on_proxy_fail: "fail", + models: [], + models_only: null, + model_source: "discovered", + model_status: "listed", + model_fetching: false, + model_count: 0, + disabled: false, + health: "ok", + billing: "per-token", + references: [], + pricing: null, + balance_setting: "auto", + balance: null, + signed_in: null, + ...patch, + }) as ProviderView; + + it("只有自定义显示;不是自动识别时高级设置展开", () => { + const custom = applyPreset(blankForm(), "custom", []); + expect(fieldRules(custom).balance).toBe(true); + expect(advancedNonDefault(custom)).toBe(false); + expect(advancedNonDefault({ ...custom, balance: "auto" })).toBe(false); + expect(advancedNonDefault({ ...custom, balance: "off" })).toBe(true); + for (const id of ["anthropic", "sub2api", "thinkwatch", "ollama"]) { + expect(fieldRules(applyPreset(blankForm(), id, [])).balance, id).toBe(false); + } + }); + + it("新建时换走自定义,选过的余额来源不跟过去", () => { + const f = { ...applyPreset(blankForm(), "custom", []), balance: "moonshot" as const }; + expect(applyPreset(f, "anthropic", []).balance).toBeNull(); + expect(toInput(applyPreset(f, "sub2api", [])).balance).toBe("sub2api"); + }); + + it("编辑自定义上游时改成中转平台的来源:认成那一种服务,这一栏照样在", () => { + const f = formFromView(view({})); + expect(f.preset).toBe("custom"); + const next = retarget({ ...f, balance: "sub2api" }); + expect(next.preset).toBe("sub2api"); + expect(fieldRules(next).balance).toBe(true); + expect(toInput(next).balance).toBe("sub2api"); + // 打开时就是中转平台的:没有这一栏 + expect(fieldRules(formFromView(view({ balance_setting: "sub2api" }))).balance).toBe(false); + }); +}); diff --git a/src/upstreams/presets.ts b/src/upstreams/presets.ts index d88f5bfd..f05d81c0 100644 --- a/src/upstreams/presets.ts +++ b/src/upstreams/presets.ts @@ -97,6 +97,26 @@ export const ZAI_ENDPOINTS: Record = { bigmodel: "https://open.bigmodel.cn/api/anthropic", }; +/** + * 登录 ChatGPT 账号之后请求发往的主机。**地址由 core 决定**:这一份只给导入链接的那一句说明用 + * (`importHosts`) + */ +export const CHATGPT_HOST = "chatgpt.com"; + +/** + * 官方地址 → 服务类型。只认主机:路径不一样(别的接口、别的版本)照样是这一家。导入链接没写 + * 服务类型时 Rust 侧按同一张表认(`import_link::HOSTS`,测试核对两边一样) + */ +export const SERVICE_HOSTS: Record = { + "api.anthropic.com": "anthropic", + "api.openai.com": "openai", + "generativelanguage.googleapis.com": "gemini", + "api.deepseek.com": "deepseek", + "openrouter.ai": "openrouter", + "api.z.ai": "zai", + "open.bigmodel.cn": "zai", +}; + export const PRESETS: Preset[] = [ { id: "anthropic", @@ -300,17 +320,6 @@ export function zaiSiteOf(url: string): ZaiFamily | null { return null; } -/** 官方地址 → 服务类型。只认主机:路径不一样(别的接口、别的版本)照样是这一家 */ -const HOSTS: Record = { - "api.anthropic.com": "anthropic", - "api.openai.com": "openai", - "generativelanguage.googleapis.com": "gemini", - "api.deepseek.com": "deepseek", - "openrouter.ai": "openrouter", - "api.z.ai": "zai", - "open.bigmodel.cn": "zai", -}; - /** * 已保存的上游是哪一种服务:编辑时按它定问哪几项(和新建时选了那一格一样)。 * @@ -336,7 +345,7 @@ export function serviceOf(u: { const family: ZaiFamily = host === "open.bigmodel.cn" ? "bigmodel" : "zai"; const at = (preset: string) => ({ preset, family }); if (u.protocol === "chatgpt" || host === "chatgpt.com") return at(CUSTOM.id); - const byHost = HOSTS[host]; + const byHost = SERVICE_HOSTS[host]; if (byHost) return at(byHost); if (u.protocol === "bedrock" || /^bedrock-runtime(-fips)?\.[a-z0-9-]+\.amazonaws\.com$/.test(host)) { return at("bedrock"); diff --git a/src/upstreams/upstreamForm.i18n.ts b/src/upstreams/upstreamForm.i18n.ts index 433e015b..a1740dd3 100644 --- a/src/upstreams/upstreamForm.i18n.ts +++ b/src/upstreams/upstreamForm.i18n.ts @@ -13,6 +13,8 @@ export const upstreamFormText = messages( headerName: "填写请求头名称", headerValue: (header: string) => `填写请求头「${header}」的值`, concurrency: "并发上限须为 1 到 1000 之间的整数", + sheetName: "填写价目表名称", + sheetTaken: (name: string) => `价目表名称「${name}」已被使用`, pickModel: "至少选择一个模型", found: (n: number) => `发现 ${n} 个模型`, notImplemented: (status: number) => `上游未提供模型列表接口(HTTP ${status})`, @@ -31,6 +33,8 @@ export const upstreamFormText = messages( headerName: "Enter a header name", headerValue: (header: string) => `Enter a value for header “${header}”`, concurrency: "The concurrency limit must be a whole number from 1 to 1000", + sheetName: "Enter a price sheet name", + sheetTaken: (name: string) => `The price sheet name “${name}” is already in use`, pickModel: "Select at least one model", found: (n: number) => (n === 1 ? "1 model found" : `${n} models found`), notImplemented: (status: number) => `No model list endpoint (HTTP ${status})`, diff --git a/src/upstreams/upstreamForm.test.ts b/src/upstreams/upstreamForm.test.ts index 05ecb255..53c25ee6 100644 --- a/src/upstreams/upstreamForm.test.ts +++ b/src/upstreams/upstreamForm.test.ts @@ -365,7 +365,15 @@ describe("编辑时认出是哪一种服务", () => { it("编辑表单按认出来的服务问:官方地址、标准写法时只剩密钥", () => { const f = formFromView(view({ base_url: "https://api.anthropic.com", protocol: "anthropic", protocol_explicit: false, headers: [] })); expect(f.preset).toBe("anthropic"); - expect(fieldRules(f)).toEqual({ auth: ["key"], site: false, url: true, protocols: null, key: true, clientIdentity: false }); + expect(fieldRules(f)).toEqual({ + auth: ["key"], + site: false, + url: true, + protocols: null, + key: true, + clientIdentity: false, + balance: false, + }); // OpenAI 编辑时没有「登录账号」:ChatGPT 账号是另一个上游 const o = formFromView(view({ base_url: "https://api.openai.com", protocol: "openai-chat", protocol_explicit: true })); expect(fieldRules(o).auth).toEqual(["key"]); diff --git a/src/upstreams/upstreamForm.ts b/src/upstreams/upstreamForm.ts index 25938e2e..cc7537d5 100644 --- a/src/upstreams/upstreamForm.ts +++ b/src/upstreams/upstreamForm.ts @@ -18,9 +18,12 @@ import type { BedrockDraft, Billing, HeaderInput, + ImportProposal, ModelList, OAuthChange, OnProxyFail, + PriceFields, + PriceSheetInput, Protocol, ProviderInput, ProviderView, @@ -29,6 +32,7 @@ import type { import { globMatch } from "./glob"; import { bedrockRegionOf, bedrockUrl } from "./labels"; import { + CHATGPT_HOST, CUSTOM, ZAI_ENDPOINTS, nameFromUrl, @@ -82,6 +86,24 @@ export interface SavedTraits { * 都不是(别的服务、别的地址)是 null —— core 只在同名、同一边的标准地址上换密钥 */ site: ZaiFamily | null; + /** + * 打开时认成「自定义」:「余额来源」那一栏一直在。改成 Sub2API 这类来源之后会认成那一种 + * 服务(`retarget`),那一栏不能跟着消失 + */ + balance: boolean; +} + +/** + * 导入链接带来的新价目表(`ImportSheet`):计费一步里预先选着,「创建」时先建它、上游用它。 + * 在对话框里改的只是这一份草稿,创建之前不写配置 + */ +export interface NewSheet { + /** 价目表名称。null = 跟着上游的名称 */ + name: string | null; + multiplier: number; + models: Record; + /** 计费一步里选的是它(而不是默认价目表或已有的一张) */ + chosen: boolean; } export interface UpstreamForm { @@ -142,6 +164,8 @@ export interface UpstreamForm { balance: BalanceSetting | null; /** 编辑时打开那一刻写着的几样;新建是 null */ saved: SavedTraits | null; + /** 导入链接带来的新价目表。没有是 null */ + newSheet: NewSheet | null; } /** @@ -196,6 +220,8 @@ export function applyPreset(form: UpstreamForm, id: string, taken: string[]): Up protocol: next.protocol, billing: next.billing ?? "per-token", authMode: next.auth.includes(form.authMode) ? form.authMode : next.auth[0]!, + // 余额从哪里读只有「自定义」由人选,别的服务按这一格定(`toInput`) + balance: next.id === CUSTOM.id ? form.balance : null, }; return { ...out, name: renamed(form, out, taken) }; } @@ -283,6 +309,8 @@ export interface FieldRules { key: boolean; /** 「转发客户端身份」 */ clientIdentity: boolean; + /** 「余额来源」:只有「自定义」(编辑时打开那一刻是「自定义」的也算) */ + balance: boolean; } /** @@ -328,6 +356,7 @@ export function fieldRules(f: UpstreamForm): FieldRules { protocols, key: authModeOf(f) === "key" && (p.key !== "none" || !!s?.key), clientIdentity: !!p.clientIdentity || !!s?.clientIdentity, + balance: p.id === CUSTOM.id || !!s?.balance, }; } @@ -380,6 +409,7 @@ export function blankForm(): UpstreamForm { disabled: false, balance: null, saved: null, + newSheet: null, }; } @@ -475,10 +505,145 @@ export function formFromView(p: ProviderView): UpstreamForm { otherUrl: zaiSiteOf(p.base_url) === null, signedIn: p.signed_in, site: service.preset === "zai" ? (p.signed_in ?? zaiSiteOf(p.base_url)) : null, + balance: service.preset === CUSTOM.id, }, + newSheet: null, }; } +/** + * 按一条导入链接(`ImportProposal`,Rust 侧逐项校验过)新建:服务类型、认证方式、站点是链接 + * 选的,别的照链接填,链接没给的照这种服务新建时的样子填(地址、协议、计费方式、名称)。 + * + * 名称没给时和新建时一样起:有固定名字的服务用它(登录 ChatGPT 账号是 `chatgpt`,Z.ai / + * BigModel 按站点),没有的跟着地址猜,被占用就加序号。**链接给的名称被占用时照样填**: + * 由人改一个,和手填一样 + */ +export function formFromImport(p: ImportProposal, taken: string[]): UpstreamForm { + const preset = presetById(p.service); + const zaiFamily: ZaiFamily = p.site ?? "bigmodel"; + const baseUrl = + p.base_url ?? + (p.service === "zai" ? ZAI_ENDPOINTS[zaiFamily] : p.service === "bedrock" && p.region ? bedrockUrl(p.region) : preset.baseUrl); + const form: UpstreamForm = { + ...blankForm(), + preset: preset.id, + zaiFamily, + baseUrl, + protocol: p.protocol ?? preset.protocol, + authMode: p.auth, + key: p.key ?? "", + headers: p.headers.map((h) => headerRow(h.name, h.value)), + oauthRefresh: p.oauth?.refresh ?? "", + oauthEndpoint: p.oauth?.endpoint ?? "", + oauthClientId: p.oauth?.client_id ?? "", + oauthClientSecret: p.oauth?.client_secret ?? "", + awsKeyId: p.aws?.access_key_id ?? "", + awsSecret: p.aws?.secret_access_key ?? "", + awsToken: p.aws?.session_token ?? "", + // 标准地址的区域就在地址里;VPC 端点、代理这类地址签名用的区域另写 + awsRegion: p.region && bedrockRegionOf(baseUrl) === null ? p.region : "", + forwardClientIdentity: p.client_identity, + maxConcurrent: p.concurrency != null ? String(p.concurrency) : "", + manualModels: p.models, + scope: p.models_only ? "some" : "all", + scopeList: p.models_only ?? [], + billing: p.billing ?? preset.billing ?? "per-token", + balance: p.balance, + newSheet: p.price_sheet + ? { + name: null, + multiplier: p.price_sheet.multiplier, + models: Object.fromEntries( + p.price_sheet.prices.map(({ model, ...price }) => [model, price satisfies PriceFields]), + ), + chosen: true, + } + : null, + }; + return { ...form, name: p.name ?? freeName(defaultName(form) || nameFromUrl(baseUrl), taken) }; +} + +/** + * 导入的对话框里第一步换了一种服务:预设照常填进来(`applyPreset`),链接里新的这种服务也收的 + * 几项留着 —— 没有固定地址时的地址、它能选的协议、链接写明的计费方式。余额从哪里读只有 + * 「自定义」收。别的(密钥、请求头、模型、价目表……)本来就留在表单里,这种服务不显示的那几栏 + * 不交给 core(`toInput`) + */ +export function withImportedService(prev: UpstreamForm, next: UpstreamForm, p: ImportProposal): UpstreamForm { + const preset = presetById(next.preset); + const protocols = preset.protocols; + const protocolOk = + prev.protocol !== "" && (protocols === "all" || (Array.isArray(protocols) && protocols.includes(prev.protocol))); + return { + ...next, + baseUrl: preset.baseUrl === "" && prev.baseUrl.trim() !== "" ? prev.baseUrl : next.baseUrl, + protocol: protocolOk ? prev.protocol : next.protocol, + billing: p.billing ?? next.billing, + }; +} + +/** + * 导入的对话框顶上那一句说的主机:请求和密钥发往哪里(ASCII,IDN 是 punycode),OAuth 的 + * Token 端点在别的主机上时也写上。登录账号时是登录之后请求发往的那一边 + */ +export function importHosts(f: UpstreamForm): string[] { + const host = (u: string) => { + try { + return new URL(u.trim()).host; + } catch { + return ""; + } + }; + const mode = authModeOf(f); + const main = + mode === "account" ? (f.preset === "openai" ? CHATGPT_HOST : host(ZAI_ENDPOINTS[f.zaiFamily])) : host(f.baseUrl); + const out = main ? [main] : []; + const token = mode === "oauth" ? host(f.oauthEndpoint) : ""; + if (token && !out.includes(token)) out.push(token); + return out; +} + +/** + * 导入的对话框里,请求或者 OAuth 的 Token 端点走的是明文的 `http://`、又不是这台机器(链接 + * 只能给内网的这种地址,`import_link.rs` 的 `plain_http_ok`):顶上那一句下面再说一句不加密 + */ +export function importPlainText(f: UpstreamForm): boolean { + const plain = (u: string) => { + try { + const x = new URL(u.trim()); + return x.protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(x.hostname); + } catch { + return false; + } + }; + const mode = authModeOf(f); + if (mode === "account") return false; + return plain(f.baseUrl) || (mode === "oauth" && plain(f.oauthEndpoint)); +} + +/** 新价目表此刻叫什么:写过名称用它,没写跟着上游的名称 */ +export function newSheetName(f: Pick): string { + return (f.newSheet?.name ?? f.name).trim(); +} + +/** 「创建」时要先建的那张新价目表:选着它、按量计费时才有 */ +export function sheetToCreate(f: UpstreamForm): PriceSheetInput | null { + const s = f.newSheet; + if (!s?.chosen || f.billing !== "per-token") return null; + return { name: newSheetName(f), multiplier: s.multiplier, models: s.models }; +} + +/** 新价目表缺什么:名称空着,或者已被占用(由人改一个,和上游重名一样) */ +export function sheetMissing(f: UpstreamForm, sheets: readonly { name: string }[]): string | null { + const s = sheetToCreate(f); + if (!s) return null; + const t = textOf(upstreamFormText); + if (s.name === "") return t.sheetName; + if (sheets.some((x) => x.name === s.name)) return t.sheetTaken(s.name); + return null; +} + /** * 这一家是 Bedrock:协议选的就是它,或者自动识别、地址是标准的 Bedrock 地址(core 按 * 同一个规矩认)。**不等识别结果**:那是异步的,换地址的一瞬间会闪一下 @@ -544,9 +709,9 @@ function awsInput(f: UpstreamForm): AwsKeys | undefined { } /** - * 交给 core 的定义。**余额从哪里读,界面上没有地方选**:编辑时是配置里原来写的那个, - * 原样交回;新建时由第一步选的服务类型定(Sub2API、New API、ThinkWatch 企业网关), - * 别的不给,就是自动。 + * 交给 core 的定义。**余额从哪里读**:「自定义」在高级设置里选(「余额来源」);别的服务 + * 编辑时是配置里原来写的那个,原样交回,新建时由第一步选的服务类型定(Sub2API、New API、 + * ThinkWatch 企业网关),别的不给,就是自动。 * * 编辑时认证方式是账号登录:密钥那一栏不显示,配置里的那把**原样交回** —— core 见密钥 * 没变,就还记着它是登录换来的;不交就是把密钥删了 @@ -563,7 +728,8 @@ export function toInput(f: UpstreamForm): ProviderInput { oauth: oauthChange(f), aws: awsInput(f), protocol: f.protocol || undefined, - forward_client_identity: f.forwardClientIdentity, + // 这种服务不给开的不交:第一步换了服务类型,之前打开的那一项不跟过去 + forward_client_identity: f.forwardClientIdentity && fieldRules(f).clientIdentity, proxy: f.proxy, on_proxy_fail: f.onProxyFail, models: f.manualModels, @@ -577,17 +743,17 @@ export function toInput(f: UpstreamForm): ProviderInput { /** * 「高级设置」里的几项有没有不是默认值的:写了请求头、打开了转发客户端身份、代理不可用时 - * 改为直连、写了并发上限。**有就自动展开** —— 收着的一节里藏着一项在起作用的设置, - * 和没写一样看不见。只有名称或只有值的请求头也算:那一行正等着补全,保存按不下去 + * 改为直连、写了并发上限、「自定义」的余额来源不是自动识别。**有就自动展开** —— 收着的一节里 + * 藏着一项在起作用的设置,和没写一样看不见。只有名称或只有值的请求头也算:那一行正等着补全, + * 保存按不下去 */ -export function advancedNonDefault( - f: Pick, -): boolean { +export function advancedNonDefault(f: UpstreamForm): boolean { return ( f.headers.some((h) => h.name.trim() !== "" || h.value.trim() !== "") || f.forwardClientIdentity || f.onProxyFail !== "fail" || - f.maxConcurrent.trim() !== "" + f.maxConcurrent.trim() !== "" || + (fieldRules(f).balance && (f.balance ?? "auto") !== "auto") ); }