diff --git a/package.json b/package.json index 9b3780d..ff46313 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "thinkwatch-lite", "private": true, "description": "Desktop app for a local AI API gateway on macOS, Windows and Linux", - "version": "2026.10.6", + "version": "2026.10.7", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { diff --git a/release-notes/2026.10.7.md b/release-notes/2026.10.7.md new file mode 100644 index 0000000..6998c4e --- /dev/null +++ b/release-notes/2026.10.7.md @@ -0,0 +1,12 @@ +**Upgrade notes:** +- The bundled core is now 0.64.0, which speaks control-plane protocol 40. A remote server has to run core 0.64.0 too: this version does not connect to 0.63.0, and 2026.10.6 and earlier do not connect to 0.64.0. +- Request history is kept. +- Clients that list the gateway's models in their own configuration (opencode, Pi, oh-my-pi, Qwen Code, Grok Build) are flagged on the Clients page for an update once, because the configuration now also carries each model's specs. + +**Clients:** +- Taking over opencode, Pi, oh-my-pi, Qwen Code or Grok Build now writes each model's specs next to its name, from the price table or the specs set on the Upstreams page: the context window, and where the client uses them, the output limit, whether the model reasons, and whether it takes images. Before, only names were written and the clients used their own defaults, for example 128K context and no reasoning levels for every model in Pi, and no automatic compaction in opencode. +- Only what is known is written; a client uses its own default for the rest. Qwen Code and Grok Build get no output limit, because both send it with every request. Qwen Code gets no reasoning setting, because its reasoning settings change what every request sends. +- When a model's specs change, for example after setting them by hand, the Clients page asks to update the client's configuration, as it already did when the model list changed. + +**Upstreams:** +- Under "Specs…" in an upstream's model list, a model can also be marked as reasoning or not, and as taking images or not, or left to the price table. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7984f4b..5ea9277 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4766,7 +4766,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.6" +version = "2026.10.7" dependencies = [ "anyhow", "block2", @@ -5268,8 +5268,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.63.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.63.0#3ac509473a50c9cbe2f92a895a462e02ca08498d" +version = "0.64.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.0#f2d4cafd979d979bcae69cb2d6a3f9007d275333" dependencies = [ "serde", "serde_json", @@ -5280,8 +5280,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.63.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.63.0#3ac509473a50c9cbe2f92a895a462e02ca08498d" +version = "0.64.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.0#f2d4cafd979d979bcae69cb2d6a3f9007d275333" dependencies = [ "serde", "serde_json", @@ -5289,8 +5289,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.63.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.63.0#3ac509473a50c9cbe2f92a895a462e02ca08498d" +version = "0.64.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.0#f2d4cafd979d979bcae69cb2d6a3f9007d275333" dependencies = [ "base64 0.22.1", "bytes", @@ -5305,8 +5305,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.63.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.63.0#3ac509473a50c9cbe2f92a895a462e02ca08498d" +version = "0.64.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.0#f2d4cafd979d979bcae69cb2d6a3f9007d275333" dependencies = [ "serde", "serde_json", @@ -5332,8 +5332,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.63.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.63.0#3ac509473a50c9cbe2f92a895a462e02ca08498d" +version = "0.64.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.0#f2d4cafd979d979bcae69cb2d6a3f9007d275333" dependencies = [ "serde", "ts-rs", @@ -5341,8 +5341,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.63.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.63.0#3ac509473a50c9cbe2f92a895a462e02ca08498d" +version = "0.64.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.0#f2d4cafd979d979bcae69cb2d6a3f9007d275333" dependencies = [ "notify", "thiserror 2.0.21", @@ -5351,8 +5351,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.63.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.63.0#3ac509473a50c9cbe2f92a895a462e02ca08498d" +version = "0.64.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.0#f2d4cafd979d979bcae69cb2d6a3f9007d275333" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1a130ac..c488433 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkwatch-lite" -version = "2026.10.6" +version = "2026.10.7" edition = "2024" # **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust # 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和 @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.63.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.63.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.63.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.63.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.63.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.63.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/crates/tw-adopt/src/clients.rs b/src-tauri/crates/tw-adopt/src/clients.rs index ea1dafa..b5834c0 100644 --- a/src-tauri/crates/tw-adopt/src/clients.rs +++ b/src-tauri/crates/tw-adopt/src/clients.rs @@ -191,12 +191,52 @@ pub struct Gateway { pub key: Option, /// 这把密钥能用的模型:网关的 `GET /v1/models` 对它答的。只有要把模型写进 /// 配置的客户端([`Client::writes_models`])用得上,别的留空。 - pub models: Vec, + pub models: Vec, +} + +/// 网关列出的一个模型:名字,加上客户端要照着跑的几项规格。 +/// +/// 规格只有网关答了才有(`GET /v1/models` 的 `context_window`、`max_output_tokens`、 +/// `supports_reasoning`、`input_modalities`),**不知道就是 `None`,写配置时整项不写** —— +/// 客户端会用自己的默认值或者按名字查它自带的目录,一个编出来的数它却会照着截断对话。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ModelCard { + pub id: String, + /// 一次最多输入多少 token,也就是上下文窗口 + pub context_window: Option, + /// 一次最多输出多少 token + pub max_output_tokens: Option, + /// 会不会推理 + pub reasoning: Option, + /// 收不收图 + pub image_input: Option, +} + +impl ModelCard { + /// 只有名字、规格一项都不知道的 + pub fn named(id: impl Into) -> ModelCard { + ModelCard { + id: id.into(), + ..Default::default() + } + } +} + +impl From<&str> for ModelCard { + fn from(id: &str) -> Self { + ModelCard::named(id) + } +} + +impl From for ModelCard { + fn from(id: String) -> Self { + ModelCard::named(id) + } } impl Gateway { /// 接管时写进去的那一份:地址、为它发的那把密钥、这把密钥能用的模型 - pub fn keyed(base: &str, key: &str, models: Vec) -> Gateway { + pub fn keyed(base: &str, key: &str, models: Vec) -> Gateway { Gateway { base: base.to_string(), key: Some(key.to_string()), @@ -1114,7 +1154,7 @@ pub fn credential_values(client: &str, text: &str) -> Vec { fn with_some_model(gw: &Gateway) -> Gateway { let mut g = gw.clone(); if g.models.is_empty() { - g.models = vec![MODEL_PLACEHOLDER.to_string()]; + g.models = vec![ModelCard::named(MODEL_PLACEHOLDER)]; } g } @@ -1227,9 +1267,37 @@ impl Client { || (self.id == "dsh" && crate::paths::dsh_desktop_app(home).is_some()) } + /// 一个模型写进这个客户端的配置、再读回来的样子:它的配置里没有地方写的规格去掉 + /// (Grok Build 没有收不收图这一项),我们特意不写的也去掉(Qwen Code 的输出上限是每次 + /// 请求都带上的 `max_tokens`,不是能力上限)。拿网关答的去比之前先过一遍它 + pub fn as_written(&self, m: &ModelCard) -> ModelCard { + match self.id { + "opencode" => crate::opencode::as_written(m), + "grok-build" => crate::grok::as_written(m), + "qwen-code" => crate::qwen::as_written(m), + _ => crate::pi::as_written(m), + } + } + + /// 配置里的模型清单跟网关此刻答的对不上了:多了、少了模型,或者写得进去的规格变了 + /// (上游、路由、手写的规格改过)。顺序不算,同名的只算第一个 + pub fn models_stale(&self, written: &[ModelCard], now: &[ModelCard]) -> bool { + let norm = |xs: &[ModelCard]| { + let mut seen = std::collections::HashSet::new(); + let mut v: Vec = xs + .iter() + .filter(|m| seen.insert(m.id.as_str())) + .map(|m| self.as_written(m)) + .collect(); + v.sort(); + v + }; + norm(written) != norm(now) + } + /// 配置里此刻写着的模型(只有 [`Client::writes_models`] 的客户端有)。没有那一条 /// provider 就是 `None`。 - pub fn models_in(&self, text: &str) -> Option> { + pub fn models_in(&self, text: &str) -> Option> { match self.id { "opencode" => crate::opencode::models_in(text), "grok-build" => crate::grok::models_in(text), @@ -1401,6 +1469,19 @@ impl Client { mod tests { use super::*; + /// 清单多了、少了模型才算对不上,顺序和同名的不算;写不进去的规格变了不算 + #[test] + fn a_changed_model_list_is_stale_and_a_reordered_one_is_not() { + let c = adoptable() + .into_iter() + .find(|c| c.id == "opencode") + .unwrap(); + let s = |xs: &[&str]| xs.iter().map(|x| ModelCard::named(*x)).collect::>(); + assert!(!c.models_stale(&s(&["a", "b"]), &s(&["b", "a", "a"]))); + assert!(c.models_stale(&s(&["a"]), &s(&["a", "b"]))); + assert!(c.models_stale(&s(&["a", "b"]), &s(&[]))); + } + #[test] fn every_client_has_a_distinct_id_and_a_real_path() { let cs = adoptable(); diff --git a/src-tauri/crates/tw-adopt/src/detect.rs b/src-tauri/crates/tw-adopt/src/detect.rs index 3a6e681..b86ce65 100644 --- a/src-tauri/crates/tw-adopt/src/detect.rs +++ b/src-tauri/crates/tw-adopt/src/detect.rs @@ -47,7 +47,7 @@ pub struct Detected { pub format: Format, pub costs: Vec, /// 配置里此刻写着的模型(只有 [`Client::writes_models`] 的客户端有) - pub models: Option>, + pub models: Option>, /// 这台电脑上它由组织统一管理,接管不了:托管配置在哪。只有 Claude Desktop 会有 pub managed: Option, } diff --git a/src-tauri/crates/tw-adopt/src/grok.rs b/src-tauri/crates/tw-adopt/src/grok.rs index 184c209..3fd3e79 100644 --- a/src-tauri/crates/tw-adopt/src/grok.rs +++ b/src-tauri/crates/tw-adopt/src/grok.rs @@ -12,8 +12,9 @@ //! 它自己也写这份文件(`/model`、`/settings`、自动更新之后),用的是重新序列化,注释和排版 //! 一律丢掉 —— 哨兵注释会跟着没了,旁文件里的记录还在,还原照样做得了。 -use crate::clients::{Edit, Gateway}; +use crate::clients::{Edit, Gateway, ModelCard}; use crate::json::Val; +use crate::plan::lookup; /// 我们写的模型表的名字:`[model."thinkwatch/<模型>"]`。 /// @@ -52,18 +53,21 @@ pub fn backend_for(model: &str) -> &'static str { } /// 网关列出来的模型,同名的只算一次 -fn unique(gw: &Gateway) -> Vec<&String> { +fn unique(gw: &Gateway) -> Vec<&ModelCard> { let mut seen = std::collections::HashSet::new(); gw.models .iter() - .filter(|m| seen.insert(m.as_str())) + .filter(|m| seen.insert(m.id.as_str())) .collect() } /// 一张模型表里的字段。**没有网关密钥也要写一个不为空的 `api_key`**:空着的话 Grok 退回 -/// 用户的会话令牌,见文件开头;这个值什么都打不开,网关会以「没有这把密钥」拒绝 -fn table(gw: &Gateway, model: &str) -> Vec<(String, Val)> { - vec![ +/// 用户的会话令牌,见文件开头;这个值什么都打不开,网关会以「没有这把密钥」拒绝。 +/// +/// 规格只写网关知道的那几项([`spec`]) +fn table(gw: &Gateway, m: &ModelCard) -> Vec<(String, Val)> { + let model = m.id.as_str(); + let mut t = vec![ ("model".into(), Val::s(model)), ("name".into(), Val::s(format!("{model} (ThinkWatch)"))), ( @@ -75,7 +79,32 @@ fn table(gw: &Gateway, model: &str) -> Vec<(String, Val)> { "api_key".into(), Val::s(gw.key.clone().unwrap_or_else(|| NO_KEY.to_string())), ), - ] + ]; + t.extend(spec(m)); + t +} + +/// 一个模型的规格在表里的写法。网关不知道的整项不写:上下文窗口 Grok 先找发同一个模型名的 +/// 内置表借,借不到取 200000;推理档位同样从内置表借。 +/// +/// - 上下文窗口写 `context_window`:自动压缩按它算阈值; +/// - 会不会推理写 `supports_reasoning_effort`。会推理的,界面上才给选推理档位,用户 +/// `[models] default_reasoning_effort` 设的默认档才用得上;**不选就什么都不带**。不会推理的 +/// 写 `false`:不给选,Messages 协议的表也就不会被当成会推理。档位清单 `reasoning_efforts` +/// 不写 —— 清单一写,Grok 就拿它的第一档当默认、每次请求都带上,档位也得我们编; +/// - **输出上限不写。**`max_completion_tokens` 每次请求都原样带上(Chat Completions 的 +/// `max_tokens`、Responses 的 `max_output_tokens`),不是能力上限;写上模型的上限,每次 +/// 请求就都要这么多; +/// - 收不收图 Grok 没有地方写 +fn spec(m: &ModelCard) -> Vec<(String, Val)> { + let mut v = Vec::new(); + if let Some(n) = m.context_window { + v.push(("context_window".into(), Val::Num(n.to_string()))); + } + if let Some(r) = m.reasoning { + v.push(("supports_reasoning_effort".into(), Val::Bool(r))); + } + v } /// 手动配置那一页列的字段:每张表拆成一项一项,照着就能写。接管写整张表,见 [`edits`] @@ -83,7 +112,7 @@ pub fn fields(gw: &Gateway) -> Vec { let mut v: Vec = unique(gw) .into_iter() .flat_map(|m| { - let k = key_of(m); + let k = key_of(&m.id); table(gw, m).into_iter().map(move |(f, value)| Edit { secret: f == "api_key" && gw.key.is_some(), path: vec!["model".into(), k.clone(), f], @@ -117,20 +146,20 @@ pub fn edits(gw: &Gateway, current: &str) -> Vec { let mut v: Vec = models .iter() .map(|m| Edit { - path: vec!["model".into(), key_of(m)], + path: vec!["model".into(), key_of(&m.id)], value: Val::Obj(table(gw, m)), secret: gw.key.is_some(), }) .collect(); - let offered = |id: &str| models.iter().any(|m| m.as_str() == id); + let offered = |id: &str| models.iter().any(|m| m.id == id); let now = default_in(current); let chosen = match now.as_deref() { Some(d) if d.strip_prefix(KEY_PREFIX).is_some_and(offered) => d.to_string(), Some(d) => match model_id_of(current, d) { Some(id) if offered(&id) => key_of(&id), - _ => key_of(first), + _ => key_of(&first.id), }, - None => key_of(first), + None => key_of(&first.id), }; v.push(Edit { path: vec!["models".into(), "default".into()], @@ -210,15 +239,37 @@ pub fn endpoint(text: &str) -> Option { } } -/// 配置里此刻写着的模型(我们那几张表各自发出去的模型名)。 +/// 一个模型写进配置再读回来的样子,见 [`crate::clients::Client::as_written`]:输出上限不写、 +/// 收不收图没有地方写(见 [`spec`]),只剩上下文窗口和会不会推理 +pub fn as_written(m: &ModelCard) -> ModelCard { + ModelCard { + id: m.id.clone(), + context_window: m.context_window, + reasoning: m.reasoning, + ..Default::default() + } +} + +/// 配置里此刻写着的模型(我们那几张表各自发出去的模型名),连同表里写着的规格。 /// /// **一张都没有也是一份清单(空的)**:网关一个模型都没列出来时接管什么表都不写,等网关有了 /// 模型,客户端页拿这份空清单去比,才提示得出「要更新」。没接管过的不拿来比 -pub fn models_in(text: &str) -> Option> { +pub fn models_in(text: &str) -> Option> { Some( ours(text) .iter() - .map(|(k, t)| field(t, "model").unwrap_or_else(|| k[KEY_PREFIX.len()..].to_string())) + .map(|(k, t)| ModelCard { + id: field(t, "model").unwrap_or_else(|| k[KEY_PREFIX.len()..].to_string()), + context_window: match lookup(t, &["context_window"]) { + Some(Val::Num(n)) => n.parse().ok(), + _ => None, + }, + reasoning: match lookup(t, &["supports_reasoning_effort"]) { + Some(Val::Bool(b)) => Some(b), + _ => None, + }, + ..Default::default() + }) .collect(), ) } @@ -270,7 +321,7 @@ mod tests { Gateway { base: "http://127.0.0.1:8788".into(), key: Some("tw-k".into()), - models: models.iter().map(|m| m.to_string()).collect(), + models: models.iter().map(|m| ModelCard::named(*m)).collect(), } } @@ -393,6 +444,128 @@ mod tests { assert_eq!(stale_table(&p(&["models", "default"])), None); } + fn card( + id: &str, + cw: Option, + out: Option, + reasoning: Option, + image: Option, + ) -> ModelCard { + ModelCard { + id: id.into(), + context_window: cw, + max_output_tokens: out, + reasoning, + image_input: image, + } + } + + /// 把接管要写的几项写进一份 config.toml + fn written(g: &Gateway) -> String { + edits(g, "").iter().fold(String::new(), |text, e| { + let path: Vec<&str> = e.path.iter().map(String::as_str).collect(); + crate::toml::set(&text, &path, &e.value).unwrap() + }) + } + + fn grok() -> crate::clients::Client { + crate::clients::adoptable() + .into_iter() + .find(|c| c.id == "grok-build") + .unwrap() + } + + #[test] + fn known_specs_go_into_the_table_and_read_back() { + let mut g = gw(&[]); + g.models = vec![ + card( + "gpt-5", + Some(400_000), + Some(128_000), + Some(true), + Some(true), + ), + card( + "deepseek-chat", + Some(128_000), + None, + Some(false), + Some(false), + ), + ModelCard::named("mystery"), + ]; + let text = written(&g); + let t = |m: &str, f: &str| crate::toml::get(&text, &["model", &key_of(m), f]).unwrap(); + assert_eq!( + t("gpt-5", "context_window"), + Some(Val::Num("400000".into())) + ); + assert_eq!( + t("gpt-5", "supports_reasoning_effort"), + Some(Val::Bool(true)) + ); + assert_eq!( + t("deepseek-chat", "supports_reasoning_effort"), + Some(Val::Bool(false)) + ); + // 网关不知道的:不写,Grok 用自己的默认值或者内置表的 + assert_eq!(t("mystery", "context_window"), None); + assert_eq!(t("mystery", "supports_reasoning_effort"), None); + // 档位清单和默认档不写:一写每次请求都带上 + assert!(!text.contains("reasoning_efforts"), "{text}"); + assert!( + !text.lines().any(|l| l.starts_with("reasoning_effort ")), + "{text}" + ); + // 手动配置那一页也一项一项列出来 + let f = fields(&g); + assert_eq!( + get(&f, "model.thinkwatch/gpt-5.context_window"), + Some(Val::Num("400000".into())) + ); + assert_eq!(get(&f, "model.thinkwatch/mystery.context_window"), None); + let back = models_in(&text).unwrap(); + let want: Vec = g.models.iter().map(as_written).collect(); + assert_eq!(back, want); + assert!(!grok().models_stale(&back, &g.models)); + } + + /// 输出上限每次请求都原样带上,收不收图 Grok 没有地方写:两样都不写,变了也不提示 + #[test] + fn max_output_and_image_input_are_never_written() { + let mut g = gw(&[]); + g.models = vec![card("a", None, Some(384_000), None, Some(true))]; + let text = written(&g); + assert!(!text.contains("max_completion_tokens"), "{text}"); + assert!(!text.contains("384000"), "{text}"); + assert!(!text.contains("image"), "{text}"); + assert!(!text.contains("modalit"), "{text}"); + let back = models_in(&text).unwrap(); + let mut now = g.models.clone(); + now[0].max_output_tokens = Some(8_192); + now[0].image_input = Some(false); + assert!(!grok().models_stale(&back, &now)); + } + + #[test] + fn a_changed_window_or_reasoning_is_stale() { + let mut g = gw(&[]); + g.models = vec![card("a", Some(200_000), None, Some(true), None)]; + let back = models_in(&written(&g)).unwrap(); + let c = grok(); + assert!(!c.models_stale(&back, &g.models)); + let mut now = g.models.clone(); + now[0].context_window = Some(256_000); + assert!(c.models_stale(&back, &now)); + let mut now = g.models.clone(); + now[0].reasoning = Some(false); + assert!(c.models_stale(&back, &now)); + let mut now = g.models.clone(); + now[0].reasoning = None; + assert!(c.models_stale(&back, &now)); + } + #[test] fn every_api_key_in_the_file_is_a_secret_for_the_diff() { let text = "[model.mine]\napi_key = \"sk-mine-123\"\n\n[model.\"thinkwatch/a\"]\napi_key = \"tw-k\"\nmodel = \"a\"\n"; diff --git a/src-tauri/crates/tw-adopt/src/hermes.rs b/src-tauri/crates/tw-adopt/src/hermes.rs index 61438d7..5d4a8fb 100644 --- a/src-tauri/crates/tw-adopt/src/hermes.rs +++ b/src-tauri/crates/tw-adopt/src/hermes.rs @@ -50,8 +50,8 @@ pub fn edits(gw: &Gateway, current: &str) -> Vec { _ => None, }; let chosen = now - .filter(|n| gw.models.iter().any(|m| m == n)) - .unwrap_or_else(|| first.clone()); + .filter(|n| gw.models.iter().any(|m| &m.id == n)) + .unwrap_or_else(|| first.id.clone()); let plain = |key: &str, value: &str| Edit { path: at(key), value: Val::s(value), @@ -195,7 +195,10 @@ mod tests { Gateway { base: "http://127.0.0.1:8788".into(), key: Some("tw-k".into()), - models: models.iter().map(|m| m.to_string()).collect(), + models: models + .iter() + .map(|m| crate::clients::ModelCard::named(*m)) + .collect(), } } diff --git a/src-tauri/crates/tw-adopt/src/opencode.rs b/src-tauri/crates/tw-adopt/src/opencode.rs index bc433c2..3f948ac 100644 --- a/src-tauri/crates/tw-adopt/src/opencode.rs +++ b/src-tauri/crates/tw-adopt/src/opencode.rs @@ -11,8 +11,9 @@ use std::path::{Path, PathBuf}; -use crate::clients::{Edit, Gateway, PROVIDER_ID}; +use crate::clients::{Edit, Gateway, ModelCard, PROVIDER_ID}; use crate::json::Val; +use crate::plan::lookup; /// v1 写法里的包名。**不写它的话**:v1 默认也是这个包,但 v2 迁移时不补默认值, /// 用的时候报 `Unsupported package`(实测 v2.0.16)。 @@ -59,21 +60,103 @@ pub fn shape_in(text: &str) -> Shape { } } -/// 模型清单的写法:`{ "模型": { "name": "模型" } }`。 +/// 模型清单的写法:`{ "模型": { "name": "模型", 规格… } }`,规格见 [`specs`]。 /// /// **name 不留空**:opencode 的模型选择器显示的就是它,空着就是一行空白。 /// 同名的只写一次:JSON 对象里重复的键,各家解析器取哪一个说法不一。 -pub fn models_val(models: &[String]) -> Val { +pub fn models_val(models: &[ModelCard], shape: Shape) -> Val { let mut seen = std::collections::HashSet::new(); Val::Obj( models .iter() - .filter(|m| seen.insert(m.as_str())) - .map(|m| (m.clone(), Val::Obj(vec![("name".into(), Val::s(m))]))) + .filter(|m| seen.insert(m.id.as_str())) + .map(|m| { + let mut fields = vec![("name".to_string(), Val::s(&m.id))]; + fields.extend(specs(m, shape)); + (m.id.clone(), Val::Obj(fields)) + }) .collect(), ) } +/// 一个模型的规格,按这一种写法写得下的写。网关没答的那一项不写。 +/// +/// **v1**(`config/provider.ts` 的 `Model`): +/// +/// - `limit: {context, output}`:两项都是必填的,只写一项整份配置读不进去。不写时两项都按 +/// 0 算:上下文 0 是「不知道」,**自动压缩永远不触发**;输出 0 按 32000 发。所以上下文知道、 +/// 输出不知道时输出写 0 —— 就是它自己不知道时的那个值。上下文不知道时整项不写:v2 读 v1 +/// 写法时照搬 `limit`,写一个 0 进去会盖掉它自己的默认值 200000,压缩在 v2 上也不触发了。 +/// 请求里的输出上限是 `min(output, 32000)`,写得再大也不会照着发 +/// - `reasoning`:会不会推理,决定有没有推理档位(`variants`) +/// - 收不收图看的是 `modalities.input` 里有没有 `image`:没有的话,贴进去的图在发出去之前 +/// 换成一句「这个模型不收图」(`transform.ts` 的 `unsupportedParts`)。`attachment` 只是 +/// 同一件事的标记,一起写。**写 `modalities` 时连 `tool_call: true` 和 `output: [text]` 一起 +/// 写**:两项都是 v1 本来的默认值,可 v2 迁移 v1 写法时,`modalities` 一在,没写的那两项 +/// 就不按默认值补了(主干的 `v1/config/migrate.ts` 补成不能调工具、什么都不输出) +/// +/// **原生**(v2 `schema/src/config/provider.ts`):`limit.{context, output}` 两项各自可选; +/// `capabilities.input` 单写,没写的 `tools`、`output` 照它自己的默认值补(`mergeCapabilities`)。 +/// 没有推理开关:推理档位按 `variants` 来,不写。 +pub fn specs(m: &ModelCard, shape: Shape) -> Vec<(String, Val)> { + let num = |n: u64| Val::Num(n.to_string()); + let input = |image: bool| { + let mut v = vec![Val::s("text")]; + if image { + v.push(Val::s("image")); + } + Val::Arr(v) + }; + let (context, output) = ( + m.context_window.filter(|n| *n > 0), + m.max_output_tokens.filter(|n| *n > 0), + ); + let mut v = Vec::new(); + match shape { + Shape::V1 => { + if let Some(c) = context { + v.push(( + "limit".into(), + Val::Obj(vec![ + ("context".into(), num(c)), + ("output".into(), num(output.unwrap_or(0))), + ]), + )); + } + if let Some(b) = m.reasoning { + v.push(("reasoning".into(), Val::Bool(b))); + } + if let Some(image) = m.image_input { + v.push(("attachment".into(), Val::Bool(image))); + v.push(("tool_call".into(), Val::Bool(true))); + v.push(( + "modalities".into(), + Val::Obj(vec![ + ("input".into(), input(image)), + ("output".into(), Val::Arr(vec![Val::s("text")])), + ]), + )); + } + } + Shape::Native => { + let limit: Vec<(String, Val)> = [("context", context), ("output", output)] + .into_iter() + .filter_map(|(k, n)| Some((k.to_string(), num(n?)))) + .collect(); + if !limit.is_empty() { + v.push(("limit".into(), Val::Obj(limit))); + } + if let Some(image) = m.image_input { + v.push(( + "capabilities".into(), + Val::Obj(vec![("input".into(), input(image))]), + )); + } + } + } + v +} + /// 接管要写的那几项。 /// /// **`models` 必须写**:v1 会把没有模型的 provider 整个删掉,v2 没有模型就无从 @@ -113,7 +196,7 @@ pub fn edits(gw: &Gateway, shape: Shape) -> Vec { } v.push(Edit { path: at(&["models"]), - value: models_val(&gw.models), + value: models_val(&gw.models, shape), secret: false, }); v @@ -129,11 +212,28 @@ pub fn endpoint(text: &str) -> Option { }) } -/// 配置里此刻写着的模型。没有那一条就是 `None`。 -pub fn models_in(text: &str) -> Option> { +/// 配置里此刻写着的模型,连同写着的规格(见 [`specs`])。没有那一条就是 `None`。 +pub fn models_in(text: &str) -> Option> { + use crate::pi::{count_of, flag_of, image_of}; let s = shape_in(text); + let card = |id: String, m: &Val| { + let image = match s { + Shape::V1 => image_of(lookup(m, &["modalities", "input"])), + Shape::Native => image_of(lookup(m, &["capabilities", "input"])), + }; + ModelCard { + id, + context_window: count_of(lookup(m, &["limit", "context"])), + max_output_tokens: count_of(lookup(m, &["limit", "output"])), + reasoning: match s { + Shape::V1 => flag_of(lookup(m, &["reasoning"])), + Shape::Native => None, + }, + image_input: image, + } + }; match crate::json::get(text, &[s.root(), PROVIDER_ID, "models"]).ok()?? { - Val::Obj(ms) => Some(ms.into_iter().map(|(k, _)| k).collect()), + Val::Obj(ms) => Some(ms.into_iter().map(|(k, m)| card(k, &m)).collect()), _ => Some(Vec::new()), } } @@ -148,15 +248,20 @@ pub fn overriding(text: &str) -> Vec { .collect() } -/// 模型清单跟网关此刻答的不一样了(上游或路由变了)。顺序不算。 -pub fn models_stale(written: &[String], now: &[String]) -> bool { - let mut a: Vec<_> = written.iter().collect(); - let mut b: Vec<_> = now.iter().collect(); - a.sort(); - a.dedup(); - b.sort(); - b.dedup(); - a != b +/// 一个模型写进配置再读回来的样子,见 [`crate::clients::Client::as_written`]。 +/// +/// 取的是两种写法都写得下的那几项(见 [`specs`]):这里不知道文件是哪一种写法,而比的 +/// 两边([`models_in`] 读回来的、网关答的)都要过一遍它,取交集两边就对得上。代价是原生 +/// 写法能单写的输出上限、v1 能写的推理开关单独变了不提示更新 —— 下一次接管照样写进去。 +pub fn as_written(m: &ModelCard) -> ModelCard { + let context_window = m.context_window.filter(|n| *n > 0); + ModelCard { + id: m.id.clone(), + context_window, + max_output_tokens: context_window.and(m.max_output_tokens.filter(|n| *n > 0)), + reasoning: None, + image_input: m.image_input, + } } // ---------------------------------------------------------------- 版本 @@ -442,7 +547,7 @@ mod tests { Gateway { base: "http://127.0.0.1:8788".into(), key: Some("tw-k".into()), - models: models.iter().map(|m| m.to_string()).collect(), + models: models.iter().map(|m| ModelCard::named(*m)).collect(), } } @@ -490,7 +595,7 @@ mod tests { ))); assert!(v1.contains(&( "provider.thinkwatch.models".into(), - models_val(&["a".into(), "b".into()]), + models_val(&["a".into(), "b".into()], Shape::V1), false ))); let native = paths(Shape::Native); @@ -505,12 +610,12 @@ mod tests { .any(|(p, _, s)| p == "providers.thinkwatch.settings.apiKey" && *s) ); // name 不留空 - let Val::Obj(ms) = models_val(&["m".into()]) else { + let Val::Obj(ms) = models_val(&["m".into()], Shape::V1) else { unreachable!() }; assert_eq!(ms[0].1, Val::Obj(vec![("name".into(), Val::s("m"))])); // 同名的只写一次,先后照原样 - let Val::Obj(ms) = models_val(&["b".into(), "a".into(), "b".into()]) else { + let Val::Obj(ms) = models_val(&["b".into(), "a".into(), "b".into()], Shape::V1) else { unreachable!() }; let keys: Vec<_> = ms.iter().map(|(k, _)| k.as_str()).collect(); @@ -535,12 +640,135 @@ mod tests { assert_eq!(models_in("{}"), None); } + fn card( + id: &str, + context: Option, + output: Option, + reasoning: Option, + image: Option, + ) -> ModelCard { + ModelCard { + id: id.into(), + context_window: context, + max_output_tokens: output, + reasoning, + image_input: image, + } + } + + /// 把一份清单照这一种写法写进一份空配置 + fn written(models: &[ModelCard], shape: Shape) -> String { + let gw = Gateway { + models: models.to_vec(), + ..gw(&[]) + }; + let base = match shape { + Shape::V1 => "{}".to_string(), + Shape::Native => r#"{"providers": {"thinkwatch": {}}}"#.to_string(), + }; + edits(&gw, shape).iter().fold(base, |t, e| { + let p: Vec<&str> = e.path.iter().map(String::as_str).collect(); + crate::json::set(&t, &p, &e.value).unwrap() + }) + } + #[test] - fn a_changed_model_list_is_stale_and_a_reordered_one_is_not() { - let s = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::>(); - assert!(!models_stale(&s(&["a", "b"]), &s(&["b", "a"]))); - assert!(models_stale(&s(&["a"]), &s(&["a", "b"]))); - assert!(models_stale(&s(&["a", "b"]), &s(&[]))); + fn v1_carries_limits_reasoning_and_image_input() { + let full = card("m", Some(200_000), Some(64_000), Some(true), Some(true)); + let Val::Obj(ms) = models_val(std::slice::from_ref(&full), Shape::V1) else { + unreachable!() + }; + assert_eq!( + ms[0].1, + crate::json::value( + r#"{"name": "m", "limit": {"context": 200000, "output": 64000}, "reasoning": true, + "attachment": true, "tool_call": true, + "modalities": {"input": ["text", "image"], "output": ["text"]}}"# + ) + .unwrap() + ); + // 不知道的不写;只知道上下文时输出写 0(它自己不知道时的值),只知道输出时整个 limit 不写 + let Val::Obj(ms) = models_val( + &[ + card("a", None, None, None, None), + card("b", Some(128_000), None, Some(false), Some(false)), + card("c", None, Some(8_192), None, None), + ], + Shape::V1, + ) else { + unreachable!() + }; + assert_eq!(ms[0].1, Val::Obj(vec![("name".into(), Val::s("a"))])); + assert_eq!( + ms[1].1, + crate::json::value( + r#"{"name": "b", "limit": {"context": 128000, "output": 0}, "reasoning": false, + "attachment": false, "tool_call": true, + "modalities": {"input": ["text"], "output": ["text"]}}"# + ) + .unwrap() + ); + assert_eq!(ms[2].1, Val::Obj(vec![("name".into(), Val::s("c"))])); + } + + #[test] + fn native_carries_what_its_schema_holds() { + let Val::Obj(ms) = models_val( + &[ + card("m", Some(200_000), Some(64_000), Some(true), Some(false)), + card("o", None, Some(8_192), None, None), + ], + Shape::Native, + ) else { + unreachable!() + }; + // 没有推理开关 + assert_eq!( + ms[0].1, + crate::json::value( + r#"{"name": "m", "limit": {"context": 200000, "output": 64000}, + "capabilities": {"input": ["text"]}}"# + ) + .unwrap() + ); + assert_eq!( + ms[1].1, + crate::json::value(r#"{"name": "o", "limit": {"output": 8192}}"#).unwrap() + ); + } + + /// 写进去再读回来,过一遍 `as_written` 和网关答的对得上;规格变了就对不上 + #[test] + fn specs_read_back_as_written_in_either_shape() { + let c = crate::clients::adoptable() + .into_iter() + .find(|c| c.id == "opencode") + .unwrap(); + let now = vec![ + card("full", Some(200_000), Some(64_000), Some(true), Some(true)), + card("ctx", Some(128_000), None, None, Some(false)), + card("out", None, Some(8_192), Some(false), None), + card("bare", None, None, None, None), + ]; + for shape in [Shape::V1, Shape::Native] { + let text = written(&now, shape); + assert_eq!(shape_in(&text), shape); + let back = models_in(&text).unwrap(); + assert!(!c.models_stale(&back, &now), "{shape:?}: {back:?}"); + let mut changed = now.clone(); + changed[0].context_window = Some(400_000); + assert!(c.models_stale(&back, &changed), "{shape:?}"); + let mut changed = now.clone(); + changed[1].image_input = Some(true); + assert!(c.models_stale(&back, &changed), "{shape:?}"); + } + let back = models_in(&written(&now, Shape::V1)).unwrap(); + assert_eq!(back[0], now[0]); + // 输出写的是 0,读回来是不知道 + assert_eq!(back[1], now[1]); + let back = models_in(&written(&now, Shape::Native)).unwrap(); + assert_eq!(back[2].max_output_tokens, Some(8_192)); + assert_eq!(back[0].reasoning, None); } #[test] diff --git a/src-tauri/crates/tw-adopt/src/pi.rs b/src-tauri/crates/tw-adopt/src/pi.rs index 1e14464..6f3d26c 100644 --- a/src-tauri/crates/tw-adopt/src/pi.rs +++ b/src-tauri/crates/tw-adopt/src/pi.rs @@ -11,6 +11,10 @@ //! - id: claude-sonnet-5 //! api: anthropic-messages //! baseUrl: http://127.0.0.1:8788 +//! contextWindow: 200000 # 网关答了的规格才写,见 specs +//! maxTokens: 64000 +//! reasoning: true +//! input: [text, image] //! - id: deepseek-chat //! ``` //! @@ -31,7 +35,7 @@ use std::path::Path; use tw_types::{Msg, msg}; -use crate::clients::{Edit, Gateway, PROVIDER_ID}; +use crate::clients::{Edit, Gateway, ModelCard, PROVIDER_ID}; use crate::cloud::Around; use crate::json::Val; use crate::plan::lookup; @@ -122,29 +126,94 @@ pub fn api_for(model: &str) -> Api { /// 模型清单:一个模型一项,不走 provider 那一种的写上自己的 `api`,地址写法不一样的再写上 /// 自己的 `baseUrl`(两边都是模型上的覆盖 provider 上的)。 /// -/// 别的元数据不写:Pi 照默认的上下文和输出长度跑,omp 按模型名从它自带的目录里补。同名的 -/// 只写一次,先后照网关答的。 +/// 网关答了的规格也写上(见 [`specs`]);没答的那一项不写,Pi 照它的默认值跑,omp 按模型名 +/// 从它自带的目录里补。同名的只写一次,先后照网关答的。 pub fn models_val(gw: &Gateway) -> Val { let mut seen = HashSet::new(); Val::Arr( gw.models .iter() - .filter(|m| seen.insert(m.as_str())) + .filter(|m| seen.insert(m.id.as_str())) .map(|m| { - let api = api_for(m); - let mut fields = vec![("id".to_string(), Val::s(m))]; + let api = api_for(&m.id); + let mut fields = vec![("id".to_string(), Val::s(&m.id))]; if api != DEFAULT_API { fields.push(("api".into(), Val::s(api.slug()))); if api.base(&gw.base) != DEFAULT_API.base(&gw.base) { fields.push(("baseUrl".into(), Val::s(api.base(&gw.base)))); } } + fields.extend(specs(m)); Val::Obj(fields) }) .collect(), ) } +/// 一个模型的规格在两边的写法,两边的字段名一样(Pi 的 `model-config.ts`,omp 的 +/// `models-config-schema-bundle.ts`): +/// +/// - `contextWindow`、`maxTokens`:不写时 Pi 按 128000、16384 跑,omp 先查自带的目录、查不到 +/// 也是这两个数。**`maxTokens` 是每次请求都带上的输出上限**,不只是一项能力说明:Pi 发出去 +/// 的是它和上下文剩下的空间里小的那个,omp 原样发(OpenAI 的两种另外封顶 64000)。写的是 +/// 网关答的这个模型真正的输出上限,上游收得下。两边都把 0 和负数当成写错了(Pi 直接报错), +/// 网关答 0 当没答 +/// - `reasoning`:会不会推理。不写是不会,`/model` 里就没有推理档位可选 +/// - `input`:收图写 `[text, image]`,不收写 `[text]`。不写是只收文字,贴进去的图发不出去 +pub fn specs(m: &ModelCard) -> Vec<(String, Val)> { + let m = as_written(m); + let mut v = Vec::new(); + if let Some(n) = m.context_window { + v.push(("contextWindow".into(), Val::Num(n.to_string()))); + } + if let Some(n) = m.max_output_tokens { + v.push(("maxTokens".into(), Val::Num(n.to_string()))); + } + if let Some(b) = m.reasoning { + v.push(("reasoning".into(), Val::Bool(b))); + } + if let Some(image) = m.image_input { + v.push(("input".into(), input_val(image))); + } + v +} + +/// 收不收图的写法:文字总是收的 +fn input_val(image: bool) -> Val { + let mut v = vec![Val::s("text")]; + if image { + v.push(Val::s("image")); + } + Val::Arr(v) +} + +/// 读回一个 token 数。JSON 里是数,YAML 的语义值里一律是字符串(见 [`crate::yamlval`]); +/// 0 和读不懂的当没写 +pub(crate) fn count_of(v: Option) -> Option { + match v? { + Val::Num(n) | Val::Str(n) => n.trim().parse().ok().filter(|n| *n > 0), + _ => None, + } +} + +/// 读回一个开关,两种文件里的写法都认 +pub(crate) fn flag_of(v: Option) -> Option { + match v? { + Val::Bool(b) => Some(b), + Val::Str(s) if s == "true" => Some(true), + Val::Str(s) if s == "false" => Some(false), + _ => None, + } +} + +/// 读回收不收图:清单里有 `image` 就是收 +pub(crate) fn image_of(v: Option) -> Option { + match v? { + Val::Arr(es) => Some(es.iter().any(|e| e.as_str() == Some("image"))), + _ => None, + } +} + /// 写进 `apiKey` 的那一串:读回来要原样是这把密钥。 /// /// Pi 会解释两种写法(`resolve-config-value.ts`):`!` 开头的整串是一条 shell 命令, @@ -196,8 +265,20 @@ pub fn edits(gw: &Gateway, flavor: Flavor) -> Vec { v } +/// 一个模型写进配置再读回来的样子,见 [`crate::clients::Client::as_written`]。四项都写得 +/// 进去,只有 0 不写(见 [`specs`]) +pub fn as_written(m: &ModelCard) -> ModelCard { + ModelCard { + id: m.id.clone(), + context_window: m.context_window.filter(|n| *n > 0), + max_output_tokens: m.max_output_tokens.filter(|n| *n > 0), + reasoning: m.reasoning, + image_input: m.image_input, + } +} + /// 配置里此刻写着的模型。没有那一条 provider 就是 `None`。 -pub fn models_in(text: &str, flavor: Flavor) -> Option> { +pub fn models_in(text: &str, flavor: Flavor) -> Option> { let v = match flavor { Flavor::Pi => crate::json::value(text).ok()?, Flavor::Omp => crate::yamlval::value(text).ok()?, @@ -207,7 +288,13 @@ pub fn models_in(text: &str, flavor: Flavor) -> Option> { Some(Val::Arr(ms)) => Some( ms.iter() .filter_map(|m| match lookup(m, &["id"]) { - Some(Val::Str(id)) => Some(id), + Some(Val::Str(id)) => Some(ModelCard { + id, + context_window: count_of(lookup(m, &["contextWindow"])), + max_output_tokens: count_of(lookup(m, &["maxTokens"])), + reasoning: flag_of(lookup(m, &["reasoning"])), + image_input: image_of(lookup(m, &["input"])), + }), _ => None, }) .collect(), @@ -391,7 +478,7 @@ mod tests { Gateway { base: "http://127.0.0.1:8788".into(), key: Some("tw-k".into()), - models: models.iter().map(|m| m.to_string()).collect(), + models: models.iter().map(|m| ModelCard::named(*m)).collect(), } } @@ -532,12 +619,142 @@ mod tests { assert!(!pi.iter().any(|(p, _, _)| p == "providers.thinkwatch.auth")); } + fn card( + id: &str, + context: Option, + output: Option, + reasoning: Option, + image: Option, + ) -> ModelCard { + ModelCard { + id: id.into(), + context_window: context, + max_output_tokens: output, + reasoning, + image_input: image, + } + } + + #[test] + fn known_specs_are_written_and_unknown_ones_left_out() { + let g = Gateway { + models: vec![ + card( + "claude-x", + Some(200_000), + Some(64_000), + Some(true), + Some(true), + ), + card( + "deepseek-chat", + Some(128_000), + None, + Some(false), + Some(false), + ), + card("bare", None, None, None, None), + // 0 是没答,不是上限为 0:Pi 遇到 0 整份文件报错 + card("zero", Some(0), Some(0), None, None), + ], + ..gw(&[]) + }; + let Val::Arr(ms) = models_val(&g) else { + unreachable!() + }; + let num = |k: &str, n: &str| (k.to_string(), Val::Num(n.into())); + let s = |k: &str, v: &str| (k.to_string(), Val::s(v)); + let input = |xs: &[&str]| { + ( + "input".to_string(), + Val::Arr(xs.iter().map(|x| Val::s(*x)).collect()), + ) + }; + assert_eq!( + ms, + vec![ + Val::Obj(vec![ + s("id", "claude-x"), + s("api", "anthropic-messages"), + s("baseUrl", "http://127.0.0.1:8788"), + num("contextWindow", "200000"), + num("maxTokens", "64000"), + ("reasoning".into(), Val::Bool(true)), + input(&["text", "image"]), + ]), + Val::Obj(vec![ + s("id", "deepseek-chat"), + num("contextWindow", "128000"), + ("reasoning".into(), Val::Bool(false)), + input(&["text"]), + ]), + Val::Obj(vec![s("id", "bare")]), + Val::Obj(vec![s("id", "zero")]), + ] + ); + } + + /// 写进两种文件再读回来,和网关答的一样;规格变了就要更新 + #[test] + fn specs_read_back_from_either_file() { + let now = vec![ + card( + "claude-x", + Some(200_000), + Some(64_000), + Some(true), + Some(true), + ), + card( + "deepseek-chat", + Some(128_000), + None, + Some(false), + Some(false), + ), + card("bare", None, None, None, None), + ]; + let g = Gateway { + models: now.clone(), + ..gw(&[]) + }; + for (flavor, id) in [(Flavor::Pi, "pi"), (Flavor::Omp, "omp")] { + let text = edits(&g, flavor).iter().fold(String::new(), |t, e| { + let p: Vec<&str> = e.path.iter().map(String::as_str).collect(); + match flavor { + Flavor::Pi => { + crate::json::set(if t.is_empty() { "{}" } else { &t }, &p, &e.value) + .unwrap() + } + Flavor::Omp => crate::yaml::set(&t, &p, &e.value).unwrap(), + } + }); + assert_eq!(models_in(&text, flavor), Some(now.clone()), "{text}"); + let c = crate::clients::adoptable() + .into_iter() + .find(|c| c.id == id) + .unwrap(); + let back = models_in(&text, flavor).unwrap(); + assert!(!c.models_stale(&back, &now)); + for change in [ + |m: &mut ModelCard| m.context_window = Some(1_000_000), + |m: &mut ModelCard| m.max_output_tokens = Some(128_000), + |m: &mut ModelCard| m.reasoning = None, + |m: &mut ModelCard| m.image_input = Some(false), + ] { + let mut changed = now.clone(); + change(&mut changed[0]); + assert!(c.models_stale(&back, &changed), "{id}: {changed:?}"); + } + } + } + #[test] fn the_models_are_read_back_from_either_file() { let json = r#"{"providers": {"thinkwatch": {"models": [{"id": "a"}, {"id": "b", "api": "anthropic-messages"}]}}}"#; assert_eq!( models_in(json, Flavor::Pi), - Some(vec!["a".to_string(), "b".to_string()]) + Some(vec!["a".into(), "b".into()]) ); assert_eq!(models_in(r#"{"providers": {}}"#, Flavor::Pi), None); assert_eq!( @@ -547,7 +764,7 @@ mod tests { let yaml = "providers:\n thinkwatch:\n models:\n - id: a\n - id: b\n"; assert_eq!( models_in(yaml, Flavor::Omp), - Some(vec!["a".to_string(), "b".to_string()]) + Some(vec!["a".into(), "b".into()]) ); } diff --git a/src-tauri/crates/tw-adopt/src/plan.rs b/src-tauri/crates/tw-adopt/src/plan.rs index a47cbf0..31c2de8 100644 --- a/src-tauri/crates/tw-adopt/src/plan.rs +++ b/src-tauri/crates/tw-adopt/src/plan.rs @@ -814,10 +814,17 @@ fn is_empty(v: &Val) -> bool { } /// 一个值写进去之后再读出来是什么样。YAML 的语义值里标量都是字符串 -/// (见 [`crate::yamlval`]),`version: 1` 读回来是 `"1"`。 +/// (见 [`crate::yamlval`]),`version: 1` 读回来是 `"1"`。容器里的也一样:omp 模型清单 +/// 每一项的 `contextWindow`、`reasoning` 读回来也是字符串。 fn as_read(fmt: Format, v: &Val) -> Val { match (fmt, v) { (Format::Yaml | Format::Rows, Val::Num(_) | Val::Bool(_)) => Val::s(v.to_line()), + (_, Val::Arr(es)) => Val::Arr(es.iter().map(|e| as_read(fmt, e)).collect()), + (_, Val::Obj(ms)) => Val::Obj( + ms.iter() + .map(|(k, x)| (k.clone(), as_read(fmt, x))) + .collect(), + ), _ => v.clone(), } } diff --git a/src-tauri/crates/tw-adopt/src/qwen.rs b/src-tauri/crates/tw-adopt/src/qwen.rs index d0ce810..f8aac02 100644 --- a/src-tauri/crates/tw-adopt/src/qwen.rs +++ b/src-tauri/crates/tw-adopt/src/qwen.rs @@ -16,8 +16,9 @@ //! 而这个产品的规矩是如实说明客户端是谁。Chat Completions 拼的是 `{baseUrl}/chat/completions`, //! 所以写带 `/v1` 的地址。 -use crate::clients::{Edit, Gateway, PROVIDER_ID}; +use crate::clients::{Edit, Gateway, ModelCard, PROVIDER_ID}; use crate::json::Val; +use crate::plan::lookup; /// 装着网关密钥的那个环境变量:写进 `settings.env`,每一条模型用 `envKey` 指着它。 /// @@ -31,11 +32,11 @@ pub const KEY_ENV: &str = "THINKWATCH_QWEN_API_KEY"; pub const NO_KEY: &str = "no-key"; /// 网关列出来的模型,同名的只算一次 -fn unique(gw: &Gateway) -> Vec<&String> { +fn unique(gw: &Gateway) -> Vec<&ModelCard> { let mut seen = std::collections::HashSet::new(); gw.models .iter() - .filter(|m| seen.insert(m.as_str())) + .filter(|m| seen.insert(m.id.as_str())) .collect() } @@ -62,12 +63,17 @@ pub fn edits(gw: &Gateway, current: &str) -> Vec { let entries: Vec = models .iter() .map(|m| { - Val::Obj(vec![ - ("id".into(), Val::s(m.as_str())), - ("name".into(), Val::s(format!("{m} (ThinkWatch)"))), + let mut e = vec![ + ("id".into(), Val::s(&m.id)), + ("name".into(), Val::s(format!("{} (ThinkWatch)", m.id))), ("baseUrl".into(), Val::s(&base)), ("envKey".into(), Val::s(KEY_ENV)), - ]) + ]; + let spec = generation(m); + if !spec.is_empty() { + e.push(("generationConfig".into(), Val::Obj(spec))); + } + Val::Obj(e) }) .collect(); let now = match crate::json::get(current, &["model", "name"]) { @@ -75,8 +81,8 @@ pub fn edits(gw: &Gateway, current: &str) -> Vec { _ => None, }; let chosen = now - .filter(|n| models.iter().any(|m| m == &n)) - .unwrap_or_else(|| first.to_string()); + .filter(|n| models.iter().any(|m| &m.id == n)) + .unwrap_or_else(|| first.id.clone()); let plain = |path: &[&str], value: Val| Edit { path: at(path), value, @@ -99,6 +105,33 @@ pub fn edits(gw: &Gateway, current: &str) -> Vec { ] } +/// 一个模型的 `generationConfig`:网关知道的规格里 Qwen 照着跑、又不会改动每次请求的那几项。 +/// 网关不知道的整项不写,Qwen 按名字查 models.dev、再按正则猜、最后取 200K。 +/// +/// - 上下文窗口写 `contextWindowSize`:压缩对话按它算阈值; +/// - 收不收图写 `modalities.image`:不收的话,图换成一段文字占位再发。这一项一写,Qwen 就 +/// 不再按名字补默认的模态(目录里的音频、视频跟着没了)—— 网关只说得出图这一项; +/// - **输出上限不写。**`samplingParams.max_tokens` 每次请求都原样带上,不是能力上限;写上 +/// 模型的上限(384000),每次请求就都要这么多。写了 `samplingParams`,Qwen 也就不再自己 +/// 补 `max_tokens`; +/// - **推理不写。**Qwen 没有只表示「会推理」的开关:`capabilities.reasoning` 要一份档位清单 +/// 和默认档,默认档每次请求都带上(档位还得我们编);`generationConfig.reasoning` 写了 +/// 档位或预算,每次请求都带上;写 `false` 是关掉思考,`/effort` 从此不起作用,切走再切回来 +/// 时用户选的档位也丢了。什么都不写,`/effort` 本来就对任何模型都给全部档位,不选就不带 +fn generation(m: &ModelCard) -> Vec<(String, Val)> { + let mut g = Vec::new(); + if let Some(n) = m.context_window { + g.push(("contextWindowSize".into(), Val::Num(n.to_string()))); + } + if let Some(image) = m.image_input { + g.push(( + "modalities".into(), + Val::Obj(vec![("image".into(), Val::Bool(image))]), + )); + } + g +} + /// 我们那一组里的模型条目 fn entries(text: &str) -> Vec { match crate::json::get(text, &["modelProviders", PROVIDER_ID]) { @@ -139,13 +172,41 @@ pub fn endpoint(text: &str) -> Option { .find(|b| picked.as_ref().is_none_or(|p| p == b)) } -/// 配置里此刻写着的模型:我们那一组里每一条的 `id`。没有那一组就是一份空的(接管时网关 -/// 一个模型都没有的话就是这样,等网关有了模型,客户端页拿它去比才提示得出要更新) -pub fn models_in(text: &str) -> Option> { +/// 一个模型写进配置再读回来的样子,见 [`crate::clients::Client::as_written`]:输出上限和 +/// 推理不写(见 [`generation`]),只剩上下文窗口和收不收图 +pub fn as_written(m: &ModelCard) -> ModelCard { + ModelCard { + id: m.id.clone(), + context_window: m.context_window, + image_input: m.image_input, + ..Default::default() + } +} + +/// 配置里此刻写着的模型:我们那一组里每一条的 `id`,连同 `generationConfig` 里写着的规格。 +/// 没有那一组就是一份空的(接管时网关一个模型都没有的话就是这样,等网关有了模型,客户端页 +/// 拿它去比才提示得出要更新) +pub fn models_in(text: &str) -> Option> { Some( entries(text) .iter() - .filter_map(|e| field(e, "id")) + .filter_map(|e| { + let id = field(e, "id")?; + let context_window = match lookup(e, &["generationConfig", "contextWindowSize"]) { + Some(Val::Num(n)) => n.parse().ok(), + _ => None, + }; + let image_input = match lookup(e, &["generationConfig", "modalities", "image"]) { + Some(Val::Bool(b)) => Some(b), + _ => None, + }; + Some(ModelCard { + id, + context_window, + image_input, + ..Default::default() + }) + }) .collect(), ) } @@ -169,7 +230,7 @@ mod tests { Gateway { base: "http://127.0.0.1:8788".into(), key: Some("tw-k".into()), - models: models.iter().map(|m| m.to_string()).collect(), + models: models.iter().map(|m| ModelCard::named(*m)).collect(), } } @@ -260,6 +321,123 @@ mod tests { assert_eq!(models_in("{}"), Some(Vec::new())); } + fn card( + id: &str, + cw: Option, + out: Option, + reasoning: Option, + image: Option, + ) -> ModelCard { + ModelCard { + id: id.into(), + context_window: cw, + max_output_tokens: out, + reasoning, + image_input: image, + } + } + + /// 把接管要写的几项写进一份 settings.json + fn written(g: &Gateway) -> String { + edits(g, "").iter().fold("{}".to_string(), |text, e| { + let path: Vec<&str> = e.path.iter().map(String::as_str).collect(); + crate::json::set(&text, &path, &e.value).unwrap() + }) + } + + fn qwen() -> crate::clients::Client { + crate::clients::adoptable() + .into_iter() + .find(|c| c.id == "qwen-code") + .unwrap() + } + + #[test] + fn known_specs_go_into_generation_config_and_read_back() { + let mut g = gw(&[]); + g.models = vec![ + card( + "gpt-5", + Some(400_000), + Some(128_000), + Some(true), + Some(true), + ), + card( + "deepseek-chat", + Some(128_000), + None, + Some(false), + Some(false), + ), + ModelCard::named("mystery"), + ]; + let text = written(&g); + let gc = |id: &str, path: &[&str]| { + let e = entries(&text) + .into_iter() + .find(|e| field(e, "id").as_deref() == Some(id)) + .unwrap(); + lookup(&e, &[&["generationConfig"], path].concat()) + }; + assert_eq!( + gc("gpt-5", &["contextWindowSize"]), + Some(Val::Num("400000".into())) + ); + assert_eq!( + gc("gpt-5", &["modalities"]), + Some(Val::Obj(vec![("image".into(), Val::Bool(true))])) + ); + assert_eq!( + gc("deepseek-chat", &["modalities", "image"]), + Some(Val::Bool(false)) + ); + // 网关不知道的模型:一项都不写,连 generationConfig 都没有 + assert_eq!(gc("mystery", &[]), None); + // 读回来的就是写得进去的那几项 + let back = models_in(&text).unwrap(); + let want: Vec = g.models.iter().map(as_written).collect(); + assert_eq!(back, want); + assert!(!qwen().models_stale(&back, &g.models)); + } + + /// 输出上限是每次请求的 `max_tokens`,推理写什么都会改动每次请求,两样都不写 + #[test] + fn max_output_and_reasoning_are_never_written() { + let mut g = gw(&[]); + g.models = vec![ + card("a", None, Some(384_000), Some(true), None), + card("b", None, Some(8_192), Some(false), None), + ]; + let text = written(&g); + assert!(!text.contains("samplingParams"), "{text}"); + assert!(!text.contains("max_tokens"), "{text}"); + assert!(!text.contains("reasoning"), "{text}"); + assert!(!text.contains("generationConfig"), "{text}"); + assert!(!text.contains("384000"), "{text}"); + // 也就不因为它们变了提示更新 + let back = models_in(&text).unwrap(); + let mut now = g.models.clone(); + now[0].max_output_tokens = Some(128_000); + now[1].reasoning = Some(true); + assert!(!qwen().models_stale(&back, &now)); + } + + #[test] + fn a_changed_window_or_image_input_is_stale() { + let mut g = gw(&[]); + g.models = vec![card("a", Some(200_000), None, None, Some(true))]; + let back = models_in(&written(&g)).unwrap(); + let c = qwen(); + assert!(!c.models_stale(&back, &g.models)); + let mut now = g.models.clone(); + now[0].context_window = Some(1_000_000); + assert!(c.models_stale(&back, &now)); + let mut now = g.models.clone(); + now[0].image_input = None; + assert!(c.models_stale(&back, &now)); + } + #[test] fn credentials_in_env_and_the_old_auth_key_are_secrets_for_the_diff() { let text = r#"{"env": {"DASHSCOPE_API_KEY": "sk-dash-123", "THEME": "dark"}, "security": {"auth": {"apiKey": "sk-old-456"}}}"#; diff --git a/src-tauri/crates/tw-adopt/tests/roundtrip.rs b/src-tauri/crates/tw-adopt/tests/roundtrip.rs index 5941081..d515a2b 100644 --- a/src-tauri/crates/tw-adopt/tests/roundtrip.rs +++ b/src-tauri/crates/tw-adopt/tests/roundtrip.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; -use tw_adopt::clients::{Gateway, adoptable}; +use tw_adopt::clients::{Gateway, ModelCard, adoptable}; use tw_adopt::cloud::Around; use tw_adopt::plan::{apply, apply_restore, plan_adopt, plan_restore}; @@ -1255,10 +1255,7 @@ fn adopting_opencode_edits_the_native_v2_entry_when_there_is_one() { // 检测认的是原生的那一条 let d = tw_adopt::detect::detect_one(&c, &b.home); assert_eq!(d.endpoint.as_deref(), Some("http://127.0.0.1:8080/v1")); - assert_eq!( - d.models, - Some(vec!["claude-sonnet".to_string(), "gpt-5".to_string()]) - ); + assert_eq!(d.models, Some(vec!["claude-sonnet".into(), "gpt-5".into()])); let r = plan_restore(&c, &b.home).unwrap(); apply_restore(&c, &r, &b.backups).unwrap(); @@ -1272,14 +1269,14 @@ fn rewriting_the_opencode_model_list_keeps_the_first_record() { let c = client("opencode"); for models in [vec!["a"], vec!["a", "b"]] { let g = Gateway { - models: models.into_iter().map(str::to_string).collect(), + models: models.into_iter().map(ModelCard::named).collect(), ..gw() }; let p = plan_adopt(&c, &b.home, &g, &Around::default()).unwrap(); apply(&c, &p, &b.backups).unwrap(); } let d = tw_adopt::detect::detect_one(&c, &b.home); - assert_eq!(d.models, Some(vec!["a".to_string(), "b".to_string()])); + assert_eq!(d.models, Some(vec!["a".into(), "b".into()])); let r = plan_restore(&c, &b.home).unwrap(); apply_restore(&c, &r, &b.backups).unwrap(); @@ -1392,10 +1389,7 @@ fn adopting_pi_adds_a_provider_with_each_models_own_api() { let d = tw_adopt::detect::detect_one(&c, &b.home); assert_eq!(d.endpoint.as_deref(), Some("http://127.0.0.1:8080/v1")); - assert_eq!( - d.models, - Some(vec!["claude-sonnet".to_string(), "gpt-5".to_string()]) - ); + assert_eq!(d.models, Some(vec!["claude-sonnet".into(), "gpt-5".into()])); assert!(d.installed); let r = plan_restore(&c, &b.home).unwrap(); @@ -1410,17 +1404,14 @@ fn rewriting_the_pi_model_list_keeps_the_first_record() { let c = client("pi"); for models in [vec!["a"], vec!["a", "claude-b"]] { let g = Gateway { - models: models.into_iter().map(str::to_string).collect(), + models: models.into_iter().map(ModelCard::named).collect(), ..gw() }; let p = plan_adopt(&c, &b.home, &g, &Around::default()).unwrap(); apply(&c, &p, &b.backups).unwrap(); } let d = tw_adopt::detect::detect_one(&c, &b.home); - assert_eq!( - d.models, - Some(vec!["a".to_string(), "claude-b".to_string()]) - ); + assert_eq!(d.models, Some(vec!["a".into(), "claude-b".into()])); let r = plan_restore(&c, &b.home).unwrap(); apply_restore(&c, &r, &b.backups).unwrap(); assert_eq!(read(&c.config_path(&b.home)), PI); @@ -1510,10 +1501,7 @@ fn adopting_omp_says_it_authenticates_with_a_key() { let d = tw_adopt::detect::detect_one(&c, &b.home); assert_eq!(d.endpoint.as_deref(), Some("http://127.0.0.1:8080/v1")); - assert_eq!( - d.models, - Some(vec!["claude-sonnet".to_string(), "gpt-5".to_string()]) - ); + assert_eq!(d.models, Some(vec!["claude-sonnet".into(), "gpt-5".into()])); // 再接管一次是空操作 let again = plan_adopt(&c, &b.home, &gw(), &Around::default()).unwrap(); @@ -1561,6 +1549,49 @@ fn omp_is_written_into_the_one_file_it_reads() { assert_eq!(read(&yaml), OMP); } +/// 网关答了规格的模型:规格照各家的写法写进去、写回校验过得去(omp 的 YAML 读回来数和开关 +/// 都是字符串),读回来不提示更新、再接管是空操作;规格一变就提示,还原照样一个字节不差 +#[test] +fn model_specs_are_written_read_back_and_restored() { + let card = |id: &str| ModelCard { + id: id.into(), + context_window: Some(200_000), + max_output_tokens: Some(64_000), + reasoning: Some(true), + image_input: Some(true), + }; + let g = Gateway { + models: vec![card("claude-sonnet"), ModelCard::named("gpt-5")], + ..gw() + }; + for (id, before) in [("pi", PI), ("omp", OMP), ("opencode", "{}\n")] { + let b = bed(id, before); + let c = client(id); + let p = plan_adopt(&c, &b.home, &g, &Around::default()).unwrap(); + apply(&c, &p, &b.backups).unwrap(); + let after = read(&c.config_path(&b.home)); + let field = match id { + "opencode" => "\"context\": 200000", + "pi" => "\"contextWindow\": 200000", + _ => "contextWindow: 200000", + }; + assert!(after.contains(field), "{id}: {after}"); + + let d = tw_adopt::detect::detect_one(&c, &b.home); + let written = d.models.unwrap(); + assert!(!c.models_stale(&written, &g.models), "{id}: {written:?}"); + let again = plan_adopt(&c, &b.home, &g, &Around::default()).unwrap(); + assert!(again.is_noop(), "{id}: {}", again.after); + let mut changed = g.models.clone(); + changed[0].context_window = Some(1_000_000); + assert!(c.models_stale(&written, &changed), "{id}"); + + let r = plan_restore(&c, &b.home).unwrap(); + apply_restore(&c, &r, &b.backups).unwrap(); + assert_eq!(read(&c.config_path(&b.home)), before, "{id}"); + } +} + /// 两边都还没有配置文件:新建的,还原时删掉 #[test] fn a_pi_or_omp_file_created_here_is_removed_again() { @@ -1881,7 +1912,7 @@ fn re_adopting_grok_with_a_changed_model_list_drops_the_stale_table() { ); let d = tw_adopt::detect::detect_one(&c, &b.home); assert!( - !tw_adopt::opencode::models_stale(d.models.as_deref().unwrap(), &g.models), + !c.models_stale(d.models.as_deref().unwrap(), &g.models), "{:?}", d.models ); diff --git a/src-tauri/src/clients/mod.rs b/src-tauri/src/clients/mod.rs index 475e483..acb95ef 100644 --- a/src-tauri/src/clients/mod.rs +++ b/src-tauri/src/clients/mod.rs @@ -17,6 +17,7 @@ pub mod wsl; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use tw_adopt::clients::ModelCard; use tw_adopt::cloud::Around; use tw_adopt::wsl::WslHome; @@ -97,13 +98,13 @@ async fn gateway(state: &AppState) -> Out { /// **问的是网关,不是 core 的控制面** —— 同一把密钥在网关上被允许用哪些模型,只有 /// 网关按它的 `allow` 答得准。opencode、Pi、oh-my-pi、Grok Build、Qwen Code 要把这份清单写进 /// 配置(它们不自己去问),Hermes Agent 要从里面挑一个默认模型。 -async fn models_of(base: &str, key: &str) -> Result, Msg> { +async fn models_of(base: &str, key: &str) -> Result, Msg> { fetch_models(base, key, false).await } /// [`models_of`]。`anthropic` = 按 Anthropic 的方式问(密钥放在 `x-api-key`、带 /// `anthropic-version`):Claude Desktop 就是这么问的,网关按这个答它说得通的那些 -async fn fetch_models(base: &str, key: &str, anthropic: bool) -> Result, Msg> { +async fn fetch_models(base: &str, key: &str, anthropic: bool) -> Result, Msg> { let url = format!("{}/v1/models", base.trim_end_matches('/')); let failed = |detail: String| { msg!( @@ -139,16 +140,33 @@ async fn fetch_models(base: &str, key: &str, anthropic: bool) -> Result Vec { +/// OpenAI 形状的 `/v1/models`:`data[]` 的 `id`,连同网关给的规格。 +/// +/// 规格的字段名是 core 定的(`tw_gateway` 的 `listing.rs`):上下文窗口 `context_window`、 +/// 输出上限 `max_output_tokens`、会不会推理 `supports_reasoning`、收不收图看 +/// `input_modalities` 里有没有 `image`。**网关不知道的那一项它就不给**,这里也就是 `None` +fn model_cards(body: &serde_json::Value) -> Vec { body.get("data") .and_then(|d| d.as_array()) .map(|xs| { xs.iter() - .filter_map(|x| x.get("id")?.as_str().map(str::to_string)) + .filter_map(|x| { + let id = x.get("id")?.as_str()?; + let tokens = |k: &str| x.get(k).and_then(|v| v.as_u64()).filter(|n| *n > 0); + Some(ModelCard { + id: id.to_string(), + context_window: tokens("context_window"), + max_output_tokens: tokens("max_output_tokens"), + reasoning: x.get("supports_reasoning").and_then(|v| v.as_bool()), + image_input: x + .get("input_modalities") + .and_then(|v| v.as_array()) + .map(|ms| ms.iter().any(|m| m.as_str() == Some("image"))), + }) + }) .collect() }) .unwrap_or_default() @@ -163,7 +181,7 @@ async fn models_for( c: &tw_adopt::clients::Client, base: &str, key: &str, -) -> Result, Msg> { +) -> Result, Msg> { if c.writes_models || tw_adopt::clients::picks_model(c) { models_of(base, key).await } else if c.id == tw_adopt::desktop::ID { @@ -844,7 +862,7 @@ impl LeftBehind { async fn retarget(self, base: &str, backups: &Path, prepare: P) -> wire::Retargeted where P: Fn(String, tw_adopt::clients::Client) -> F, - F: std::future::Future), Msg>>, + F: std::future::Future), Msg>>, { let mut out = wire::Retargeted { synced: Vec::new(), @@ -907,7 +925,7 @@ async fn key_and_models( base: &str, owner: String, c: tw_adopt::clients::Client, -) -> Result<(String, Vec), Msg> { +) -> Result<(String, Vec), Msg> { let key = prepare_key(control, &owner) .await .map_err(CmdError::into_msg)? @@ -1096,13 +1114,44 @@ mod tests { use tw_adopt::wsl::Distro; #[test] - fn the_model_list_is_the_ids_under_data() { + fn the_model_list_is_the_ids_under_data_with_what_the_gateway_knows() { let body = serde_json::json!({ "object": "list", - "data": [{ "id": "gpt-5", "object": "model" }, { "id": "claude-sonnet" }, { "x": 1 }], + "data": [ + { + "id": "gpt-5", + "object": "model", + "context_window": 400000, + "max_output_tokens": 128000, + "supports_reasoning": true, + "input_modalities": ["text", "image"], + }, + { "id": "deepseek-chat", "context_window": 0, "supports_reasoning": false, "input_modalities": ["text"] }, + { "id": "claude-sonnet" }, + { "x": 1 }, + ], }); - assert_eq!(model_ids(&body), ["gpt-5", "claude-sonnet"]); - assert!(model_ids(&serde_json::json!({ "models": [] })).is_empty()); + assert_eq!( + model_cards(&body), + [ + ModelCard { + id: "gpt-5".into(), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some(true), + image_input: Some(true), + }, + // 0 不是一个窗口:当成不知道 + ModelCard { + id: "deepseek-chat".into(), + reasoning: Some(false), + image_input: Some(false), + ..Default::default() + }, + ModelCard::named("claude-sonnet"), + ] + ); + assert!(model_cards(&serde_json::json!({ "models": [] })).is_empty()); } #[test] @@ -1124,7 +1173,7 @@ mod tests { async fn issued( owner: String, _c: tw_adopt::clients::Client, - ) -> Result<(String, Vec), Msg> { + ) -> Result<(String, Vec), Msg> { Ok((format!("tw-{owner}"), Vec::new())) } diff --git a/src-tauri/src/clients/ops.rs b/src-tauri/src/clients/ops.rs index 71af805..9db0044 100644 --- a/src-tauri/src/clients/ops.rs +++ b/src-tauri/src/clients/ops.rs @@ -11,7 +11,7 @@ use std::collections::BTreeMap; use std::path::Path; -use tw_adopt::clients::{self, Client}; +use tw_adopt::clients::{self, Client, ModelCard}; use tw_adopt::cloud::Around; use tw_adopt::{detect, plan}; use tw_api::ClientView; @@ -83,7 +83,7 @@ pub fn list( home: &Path, backups: &Path, gw: &Gateway, - models: &BTreeMap>, + models: &BTreeMap>, ) -> wire::ClientsResponse { // 「使用中」的依据:**我们改了一个文件,但那个文件有没有被读到,只有请求能证明** // —— 而且是带着为它生成的那把密钥的请求。按请求头里自报的客户端标识算的话, @@ -111,7 +111,7 @@ pub fn list( let stale = ours(&d, backups) && matches!( (&d.models, now), - (Some(written), Some(now)) if tw_adopt::opencode::models_stale(written, now) + (Some(written), Some(now)) if c.models_stale(written, now) ); let mut v = detected_view(d, backups, key, last_seen_ms.flatten(), manual, |p| { p.display().to_string() @@ -182,7 +182,7 @@ fn detected_view( verified: d.verified.into(), costs: d.costs, models_stale: false, - models: d.models, + models: d.models.map(|ms| ms.into_iter().map(|m| m.id).collect()), movable: false, managed: d.managed.as_ref().map(|by| { plan::PlanError::Managed { @@ -266,7 +266,7 @@ fn field( /// 主配置和另一份文件的路径不会撞(一个以行 id 开头,一个以 `refs` 开头)。 /// /// `models` 是这次写进去的模型:Grok Build 一个模型一张表,整张表算密钥,路径跟着模型走 -fn secret_paths(c: &Client, models: &[String]) -> Vec> { +fn secret_paths(c: &Client, models: &[ModelCard]) -> Vec> { let gw = clients::Gateway { base: String::new(), key: Some(String::new()), @@ -546,7 +546,7 @@ pub fn plan_adopt( home: &Path, id: &str, gw: &Gateway, - models: Vec, + models: Vec, around: &Around, ) -> Result { plan_adopt_as(home, id, id, gw, models, around) @@ -559,7 +559,7 @@ pub fn plan_adopt_as( id: &str, owner: &str, gw: &Gateway, - models: Vec, + models: Vec, around: &Around, ) -> Result { plan_adopt_picking(home, id, owner, gw, models, around).map(|(v, _)| v) @@ -573,7 +573,7 @@ pub fn plan_adopt_picking( id: &str, owner: &str, gw: &Gateway, - models: Vec, + models: Vec, around: &Around, ) -> Result<(wire::PlanView, Option), Msg> { let c = find(id, home)?; @@ -616,7 +616,8 @@ fn plan_for( around: &Around, ) -> Result { if c.id == tw_adopt::desktop::ID { - tw_adopt::desktop::plan_adopt(c, home, gw, Some(&gw.models), around) + let ids: Vec = gw.models.iter().map(|m| m.id.clone()).collect(); + tw_adopt::desktop::plan_adopt(c, home, gw, Some(&ids), around) } else { plan::plan_adopt(c, home, gw, around) } @@ -932,7 +933,7 @@ pub fn repoint( c: &Client, base: &str, key: &str, - models: Vec, + models: Vec, around: &Around, ) -> Result { let c = &c.clone().here(home); @@ -1244,7 +1245,7 @@ pub(crate) mod tests { ) .unwrap(); let g = gw(vec![key("default", "tw-secret-value", None, true)]); - let models = vec!["claude-sonnet-5".to_string(), "gpt-5.5".to_string()]; + let models: Vec = vec!["claude-sonnet-5".into(), "gpt-5.5".into()]; for (id, theirs) in [ ("grok-build", "sk-mine-0123456789"), ("qwen-code", "sk-dash-0123456789"), @@ -1381,10 +1382,10 @@ pub(crate) mod tests { let now = |ms: &[&str]| { BTreeMap::from([( "opencode".to_string(), - ms.iter().map(|m| m.to_string()).collect::>(), + ms.iter().map(|m| ModelCard::named(*m)).collect::>(), )]) }; - let stale = |models: &BTreeMap>| { + let stale = |models: &BTreeMap>| { list(home.path(), &backups(&home), &gw(keys.clone()), models) .clients .into_iter() @@ -1439,10 +1440,10 @@ pub(crate) mod tests { let now = |ms: &[&str]| { BTreeMap::from([( id.to_string(), - ms.iter().map(|m| m.to_string()).collect::>(), + ms.iter().map(|m| ModelCard::named(*m)).collect::>(), )]) }; - let stale = |models: &BTreeMap>| { + let stale = |models: &BTreeMap>| { list(home.path(), &backups(&home), &gw(keys.clone()), models) .clients .into_iter() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8cf7b53..752b854 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ThinkWatch Lite", - "version": "2026.10.6", + "version": "2026.10.7", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 50bf80b..ce5baa4 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 39; +export const CONTROL_API_VERSION = 40; /** * 一个账号上游登的是哪个账号。 @@ -2330,6 +2330,22 @@ max_output_tokens?: number | null, * `max_output_tokens` 从哪儿来。不知道输出上限时没有 */ max_output_tokens_source?: SpecSource | null, +/** + * 会不会推理:这一家手写的,没写时来自价目表。不知道时没有 + */ +reasoning?: boolean | null, +/** + * `reasoning` 从哪儿来。不知道时没有 + */ +reasoning_source?: SpecSource | null, +/** + * 收不收图:这一家手写的,没写时来自价目表。不知道时没有 + */ +image_input?: boolean | null, +/** + * `image_input` 从哪儿来。不知道时没有 + */ +image_input_source?: SpecSource | null, /** * 按这个上游选的价目表查到的价格。空 = 无法计价 */ @@ -2351,7 +2367,7 @@ export type ModelSource = "discovered" | "manual" | "none"; /** * 设一家上游的一个模型的规格(`PUT /provider-model-spec`):价目表不认识这个模型、 - * 或者写错了时手写。**两项都空就是删掉这一项**,回到价目表。 + * 或者写错了时手写。**四项都空就是删掉这一项**,回到价目表。 */ export type ModelSpecSave = { provider: string, /** @@ -2366,6 +2382,14 @@ context_window?: number | null, * 输出上限(token)。空 = 用价目表的 */ max_output_tokens?: number | null, +/** + * 会不会推理。空 = 用价目表的 + */ +reasoning?: boolean | null, +/** + * 收不收图。空 = 用价目表的 + */ +image_input?: boolean | null, /** * 你基于哪一版。**对不上就是 409** */ diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 41888da..8290219 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -652,7 +652,7 @@ "config.alias_only_itself": "别名「{alias}」只列出了它自己,不起任何作用。请列出各上游使用的名称,或删除该别名。", "config.model_spec_blank_model": "上游「{upstream}」的模型规格(model_specs)中有一项的模型 ID 为空。", "config.model_spec_wildcard": "上游「{upstream}」的模型规格「{model}」含有 * 或 ?。模型规格只能对应一个确切的模型 ID。", - "config.model_spec_empty": "上游「{upstream}」的模型规格「{model}」既未设置 context_window,也未设置 max_output_tokens。请至少设置一项,或删除该规格。", + "config.model_spec_nothing_set": "上游「{upstream}」的模型规格「{model}」中 context_window、max_output_tokens、reasoning、image_input 均未设置。请至少设置一项,或删除该规格。", "config.model_spec_zero": "上游「{upstream}」的模型规格「{model}」中 {field} 为 0,须为大于 0 的 token 数。要使用价目表中的值,请删除该项。", "config.reserved_name": "{what:kind}名称「{name}」以 __ 开头,该前缀保留给内置项,请使用其他名称。", "config.rule_name_empty": "有一条自定义{what:rule_line}规则没有名称。", diff --git a/src/upstreams/ModelSpecDialog.i18n.ts b/src/upstreams/ModelSpecDialog.i18n.ts index 433cc32..66999fa 100644 --- a/src/upstreams/ModelSpecDialog.i18n.ts +++ b/src/upstreams/ModelSpecDialog.i18n.ts @@ -3,24 +3,34 @@ import { messages } from "@/i18n"; export const modelSpecDialogText = messages( { title: "模型规格", - desc: "价目表中没有此模型或数值有误时填写。留空的一项使用价目表。", + desc: "价目表中没有此模型或数值有误时填写。留空或选「价目表」的一项使用价目表。", contextWindow: "上下文窗口", maxOutput: "输出上限", + reasoning: "推理", + imageInput: "图片输入", + table: "价目表", + yes: "支持", + no: "不支持", fromTable: (n: string) => `价目表:${n}`, useTable: "使用价目表", notInTable: "价目表中没有", bad: "须为正整数,最大 4294967295。", - removing: "保存后删除手动设置,两项都使用价目表。", + removing: "保存后删除手动设置,各项都使用价目表。", }, { title: "Model specs", - desc: "For a model the price table lacks or gets wrong. A blank field uses the price table.", + desc: "For a model the price table lacks or gets wrong. A blank field, or one set to Price table, uses the price table.", contextWindow: "Context window", maxOutput: "Max output", + reasoning: "Reasoning", + imageInput: "Image input", + table: "Price table", + yes: "Yes", + no: "No", fromTable: (n: string) => `Price table: ${n}`, useTable: "Use the price table", notInTable: "Not in the price table", bad: "A whole number above 0, at most 4294967295.", - removing: "Saving removes the manual values; both use the price table.", + removing: "Saving removes the manual values; all specs use the price table.", }, ); diff --git a/src/upstreams/ModelSpecDialog.tsx b/src/upstreams/ModelSpecDialog.tsx index 3c08b40..8a62238 100644 --- a/src/upstreams/ModelSpecDialog.tsx +++ b/src/upstreams/ModelSpecDialog.tsx @@ -10,23 +10,25 @@ import { DialogTitle, } from "@/ui/dialog"; import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/ui/input-group"; +import { Segmented } from "@/ui/segmented"; import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import type { ModelRow, SpecSource } from "@/types"; import { api } from "./api"; import { errorText } from "./labels"; -import { manualOf, tokensOf } from "./modelSpec"; +import { isEmptySpec, manualOf, sameSpec, specOf, tokensOf, type SpecFlag } from "./modelSpec"; import { modelSpecDialogText } from "./ModelSpecDialog.i18n"; import { DialogError, FormItem } from "./parts"; /** - * 手写一家上游的一个模型的上下文窗口、输出上限(`PUT /provider-model-spec`)。 + * 手写一家上游的一个模型的上下文窗口、输出上限、推理、图片输入(`PUT /provider-model-spec`)。 * * 价目表不认识的中转站模型说不出上下文窗口,价目表写错的也有:这里写的只管这一家的 - * 这一个模型,写了就优先于价目表。**两项都空就是删掉手写的**,回到价目表。 + * 这一个模型,写了就优先于价目表。**四项都不写就是删掉手写的**,回到价目表。 * * 格子里是手写的那个数;没手写的空着,占位写价目表给的数(没有就说价目表中没有)—— - * 留空是什么意思,看占位就知道。 + * 留空是什么意思,看占位就知道。推理、图片输入是三段:价目表 / 支持 / 不支持,价目表怎么说 + * 写在下面一行。 * * 挂载方:上游表「模型」一格的弹窗(「规格…」)。对话框挂在弹窗外面:弹窗一收起,里面的 * 东西就卸掉了。 @@ -85,6 +87,8 @@ function Body({ const manual = manualOf(row); const [context, setContext] = useState(manual.context); const [output, setOutput] = useState(manual.output); + const [reasoning, setReasoning] = useState(manual.reasoning); + const [imageInput, setImageInput] = useState(manual.imageInput); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); /* @@ -93,26 +97,25 @@ function Body({ */ const [base] = useState(configVersion); - const ctx = tokensOf(context); - const out = tokensOf(output); - const valid = ctx !== undefined && out !== undefined; - const changed = ctx !== tokensOf(manual.context) || out !== tokensOf(manual.output); - /** 原来手写过、现在两项都清空了:保存就是删掉,回到价目表 */ - const removing = valid && ctx === null && out === null && (manual.context !== "" || manual.output !== ""); + const spec = specOf({ context, output, reasoning, imageInput }); + /** 打开时的四项。打开时的值是 core 给的,一定写得对 */ + const before = specOf(manual)!; + const changed = spec !== undefined && !sameSpec(spec, before); + /** 原来手写过、现在四项都不写了:保存就是删掉,回到价目表 */ + const removing = spec !== undefined && isEmptySpec(spec) && !isEmptySpec(before); async function save() { - if (!valid) return; + if (!spec) return; setSaving(true); setError(null); try { await api.setModelSpec({ provider, model: row.id, - context_window: ctx, - max_output_tokens: out, + ...spec, base_version: base, }); - // 用到上下文窗口的几处:这家的模型清单(弹窗、路由里指定的模型)、别名、模型目录 + // 用到规格的几处:这家的模型清单(弹窗、路由里指定的模型)、别名、模型目录 invalidate(`upstream-models:${provider}`); invalidate("aliases"); invalidate("known-models"); @@ -151,7 +154,7 @@ function Body({ value={context} onChange={setContext} placeholder={placeholderOf(row.context_window, row.context_window_source, t)} - bad={ctx === undefined} + bad={tokensOf(context) === undefined} /> + + @@ -170,7 +187,7 @@ function Body({ - @@ -192,6 +209,47 @@ function placeholderOf( return t.notInTable; } +/** + * 推理、图片输入:价目表 / 支持 / 不支持。下面一行写价目表怎么说,和数的占位是同一个说法; + * 此刻是手写的,价目表怎么说这里不知道,就不写。 + */ +function FlagField({ + label, + value, + onChange, + table, + source, +}: { + label: string; + value: SpecFlag; + onChange: (v: SpecFlag) => void; + /** 行里的值:来源是价目表时就是价目表说的 */ + table: boolean | null | undefined; + source: SpecSource | null | undefined; +}) { + const t = useText(modelSpecDialogText); + const desc = + source === "price_table" && table != null + ? t.fromTable(table ? t.yes : t.no) + : source === "manual" + ? undefined + : t.notInTable; + return ( + + + label={label} + value={value} + options={[ + { id: "table", label: t.table }, + { id: "yes", label: t.yes }, + { id: "no", label: t.no }, + ]} + onChange={onChange} + /> + + ); +} + function TokensField({ id, label, diff --git a/src/upstreams/ModelsPanel.i18n.ts b/src/upstreams/ModelsPanel.i18n.ts index 117c6dc..7bc7c13 100644 --- a/src/upstreams/ModelsPanel.i18n.ts +++ b/src/upstreams/ModelsPanel.i18n.ts @@ -34,6 +34,8 @@ export const modelsPanelText = messages( manualSpecs: "手动规格", manualContext: (n: string) => `上下文窗口 ${n}`, manualOutput: (n: string) => `输出上限 ${n}`, + manualReasoning: (on: boolean): string => (on ? "支持推理" : "不支持推理"), + manualImageInput: (on: boolean): string => (on ? "支持图片输入" : "不支持图片输入"), listSep: ",", manualTitle: (what: string) => `手动设置:${what}`, }, @@ -74,6 +76,8 @@ export const modelsPanelText = messages( manualSpecs: "Manual specs", manualContext: (n: string) => `context window ${n}`, manualOutput: (n: string) => `max output ${n}`, + manualReasoning: (on: boolean): string => (on ? "reasoning" : "no reasoning"), + manualImageInput: (on: boolean): string => (on ? "image input" : "no image input"), listSep: ", ", manualTitle: (what: string) => `Set by hand: ${what}`, }, diff --git a/src/upstreams/ModelsPanel.tsx b/src/upstreams/ModelsPanel.tsx index b12c7f4..7195f67 100644 --- a/src/upstreams/ModelsPanel.tsx +++ b/src/upstreams/ModelsPanel.tsx @@ -250,10 +250,12 @@ function Row({ ? `$${perMillion(m.price.input)} / $${perMillion(m.price.output)}${m.estimated ? t.estimated : ""}` : null; const actions = onAlias || onSpec; - /** 手写了哪几项:「上下文窗口 128K」「输出上限 16K」 */ + /** 手写了哪几项:「上下文窗口 128K」「输出上限 16K」「支持推理」「不支持图片输入」 */ const manual = [ m.context_window_source === "manual" ? t.manualContext(contextWindow(m.context_window)) : null, m.max_output_tokens_source === "manual" ? t.manualOutput(contextWindow(m.max_output_tokens)) : null, + m.reasoning_source === "manual" && m.reasoning != null ? t.manualReasoning(m.reasoning) : null, + m.image_input_source === "manual" && m.image_input != null ? t.manualImageInput(m.image_input) : null, ].filter((x): x is string => x !== null); return (
call("ProviderModels", null, name), refreshProviderModels: (name: string) => call("RefreshProviderModels", null, name), - /** 手写一个模型的上下文窗口、输出上限。两项都空 = 删掉手写的,回到价目表 */ + /** 手写一个模型的上下文窗口、输出上限、推理、图片输入。四项都空 = 删掉手写的,回到价目表 */ setModelSpec: (save: ModelSpecSave) => call("SetModelSpec", save), /** 补问缺失、失败、过期的清单。**立刻回**,答案随 `models_changed` 到 */ refreshStaleModels: () => call("RefreshStaleModels", null), diff --git a/src/upstreams/modelSpec.test.ts b/src/upstreams/modelSpec.test.ts index 0b3840e..f46b35e 100644 --- a/src/upstreams/modelSpec.test.ts +++ b/src/upstreams/modelSpec.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { ModelRow } from "@/types"; import { catalogOf } from "./ModelsSection"; -import { MAX_SPEC_TOKENS, hasManual, manualOf, tokensOf } from "./modelSpec"; +import { MAX_SPEC_TOKENS, hasManual, isEmptySpec, manualOf, sameSpec, specOf, tokensOf } from "./modelSpec"; function row(patch: Partial = {}): ModelRow { return { id: "glm-5-air", enabled: true, estimated: false, aliases: [], ...patch }; @@ -26,15 +26,69 @@ describe("模型规格", () => { max_output_tokens: 64_000, max_output_tokens_source: "price_table", }); - expect(manualOf(both)).toEqual({ context: "1000000", output: "" }); + expect(manualOf(both)).toEqual({ context: "1000000", output: "", reasoning: "table", imageInput: "table" }); expect(hasManual(both)).toBe(true); const table = row({ context_window: 200_000, context_window_source: "price_table" }); - expect(manualOf(table)).toEqual({ context: "", output: "" }); + expect(manualOf(table)).toEqual({ context: "", output: "", reasoning: "table", imageInput: "table" }); expect(hasManual(table)).toBe(false); expect(hasManual(row({ max_output_tokens: 16_384, max_output_tokens_source: "manual" }))).toBe(true); expect(hasManual(row())).toBe(false); }); + it("推理、图片输入:手写的回填成支持 / 不支持,价目表给的是「价目表」", () => { + const m = row({ + reasoning: false, + reasoning_source: "manual", + image_input: true, + image_input_source: "price_table", + }); + expect(manualOf(m)).toMatchObject({ reasoning: "no", imageInput: "table" }); + expect(hasManual(m)).toBe(true); + expect(manualOf(row({ image_input: true, image_input_source: "manual" })).imageInput).toBe("yes"); + expect(hasManual(row({ image_input: true, image_input_source: "manual" }))).toBe(true); + expect(hasManual(row({ reasoning: true, reasoning_source: "price_table" }))).toBe(false); + }); + + it("表单 → 要存的四项:「价目表」和空格子是 null;数不对就不能存", () => { + expect(specOf({ context: "128,000", output: "", reasoning: "yes", imageInput: "no" })).toEqual({ + context_window: 128_000, + max_output_tokens: null, + reasoning: true, + image_input: false, + }); + expect(specOf({ context: "128k", output: "", reasoning: "yes", imageInput: "table" })).toBeUndefined(); + // 回填再转回去,和 core 给的是同一份 + const m = row({ + context_window: 200_000, + context_window_source: "price_table", + max_output_tokens: 32_000, + max_output_tokens_source: "manual", + reasoning: true, + reasoning_source: "manual", + }); + expect(specOf(manualOf(m))).toEqual({ + context_window: null, + max_output_tokens: 32_000, + reasoning: true, + image_input: null, + }); + }); + + it("四项都不写就是删掉;只要有一项写了就不是", () => { + const empty = specOf({ context: "", output: " ", reasoning: "table", imageInput: "table" })!; + expect(isEmptySpec(empty)).toBe(true); + for (const f of [ + { context: "1", output: "", reasoning: "table", imageInput: "table" }, + { context: "", output: "", reasoning: "no", imageInput: "table" }, + { context: "", output: "", reasoning: "table", imageInput: "yes" }, + ] as const) + expect(isEmptySpec(specOf(f)!)).toBe(false); + // 千分位写法不同不算改了;是非项换了算 + const a = specOf({ context: "128,000", output: "", reasoning: "table", imageInput: "table" })!; + expect(sameSpec(a, specOf({ context: "128000", output: "", reasoning: "table", imageInput: "table" })!)).toBe(true); + expect(sameSpec(a, specOf({ context: "128000", output: "", reasoning: "no", imageInput: "table" })!)).toBe(false); + }); + it("编辑对话框的模型一节:手写的上下文窗口和弹窗里是同一个数", () => { const c = catalogOf({ provider: "relay", diff --git a/src/upstreams/modelSpec.ts b/src/upstreams/modelSpec.ts index 7e6a433..9130caa 100644 --- a/src/upstreams/modelSpec.ts +++ b/src/upstreams/modelSpec.ts @@ -1,9 +1,9 @@ /** - * 手写的模型规格(上下文窗口、输出上限):从 `ModelRow` 读出手写的那几项、格子里的数 - * 写得对不对。**先后只在 core 定**(`tw_config::model_specs::resolve`):这里只认 core - * 标的来源,不自己比大小。 + * 手写的模型规格(上下文窗口、输出上限、推理、图片输入):从 `ModelRow` 读出手写的那几项、 + * 格子里的数写得对不对、表单怎么变成要存的四项。**先后只在 core 定** + * (`tw_config::model_specs::resolve`):这里只认 core 标的来源,不自己比大小。 */ -import type { ModelRow } from "@/types"; +import type { ModelRow, ModelSpecSave } from "@/types"; /** 一项最多写多少:core 存的是 `u32` */ export const MAX_SPEC_TOKENS = 4_294_967_295; @@ -20,15 +20,65 @@ export function tokensOf(v: string): number | null | undefined { return n >= 1 && n <= MAX_SPEC_TOKENS ? n : undefined; } -/** 这个模型此刻手写的两项,格子里的写法。没手写的那一项是空的 */ -export function manualOf(m: ModelRow): { context: string; output: string } { +/** 推理、图片输入这类是非项的三个选择:用价目表的、手写「支持」、手写「不支持」 */ +export type SpecFlag = "table" | "yes" | "no"; + +/** 对话框里的四项。数是格子里的原文,是非项是选中的那一段 */ +export type SpecForm = { context: string; output: string; reasoning: SpecFlag; imageInput: SpecFlag }; + +/** 手写的是非项在表单里的选择。没手写(来自价目表或不知道)就是「价目表」 */ +function flagOf(value: boolean | null | undefined, source: ModelRow["reasoning_source"]): SpecFlag { + if (source !== "manual" || value == null) return "table"; + return value ? "yes" : "no"; +} + +/** 选择 → 要存的值。「价目表」是不写(`null`) */ +function flagValue(f: SpecFlag): boolean | null { + return f === "table" ? null : f === "yes"; +} + +/** 这个模型此刻手写的几项,表单里的写法。没手写的数是空的、是非项是「价目表」 */ +export function manualOf(m: ModelRow): SpecForm { return { context: m.context_window_source === "manual" && m.context_window != null ? String(m.context_window) : "", output: m.max_output_tokens_source === "manual" && m.max_output_tokens != null ? String(m.max_output_tokens) : "", + reasoning: flagOf(m.reasoning, m.reasoning_source), + imageInput: flagOf(m.image_input, m.image_input_source), }; } +/** 要存的四项。**四项都是 `null` 就是删掉手写的**,回到价目表 */ +export type SpecValues = Required>; + +/** 表单 → 要存的四项。数写得不对时是 `undefined`,不能存 */ +export function specOf(f: SpecForm): SpecValues | undefined { + const context_window = tokensOf(f.context); + const max_output_tokens = tokensOf(f.output); + if (context_window === undefined || max_output_tokens === undefined) return undefined; + return { context_window, max_output_tokens, reasoning: flagValue(f.reasoning), image_input: flagValue(f.imageInput) }; +} + +/** 四项都不写:存下去就是删掉这个模型手写的规格 */ +export function isEmptySpec(v: SpecValues): boolean { + return v.context_window === null && v.max_output_tokens === null && v.reasoning === null && v.image_input === null; +} + +/** 两份要存的四项是不是一样(格子里 `128,000` 和 `128000` 算一样) */ +export function sameSpec(a: SpecValues, b: SpecValues): boolean { + return ( + a.context_window === b.context_window && + a.max_output_tokens === b.max_output_tokens && + a.reasoning === b.reasoning && + a.image_input === b.image_input + ); +} + /** 这个模型在这一家有手写的规格 */ export function hasManual(m: ModelRow): boolean { - return m.context_window_source === "manual" || m.max_output_tokens_source === "manual"; + return ( + m.context_window_source === "manual" || + m.max_output_tokens_source === "manual" || + m.reasoning_source === "manual" || + m.image_input_source === "manual" + ); }