From 4a1de299c3f49322dac9bc58247b2ceecd08c37a Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:43:04 +0800 Subject: [PATCH 01/18] feat(listing): context window and token limits in the model listing Each model in GET /v1/models and GET /v1/models/{id} now carries what clients read about it, taken from the price table (the same number the control plane shows as ModelRow.context_window): - OpenAI shape: context_window, context_length and max_input_tokens - Anthropic shape: the same, plus supports_1m (true at 1,000,000 or more) - Gemini shape: inputTokenLimit and outputTokenLimit When the price table has no entry for the model, the fields are left out. model_meta(book, provider, model) returns this metadata, so an alias can take it from the model that serves it. anthropic_family_tier also recognises fable and mythos. Co-Authored-By: Claude Opus 5.5 --- crates/tw-gateway/src/server/listing.rs | 241 ++++++++++++++++++---- crates/tw-gateway/tests/claude_desktop.rs | 67 +++++- 2 files changed, 271 insertions(+), 37 deletions(-) diff --git a/crates/tw-gateway/src/server/listing.rs b/crates/tw-gateway/src/server/listing.rs index 51c0e093..a522430d 100644 --- a/crates/tw-gateway/src/server/listing.rs +++ b/crates/tw-gateway/src/server/listing.rs @@ -49,6 +49,51 @@ impl ListingShape { }; slugs(api.servable_by(true)) } + + /// 这种客户端要的一个模型对象。**列表和单点查询都从这里出** —— 同一个模型在 + /// 两处必须是同一个对象。 + fn object(&self, id: &str, meta: &ModelMeta) -> serde_json::Value { + match self { + ListingShape::Gemini => gemini_model(id, meta), + ListingShape::Anthropic => anthropic_model(id, meta), + ListingShape::Openai => openai_model(id, meta), + } + } +} + +/// 列表里一个模型带给客户端的元数据。 +/// +/// 来自价目表,和上游页模型一格的「上下文」(`ModelRow.context_window`)是同一个数。 +/// **查不到就是 `None`,对应的字段整个不出现** —— 客户端读不到会用自己的默认值, +/// 一个编出来的数它却会照着截断对话。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct ModelMeta { + /// 一次最多输入多少 token,也就是上下文窗口 + pub max_input_tokens: Option, + /// 一次最多输出多少 token。只有 Gemini 的模型对象有这一项 + pub max_output_tokens: Option, +} + +/// 查一个模型的元数据。 +/// +/// `provider`:知道这个名称发给哪家上游时给上,按它选的价目表查,和上游页那一格是 +/// 同一个查法;不给就查默认价目表。自定义价目表只改单价,上下文窗口照样取自默认 +/// 价目表,所以两种查法给出的通常是同一个数。 +pub(crate) fn model_meta( + book: &tw_pricing::PriceBook, + provider: Option<&str>, + model: &str, +) -> ModelMeta { + let resolved = match provider { + Some(p) => book.resolve_for(p, model), + None => book.resolve(None, model), + }; + resolved + .map(|r| ModelMeta { + max_input_tokens: r.price.max_input_tokens, + max_output_tokens: r.price.max_output_tokens, + }) + .unwrap_or_default() } /// `GET /v1/models`。 @@ -82,24 +127,24 @@ pub(super) async fn list_models( .load() .resolve_allowed(Some(&shape.protocols()), allow.as_deref()); + let book = state.pricing.load(); + let objects: Vec<_> = models + .iter() + .map(|m| shape.object(m, &model_meta(&book, None, m))) + .collect(); + let body = match shape { - ListingShape::Gemini => serde_json::json!({ - "models": models.iter().map(|m| serde_json::json!({ - "name": format!("models/{m}"), - })).collect::>() - }), + ListingShape::Gemini => serde_json::json!({ "models": objects }), ListingShape::Anthropic => serde_json::json!({ "object": "list", - "data": models.iter().map(|m| anthropic_model(m)).collect::>(), + "data": objects, "has_more": false, "first_id": models.first(), "last_id": models.last(), }), ListingShape::Openai => serde_json::json!({ "object": "list", - "data": models.iter().map(|m| serde_json::json!({ - "id": m, "object": "model", "created": RELEASED_AT, - })).collect::>() + "data": objects, }), }; Ok(axum::Json(body).into_response()) @@ -112,33 +157,62 @@ pub(super) async fn list_models( /// 模型每秒换一个发布时间,列表和单点查询跨过整秒就对不上。 const RELEASED_AT: i64 = 0; +/// OpenAI 格式的一个模型对象。 +/// +/// 知道上下文窗口时,同一个数写成三个字段:各家客户端读的不是同一个名字 —— +/// Grok Build 读 `context_window`,oh-my-pi 读 `context_length`,Hermes 三个依次试。 +fn openai_model(id: &str, meta: &ModelMeta) -> serde_json::Value { + let mut m = serde_json::json!({ "id": id, "object": "model", "created": RELEASED_AT }); + if let Some(n) = meta.max_input_tokens { + m["context_window"] = n.into(); + m["context_length"] = n.into(); + m["max_input_tokens"] = n.into(); + } + m +} + /// Anthropic 格式的一个模型对象。 /// /// **是 OpenAI 那个对象的超集**:Anthropic 的字段(`type`、`display_name`、 -/// `created_at`)之外,`object` 和 `created` 照样在。只放 `x-api-key` 的客户端也被 -/// 认成 Anthropic,其中有按 OpenAI 的形状读列表的,不能让它们读不出来。 +/// `created_at`)之外,`object`、`created` 和上下文窗口那几个字段照样在。只放 +/// `x-api-key` 的客户端也被认成 Anthropic,其中有按 OpenAI 的形状读列表的,不能让 +/// 它们读不出来。 /// -/// Claude 的模型再带上 `anthropic_family_tier`:Claude Desktop 按它把模型归到 -/// opus / sonnet / haiku,配置里写的 `sonnet` 这样的简称靠它解析。**只看模型名**, +/// Claude Desktop 读其中的 `max_input_tokens`,另外读 `supports_1m`:上下文窗口到 +/// 1,000,000 时是 `true`。上下文窗口不知道时这两项都不给。 +/// +/// Claude 的模型再带上 `anthropic_family_tier`:Claude Desktop 按它把模型归档(见 +/// [`family_tier`]),配置里写的 `sonnet` 这样的简称靠它解析。**只看模型名**, /// 名字里看不出是 Claude 的一律不标 —— 把别家的模型标成 Claude 是在替客户端撒谎。 -fn anthropic_model(id: &str) -> serde_json::Value { +fn anthropic_model(id: &str, meta: &ModelMeta) -> serde_json::Value { let created_at = chrono::DateTime::from_timestamp(RELEASED_AT, 0) .unwrap_or_default() .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let mut m = serde_json::json!({ - "type": "model", - "id": id, - "display_name": display_name(id).unwrap_or_else(|| id.to_string()), - "created_at": created_at, - "object": "model", - "created": RELEASED_AT, - }); + let mut m = openai_model(id, meta); + m["type"] = "model".into(); + m["display_name"] = display_name(id).unwrap_or_else(|| id.to_string()).into(); + m["created_at"] = created_at.into(); + if let Some(n) = meta.max_input_tokens { + m["supports_1m"] = (n >= 1_000_000).into(); + } if let Some(tier) = family_tier(id) { m["anthropic_family_tier"] = tier.into(); } m } +/// Gemini 格式的一个模型对象。上下文窗口和输出上限用 Gemini 自己的字段名。 +fn gemini_model(id: &str, meta: &ModelMeta) -> serde_json::Value { + let mut m = serde_json::json!({ "name": format!("models/{id}") }); + if let Some(n) = meta.max_input_tokens { + m["inputTokenLimit"] = n.into(); + } + if let Some(n) = meta.max_output_tokens { + m["outputTokenLimit"] = n.into(); + } + m +} + /// Claude 模型的名字:`claude-sonnet-4-5-20250929` → `Claude Sonnet 4.5`。 /// /// 只认最后一段(`/` 之后)以 `claude-` 开头、每一节都是字母或数字的;日期那一节 @@ -178,13 +252,14 @@ fn display_name(id: &str) -> Option { (words.len() > 1).then(|| words.join(" ")) } -/// 名字里看得出是哪一档的 Claude 模型:`opus`、`sonnet` 或 `haiku`。 +/// 名字里看得出是哪一档的 Claude 模型:`opus`、`sonnet`、`haiku`、`fable` 或 +/// `mythos`(Claude Desktop 认的就是这五档)。 fn family_tier(id: &str) -> Option<&'static str> { let lower = id.to_ascii_lowercase(); if !lower.contains("claude") && !lower.contains("anthropic") { return None; } - let mut tiers = ["opus", "sonnet", "haiku"] + let mut tiers = ["opus", "sonnet", "haiku", "fable", "mythos"] .into_iter() .filter(|t| lower.contains(t)); // 名字里同时出现两档的(一个路由别名)不猜 @@ -234,16 +309,8 @@ pub(super) async fn get_model( ), )); } - let body = match shape { - ListingShape::Gemini => { - serde_json::json!({ "name": format!("models/{model}") }) - } - ListingShape::Anthropic => anthropic_model(&model), - ListingShape::Openai => { - serde_json::json!({ "id": model, "object": "model", "created": RELEASED_AT }) - } - }; - Ok(axum::Json(body).into_response()) + let meta = model_meta(&state.pricing.load(), None, &model); + Ok(axum::Json(shape.object(&model, &meta)).into_response()) } #[cfg(test)] @@ -285,9 +352,113 @@ mod tests { family_tier("us.anthropic.claude-sonnet-4-5-20250929-v1:0"), Some("sonnet") ); - assert_eq!(family_tier("claude-fable-5-1"), None); // 名字里有 sonnet,但看不出是 Claude assert_eq!(family_tier("my-sonnet-alias"), None); assert_eq!(family_tier("claude-opus-or-sonnet"), None); } + + #[test] + fn fable_and_mythos_are_tiers_too() { + assert_eq!(family_tier("claude-fable-5-1"), Some("fable")); + assert_eq!( + family_tier("global.anthropic.claude-fable-5"), + Some("fable") + ); + assert_eq!(family_tier("claude-mythos-preview"), Some("mythos")); + assert_eq!( + family_tier("anthropic.claude-mythos-preview"), + Some("mythos") + ); + assert_eq!(family_tier("claude-fable-or-opus"), None); + assert_eq!(family_tier("fable-mini"), None); + } + + const KNOWN: ModelMeta = ModelMeta { + max_input_tokens: Some(200_000), + max_output_tokens: Some(64_000), + }; + + #[test] + fn an_openai_model_carries_its_context_window_under_each_name_clients_read() { + let m = openai_model("claude-sonnet-4-5", &KNOWN); + assert_eq!(m["id"], "claude-sonnet-4-5"); + assert_eq!(m["context_window"], 200_000); + assert_eq!(m["context_length"], 200_000); + assert_eq!(m["max_input_tokens"], 200_000); + // 输出上限 OpenAI 的模型对象里没有人读 + assert!(m.get("max_output_tokens").is_none(), "{m}"); + } + + #[test] + fn an_anthropic_model_says_whether_it_takes_a_million_tokens() { + let m = anthropic_model("claude-sonnet-4-5", &KNOWN); + assert_eq!(m["max_input_tokens"], 200_000); + assert_eq!(m["supports_1m"], false); + assert_eq!(m["anthropic_family_tier"], "sonnet"); + // 仍是 OpenAI 那个对象的超集 + let openai = openai_model("claude-sonnet-4-5", &KNOWN); + for (k, v) in openai.as_object().unwrap() { + assert_eq!(&m[k], v, "{k}"); + } + + for n in [1_000_000, 1_048_576] { + let big = ModelMeta { + max_input_tokens: Some(n), + ..KNOWN + }; + let m = anthropic_model("claude-fable-5", &big); + assert_eq!(m["supports_1m"], true, "{n}"); + assert_eq!(m["max_input_tokens"], n); + assert_eq!(m["anthropic_family_tier"], "fable"); + } + } + + #[test] + fn a_gemini_model_uses_geminis_field_names() { + let m = gemini_model("gemini-2.5-pro", &KNOWN); + assert_eq!(m["name"], "models/gemini-2.5-pro"); + assert_eq!(m["inputTokenLimit"], 200_000); + assert_eq!(m["outputTokenLimit"], 64_000); + } + + #[test] + fn an_unknown_context_window_leaves_the_fields_out() { + let unknown = ModelMeta::default(); + let fields = [ + "context_window", + "context_length", + "max_input_tokens", + "supports_1m", + "inputTokenLimit", + "outputTokenLimit", + ]; + for m in [ + openai_model("my-model", &unknown), + anthropic_model("my-model", &unknown), + gemini_model("my-model", &unknown), + ] { + for f in fields { + assert!(m.get(f).is_none(), "{f}: {m}"); + } + } + } + + #[test] + fn metadata_comes_from_the_price_table() { + let book = tw_pricing::PriceBook::builtin().unwrap(); + let sonnet = model_meta(&book, None, "claude-sonnet-4-5-20250929"); + assert_eq!(sonnet.max_input_tokens, Some(200_000)); + assert_eq!(sonnet.max_output_tokens, Some(64_000)); + // 给了上游就按它选的价目表查;没选价目表的上游和默认价目表一样 + assert_eq!(model_meta(&book, Some("up"), "claude-sonnet-4-5"), sonnet); + // Bedrock 的名字也查得到 + assert_eq!( + model_meta(&book, Some("bedrock"), "us.anthropic.claude-fable-5").max_input_tokens, + Some(1_000_000) + ); + assert_eq!( + model_meta(&book, None, "no-such-model-anywhere"), + ModelMeta::default() + ); + } } diff --git a/crates/tw-gateway/tests/claude_desktop.rs b/crates/tw-gateway/tests/claude_desktop.rs index 5050e645..9a629d09 100644 --- a/crates/tw-gateway/tests/claude_desktop.rs +++ b/crates/tw-gateway/tests/claude_desktop.rs @@ -3,7 +3,7 @@ //! - 启动时的 `HEAD /api/hello` 预热:不要密钥,不打上游 //! - 推理请求打到 `/v1/messages?beta=true`,`anthropic-beta` 和 `cache_control` 原样到上游 //! - `/v1/messages/count_tokens` 照样转给 Anthropic 上游 -//! - `/v1/models` 回 Anthropic 的列表格式,Claude 模型带上名字和档位 +//! - `/v1/models` 回 Anthropic 的列表格式,Claude 模型带上名字、档位和上下文窗口 //! - 上游静默时,Anthropic 流里补 `ping`:客户端按字节计时,五分钟没有字节就放弃 use std::net::SocketAddr; @@ -247,10 +247,15 @@ async fn models_are_listed_in_the_anthropic_shape() { // OpenAI 那几个字段照样在 assert_eq!(claude["object"], "model"); assert_eq!(claude["created"], 0); - // 看不出是 Claude 的:名字就是 ID,也不标档位 + // 上下文窗口来自价目表;不到一百万 + assert_eq!(claude["max_input_tokens"], 200_000); + assert_eq!(claude["supports_1m"], false); + // 看不出是 Claude 的:名字就是 ID,也不标档位;价目表里没有,就不给上下文 let alias = &data[1]; assert_eq!(alias["display_name"], "my-alias"); assert!(alias.get("anthropic_family_tier").is_none(), "{alias}"); + assert!(alias.get("max_input_tokens").is_none(), "{alias}"); + assert!(alias.get("supports_1m").is_none(), "{alias}"); let one: Value = get("/v1/models/claude-sonnet-4-5-20250929") .await @@ -278,10 +283,68 @@ async fn an_openai_client_still_gets_the_openai_listing() { let m = &list["data"][0]; assert_eq!(m["id"], "claude-sonnet-4-5"); assert_eq!(m["created"], 0); + assert_eq!(m["context_window"], 200_000); + assert_eq!(m["context_length"], 200_000); + assert_eq!(m["max_input_tokens"], 200_000); assert!(m.get("type").is_none(), "{m}"); + assert!(m.get("supports_1m").is_none(), "{m}"); assert!(list.get("has_more").is_none(), "{list}"); } +#[tokio::test] +async fn a_million_token_model_is_listed_as_such_with_its_tier() { + let (up, _) = recording_upstream().await; + let gw = gateway(provider( + up, + Protocol::Anthropic, + &["claude-fable-5", "claude-mythos-preview"], + )) + .await; + let list: Value = reqwest::Client::new() + .get(format!("http://{}/v1/models", gw.addr)) + .header("x-api-key", "tw-k") + .header("anthropic-version", "2023-06-01") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let data = list["data"].as_array().unwrap(); + assert_eq!(data.len(), 2, "{list}"); + for (m, tier) in data.iter().zip(["fable", "mythos"]) { + assert_eq!(m["anthropic_family_tier"], tier, "{m}"); + assert_eq!(m["max_input_tokens"], 1_000_000, "{m}"); + assert_eq!(m["supports_1m"], true, "{m}"); + } +} + +#[tokio::test] +async fn a_gemini_client_gets_the_token_limits_in_geminis_fields() { + let (up, _) = recording_upstream().await; + let gw = gateway(provider(up, Protocol::Gemini, &["gemini-2.5-pro"])).await; + let get = |path: &'static str| { + reqwest::Client::new() + .get(format!("http://{}{path}", gw.addr)) + .header("x-goog-api-key", "tw-k") + .send() + }; + let list: Value = get("/v1beta/models").await.unwrap().json().await.unwrap(); + let m = &list["models"][0]; + assert_eq!(m["name"], "models/gemini-2.5-pro"); + assert_eq!(m["inputTokenLimit"], 1_048_576); + assert_eq!(m["outputTokenLimit"], 65_535); + assert!(m.get("context_window").is_none(), "{m}"); + + let one: Value = get("/v1beta/models/gemini-2.5-pro") + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(one, *m, "单点查询和列表里的应该是同一个对象"); +} + const CHAT_FIRST: &str = "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-x\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hel\"}}]}\n\n"; const CHAT_REST: &str = "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}]}\n\n\ data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-x\",\"choices\":[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\n\ From 3410594cc56bd67305d25c0bf238147adfb995b5 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:54:10 +0800 Subject: [PATCH 02/18] Model aliases, part 0: the config table, pinned-model rule targets, and resolve - tw-config: top-level `aliases` (`Aliases` / `Alias { name, models }`), a map whose values are a model name or a list of them, kept in the order written. Validation with `config.alias_*` codes: empty name, `*`/`?`, leading `__`, duplicate, empty list, empty model name, listing another alias, listing only itself. `check_aliases` is public for the alias preview. - tw-engine: `Rule.to` is now `Option`, `Target::Name(String)` or `Target::Models(Vec)` (a string or a list of `{provider, model}`). A pinned list makes the listed upstreams the candidates in list order, with no group ordering; `Decision.pinned` carries each candidate's model and `models_asked` uses it. Validation with `engine.pinned_*` codes. - tw-gateway: `models::resolve(cfg, catalog, provider, name)`, the name sent to an upstream for what the client asked (first listed alias model the upstream can serve; non-aliases unchanged). A hop sends the pinned model as written. - Provider renames follow pinned entries; references count them. - Control API types are unchanged for now: a pinned target shows as no target in `RuleView.to`. - Manual tables regenerated; new section `routes[].rules[].to[]`. Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/msg-codes.txt | 14 + crates/tw-config/src/aliases.rs | 208 +++++++++ crates/tw-config/src/lib.rs | 9 +- crates/tw-config/src/refs.rs | 75 +++- crates/tw-config/src/repair.rs | 1 + crates/tw-config/src/validate.rs | 155 +++++++ crates/tw-config/tests/manual.rs | 19 +- crates/tw-config/tests/manual/schema.rs | 41 +- crates/tw-control/src/routes.rs | 5 +- crates/tw-control/tests/routes.rs | 4 +- crates/tw-engine/src/engine.rs | 424 ++++++++++++++++++- crates/tw-engine/src/lib.rs | 4 +- crates/tw-engine/tests/budget.rs | 2 +- crates/tw-gateway/src/affinity.rs | 1 + crates/tw-gateway/src/models.rs | 97 +++++ crates/tw-gateway/src/server/pipeline/hop.rs | 4 + crates/tw-gateway/tests/models.rs | 42 ++ crates/tw-gateway/tests/passthrough.rs | 5 + crates/tw-gateway/tests/routing_facts.rs | 2 +- docs/config.md | 16 +- docs/config.zh-CN.md | 16 +- 21 files changed, 1110 insertions(+), 34 deletions(-) create mode 100644 crates/tw-config/src/aliases.rs diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index 18214374..a06c33e4 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -3,6 +3,14 @@ # A second column marks codes a UI does not translate: # passthrough the sentence is only placeholders (system or upstream text); show `text` # test only produced by tests; never reaches a UI +config.alias_blank_model +config.alias_chained +config.alias_duplicate +config.alias_empty_name +config.alias_no_models +config.alias_only_itself +config.alias_reserved +config.alias_wildcard config.aws_profile.no_home config.aws_profile.no_keys config.aws_profile.not_found @@ -224,6 +232,12 @@ engine.empty_group engine.no_action engine.no_match engine.phase_two_with_to +engine.pinned_blank_model +engine.pinned_duplicate_provider +engine.pinned_empty +engine.pinned_unknown_provider +engine.pinned_with_provider_would_be +engine.pinned_with_set_model engine.unknown_default_route engine.unknown_route engine.unknown_target diff --git a/crates/tw-config/src/aliases.rs b/crates/tw-config/src/aliases.rs new file mode 100644 index 00000000..2e05c1ae --- /dev/null +++ b/crates/tw-config/src/aliases.rs @@ -0,0 +1,208 @@ +//! 模型别名:一个名字 = 同一个模型在各家上游的名称。 +//! +//! ```yaml +//! aliases: +//! deepseek-v4.1: DeepSeek-v4.1-flash # 单个写成字符串 +//! claude-sonnet-5: # 多个写成列表,有序 +//! - claude-sonnet-5 +//! - us.anthropic.claude-sonnet-5-v1:0 +//! - anthropic/claude-sonnet-5 +//! ``` +//! +//! **全局一张表。**别名不能指向别名,没有通配,没有兜底:哪家上游提供列表里的任一 +//! 名称,就能服务这个别名,发过去用它自己的那个名称(按列表顺序取它有的第一个)。 +//! 请求的名称在这张表里,就按别名处理 —— 某家上游恰好有同名的真模型、却没列进来, +//! 这个名称不会发给它。 +//! +//! **保持书写顺序。**读进来是一个列表,写回去还是用户写的那个顺序:界面按它列, +//! 改一个别名不该让整张表重新排一遍。 + +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::ser::SerializeMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// 一个别名:它的名字,和它在各家上游叫什么(有序)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Alias { + pub name: String, + pub models: Vec, +} + +/// 整张别名表,按书写顺序。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Aliases(pub Vec); + +impl Aliases { + /// 叫这个名字的别名。**名字要完全相等** —— 别名没有通配 + pub fn find(&self, name: &str) -> Option<&Alias> { + self.0.iter().find(|a| a.name == name) + } + + /// 这个名字是不是一个别名 + pub fn contains(&self, name: &str) -> bool { + self.find(name).is_some() + } +} + +impl std::ops::Deref for Aliases { + type Target = [Alias]; + fn deref(&self) -> &[Alias] { + &self.0 + } +} + +impl FromIterator for Aliases { + fn from_iter>(iter: I) -> Self { + Aliases(iter.into_iter().collect()) + } +} + +/// 一个名称写成字符串,几个写成列表 —— 和读进来时接受的两种写法一样。 +impl Serialize for Aliases { + fn serialize(&self, s: S) -> Result { + let mut m = s.serialize_map(Some(self.0.len()))?; + for a in &self.0 { + match a.models.as_slice() { + [one] => m.serialize_entry(&a.name, one)?, + many => m.serialize_entry(&a.name, many)?, + } + } + m.end() + } +} + +impl<'de> Deserialize<'de> for Aliases { + fn deserialize>(d: D) -> Result { + struct Table; + impl<'de> Visitor<'de> for Table { + type Value = Aliases; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a map from each alias to a model name or a list of model names") + } + fn visit_unit(self) -> Result { + Ok(Aliases::default()) + } + fn visit_map>(self, mut map: A) -> Result { + // **重名不在这里拒**:这里拒的话说出来的是解析器的话,而校验那一句 + // 带码、说得出是哪个别名(`config.alias_duplicate`) + let mut out = Vec::new(); + while let Some((name, Models(models))) = map.next_entry::()? { + out.push(Alias { name, models }); + } + Ok(Aliases(out)) + } + } + d.deserialize_map(Table) + } +} + +/// 一个别名的值:一个名称,或者一组名称。 +struct Models(Vec); + +impl<'de> Deserialize<'de> for Models { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> Visitor<'de> for V { + type Value = Models; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a model name or a list of model names") + } + fn visit_str(self, v: &str) -> Result { + Ok(Models(vec![v.to_string()])) + } + fn visit_string(self, v: String) -> Result { + Ok(Models(vec![v])) + } + /// `x:` 后面什么都没写:读成空列表,让校验说「这个别名没有模型」, + /// 比一句「类型不对」好懂 + fn visit_unit(self) -> Result { + Ok(Models(Vec::new())) + } + fn visit_none(self) -> Result { + Ok(Models(Vec::new())) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::new(); + while let Some(m) = seq.next_element::()? { + out.push(m); + } + Ok(Models(out)) + } + } + d.deserialize_any(V) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TABLE: &str = "\ +deepseek-v4.1: DeepSeek-v4.1-flash +claude-sonnet-5: + - claude-sonnet-5 + - us.anthropic.claude-sonnet-5-v1:0 + - anthropic/claude-sonnet-5 +a-model: [x] +"; + + fn alias(name: &str, models: &[&str]) -> Alias { + Alias { + name: name.into(), + models: models.iter().map(|m| m.to_string()).collect(), + } + } + + #[test] + fn a_string_or_a_list_reads_in_the_order_it_is_written() { + let t: Aliases = serde_yaml_ng::from_str(TABLE).unwrap(); + assert_eq!( + t.0, + [ + alias("deepseek-v4.1", &["DeepSeek-v4.1-flash"]), + alias( + "claude-sonnet-5", + &[ + "claude-sonnet-5", + "us.anthropic.claude-sonnet-5-v1:0", + "anthropic/claude-sonnet-5" + ] + ), + alias("a-model", &["x"]), + ] + ); + assert_eq!( + t.find("deepseek-v4.1").unwrap().models, + ["DeepSeek-v4.1-flash"] + ); + assert!(t.contains("a-model")); + assert!(!t.contains("a-*"), "没有通配"); + } + + /// 写回去还是那个顺序;一个名称写成字符串 + #[test] + fn it_writes_back_in_the_same_order_and_shape() { + let t: Aliases = serde_yaml_ng::from_str(TABLE).unwrap(); + let out = serde_yaml_ng::to_string(&t).unwrap(); + assert_eq!( + out, + "deepseek-v4.1: DeepSeek-v4.1-flash\nclaude-sonnet-5:\n- claude-sonnet-5\n- us.anthropic.claude-sonnet-5-v1:0\n- anthropic/claude-sonnet-5\na-model: x\n" + ); + let back: Aliases = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(back, t); + } + + #[test] + fn an_empty_value_reads_as_no_models_and_a_wrong_type_says_what_to_write() { + let t: Aliases = serde_yaml_ng::from_str("x:\ny: []\n").unwrap(); + assert_eq!(t.0, [alias("x", &[]), alias("y", &[])]); + let e = serde_yaml_ng::from_str::("x: {a: b}\n") + .unwrap_err() + .to_string(); + assert!(e.contains("a model name or a list of model names"), "{e}"); + let e = serde_yaml_ng::from_str::("[a, b]\n") + .unwrap_err() + .to_string(); + assert!(e.contains("a map from each alias"), "{e}"); + } +} diff --git a/crates/tw-config/src/lib.rs b/crates/tw-config/src/lib.rs index 56323bcf..831c9449 100644 --- a/crates/tw-config/src/lib.rs +++ b/crates/tw-config/src/lib.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +pub mod aliases; pub mod aws_profile; pub mod chatgpt; pub mod control_key; @@ -30,6 +31,7 @@ mod validate; pub mod watch; mod wire; +pub use aliases::{Alias, Aliases}; pub use credential::{CredentialError, Header, Headers, Secret, SecretResolveError, auth_header}; pub use init::{generate_control_key, generate_initial, generate_key}; pub use plugins::Plugin; @@ -95,6 +97,10 @@ pub struct Config { /// 一家上游失败之后停用多久、流开头最多等多久。不写就是默认值。 #[serde(default, skip_serializing_if = "is_default")] pub failover: Failover, + /// 模型别名:一个名字 = 同一个模型在各家上游的名称,按书写顺序。不写就没有。 + /// 见 [`aliases`] + #[serde(default, skip_serializing_if = "is_default")] + pub aliases: Aliases, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub groups: Vec, /// 路由。一条路由是一组按顺序求值的规则。 @@ -137,6 +143,7 @@ impl Default for Config { security: Security::default(), retention: Retention::default(), failover: Failover::default(), + aliases: Aliases::default(), groups: Vec::new(), routes: Vec::new(), default_route: None, @@ -1166,7 +1173,7 @@ pub use security::{ }; // Billing 在本文件里定义,这里不必再导出 pub use store::{Fingerprint, Loaded, StoreError, version_of}; -pub use validate::validate; +pub use validate::{check_aliases, validate}; pub fn default_path() -> PathBuf { tw_api::data::dir().join("config.yaml") diff --git a/crates/tw-config/src/refs.rs b/crates/tw-config/src/refs.rs index a99821e6..8d988de2 100644 --- a/crates/tw-config/src/refs.rs +++ b/crates/tw-config/src/refs.rs @@ -6,6 +6,7 @@ //! 所有引用它的地方在同一个版本里一起变。 use serde_yaml_ng::Value; +use tw_engine::Target; use tw_engine::rule::OneOrMany; use tw_yaml::Step; @@ -28,7 +29,8 @@ pub fn provider_refs(cfg: &Config, name: &str) -> Vec { let mut out = Vec::new(); for route in &cfg.routes { for rule in &route.rules { - if rule.to.as_deref() == Some(name) { + // 转发给它,或者指定了它的模型 + if rule.to.as_ref().is_some_and(|t| t.mentions_provider(name)) { out.push(ProviderRef::RuleTarget { route: route.name.clone(), rule: rule.name.clone(), @@ -108,10 +110,21 @@ pub fn rename_provider( Step::key("rules"), Step::Index(i), ]; - if rule.to.as_deref() == Some(old) { - let mut p = base.to_vec(); - p.push(Step::key("to")); - out = edit::set(&out, &p, Some(&s(new)))?; + match &rule.to { + Some(Target::Name(to)) if to == old => { + let mut p = base.to_vec(); + p.push(Step::key("to")); + out = edit::set(&out, &p, Some(&s(new)))?; + } + // 指定模型:只改那一项的 `provider`,模型名和别的项原样不动 + Some(Target::Models(pinned)) => { + for (k, _) in pinned.iter().enumerate().filter(|(_, x)| x.provider == old) { + let mut p = base.to_vec(); + p.extend([Step::key("to"), Step::Index(k), Step::key("provider")]); + out = edit::set(&out, &p, Some(&s(new)))?; + } + } + _ => {} } if let Some(pw) = &rule.when.provider_would_be && pw.contains(old) @@ -168,7 +181,7 @@ pub fn group_refs(cfg: &Config, name: &str) -> Vec { route .rules .iter() - .filter(|rule| rule.to.as_deref() == Some(name)) + .filter(|rule| rule.to.as_ref().and_then(Target::name) == Some(name)) .map(|rule| RuleRef { route: route.name.clone(), rule: rule.name.clone(), @@ -274,7 +287,7 @@ pub fn rename_group(text: &str, cfg: &Config, old: &str, new: &str) -> Result Option { "security" => to_value(&cfg.security), "retention" => to_value(&cfg.retention), "failover" => to_value(&cfg.failover), + "aliases" => to_value(&cfg.aliases), "groups" => to_value(&cfg.groups), "routes" => to_value(&cfg.routes), "plugins" => to_value(&cfg.plugins), diff --git a/crates/tw-config/src/validate.rs b/crates/tw-config/src/validate.rs index 32c91bb3..a4d00c1d 100644 --- a/crates/tw-config/src/validate.rs +++ b/crates/tw-config/src/validate.rs @@ -84,6 +84,22 @@ pub enum ValidationError { PluginFile { id: String, file: String }, #[error("{}", self.msg())] PluginSha256 { id: String }, + #[error("{}", self.msg())] + AliasEmptyName, + #[error("{}", self.msg())] + AliasWildcard { alias: String }, + #[error("{}", self.msg())] + AliasReserved { alias: String }, + #[error("{}", self.msg())] + AliasDuplicate { alias: String }, + #[error("{}", self.msg())] + AliasNoModels { alias: String }, + #[error("{}", self.msg())] + AliasBlankModel { alias: String }, + #[error("{}", self.msg())] + AliasChained { alias: String, model: String }, + #[error("{}", self.msg())] + AliasOnlyItself { alias: String }, } impl ValidationError { @@ -239,6 +255,43 @@ impl ValidationError { "config.plugin.sha256", plugin = id => "the sha256 of plugin `{plugin}` has to be 64 lowercase hexadecimal characters" ), + AliasEmptyName => msg!( + "config.alias_empty_name" => + "an alias under `aliases` has an empty name" + ), + AliasWildcard { alias } => msg!( + "config.alias_wildcard", alias = alias => + "the alias name `{alias}` contains * or ?. An alias is one exact name; to match \ + several models, write the pattern in a key's allow or a rule's when.model" + ), + AliasReserved { alias } => msg!( + "config.alias_reserved", alias = alias => + "the alias name `{alias}` starts with __, which is reserved for built-ins. Use a \ + different name" + ), + AliasDuplicate { alias } => msg!( + "config.alias_duplicate", alias = alias => + "the alias `{alias}` appears twice" + ), + AliasNoModels { alias } => msg!( + "config.alias_no_models", alias = alias => + "alias `{alias}` lists no model. List the name each upstream uses for the model, \ + or remove the alias" + ), + AliasBlankModel { alias } => msg!( + "config.alias_blank_model", alias = alias => + "alias `{alias}` lists an empty model name" + ), + AliasChained { alias, model } => msg!( + "config.alias_chained", alias = alias, model = model => + "alias `{alias}` lists `{model}`, which is itself an alias. An alias lists the \ + names upstreams use, not other aliases" + ), + AliasOnlyItself { alias } => msg!( + "config.alias_only_itself", alias = alias => + "alias `{alias}` lists only itself, so it changes nothing. List the names the \ + upstreams use, or remove the alias" + ), } } } @@ -370,6 +423,8 @@ pub fn validate(cfg: &Config) -> Result<(), ValidationError> { } } + check_aliases(&cfg.aliases)?; + // provider 和组不能同名。`to: x` 指向哪个会变成一个靠实现顺序决定 // 的问题 —— 而那种问题在换一个人读代码的时候就会变成 bug。 let group_names: std::collections::HashSet<&str> = @@ -459,6 +514,51 @@ pub fn validate(cfg: &Config) -> Result<(), ValidationError> { Ok(()) } +/// 别名表写得对不对,和整份配置的校验是同一套(界面预览一个别名时也用它)。 +/// **模型名在不在上游清单里不在这里查**:清单是运行时问来的, +/// 上游一时没列出来不该让整份配置不收。 +pub fn check_aliases(aliases: &crate::Aliases) -> Result<(), ValidationError> { + let mut seen = std::collections::HashSet::new(); + for a in aliases.iter() { + let alias = || a.name.clone(); + if a.name.trim().is_empty() { + return Err(ValidationError::AliasEmptyName); + } + // 别名没有通配:`claude-*` 写成别名,读的人会以为它匹配一批模型 + if a.name.contains(['*', '?']) { + return Err(ValidationError::AliasWildcard { alias: alias() }); + } + if a.name.starts_with(tw_engine::RESERVED_PREFIX) { + return Err(ValidationError::AliasReserved { alias: alias() }); + } + if !seen.insert(a.name.as_str()) { + return Err(ValidationError::AliasDuplicate { alias: alias() }); + } + if a.models.is_empty() { + return Err(ValidationError::AliasNoModels { alias: alias() }); + } + if a.models.iter().any(|m| m.trim().is_empty()) { + return Err(ValidationError::AliasBlankModel { alias: alias() }); + } + // **别名不能指向别名**:指向了,那个名字在请求里是别名、在这里却要当上游的 + // 名称发出去,两种读法只能有一种。列着自己的名字可以 —— 那是某家上游的真名 + if let Some(m) = a + .models + .iter() + .find(|m| **m != a.name && aliases.contains(m)) + { + return Err(ValidationError::AliasChained { + alias: alias(), + model: m.clone(), + }); + } + if a.models.iter().all(|m| *m == a.name) { + return Err(ValidationError::AliasOnlyItself { alias: alias() }); + } + } + Ok(()) +} + /// 最小的 CIDR 形状校验。**真正的匹配逻辑在 tw-gateway::access** —— /// 这里只是不想让 tw-config 依赖数据面,而「这条写法对不对」是配置层 /// 该回答的问题。 @@ -828,6 +928,50 @@ mod tests { } } + /// 别名表:名字一个一个、不带通配、不撞内置前缀,每个别名列着别的名称, + /// 不指向别的别名。 + #[test] + fn aliases_are_checked_one_rule_at_a_time() { + let with = |yaml: &str| { + let mut x = cfg(vec![c("d", "tw-1")], vec![]); + x.aliases = serde_yaml_ng::from_str(yaml).unwrap(); + validate(&x) + }; + let ok = "deepseek-v4.1: DeepSeek-v4.1-flash\nclaude-sonnet-5: [claude-sonnet-5, us.anthropic.claude-sonnet-5-v1:0]\n"; + assert!(with(ok).is_ok(), "{:?}", with(ok)); + let code = |yaml: &str| with(yaml).unwrap_err().msg().code; + assert_eq!(code("'': x\n"), "config.alias_empty_name"); + assert_eq!(code("'claude-*': x\n"), "config.alias_wildcard"); + assert_eq!(code("gpt-?: x\n"), "config.alias_wildcard"); + assert_eq!(code("__x: y\n"), "config.alias_reserved"); + assert_eq!(code("x: []\n"), "config.alias_no_models"); + assert_eq!(code("x:\n"), "config.alias_no_models"); + assert_eq!(code("x: [a, '']\n"), "config.alias_blank_model"); + assert_eq!(code("x: [a, y]\ny: b\n"), "config.alias_chained"); + assert_eq!(code("x: x\n"), "config.alias_only_itself"); + let e = with("x: [a, y]\ny: b\n").unwrap_err(); + assert!(e.to_string().contains("`y`"), "{e}"); + // 重名:YAML 读得进来的话(界面交来的、或者解析器不拦)也要拒 + let mut x = cfg(vec![c("d", "tw-1")], vec![]); + x.aliases = crate::Aliases(vec![ + crate::Alias { + name: "x".into(), + models: vec!["a".into()], + }, + crate::Alias { + name: "x".into(), + models: vec!["b".into()], + }, + ]); + assert_eq!( + validate(&x).unwrap_err().msg().code, + "config.alias_duplicate" + ); + // 文件里写了两遍同一个别名 + let text = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\naliases:\n x: a\n x: b\n"; + assert!(crate::try_parse(text).is_err()); + } + /// 按 id 写到的内置规则得真的存在:写错的 id 和写错的字段名一样拒绝, /// 说出是哪一项下的哪个 id。 #[test] @@ -1057,6 +1201,17 @@ mod msg_codes { guard: Guard::Redact, id: "x".into(), }), + AliasEmptyName, + AliasWildcard { alias: "a".into() }, + AliasReserved { alias: "a".into() }, + AliasDuplicate { alias: "a".into() }, + AliasNoModels { alias: "a".into() }, + AliasBlankModel { alias: "a".into() }, + AliasChained { + alias: "a".into(), + model: "b".into(), + }, + AliasOnlyItself { alias: "a".into() }, ]; check( "config.", diff --git a/crates/tw-config/tests/manual.rs b/crates/tw-config/tests/manual.rs index 649208f0..a2966496 100644 --- a/crates/tw-config/tests/manual.rs +++ b/crates/tw-config/tests/manual.rs @@ -176,6 +176,10 @@ pub enum Kind { Objs(&'static str), /// 键 → 对象,见那一节 ObjMap(T2, &'static str), + /// 键 → 一个字符串或一组字符串 + OneOrManyMap(T2), + /// 一个字符串,或者一组对象(见那一节) + StrOrObjs(&'static str), } #[derive(Clone, Copy)] @@ -298,6 +302,17 @@ fn kind(k: &Kind, l: Lang) -> String { Kind::ObjMap(key, p) => { format!("{} {} → {}", pick("map of", "映射:"), key.get(l), link(p)) } + Kind::OneOrManyMap(key) => format!( + "{} {} → {}", + pick("map of", "映射:"), + key.get(l), + pick("string or list of strings", "字符串或字符串列表") + ), + Kind::StrOrObjs(p) => format!( + "{} {}", + pick("string, or list of", "字符串,或对象列表,见"), + link(p) + ), } } @@ -342,7 +357,7 @@ pub fn render_table(s: &Section, l: Lang) -> String { fn check_section(s: &Section, all: &[Section], errs: &mut Vec) { for r in &s.rows { match r.kind { - Kind::Obj(p) | Kind::Objs(p) | Kind::ObjMap(_, p) => { + Kind::Obj(p) | Kind::Objs(p) | Kind::ObjMap(_, p) | Kind::StrOrObjs(p) => { if !all.iter().any(|x| x.path == p) { errs.push(format!( "{}.{} points at section `{p}`, which is not declared", @@ -374,7 +389,7 @@ fn check_section(s: &Section, all: &[Section], errs: &mut Vec) { // 指向一节还没进代码的对象的那一行,同样还没进代码:它由那一节的 // `Ty::Pending` 看着,这里不数它 let pending = |r: &Row| match r.kind { - Kind::Obj(p) | Kind::Objs(p) | Kind::ObjMap(_, p) => all + Kind::Obj(p) | Kind::Objs(p) | Kind::ObjMap(_, p) | Kind::StrOrObjs(p) => all .iter() .any(|x| x.path == p && matches!(x.ty, Ty::Pending { .. })), _ => false, diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index ead7bf14..d31c45e8 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -11,7 +11,7 @@ use super::{Def, Kind, Lang, Row, Section, T2}; use tw_config::proxy::ProxyAuth; use tw_config::*; use tw_engine::rule::When; -use tw_engine::{Group, GroupType, RouteSet, Rule, SetAction}; +use tw_engine::{Group, GroupType, Pinned, RouteSet, Rule, SetAction}; use tw_pricing::{PerMillion, PricingConfig, SheetDef}; const fn t(en: &'static str, zh: &'static str) -> T2 { @@ -180,6 +180,15 @@ pub fn sections() -> Vec
{ "上游失败后停用多久,以及流式回答的开头最多等多久。", ), ), + row( + "aliases", + Kind::OneOrManyMap(t("alias", "别名")), + Def::Is("{}"), + t( + "Model aliases: one name for the same model across upstreams, mapped to the name each upstream uses, in order. A request for an alias goes to any upstream offering one of the listed names, under the first of them it offers. An alias cannot list another alias.", + "模型别名:同一个模型在各上游的名称合用一个名字,按顺序列出各上游的名称。请求别名时,提供其中任一名称的上游都能服务,发给它的是列表中它提供的第一个名称。别名不能列出别的别名。", + ), + ), row( "groups", Kind::Objs("groups[]"), @@ -1288,11 +1297,11 @@ pub fn sections() -> Vec
{ ), row( "to", - Kind::Str, + Kind::StrOrObjs("routes[].rules[].to[]"), Def::Unset, t( - "An upstream or a group, by name; `__all__` is every upstream in declared order. Not allowed together with `when.provider_would_be`.", - "上游或策略组的名字;`__all__` 表示按声明顺序的全部上游。不能与 `when.provider_would_be` 同时写。", + "An upstream or a group, by name; `__all__` is every upstream in declared order. Or pinned models: a list of upstreams with the model to send to each, tried in order. Not allowed together with `when.provider_would_be`.", + "上游或策略组的名字;`__all__` 表示按声明顺序的全部上游。也可以指定模型:列出上游及发给它的模型,按顺序备用。不能与 `when.provider_would_be` 同时写。", ), ), row( @@ -1315,6 +1324,30 @@ pub fn sections() -> Vec
{ ), ], }, + Section { + path: "routes[].rules[].to[]", + ty: checked!(Pinned, "{provider: a, model: m}"), + rows: vec![ + row( + "provider", + Kind::Str, + Def::Required, + t( + "An upstream, by name; not a group. Each upstream appears once in the list.", + "上游的名字,不能是策略组。同一个上游在列表中只出现一次。", + ), + ), + row( + "model", + Kind::Str, + Def::Required, + t( + "The model name sent to that upstream, as written: aliases do not apply, and no `set.model` changes it. Not allowed together with `set.model` in the same rule.", + "发给这个上游的模型名,原样发出:不经过别名,也不受 `set.model` 改写。同一条规则里不能再写 `set.model`。", + ), + ), + ], + }, Section { path: "routes[].rules[].when", ty: checked!(When, "{}"), diff --git a/crates/tw-control/src/routes.rs b/crates/tw-control/src/routes.rs index 2d2098a1..3d2e683d 100644 --- a/crates/tw-control/src/routes.rs +++ b/crates/tw-control/src/routes.rs @@ -262,7 +262,7 @@ pub(crate) fn to_rule(input: &tw_api::RuleInput, cfg: &tw_config::Config) -> Res .as_deref() .map(str::trim) .filter(|t| !t.is_empty()) - .map(str::to_string); + .map(tw_engine::Target::from); let deny = match input.deny.as_deref().map(str::trim) { Some("") => { return Err(msg!( @@ -635,7 +635,8 @@ pub(crate) fn rule_view(r: &Rule, n: tw_engine::RuleNotes) -> tw_api::RuleView { tw_api::RuleView { name: r.name.clone(), conditions: crate::describe_when(&r.when), - to: r.to.clone(), + // 指定模型在这一版的视图里还没有写法(协议 38 起 `to` 才能是列表),先不给 + to: r.to.as_ref().and_then(|t| t.name()).map(str::to_string), deny: r.deny.clone(), set: r .set diff --git a/crates/tw-control/tests/routes.rs b/crates/tw-control/tests/routes.rs index 829c2eb7..7998b901 100644 --- a/crates/tw-control/tests/routes.rs +++ b/crates/tw-control/tests/routes.rs @@ -457,7 +457,7 @@ async fn changing_the_default_keeps_the_synthesised_one_as_an_ordinary_route() { .iter() .find(|r| r.name == "default") .expect("「默认」留下来"); - assert_eq!(kept.rules[0].to.as_deref(), Some("__all__")); + assert_eq!(kept.rules[0].to, Some("__all__".into())); // 换回来:默认值不写进文件 let (st, v) = call( @@ -503,7 +503,7 @@ async fn renaming_a_group_moves_the_rules_that_forward_to_it() { .await; assert_eq!(st, StatusCode::OK, "{v}"); let cfg = b.parsed(); - assert_eq!(cfg.routes[0].rules[1].to.as_deref(), Some("主力组")); + assert_eq!(cfg.routes[0].rules[1].to, Some("主力组".into())); assert_eq!(cfg.groups[0].selected.as_deref(), Some("中转")); } diff --git a/crates/tw-engine/src/engine.rs b/crates/tw-engine/src/engine.rs index f4ab7776..045b6d63 100644 --- a/crates/tw-engine/src/engine.rs +++ b/crates/tw-engine/src/engine.rs @@ -204,11 +204,12 @@ pub struct Rule { #[serde(default, skip_serializing_if = "When::is_catch_all")] pub when: When, /// **可以直接指 provider,不需要先建组**(层 1)。大多数分流 - /// 需求到这一层就解决了,不必引入策略组这个概念。 + /// 需求到这一层就解决了,不必引入策略组这个概念。也可以指定模型: + /// 「上游 + 模型」的列表,见 [`Target`]。 /// /// 阶段二的规则(含 `provider_would_be`)**不允许写它** —— 那会成环。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub to: Option, + pub to: Option, /// 改写请求参数。**从所有命中的规则累积**,不只是第一条。 #[serde(default, skip_serializing_if = "Option::is_none")] pub set: Option, @@ -217,6 +218,103 @@ pub struct Rule { pub deny: Option, } +/// 规则转发到哪里。 +/// +/// ```yaml +/// to: 官方 # 上游或策略组的名字;__all__ 是全部上游 +/// to: # 指定模型:按顺序备用 +/// - { provider: bedrock, model: us.anthropic.claude-opus-5-v1:0 } +/// - { provider: anthropic, model: claude-opus-5 } +/// ``` +/// +/// **指定模型的模型名原样发出**:不经过别名表,阶段二的改写和 `set.model` 都改不动它 +/// —— 写下的就是那一家收到的。候选就是列出来的上游,按列表顺序故障转移,不经过策略组 +/// 排序。 +/// +/// 读的时候一个字符串是名字、一个列表是指定模型(和 serde 的 untagged 同一种写法), +/// 只是手写了反序列化:untagged 对写错的地方只会说「哪一种都不像」,而 `modle` 这种 +/// 拼错要说出是哪个字段。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(untagged)] +pub enum Target { + /// 上游或策略组的名字 + Name(String), + /// 指定模型:「上游 + 模型」,按顺序备用 + Models(Vec), +} + +/// 指定模型里的一项:发给哪一家、发什么模型名。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Pinned { + pub provider: String, + pub model: String, +} + +impl Target { + /// 上游或策略组的名字。指定模型时是 `None` + pub fn name(&self) -> Option<&str> { + match self { + Target::Name(n) => Some(n), + Target::Models(_) => None, + } + } + + /// 指定的模型。去向是名字时是 `None` + pub fn pinned(&self) -> Option<&[Pinned]> { + match self { + Target::Name(_) => None, + Target::Models(m) => Some(m), + } + } + + /// 这个去向用到了上游 `provider`:就是它的名字,或者指定模型里有它 + pub fn mentions_provider(&self, provider: &str) -> bool { + match self { + Target::Name(n) => n == provider, + Target::Models(m) => m.iter().any(|p| p.provider == provider), + } + } +} + +impl From<&str> for Target { + fn from(name: &str) -> Self { + Target::Name(name.to_string()) + } +} + +impl From for Target { + fn from(name: String) -> Self { + Target::Name(name) + } +} + +impl<'de> Deserialize<'de> for Target { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Target; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str( + "the name of an upstream or a group, or a list of {provider, model} entries", + ) + } + fn visit_str(self, v: &str) -> Result { + Ok(Target::Name(v.to_string())) + } + fn visit_string(self, v: String) -> Result { + Ok(Target::Name(v)) + } + fn visit_seq>(self, seq: A) -> Result { + // 列表里每一项照 `Pinned` 自己的规矩读:写错的字段名由 serde 说出来 + Vec::::deserialize(serde::de::value::SeqAccessDeserializer::new(seq)) + .map(Target::Models) + } + } + d.deserialize_any(V) + } +} + impl Rule { /// 命中就决定去向:转发或拒绝。 pub fn decides(&self) -> bool { @@ -353,6 +451,22 @@ pub struct Decision { /// 用户头一个要问的就是哪条规则干的;一条改写规则从来没命中过(条件写错了), /// 也只有这样数得出来 pub rewritten_by: Vec, + /// 指定模型:每个候选要的模型,按规则里列的顺序(就是 `candidates` 起初的顺序)。 + /// 去向是上游或策略组的名字时是空的。 + /// + /// **按上游名查,不按下标对**([`Decision::pinned_model`]):候选之后会被筛掉、 + /// 被会话粘性挪到前面,和这张表的下标早就对不上了。 + pub pinned: Vec, +} + +impl Decision { + /// 指定模型时这一家要发的模型名,原样发出。不是指定模型、或者没有这一家时是 `None` + pub fn pinned_model(&self, provider: &str) -> Option<&str> { + self.pinned + .iter() + .find(|p| p.provider == provider) + .map(|p| p.model.as_str()) + } } /// 阶段一结束时可能是「不让干」。 @@ -405,6 +519,18 @@ pub enum RouteError { UnknownDefaultRoute(String), #[error("{}", self.msg())] UnknownRoute { client: String, route: String }, + #[error("{}", self.msg())] + PinnedEmpty(String), + #[error("{}", self.msg())] + PinnedUnknownProvider { rule: String, provider: String }, + #[error("{}", self.msg())] + PinnedDuplicateProvider { rule: String, provider: String }, + #[error("{}", self.msg())] + PinnedBlankModel { rule: String, provider: String }, + #[error("{}", self.msg())] + PinnedWithProviderWouldBe(String), + #[error("{}", self.msg())] + PinnedWithSetModel(String), #[error(transparent)] Match(#[from] MatchError), } @@ -453,11 +579,44 @@ impl RouteError { "engine.unknown_route", key = client, route = route => "gateway key `{key}` binds to route `{route}`, which does not exist" ), + RouteError::PinnedEmpty(rule) => msg!( + "engine.pinned_empty", rule = rule => + "rule `{rule}` pins models, and the list is empty. List at least one upstream \ + and model, or forward to an upstream or a group" + ), + RouteError::PinnedUnknownProvider { rule, provider } => msg!( + "engine.pinned_unknown_provider", rule = rule, upstream = provider => + "rule `{rule}` pins a model on `{upstream}`, which is not an upstream. A pinned \ + model names an upstream, not a group" + ), + RouteError::PinnedDuplicateProvider { rule, provider } => msg!( + "engine.pinned_duplicate_provider", rule = rule, upstream = provider => + "rule `{rule}` pins models on upstream `{upstream}` twice. Each upstream appears \ + once in the list" + ), + RouteError::PinnedBlankModel { rule, provider } => msg!( + "engine.pinned_blank_model", rule = rule, upstream = provider => + "rule `{rule}` pins an empty model name on upstream `{upstream}`" + ), + RouteError::PinnedWithProviderWouldBe(rule) => msg!( + "engine.pinned_with_provider_would_be", rule = rule => + "rule `{rule}` pins models and also tests provider_would_be. provider_would_be is \ + evaluated once an upstream is chosen, and the pinned list already chooses the \ + upstreams" + ), + RouteError::PinnedWithSetModel(rule) => msg!( + "engine.pinned_with_set_model", rule = rule => + "rule `{rule}` pins models and also sets the model. A pinned model is sent as \ + written, so set.model would do nothing; remove one of them" + ), RouteError::Match(e) => e.msg(), } } } +/// 规则的去向解出来的样子:候选、经过的策略组、指定的模型(见 [`Decision`])。 +type Resolved = (Vec, Option, Vec); + pub struct Engine { sets: Vec, /// 没绑定时走哪条。 @@ -523,7 +682,7 @@ impl Engine { let fallback = Rule { name: CATCH_ALL_RULE.to_string(), when: When::default(), - to: Some(ALL_UPSTREAMS.to_string()), + to: Some(Target::Name(ALL_UPSTREAMS.to_string())), set: None, deny: None, }; @@ -638,6 +797,11 @@ impl Engine { pub fn check_rules(&self, rules: &[Rule]) -> Result<(), RouteError> { for r in rules { r.when.validate()?; + // 指定模型的几条单独说:它和阶段二、和 `set.model` 同用时,泛泛的那句 + // 说不清为什么不行 + if let Some(pinned) = r.to.as_ref().and_then(Target::pinned) { + self.check_pinned(r, pinned)?; + } // **这条禁令是必需的**:允许阶段二的规则写 `to`,求值就直接 // 成环了。在校验阶段挡下来,而不是在运行时。 if r.when.is_phase_two() && r.to.is_some() { @@ -653,6 +817,45 @@ impl Engine { Ok(()) } + /// 指定模型写得对不对:不空、每一家是存在的上游且只出现一次、模型名不空; + /// 不和 `provider_would_be`、`set.model` 同用。 + fn check_pinned(&self, r: &Rule, pinned: &[Pinned]) -> Result<(), RouteError> { + if r.when.is_phase_two() { + return Err(RouteError::PinnedWithProviderWouldBe(r.name.clone())); + } + if r.set.as_ref().is_some_and(|s| s.model.is_some()) { + return Err(RouteError::PinnedWithSetModel(r.name.clone())); + } + if pinned.is_empty() { + return Err(RouteError::PinnedEmpty(r.name.clone())); + } + let mut seen = std::collections::HashSet::new(); + for p in pinned { + // **只认上游,不认策略组**:组里挑哪一家由策略定,而指定模型说的是 + // 「这一家发这个名字」 + if !self.providers.contains(&p.provider) { + return Err(RouteError::PinnedUnknownProvider { + rule: r.name.clone(), + provider: p.provider.clone(), + }); + } + // 同一家写两次:候选按上游名排,第二次永远轮不到 + if !seen.insert(p.provider.as_str()) { + return Err(RouteError::PinnedDuplicateProvider { + rule: r.name.clone(), + provider: p.provider.clone(), + }); + } + if p.model.trim().is_empty() { + return Err(RouteError::PinnedBlankModel { + rule: r.name.clone(), + provider: p.provider.clone(), + }); + } + } + Ok(()) + } + /// 阶段一:按请求性质选出候选 provider。 /// /// **自上而下,首个命中决定 `to` 和 `deny`;`set` 从所有命中的规则 @@ -696,13 +899,14 @@ impl Engine { reason: reason.clone(), }); } - let (candidates, via_group) = self.resolve_target(r)?; + let (candidates, via_group, pinned) = self.resolve_target(r)?; Ok(Outcome::Route(Decision { candidates, matched_rule: r.name.clone(), via_group, set, rewritten_by, + pinned, })) } @@ -758,6 +962,8 @@ impl Engine { /// /// 阶段二拒绝或求不了值时按阶段一算:那一跳本来就发不出去,尝试那一步会 /// 报出来。请求里读不出模型时一律是空的 —— 请求体都解不开,改写也写不进去。 + /// + /// **指定模型的候选要的就是指定的那个**,规则改写盖不过它。 pub fn models_asked( &self, rules: &[Rule], @@ -769,6 +975,8 @@ impl Engine { .map(|c| { let model = if facts.model.is_empty() { String::new() + } else if let Some(m) = d.pinned_model(c) { + m.to_string() } else { let set = match self.phase_two_with(rules, facts, c, &d.set) { Ok(Outcome2::Proceed { set, .. }) => set.model, @@ -781,17 +989,25 @@ impl Engine { .collect() } - fn resolve_target(&self, r: &Rule) -> Result<(Vec, Option), RouteError> { - let Some(to) = &r.to else { - return Ok((Vec::new(), None)); + /// 规则的去向 → 候选、经过的策略组、指定的模型。 + fn resolve_target(&self, r: &Rule) -> Result { + let to = match &r.to { + None => return Ok((Vec::new(), None, Vec::new())), + // 指定模型:候选就是列出来的上游,按列表的顺序。**不经过策略组**, + // 所以也不按策略排序 + Some(Target::Models(pinned)) => { + let candidates = pinned.iter().map(|p| p.provider.clone()).collect(); + return Ok((candidates, None, pinned.clone())); + } + Some(Target::Name(to)) => to, }; // provider 优先于组。同名时按 provider 解释 —— 而校验会挡住 // 同名的情况,所以这个优先级实际上不会被用到。 if self.providers.iter().any(|p| p == to) { - return Ok((vec![to.clone()], None)); + return Ok((vec![to.clone()], None, Vec::new())); } if let Some(g) = self.groups.iter().find(|g| g.name == *to) { - return Ok((self.expand_group(g), Some(g.name.clone()))); + return Ok((self.expand_group(g), Some(g.name.clone()), Vec::new())); } Err(RouteError::UnknownTarget { rule: r.name.clone(), @@ -1234,7 +1450,7 @@ mod tests { Rule { name: name.into(), when: serde_yaml_ng::from_str(when_yaml).unwrap(), - to: to.map(String::from), + to: to.map(Target::from), set, deny: deny.map(String::from), } @@ -1872,6 +2088,179 @@ mod builtin_tests { } } +#[cfg(test)] +mod pinned_tests { + use super::*; + + fn rule(yaml: &str) -> Rule { + serde_yaml_ng::from_str(yaml).unwrap() + } + + fn engine(rules: Vec) -> Engine { + Engine::with_default_rules( + vec!["bedrock".into(), "anthropic".into(), "relay".into()], + vec![Group { + name: "pool".into(), + kind: GroupType::LoadBalance, + providers: vec!["relay".into(), "anthropic".into()], + selected: None, + }], + rules, + ) + } + + fn facts(model: &str) -> RequestFacts { + RequestFacts { + model: model.into(), + client: "claude-code".into(), + dialect: "anthropic".into(), + ..Default::default() + } + } + + const PINNED: &str = "name: Opus 走 Bedrock +when: { model: claude-opus-5 } +to: + - { provider: bedrock, model: us.anthropic.claude-opus-5-v1:0 } + - { provider: anthropic, model: claude-opus-5 } +"; + + /// 一个字符串是名字,一个列表是指定模型;写回去还是原来的样子 + #[test] + fn a_target_reads_and_writes_both_forms() { + let named = rule("{name: r, to: __all__}"); + assert_eq!(named.to, Some(Target::Name("__all__".into()))); + let pinned = rule(PINNED); + assert_eq!( + pinned.to, + Some(Target::Models(vec![ + Pinned { + provider: "bedrock".into(), + model: "us.anthropic.claude-opus-5-v1:0".into(), + }, + Pinned { + provider: "anthropic".into(), + model: "claude-opus-5".into(), + }, + ])) + ); + for r in [named, pinned] { + let yaml = serde_yaml_ng::to_string(&r).unwrap(); + let back: Rule = serde_yaml_ng::from_str(&yaml).unwrap(); + assert_eq!(back.to, r.to, "{yaml}"); + // 界面那边是 JSON,同一种写法 + let json = serde_json::to_value(&r.to).unwrap(); + let back: Option = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(back, r.to, "{json}"); + } + assert_eq!( + serde_json::to_value(Target::from("官方")).unwrap(), + serde_json::json!("官方") + ); + assert_eq!( + serde_json::to_value(&rule(PINNED).to).unwrap(), + serde_json::json!([ + {"provider": "bedrock", "model": "us.anthropic.claude-opus-5-v1:0"}, + {"provider": "anthropic", "model": "claude-opus-5"}, + ]) + ); + } + + /// 拼错的字段要说出是哪个,而不是「哪一种都不像」 + #[test] + fn a_typo_in_a_pinned_entry_names_the_field() { + let e = + serde_yaml_ng::from_str::("name: r\nto:\n - { provider: bedrock, modle: x }\n") + .unwrap_err() + .to_string(); + assert!(e.contains("modle"), "{e}"); + assert!(e.contains("model"), "没说对的写法:{e}"); + let e = serde_yaml_ng::from_str::("name: r\nto: { provider: a, model: m }\n") + .unwrap_err() + .to_string(); + assert!(e.contains("list of {provider, model}"), "{e}"); + } + + /// 候选就是列出来的上游,按列表顺序;每一家要的就是指定的那个,规则改写盖不过它 + #[test] + fn pinned_models_are_the_candidates_in_order_and_are_asked_as_written() { + let rewrite = rule("{name: 改名, set: {model: claude-sonnet-5}}"); + let later = rule("{name: 阶段二, when: {provider_would_be: anthropic}, set: {model: x}}"); + let rules = vec![rewrite, rule(PINNED), rule("{name: 兜底, to: pool}"), later]; + let e = engine(rules.clone()); + assert!(e.validate().is_ok(), "{:?}", e.validate()); + let Outcome::Route(d) = e.route(&facts("claude-opus-5")).unwrap() else { + panic!("该路由"); + }; + assert_eq!(d.candidates, ["bedrock", "anthropic"]); + assert_eq!(d.via_group, None, "指定模型不经过策略组"); + assert_eq!( + d.pinned_model("bedrock"), + Some("us.anthropic.claude-opus-5-v1:0") + ); + assert_eq!(d.pinned_model("relay"), None); + assert_eq!( + e.models_asked(&rules, &facts("claude-opus-5"), &d), + [ + ( + "bedrock".to_string(), + "us.anthropic.claude-opus-5-v1:0".to_string() + ), + ("anthropic".to_string(), "claude-opus-5".to_string()), + ] + ); + // 别的模型照旧走组 + let Outcome::Route(d) = e.route(&facts("claude-sonnet-5")).unwrap() else { + panic!("该路由"); + }; + assert!(d.pinned.is_empty()); + assert_eq!(d.via_group.as_deref(), Some("pool")); + assert_eq!(d.pinned_model("relay"), None); + } + + #[test] + fn a_pinned_list_is_checked_where_it_is_written() { + let check = |yaml: &str| engine(vec![rule(yaml)]).validate(); + assert_eq!( + check("{name: r, to: []}"), + Err(RouteError::PinnedEmpty("r".into())) + ); + assert_eq!( + check("{name: r, to: [{provider: pool, model: m}]}"), + Err(RouteError::PinnedUnknownProvider { + rule: "r".into(), + provider: "pool".into() + }), + "组不是上游" + ); + assert_eq!( + check("{name: r, to: [{provider: relay, model: a}, {provider: relay, model: b}]}"), + Err(RouteError::PinnedDuplicateProvider { + rule: "r".into(), + provider: "relay".into() + }) + ); + assert_eq!( + check("{name: r, to: [{provider: relay, model: ' '}]}"), + Err(RouteError::PinnedBlankModel { + rule: "r".into(), + provider: "relay".into() + }) + ); + assert_eq!( + check("{name: r, when: {provider_would_be: relay}, to: [{provider: relay, model: m}]}"), + Err(RouteError::PinnedWithProviderWouldBe("r".into())) + ); + assert_eq!( + check("{name: r, set: {model: x}, to: [{provider: relay, model: m}]}"), + Err(RouteError::PinnedWithSetModel("r".into())) + ); + assert!( + check("{name: r, set: {max_tokens: 10}, to: [{provider: relay, model: m}]}").is_ok() + ); + } +} + #[cfg(test)] mod msg_codes { use super::*; @@ -1901,6 +2290,21 @@ mod msg_codes { client: "k".into(), route: "x".into(), }, + RouteError::PinnedEmpty("r".into()), + RouteError::PinnedUnknownProvider { + rule: "r".into(), + provider: "p".into(), + }, + RouteError::PinnedDuplicateProvider { + rule: "r".into(), + provider: "p".into(), + }, + RouteError::PinnedBlankModel { + rule: "r".into(), + provider: "p".into(), + }, + RouteError::PinnedWithProviderWouldBe("r".into()), + RouteError::PinnedWithSetModel("r".into()), bad(ParseError::Empty), bad(ParseError::NoOperator("200k".into())), bad(ParseError::BadNumber(">x".into())), diff --git a/crates/tw-engine/src/lib.rs b/crates/tw-engine/src/lib.rs index 9bfa3d05..35888538 100644 --- a/crates/tw-engine/src/lib.rs +++ b/crates/tw-engine/src/lib.rs @@ -7,7 +7,7 @@ pub mod rule; pub use catalog::{Catalog, ProviderModels}; pub use engine::{ ALL_UPSTREAMS, CATCH_ALL_RULE, DEFAULT_ROUTE, Decision, Engine, Facts, Group, GroupType, - Outcome, Outcome2, RESERVED_PREFIX, RouteError, RouteSet, Rule, RuleNotes, SetAction, - has_catch_all, is_builtin_group, notes, order_by, + Outcome, Outcome2, Pinned, RESERVED_PREFIX, RouteError, RouteSet, Rule, RuleNotes, SetAction, + Target, has_catch_all, is_builtin_group, notes, order_by, }; pub use facts::{RequestFacts, estimate_strings, estimate_tokens}; diff --git a/crates/tw-engine/tests/budget.rs b/crates/tw-engine/tests/budget.rs index a274644c..c6195a45 100644 --- a/crates/tw-engine/tests/budget.rs +++ b/crates/tw-engine/tests/budget.rs @@ -23,7 +23,7 @@ fn heavy() -> Engine { model, ..Default::default() }, - to: Some(to), + to: Some(to.into()), set: None, deny: None, }; diff --git a/crates/tw-gateway/src/affinity.rs b/crates/tw-gateway/src/affinity.rs index d1ec9d98..7a3384a4 100644 --- a/crates/tw-gateway/src/affinity.rs +++ b/crates/tw-gateway/src/affinity.rs @@ -343,6 +343,7 @@ mod tests { via_group: Some("池子".into()), set: Default::default(), rewritten_by: vec![], + pinned: vec![], } } diff --git a/crates/tw-gateway/src/models.rs b/crates/tw-gateway/src/models.rs index 0687c766..4d3be218 100644 --- a/crates/tw-gateway/src/models.rs +++ b/crates/tw-gateway/src/models.rs @@ -632,6 +632,34 @@ pub fn fit(catalog: &tw_engine::Catalog, p: &tw_config::Provider, model: &str) - } } +/// 发给上游 `p` 时,客户端说的 `name` 叫什么。`None` = 这家服务不了这个别名。 +/// +/// - `name` 是别名:按列表顺序取第一个**这家能服务**的名称 —— 在它的启用范围里 +/// (`models_only`),清单里也没说没有(没有清单的上游当作有,取第一个在启用 +/// 范围里的)。一个都没有就是 `None`。 +/// - 不是别名:就是 `name` 本身。这家有没有它不在这里判断,那是 [`fit`] 的事。 +/// +/// **别名优先**:`name` 是别名时,这家恰好有一个同名的真模型、却没列进别名, +/// 也不发这个名字给它。 +/// +/// **不看停用**,和 [`fit`] 一样:停用是路由的事。指定模型(规则的 `to` 列出的 +/// 「上游 + 模型」)不经过这里,那个名字原样发出。 +pub fn resolve( + cfg: &tw_config::Config, + catalog: &tw_engine::Catalog, + p: &tw_config::Provider, + name: &str, +) -> Option { + let Some(alias) = cfg.aliases.find(name) else { + return Some(name.to_string()); + }; + alias + .models + .iter() + .find(|m| p.uses_model(m) && catalog.offers(&p.name, m) != Some(false)) + .cloned() +} + /// 在路由选出的候选里去掉服务不了这个请求的上游。 /// /// `asked` 是每个候选和它要的模型([`tw_engine::Engine::models_asked`]): @@ -743,6 +771,75 @@ mod tests { assert!(d.lock().get("a").is_none()); } + /// 别名对到每一家:按列表顺序取它能服务的第一个名称;范围外的、清单里没有的跳过; + /// 没有清单的当作有。不是别名的就是它自己 + #[test] + fn an_alias_resolves_to_the_first_listed_name_each_upstream_can_serve() { + let mut c = cfg(vec![ + provider("bedrock"), + provider("anthropic"), + provider("relay"), + provider("no-list"), + provider("scoped"), + ]); + c.providers[4].models_only = Some(vec!["anthropic/*".into()]); + c.aliases = serde_yaml_ng::from_str( + "claude-sonnet-5: [claude-sonnet-5, us.anthropic.claude-sonnet-5-v1:0, anthropic/claude-sonnet-5]\n\ + gpt-x: gpt-x-2026\n", + ) + .unwrap(); + let catalog = tw_engine::Catalog::build(&[ + listed( + "bedrock", + &["us.anthropic.claude-sonnet-5-v1:0", "claude-sonnet-5"], + ), + listed("anthropic", &["claude-sonnet-5"]), + // 有同名的真模型,但只有它没列进别名的那个名字 + listed("relay", &["gpt-x", "anthropic/claude-sonnet-5"]), + tw_engine::ProviderModels { + provider: "no-list".into(), + protocol: "anthropic".into(), + known: false, + models: Vec::new(), + }, + tw_engine::ProviderModels { + provider: "scoped".into(), + protocol: "anthropic".into(), + known: false, + models: Vec::new(), + }, + ]); + let r = |p: usize, name: &str| resolve(&c, &catalog, &c.providers[p], name); + // 按列表顺序:bedrock 两个都有,取排在前面的那个 + assert_eq!(r(0, "claude-sonnet-5").as_deref(), Some("claude-sonnet-5")); + assert_eq!(r(1, "claude-sonnet-5").as_deref(), Some("claude-sonnet-5")); + assert_eq!( + r(2, "claude-sonnet-5").as_deref(), + Some("anthropic/claude-sonnet-5") + ); + // 没有清单:当作有,取第一个 + assert_eq!(r(3, "claude-sonnet-5").as_deref(), Some("claude-sonnet-5")); + // 没有清单、有启用范围:取第一个在范围里的 + assert_eq!( + r(4, "claude-sonnet-5").as_deref(), + Some("anthropic/claude-sonnet-5") + ); + // 别名优先:relay 有个叫 gpt-x 的真模型,没列进别名,不发给它 + assert_eq!(r(2, "gpt-x"), None); + assert_eq!(r(1, "gpt-x"), None); + // 不是别名:原样,有没有交给 `fit` + assert_eq!(r(1, "claude-opus-5").as_deref(), Some("claude-opus-5")); + } + + fn listed(p: &str, models: &[&str]) -> tw_engine::ProviderModels { + tw_engine::ProviderModels { + provider: p.into(), + protocol: "anthropic".into(), + known: true, + models: models.iter().map(|m| m.to_string()).collect(), + } + } + #[test] fn an_answer_for_an_upstream_that_changed_meanwhile_is_dropped() { let d = Directory::default(); diff --git a/crates/tw-gateway/src/server/pipeline/hop.rs b/crates/tw-gateway/src/server/pipeline/hop.rs index 7daa5088..a21e9b44 100644 --- a/crates/tw-gateway/src/server/pipeline/hop.rs +++ b/crates/tw-gateway/src/server/pipeline/hop.rs @@ -214,6 +214,10 @@ pub(super) async fn try_upstreams<'a>( } }; + // 指定模型:这一家要的名字原样发出,规则改写(两个阶段的 `set.model`)盖不过它 + if let Some(m) = decision.pinned_model(&provider.name) { + effective_set.model = Some(m.to_string()); + } // 这一跳要发的模型名:规则改写过的是改写之后的 let sent = effective_set .model diff --git a/crates/tw-gateway/tests/models.rs b/crates/tw-gateway/tests/models.rs index ddc01612..ec8a27ef 100644 --- a/crates/tw-gateway/tests/models.rs +++ b/crates/tw-gateway/tests/models.rs @@ -386,3 +386,45 @@ async fn a_rename_to_a_model_nobody_serves_says_both_names() { "{msg}" ); } + +// ─────────────────────────────────────────────────────────── 指定模型 + +#[tokio::test] +async fn pinned_models_go_out_as_written_in_list_order() { + // 同一个模型在两家叫法不同:先 Bedrock、再官方,每一家发它自己的那个名字。 + // 别的规则改写的模型名盖不过指定的 + let bedrock = upstream("bedrock", &["us.anthropic.claude-opus-5-v1:0"]).await; + let official = upstream("official", &["claude-opus-5"]).await; + let mut cfg = config(vec![ + provider("bedrock", bedrock), + provider("official", official), + ]); + let rule = |yaml: &str| -> tw_engine::Rule { serde_yaml_ng::from_str(yaml).unwrap() }; + cfg.routes = vec![tw_engine::RouteSet::default_with(vec![ + rule("{name: 改名, set: {model: glm-5}}"), + rule( + "name: Opus 走 Bedrock +to: + - { provider: bedrock, model: us.anthropic.claude-opus-5-v1:0 } + - { provider: official, model: claude-opus-5 } +", + ), + ])]; + let state = tw_gateway::AppState::new(cfg.clone()).unwrap(); + tw_gateway::models::refresh_all(&state).await; + let gw = serve(state.clone()).await; + + let (status, body) = ask(gw, "claude-opus-5").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["by"], "bedrock"); + assert_eq!(body["model"], "us.anthropic.claude-opus-5-v1:0"); + + // 第一家停用了:下一家,用它自己的名字 + cfg.providers[0].disabled = true; + state.reload(cfg).unwrap(); + tw_gateway::models::refresh_all(&state).await; + let (status, body) = ask(gw, "claude-opus-5").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["by"], "official"); + assert_eq!(body["model"], "claude-opus-5"); +} diff --git a/crates/tw-gateway/tests/passthrough.rs b/crates/tw-gateway/tests/passthrough.rs index 0750d879..5fc45305 100644 --- a/crates/tw-gateway/tests/passthrough.rs +++ b/crates/tw-gateway/tests/passthrough.rs @@ -68,6 +68,7 @@ async fn start_gateway(upstream: SocketAddr) -> SocketAddr { let cfg = Config { retention: Default::default(), failover: Default::default(), + aliases: Default::default(), default_route: None, default_key: None, version: 1, @@ -240,6 +241,7 @@ async fn a_request_emits_the_four_lifecycle_events_in_order() { let cfg = Config { retention: Default::default(), failover: Default::default(), + aliases: Default::default(), default_route: None, default_key: None, version: 1, @@ -346,6 +348,7 @@ async fn an_unreachable_upstream_emits_a_failure_event_and_a_502() { let cfg = Config { retention: Default::default(), failover: Default::default(), + aliases: Default::default(), default_route: None, default_key: None, version: 1, @@ -411,6 +414,7 @@ async fn a_rule_sends_opus_to_one_upstream_and_everything_else_to_another() { let cfg = Config { retention: Default::default(), failover: Default::default(), + aliases: Default::default(), default_route: None, default_key: None, plugins: Vec::new(), @@ -515,6 +519,7 @@ async fn with_no_routes_at_all_requests_still_go_somewhere() { let cfg = Config { retention: Default::default(), failover: Default::default(), + aliases: Default::default(), default_route: None, default_key: None, version: 1, diff --git a/crates/tw-gateway/tests/routing_facts.rs b/crates/tw-gateway/tests/routing_facts.rs index 08c62e7e..2b570a45 100644 --- a/crates/tw-gateway/tests/routing_facts.rs +++ b/crates/tw-gateway/tests/routing_facts.rs @@ -51,7 +51,7 @@ fn rule( Rule { name: name.into(), when: serde_yaml_ng::from_str(when).unwrap(), - to: to.map(str::to_string), + to: to.map(Into::into), set, deny: deny.map(str::to_string), } diff --git a/docs/config.md b/docs/config.md index 3389d827..81f9a551 100644 --- a/docs/config.md +++ b/docs/config.md @@ -172,6 +172,7 @@ means. | `security` | object, [`security`](#cfg-security) | — | The three guards. All of them start in `observe`, so out of the box nothing is changed or refused. | | `retention` | object, [`retention`](#cfg-retention) | — | How long request logs are kept. | | `failover` | object, [`failover`](#cfg-failover) | — | How long an upstream is set aside after it fails, and how long the start of a stream is awaited. | +| `aliases` | map of alias → string or list of strings | `{}` | Model aliases: one name for the same model across upstreams, mapped to the name each upstream uses, in order. A request for an alias goes to any upstream offering one of the listed names, under the first of them it offers. An alias cannot list another alias. | | `groups` | list of [`groups[]`](#cfg-groups) | `[]` | Strategy groups: several upstreams behind one name, with a way to pick among them. | | `routes` | list of [`routes[]`](#cfg-routes) | `[]` | Routes. Without any, requests fail over across all upstreams in the order they are declared. | | `default_route` | string | — | The route for keys that do not name one. Unset: the route named `default`, or the built-in failover when there is none. | @@ -962,11 +963,24 @@ order they are declared. |---|---|---|---| | `name` | string | **required** | Name shown in logs and in the traffic view. | | `when` | object, [`routes[].rules[].when`](#cfg-routes-rules-when) | — | Conditions, all of which have to hold. Unset: matches every request. | -| `to` | string | — | An upstream or a group, by name; `__all__` is every upstream in declared order. Not allowed together with `when.provider_would_be`. | +| `to` | string, or list of [`routes[].rules[].to[]`](#cfg-routes-rules-to) | — | An upstream or a group, by name; `__all__` is every upstream in declared order. Or pinned models: a list of upstreams with the model to send to each, tried in order. Not allowed together with `when.provider_would_be`. | | `set` | object, [`routes[].rules[].set`](#cfg-routes-rules-set) | — | Parameters to rewrite. Collected from every matching rule, not only the first. | | `deny` | string | — | Refuse the request with this reason. | +#### `routes[].rules[].to` + +Pinned models in `to`: each entry is an upstream and the model sent to it, tried in order. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `provider` | string | **required** | An upstream, by name; not a group. Each upstream appears once in the list. | +| `model` | string | **required** | The model name sent to that upstream, as written: aliases do not apply, and no `set.model` changes it. Not allowed together with `set.model` in the same rule. | + + #### `routes[].rules[].when` diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index e7a455cf..8c5462cc 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -106,6 +106,7 @@ twcore config set /listen/gateway/port 8790 --int | `security` | 对象,见 [`security`](#cfg-security) | — | 三项防护。出厂时都处在 `observe`,不改变、不拒绝任何请求。 | | `retention` | 对象,见 [`retention`](#cfg-retention) | — | 请求日志保留多久。 | | `failover` | 对象,见 [`failover`](#cfg-failover) | — | 上游失败后停用多久,以及流式回答的开头最多等多久。 | +| `aliases` | 映射: 别名 → 字符串或字符串列表 | `{}` | 模型别名:同一个模型在各上游的名称合用一个名字,按顺序列出各上游的名称。请求别名时,提供其中任一名称的上游都能服务,发给它的是列表中它提供的第一个名称。别名不能列出别的别名。 | | `groups` | 对象列表,见 [`groups[]`](#cfg-groups) | `[]` | 策略组:多个上游合用一个名字,并规定如何在其中选择。 | | `routes` | 对象列表,见 [`routes[]`](#cfg-routes) | `[]` | 路由。一条都不写时,请求按上游的声明顺序故障转移。 | | `default_route` | 字符串 | — | 未指定路由的密钥走哪条路由。不写:名为 `default` 的路由;没有这条路由时走内置的故障转移。 | @@ -756,11 +757,24 @@ security: |---|---|---|---| | `name` | 字符串 | **必填** | 日志和流量详情中显示的名字。 | | `when` | 对象,见 [`routes[].rules[].when`](#cfg-routes-rules-when) | — | 条件,须全部满足。不写:匹配所有请求。 | -| `to` | 字符串 | — | 上游或策略组的名字;`__all__` 表示按声明顺序的全部上游。不能与 `when.provider_would_be` 同时写。 | +| `to` | 字符串,或对象列表,见 [`routes[].rules[].to[]`](#cfg-routes-rules-to) | — | 上游或策略组的名字;`__all__` 表示按声明顺序的全部上游。也可以指定模型:列出上游及发给它的模型,按顺序备用。不能与 `when.provider_would_be` 同时写。 | | `set` | 对象,见 [`routes[].rules[].set`](#cfg-routes-rules-set) | — | 改写请求参数。从所有匹配的规则累积,不只第一条。 | | `deny` | 字符串 | — | 以这句原因拒绝请求。 | +#### `routes[].rules[].to` + +`to` 指定模型时,每一项是一个上游和发给它的模型,按顺序备用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `provider` | 字符串 | **必填** | 上游的名字,不能是策略组。同一个上游在列表中只出现一次。 | +| `model` | 字符串 | **必填** | 发给这个上游的模型名,原样发出:不经过别名,也不受 `set.model` 改写。同一条规则里不能再写 `set.model`。 | + + #### `routes[].rules[].when` From 776e3246e91b00709a84854cde3349f9fdc80c0f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:54:29 +0800 Subject: [PATCH 03/18] Answers name the model the client asked for when the sent name differs When the model name sent upstream (S) differs from the one the client asked for (N) -- a rule's set.model, a plugin renaming it, and soon aliases and pinned models -- the model the upstream writes in its answer (A) is now written back as N, provided A is the same model as S (model_name::same, dated snapshots included). A different model stays visible, so a real substitution still shows (ChatGPT rerouting, Codex's openai-model warning). A missing or empty model is not filled in, and upstream errors are passed through untouched. - tw-gateway answer_model: a streaming rewriter that counts JSON levels like the usage sniffer and holds back only the model string itself, so streams are not buffered. It rewrites exactly the fields the sniffer reads: root model / modelVersion / model_version, message.model, response.model -- SSE frames, Gemini JSON arrays, whole bodies and WebSocket messages. - relay: runs after format conversion and before reply hooks (plugin ctx keeps model = S, requested_model = N; frames plugins synthesize copy the renamed envelope); the recorder and the upstream check-up still see A because ending.feed runs on the raw bytes first. openai-model and x-openai-model response headers follow the same rule. Failover uses the name the answering hop sent. - WebSocket: a model a plugin renamed in response.create is written back. - model_name moves from tw-store to tw-pricing (re-exported as tw_store::model_name) so the gateway can use it without depending on the store. - tests/models.rs: the fake upstream echoes the received model under `sent`, since `model` in an answer is now rewritten. Co-Authored-By: Claude Opus 5.5 --- crates/tw-gateway/src/answer_model.rs | 626 +++++++++++++ crates/tw-gateway/src/lib.rs | 1 + crates/tw-gateway/src/server/pipeline.rs | 1 + crates/tw-gateway/src/server/pipeline/hop.rs | 3 +- .../tw-gateway/src/server/pipeline/relay.rs | 48 +- crates/tw-gateway/src/ws.rs | 15 + crates/tw-gateway/tests/client_model_name.rs | 836 ++++++++++++++++++ crates/tw-gateway/tests/models.rs | 10 +- crates/tw-gateway/tests/plugins_ws.rs | 52 +- crates/tw-pricing/src/lib.rs | 1 + .../src/model_name.rs | 11 +- crates/tw-pricing/src/name.rs | 2 +- crates/tw-store/src/lib.rs | 4 +- 13 files changed, 1591 insertions(+), 19 deletions(-) create mode 100644 crates/tw-gateway/src/answer_model.rs create mode 100644 crates/tw-gateway/tests/client_model_name.rs rename crates/{tw-store => tw-pricing}/src/model_name.rs (98%) diff --git a/crates/tw-gateway/src/answer_model.rs b/crates/tw-gateway/src/answer_model.rs new file mode 100644 index 00000000..298dda67 --- /dev/null +++ b/crates/tw-gateway/src/answer_model.rs @@ -0,0 +1,626 @@ +//! 回答里的模型名写成客户端用的名称。 +//! +//! 发出去的名称 S 和客户端要的 N 不一样时 —— 别名、规则改写、指定模型、插件改名 —— +//! 上游在回答里写的是它自己的那个名称 A。**A 和 S 是同一个模型就写成 N**:客户端问的 +//! 是 N,回答说的也该是 N。认法和上游体检同一套([`tw_pricing::model_name::same`]), +//! 日期版本不同也算同一个。有的客户端拿回答里的名称和请求比,对不上就当成模型被换了 +//! (Codex 看 `openai-model` 头)。 +//! +//! **A 是别的模型就原样转发**:上游真换了模型(ChatGPT 改道、中转站偷换),客户端必须 +//! 看得见。回答里没写模型名(或者写的是空串)的,不替它加。只改成功的回答:上游回的 +//! 错误原样交给客户端,那是它的原话。 +//! +//! # 在中继上的位置 +//! +//! - 留档和嗅探在它之前:请求记录和上游体检看的是上游的原话 A(见 [`crate::ending`])。 +//! - 在格式转换之后:改的是客户端那种格式里的字段。上游没写模型名时转换器填的是 S +//! (Bedrock 的回答里就没有),一样写成 N。 +//! - 在回答钩子之前:插件看不到、也改不了回答里的模型名(它只管文字和工具调用),插件 +//! 补出来的帧照抄前一帧的外壳,抄到的已经是 N。插件的 `ctx.model` 照旧是 S、 +//! `ctx.requestedModel` 是 N。 +//! +//! # 不缓冲 +//! +//! 和 [`tw_dialect::usage`] 的嗅探器同一个办法:边流边数 JSON 的层次,不解析成值。字节 +//! 照常往下走,**只扣住模型名那一个字符串**(几十个字节),读完了再决定原样发还是换掉。 +//! 认的字段也和嗅探器一样 —— 嗅探器读出来记成 A 的,正是这里换掉的那一个: +//! +//! | | 在哪儿 | +//! |---|---| +//! | Anthropic | 整包的 `model`;流里 `message_start` 的 `message.model` | +//! | OpenAI Chat | 整包和每个 chunk 的 `model` | +//! | OpenAI Responses | 整包的 `model`;流里 `response.created` 这些帧的 `response.model` | +//! | Gemini | 整包和每一帧的 `modelVersion`(`model_version` 也认) | +//! +//! 一帧是 SSE 的一行 `data:`、整包那一个对象、Gemini 不带 `alt=sse` 时那个数组里的每一个 +//! 元素,或者 WebSocket 上的一条消息。嵌在别处的同名键(工具参数里的 `model`)不动。 + +use axum::http::{HeaderMap, HeaderValue}; + +/// 回答里写着模型名的响应头。OpenAI 和 Codex 后端都发;Codex 拿它和请求的模型比 +pub const HEADERS: &[&str] = &["openai-model", "x-openai-model"]; + +/// 模型名最多扣住多长。再长就不是一个模型名,原样放过去 +const MAX_MODEL_LEN: usize = 256; + +/// JSON 最多数到第几层。再深的只数不记:根对象和它下面那一层早就过去了 +const MAX_DEPTH: usize = 64; + +/// 客户端用的名称和发出去的名称。 +#[derive(Debug, Clone)] +pub struct Rename { + /// N:客户端请求里写的 + client: String, + /// S:发给回答它的那一家的 + sent: String, +} + +impl Rename { + /// 两个名称一样(或者客户端没写模型名)时不用改,是 None。 + pub fn new(client: &str, sent: &str) -> Option { + (!client.is_empty() && client != sent).then(|| Self { + client: client.to_string(), + sent: sent.to_string(), + }) + } + + /// 上游写的这个名称要不要换成客户端的:和发出去的是同一个模型才换。空的不算写了 + pub fn wants(&self, answered: &str) -> bool { + let a = answered.trim(); + !a.is_empty() && a != self.client && tw_pricing::model_name::same(a, &self.sent) + } + + /// 响应头里的模型名([`HEADERS`])按同一条规矩换。 + pub fn headers(&self, headers: &mut HeaderMap) { + for name in HEADERS { + let Some(v) = headers.get(*name) else { + continue; + }; + let wants = std::str::from_utf8(v.as_bytes()).is_ok_and(|a| self.wants(a)); + // 名称里有头里放不下的字符(换行这类)时不换:宁可原样,也不发一个坏的头 + if wants && let Ok(n) = HeaderValue::from_bytes(self.client.as_bytes()) { + headers.insert(*name, n); + } + } + } + + /// 边流边改回答正文的那一个。 + pub fn body(self) -> Body { + let quoted = serde_json::to_vec(&self.client).unwrap_or_default(); + Body { + names: self, + quoted, + last: None, + stack: Vec::new(), + deep: 0, + in_str: false, + escaped: false, + capture: Capture::Skip, + key: Vec::new(), + held: Vec::new(), + line: Some(0), + } + } +} + +/// 回答正文里的模型名:边流边换。 +/// +/// 喂进来的是客户端将要收到的字节(SSE、JSON 数组流、整包都行),吐出来的是换过的。 +/// 除了正在读的模型名那一个字符串,什么都不扣。 +#[derive(Debug)] +pub struct Body { + names: Rename, + /// N 写成 JSON 字符串的样子(带引号、转义过) + quoted: Vec, + /// 上一次见到的模型名(原样的字节)和换不换。Chat 的每个 chunk 都写一遍同一个名字, + /// 不必每一帧都归一一遍 + last: Option<(Vec, bool)>, + stack: Vec, + /// 超过 [`MAX_DEPTH`] 的那几层:只数,不记 + deep: usize, + in_str: bool, + escaped: bool, + capture: Capture, + /// 正在读的键(只有根和 `Inner` 那两层的键要认) + key: Vec, + /// 扣住的模型名:从开头的引号起 + held: Vec, + /// 这一行开头对上了 `data:` 的几个字节;不在行首、或者已经对不上了是 None。 + /// **对上了就是新的一帧**,层次从零数起(理由同 [`tw_dialect::usage`] 的嗅探器) + line: Option, +} + +/// 数到的一层。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Scope { + Obj { + at: Level, + /// 刚读到的键是哪一个(冒号后面那个值归它) + key: Key, + /// 下一个字符串是键 + want_key: bool, + }, + Arr, +} + +/// 一个对象在一帧里的位置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Level { + /// 帧的根 + Root, + /// 根下 `message` / `response` 的那个对象 + Inner, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Key { + None, + /// 这一层的模型名 + Model, + /// 根下的 `message` / `response`:它的值是 [`Level::Inner`] 那一层 + Wrapper, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Capture { + /// 原样过去 + Skip, + /// 一个键:原样过去,同时记下是哪一个 + Key, + /// 模型名:扣住,读完再定 + Value, +} + +impl Body { + /// 改一段。返回现在该发的字节:模型名读到一半的话,那一截留到下一段。 + pub fn feed(&mut self, chunk: &[u8]) -> Vec { + let mut out = Vec::with_capacity(chunk.len() + self.quoted.len()); + let mut i = 0; + while i < chunk.len() { + if self.in_str { + i = self.in_string(chunk, i, &mut out); + continue; + } + let c = chunk[i]; + i += 1; + self.at_line_start(c); + if c == b'"' { + self.open_string(&mut out); + continue; + } + out.push(c); + match c { + b':' => { + if let Some(Scope::Obj { want_key, .. }) = self.top() { + *want_key = false; + } + } + b',' => { + if let Some(Scope::Obj { want_key, key, .. }) = self.top() { + *want_key = true; + *key = Key::None; + } + } + b'{' => { + let at = match (self.deep, self.stack.as_slice()) { + (0, [] | [Scope::Arr]) => Level::Root, + ( + 0, + [ + .., + Scope::Obj { + at: Level::Root, + key: Key::Wrapper, + want_key: false, + }, + ], + ) => Level::Inner, + _ => Level::Other, + }; + self.push(Scope::Obj { + at, + key: Key::None, + want_key: true, + }); + } + b'[' => self.push(Scope::Arr), + b'}' | b']' => { + if self.deep > 0 { + self.deep -= 1; + } else { + self.stack.pop(); + } + } + _ => {} + } + } + out + } + + /// 流结束了(或者断了):扣着的那半个名字原样交出去,从头数起。 + pub fn flush(&mut self) -> Vec { + self.stack.clear(); + self.deep = 0; + self.in_str = false; + self.escaped = false; + self.capture = Capture::Skip; + self.key.clear(); + self.line = Some(0); + std::mem::take(&mut self.held) + } + + /// 当前这一层。太深、只数不记的时候没有 + fn top(&mut self) -> Option<&mut Scope> { + if self.deep > 0 { + return None; + } + self.stack.last_mut() + } + + fn push(&mut self, s: Scope) { + if self.deep > 0 || self.stack.len() >= MAX_DEPTH { + self.deep += 1; + } else { + self.stack.push(s); + } + } + + /// 字符串外面的一个字节:数这一行开头是不是 `data:` + fn at_line_start(&mut self, c: u8) { + const DATA: &[u8] = b"data:"; + if c == b'\n' { + self.line = Some(0); + return; + } + let Some(n) = self.line else { + return; + }; + if DATA.get(n) == Some(&c) { + self.line = Some(n + 1); + if n + 1 == DATA.len() { + self.stack.clear(); + self.deep = 0; + self.line = None; + } + } else { + self.line = None; + } + } + + /// 一个字符串开头的引号:模型名的值扣住,别的照发 + fn open_string(&mut self, out: &mut Vec) { + self.in_str = true; + self.escaped = false; + let top = if self.deep > 0 { + None + } else { + self.stack.last().copied() + }; + self.capture = match top { + Some(Scope::Obj { + at: Level::Root | Level::Inner, + want_key: true, + .. + }) => Capture::Key, + Some(Scope::Obj { + at: Level::Root | Level::Inner, + key: Key::Model, + want_key: false, + }) => Capture::Value, + _ => Capture::Skip, + }; + match self.capture { + Capture::Value => { + self.held.clear(); + self.held.push(b'"'); + } + Capture::Key => { + self.key.clear(); + out.push(b'"'); + } + Capture::Skip => out.push(b'"'), + } + } + + /// 字符串里面:从 `i` 读到这个字符串结束(或者这一段结束),返回读到了哪儿 + fn in_string(&mut self, chunk: &[u8], mut i: usize, out: &mut Vec) -> usize { + while i < chunk.len() { + if self.escaped { + self.escaped = false; + self.take(&chunk[i..=i], out); + i += 1; + continue; + } + // 一口气读到下一个要看的字节:字符串里绝大多数是正文,不必逐个判断 + let rest = &chunk[i..]; + let run = rest + .iter() + .position(|&b| matches!(b, b'"' | b'\\' | b'\n')) + .unwrap_or(rest.len()); + self.take(&rest[..run], out); + i += run; + let Some(&c) = chunk.get(i) else { + break; + }; + i += 1; + match c { + b'\\' => { + self.escaped = true; + self.take(b"\\", out); + } + b'"' => { + self.in_str = false; + self.close_string(out); + return i; + } + // JSON 的字符串里不会有裸的换行:引号的配对已经乱了(帧被截断、不是 JSON)。 + // 扣着的原样交出去,从头数起,下一帧照样认得出 + _ => { + self.in_str = false; + self.capture = Capture::Skip; + out.append(&mut self.held); + out.push(b'\n'); + self.stack.clear(); + self.deep = 0; + self.line = Some(0); + return i; + } + } + } + i + } + + /// 字符串里的一段:按正在读的是什么,发出去、记下、或者扣住 + fn take(&mut self, bytes: &[u8], out: &mut Vec) { + match self.capture { + Capture::Skip => out.extend_from_slice(bytes), + Capture::Key => { + out.extend_from_slice(bytes); + // 认的几个键最长 13 个字节(`model_version`),多记几个就够分辨 + let room = 16usize.saturating_sub(self.key.len()); + self.key.extend_from_slice(&bytes[..bytes.len().min(room)]); + } + Capture::Value => { + self.held.extend_from_slice(bytes); + // 太长了不是模型名:原样放过去,这个字符串剩下的照发 + if self.held.len() > MAX_MODEL_LEN { + out.append(&mut self.held); + self.capture = Capture::Skip; + } + } + } + } + + fn close_string(&mut self, out: &mut Vec) { + match std::mem::replace(&mut self.capture, Capture::Skip) { + Capture::Skip => out.push(b'"'), + Capture::Key => { + out.push(b'"'); + let deep = self.deep; + if let Some(Scope::Obj { at, key, .. }) = + self.stack.last_mut().filter(|_| deep == 0) + { + *key = match (*at, self.key.as_slice()) { + (Level::Root, b"model" | b"modelVersion" | b"model_version") => Key::Model, + (Level::Root, b"message" | b"response") => Key::Wrapper, + (Level::Inner, b"model") => Key::Model, + _ => Key::Other, + }; + } + } + Capture::Value => { + self.held.push(b'"'); + if self.renames_held() { + out.extend_from_slice(&self.quoted); + self.held.clear(); + } else { + out.append(&mut self.held); + } + } + } + } + + /// 扣住的这个名字(带着两头的引号)要不要换 + fn renames_held(&mut self) -> bool { + if let Some((seen, wants)) = &self.last + && *seen == self.held + { + return *wants; + } + // 带转义的(`\/`、`\u…`)照 JSON 的规矩解开;绝大多数名字没有 + let wants = + serde_json::from_slice::(&self.held).is_ok_and(|a| self.names.wants(&a)); + self.last = Some((self.held.clone(), wants)); + wants + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rename(client: &str, sent: &str) -> Body { + Rename::new(client, sent).expect("names differ").body() + } + + /// 整段一次喂进去,和逐字节喂进去,出来的必须一样 + fn through(b: &mut Body, input: &str) -> String { + let mut out = b.feed(input.as_bytes()); + out.extend(b.flush()); + String::from_utf8(out).unwrap() + } + + fn bytewise(client: &str, sent: &str, input: &str) -> String { + let mut b = rename(client, sent); + let mut out = Vec::new(); + for c in input.as_bytes() { + out.extend(b.feed(std::slice::from_ref(c))); + } + out.extend(b.flush()); + String::from_utf8(out).unwrap() + } + + fn check(client: &str, sent: &str, input: &str, want: &str) { + assert_eq!(through(&mut rename(client, sent), input), want, "整段喂"); + assert_eq!(bytewise(client, sent, input), want, "逐字节喂"); + } + + #[test] + fn the_same_names_need_no_rename() { + assert!(Rename::new("claude-opus-5", "claude-opus-5").is_none()); + assert!(Rename::new("", "claude-opus-5").is_none()); + assert!(Rename::new("opus", "claude-opus-5").is_some()); + } + + #[test] + fn an_anthropic_stream_renames_message_start_only() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"model\":\"claude-opus-5-20261001\",\"content\":[]}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"model\\\": \\\"claude-opus-5\\\"}\"}}\n\n", + ); + let want = input.replace("\"claude-opus-5-20261001\"", "\"opus\""); + check("opus", "us.anthropic.claude-opus-5-v1:0", input, &want); + } + + #[test] + fn an_anthropic_whole_body_renames_the_top_level_model_not_a_tool_input() { + // 按键名排序的中转站:工具参数排在前面,里面也有一个 `model` + let input = r#"{"content":[{"type":"tool_use","id":"t1","name":"pick","input":{"model":"claude-opus-5"}}],"id":"msg_1","model":"claude-opus-5","role":"assistant","type":"message"}"#; + let want = r#"{"content":[{"type":"tool_use","id":"t1","name":"pick","input":{"model":"claude-opus-5"}}],"id":"msg_1","model":"opus","role":"assistant","type":"message"}"#; + check("opus", "anthropic/claude-opus-5", input, want); + } + + #[test] + fn every_chat_chunk_is_renamed() { + let input = concat!( + "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-6-2026-09-01\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n", + "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-6-2026-09-01\",\"choices\":[]}\n\n", + "data: [DONE]\n\n", + ); + let want = input.replace("gpt-6-2026-09-01", "my-gpt"); + check("my-gpt", "gpt-6", input, &want); + } + + #[test] + fn responses_events_rename_the_response_object() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r1\",\"model\":\"gpt-6-codex\",\"output\":[]}}\n\n", + "event: response.output_item.added\n", + "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"arguments\":\"{}\",\"model\":\"gpt-6-codex\"}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\",\"model\":\"gpt-6-codex\",\"usage\":{}}}\n\n", + ); + let want = input.replace( + "\"response\":{\"id\":\"r1\",\"model\":\"gpt-6-codex\"", + "\"response\":{\"id\":\"r1\",\"model\":\"codex\"", + ); + check("codex", "gpt-6-codex", input, &want); + // 不是 `response` 下面的那一层不动 + assert!(want.contains( + "\"item\":{\"type\":\"function_call\",\"arguments\":\"{}\",\"model\":\"gpt-6-codex\"}" + )); + } + + #[test] + fn a_gemini_array_stream_renames_every_element() { + let input = "[{\"candidates\":[],\"modelVersion\":\"gemini-3-pro-preview-09-2026\"}\r\n,{\"candidates\":[],\"modelVersion\":\"gemini-3-pro-preview-09-2026\"}]"; + let want = input.replace("gemini-3-pro-preview-09-2026", "gemini"); + check("gemini", "gemini-3-pro", input, &want); + } + + #[test] + fn a_different_model_is_left_alone() { + let input = "data: {\"model\":\"gpt-6-mini\",\"choices\":[]}\n\n"; + check("my-gpt", "gpt-6", input, input); + } + + #[test] + fn an_empty_or_missing_model_is_not_filled_in() { + let input = "data: {\"model\":\"\",\"choices\":[]}\n\ndata: {\"choices\":[]}\n\n"; + check("my-gpt", "gpt-6", input, input); + } + + #[test] + fn an_escaped_name_is_read_as_json() { + let input = r#"{"model":"anthropic\/claude-opus-5"}"#; + check("opus", "claude-opus-5", input, r#"{"model":"opus"}"#); + } + + #[test] + fn the_client_name_is_written_as_a_json_string() { + let input = r#"{"model":"claude-opus-5"}"#; + check( + "中转\"别名\"", + "claude-opus-5", + input, + r#"{"model":"中转\"别名\""}"#, + ); + } + + #[test] + fn an_overlong_value_passes_through() { + let long = "x".repeat(MAX_MODEL_LEN + 10); + let input = format!(r#"{{"model":"{long}","n":1}}"#); + check("a", "b", &input, &input); + } + + #[test] + fn a_broken_frame_does_not_confuse_the_next() { + let input = concat!( + "data: {\"model\":\"gpt-6\",\"x\":\"cut off\n\n", + "data: {\"model\":\"gpt-6\",\"choices\":[]}\n\n", + ); + let want = concat!( + "data: {\"model\":\"my-gpt\",\"x\":\"cut off\n\n", + "data: {\"model\":\"my-gpt\",\"choices\":[]}\n\n", + ); + check("my-gpt", "gpt-6", input, want); + } + + #[test] + fn a_name_cut_off_by_the_end_of_the_stream_is_handed_over_as_it_was() { + let mut b = rename("my-gpt", "gpt-6"); + let mut out = b.feed(b"data: {\"model\":\"gpt-"); + out.extend(b.flush()); + assert_eq!(out, b"data: {\"model\":\"gpt-"); + } + + #[test] + fn a_deeply_nested_answer_does_not_grow_the_stack() { + let mut b = rename("my-gpt", "gpt-6"); + let mut out = b.feed(format!(r#"{{"a":{}"#, "[".repeat(10_000)).as_bytes()); + assert_eq!(b.stack.len(), MAX_DEPTH); + out.extend(b.feed(format!(r#"{},"model":"gpt-6"}}"#, "]".repeat(10_000)).as_bytes())); + out.extend(b.flush()); + let out = String::from_utf8(out).unwrap(); + assert!( + out.ends_with(r#"],"model":"my-gpt"}"#), + "{}", + &out[out.len() - 40..] + ); + } + + #[test] + fn headers_follow_the_same_rule() { + let r = Rename::new("codex", "gpt-6-codex").unwrap(); + let mut h = HeaderMap::new(); + h.insert( + "openai-model", + HeaderValue::from_static("gpt-6-codex-2026-09-30"), + ); + h.insert("x-openai-model", HeaderValue::from_static("gpt-6-codex")); + r.headers(&mut h); + assert_eq!(h["openai-model"], "codex"); + assert_eq!(h["x-openai-model"], "codex"); + // 真换了模型的照原样:Codex 要靠它提示 + let mut h = HeaderMap::new(); + h.insert("openai-model", HeaderValue::from_static("gpt-6-mini")); + r.headers(&mut h); + assert_eq!(h["openai-model"], "gpt-6-mini"); + } +} diff --git a/crates/tw-gateway/src/lib.rs b/crates/tw-gateway/src/lib.rs index b5f70fe6..05743fa3 100644 --- a/crates/tw-gateway/src/lib.rs +++ b/crates/tw-gateway/src/lib.rs @@ -7,6 +7,7 @@ pub mod access; pub mod affinity; +pub mod answer_model; pub mod auth; pub mod bedrock; pub mod bodies; diff --git a/crates/tw-gateway/src/server/pipeline.rs b/crates/tw-gateway/src/server/pipeline.rs index 85337183..cae95184 100644 --- a/crates/tw-gateway/src/server/pipeline.rs +++ b/crates/tw-gateway/src/server/pipeline.rs @@ -213,6 +213,7 @@ pub(super) async fn pipeline( &rt, &req, reading.generates, + &reading.facts.model, served, started.id, live, diff --git a/crates/tw-gateway/src/server/pipeline/hop.rs b/crates/tw-gateway/src/server/pipeline/hop.rs index 7daa5088..bcf77ea1 100644 --- a/crates/tw-gateway/src/server/pipeline/hop.rs +++ b/crates/tw-gateway/src/server/pipeline/hop.rs @@ -24,7 +24,8 @@ use tw_types::msg; pub(super) struct Served<'a> { pub(super) upstream: reqwest::Response, pub(super) provider: &'a tw_config::Provider, - /// 发给它的模型名:路由规则、插件改过的是改过之后的。回答钩子的 `ctx.model` 和范围看它 + /// 发给它的模型名:路由规则、插件改过的是改过之后的。回答钩子的 `ctx.model` 和范围看它; + /// 和客户端要的不一样时,回答里的模型名按它认、换回客户端的(见 [`crate::answer_model`]) pub(super) model: String, /// 它是尝试链上的第几跳。回答钩子的运行记录按它分组 pub(super) attempt: usize, diff --git a/crates/tw-gateway/src/server/pipeline/relay.rs b/crates/tw-gateway/src/server/pipeline/relay.rs index 5171620f..6034e9f8 100644 --- a/crates/tw-gateway/src/server/pipeline/relay.rs +++ b/crates/tw-gateway/src/server/pipeline/relay.rs @@ -2,7 +2,8 @@ //! //! 流式:**不缓冲**。整块缓冲会把 SSE 变成一次性交付,客户端那边看起来 //! 就是「卡住很久然后一下全出来」。每一块依次经过:留档与嗅探 → 回显 -//! 还原 → 格式转换 → 工具调用审查,然后才写给客户端。 +//! 还原 → 格式转换 → 模型名换回客户端用的名称 → 回答钩子 → 工具调用审查,然后才写给 +//! 客户端。 //! //! 这些步骤要不要做、怎么做,在响应头到手的那一刻就全定了([`Plan`]); //! 流里每一块怎么处理在 [`Relay`] 上,`respond` 里的流只剩一个循环。 @@ -21,12 +22,15 @@ use crate::server::{because, flagged}; use crate::state::{AppState, Runtime}; use tw_types::msg; +/// `asked_model` 是客户端请求里写的模型名:发出去的不是它、上游答的又是发出去的那个 +/// 模型时,回答里的模型名写回它(见 [`crate::answer_model`])。 #[allow(clippy::too_many_arguments)] pub(super) fn respond( state: &AppState, rt: &Runtime, req: &Inbound, generates: bool, + asked_model: &str, served: Served<'_>, id: u64, live: crate::live::Pass, @@ -36,6 +40,7 @@ pub(super) fn respond( let Served { upstream, provider, + model: sent_model, ledger, session, refusal, @@ -76,6 +81,13 @@ pub(super) fn respond( if let Ok(v) = axum::http::HeaderValue::from_str(&provider.name) { out_headers.insert("x-thinkwatch-upstream", v); } + // 回答里的模型名写成客户端用的名称:**只改成功的回答**,上游的错误是它的原话。 + // 请求记录和上游体检看的是上游原话(`ending.feed` 在改写之前),不受影响 + let rename = + crate::answer_model::Rename::new(asked_model, &sent_model).filter(|_| status.is_success()); + if let Some(r) = &rename { + r.headers(&mut out_headers); + } // **Bedrock 的流不是 SSE**,是 AWS eventstream 的二进制帧。在字节进门的地方就转成 // SSE(`event:` 是事件名,`data:` 是载荷),后面的一切 —— 用量、首 token、留档、 @@ -132,6 +144,7 @@ pub(super) fn respond( id, upstream_dialect, plugins, + rename, ); let chunks = upstream.bytes_stream(); let dialect = req.dialect; @@ -434,6 +447,10 @@ struct Relay { /// 回答钩子(见 [`crate::plugin::reply`])。**排在转换之后、工具调用审查之前**:审查 /// 看的是插件改过的那一版。范围内没有插件时是 None,整段零成本 plugins: Option, + /// 回答里的模型名换回客户端用的名称(见 [`crate::answer_model`])。**排在转换之后、 + /// 回答钩子之前**:改的是客户端那种格式的字段,插件补出来的帧抄到的已经是换过的。 + /// 发出去的就是客户端要的那个名称(绝大多数请求)时是 None,整段零成本 + rename: Option, } /// 回答钩子在这条中继上怎么跑。 @@ -458,6 +475,7 @@ impl Relay { id: u64, upstream_dialect: tw_dialect::ir::Dialect, plugins: Option, + rename: Option, ) -> Self { // 还原看的是**上游的原话**(转换之前),所以按上游的格式认帧 let restorer = tw_guard::redact::sse::Body::new(ledger, plan.is_sse, upstream_dialect); @@ -532,6 +550,7 @@ impl Relay { id, provider: provider.name.clone(), plugins, + rename: rename.map(crate::answer_model::Rename::body), } } @@ -556,6 +575,11 @@ impl Relay { c.process(&out); return (Vec::new(), None); } + // 模型名换回客户端用的名称:只扣住读到一半的那个名字,别的照发 + let out = match self.rename.as_mut() { + Some(r) => r.feed(&out), + None => out, + }; // 回答钩子:插件改过的才是客户端将要看到的那一版 let (out, failed) = match self.plugins.as_mut() { Some(ReplyStage::Stream(s)) => s.feed(&out).await, @@ -676,6 +700,8 @@ impl Relay { } _ => tail, }; + // 模型名:整包的这时才见到,流的补上扣着的那半个名字 + let tail = self.renamed(tail); // 回答钩子的收尾:流的补上扣着的,整包的这时才改 let tail = match self.plugins.as_mut() { None => tail, @@ -833,8 +859,26 @@ impl Relay { self.at_boundary } - /// 流断了之后还能对客户端说的最后一句:按它收到的格式收尾。 + /// 收尾的一段过一遍模型名的改写,扣着的一起交出去 + fn renamed(&mut self, tail: Vec) -> Vec { + match self.rename.as_mut() { + Some(r) => { + let mut out = r.feed(&tail); + out.extend(r.flush()); + out + } + None => tail, + } + } + + /// 流断了之后还能对客户端说的最后一句:按它收到的格式收尾。转换过的流收尾时写的 + /// 外壳(Responses 的 `response.failed`)带着模型名,和前面的帧一样换过 fn error_tail(&mut self, err: &GatewayError) -> Option> { + let frame = self.error_frame(err)?; + Some(self.renamed(frame)) + } + + fn error_frame(&mut self, err: &GatewayError) -> Option> { if let Some(c) = self.back.as_mut() { // 转换过的流按客户端的格式收尾 Some(c.fail(&format!("[ThinkWatch] {}", err.message()))) diff --git a/crates/tw-gateway/src/ws.rs b/crates/tw-gateway/src/ws.rs index 82facdf3..8b90beb5 100644 --- a/crates/tw-gateway/src/ws.rs +++ b/crates/tw-gateway/src/ws.rs @@ -159,6 +159,8 @@ struct Pipes { bridge: Option, /// 这一次回答的回答钩子 reply: Option, + /// 插件换了发出去的模型名时,回答里的模型名换回客户端用的(见 [`crate::answer_model`]) + rename: Option, } /// 接管一次升级。 @@ -234,6 +236,7 @@ pub async fn proxy( sent_model: String::new(), bridge: None, reply: None, + rename: None, }; pump(state, client, up, &mut p, ending).await; } @@ -504,6 +507,16 @@ async fn upstream_text( ending: &mut crate::ending::Ending, ) -> Flow { let restored = tw_guard::redact::replace::restore(t, &p.ledger); + // 模型名换回客户端用的名称:一条消息是一个完整的 JSON,整条过一遍。排在回答钩子之前, + // 和 HTTP 那条路一样 + let restored = match p.rename.as_mut() { + Some(r) => { + let mut out = r.feed(restored.as_bytes()); + out.extend(r.flush()); + String::from_utf8(out).unwrap_or(restored) + } + None => restored, + }; // 回答的边界:一次新的回答起一组回答钩子的实例;被切掉的那次剩下的帧不发 let kind = frame_kind(&restored); let terminal = matches!( @@ -671,6 +684,8 @@ async fn plugin_request(state: &AppState, p: &mut Pipes, text: &str) -> Result, + body: String, +} + +fn ok(content_type: &'static str, body: impl Into) -> Reply { + Reply { + status: 200, + content_type, + headers: Vec::new(), + body: body.into(), + } +} + +const JSON: &str = "application/json"; +const SSE: &str = "text/event-stream"; + +/// 收到的生成请求:路径和正文。**只记 POST**:网关会在后台来取模型清单 +type Seen = Arc>>; + +async fn upstream(reply: Reply) -> (SocketAddr, Seen) { + let seen: Seen = Arc::default(); + let app = Router::new() + .fallback( + move |State(s): State, + method: axum::http::Method, + OriginalUri(uri): OriginalUri, + body: bytes::Bytes| { + let reply = reply.clone(); + async move { + if method == axum::http::Method::POST { + let v = serde_json::from_slice(&body).unwrap_or(Value::Null); + s.lock().unwrap().push((uri.to_string(), v)); + } + let mut b = axum::response::Response::builder() + .status(reply.status) + .header("content-type", reply.content_type); + for (k, v) in &reply.headers { + b = b.header(*k, *v); + } + b.body(axum::body::Body::from(reply.body)).unwrap() + } + }, + ) + .with_state(seen.clone()); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(l, app).await.unwrap() }); + (addr, seen) +} + +fn provider(name: &str, up: SocketAddr, protocol: Protocol) -> Provider { + Provider { + name: name.into(), + base_url: format!("http://{up}"), + key: Some("sk-x".into()), + protocol: Some(protocol), + ..Default::default() + } +} + +/// 默认路由:客户端要 `client` 时发 `sent`,都去 `to`。`sent` 为空就不改写 +fn routes(client: &str, sent: &str, to: &str) -> String { + let rename = if sent.is_empty() { + String::new() + } else { + format!( + r#" + - name: 改名 + when: {{ model: "{client}" }} + set: {{ model: "{sent}" }}"# + ) + }; + format!( + r#" +- name: default + rules:{rename} + - name: 兜底 + to: {to} +"# + ) +} + +async fn serve( + providers: Vec, + groups: Vec, + routes: &str, +) -> (SocketAddr, Receiver) { + serve_with(providers, groups, routes, Vec::new()).await +} + +/// 装着插件 `plugins` 的网关 +async fn serve_with( + providers: Vec, + groups: Vec, + routes: &str, + plugins: Vec, +) -> (SocketAddr, Receiver) { + let cfg = Config { + clients: vec![Client { + name: "me".into(), + key: "tw-k".into(), + ..Default::default() + }], + providers, + groups, + // 规则从 YAML 读:和用户写在 config.yaml 里的是同一个形状 + routes: serde_yaml_ng::from_str(routes).unwrap(), + ..Default::default() + }; + let state = tw_gateway::AppState::new(cfg).unwrap(); + state.swap_plugins(PluginSet::new( + plugins + .into_iter() + .enumerate() + .map(|(i, d)| Arc::new(double::active(&format!("p{i}"), d))) + .collect(), + )); + let events = state.bus.subscribe(); + let addr = tw_gateway::serve(state, ([127, 0, 0, 1], 0).into()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + (addr, events) +} + +/// 客户端收到的 +struct Got { + status: u16, + headers: reqwest::header::HeaderMap, + body: String, + /// 结局里记的上游原话 + answered: Option, +} + +async fn ask(gw: SocketAddr, rx: &mut Receiver, path: &str, body: &str) -> Got { + let r = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .post(format!("http://{gw}{path}")) + .header("x-api-key", "tw-k") + .header("content-type", "application/json") + .body(body.to_string()) + .send() + .await + .unwrap(); + let status = r.status().as_u16(); + let headers = r.headers().clone(); + let body = r.text().await.unwrap(); + let answered = loop { + let ev = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("5 秒内没等到结局") + .expect("事件流断了"); + match ev { + Event::RequestFinished { answered_model, .. } + | Event::RequestFailed { answered_model, .. } + | Event::RequestCancelled { answered_model, .. } => break answered_model, + _ => {} + } + }; + Got { + status, + headers, + body, + answered, + } +} + +/// 回答里所有写着模型名的地方,按出现的顺序:整包的、流里每一帧的(SSE 或 JSON 数组) +fn models(body: &str) -> Vec { + fn of(v: &Value, out: &mut Vec) { + for m in [ + &v["model"], + &v["message"]["model"], + &v["response"]["model"], + &v["modelVersion"], + ] { + if let Some(s) = m.as_str() { + out.push(s.to_string()); + } + } + } + let mut out = Vec::new(); + let t = body.trim_start(); + if t.starts_with('{') || t.starts_with('[') { + match serde_json::from_str::(t).expect("不是 JSON") { + Value::Array(items) => items.iter().for_each(|v| of(v, &mut out)), + v => of(&v, &mut out), + } + } else { + for line in body.lines() { + let Some(data) = line.strip_prefix("data:") else { + continue; + }; + if let Ok(v) = serde_json::from_str::(data.trim()) { + of(&v, &mut out); + } + } + } + out +} + +/// 回答里每一处模型名都是 `want`,而且至少有一处 +fn all_are(got: &Got, want: &str) { + let found = models(&got.body); + assert!(!found.is_empty(), "回答里没有模型名:{}", got.body); + assert!( + found.iter().all(|m| m == want), + "应该都是 {want}:{found:?}\n{}", + got.body + ); +} + +// ---- 上游的原话 ---- + +const CLAUDE_A: &str = "claude-opus-5-20261001"; + +fn anthropic_whole() -> String { + format!( + r#"{{"id":"msg_1","type":"message","role":"assistant","model":"{CLAUDE_A}","content":[{{"type":"text","text":"hi"}}],"stop_reason":"end_turn","usage":{{"input_tokens":3,"output_tokens":1}}}}"# + ) +} + +fn anthropic_stream() -> String { + [ + format!( + "event: message_start\ndata: {{\"type\":\"message_start\",\"message\":{{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"{CLAUDE_A}\",\"content\":[],\"usage\":{{\"input_tokens\":3,\"output_tokens\":1}}}}}}\n\n" + ), + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n".into(), + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n".into(), + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n".into(), + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n".into(), + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n".into(), + ] + .concat() +} + +const GPT_A: &str = "gpt-6-2026-09-01"; + +fn chat_whole(model: &str) -> String { + format!( + r#"{{"id":"c1","object":"chat.completion","created":1,"model":"{model}","choices":[{{"index":0,"message":{{"role":"assistant","content":"hi"}},"finish_reason":"stop"}}],"usage":{{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}}}"# + ) +} + +fn chat_stream(model: &str) -> String { + format!( + "data: {{\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"{model}\",\"choices\":[{{\"index\":0,\"delta\":{{\"role\":\"assistant\",\"content\":\"hi\"}}}}]}}\n\n\ + data: {{\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"{model}\",\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}],\"usage\":{{\"prompt_tokens\":3,\"completion_tokens\":1,\"total_tokens\":4}}}}\n\n\ + data: [DONE]\n\n" + ) +} + +const CODEX_A: &str = "gpt-6-codex-2026-09-30"; + +fn responses_whole() -> String { + format!( + r#"{{"id":"resp_1","object":"response","created_at":1,"status":"completed","model":"{CODEX_A}","output":[{{"type":"message","id":"m1","role":"assistant","status":"completed","content":[{{"type":"output_text","text":"hi","annotations":[]}}]}}],"usage":{{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}}"# + ) +} + +fn responses_stream() -> String { + format!( + "event: response.created\ndata: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":1,\"status\":\"in_progress\",\"model\":\"{CODEX_A}\",\"output\":[]}}}}\n\n\ + event: response.output_text.delta\ndata: {{\"type\":\"response.output_text.delta\",\"item_id\":\"m1\",\"output_index\":0,\"content_index\":0,\"delta\":\"hi\"}}\n\n\ + event: response.completed\ndata: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":1,\"status\":\"completed\",\"model\":\"{CODEX_A}\",\"output\":[{{\"type\":\"message\",\"id\":\"m1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{{\"type\":\"output_text\",\"text\":\"hi\",\"annotations\":[]}}]}}],\"usage\":{{\"input_tokens\":3,\"output_tokens\":1,\"total_tokens\":4}}}}}}\n\n" + ) +} + +const GEMINI_A: &str = "gemini-3-pro-preview-09-2026"; + +fn gemini_chunk(text: &str, last: bool) -> String { + let finish = if last { + r#","finishReason":"STOP""# + } else { + "" + }; + format!( + r#"{{"candidates":[{{"content":{{"role":"model","parts":[{{"text":"{text}"}}]}}{finish},"index":0}}],"usageMetadata":{{"promptTokenCount":3,"candidatesTokenCount":1,"totalTokenCount":4}},"modelVersion":"{GEMINI_A}","responseId":"r1"}}"# + ) +} + +fn gemini_sse() -> String { + format!( + "data: {}\r\n\r\ndata: {}\r\n\r\n", + gemini_chunk("h", false), + gemini_chunk("i", true) + ) +} + +fn gemini_array() -> String { + format!( + "[{}\r\n,{}]", + gemini_chunk("h", false), + gemini_chunk("i", true) + ) +} + +// ---- 客户端的请求 ---- + +fn anthropic_req(model: &str, stream: bool) -> String { + format!( + r#"{{"model":"{model}","max_tokens":16,"stream":{stream},"messages":[{{"role":"user","content":"hi"}}]}}"# + ) +} + +fn chat_req(model: &str, stream: bool) -> String { + format!( + r#"{{"model":"{model}","stream":{stream},"messages":[{{"role":"user","content":"hi"}}]}}"# + ) +} + +fn responses_req(model: &str, stream: bool) -> String { + format!(r#"{{"model":"{model}","stream":{stream},"input":"hi"}}"#) +} + +const GEMINI_REQ: &str = r#"{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}"#; + +/// 一个上游、一条改名规则,问一次 +async fn once( + reply: Reply, + protocol: Protocol, + client: &str, + sent: &str, + path: &str, + body: &str, +) -> (Got, Seen) { + let (up, seen) = upstream(reply).await; + let (gw, mut rx) = serve( + vec![provider("up", up, protocol)], + Vec::new(), + &routes(client, sent, "up"), + ) + .await; + (ask(gw, &mut rx, path, body).await, seen) +} + +/// 上游收到的最后一个生成请求 +fn sent_to(seen: &Seen) -> (String, Value) { + seen.lock() + .unwrap() + .last() + .cloned() + .expect("上游没收到生成请求") +} + +// ---- 直通 ---- + +#[tokio::test] +async fn anthropic_passthrough_whole_and_stream() { + for (stream, reply) in [ + (false, ok(JSON, anthropic_whole())), + (true, ok(SSE, anthropic_stream())), + ] { + let (got, seen) = once( + reply, + Protocol::Anthropic, + "opus", + "claude-opus-5", + "/v1/messages", + &anthropic_req("opus", stream), + ) + .await; + assert_eq!(got.status, 200, "{}", got.body); + assert_eq!(sent_to(&seen).1["model"], "claude-opus-5", "发出去的是 S"); + all_are(&got, "opus"); + // 记下的是上游的原话:体检按它和 S 比 + assert_eq!(got.answered.as_deref(), Some(CLAUDE_A), "stream={stream}"); + } +} + +#[tokio::test] +async fn chat_passthrough_whole_and_stream_renames_every_chunk() { + for (stream, reply) in [ + (false, ok(JSON, chat_whole(GPT_A))), + (true, ok(SSE, chat_stream(GPT_A))), + ] { + let (got, _) = once( + reply, + Protocol::OpenaiChat, + "smart", + "gpt-6", + "/v1/chat/completions", + &chat_req("smart", stream), + ) + .await; + assert_eq!(got.status, 200, "{}", got.body); + all_are(&got, "smart"); + if stream { + assert_eq!(models(&got.body).len(), 2, "每个 chunk 都换"); + assert!(got.body.ends_with("data: [DONE]\n\n"), "{}", got.body); + } + assert_eq!(got.answered.as_deref(), Some(GPT_A)); + } +} + +#[tokio::test] +async fn responses_passthrough_whole_and_stream() { + for (stream, reply) in [ + (false, ok(JSON, responses_whole())), + (true, ok(SSE, responses_stream())), + ] { + let (got, _) = once( + reply, + Protocol::OpenaiResponses, + "codex", + "gpt-6-codex", + "/v1/responses", + &responses_req("codex", stream), + ) + .await; + assert_eq!(got.status, 200, "{}", got.body); + all_are(&got, "codex"); + if stream { + assert_eq!( + models(&got.body).len(), + 2, + "response.created 和 response.completed" + ); + } + assert_eq!(got.answered.as_deref(), Some(CODEX_A)); + } +} + +#[tokio::test] +async fn gemini_passthrough_whole_sse_and_json_array() { + for (path, reply) in [ + ( + "/v1beta/models/gem:generateContent", + ok(JSON, gemini_chunk("hi", true)), + ), + ( + "/v1beta/models/gem:streamGenerateContent?alt=sse", + ok(SSE, gemini_sse()), + ), + ( + "/v1beta/models/gem:streamGenerateContent", + ok(JSON, gemini_array()), + ), + ] { + let (got, seen) = once( + reply, + Protocol::Gemini, + "gem", + "gemini-3-pro", + path, + GEMINI_REQ, + ) + .await; + assert_eq!(got.status, 200, "{path}: {}", got.body); + assert!( + sent_to(&seen).0.contains("/models/gemini-3-pro:"), + "Gemini 的模型在路径里:{:?}", + sent_to(&seen).0 + ); + all_are(&got, "gem"); + assert_eq!(got.answered.as_deref(), Some(GEMINI_A), "{path}"); + } +} + +// ---- 转换 ---- + +/// 上游说 Chat、客户端说 Anthropic +#[tokio::test] +async fn an_anthropic_client_on_a_chat_upstream() { + for (stream, reply) in [ + (false, ok(JSON, chat_whole(GPT_A))), + (true, ok(SSE, chat_stream(GPT_A))), + ] { + let (got, _) = once( + reply, + Protocol::OpenaiChat, + "smart", + "gpt-6", + "/v1/messages", + &anthropic_req("smart", stream), + ) + .await; + assert_eq!(got.status, 200, "{}", got.body); + assert!( + got.body.contains("\"type\":\"message"), + "没转成 Anthropic:{}", + got.body + ); + all_are(&got, "smart"); + // 记下的是上游自己写的,不是转给客户端的那一版 + assert_eq!(got.answered.as_deref(), Some(GPT_A)); + } +} + +/// 上游说 Anthropic,客户端分别说 Chat、Responses、Gemini(SSE、JSON 数组、整包) +#[tokio::test] +async fn chat_responses_and_gemini_clients_on_an_anthropic_upstream() { + let cases: Vec<(&str, String, Reply)> = vec![ + ( + "/v1/chat/completions", + chat_req("opus", false), + ok(JSON, anthropic_whole()), + ), + ( + "/v1/chat/completions", + chat_req("opus", true), + ok(SSE, anthropic_stream()), + ), + ( + "/v1/responses", + responses_req("opus", false), + ok(JSON, anthropic_whole()), + ), + ( + "/v1/responses", + responses_req("opus", true), + ok(SSE, anthropic_stream()), + ), + ( + "/v1beta/models/opus:generateContent", + GEMINI_REQ.into(), + ok(JSON, anthropic_whole()), + ), + ( + "/v1beta/models/opus:streamGenerateContent?alt=sse", + GEMINI_REQ.into(), + ok(SSE, anthropic_stream()), + ), + ( + "/v1beta/models/opus:streamGenerateContent", + GEMINI_REQ.into(), + ok(SSE, anthropic_stream()), + ), + ]; + for (path, body, reply) in cases { + let (got, seen) = once( + reply, + Protocol::Anthropic, + "opus", + "claude-opus-5", + path, + &body, + ) + .await; + assert_eq!(got.status, 200, "{path}: {}", got.body); + assert_eq!(sent_to(&seen).1["model"], "claude-opus-5", "{path}"); + all_are(&got, "opus"); + assert_eq!(got.answered.as_deref(), Some(CLAUDE_A), "{path}"); + } +} + +/// 客户端要整包、上游给了流(收齐),和客户端要流、上游给了整包(写成流) +#[tokio::test] +async fn collected_and_unrolled_answers_are_renamed_too() { + let (got, _) = once( + ok(SSE, chat_stream(GPT_A)), + Protocol::OpenaiChat, + "smart", + "gpt-6", + "/v1/messages", + &anthropic_req("smart", false), + ) + .await; + assert_eq!(got.status, 200, "{}", got.body); + assert!(got.body.starts_with('{'), "收成整包:{}", got.body); + all_are(&got, "smart"); + + let (got, _) = once( + ok(JSON, chat_whole(GPT_A)), + Protocol::OpenaiChat, + "smart", + "gpt-6", + "/v1/messages", + &anthropic_req("smart", true), + ) + .await; + assert_eq!(got.status, 200, "{}", got.body); + assert!(got.body.starts_with("event:"), "写成流:{}", got.body); + all_are(&got, "smart"); +} + +// ---- 响应头 ---- + +#[tokio::test] +async fn the_openai_model_headers_follow_the_same_rule() { + let mut reply = ok(SSE, chat_stream(GPT_A)); + reply.headers = vec![("openai-model", GPT_A), ("x-openai-model", "gpt-6")]; + let (got, _) = once( + reply, + Protocol::OpenaiChat, + "smart", + "gpt-6", + "/v1/chat/completions", + &chat_req("smart", true), + ) + .await; + assert_eq!(got.headers["openai-model"], "smart"); + assert_eq!(got.headers["x-openai-model"], "smart"); + all_are(&got, "smart"); +} + +// ---- 不改的 ---- + +/// 上游真换了模型:客户端必须看得见。正文和头都原样 +#[tokio::test] +async fn a_different_model_in_the_answer_is_left_alone() { + for stream in [false, true] { + let mut reply = if stream { + ok(SSE, chat_stream("gpt-6-mini")) + } else { + ok(JSON, chat_whole("gpt-6-mini")) + }; + reply.headers = vec![("openai-model", "gpt-6-mini")]; + let original = reply.body.clone(); + let (got, _) = once( + reply, + Protocol::OpenaiChat, + "smart", + "gpt-6", + "/v1/chat/completions", + &chat_req("smart", stream), + ) + .await; + assert_eq!(got.body, original, "一个字节都不动"); + assert_eq!(got.headers["openai-model"], "gpt-6-mini"); + assert_eq!(got.answered.as_deref(), Some("gpt-6-mini")); + } +} + +/// 发出去的就是客户端要的名称:上游写的带日期的快照照原样交给客户端 +#[tokio::test] +async fn nothing_changes_when_the_sent_name_is_the_clients() { + for stream in [false, true] { + let mut reply = if stream { + ok(SSE, chat_stream(GPT_A)) + } else { + ok(JSON, chat_whole(GPT_A)) + }; + reply.headers = vec![("openai-model", GPT_A)]; + let original = reply.body.clone(); + let (got, _) = once( + reply, + Protocol::OpenaiChat, + "gpt-6", + "", + "/v1/chat/completions", + &chat_req("gpt-6", stream), + ) + .await; + assert_eq!(got.body, original); + assert_eq!(got.headers["openai-model"], GPT_A); + } +} + +/// 上游回的错误是它的原话,不改 +#[tokio::test] +async fn an_upstream_error_is_not_touched() { + let body = r#"{"error":{"message":"bad","type":"invalid_request_error"},"model":"gpt-6"}"#; + let reply = Reply { + status: 400, + content_type: JSON, + headers: vec![("openai-model", "gpt-6")], + body: body.into(), + }; + let (got, _) = once( + reply, + Protocol::OpenaiChat, + "smart", + "gpt-6", + "/v1/chat/completions", + &chat_req("smart", false), + ) + .await; + assert_eq!(got.status, 400); + assert_eq!(got.body, body); + assert_eq!(got.headers["openai-model"], "gpt-6"); +} + +// ---- 故障转移 ---- + +/// 第一家失败、第二家接下,第二阶段的规则给第二家发了另一个名字:**按接下的那一跳发出去的 +/// 名字认**。按第一跳的 `gpt-6` 认的话,Claude 的回答就对不上,原样漏给客户端 +#[tokio::test] +async fn after_failover_the_name_the_answering_hop_sent_decides() { + let (official, official_seen) = upstream(Reply { + status: 503, + content_type: JSON, + headers: Vec::new(), + body: r#"{"error":{"message":"overloaded"}}"#.into(), + }) + .await; + let (relay, relay_seen) = upstream(ok(SSE, anthropic_stream())).await; + let routes = r#" +- name: default + rules: + - name: 先试 GPT + when: { model: smart } + set: { model: gpt-6 } + - name: 中转上换成 Claude + when: { provider_would_be: relay } + set: { model: claude-opus-5 } + - name: 兜底 + to: 全部 +"#; + let (gw, mut rx) = serve( + vec![ + provider("official", official, Protocol::Anthropic), + provider("relay", relay, Protocol::Anthropic), + ], + vec![tw_engine::Group { + name: "全部".into(), + kind: tw_engine::GroupType::Fallback, + providers: vec!["official".into(), "relay".into()], + selected: None, + }], + routes, + ) + .await; + let got = ask(gw, &mut rx, "/v1/messages", &anthropic_req("smart", true)).await; + assert_eq!(got.status, 200, "{}", got.body); + assert_eq!(sent_to(&official_seen).1["model"], "gpt-6"); + assert_eq!(sent_to(&relay_seen).1["model"], "claude-opus-5"); + all_are(&got, "smart"); + assert_eq!(got.answered.as_deref(), Some(CLAUDE_A)); +} + +// ---- 插件 ---- + +/// 插件在请求钩子里换了发出去的模型名:和规则改写一样,回答里写回客户端要的那个 +#[tokio::test] +async fn a_name_a_plugin_sent_is_renamed_back() { + for stream in [false, true] { + let (up, seen) = upstream(if stream { + ok(SSE, anthropic_stream()) + } else { + ok(JSON, anthropic_whole()) + }) + .await; + let swap = Double::new("swap model") + .permit(&[Permission::Params]) + .on_request(|mut view, _| { + view["params"]["model"] = json!("claude-opus-5"); + Invocation::ok(RequestOutcome::Changed(view)) + }); + let (gw, mut rx) = serve_with( + vec![provider("up", up, Protocol::Anthropic)], + Vec::new(), + &routes("opus", "", "up"), + vec![swap], + ) + .await; + let got = ask(gw, &mut rx, "/v1/messages", &anthropic_req("opus", stream)).await; + assert_eq!(got.status, 200, "{}", got.body); + assert_eq!(sent_to(&seen).1["model"], "claude-opus-5"); + all_are(&got, "opus"); + assert_eq!(got.answered.as_deref(), Some(CLAUDE_A)); + } +} + +/// 回答钩子排在改名之后:插件补出来的帧照抄前一帧的外壳,抄到的是客户端的名称;插件的 +/// `ctx.model` 仍是发出去的那个 +#[tokio::test] +async fn reply_hooks_see_the_sent_name_in_ctx_and_their_frames_carry_the_clients() { + for (mode, stream) in [ + (tw_api::ReplyMode::Block, true), + (tw_api::ReplyMode::Stream, true), + (tw_api::ReplyMode::Block, false), + ] { + let (up, _) = upstream(if stream { + ok(SSE, chat_stream(GPT_A)) + } else { + ok(JSON, chat_whole(GPT_A)) + }) + .await; + let ctx: Arc>> = Arc::default(); + let saw = ctx.clone(); + let upper = Double::new("upper") + .permit(&[Permission::ReplyText]) + .mode(mode) + .on_reply(true, false, false, move |c| { + *saw.lock().unwrap() = Some(c); + Ok(Box::new(double::Closures { + text: Box::new(|t| Invocation::ok(Some(t.to_uppercase()))), + end: Box::new(|| Invocation::ok(None)), + tool: Box::new(|_| { + Invocation::ok(tw_gateway::plugin::ToolCallOutcome::Unchanged) + }), + })) + }); + let (gw, mut rx) = serve_with( + vec![provider("up", up, Protocol::OpenaiChat)], + Vec::new(), + &routes("smart", "gpt-6", "up"), + vec![upper], + ) + .await; + let got = ask( + gw, + &mut rx, + "/v1/chat/completions", + &chat_req("smart", stream), + ) + .await; + assert_eq!(got.status, 200, "{}", got.body); + assert!(got.body.contains("HI"), "插件没改到:{}", got.body); + all_are(&got, "smart"); + let c = ctx.lock().unwrap().clone().expect("回答钩子没起"); + assert_eq!( + (&c["model"], &c["requested_model"]), + (&json!("gpt-6"), &json!("smart")) + ); + } +} diff --git a/crates/tw-gateway/tests/models.rs b/crates/tw-gateway/tests/models.rs index ddc01612..c56c956e 100644 --- a/crates/tw-gateway/tests/models.rs +++ b/crates/tw-gateway/tests/models.rs @@ -27,9 +27,11 @@ async fn upstream(name: &'static str, models: &'static [&'static str]) -> Socket let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); let model = v["model"].as_str().unwrap_or_default().to_string(); if models.contains(&model.as_str()) { + // 收到的模型名放在 `sent` 里:回答里的 `model` 会被网关写回客户端用的名称 + // (见 `tw_gateway::answer_model`),看不出发出去的是哪个 ( axum::http::StatusCode::OK, - axum::Json(serde_json::json!({ "by": name, "model": model })), + axum::Json(serde_json::json!({ "by": name, "sent": model })), ) } else { // 中转站对它没有的模型就是这么回的:一个 4xx,不会触发故障转移 @@ -319,7 +321,7 @@ async fn a_rule_that_renames_the_model_is_judged_by_the_name_it_sends() { let (status, body) = ask(gw, "claude-sonnet-4-5").await; assert_eq!(status, 200, "{body}"); assert_eq!(body["by"], "zhipu"); - assert_eq!(body["model"], "glm-5"); + assert_eq!(body["sent"], "glm-5"); } #[tokio::test] @@ -356,7 +358,7 @@ async fn a_rename_for_one_upstream_counts_for_that_upstream_only() { let (status, body) = ask(gw, "claude-sonnet-4-5").await; assert_eq!(status, 200, "{body}"); assert_eq!(body["by"], "zhipu"); - assert_eq!(body["model"], "glm-5"); + assert_eq!(body["sent"], "glm-5"); // 官方回来了:它有这个名字,排在前面,照旧先给它,名字不改 cfg.providers[0].disabled = false; @@ -365,7 +367,7 @@ async fn a_rename_for_one_upstream_counts_for_that_upstream_only() { let (status, body) = ask(gw, "claude-sonnet-4-5").await; assert_eq!(status, 200, "{body}"); assert_eq!(body["by"], "official"); - assert_eq!(body["model"], "claude-sonnet-4-5"); + assert_eq!(body["sent"], "claude-sonnet-4-5"); } #[tokio::test] diff --git a/crates/tw-gateway/tests/plugins_ws.rs b/crates/tw-gateway/tests/plugins_ws.rs index 2c2b9849..7ea9d4f8 100644 --- a/crates/tw-gateway/tests/plugins_ws.rs +++ b/crates/tw-gateway/tests/plugins_ws.rs @@ -21,7 +21,8 @@ use tw_gateway::plugin::{ State as PluginState, ToolCallOutcome, }; -/// 假上游:记下收到的每一帧,每个 `response.create` 回一次完整的回答 +/// 假上游:记下收到的每一帧,每个 `response.create` 回一次完整的回答。回答里的模型名是 +/// 请求里那个的带日期快照(上游常这么写) async fn upstream() -> (SocketAddr, Arc>>) { let seen: Arc>> = Arc::default(); let app = Router::new() @@ -44,6 +45,7 @@ async fn answer(mut sock: WebSocket, seen: Arc>>) { while let Some(Ok(m)) = sock.recv().await { let Message::Text(t) = m else { continue }; let v: Value = serde_json::from_str(&t).unwrap_or(Value::Null); + let model = format!("{}-2026-09-30", v["model"].as_str().unwrap_or_default()); let n = { let mut s = seen.lock().unwrap(); s.push(v); @@ -60,7 +62,7 @@ async fn answer(mut sock: WebSocket, seen: Arc>>) { let frames = vec![ ev( "response.created", - json!({"response":{"id":id,"status":"in_progress","output":[]}}), + json!({"response":{"id":id,"status":"in_progress","model":model,"output":[]}}), ), ev( "response.output_item.added", @@ -84,7 +86,7 @@ async fn answer(mut sock: WebSocket, seen: Arc>>) { ), ev( "response.completed", - json!({"response":{"id":id,"status":"completed","output":[{"type":"message","id":"msg","role":"assistant","content":[{"type":"output_text","text":"hello"}]}]}}), + json!({"response":{"id":id,"status":"completed","model":model,"output":[{"type":"message","id":"msg","role":"assistant","content":[{"type":"output_text","text":"hello"}]}]}}), ), ]; for f in frames { @@ -239,6 +241,50 @@ async fn each_response_create_goes_through_the_request_hook_and_each_answer_thro } } +/// 插件换了发出去的模型名:回答里写着的、和发出去的是同一个模型的名称写回客户端要的那个 +/// (见 `tw_gateway::answer_model`)。没换的那一轮原样 +#[tokio::test] +async fn a_model_a_plugin_renamed_is_written_back_in_the_answer() { + let (up, seen) = upstream().await; + let swap = Double::new("swap") + .permit(&[Permission::Params]) + .on_request(|mut view, ctx| { + if ctx["requested_model"] == "codex" { + view["params"]["model"] = json!("gpt-5.1-codex"); + } + Invocation::ok(RequestOutcome::Changed(view)) + }); + let gw = gateway(up, vec![entry("swap", swap)]).await; + let mut c = connect(gw).await; + let models = |frames: &[String]| -> Vec { + frames + .iter() + .filter_map(|f| serde_json::from_str::(f).ok()) + .filter_map(|v| v["response"]["model"].as_str().map(str::to_string)) + .collect() + }; + let ask = |model: &str| { + let mut v: Value = match create("hi") { + WsMsg::Text(t) => serde_json::from_str(&t).unwrap(), + _ => unreachable!(), + }; + v["model"] = json!(model); + WsMsg::Text(v.to_string().into()) + }; + c.send(ask("codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(seen.lock().unwrap()[0]["model"], "gpt-5.1-codex"); + assert_eq!(models(&frames), ["codex", "codex"], "{frames:?}"); + + c.send(ask("gpt-5.1-codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + models(&frames), + ["gpt-5.1-codex-2026-09-30", "gpt-5.1-codex-2026-09-30"], + "{frames:?}" + ); +} + #[tokio::test] async fn a_rejected_response_create_cuts_the_connection_with_the_reason() { let (up, seen) = upstream().await; diff --git a/crates/tw-pricing/src/lib.rs b/crates/tw-pricing/src/lib.rs index d9faf92c..7959f0d3 100644 --- a/crates/tw-pricing/src/lib.rs +++ b/crates/tw-pricing/src/lib.rs @@ -18,6 +18,7 @@ //! 记成 0 —— 那是在撒谎,而一个会撒谎的成本面板不如没有。 mod book; +pub mod model_name; pub mod name; mod sheet; mod table; diff --git a/crates/tw-store/src/model_name.rs b/crates/tw-pricing/src/model_name.rs similarity index 98% rename from crates/tw-store/src/model_name.rs rename to crates/tw-pricing/src/model_name.rs index c38d4b34..e5f1ffa7 100644 --- a/crates/tw-store/src/model_name.rs +++ b/crates/tw-pricing/src/model_name.rs @@ -1,11 +1,12 @@ //! 两个模型名说的是不是同一个模型。 //! -//! 上游体检拿发出去的模型名和上游在回答里写的比(见 [`crate::health`])。**两边写法 +//! 上游体检拿发出去的模型名和上游在回答里写的比(见 `tw_store::health`),网关把回答里的 +//! 模型名换回客户端用的名称之前也拿它比(见 `tw_gateway::answer_model`)。**两边写法 //! 不一样是常态**:请求里写别名、回答里写带日期的快照(`claude-sonnet-4-5` → //! `claude-sonnet-4-5-20250929`),中转站带着厂商前缀(`anthropic/claude-sonnet-4.5`), //! Bedrock 有它自己的一套(`us.anthropic.claude-sonnet-4-5-20250929-v1:0`)。 //! -//! **方向和价目表那边(`tw_pricing::name`)相反。**查价宁可查不到,也不能把一个模型 +//! **方向和价目表那边([`crate::name`])相反。**查价宁可查不到,也不能把一个模型 //! 猜成另一个、给出一个自信的错数字;这里宁可把两个名字认成同一个,也不能错判一次 //! 「对不上」—— 那是在冤枉一家诚实的上游。所以这里抹掉的是**一切不是换了模型的 //! 差别**,但模型本身的身份(家族、大小、代数、变体)一个字都不动:`opus` 和 `sonnet`、 @@ -127,11 +128,7 @@ const BEDROCK_VENDORS: &[&str] = &[ fn bedrock(id: &str) -> &str { // 地域前缀:和价目表认的是同一张清单。后面得还是一个 `厂商.模型` 的 id let id = match id.split_once('.') { - Some((geo, rest)) - if tw_pricing::name::BEDROCK_GEOS.contains(&geo) && rest.contains('.') => - { - rest - } + Some((geo, rest)) if crate::name::BEDROCK_GEOS.contains(&geo) && rest.contains('.') => rest, _ => id, }; let Some(name) = id diff --git a/crates/tw-pricing/src/name.rs b/crates/tw-pricing/src/name.rs index 74f96987..fbf47ff9 100644 --- a/crates/tw-pricing/src/name.rs +++ b/crates/tw-pricing/src/name.rs @@ -110,7 +110,7 @@ pub fn cross_platform(model: &str) -> Vec { /// 之间调度。 /// /// **只认这几个**,取自价格数据集里 Bedrock 的键。认不出的前缀不剥 —— 查不到价 -/// 就说查不到。上游体检比模型名时认的也是这一张(`tw_store::model_name`) +/// 就说查不到。上游体检比模型名时认的也是这一张([`crate::model_name`]) pub const BEDROCK_GEOS: &[&str] = &["us", "eu", "apac", "jp", "au", "us-gov", "global"]; /// 剥掉推理配置文件的地域前缀,剩下的是模型 id。不是配置文件的 `None`。 diff --git a/crates/tw-store/src/lib.rs b/crates/tw-store/src/lib.rs index c654ce25..eb73756f 100644 --- a/crates/tw-store/src/lib.rs +++ b/crates/tw-store/src/lib.rs @@ -11,7 +11,9 @@ pub mod blobs; pub mod db; pub mod health; -pub mod model_name; +/// 两个模型名是不是同一个模型。网关也要用(把回答里的模型名换回客户端用的名称), +/// 所以放在两边都依赖的价目表那一层 +pub use tw_pricing::model_name; pub mod recorder; pub mod search; pub mod task; From d34dca701ec36a6348aa6d45f80c4bd28cdc0ef8 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:57:14 +0800 Subject: [PATCH 04/18] tw-api: PinnedModel, the shared shape of a pinned rule target and an alias's served name Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/src/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index db0e4725..b8339167 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -1995,6 +1995,16 @@ pub enum ReferenceView { Group { group: String }, } +/// 规则「指定模型」里的一项,也是别名在某家上游实际发出的名称:哪家上游、发什么模型名。 +/// +/// **模型名原样发出**,不经过别名表 —— 和配置里 `to: [{provider, model}]` 同一个形状。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PinnedModel { + pub provider: String, + pub model: String, +} + /// 一条规则。 /// /// **是全文,不是摘要** —— 编辑对话框靠它回填:条件、去向、拒绝原因、 From 19f81b6b52fa8750878c8c1f61bbf6415aa8c444 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:01:55 +0800 Subject: [PATCH 05/18] docs: model aliases and pinned models in the configuration reference Co-Authored-By: Claude Opus 5.5 --- docs/config.md | 53 +++++++++++++++++++++++++++++++++++++++++++- docs/config.zh-CN.md | 36 +++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index 81f9a551..42a84788 100644 --- a/docs/config.md +++ b/docs/config.md @@ -907,6 +907,40 @@ the same as an error status would. | `stream_start_wait_secs` | integer | `15` | Seconds to hold a streamed answer until its first content arrives. An error before then moves the request to the next upstream; after this long, what has arrived is passed on. From 1 to 120. | +### `aliases` + +A model alias is another name for the same model. Clients request it like any +model; each upstream receives the name it uses for that model. + +```yaml +aliases: + deepseek-v4.1: DeepSeek-v4.1-flash + claude-sonnet-5: + - claude-sonnet-5 + - us.anthropic.claude-sonnet-5-v1:0 + - anthropic/claude-sonnet-5 +``` + +- An upstream serves an alias when it offers one of the listed names within + its `models_only`, and receives the first such name in the list. Every + upstream that serves an alias takes part in failover for it. +- `GET /v1/models` lists aliases next to the upstream models, and the + original names stay available. +- An alias takes precedence over an upstream model of the same name: a + request for `claude-sonnet-5` above goes only to upstreams offering one of + the three names. List an upstream's own name when it should keep serving it. +- A key's `allow` and a rule's `when.model` written for an upstream model name + also cover the aliases that list it; written for an alias, they cover only + the alias. +- When the upstream answers with the model it was sent, the answer carries the + name the client asked for. An answer naming a different model is passed on + as it is. +- Prices and the upstream check-up use the name sent upstream. The request log + keeps the name the client asked for, and each attempt the name it sent. +- To send one key's requests to a particular upstream model, use a rule with + pinned models ([`routes[].rules[].to`](#cfg-routes-rules-to)) rather than an + alias: an alias changes what the name means for every client. + ### `groups` A group puts several upstreams behind one name. Rules send requests to a @@ -970,7 +1004,21 @@ order they are declared. #### `routes[].rules[].to` -Pinned models in `to`: each entry is an upstream and the model sent to it, tried in order. +`to` names an upstream or a group, or pins models: a list of upstreams, each +with the model sent to it, tried in order. A pinned model is sent as written, +without aliases or `set.model`, so a rule can send a key's requests to one +upstream's model even when an alias of the same name points elsewhere. + +```yaml +routes: + - name: default + rules: + - name: Opus on Bedrock + when: { model: claude-opus-5 } + to: + - { provider: bedrock, model: us.anthropic.claude-opus-5-v1:0 } + - { provider: anthropic, model: claude-opus-5 } +``` @@ -1009,6 +1057,9 @@ not an equality: `"200k"` alone is refused. #### `routes[].rules[].set` +`set.model` may name an alias; each upstream then receives its own name for +it. Answers carry the name the client asked for, as with aliases. + diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 8c5462cc..5915e4fd 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -716,6 +716,27 @@ security: | `stream_start_wait_secs` | 整数 | `15` | 流式回答在第一段内容到达前最多暂存的秒数。在此之前上游报错,请求换到下一家;超过这个时间,已收到的部分照常交给客户端。取值 1 到 120。 | +### `aliases` + +模型别名是同一个模型的另一个名称。客户端像请求其他模型一样请求它,每家上游收到的是自己对这个模型的叫法。 + +```yaml +aliases: + deepseek-v4.1: DeepSeek-v4.1-flash + claude-sonnet-5: + - claude-sonnet-5 + - us.anthropic.claude-sonnet-5-v1:0 + - anthropic/claude-sonnet-5 +``` + +- 上游在 `models_only` 范围内提供列出的任一名称,就能服务这个别名,收到的是列表里它提供的第一个名称。能服务同一别名的上游互为备用。 +- `GET /v1/models` 在上游模型旁一并列出别名,原来的名称照常可用。 +- 别名优先于同名的上游模型:上例中请求 `claude-sonnet-5` 只会发给提供这三个名称之一的上游。要让某家上游继续用自己的同名模型提供服务,把它的名称列进去。 +- 密钥的 `allow` 和规则的 `when.model` 写上游模型名时,对列有这个名称的别名同样有效;写别名时只对别名有效。 +- 上游答的是发给它的那个模型时,回答里的模型名写成客户端请求的名称;答的是别的模型,原样转发。 +- 计价和上游体检按发给上游的名称;请求记录保留客户端请求的名称,每次尝试记录实际发出的名称。 +- 要把某把密钥的请求发到某家上游的某个模型,用带指定模型的规则([`routes[].rules[].to`](#cfg-routes-rules-to)),不要用别名:别名会改变这个名称对所有客户端的含义。 + ### `groups` 策略组让多个上游合用一个名字。规则用 `to` 把请求交给策略组。 @@ -764,7 +785,18 @@ security: #### `routes[].rules[].to` -`to` 指定模型时,每一项是一个上游和发给它的模型,按顺序备用。 +`to` 写上游或策略组的名称,或者指定模型:一组上游,每家写明发给它的模型,按顺序备用。指定模型原样发出,不经过别名,也不受 `set.model` 影响;所以即使同名别名指向别处,规则仍能把某把密钥的请求发到某家上游的这个模型。 + +```yaml +routes: + - name: default + rules: + - name: Opus 走 Bedrock + when: { model: claude-opus-5 } + to: + - { provider: bedrock, model: us.anthropic.claude-opus-5-v1:0 } + - { provider: anthropic, model: claude-opus-5 } +``` @@ -801,6 +833,8 @@ security: #### `routes[].rules[].set` +`set.model` 可以写别名,每家上游收到各自对它的叫法。和别名一样,回答里的模型名写成客户端请求的名称。 + From c06303871c1b6eec989b98fc56db948b585d6533 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:14:11 +0800 Subject: [PATCH 06/18] Control API 38: rule targets may pin models, model entries name their aliases - RuleView.to / RuleInput.to are a RuleTarget: a string (upstream, group or __all__) or a list of PinnedModel, written exactly like `to` in the config (TypeScript: string | Array). Both directions map in tw-control, so the overview gives pinned targets back and saving a route, or a dry-run draft, keeps them; an empty list is left to the engine, which refuses it with engine.pinned_empty. - GET /models lists every alias (KnownModel.alias: its models; providers: listed, enabled upstreams where resolve finds a name), once even when a real model shares its name; real models carry the aliases that list them. - ModelRow.aliases: the aliases that list a model on an upstream's list. - CONTROL_API_VERSION 37 -> 38, with the notes for the whole alias feature. Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/src/lib.rs | 100 ++++++++++++- crates/tw-api/src/ts.rs | 18 +++ crates/tw-control/src/resources.rs | 2 + crates/tw-control/src/routes.rs | 203 +++++++++++++++++++++++--- crates/tw-control/tests/routes.rs | 227 +++++++++++++++++++++++++++++ 5 files changed, 525 insertions(+), 25 deletions(-) diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index b8339167..6d2bdc0f 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -738,7 +738,19 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// ([`ConfigRepairRequest`] → [`ConfigWritten`])照着修好写回。取值不在可选范围里、字段不认识 /// 这两种字段错有了自己的码:`config.unknown_variant`、`config.unknown_field`(以前是 /// `config.unparsable` 里的一句英文原话);修不了时 `control.config_not_repairable`。 -pub const CONTROL_API_VERSION: u32 = 37; +/// +/// **38 起有模型别名**:配置多了顶层的 `aliases`(一个名称 → 同一个模型在各家上游的名称, +/// 有序),新端点 `GET /aliases`、`POST /aliases`、`PUT /aliases/{name}`(可改名,密钥范围和 +/// 规则里等于旧名的地方在同一个版本里一起改)、`DELETE /aliases/{name}`、 +/// `POST /aliases/preview`、`GET /aliases/{name}/usage`。`GET /models` 的列表含别名 +/// ([`KnownModel::alias`];和真模型同名时只有别名那一项),真模型带着列出它的别名 +/// ([`KnownModel::aliases`]),上游模型清单的每一行也带([`ModelRow::aliases`])。规则的 +/// 去向可以是指定模型:[`RuleView::to`]、[`RuleInput::to`] 是 [`RuleTarget`],字符串或 +/// `[{provider, model}]` 的列表,和配置里的 `to` 一个写法。试算多了 `candidate_models`:每个 +/// 候选发出的模型名,以及它是别名、规则改写还是指定模型来的。发出的模型名和客户端写的不同、 +/// 上游答的又是同一个模型时,回答里的模型名(含 `openai-model`、`x-openai-model` 回应头) +/// 写成客户端写的名称。照 37 写的界面读不懂列表形状的 `to`,保存规则时会把指定模型丢掉。 +pub const CONTROL_API_VERSION: u32 = 38; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] @@ -2005,6 +2017,27 @@ pub struct PinnedModel { pub model: String, } +/// 规则转发到哪里([`RuleView::to`]、[`RuleInput::to`])。 +/// +/// **线上和配置里的 `to` 一个写法**:一个字符串是上游或策略组的名字(`__all__` 是全部 +/// 上游),一个列表是指定模型 —— 「上游 + 模型」,按顺序备用,模型名原样发出。前端的 +/// 类型是 `string | Array`。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[serde(untagged)] +pub enum RuleTarget { + /// 上游或策略组的名字 + Name(String), + /// 指定模型,按顺序备用 + Models(Vec), +} + +impl From<&str> for RuleTarget { + fn from(name: &str) -> Self { + RuleTarget::Name(name.to_string()) + } +} + /// 一条规则。 /// /// **是全文,不是摘要** —— 编辑对话框靠它回填:条件、去向、拒绝原因、 @@ -2015,9 +2048,9 @@ pub struct RuleView { pub name: String, /// `when` 里写了的条件,按固定顺序。空 = 兜底 pub conditions: Vec, - /// 去向:上游名或组名。拒绝的规则和只附加改写的规则没有 + /// 去向:上游名或组名,或者指定模型的列表。拒绝的规则和只附加改写的规则没有 #[serde(default, skip_serializing_if = "Option::is_none")] - pub to: Option, + pub to: Option, /// 命中就拒绝。值是返回给客户端的原因 #[serde(default, skip_serializing_if = "Option::is_none")] pub deny: Option, @@ -2823,6 +2856,9 @@ pub struct ModelRow { pub price_source: Option, /// 价格是从别的平台借来的。**按它算出来的钱是估算** pub estimated: bool, + /// 列出了这个模型名的别名,按别名表的顺序。**不论这家发不发它**:别名在这家按列表 + /// 顺序取它有的第一个,排在后面的名字也算列进了这个别名 + pub aliases: Vec, } fn direct() -> String { @@ -2948,8 +2984,9 @@ pub struct RuleInput { /// 空 = 兜底,匹配全部请求 #[serde(default)] pub conditions: Vec, + /// 去向,和 [`RuleView::to`] 同一个写法。指定模型的列表原样写进配置,交空列表会被拒 #[serde(default, skip_serializing_if = "Option::is_none")] - pub to: Option, + pub to: Option, /// 拒绝,以及返回给客户端的原因 #[serde(default, skip_serializing_if = "Option::is_none")] pub deny: Option, @@ -3028,11 +3065,22 @@ pub struct GroupSave { } /// 网关知道的一个模型,以及能提供它的上游(已按启用范围与停用过滤)。 +/// +/// **别名也是一项**:`id` 是别名的名称,`alias` 是它的模型列表。别名和某个真模型同名 +/// 时只有一项,就是别名 —— 请求这个名称按别名处理。 #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct KnownModel { pub id: String, + /// 有模型清单的上游里提供它的那几家,按配置里上游的顺序。别名是能服务它的那几家: + /// 它的模型列表里至少有一个名称在这家的清单里、也在启用范围里。没有清单的上游 + /// 不在这里(不知道它有什么),但照样收这个名称的请求 pub providers: Vec, + /// 这一项是别名:它的模型列表,按顺序 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option>, + /// 列出了这个模型名的别名,按别名表的顺序。别名自己这一项是空的 + pub aliases: Vec, } /// 聚合类端点的时间窗(`GET /summary`、`/latency`)。**缺省是「今天」而不是 @@ -5688,6 +5736,50 @@ mod tests { assert!(v.get("input_estimate").is_none(), "{v}"); } + /// 规则的去向和配置里的 `to` 一个写法:字符串是名字,列表是指定模型。 + #[test] + fn a_rule_target_is_a_name_or_a_list_of_pinned_models() { + let name: RuleTarget = serde_json::from_str(r#""__all__""#).unwrap(); + assert_eq!(name, RuleTarget::from("__all__")); + assert_eq!(serde_json::to_string(&name).unwrap(), r#""__all__""#); + + let text = r#"[{"provider":"bedrock","model":"us.anthropic.claude-opus-5-v1:0"},{"provider":"anthropic","model":"claude-opus-5"}]"#; + let pinned: RuleTarget = serde_json::from_str(text).unwrap(); + let pin = |provider: &str, model: &str| PinnedModel { + provider: provider.into(), + model: model.into(), + }; + assert_eq!( + pinned, + RuleTarget::Models(vec![ + pin("bedrock", "us.anthropic.claude-opus-5-v1:0"), + pin("anthropic", "claude-opus-5"), + ]) + ); + assert_eq!(serde_json::to_string(&pinned).unwrap(), text, "顺序不变"); + + // 一项不是列表,哪一种都不是 + assert!(serde_json::from_str::(r#"{"provider":"a","model":"m"}"#).is_err()); + + // 规则视图里没有去向就不带这个字段 + let view = RuleView { + name: "r".into(), + conditions: vec![], + to: Some(pinned), + deny: None, + set: None, + catch_all: true, + phase_two: false, + shadowed: false, + }; + let v = serde_json::to_value(&view).unwrap(); + assert_eq!(v["to"][1]["model"], "claude-opus-5"); + let back: RuleInput = serde_json::from_value(v).unwrap(); + assert_eq!(back.to, view.to, "视图交回来就是同一个去向"); + let none = RuleView { to: None, ..view }; + assert!(serde_json::to_value(&none).unwrap().get("to").is_none()); + } + #[test] fn status_round_trips() { let s = Status { diff --git a/crates/tw-api/src/ts.rs b/crates/tw-api/src/ts.rs index 799e3b18..789bae44 100644 --- a/crates/tw-api/src/ts.rs +++ b/crates/tw-api/src/ts.rs @@ -517,6 +517,24 @@ mod tests { ); } + /// 规则的去向和配置里一个写法;已知模型和上游模型清单说得出别名。 + #[test] + fn rule_targets_and_model_entries_carry_the_alias_shapes() { + let ts = typescript(); + assert_eq!( + decl_of(&ts, "RuleTarget"), + "export type RuleTarget = string | Array" + ); + assert!(decl_of(&ts, "RuleView").contains("to?: RuleTarget")); + assert!(decl_of(&ts, "RuleInput").contains("to?: RuleTarget")); + let known = decl_of(&ts, "KnownModel"); + for field in ["alias?: Array", "aliases: Array"] { + assert!(known.contains(field), "{field}: {known}"); + } + let row = decl_of(&ts, "ModelRow"); + assert!(row.contains("aliases: Array"), "{row}"); + } + #[test] fn it_writes_one_file() { let dir = std::env::temp_dir().join(format!("tw-api-ts-{}", std::process::id())); diff --git a/crates/tw-control/src/resources.rs b/crates/tw-control/src/resources.rs index d64096ee..aaeb11ff 100644 --- a/crates/tw-control/src/resources.rs +++ b/crates/tw-control/src/resources.rs @@ -293,6 +293,7 @@ fn models_view( ) -> tw_api::ProviderModelsView { let book = s.gateway.pricing.load(); let date = &book.table().date; + let cfg = s.config(); tw_api::ProviderModelsView { provider: p.name.clone(), source: listing.source.into(), @@ -313,6 +314,7 @@ fn models_view( }), price_source: r.as_ref().map(|r| tw_store::price_source(&r.source, date)), estimated: r.as_ref().is_some_and(|r| r.cross_platform), + aliases: crate::routes::aliases_listing(&cfg, &id), id, } }) diff --git a/crates/tw-control/src/routes.rs b/crates/tw-control/src/routes.rs index 3d2e683d..1c7655ef 100644 --- a/crates/tw-control/src/routes.rs +++ b/crates/tw-control/src/routes.rs @@ -257,12 +257,7 @@ fn to_route(input: &tw_api::RouteInput, cfg: &tw_config::Config) -> Result Result { let name = checked_name(&input.name, "rule")?; let when = when_from(&name, &input.conditions, cfg)?; - let to = input - .to - .as_deref() - .map(str::trim) - .filter(|t| !t.is_empty()) - .map(tw_engine::Target::from); + let to = input.to.as_ref().and_then(target_from); let deny = match input.deny.as_deref().map(str::trim) { Some("") => { return Err(msg!( @@ -301,6 +296,40 @@ pub(crate) fn to_rule(input: &tw_api::RuleInput, cfg: &tw_config::Config) -> Res }) } +/// 界面交过来的去向 → 配置里的 `to`。名字是空的就是没有去向;**指定模型的列表原样 +/// 交给引擎校验**,空列表、不存在的上游、空的模型名都由它说出是哪条规则的哪一项。 +fn target_from(t: &tw_api::RuleTarget) -> Option { + match t { + tw_api::RuleTarget::Name(n) => { + let n = n.trim(); + (!n.is_empty()).then(|| tw_engine::Target::from(n)) + } + tw_api::RuleTarget::Models(list) => Some(tw_engine::Target::Models( + list.iter() + .map(|p| tw_engine::Pinned { + provider: p.provider.trim().to_string(), + model: p.model.trim().to_string(), + }) + .collect(), + )), + } +} + +/// 配置里的 `to` → 视图。和 [`target_from`] 互逆。 +fn target_view(t: &tw_engine::Target) -> tw_api::RuleTarget { + match t { + tw_engine::Target::Name(n) => tw_api::RuleTarget::Name(n.clone()), + tw_engine::Target::Models(list) => tw_api::RuleTarget::Models( + list.iter() + .map(|p| tw_api::PinnedModel { + provider: p.provider.clone(), + model: p.model.clone(), + }) + .collect(), + ), + } +} + /// 客户端格式,和请求路径判出来的那一套一致。 const DIALECTS: &[&str] = &["anthropic", "openai-chat", "openai-responses", "gemini"]; /// 辅助请求的类别。`assistant_internal` 是五类的总称。 @@ -612,20 +641,62 @@ fn to_group(input: &tw_api::GroupInput, cfg: &tw_config::Config) -> Result) -> Json> { - let catalog = s.gateway.catalog.load(); - Json( - catalog - .all() - .into_iter() - .map(|id| tw_api::KnownModel { - id: id.to_string(), - providers: catalog.providers_for(id).to_vec(), - }) - .collect(), - ) + Json(known(&s.config(), &s.gateway.catalog.load())) +} + +/// 目录里的真模型加上别名表里的每个别名,按名称排。 +/// +/// - 真模型:提供它的上游照目录,带着列出它的别名。和某个别名同名的真模型不单列 —— +/// 请求这个名称按别名处理(别名优先)。 +/// - 别名:**每一个都列出来**,哪家都服务不了的也列:它是配置里写下的名称,规则条件 +/// 要能选到它。上游是有清单、`resolve` 在这家有结果的那几家 —— 和真模型一样只算 +/// 有清单的;没有清单的上游照样收它的请求,只是不知道它有什么。 +fn known(cfg: &tw_config::Config, catalog: &tw_engine::Catalog) -> Vec { + let mut out: Vec = catalog + .all() + .into_iter() + .filter(|id| !cfg.aliases.contains(id)) + .map(|id| tw_api::KnownModel { + id: id.to_string(), + providers: catalog.providers_for(id).to_vec(), + alias: None, + aliases: aliases_listing(cfg, id), + }) + .collect(); + out.extend(cfg.aliases.iter().map(|a| tw_api::KnownModel { + id: a.name.clone(), + providers: serving_alias(cfg, catalog, &a.name), + alias: Some(a.models.clone()), + aliases: Vec::new(), + })); + out.sort_by(|a, b| a.id.cmp(&b.id)); + out +} + +/// 能服务别名 `name` 的上游,按配置里的顺序:没停用、有清单,`resolve` 在这家取得到 +/// 名称(清单里有,也在启用范围里)。 +fn serving_alias(cfg: &tw_config::Config, catalog: &tw_engine::Catalog, name: &str) -> Vec { + cfg.providers + .iter() + .filter(|p| !p.disabled) + .filter(|p| { + tw_gateway::models::resolve(cfg, catalog, p, name) + .is_some_and(|m| catalog.offers(&p.name, &m) == Some(true)) + }) + .map(|p| p.name.clone()) + .collect() +} + +/// 列出了模型名 `model` 的别名,按别名表的顺序。 +pub(crate) fn aliases_listing(cfg: &tw_config::Config, model: &str) -> Vec { + cfg.aliases + .iter() + .filter(|a| a.models.iter().any(|m| m == model)) + .map(|a| a.name.clone()) + .collect() } // ─────────────────────────────────────────────────────────── 视图 @@ -635,8 +706,7 @@ pub(crate) fn rule_view(r: &Rule, n: tw_engine::RuleNotes) -> tw_api::RuleView { tw_api::RuleView { name: r.name.clone(), conditions: crate::describe_when(&r.when), - // 指定模型在这一版的视图里还没有写法(协议 38 起 `to` 才能是列表),先不给 - to: r.to.as_ref().and_then(|t| t.name()).map(str::to_string), + to: r.to.as_ref().map(target_view), deny: r.deny.clone(), set: r .set @@ -679,6 +749,97 @@ fn name_taken(what: &'static str, name: &str) -> ApplyError { }) } +#[cfg(test)] +mod targets { + use super::*; + + fn cfg() -> tw_config::Config { + tw_config::try_parse( + "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: a\n base_url: https://x\n key: k\n - name: b\n base_url: https://y\n key: k\n", + ) + .unwrap() + } + + fn input(to: Option) -> tw_api::RuleInput { + tw_api::RuleInput { + name: "r".into(), + conditions: vec![], + to, + deny: None, + set: None, + } + } + + fn pin(provider: &str, model: &str) -> tw_api::PinnedModel { + tw_api::PinnedModel { + provider: provider.into(), + model: model.into(), + } + } + + /// 指定模型交进来、写进配置、再读成视图,还是那个列表、那个顺序。以前视图把它说成 + /// 没有去向,界面原样交回来就把它丢了 + #[test] + fn a_pinned_target_comes_back_from_the_view_as_it_went_in() { + let c = cfg(); + let to = tw_api::RuleTarget::Models(vec![pin("b", "m-2"), pin("a", " m-1 ")]); + let rule = to_rule(&input(Some(to)), &c).unwrap(); + assert_eq!( + rule.to, + Some(tw_engine::Target::Models(vec![ + tw_engine::Pinned { + provider: "b".into(), + model: "m-2".into(), + }, + tw_engine::Pinned { + provider: "a".into(), + model: "m-1".into(), + }, + ])) + ); + let view = rule_view(&rule, Default::default()); + assert_eq!( + view.to, + Some(tw_api::RuleTarget::Models(vec![ + pin("b", "m-2"), + pin("a", "m-1") + ])) + ); + let again = tw_api::RuleInput { + name: view.name.clone(), + conditions: view.conditions.clone(), + to: view.to.clone(), + deny: view.deny.clone(), + set: view.set.clone(), + }; + assert_eq!(to_rule(&again, &c).unwrap().to, rule.to); + } + + #[test] + fn a_named_target_is_trimmed_and_a_blank_one_is_none() { + let c = cfg(); + let rule = to_rule(&input(Some(" a ".into())), &c).unwrap(); + assert_eq!(rule.to, Some("a".into())); + assert_eq!( + rule_view(&rule, Default::default()).to, + Some(tw_api::RuleTarget::from("a")) + ); + let mut blank = input(Some(" ".into())); + blank.deny = Some("不行".into()); + assert_eq!(to_rule(&blank, &c).unwrap().to, None); + } + + /// 空列表不悄悄当成没有去向:交给引擎,它说出是哪条规则 + #[test] + fn an_empty_pinned_list_is_refused_by_the_engine() { + let c = cfg(); + let rule = to_rule(&input(Some(tw_api::RuleTarget::Models(vec![]))), &c).unwrap(); + let err = c.engine().check_rules(&[rule]).unwrap_err().msg(); + assert_eq!(err.code, "engine.pinned_empty"); + assert_eq!(err.arg("rule"), "r"); + } +} + #[cfg(test)] mod msg_codes { use super::*; diff --git a/crates/tw-control/tests/routes.rs b/crates/tw-control/tests/routes.rs index 7998b901..261dd3ac 100644 --- a/crates/tw-control/tests/routes.rs +++ b/crates/tw-control/tests/routes.rs @@ -471,6 +471,124 @@ async fn changing_the_default_keeps_the_synthesised_one_as_an_ordinary_route() { assert!(!b.file().contains("default_route"), "{}", b.file()); } +// ─────────────────────────────────────────────────────────── 指定模型 + +/// 规则的去向可以是指定模型:交进来原样写进文件,概览给回同一个列表;界面把概览里的 +/// 规则原样交回来(只改了别的地方),指定模型还在。以前概览把它说成没有去向,界面 +/// 一保存就丢了 +#[tokio::test] +async fn pinned_models_survive_a_save_and_a_save_of_what_the_overview_gave_back() { + let b = bed(BASE); + let pinned = json!([ + { "provider": "中转", "model": "anthropic/claude-opus-5" }, + { "provider": "官方", "model": "claude-opus-5" }, + ]); + let opus = json!({ + "name": "Opus 走中转", + "conditions": [{ "field": "model", "values": ["claude-opus-5"] }], + "to": pinned, + }); + let (st, v) = call( + &b.app, + "POST", + "/routes", + json!({ "route": { "name": "指定", "rules": [opus, catch_all("__all__")] } }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let expected = Some(tw_engine::Target::Models(vec![ + tw_engine::Pinned { + provider: "中转".into(), + model: "anthropic/claude-opus-5".into(), + }, + tw_engine::Pinned { + provider: "官方".into(), + model: "claude-opus-5".into(), + }, + ])); + let written = |b: &Bed, route: &str| { + b.parsed() + .routes + .into_iter() + .find(|r| r.name == route) + .unwrap_or_else(|| panic!("没有「{route}」:{}", b.file())) + .rules + }; + assert_eq!(written(&b, "指定")[0].to, expected); + + let ov = b.overview().await; + let rules = find(&ov["routes"], "指定")["rules"].clone(); + assert_eq!(rules[0]["to"], pinned, "{rules}"); + assert_eq!(rules[1]["to"], "__all__"); + + // 编辑对话框回填的就是概览里的规则;改了名字,别的原样交回 + let (st, v) = call( + &b.app, + "PUT", + &format!("/routes/{}", enc("指定")), + json!({ "route": { "name": "指定模型", "rules": rules } }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let rules = written(&b, "指定模型"); + assert_eq!(rules[0].to, expected, "{}", b.file()); + assert_eq!(rules[1].to, Some("__all__".into())); +} + +/// 试算未保存的草稿:指定模型照写的读,候选就是列出来的上游、按列表顺序(中转的 +/// 清单里有它指定的那个名称,官方没有清单) +#[tokio::test] +async fn a_draft_with_pinned_models_can_be_tried_before_it_is_saved() { + let b = bed(BASE); + let (st, v) = call( + &b.app, + "POST", + "/dryrun", + json!({ "model": "claude-opus-5", "draft": { "name": "草稿", "rules": [ + { "name": "指定", "to": [ + { "provider": "中转", "model": "中转自有模型" }, + { "provider": "官方", "model": "claude-opus-5" }, + ] }, + ] } }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(v["rule"], "指定", "{v}"); + assert_eq!(v["candidates"], json!(["中转", "官方"]), "{v}"); + + let (st, v) = call( + &b.app, + "POST", + "/dryrun", + json!({ "model": "m", "draft": { "name": "草稿", "rules": [ + { "name": "空的", "to": [] }, + ] } }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); + assert_eq!(v["code"], "engine.pinned_empty", "{v}"); +} + +#[tokio::test] +async fn an_empty_pinned_list_is_refused_and_says_which_rule() { + let b = bed(BASE); + let before = b.file(); + let (st, v) = call( + &b.app, + "POST", + "/routes", + json!({ "route": { "name": "指定", "rules": [ + { "name": "空的", "to": [] }, + catch_all("__all__"), + ] } }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); + assert_eq!(v["code"], "engine.pinned_empty", "{v}"); + assert_eq!(v["args"]["rule"], "空的", "{v}"); + assert_eq!(b.file(), before); +} + // ─────────────────────────────────────────────────────────── 策略组 #[tokio::test] @@ -563,6 +681,115 @@ async fn known_models_come_from_the_same_catalog_as_the_model_list() { ); } +/// 别名:`sonnet` 两家都有(各叫各的),和中转上一个同名的真模型撞名;`opus` 只有 +/// 官方有;`nobody` 哪家都没有;没有清单的上游和停用的上游不算提供。 +const ALIASED: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 +clients: + - name: c + key: tw-a +providers: + - name: 官方 + base_url: https://api.anthropic.com + key: sk-a + models: [claude-sonnet-5, claude-opus-5] + - name: 中转 + base_url: https://relay.example.com + key: sk-b + models: [anthropic/claude-sonnet-5, sonnet] + - name: 没有清单 + base_url: https://other.example.com + key: sk-c + - name: 停用 + base_url: https://off.example.com + key: sk-d + models: [claude-sonnet-5] + disabled: true +aliases: + sonnet: [claude-sonnet-5, anthropic/claude-sonnet-5] + opus: claude-opus-5 + nobody: no-such-model +"; + +#[tokio::test] +async fn known_models_list_each_alias_once_and_say_which_aliases_list_a_model() { + let b = bed(ALIASED); + let (st, v) = call(&b.app, "GET", "/models", json!(null)).await; + assert_eq!(st, StatusCode::OK, "{v}"); + let ids: Vec<&str> = v + .as_array() + .unwrap() + .iter() + .map(|m| m["id"].as_str().unwrap()) + .collect(); + // 按名称排;`sonnet` 只有一项,就是别名 + assert_eq!( + ids, + [ + "anthropic/claude-sonnet-5", + "claude-opus-5", + "claude-sonnet-5", + "nobody", + "opus", + "sonnet" + ], + "{v}" + ); + + let sonnet = find_id(&v, "sonnet"); + assert_eq!( + sonnet["alias"], + json!(["claude-sonnet-5", "anthropic/claude-sonnet-5"]) + ); + assert_eq!(sonnet["providers"], json!(["官方", "中转"]), "{sonnet}"); + assert_eq!(sonnet["aliases"], json!([])); + assert_eq!(find_id(&v, "opus")["providers"], json!(["官方"])); + // 哪家都没有的别名也列出来,规则条件要能选到它 + let nobody = find_id(&v, "nobody"); + assert_eq!(nobody["providers"], json!([])); + assert_eq!(nobody["alias"], json!(["no-such-model"])); + + let real = find_id(&v, "claude-sonnet-5"); + assert!(real.get("alias").is_none(), "{real}"); + assert_eq!(real["aliases"], json!(["sonnet"])); + assert_eq!(real["providers"], json!(["官方"]), "停用的不算:{real}"); + assert_eq!( + find_id(&v, "anthropic/claude-sonnet-5")["aliases"], + json!(["sonnet"]) + ); + assert_eq!(find_id(&v, "claude-opus-5")["aliases"], json!(["opus"])); +} + +#[tokio::test] +async fn an_upstreams_model_rows_say_which_aliases_list_them() { + let b = bed(ALIASED); + let (st, v) = call( + &b.app, + "GET", + &format!("/providers/{}/models", enc("中转")), + json!(null), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let row = |id: &str| { + v["models"] + .as_array() + .unwrap() + .iter() + .find(|m| m["id"] == id) + .unwrap_or_else(|| panic!("没有「{id}」:{v}")) + .clone() + }; + assert_eq!( + row("anthropic/claude-sonnet-5")["aliases"], + json!(["sonnet"]) + ); + // 和别名同名、却没列进别名的真模型不算 + assert_eq!(row("sonnet")["aliases"], json!([])); +} + fn find_id<'a>(list: &'a serde_json::Value, id: &str) -> &'a serde_json::Value { list.as_array() .unwrap() From 6ec32d2db3a33bf71f0d74bad58aec4fda0b49cf Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:20:44 +0800 Subject: [PATCH 07/18] Model aliases, part 1: the catalog lists aliases, and admission knows them - tw-engine `Catalog`: `with_aliases` registers each alias under the listed upstreams that have any of its models (the first in list order is what goes out). Names come in two spaces: client names (`all`, `providers_for`, listing, admission) include aliases, and an alias takes its name from a real model it does not list; sent names (`offers`, `count_for`) stay each upstream's own list, so a pinned model or a phase-two rename is judged as written. Upstreams without a list register nothing, as before. New `alias`, `served`, `first_served`. - `resolve_allowed` / `admits` stay one function and inherit from real names to aliases: an `allow` pattern that matches any model of an alias lets the alias through; naming the alias never exposes its models. - tw-gateway: the published catalog carries the config's alias table. `models::sent_to` gives the name a candidate is sent (pinned as written, otherwise `resolve`) or why it cannot serve; `serving` takes the decision and uses it, so an alias skips upstreams that serve none of its models and a pinned candidate is judged on its pinned model. `fit` is the as-written check it was. - `/v1/models` and `/v1/models/{id}` list aliases, with the metadata of the first model of the alias some upstream serves, looked up for that upstream. - Admission: pinned candidates are judged at their own upstream; errors say which case it is, with new codes `gw.model.alias_unserved`, `gw.model.alias_unserved_rewritten`, `gw.model.pinned_not_offered`, `gw.model.pinned_not_allowed`. - The pinned-model test reads `sent`, which the fake upstream answers since the response-name change. Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/msg-codes.txt | 4 + crates/tw-control/src/dryrun.rs | 2 +- crates/tw-engine/src/catalog.rs | 361 ++++++++++++++++++++--- crates/tw-gateway/src/models.rs | 165 ++++++++++- crates/tw-gateway/src/server/listing.rs | 30 +- crates/tw-gateway/src/server/pipeline.rs | 121 ++++++-- crates/tw-gateway/tests/alias_catalog.rs | 306 +++++++++++++++++++ crates/tw-gateway/tests/models.rs | 4 +- 8 files changed, 913 insertions(+), 80 deletions(-) create mode 100644 crates/tw-gateway/tests/alias_catalog.rs diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index a06c33e4..5b71f780 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -272,11 +272,15 @@ gw.listen.nic_offline gw.listen.no_such_nic gw.listen.port_taken gw.method.not_forwarded +gw.model.alias_unserved +gw.model.alias_unserved_rewritten gw.model.no_upstream gw.model.no_upstream_available gw.model.no_upstream_rewritten gw.model.not_allowed gw.model.not_allowed_rewritten +gw.model.pinned_not_allowed +gw.model.pinned_not_offered gw.model.unknown gw.models.check_failed gw.models.empty diff --git a/crates/tw-control/src/dryrun.rs b/crates/tw-control/src/dryrun.rs index a671f9cf..8bf1ba58 100644 --- a/crates/tw-control/src/dryrun.rs +++ b/crates/tw-control/src/dryrun.rs @@ -230,7 +230,7 @@ pub async fn dry_run( // 每一家按它实际要的模型看:规则改写过的按改写后的算,和数据面一样 let asked = engine.models_asked(rules, &f, &d); let serving = - tw_gateway::models::serving(&rt.config, &s.gateway.catalog.load(), &asked); + tw_gateway::models::serving(&rt.config, &s.gateway.catalog.load(), &d, &asked); out.skipped = serving .skipped .iter() diff --git a/crates/tw-engine/src/catalog.rs b/crates/tw-engine/src/catalog.rs index 53da1f3d..a639c3cb 100644 --- a/crates/tw-engine/src/catalog.rs +++ b/crates/tw-engine/src/catalog.rs @@ -32,64 +32,116 @@ pub struct ProviderModels { /// /// **不含「这个模型走哪家」的决策** —— 只记录「谁能提供」。去向由路由 /// 决定,这里只回答准入。 +/// +/// 名称分两侧: +/// +/// - **客户端那一侧**([`Self::all`]、[`Self::providers_for`]、列表和准入):真模型 +/// 和别名都在。别名登记在**有清单、能服务它**的上游名下;和别名同名的真模型让给 +/// 别名(别名优先:请求这个名称就按别名处理,同名真模型没列进别名的那家服务不了它)。 +/// - **发出去的那一侧**([`Self::offers`]、[`Self::count_for`]):每家清单里的原名。 +/// 别名只是客户端的叫法,指定模型、第二阶段改写的名字原样发出,有没有看这家自己的清单。 #[derive(Debug, Clone, Default)] pub struct Catalog { - /// 模型 → 能提供它的 provider(保序,和声明顺序一致) + /// 客户端那一侧的名称 → 能提供它的 provider(保序,和声明顺序一致) by_model: BTreeMap>, + /// provider → 它清单里的模型(启用范围内) + offered: BTreeMap>, + /// provider,按声明顺序 + order: Vec, /// provider → 它说的方言 protocols: BTreeMap, /// 有模型清单的 provider。**不在里面的不是「什么都不提供」,是「不知道」** listed: BTreeSet, + /// 别名 → 它的模型列表(有序)。**整张表**,谁都服务不了的也在:继承要看它 + aliases: BTreeMap>, + /// 别名 → 有清单、能服务它的 provider 和发给它的名称,按声明顺序 + served: BTreeMap>, } impl Catalog { pub fn build(sources: &[ProviderModels]) -> Self { - let mut by_model: BTreeMap> = BTreeMap::new(); - let mut protocols = BTreeMap::new(); - let mut listed = BTreeSet::new(); + let mut c = Self::default(); for s in sources { - protocols.insert(s.provider.clone(), s.protocol.clone()); + c.protocols.insert(s.provider.clone(), s.protocol.clone()); + if !c.order.contains(&s.provider) { + c.order.push(s.provider.clone()); + } if s.known { - listed.insert(s.provider.clone()); + c.listed.insert(s.provider.clone()); } + let offered = c.offered.entry(s.provider.clone()).or_default(); for m in &s.models { - let v = by_model.entry(m.clone()).or_default(); + offered.insert(m.clone()); + let v = c.by_model.entry(m.clone()).or_default(); if !v.contains(&s.provider) { v.push(s.provider.clone()); } } } - Self { - by_model, - protocols, - listed, + c + } + + /// 登记别名表(配置里的 `aliases`,名称和它的模型列表,按书写顺序)。 + /// + /// 一个别名登记在哪家名下:**有清单**、清单里有它列表里任一名称的上游,发过去用 + /// 按列表顺序排在最前的那个(和 `tw_gateway::models::resolve` 对有清单的上游 + /// 得出的一样 —— 清单已经按启用范围过滤过)。没有清单的上游不登记,和真模型 + /// 一样不进列表;发不发给它是挑候选时的事,那时它照样当作能服务。 + /// + /// 和别名同名的真模型从客户端那一侧拿掉:别名列表里有这个名字的,那几家照样 + /// 以别名的身份在;没列进来的,请求这个名字也到不了它们。 + pub fn with_aliases<'a>( + mut self, + aliases: impl IntoIterator, + ) -> Self { + for (name, models) in aliases { + self.aliases.insert(name.to_string(), models.to_vec()); + self.by_model.remove(name); + let served: Vec<(String, String)> = self + .order + .iter() + .filter(|p| self.listed.contains(*p)) + .filter_map(|p| { + models + .iter() + .find(|m| self.offers(p, m) == Some(true)) + .map(|m| (p.clone(), m.clone())) + }) + .collect(); + if !served.is_empty() { + self.by_model.insert( + name.to_string(), + served.iter().map(|(p, _)| p.clone()).collect(), + ); + self.served.insert(name.to_string(), served); + } } + self } - /// 这家能不能提供这个模型。`None` = 不知道:它没有模型清单。 + /// 这家清单里有没有这个名字(原名,不经过别名表)。`None` = 不知道:它没有模型清单。 /// /// **不知道不等于不能。**把没有清单的上游当成「什么都没有」,一家不 /// 实现 `/v1/models` 的中转站就再也收不到请求 —— 而它可能什么都能服务。 pub fn offers(&self, provider: &str, model: &str) -> Option { - self.listed - .contains(provider) - .then(|| self.providers_for(model).iter().any(|p| p == provider)) + self.listed.contains(provider).then(|| { + self.offered + .get(provider) + .is_some_and(|ms| ms.contains(model)) + }) } - /// 这家能服务几个模型。 + /// 这家清单里有几个模型。别名不算 —— 它们是别家模型的另一个叫法。 pub fn count_for(&self, provider: &str) -> usize { - self.by_model - .values() - .filter(|ps| ps.iter().any(|p| p == provider)) - .count() + self.offered.get(provider).map_or(0, BTreeSet::len) } - /// 全集。 + /// 全集:客户端那一侧的名称,别名在内。 pub fn all(&self) -> Vec<&str> { self.by_model.keys().map(|s| s.as_str()).collect() } - /// 谁能提供这个模型。 + /// 谁能提供这个名称。别名是有清单、能服务它的那几家。 pub fn providers_for(&self, model: &str) -> &[String] { self.by_model .get(model) @@ -97,8 +149,43 @@ impl Catalog { .unwrap_or(&[]) } + /// 这个名称是别名时,它的模型列表(有序)。谁都服务不了的别名也有。 + pub fn alias(&self, name: &str) -> Option<&[String]> { + self.aliases.get(name).map(Vec::as_slice) + } + + /// 有清单、能服务这个别名的上游,和发给它的名称,按声明顺序。 + pub fn served(&self, alias: &str) -> &[(String, String)] { + self.served.get(alias).map(Vec::as_slice).unwrap_or(&[]) + } + + /// 这个别名按列表顺序第一个有上游提供的名称,和提供它的头一家。 + /// `/v1/models` 给别名的元数据按它查。不是别名、或者没有谁服务它时是 `None`。 + pub fn first_served(&self, alias: &str) -> Option<(&str, &str)> { + let served = self.served.get(alias)?; + self.aliases.get(alias)?.iter().find_map(|m| { + served + .iter() + .find(|(_, s)| s == m) + .map(|(p, s)| (p.as_str(), s.as_str())) + }) + } + pub fn is_empty(&self) -> bool { - self.by_model.is_empty() + self.offered.values().all(BTreeSet::is_empty) + } + + /// `allow` 放不放行这个名称。 + /// + /// **继承只从真名到别名**:写上游的模型名(glob)时,指向它的别名也放行 —— 别名 + /// 列表里任一名称匹配就算;写别名时只管别名本身,它列表里的模型不跟着放出来。 + fn allows(&self, name: &str, patterns: &[String]) -> bool { + let hit = |m: &str| patterns.iter().any(|p| glob_match(p, m)); + hit(name) + || self + .aliases + .get(name) + .is_some_and(|ms| ms.iter().any(|m| hit(m))) } /// **唯一的真相来源**。 @@ -107,6 +194,9 @@ impl Catalog { /// 不在里面 —— 同一个函数。这样列表和准入不可能不一致,而 one-api 和 /// new-api 都栽在「准入检查散落到各处」这件事上。 /// + /// 别名和真模型一起列:别名按能服务它的那几家过滤方言,按 [`Self::allows`] + /// 继承 `allow`。 + /// /// `allow` 的三种状态语义**必须分明**,因为那两个项目在这里正好相反 /// (一个里空表示全放行,另一个表示全禁止): /// @@ -118,8 +208,7 @@ impl Catalog { protocols: Option<&[&str]>, allow: Option<&[String]>, ) -> Vec { - let by_dialect: BTreeSet<&str> = self - .by_model + self.by_model .iter() .filter(|(_, provs)| match protocols { // 至少有一家能服务这种请求的上游提供它(同协议,或者有现成转换) @@ -132,17 +221,10 @@ impl Catalog { // 而那比多列几个更难查。 None => true, }) - .map(|(m, _)| m.as_str()) - .collect(); - - match allow { - None => by_dialect.into_iter().map(String::from).collect(), - Some(patterns) => by_dialect - .into_iter() - .filter(|m| patterns.iter().any(|p| glob_match(p, m))) - .map(String::from) - .collect(), - } + .map(|(m, _)| m) + .filter(|m| allow.is_none_or(|patterns| self.allows(m, patterns))) + .cloned() + .collect() } /// 这个客户端能用这个模型吗。和上面同一套逻辑。 @@ -322,4 +404,211 @@ mod tests { assert!(c.resolve_allowed(Some(&["anthropic"]), None).is_empty()); assert!(!c.admits("anything", None, None)); } + + // ─────────────────────────────────────────────── 别名 + + /// 同一个模型在三家叫法不同,外加一个没有清单的中转站 + fn with_aliases() -> Catalog { + aliased( + Catalog::build(&[ + pm( + "bedrock", + "bedrock", + &["us.anthropic.claude-sonnet-5-v1:0", "claude-haiku-5"], + ), + pm( + "anthropic", + "anthropic", + &["claude-sonnet-5", "claude-haiku-5"], + ), + // 有一个叫 gpt-x 的真模型,别名 gpt-x 却没列它 + pm( + "relay", + "openai-chat", + &["gpt-x", "anthropic/claude-sonnet-5"], + ), + pm("openai", "openai-chat", &["gpt-x-2026"]), + pm("no-list", "anthropic", &[]), + ]), + &[ + ( + "claude-sonnet-5", + &[ + "claude-sonnet-5", + "us.anthropic.claude-sonnet-5-v1:0", + "anthropic/claude-sonnet-5", + ], + ), + ("gpt-x", &["gpt-x-2026"]), + ("sonnet", &["claude-sonnet-5"]), + // 哪家都没有 + ("ghost", &["nobody-has-this"]), + ], + ) + } + + /// 给目录登记一张别名表 + fn aliased(c: Catalog, table: &[(&str, &[&str])]) -> Catalog { + let t: Vec<(String, Vec)> = table + .iter() + .map(|(n, ms)| (n.to_string(), owned(ms))) + .collect(); + c.with_aliases(t.iter().map(|(n, ms)| (n.as_str(), ms.as_slice()))) + } + + #[test] + fn an_alias_is_offered_by_each_listed_upstream_that_has_any_of_its_names() { + let c = with_aliases(); + // 按声明顺序;每家发它自己的那个名字 + assert_eq!( + c.providers_for("claude-sonnet-5"), + ["bedrock", "anthropic", "relay"] + ); + assert_eq!( + c.served("claude-sonnet-5"), + [ + ("bedrock".into(), "us.anthropic.claude-sonnet-5-v1:0".into()), + ("anthropic".into(), "claude-sonnet-5".into()), + ("relay".into(), "anthropic/claude-sonnet-5".into()), + ] + ); + assert_eq!(c.providers_for("sonnet"), ["anthropic"]); + // 谁都服务不了的别名不列,但继承还要看它的列表 + assert!(c.providers_for("ghost").is_empty()); + assert!(!c.all().contains(&"ghost")); + assert_eq!(c.alias("ghost"), Some(&owned(&["nobody-has-this"])[..])); + assert_eq!(c.alias("claude-haiku-5"), None); + // 别名不算进一家的模型数:那是别家模型的另一个叫法 + assert_eq!(c.count_for("anthropic"), 2); + } + + #[test] + fn an_upstream_without_a_list_does_not_list_an_alias() { + // 和真模型一样:不知道它有什么,就不替它承诺。发不发给它是挑候选时的事 + let c = aliased( + Catalog::build(&[pm("no-list", "anthropic", &[])]), + &[("sonnet", &["claude-sonnet-5"])], + ); + assert!(c.providers_for("sonnet").is_empty()); + assert!(c.is_empty()); + assert_eq!(c.offers("no-list", "claude-sonnet-5"), None); + } + + #[test] + fn an_alias_takes_its_name_from_a_real_model_it_does_not_list() { + let c = with_aliases(); + // relay 的 gpt-x 没列进别名 gpt-x:这个名称归别名,到不了 relay + assert_eq!(c.providers_for("gpt-x"), ["openai"]); + // 只在客户端那一侧让出来:relay 的清单里照样有 gpt-x(指定模型原样发它) + assert_eq!(c.offers("relay", "gpt-x"), Some(true)); + assert_eq!(c.offers("bedrock", "claude-sonnet-5"), Some(false)); + // 和自己列表里某个名称同名的别名只出现一次 + let all = c.all(); + assert_eq!(all.iter().filter(|m| **m == "claude-sonnet-5").count(), 1); + assert_eq!( + all, + [ + "anthropic/claude-sonnet-5", + "claude-haiku-5", + "claude-sonnet-5", + "gpt-x", + "gpt-x-2026", + "sonnet", + "us.anthropic.claude-sonnet-5-v1:0", + ] + ); + } + + #[test] + fn an_alias_nobody_serves_still_takes_its_name() { + // 请求这个名称按别名处理,而别名谁都服务不了:列出 relay 的同名真模型就是 + // 一个用不了的承诺 + let c = aliased( + Catalog::build(&[pm("relay", "openai-chat", &["gpt-x"])]), + &[("gpt-x", &["gpt-x-2026"])], + ); + assert!(c.all().is_empty()); + assert!(!c.admits("gpt-x", None, None)); + assert_eq!(c.offers("relay", "gpt-x"), Some(true)); + } + + #[test] + fn an_alias_is_filtered_by_the_dialects_of_the_upstreams_that_serve_it() { + let c = with_aliases(); + // gpt-x 只有 openai-chat 的上游服务它 + assert!(c.admits("gpt-x", Some(&["openai-chat"]), None)); + assert!(!c.admits("gpt-x", Some(&["anthropic"]), None)); + assert!(c.admits("claude-sonnet-5", Some(&["openai-chat"]), None)); + } + + #[test] + fn allowing_a_real_name_lets_its_aliases_through_but_not_the_other_way() { + let c = with_aliases(); + // 写上游的模型名:指向它的别名一起放行 + assert_eq!( + c.resolve_allowed(None, Some(&owned(&["us.anthropic.*"]))), + owned(&["claude-sonnet-5", "us.anthropic.claude-sonnet-5-v1:0"]) + ); + assert_eq!( + c.resolve_allowed(None, Some(&owned(&["gpt-x-*"]))), + owned(&["gpt-x", "gpt-x-2026"]) + ); + // 写别名:只有别名本身,它列表里的名字不跟着出来 + assert_eq!( + c.resolve_allowed(None, Some(&owned(&["sonnet"]))), + owned(&["sonnet"]) + ); + assert_eq!( + c.resolve_allowed(None, Some(&owned(&["gpt-x"]))), + owned(&["gpt-x"]) + ); + // 和别名同名的那个名称就是别名:写它放行别名,别家的叫法不跟着出来 + assert_eq!( + c.resolve_allowed(None, Some(&owned(&["claude-sonnet-5"]))), + owned(&["claude-sonnet-5", "sonnet"]) + ); + // 准入和列表是同一个函数 + let allow = owned(&["us.anthropic.*"]); + assert!(c.admits("claude-sonnet-5", None, Some(&allow))); + assert!(!c.admits("sonnet", None, Some(&allow))); + assert!(!c.admits("anthropic/claude-sonnet-5", None, Some(&allow))); + } + + #[test] + fn admission_and_listing_agree_with_aliases_too() { + let c = with_aliases(); + let mut names: Vec<&str> = c.all(); + names.extend(["ghost", "nobody-has-this"]); + for dialect in [Some(&["anthropic"][..]), Some(&["openai-chat"][..]), None] { + for allow in [ + None, + Some(owned(&["claude-*"])), + Some(owned(&["gpt-x"])), + Some(owned(&["nobody-*"])), + Some(vec![]), + ] { + let listed = c.resolve_allowed(dialect, allow.as_deref()); + for m in &names { + assert_eq!( + c.admits(m, dialect, allow.as_deref()), + listed.iter().any(|x| x == m), + "{m} {dialect:?} {allow:?}" + ); + } + } + } + } + + #[test] + fn an_alias_takes_its_metadata_from_its_first_listed_name_that_is_served() { + let c = with_aliases(); + // 列表头一个 claude-sonnet-5 有人提供:按提供它的头一家查 + assert_eq!( + c.first_served("claude-sonnet-5"), + Some(("anthropic", "claude-sonnet-5")) + ); + assert_eq!(c.first_served("gpt-x"), Some(("openai", "gpt-x-2026"))); + assert_eq!(c.first_served("ghost"), None); + assert_eq!(c.first_served("claude-haiku-5"), None); + } } diff --git a/crates/tw-gateway/src/models.rs b/crates/tw-gateway/src/models.rs index 4d3be218..7a4276c5 100644 --- a/crates/tw-gateway/src/models.rs +++ b/crates/tw-gateway/src/models.rs @@ -226,7 +226,13 @@ impl Directory { } }) .collect(); - into.store(std::sync::Arc::new(tw_engine::Catalog::build(&sources))); + // 别名跟着同一份配置登记:列表、准入看到的别名表和挑候选时的是同一张 + let catalog = tw_engine::Catalog::build(&sources).with_aliases( + cfg.aliases + .iter() + .map(|a| (a.name.as_str(), a.models.as_slice())), + ); + into.store(std::sync::Arc::new(catalog)); } /// 到时间该去问的上游。 @@ -620,8 +626,11 @@ impl Serving { } } -/// 这家能不能服务这个模型:不在启用范围里、清单里没有,都不能。**不看 +/// 这家能不能服务这个模型名:不在启用范围里、清单里没有,都不能。**不看 /// 停用** —— 停用是路由的事,测速一家停用的上游是合理的。 +/// +/// **`model` 是发出去的名字,原样看,不经过别名表。**客户端说的名称(可能是别名) +/// 先用 [`resolve`] 对到这家,或者直接用 [`sent_to`]。 pub fn fit(catalog: &tw_engine::Catalog, p: &tw_config::Provider, model: &str) -> Option { if !p.uses_model(model) { Some(Skip::OutOfScope) @@ -660,16 +669,57 @@ pub fn resolve( .cloned() } +/// 发给候选 `p` 的模型名;这家服务不了时是为什么。 +/// +/// `asked` 是这一家被要的名称([`tw_engine::Engine::models_asked`]),在客户端那一侧: +/// 别名还是别名名称。 +/// +/// - **指定模型**([`tw_engine::Decision::pinned_model`]):原样发出,不经过别名表, +/// 就看这家有没有这个名字。 +/// - 别的按 [`resolve`] 对到这家:别名取它列表里这家能服务的第一个,一个都没有就 +/// 服务不了 —— 都不在启用范围里算范围外,否则算清单里没有。 +pub fn sent_to( + cfg: &tw_config::Config, + catalog: &tw_engine::Catalog, + decision: &tw_engine::Decision, + p: &tw_config::Provider, + asked: &str, +) -> Result { + let sent = match decision.pinned_model(&p.name) { + Some(m) => m.to_string(), + None => match resolve(cfg, catalog, p, asked) { + Some(m) => m, + None => { + let in_scope = cfg + .aliases + .find(asked) + .is_some_and(|a| a.models.iter().any(|m| p.uses_model(m))); + return Err(if in_scope { + Skip::NotOffered + } else { + Skip::OutOfScope + }); + } + }, + }; + match fit(catalog, p, &sent) { + Some(skip) => Err(skip), + None => Ok(sent), + } +} + /// 在路由选出的候选里去掉服务不了这个请求的上游。 /// /// `asked` 是每个候选和它要的模型([`tw_engine::Engine::models_asked`]): -/// **每一家按它实际要的那个模型看**,规则改写过的就是改写后的。 +/// **每一家按它实际要的那个模型看**,规则改写过的就是改写后的;别名按这家能不能 +/// 服务它看,指定模型按指定的那个名字看(见 [`sent_to`])。 /// /// **没有模型清单的上游不跳过**:不知道它有什么,不等于它没有。模型是空的 /// (请求体解析不了)时只看停用。 pub fn serving( cfg: &tw_config::Config, catalog: &tw_engine::Catalog, + decision: &tw_engine::Decision, asked: &[(String, String)], ) -> Serving { let mut out = Serving { @@ -687,7 +737,7 @@ pub fn serving( } else if model.is_empty() { None } else { - fit(catalog, p, model) + sent_to(cfg, catalog, decision, p, model).err() }; match skip { Some(s) => out.skipped.push((name.clone(), s)), @@ -727,6 +777,24 @@ mod tests { .collect() } + /// 路由的决定。`pinned` 是指定模型的那几项(上游、模型),空的就是按名称要 + fn decided(pinned: &[(&str, &str)]) -> tw_engine::Decision { + tw_engine::Decision { + candidates: pinned.iter().map(|(p, _)| p.to_string()).collect(), + matched_rule: "r".into(), + via_group: None, + set: Default::default(), + rewritten_by: Vec::new(), + pinned: pinned + .iter() + .map(|(p, m)| tw_engine::Pinned { + provider: p.to_string(), + model: m.to_string(), + }) + .collect(), + } + } + fn cfg(providers: Vec) -> tw_config::Config { tw_config::Config { providers, @@ -831,6 +899,91 @@ mod tests { assert_eq!(r(1, "claude-opus-5").as_deref(), Some("claude-opus-5")); } + /// 别名登记进汇总;挑候选时别名按每家能不能服务它看,指定模型按写的那个名字看 + #[test] + fn candidates_are_judged_by_the_alias_each_can_serve_and_pinned_models_as_written() { + let d = Directory::default(); + let mut c = cfg(vec![ + provider("bedrock"), + provider("relay"), + provider("openai"), + provider("no-list"), + provider("scoped"), + ]); + c.providers[4].models_only = Some(vec!["gemini-*".into()]); + c.aliases = serde_yaml_ng::from_str( + "claude-sonnet-5: [claude-sonnet-5, us.anthropic.claude-sonnet-5-v1:0]\n\ + gpt-x: gpt-x-2026\n", + ) + .unwrap(); + d.reconcile(&c); + for (p, models) in [ + ("bedrock", vec!["us.anthropic.claude-sonnet-5-v1:0"]), + // 有一个叫 gpt-x 的真模型,别名 gpt-x 没列它 + ("relay", vec!["gpt-x", "claude-sonnet-5"]), + ("openai", vec!["gpt-x-2026"]), + ("scoped", vec!["gemini-3"]), + ] { + let i = c.providers.iter().position(|x| x.name == p).unwrap(); + let models = models.into_iter().map(String::from).collect(); + d.record(p, &identity(&c, &c.providers[i]), Answer::Listed(models), 1); + } + d.record( + "no-list", + &identity(&c, &c.providers[3]), + Answer::NoList(why("没有接口")), + 1, + ); + let cat = published(&d, &c); + // 别名登记在有清单、能服务它的上游名下;同名的真模型让给别名 + assert_eq!(cat.providers_for("claude-sonnet-5"), ["bedrock", "relay"]); + assert_eq!(cat.providers_for("gpt-x"), ["openai"]); + assert_eq!(cat.offers("relay", "gpt-x"), Some(true)); + + let all = ["bedrock", "relay", "openai", "no-list", "scoped"]; + let s = serving(&c, &cat, &decided(&[]), &asked(&all, "gpt-x")); + // relay 只有同名的真模型:别名优先,到不了它。没有清单的照样能 + assert_eq!(s.usable, ["openai", "no-list"]); + assert_eq!( + s.skipped, + [ + ("bedrock".to_string(), Skip::NotOffered), + ("relay".to_string(), Skip::NotOffered), + ("scoped".to_string(), Skip::OutOfScope), + ] + ); + let unpinned = decided(&[]); + let sent = |p: usize, name: &str| sent_to(&c, &cat, &unpinned, &c.providers[p], name); + assert_eq!( + sent(0, "claude-sonnet-5"), + Ok("us.anthropic.claude-sonnet-5-v1:0".into()) + ); + assert_eq!(sent(1, "claude-sonnet-5"), Ok("claude-sonnet-5".into())); + assert_eq!(sent(3, "gpt-x"), Ok("gpt-x-2026".into())); + // 不是别名的照旧按清单看 + assert_eq!(sent(2, "gpt-x-2026"), Ok("gpt-x-2026".into())); + assert_eq!(sent(2, "gpt-9"), Err(Skip::NotOffered)); + + // 指定模型原样看,不经过别名表:relay 的 gpt-x 就是它自己的那个;bedrock 没有 + // 叫 claude-sonnet-5 的(虽然它能服务这个别名) + let pinned = decided(&[("relay", "gpt-x"), ("bedrock", "claude-sonnet-5")]); + let s = serving( + &c, + &cat, + &pinned, + &[ + ("relay".into(), "gpt-x".into()), + ("bedrock".into(), "claude-sonnet-5".into()), + ], + ); + assert_eq!(s.usable, ["relay"]); + assert_eq!(s.skipped, [("bedrock".to_string(), Skip::NotOffered)]); + assert_eq!( + sent_to(&c, &cat, &pinned, &c.providers[1], "gpt-x"), + Ok("gpt-x".into()) + ); + } + fn listed(p: &str, models: &[&str]) -> tw_engine::ProviderModels { tw_engine::ProviderModels { provider: p.into(), @@ -869,7 +1022,7 @@ mod tests { c.providers[2].models_only = Some(vec!["c-1".into()]); let cat = published(&d, &c); assert_eq!(cat.all(), ["a-1", "a-2", "c-1"]); - let s = serving(&c, &cat, &asked(&["a", "b", "c"], "c-2")); + let s = serving(&c, &cat, &decided(&[]), &asked(&["a", "b", "c"], "c-2")); assert_eq!( s.skipped, [ @@ -900,7 +1053,7 @@ mod tests { ); let cat = published(&d, &c); // 不知道它们有什么:不跳过 - let s = serving(&c, &cat, &asked(&["a", "b"], "m")); + let s = serving(&c, &cat, &decided(&[]), &asked(&["a", "b"], "m")); assert_eq!(s.usable, ["a", "b"]); // 失败的一小时后重问,没有接口的一天后 let hour = RETRY_AFTER.as_millis() as u64; diff --git a/crates/tw-gateway/src/server/listing.rs b/crates/tw-gateway/src/server/listing.rs index a522430d..c2e9f6d5 100644 --- a/crates/tw-gateway/src/server/listing.rs +++ b/crates/tw-gateway/src/server/listing.rs @@ -96,6 +96,26 @@ pub(crate) fn model_meta( .unwrap_or_default() } +/// 列表里一个名称的元数据。 +/// +/// 别名用它第一个有上游提供的模型的(按列表顺序),按提供它的头一家查(见 +/// [`tw_engine::Catalog::first_served`])。目录空着、列表里谁都不提供时(这时单点 +/// 查询不拦),按它列表里的头一个查默认价目表。 +fn listed_meta( + book: &tw_pricing::PriceBook, + catalog: &tw_engine::Catalog, + cfg: &tw_config::Config, + name: &str, +) -> ModelMeta { + if let Some((provider, model)) = catalog.first_served(name) { + return model_meta(book, Some(provider), model); + } + match cfg.aliases.find(name).and_then(|a| a.models.first()) { + Some(model) => model_meta(book, None, model), + None => model_meta(book, None, name), + } +} + /// `GET /v1/models`。 /// /// 三种方言的响应结构不同,但**列表内容来自同一个函数** —— 差别只在 @@ -122,15 +142,13 @@ pub(super) async fn list_models( .find(|c| c.name == client) .and_then(|c| c.allow.clone()); let shape = ListingShape::of(uri.path(), &headers, position); - let models = state - .catalog - .load() - .resolve_allowed(Some(&shape.protocols()), allow.as_deref()); + let catalog = state.catalog.load(); + let models = catalog.resolve_allowed(Some(&shape.protocols()), allow.as_deref()); let book = state.pricing.load(); let objects: Vec<_> = models .iter() - .map(|m| shape.object(m, &model_meta(&book, None, m))) + .map(|m| shape.object(m, &listed_meta(&book, &catalog, &rt.config, m))) .collect(); let body = match shape { @@ -309,7 +327,7 @@ pub(super) async fn get_model( ), )); } - let meta = model_meta(&state.pricing.load(), None, &model); + let meta = listed_meta(&state.pricing.load(), &catalog, &rt.config, &model); Ok(axum::Json(shape.object(&model, &meta)).into_response()) } diff --git a/crates/tw-gateway/src/server/pipeline.rs b/crates/tw-gateway/src/server/pipeline.rs index cae95184..ca718f99 100644 --- a/crates/tw-gateway/src/server/pipeline.rs +++ b/crates/tw-gateway/src/server/pipeline.rs @@ -375,11 +375,16 @@ fn outgrown( /// 管线第 2 步的中段:模型准入。**和 `GET /v1/models` 共用同一个函数** /// —— 列出来的一定能用。 /// -/// **看的是发出去的模型,不是客户端写的那个**,所以放在规则做完决定之后: +/// **看的是每个候选实际要的模型,不是客户端写的那个**,所以放在规则做完决定之后: /// 一条把 `claude-*` 改成 `glm-*` 的规则,请求里的名字哪一家的清单里都没有, /// 改写后的才有。`asked` 里有一个过得去就放行 —— 哪一家服务不了,由下一步 /// 把它跳过。 /// +/// - 要的是别名:按别名在目录里的样子看 —— 有清单的上游里有谁服务它,`allow` 写它 +/// 的名称、或者写它列表里任一模型名都算放行(继承只从真名到别名)。 +/// - 指定模型的候选:看这一家有没有指定的那个名字(没有清单的当作有,和挑候选时 +/// 一样),`allow` 按这个名字看。 +/// /// 目录空着时不拦:那说明探测还没回来或者上游都不给列表,这时候拦 /// 等于把整个网关关掉。 fn admit( @@ -408,42 +413,100 @@ fn admit( .map(|a| crate::client_api::slugs(a.servable_by(any))); // 阶段一之后要的模型。规则一家候选都没给时就看它 let model = decision.set.model.as_deref().unwrap_or(&facts.model); - let mut models: Vec<&str> = asked.iter().map(|(_, m)| m.as_str()).collect(); - if models.is_empty() { - models.push(model); + // 按名称要的(别名还是别名),和指定模型的(哪一家、哪个名字)分开看 + let mut named: Vec<&str> = Vec::new(); + let mut pinned: Vec<(&str, &str)> = Vec::new(); + for (c, m) in asked { + match decision.pinned_model(c) { + Some(p) => pinned.push((c, p)), + None => named.push(m), + } + } + if named.is_empty() && pinned.is_empty() { + named.push(model); } - if models + let allowed = |m: &str| { + allow + .as_deref() + .is_none_or(|ps| ps.iter().any(|p| tw_engine::rule::glob_match(p, m))) + }; + // 这一家有没有指定的名字。配置里没有这一家的留给尝试那一步报出来 + let offered_at = |p: &str, m: &str| { + rt.config + .providers + .iter() + .find(|x| x.name == p) + .is_none_or(|x| crate::models::fit(&catalog, x, m).is_none()) + }; + if named .iter() .any(|m| catalog.admits(m, servable.as_deref(), allow.as_deref())) + || pinned.iter().any(|(p, m)| offered_at(p, m) && allowed(m)) { return Ok(()); } // 错误信息要说清是哪一种:没有上游提供它,和这个客户端不让用它, // 该去改的地方不一样。**改写过的两个名字都要说**:客户端写的是一个, // 报错里说的是另一个,不说清楚像是网关认错了模型 - let offered = models.iter().any(|m| !catalog.providers_for(m).is_empty()); - let why = match (offered, model != facts.model) { - (false, false) => msg!( - "gw.model.no_upstream", model = facts.model.clone() => - "No upstream serves model {model}. GET /v1/models lists the models that \ - are available." - ), - (true, false) => msg!( - "gw.model.not_allowed", key = req.client_name.clone(), model = facts.model.clone() => - "Gateway key `{key}` may not use model {model}. GET /v1/models lists the \ - models that are available." - ), - (false, true) => msg!( - "gw.model.no_upstream_rewritten", from = facts.model.clone(), model = model => - "A routing rule rewrote model {from} to {model}, and no upstream serves {model}. \ - GET /v1/models lists the models that are available." - ), - (true, true) => msg!( - "gw.model.not_allowed_rewritten", from = facts.model.clone(), model = model, - key = req.client_name.clone() => - "A routing rule rewrote model {from} to {model}, which gateway key `{key}` may not \ - use. GET /v1/models lists the models that are available." - ), + let key = req.client_name.clone(); + let why = if named.is_empty() { + // 去向是指定模型:说出是哪条规则指定的、指定在哪一家 + let rule = decision.matched_rule.clone(); + match pinned.iter().find(|(p, m)| offered_at(p, m)) { + Some((p, m)) => msg!( + "gw.model.pinned_not_allowed", rule = rule, model = m, upstream = p, key = key => + "Rule `{rule}` pins model {model} on `{upstream}`, which gateway key `{key}` may \ + not use." + ), + None => { + let detail = pinned + .iter() + .map(|(p, m)| format!("{p} ({m})")) + .collect::>() + .join(", "); + msg!( + "gw.model.pinned_not_offered", rule = rule, detail = detail => + "Rule `{rule}` pins models that their upstreams do not offer: {detail}." + ) + } + } + } else { + let offered = named.iter().any(|m| !catalog.providers_for(m).is_empty()); + let alias = catalog.alias(model).map(|ms| ms.join(", ")); + match (offered, model != facts.model, alias) { + (false, false, Some(models)) => msg!( + "gw.model.alias_unserved", model = facts.model.clone(), models = models => + "No upstream serves alias {model}: none offers any of {models}. GET /v1/models \ + lists the models that are available." + ), + (false, true, Some(models)) => msg!( + "gw.model.alias_unserved_rewritten", from = facts.model.clone(), model = model, + models = models => + "A routing rule rewrote model {from} to alias {model}, and no upstream offers any \ + of {models}. GET /v1/models lists the models that are available." + ), + (false, false, None) => msg!( + "gw.model.no_upstream", model = facts.model.clone() => + "No upstream serves model {model}. GET /v1/models lists the models that \ + are available." + ), + (true, false, _) => msg!( + "gw.model.not_allowed", key = key, model = facts.model.clone() => + "Gateway key `{key}` may not use model {model}. GET /v1/models lists the \ + models that are available." + ), + (false, true, None) => msg!( + "gw.model.no_upstream_rewritten", from = facts.model.clone(), model = model => + "A routing rule rewrote model {from} to {model}, and no upstream serves {model}. \ + GET /v1/models lists the models that are available." + ), + (true, true, _) => msg!( + "gw.model.not_allowed_rewritten", from = facts.model.clone(), model = model, + key = key => + "A routing rule rewrote model {from} to {model}, which gateway key `{key}` may not \ + use. GET /v1/models lists the models that are available." + ), + } }; Err(GatewayError::new(crate::error::Source::Request, why)) } @@ -535,7 +598,7 @@ fn route( // // 不跳过的话,一家没有这个模型的上游排在前面,它回的 404 不触发故障 // 转移,请求就在一家能服务它的上游旁边失败了。 - let serving = crate::models::serving(&rt.config, &state.catalog.load(), &asked); + let serving = crate::models::serving(&rt.config, &state.catalog.load(), &decision, &asked); let model = decision.set.model.as_deref().unwrap_or(&facts.model); if serving.usable.is_empty() { return Ok(Routed::Refused(choice, serving.explain(model))); diff --git a/crates/tw-gateway/tests/alias_catalog.rs b/crates/tw-gateway/tests/alias_catalog.rs new file mode 100644 index 00000000..cdf8fbf9 --- /dev/null +++ b/crates/tw-gateway/tests/alias_catalog.rs @@ -0,0 +1,306 @@ +//! 模型别名在目录里:`/v1/models` 怎么列、密钥的 `allow` 怎么继承、准入和挑候选 +//! 怎么看别名和指定模型。 +//! +//! 假上游列出自己的清单,**收什么模型都答**,并说出自己是谁、收到的是哪个名字 —— +//! 断言落在「准入放没放行、请求落在了哪一家」上。发过去叫什么是发送那一步的事, +//! 这里只在指定模型(原样发出)时看。 + +use std::net::SocketAddr; +use std::time::Duration; + +use axum::Router; +use axum::routing::{get, post}; +use serde_json::Value; +use tw_config::{Client, Config, Provider}; + +/// 一个假上游:列出 `models`,什么模型的请求都答。 +async fn upstream(name: &'static str, models: &'static [&'static str]) -> SocketAddr { + let app = Router::new() + .route( + "/v1/models", + get(move || async move { + axum::Json(serde_json::json!({ + "data": models.iter().map(|m| serde_json::json!({ "id": m })).collect::>() + })) + }), + ) + .route( + "/v1/messages", + post(move |body: axum::body::Bytes| async move { + let v: Value = serde_json::from_slice(&body).unwrap_or_default(); + let sent = v["model"].as_str().unwrap_or_default().to_string(); + axum::Json(serde_json::json!({ "by": name, "sent": sent })) + }), + ); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let a = l.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(l, app).await.unwrap() }); + a +} + +fn provider(name: &str, at: SocketAddr) -> Provider { + Provider { + name: name.into(), + base_url: format!("http://{at}"), + key: Some("k".into()), + protocol: Some(tw_config::Protocol::Anthropic), + ..Default::default() + } +} + +/// 一把密钥。`allow` 不写就是什么都放行 +fn client(key: &str, allow: Option<&[&str]>, route: Option<&str>) -> Client { + Client { + name: key.into(), + key: key.into(), + allow: allow.map(|a| a.iter().map(|s| s.to_string()).collect()), + route: route.map(String::from), + ..Default::default() + } +} + +/// 三家上游、四个别名: +/// +/// - `sonnet`:官方叫 `claude-sonnet-4-5`,中转站叫 `anthropic/claude-sonnet-4-5` +/// - `gpt-x`:只有 openai 的 `gpt-x-2026`。中转站有个同名的真模型,没列进来 +/// - `ghost`:谁都没有 +async fn setup(clients: Vec, routes: &str) -> Config { + let official = upstream("official", &["claude-sonnet-4-5"]).await; + let relay = upstream("relay", &["anthropic/claude-sonnet-4-5", "gpt-x"]).await; + let openai = upstream("openai", &["gpt-x-2026"]).await; + let mut cfg = Config { + clients, + providers: vec![ + provider("official", official), + provider("relay", relay), + provider("openai", openai), + ], + ..Default::default() + }; + cfg.aliases = serde_yaml_ng::from_str( + "sonnet: [claude-sonnet-4-5, anthropic/claude-sonnet-4-5]\n\ + gpt-x: gpt-x-2026\n\ + ghost: nobody-has-this\n", + ) + .unwrap(); + if !routes.is_empty() { + cfg.routes = serde_yaml_ng::from_str(routes).unwrap(); + } + cfg +} + +async fn serve(cfg: Config) -> SocketAddr { + let state = tw_gateway::AppState::new(cfg).unwrap(); + tw_gateway::models::refresh_all(&state).await; + let addr = tw_gateway::serve(state, ([127, 0, 0, 1], 0).into()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + addr +} + +/// `GET path`,按 OpenAI 的样子(Bearer)或 Anthropic 的样子(`x-api-key` + 版本头)。 +async fn get_json(gw: SocketAddr, key: &str, path: &str, anthropic: bool) -> (u16, Value) { + let mut r = reqwest::Client::new().get(format!("http://{gw}{path}")); + r = if anthropic { + r.header("x-api-key", key) + .header("anthropic-version", "2023-06-01") + } else { + r.header("authorization", format!("Bearer {key}")) + }; + let r = r.send().await.unwrap(); + let status = r.status().as_u16(); + (status, r.json().await.unwrap_or_default()) +} + +async fn listed(gw: SocketAddr, key: &str) -> Vec { + let (_, v) = get_json(gw, key, "/v1/models", false).await; + v["data"] + .as_array() + .unwrap() + .iter() + .map(|m| m["id"].as_str().unwrap().to_string()) + .collect() +} + +async fn ask(gw: SocketAddr, key: &str, model: &str) -> (u16, Value) { + let r = reqwest::Client::new() + .post(format!("http://{gw}/v1/messages")) + .header("x-api-key", key) + .body(serde_json::json!({ "model": model, "messages": [] }).to_string()) + .send() + .await + .unwrap(); + let status = r.status().as_u16(); + (status, r.json().await.unwrap_or_default()) +} + +fn message(body: &Value) -> &str { + body["error"]["message"].as_str().unwrap_or_default() +} + +// ─────────────────────────────────────────────────────────── 列表 + +#[tokio::test] +async fn aliases_are_listed_with_the_metadata_of_the_model_they_stand_for() { + let gw = serve(setup(vec![client("tw-k", None, None)], "").await).await; + + // 谁都服务不了的别名不列;中转站那个同名的 gpt-x 让给了别名,只出现一次 + assert_eq!( + listed(gw, "tw-k").await, + [ + "anthropic/claude-sonnet-4-5", + "claude-sonnet-4-5", + "gpt-x", + "gpt-x-2026", + "sonnet" + ] + ); + + // 别名的上下文窗口按它第一个有人提供的模型查(claude-sonnet-4-5,官方) + let (_, list) = get_json(gw, "tw-k", "/v1/models", true).await; + let sonnet = list["data"] + .as_array() + .unwrap() + .iter() + .find(|m| m["id"] == "sonnet") + .unwrap() + .clone(); + assert_eq!(sonnet["max_input_tokens"], 200_000, "{sonnet}"); + assert_eq!(sonnet["context_window"], 200_000, "{sonnet}"); + assert_eq!(sonnet["supports_1m"], false, "{sonnet}"); + // 显示名按别名自己的名称推:看不出是 Claude 的就是名称本身 + assert_eq!(sonnet["display_name"], "sonnet"); + + // 单点查询和列表里是同一个对象 + let (status, one) = get_json(gw, "tw-k", "/v1/models/sonnet", true).await; + assert_eq!(status, 200, "{one}"); + assert_eq!(one, sonnet); + let (status, _) = get_json(gw, "tw-k", "/v1/models/ghost", true).await; + assert_eq!(status, 400); +} + +#[tokio::test] +async fn a_keys_allow_reaches_an_alias_through_its_models_but_not_the_other_way() { + let gw = serve( + setup( + vec![ + // 写上游的模型名:指向它的别名一起放行 + client("by-model", Some(&["claude-sonnet-*", "gpt-x-*"]), None), + // 写别名:只有别名本身 + client("by-alias", Some(&["sonnet"]), None), + ], + "", + ) + .await, + ) + .await; + + assert_eq!( + listed(gw, "by-model").await, + ["claude-sonnet-4-5", "gpt-x", "gpt-x-2026", "sonnet"] + ); + assert_eq!(listed(gw, "by-alias").await, ["sonnet"]); + + // 准入和列表一致 + let (status, body) = ask(gw, "by-model", "sonnet").await; + assert_eq!(status, 200, "{body}"); + let (status, body) = ask(gw, "by-alias", "sonnet").await; + assert_eq!(status, 200, "{body}"); + let (status, body) = ask(gw, "by-alias", "claude-sonnet-4-5").await; + assert_eq!(status, 400, "{body}"); + assert!( + message(&body).contains("may not use model claude-sonnet-4-5"), + "{body}" + ); + let (status, body) = ask(gw, "by-model", "anthropic/claude-sonnet-4-5").await; + assert_eq!(status, 400, "{body}"); +} + +// ─────────────────────────────────────────────────────────── 准入与挑候选 + +#[tokio::test] +async fn an_alias_skips_an_upstream_whose_same_named_model_it_does_not_list() { + // 中转站排在前面,它有一个叫 gpt-x 的真模型;别名 gpt-x 没列它,请求到不了它 + let mut cfg = setup( + vec![client("tw-k", None, None)], + "- name: default + rules: + - { name: 都走, to: pool } +", + ) + .await; + cfg.groups = vec![ + serde_yaml_ng::from_str("name: pool\ntype: fallback\nproviders: [relay, openai]\n") + .unwrap(), + ]; + let gw = serve(cfg).await; + let (status, body) = ask(gw, "tw-k", "gpt-x").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["by"], "openai"); + + // 别名谁都服务不了:说清是别名、列表里的名字一个都没人提供 + let (status, body) = ask(gw, "tw-k", "ghost").await; + assert_eq!(status, 400, "{body}"); + assert!( + message(&body).contains("No upstream serves alias ghost") + && message(&body).contains("nobody-has-this"), + "{body}" + ); +} + +#[tokio::test] +async fn a_pinned_model_is_judged_at_its_own_upstream_and_as_written() { + let gw = serve( + setup( + vec![ + client("tw-k", None, Some("pinned")), + client("missing", None, Some("missing")), + client("narrow", Some(&["claude-*"]), Some("narrow")), + ], + "- name: pinned + rules: + - name: 走中转站 + to: + - { provider: relay, model: gpt-x } +- name: missing + rules: + - name: 指定了没有的 + to: + - { provider: official, model: claude-opus-5 } + - { provider: openai, model: gpt-x } +- name: narrow + rules: + - name: 指定中转站的叫法 + to: + - { provider: relay, model: anthropic/claude-sonnet-4-5 } +", + ) + .await, + ) + .await; + + // 指定模型原样发出、不经过别名表:中转站自己的 gpt-x + let (status, body) = ask(gw, "tw-k", "anything").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["by"], "relay"); + assert_eq!(body["sent"], "gpt-x"); + + // 指定的模型那一家没有:说是哪条规则、哪一家、哪个名字 + let (status, body) = ask(gw, "missing", "anything").await; + assert_eq!(status, 400, "{body}"); + assert!( + message(&body).contains("Rule `指定了没有的` pins models") + && message(&body).contains("official (claude-opus-5), openai (gpt-x)"), + "{body}" + ); + + // 密钥不让用指定的那个名字 + let (status, body) = ask(gw, "narrow", "claude-sonnet-4-5").await; + assert_eq!(status, 400, "{body}"); + assert!( + message(&body).contains("pins model anthropic/claude-sonnet-4-5 on `relay`") + && message(&body).contains("`narrow` may not use"), + "{body}" + ); +} diff --git a/crates/tw-gateway/tests/models.rs b/crates/tw-gateway/tests/models.rs index 5d027254..b138eca1 100644 --- a/crates/tw-gateway/tests/models.rs +++ b/crates/tw-gateway/tests/models.rs @@ -419,7 +419,7 @@ to: let (status, body) = ask(gw, "claude-opus-5").await; assert_eq!(status, 200, "{body}"); assert_eq!(body["by"], "bedrock"); - assert_eq!(body["model"], "us.anthropic.claude-opus-5-v1:0"); + assert_eq!(body["sent"], "us.anthropic.claude-opus-5-v1:0"); // 第一家停用了:下一家,用它自己的名字 cfg.providers[0].disabled = true; @@ -428,5 +428,5 @@ to: let (status, body) = ask(gw, "claude-opus-5").await; assert_eq!(status, 200, "{body}"); assert_eq!(body["by"], "official"); - assert_eq!(body["model"], "claude-opus-5"); + assert_eq!(body["sent"], "claude-opus-5"); } From 8b6e27669e2b0353949b5223c3c58828eef148a5 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:21:55 +0800 Subject: [PATCH 08/18] Model aliases: replay, the speed test and plugin renames send each upstream its own name Replay used to resend the stored body unchanged, so a request for an alias, or one a rule had rewritten, went out under the client's name. It now sends the chosen upstream the name it took the first time when the request went there under a different name (the attempt chain's recorded model), and otherwise the alias resolved for that upstream. The model in the body, or in the Gemini path, is the only thing changed, and the quote prices that name. An upstream that offers none of the alias's models is refused with control.replay_alias_unserved instead of being sent a name it cannot serve. The inference speed test resolves the model per upstream before fitting, pricing and sending; an alias an upstream cannot serve is skipped as out_of_scope or not_offered, as a real name would be. A plugin's params.model is a client-side name and may be an alias: it is resolved per hop. The key's allow check stays on the name the plugin wrote. An upstream that cannot serve the alias is skipped (gw.plugin.alias_unserved on the attempt chain) and the next one is tried, so plug::attempt now tells a whole-request refusal (Stop::Request) from a skipped hop (Stop::Hop). Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/msg-codes.txt | 2 + crates/tw-api/src/lib.rs | 4 + crates/tw-control/src/lib.rs | 52 ++++- crates/tw-control/src/replay.rs | 108 ++++++++- crates/tw-control/tests/replay.rs | 215 ++++++++++++++++-- crates/tw-control/tests/speed.rs | 77 ++++++- crates/tw-gateway/src/server/pipeline/hop.rs | 13 +- crates/tw-gateway/src/server/pipeline/plug.rs | 62 ++++- crates/tw-gateway/tests/plugins_request.rs | 54 +++++ 9 files changed, 539 insertions(+), 48 deletions(-) diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index a06c33e4..814d9a98 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -181,6 +181,7 @@ control.redirect_must_be_app_scheme control.remote.control_section_locked control.remote.diagnostics_refused control.remote.shutdown_refused +control.replay_alias_unserved control.replay_bedrock control.request_body_gone control.request_body_truncated @@ -292,6 +293,7 @@ gw.oauth.rotation_no_manager gw.oauth.rotation_queue_full gw.oauth.status gw.oauth.unreachable +gw.plugin.alias_unserved gw.plugin.answer_unreadable gw.plugin.api gw.plugin.bad_output diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index b8339167..1ad7d261 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -4020,6 +4020,7 @@ pub struct ZaiLoginStatus { #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct SpeedEstimate { pub provider: String, + /// 发给这一家的模型名,按它报价:要测的是别名时是这一家自己的那个名字 pub model: String, /// 输入 token。**精确值** —— 请求是固定的 pub input_tokens: u64, @@ -4066,6 +4067,7 @@ pub struct SpeedRunRequest { #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct SpeedResult { pub provider: String, + /// 发给这一家的模型名(别名对到这一家的那个名字) pub model: String, pub ok: bool, pub connect_ms: u64, @@ -4307,6 +4309,8 @@ pub struct ReplayRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct ReplayQuote { + /// 重放发给这一家的模型名,**按它报价**:客户端要的是别名时是这一家自己的那个名字, + /// 原来那一次发给这一家的名字改写过的是改写后的那个 pub model: String, pub provider: String, pub body_bytes: i64, diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index 4da38f5e..5c057080 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -872,16 +872,20 @@ async fn speed_quote( targets(&cfg, &req.providers)? .into_iter() .map(|p| { - // 和记账同一个口径:按这家的计费方式和价目表。**协议也要给**: - // 输出上限报多少由它决定(见 `tw_gateway::l3::max_output_tokens`) + let (model, skip) = match speed_model(&cfg, &catalog, p, &req.model) { + Ok(m) => (m, None), + Err(skip) => (req.model.clone(), Some(skip)), + }; + // 和记账同一个口径:按这家的计费方式和价目表、发给它的那个模型名。**协议 + // 也要给**:输出上限报多少由它决定(见 `tw_gateway::l3::max_output_tokens`) let e = tw_gateway::l3::estimate( &book, &p.name, - &req.model, + &model, p.billing, p.effective_protocol(), ); - (e, tw_gateway::models::fit(&catalog, p, &req.model)) + (e, skip) }) .collect(); Ok(Json(tw_api::SpeedQuote { @@ -913,9 +917,9 @@ async fn speed_run( for p in targets(&cfg, &req.providers)? { // 服务不了这个模型的不发:报价里已经说了它不会被测,发出去只会得到 // 一个 4xx,还可能被计费 - if tw_gateway::models::fit(&catalog, p, &req.model).is_some() { + let Ok(model) = speed_model(&cfg, &catalog, p, &req.model) else { continue; - } + }; // OAuth 那类要联网换 token,所以走网关那条 async 的路。 // **用这一家自己的 client** —— 换 token 要走它的代理。 let pk_http = s.gateway.client_for(&p.name); @@ -924,7 +928,7 @@ async fn speed_run( Err(e) => { out.push(tw_api::SpeedResult { provider: p.name.clone(), - model: req.model.clone(), + model, ok: false, connect_ms: 0, ttft_ms: None, @@ -945,10 +949,10 @@ async fn speed_run( &pk_http, p, &headers, - &req.model, + &model, tw_gateway::l3::max_output_tokens( &s.gateway.pricing.load(), - &req.model, + &model, p.effective_protocol(), ), ) @@ -968,6 +972,36 @@ async fn speed_run( Ok(Json(out)) } +/// 测速发给 `p` 的模型名,或者它测不了的原因。 +/// +/// 要测的是别名的话,和转发一样发这一家**自己的那个名字**([`tw_gateway::models::resolve`]): +/// 别名本身它不认识。列表里的名字它一个都服务不了:一个都不在启用范围里是 +/// `out_of_scope`,在范围里、清单里没有是 `not_offered`。对上的名字再照常看这一家能不能 +/// 服务它([`tw_gateway::models::fit`])。 +fn speed_model( + cfg: &tw_config::Config, + catalog: &tw_engine::Catalog, + p: &tw_config::Provider, + model: &str, +) -> Result { + use tw_gateway::models::Skip; + let Some(sent) = tw_gateway::models::resolve(cfg, catalog, p, model) else { + let scoped = cfg + .aliases + .find(model) + .is_some_and(|a| a.models.iter().any(|m| p.uses_model(m))); + return Err(if scoped { + Skip::NotOffered + } else { + Skip::OutOfScope + }); + }; + match tw_gateway::models::fit(catalog, p, &sent) { + Some(skip) => Err(skip), + None => Ok(sent), + } +} + fn quote_item( e: tw_gateway::Estimate, skip: Option, diff --git a/crates/tw-control/src/replay.rs b/crates/tw-control/src/replay.rs index ace9d553..d8d77fe1 100644 --- a/crates/tw-control/src/replay.rs +++ b/crates/tw-control/src/replay.rs @@ -31,6 +31,12 @@ //! 那些占位符这一次没有对应的原值,回答里提到它们也原样留着。这一次重新找到的值从 //! 存下来的那些号后面接着编([`tw_gateway::guard::ledger_for`]),不会和它们撞号 —— //! 撞了的话,回答里的 1 号会被还原成这一次新找到的那个值,而它原本指的是另一样。 +//! +//! # 发给这一家的模型名 +//! +//! 存下来的请求体里写的是客户端要的名字。它是别名的话,原样发出去,上游不认识;规则改写过 +//! 的,原样发出去就成了另一个请求。所以重放和转发一样,发给这一家**它自己的那个名字**(见 +//! [`sent_model`]),请求体和 Gemini 的路径里改的只有这一处,报价也按它算。 use std::time::Instant; @@ -111,6 +117,87 @@ fn replayable_to(provider: &tw_config::Provider) -> Result<(), Fail> { Ok(()) } +/// 重放到 `provider` 时发出去的模型名。 +/// +/// - 原来那一次发给过这一家、发的不是客户端要的那个名字(规则改写、指定模型、插件改名、 +/// 别名):照尝试链上记的发。重放要比的是**同一个请求**,这一家上一次收到的就是它; +/// - 否则按别名表对到这一家([`tw_gateway::models::resolve`]):客户端要的是别名时发这一家 +/// 自己的那个名字,不是别名本身;不是别名就是客户端要的那个。 +/// +/// 这一家服务不了这个别名(列表里的名字它一个都没有):不发,说清楚 —— 发过去只会换来 +/// 一个「没有这个模型」。 +fn sent_model( + cfg: &tw_config::Config, + catalog: &tw_engine::Catalog, + provider: &tw_config::Provider, + row: &tw_store::db::RequestRow, +) -> Result { + if let Some(m) = recorded_model(row, &provider.name) { + return Ok(m); + } + tw_gateway::models::resolve(cfg, catalog, provider, &row.model).ok_or_else(|| { + fail( + StatusCode::CONFLICT, + msg!( + "control.replay_alias_unserved", + model = row.model.clone(), upstream = provider.name.clone() => + "`{model}` is an alias, and upstream `{upstream}` offers none of its models, so \ + the request cannot be replayed there." + ), + ) + }) +} + +/// 原来那一次发给 `provider` 的、和客户端要的不一样的模型名。尝试链上每一跳只在两者不同时 +/// 记它(`AttemptView::model`);没有尝试链的老记录,看服务它的那一家发出的名字 +fn recorded_model(row: &tw_store::db::RequestRow, provider: &str) -> Option { + let routing = row + .routing + .as_deref() + .and_then(|r| serde_json::from_str::(r).ok()); + if let Some(routing) = routing + && routing.attempts.iter().any(|a| a.provider == provider) + { + return routing + .attempts + .iter() + .rev() + .filter(|a| a.provider == provider) + .find_map(|a| a.model.clone()) + .filter(|m| !m.is_empty() && *m != row.model); + } + (row.provider == provider && !row.sent_model.is_empty() && row.sent_model != row.model) + .then(|| row.sent_model.clone()) +} + +/// 存下来的请求换成发给这一家的模型名:请求体里的 `model`,Gemini 是路径里的那一段。 +/// **名字没变时一个字节都不动** —— 和转发一样,改写请求体可能是缓存杀手。 +fn renamed(row: &tw_store::db::RequestRow, raw: Vec, model: &str) -> (bytes::Bytes, String) { + let raw = bytes::Bytes::from(raw); + if row.model.is_empty() || model == row.model { + return (raw, row.path.clone()); + } + use tw_gateway::client_api::ClientApi; + match ClientApi::of_path(&row.path) { + Some(ClientApi::Gemini) => ( + raw, + tw_gateway::forward::gemini_path_with_model(&row.path, model), + ), + // 认不出格式的不动:不知道模型名写在哪儿 + None => (raw, row.path.clone()), + Some(api) => { + let set = tw_engine::SetAction { + model: Some(model.to_string()), + ..Default::default() + }; + ( + tw_gateway::forward::apply_set(&raw, &set, Some(api.dialect())), + row.path.clone(), + ) + } + } +} + /// 报价。**不发任何请求。** pub async fn quote( State(s): State, @@ -141,6 +228,7 @@ pub async fn quote( ) })?; replayable_to(provider)?; + let model = sent_model(&cfg, &s.gateway.catalog.load(), provider, &row)?; // 输入 token 用记录里的真值 —— 那是上游报回来的,比任何估算都准。 // 没有的话按字节粗估(和路由用的是同一个系数) @@ -154,11 +242,11 @@ pub async fn quote( output: output.max(256), ..Default::default() }; - // **按要重放到的那个上游报价**,不是原来那条走的上游。计费方式和记账 - let quote = - tw_gateway::quote::quote(&book, &provider.name, &row.model, &usage, provider.billing); + // **按要重放到的那个上游、发给它的那个模型名报价**,不是原来那条走的上游、也不是客户端 + // 要的名字(别名、规则改写过的)。计费方式和记账 + let quote = tw_gateway::quote::quote(&book, &provider.name, &model, &usage, provider.billing); Ok(Json(tw_api::ReplayQuote { - model: row.model.clone(), + model, provider: provider.name.clone(), body_bytes: raw.len() as i64, input_tokens: input as i64, @@ -205,6 +293,7 @@ pub async fn run( ) })?; replayable_to(provider)?; + let model = sent_model(&cfg, &s.gateway.catalog.load(), provider, &row)?; // **这一家自己的 client,和数据面转发用的是同一个**:它带着这家该走的 // 代理(`direct` 就是不走任何代理,连系统代理也不读)。换 token 和发请求 // 都用它 —— 用别的 client,重放就会走一条和原请求不同的出站路径:本机 @@ -222,14 +311,11 @@ pub async fn run( // 里已经写着的占位符(见模块说明) let rt = s.gateway.runtime(); let ledger = tw_gateway::guard::ledger_for(&raw); - let (body, ledger) = tw_gateway::guard::replace( - rt.config.security.redact.mode, - &rt.redact, - bytes::Bytes::from(raw), - &ledger, - ); + let (body, path) = renamed(&row, raw, &model); + let (body, ledger) = + tw_gateway::guard::replace(rt.config.security.redact.mode, &rt.redact, body, &ledger); - let url = tw_gateway::forward::upstream_url(&provider.base_url, &row.path, None); + let url = tw_gateway::forward::upstream_url(&provider.base_url, &path, None); let started = Instant::now(); let mut r = http.post(&url).header("content-type", "application/json"); r = tw_gateway::forward::apply_headers(r, &headers); diff --git a/crates/tw-control/tests/replay.rs b/crates/tw-control/tests/replay.rs index c8ec0ccf..98a20df9 100644 --- a/crates/tw-control/tests/replay.rs +++ b/crates/tw-control/tests/replay.rs @@ -98,11 +98,19 @@ fn app(config: &str) -> (tempfile::TempDir, axum::Router) { /// 同上,存着的请求体是 `body`。 fn app_with(config: &str, body: &[u8]) -> (tempfile::TempDir, axum::Router) { + app_of(config, body, row(1, "本机")) +} + +/// 同上,存着的那一行是 `r`。 +fn app_of( + config: &str, + body: &[u8], + r: tw_store::db::RequestRow, +) -> (tempfile::TempDir, axum::Router) { let d = tempfile::tempdir().unwrap(); let p = d.path().join("config.yaml"); std::fs::write(&p, config).unwrap(); let db = tw_store::Db::open(&d.path().join("data.db")).unwrap(); - let r = row(1, "本机"); db.insert(&r).unwrap(); let blobs = tw_store::Blobs::new(d.path().join("blobs")); assert!(blobs.put(r.at_ms, r.id, tw_store::Which::Request, body)); @@ -129,6 +137,13 @@ fn app_with(config: &str, body: &[u8]) -> (tempfile::TempDir, axum::Router) { } async fn replay(app: &axum::Router, provider: &str) -> serde_json::Value { + let (status, v) = ask(app, "/replay/run", provider).await; + assert_eq!(status, StatusCode::OK, "{v}"); + v +} + +/// 向 `path`(报价或重放)要第 1 条请求重放到 `provider` +async fn ask(app: &axum::Router, path: &str, provider: &str) -> (StatusCode, serde_json::Value) { let req = tw_api::ReplayRequest { id: 1, provider: provider.into(), @@ -136,7 +151,7 @@ async fn replay(app: &axum::Router, provider: &str) -> serde_json::Value { let r = app .clone() .oneshot( - Request::post("/replay/run") + Request::post(path) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&req).unwrap())) .unwrap(), @@ -145,9 +160,7 @@ async fn replay(app: &axum::Router, provider: &str) -> serde_json::Value { .unwrap(); let status = r.status(); let b = axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap(); - let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); - assert_eq!(status, StatusCode::OK, "{v}"); - v + (status, serde_json::from_slice(&b).unwrap()) } /// 系统代理开着的时候,重放照样直连、照样走上游自己的代理:两条都在一个子进程里跑, @@ -269,20 +282,41 @@ async fn a_bedrock_upstream_is_not_offered_a_replay_it_cannot_take() { } } -/// 一个把收到的请求体原样回给你的上游,顺手记下收到了什么。 -async fn echoing() -> (SocketAddr, Arc>) { - let seen: Arc> = Arc::default(); +/// 假上游收到的请求:路径和正文,按先后。 +#[derive(Clone, Default)] +struct Seen(Arc>>); + +impl Seen { + /// 最后收到的那一个 + fn last(&self) -> (String, String) { + self.0 + .lock() + .unwrap() + .last() + .cloned() + .expect("nothing was sent") + } + + fn count(&self) -> usize { + self.0.lock().unwrap().len() + } +} + +/// 一个把收到的请求体原样回给你的上游,顺手记下收到了什么:每一个请求的路径和正文。 +async fn echoing() -> (SocketAddr, Seen) { + let seen = Seen::default(); let s = seen.clone(); - let app = axum::Router::new().route( - "/v1/messages", - axum::routing::post(move |body: String| { + let app = axum::Router::new().fallback(axum::routing::post( + move |uri: axum::http::Uri, body: String| { let s = s.clone(); async move { - *s.lock().unwrap() = body.clone(); + s.0.lock() + .unwrap() + .push((uri.path().to_string(), body.clone())); body } - }), - ); + }, + )); let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = l.local_addr().unwrap(); tokio::spawn(async move { axum::serve(l, app).await.unwrap() }); @@ -309,7 +343,7 @@ async fn a_replay_numbers_new_finds_after_the_placeholders_already_stored() { ); let v = replay(&app, "本机").await; - let sent = seen.lock().unwrap().clone(); + let (_, sent) = seen.last(); assert!( sent.contains("旧的 <>,新的 <>"), "{sent}" @@ -321,3 +355,154 @@ async fn a_replay_numbers_new_finds_after_the_placeholders_already_stored() { "{v}" ); } + +/// 一个别名表、几家上游(都指到同一个假上游),启用范围各不相同: +/// - `本机` 只有 `claude-*`,`中转` 只有 `anthropic/*`:`sonnet` 在两家各叫各的; +/// - `谷歌` 只有 `gemini-*`:`flash` 只有它服务得了; +/// - `别家` 只有 `gpt-*`:两个别名它都服务不了。 +fn aliased(upstream: SocketAddr) -> String { + format!( + "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\n\ + clients:\n - name: 我\n key: tw-一把钥匙就够\n\ + aliases:\n sonnet:\n - claude-sonnet-4-5\n - anthropic/claude-sonnet-4-5\n \ + flash: gemini-2.5-flash\n\ + providers:\n \ + - name: 本机\n base_url: http://{upstream}\n key: sk-x\n models_only: [\"claude-*\"]\n \ + - name: 中转\n base_url: http://{upstream}\n key: sk-x\n models_only: [\"anthropic/*\"]\n \ + - name: 谷歌\n base_url: http://{upstream}\n key: sk-x\n models_only: [\"gemini-*\"]\n \ + - name: 别家\n base_url: http://{upstream}\n key: sk-x\n models_only: [\"gpt-*\"]\n" + ) +} + +/// 原来那一次的尝试链:每一跳是 `(上游, 发给它的、和客户端要的不一样的模型名)` +fn routed(hops: &[(&str, Option<&str>)]) -> Option { + let attempts = hops + .iter() + .map(|(p, m)| tw_api::AttemptView { + provider: p.to_string(), + model: m.map(String::from), + outcome: tw_api::AttemptOutcome::Served, + status: Some(200), + error: None, + ms: 100, + }) + .collect(); + Some( + serde_json::to_string(&tw_api::RoutingView { + route: "default".into(), + rule: "兜底".into(), + attempts, + ..Default::default() + }) + .unwrap(), + ) +} + +/// 发出去的那一份请求体里的模型名 +fn model_of(body: &str) -> String { + let v: serde_json::Value = serde_json::from_str(body).unwrap(); + v["model"].as_str().unwrap_or_default().to_string() +} + +/// 客户端要的是别名:重放到另一家,发的是**那一家自己的名字**,不是别名本身,也不是原来 +/// 那一家的名字。报价也按这个名字。重放回原来那一家,发的是记录里它收到的那个 +#[tokio::test] +async fn an_alias_is_replayed_under_the_chosen_upstreams_own_name() { + let (upstream, seen) = echoing().await; + let mut r = row(1, "本机"); + r.model = "sonnet".into(); + r.sent_model = "claude-sonnet-4-5".into(); + r.routing = routed(&[("本机", Some("claude-sonnet-4-5"))]); + let (_d, app) = app_of( + &aliased(upstream), + br#"{"model":"sonnet","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}"#, + r, + ); + + let (status, q) = ask(&app, "/replay/quote", "中转").await; + assert_eq!(status, StatusCode::OK, "{q}"); + assert_eq!(q["model"], "anthropic/claude-sonnet-4-5", "{q}"); + + replay(&app, "中转").await; + let (path, sent) = seen.last(); + assert_eq!(path, "/v1/messages"); + assert_eq!(model_of(&sent), "anthropic/claude-sonnet-4-5", "{sent}"); + // 只换了模型名 + assert!(sent.contains(r#""content":"hi""#), "{sent}"); + + replay(&app, "本机").await; + assert_eq!(model_of(&seen.last().1), "claude-sonnet-4-5"); +} + +/// 规则把模型改写过:重放回原来那一家,发的是记录里它收到的那个名字(报价也按它);重放到 +/// 别的一家,规则没有作用在它身上,发的是客户端要的那个,请求体一个字节都不动 +#[tokio::test] +async fn a_rewritten_request_goes_back_to_its_upstream_under_the_recorded_name() { + let (upstream, seen) = echoing().await; + let stored: &[u8] = + br#"{"model":"claude-sonnet-4-5","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}"#; + let mut r = row(1, "本机"); + r.sent_model = "claude-haiku-4-5".into(); + r.routing = routed(&[("别家", None), ("本机", Some("claude-haiku-4-5"))]); + let (_d, app) = app_of(&aliased(upstream), stored, r); + + let (status, q) = ask(&app, "/replay/quote", "本机").await; + assert_eq!(status, StatusCode::OK, "{q}"); + assert_eq!(q["model"], "claude-haiku-4-5", "{q}"); + // 按发出去的那个模型报价:输入是记录里的 5,输出按上次的估、至少 256 + let book = tw_pricing::PriceBook::builtin().unwrap(); + let usage = tw_pricing::Usage { + input: 5, + output: 256, + ..Default::default() + }; + let cost = |m: &str| match book.cost_for("本机", m, &usage, false) { + tw_pricing::Cost::Known(c) | tw_pricing::Cost::Estimated(c) => c, + other => panic!("{m} has no price: {other:?}"), + }; + assert_ne!(cost("claude-haiku-4-5"), cost("claude-sonnet-4-5")); + assert_eq!(q["cost_micros"], cost("claude-haiku-4-5"), "{q}"); + + replay(&app, "本机").await; + assert_eq!(model_of(&seen.last().1), "claude-haiku-4-5"); + + replay(&app, "中转").await; + assert_eq!(seen.last().1.as_bytes(), stored); +} + +/// 这一家服务不了这个别名(列表里的名字它一个都没有):报价和重放都说清楚,什么都不发 +#[tokio::test] +async fn an_alias_the_upstream_cannot_serve_is_not_replayed() { + let (upstream, seen) = echoing().await; + let mut r = row(1, "本机"); + r.model = "sonnet".into(); + r.sent_model = "claude-sonnet-4-5".into(); + let (_d, app) = app_of( + &aliased(upstream), + br#"{"model":"sonnet","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}"#, + r, + ); + for path in ["/replay/quote", "/replay/run"] { + let (status, v) = ask(&app, path, "别家").await; + assert_eq!(status, StatusCode::CONFLICT, "{path}: {v}"); + assert_eq!(v["code"], "control.replay_alias_unserved", "{path}: {v}"); + } + assert_eq!(seen.count(), 0); +} + +/// Gemini 的模型写在路径里:换的是路径里的那一段,请求体一个字节都不动 +#[tokio::test] +async fn a_gemini_alias_is_renamed_in_the_path() { + let (upstream, seen) = echoing().await; + let stored: &[u8] = br#"{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}"#; + let mut r = row(1, "谷歌"); + r.model = "flash".into(); + r.sent_model = "flash".into(); + r.path = "/v1beta/models/flash:generateContent".into(); + let (_d, app) = app_of(&aliased(upstream), stored, r); + + replay(&app, "谷歌").await; + let (path, sent) = seen.last(); + assert_eq!(path, "/v1beta/models/gemini-2.5-flash:generateContent"); + assert_eq!(sent.as_bytes(), stored); +} diff --git a/crates/tw-control/tests/speed.rs b/crates/tw-control/tests/speed.rs index d486c905..437df2fb 100644 --- a/crates/tw-control/tests/speed.rs +++ b/crates/tw-control/tests/speed.rs @@ -67,6 +67,8 @@ struct Seen { path: String, host: String, anthropic_version: Option, + /// 请求体里的模型名 + model: String, } /// 一个假的 HTTP 代理,同时扮演上游。 @@ -74,9 +76,10 @@ struct Seen { /// **上游地址故意写成解析不了的域名**:请求只有真的经过代理才能到达这里。 /// 走默认 client 的话,它会在 DNS 那一步失败 —— 测试要抓的正是这个。 async fn proxy_that_answers(seen: Arc>>) -> std::net::SocketAddr { - let app = axum::Router::new().fallback(move |uri: Uri, headers: HeaderMap| { + let app = axum::Router::new().fallback(move |uri: Uri, headers: HeaderMap, body: String| { let seen = seen.clone(); async move { + let body: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); *seen.lock().unwrap() = Some(Seen { path: uri.path().to_string(), host: headers @@ -88,6 +91,7 @@ async fn proxy_that_answers(seen: Arc>>) -> std::net::SocketA .get("anthropic-version") .and_then(|v| v.to_str().ok()) .map(String::from), + model: body["model"].as_str().unwrap_or_default().to_string(), }); let sse = concat!( "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n", @@ -251,3 +255,74 @@ providers: .await; assert_eq!(st, StatusCode::NOT_FOUND); } + +/// 测的是别名:每一家发的是**它自己的那个名字**,报价也按它;列表里的名字一家一个都服务不了 +/// 的,和服务不了一个真名一样跳过(不在启用范围里 / 清单里没有) +#[tokio::test] +async fn an_alias_is_tested_under_each_upstreams_own_name() { + let seen = Arc::new(Mutex::new(None)); + let proxy = proxy_that_answers(seen.clone()).await; + let yaml = format!( + "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 +clients: + - name: c + key: tw-k +aliases: + sonnet: + - claude-sonnet-4-5 + - anthropic/claude-sonnet-4-5 +proxies: + - name: corp + type: http + addr: {proxy} +providers: + - name: official + base_url: https://api.anthropic.com + key: sk-x + - name: relay + base_url: http://relay.speed-test.invalid + key: sk-y + protocol: anthropic + proxy: corp + models_only: [\"anthropic/*\"] + - name: haiku-only + base_url: https://relay.example + key: sk-z + models_only: [claude-haiku-*] +" + ); + let b = bed(&yaml); + let (st, v) = post(&b.app, "/speed/quote", r#"{"model":"sonnet"}"#).await; + assert_eq!(st, StatusCode::OK, "{v}"); + let items = v["items"].as_array().unwrap(); + let pick = |name: &str| items.iter().find(|i| i["provider"] == name).unwrap(); + let (official, relay, scoped) = (pick("official"), pick("relay"), pick("haiku-only")); + assert_eq!(official["model"], "claude-sonnet-4-5", "{v}"); + assert_eq!(relay["model"], "anthropic/claude-sonnet-4-5", "{v}"); + assert!(official.get("skipped").is_none(), "{v}"); + assert!(relay.get("skipped").is_none(), "{v}"); + assert_eq!(scoped["skipped"], "out_of_scope", "{v}"); + // 按发出去的名字报价:它在价目表里有价,别名本身没有 + assert!( + official["cost_micros"].as_i64().is_some_and(|c| c > 0), + "{v}" + ); + + let (st, v) = post( + &b.app, + "/speed/run", + r#"{"providers":["relay","haiku-only"],"model":"sonnet"}"#, + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let results = v.as_array().unwrap(); + assert_eq!(results.len(), 1, "服务不了的那一家不测:{v}"); + assert_eq!(results[0]["provider"], "relay", "{v}"); + assert_eq!(results[0]["ok"], true, "{v}"); + assert_eq!(results[0]["model"], "anthropic/claude-sonnet-4-5", "{v}"); + let seen = seen.lock().unwrap().clone().expect("代理没收到请求"); + assert_eq!(seen.model, "anthropic/claude-sonnet-4-5"); +} diff --git a/crates/tw-gateway/src/server/pipeline/hop.rs b/crates/tw-gateway/src/server/pipeline/hop.rs index 48a4cdea..301b86f0 100644 --- a/crates/tw-gateway/src/server/pipeline/hop.rs +++ b/crates/tw-gateway/src/server/pipeline/hop.rs @@ -254,7 +254,7 @@ pub(super) async fn try_upstreams<'a>( .await { Ok(p) => p, - Err(why) => { + Err(super::plug::Stop::Request(why)) => { // 这一跳没有发出去。**它在尝试链上**,原因就是拒绝它的那句话 chain.push(hop_failed( &provider.name, @@ -265,6 +265,17 @@ pub(super) async fn try_upstreams<'a>( halt = Some(GatewayError::denied(why)); break; } + // 插件换上的别名这一家服务不了:这一家不发,换下一家 + Err(super::plug::Stop::Hop(why)) => { + chain.push(hop_failed( + &provider.name, + Some(sent.clone()).filter(asked_other), + why.clone(), + hop_started, + )); + last_err = Some(GatewayError::new(crate::error::Source::Request, why)); + continue; + } }; // 插件换了发给这一家的模型名:和规则改写的一样,只是盖过它 if let Some(m) = &plugged.model { diff --git a/crates/tw-gateway/src/server/pipeline/plug.rs b/crates/tw-gateway/src/server/pipeline/plug.rs index e74a3856..ce484d8d 100644 --- a/crates/tw-gateway/src/server/pipeline/plug.rs +++ b/crates/tw-gateway/src/server/pipeline/plug.rs @@ -9,12 +9,14 @@ //! 过了,见 [`crate::guard::rescreen`])。拒绝的话拒绝整个请求,不换下一家 —— 和开头 //! 那一遍一样;处置档下插件加进来的字命中了删除规则的,删掉之后再发; //! - 插件换了发出去的模型名:**密钥的模型范围照样管**。规则改写的模型名要过这一关,插件 -//! 改的也要;上游的模型清单不再对(契约附录二); +//! 改的也要;上游的模型清单不再对(契约附录二)。插件写的是客户端那一侧的名字,**可以是 +//! 别名**:和客户端要的一样,按这一家对上它自己的那个名字([`crate::models::resolve`]) +//! 再发;这一家服务不了这个别名的,这一跳不发、换下一家([`Stop::Hop`]); //! - 出站脱敏接着插件那本账编号:插件写进来的新值拿到新的号,报一条记录; //! - 重新解码:格式转换用改过的这一份。 //! -//! 插件拒绝了、出错而策略是拒绝、或者上面哪一道没过,**整个请求被拒**,不换下一家: -//! 换一家,管它的还是这些插件。 +//! 插件拒绝了、出错而策略是拒绝、或者上面哪一道没过(别名对不上除外),**整个请求被拒**, +//! 不换下一家:换一家,管它的还是这些插件。 //! //! **发往上游的每一跳都过这一步**,不只生成回答的:数 token、Responses 的压缩一样过插件 //! (插件删掉的东西不能从这些接口漏出去),嵌入和旧版补全过声明了它们的插件,别的接口 @@ -53,10 +55,22 @@ pub(super) struct Plugged { pub(super) bridge: Option, } +/// 这一跳过不了插件这一步:说给客户端(和尝试链)的那句话,和接下来怎么办。 +pub(super) enum Stop { + /// 拒绝整个请求,不换下一家 + Request(Msg), + /// 只是这一家不发,换下一家:插件换上的别名这一家服务不了,后面的上游可能可以 + Hop(Msg), +} + +impl From for Stop { + fn from(why: Msg) -> Self { + Stop::Request(why) + } +} + /// 发往 `provider` 之前跑一遍管这一跳的插件。`model` 是发给它的模型名(路由规则改写 /// 之后的),`attempt` 是这一跳在尝试链上的位置。每一次运行当场记到请求上。 -/// -/// `Err` 是拒绝整个请求时告诉客户端的那句话。 #[allow(clippy::too_many_arguments)] pub(super) async fn attempt( state: &AppState, @@ -68,7 +82,7 @@ pub(super) async fn attempt( provider: &tw_config::Provider, model: &str, attempt: usize, -) -> Result { +) -> Result { let to = crate::plugin::request::Target { upstream: &provider.name, model, @@ -79,7 +93,7 @@ pub(super) async fn attempt( Ok(p) => p, Err(refused) => { crate::plugin::request::record(state, started.id, &refused.runs); - return Err(refused.why); + return Err(Stop::Request(refused.why)); } }; crate::plugin::request::record(state, started.id, &p.runs); @@ -90,9 +104,14 @@ pub(super) async fn attempt( let Some(c) = p.changed else { return Ok(out); }; - if let Some(r) = &c.renamed { - allowed(rt, req, r)?; - } + // 插件换上的名字发给这一家时叫什么:别名对到这一家自己的那个名字 + let renamed = match &c.renamed { + Some(r) => { + allowed(rt, req, r)?; + Some(sent_name(state, rt, provider, r)?) + } + None => None, + }; // 内容过滤:只报插件加进来的。处置档下删过的话,后面一律用删过的那一份 let (body, value) = rescreen(state, rt, req, started, &provider.name, &c)?; // 生成回答的请求重新解码:格式转换用改过的这一份。数 token、压缩这些不转换(只发给 @@ -121,7 +140,7 @@ pub(super) async fn attempt( at_ms: crate::server::now_ms(), }); } - out.model = c.renamed.map(|r| r.model); + out.model = renamed; out.rewritten = Some(Rewritten { body, path: c.path, @@ -262,6 +281,27 @@ fn request_unscreenable() -> Msg { msg!("gw.internal" => "The request was interrupted by an error inside the gateway.") } +/// 插件换上的模型名发给 `provider` 时叫什么([`crate::models::resolve`])。插件写的是客户端 +/// 那一侧的名字:是别名的话,发这一家自己的那个,不是别名本身。这一家服务不了这个别名 +/// 是 [`Stop::Hop`]:这一跳不发,后面的上游可能可以。 +/// +/// 不是别名的照旧原样发:插件改的名字不对上游的模型清单(见模块说明)。 +fn sent_name( + state: &AppState, + rt: &Runtime, + provider: &tw_config::Provider, + r: &crate::plugin::request::Renamed, +) -> Result { + crate::models::resolve(&rt.config, &state.catalog.load(), provider, &r.model).ok_or_else(|| { + Stop::Hop(msg!( + "gw.plugin.alias_unserved", + plugin = r.by.clone(), model = r.model.clone(), upstream = provider.name.clone() => + "Plugin `{plugin}` changed the model to the alias {model}, and upstream `{upstream}` \ + offers none of its models, so the request was not sent there." + )) + }) +} + /// 插件换上的模型名,这把密钥用不用得了。**和路由规则改写的模型名过同一关**(见 /// `super::admit` 里的说法):密钥的模型范围管的是发出去的模型,谁改的都一样。 fn allowed(rt: &Runtime, req: &Inbound, r: &crate::plugin::request::Renamed) -> Result<(), Msg> { diff --git a/crates/tw-gateway/tests/plugins_request.rs b/crates/tw-gateway/tests/plugins_request.rs index 69481d28..45047361 100644 --- a/crates/tw-gateway/tests/plugins_request.rs +++ b/crates/tw-gateway/tests/plugins_request.rs @@ -492,6 +492,60 @@ async fn the_hook_sees_the_upstream_the_sent_model_and_the_asked_one() { assert_eq!(up.seen.lock().unwrap()[0].1["model"], "glm-5"); } +/// 插件换上的是别名:和客户端要的别名一样,每一家发的是**它自己的那个名字**(a 只有 +/// `claude-*`,b 只有 `anthropic/*`),尝试链上记的也是它。列表里的名字一个都服务不了的那 +/// 一家(c)这一跳不发、换下一家:后面的上游服务得了 +#[tokio::test] +async fn a_plugin_renaming_to_an_alias_sends_each_upstream_its_own_name() { + let (a, b, mut providers) = a_fails_then_b().await; + providers[0].models_only = Some(vec!["claude-*".into()]); + providers[1].models_only = Some(vec!["anthropic/*".into(), "claude-sonnet-4-5".into()]); + let c = Upstream::default(); + let mut only_asked = provider("c", start_upstream(c.clone()).await, Protocol::Anthropic); + only_asked.models_only = Some(vec!["claude-sonnet-4-5".into()]); + providers.insert(0, only_asked); + let to_alias = Double::new("to alias") + .permit(&[Permission::Params]) + .on_request(|mut view, _| { + view["params"]["model"] = json!("sonnet"); + Invocation::ok(RequestOutcome::Changed(view)) + }); + let gw = gateway_of( + Config { + providers, + aliases: tw_config::Aliases(vec![tw_config::Alias { + name: "sonnet".into(), + models: vec!["claude-sonnet-5".into(), "anthropic/claude-sonnet-5".into()], + }]), + ..config() + }, + vec![entry("alias", to_alias)], + ) + .await; + let rx = gw.state.bus.subscribe(); + let (status, _) = post(&gw, "/v1/messages", &anthropic_body()).await; + assert_eq!(status, 200); + assert_eq!((c.hits(), a.hits(), b.hits()), (0, 1, 1)); + assert_eq!(a.seen.lock().unwrap()[0].1["model"], "claude-sonnet-5"); + assert_eq!( + b.seen.lock().unwrap()[0].1["model"], + "anthropic/claude-sonnet-5" + ); + let chain: Vec<(String, Option, Option)> = routed(rx) + .await + .into_iter() + .map(|h| (h.provider, h.model, h.error.map(|e| e.code))) + .collect(); + assert_eq!( + chain, + [ + ("c".into(), None, Some("gw.plugin.alias_unserved".into())), + ("a".into(), Some("claude-sonnet-5".into()), None), + ("b".into(), Some("anthropic/claude-sonnet-5".into()), None), + ] + ); +} + #[tokio::test] async fn a_rejection_is_answered_in_the_clients_format_and_nothing_goes_upstream() { let up = Upstream::default(); From 391bf326371bfacbc6e95ac6885c1086921f0b85 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:22:53 +0800 Subject: [PATCH 09/18] Model aliases, routing: rule inheritance, per-upstream sent names, pinned failover, dry-run models - tw-engine: the engine carries the alias table (`Engine::with_aliases`, `alias_models`; `Config::engine()` passes it). `When::matches` / `matches_with_provider` take the request alias's models, so a `when.model` glob on an upstream name also matches the aliases that list it; a condition written on an alias matches only that alias. Both phases inherit. - `Engine::asked` / `asked_of` give each candidate's asked name with its origin (`Origin::Client | Rule | PhaseTwo | Pinned`); `models_asked` is the same list without origins. Client and phase-one names are client-side (may be an alias); pinned and phase-two names go out as written. `Outcome2::Proceed` now says which model phase two set. - tw-gateway `sent`: the name each upstream receives (`resolve` for client-side names, as written otherwise) and why it differs from the client's name. Routing skips candidates by the sent name (an upstream that serves none of an alias's models is skipped), `cheapest` prices and the context-window check use the sent name, and each hop re-resolves after failover; `Served.model` and the attempt record are the sent name. A hop whose upstream no longer serves the alias fails with `gw.model.alias_not_served` instead of sending the alias. - Pinned targets fail over in list order with no group ordering; session affinity applies as for any candidate list (group `None`). - Dry run: `candidate_models` (one per candidate: `sent_model`, `model_via` = alias | rule | pinned), rule trace and mismatch with inheritance, cheapest by sent name. - Tests: engine inheritance/origins/pinned/phase-two; gateway end-to-end alias failover, set.model to an alias, pinned failover in list order, phase-two rename as written, inheritance; dry-run fields and cheapest by sent name. C0's pinned e2e test reads the echoed `sent` (C3 changed the fake upstream). Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/msg-codes.txt | 1 + crates/tw-api/src/lib.rs | 24 +- crates/tw-config/src/lib.rs | 6 + crates/tw-control/src/dryrun.rs | 50 ++- crates/tw-control/tests/dryrun.rs | 176 ++++++++++ crates/tw-engine/src/engine.rs | 338 ++++++++++++++++++- crates/tw-engine/src/lib.rs | 6 +- crates/tw-engine/src/rule.rs | 111 ++++-- crates/tw-gateway/src/lib.rs | 1 + crates/tw-gateway/src/sent.rs | 270 +++++++++++++++ crates/tw-gateway/src/server/pipeline.rs | 42 ++- crates/tw-gateway/src/server/pipeline/hop.rs | 49 ++- crates/tw-gateway/tests/alias_routing.rs | 320 ++++++++++++++++++ crates/tw-gateway/tests/models.rs | 4 +- 14 files changed, 1315 insertions(+), 83 deletions(-) create mode 100644 crates/tw-gateway/src/sent.rs create mode 100644 crates/tw-gateway/tests/alias_routing.rs diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index a06c33e4..ab989637 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -272,6 +272,7 @@ gw.listen.nic_offline gw.listen.no_such_nic gw.listen.port_taken gw.method.not_forwarded +gw.model.alias_not_served gw.model.no_upstream gw.model.no_upstream_available gw.model.no_upstream_rewritten diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index b8339167..5949fe46 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -1423,8 +1423,9 @@ slug_enum! { #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct AttemptView { pub provider: String, - /// 规则改写了模型名:这一跳发给(没发出去的,要发给)上游的是哪个。没改写的 - /// 没有 —— 发出去的就是客户端要的那个(`RequestStarted::model`)。 + /// 这一跳发给(没发出去的,要发给)上游的模型名,和客户端要的不一样时才有:别名对到 + /// 这一家的名称、规则改写、规则指定的模型、插件改名。一样的没有 —— 发出去的就是客户端 + /// 要的那个(`RequestStarted::model`)。 /// /// **费用按它算**:请求改写成另一个模型发出去,上游按那个模型收钱。 #[serde(default, skip_serializing_if = "Option::is_none")] @@ -4502,6 +4503,8 @@ pub struct DryRunResult { pub reason: Option, /// 候选链,第一个是首选,后面是故障转移的备选 pub candidates: Vec, + /// 每个候选发出去的模型名,和 `candidates` 一一对应 + pub candidate_models: Vec, /// 经过了哪个组 pub via_group: Option, /// 累积起来的参数改写 @@ -4515,6 +4518,23 @@ pub struct DryRunResult { pub converted: Vec, } +/// 试算里一个候选上游收到的模型名。 +/// +/// **同一个请求在各家可能是不同的名字**:别名对到每一家自己的名称,规则改写、规则指定的 +/// 模型也会换掉它。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct DryRunCandidate { + pub provider: String, + /// 发给它的模型名。试算没写模型时没有 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sent_model: Option, + /// 发出的名字为什么和请求里写的不一样:`alias`(别名对到这一家的名称)、`rule`(规则 + /// 改写了模型)、`pinned`(规则指定了这一家发什么模型)。一样时没有 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_via: Option, +} + /// 一个要转换格式的候选上游。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] diff --git a/crates/tw-config/src/lib.rs b/crates/tw-config/src/lib.rs index 831c9449..171c3520 100644 --- a/crates/tw-config/src/lib.rs +++ b/crates/tw-config/src/lib.rs @@ -247,6 +247,12 @@ impl Config { .filter_map(|c| c.route.clone().map(|r| (c.name.clone(), r))) .collect(), ) + // 规则条件 `when.model` 写上游模型名时也匹配列了它的别名 + .with_aliases( + self.aliases + .iter() + .map(|a| (a.name.clone(), a.models.clone())), + ) } } diff --git a/crates/tw-control/src/dryrun.rs b/crates/tw-control/src/dryrun.rs index a671f9cf..a58637b9 100644 --- a/crates/tw-control/src/dryrun.rs +++ b/crates/tw-control/src/dryrun.rs @@ -12,12 +12,12 @@ use axum::{Json, extract::State, http::StatusCode}; /// /// 试算页存在的全部意义是「告诉你这条请求会走哪儿」,所以它**必须**用 /// 同一个函数、同一份数字 —— 各算各的话,两边迟早会不一样,而那时 -/// 试算比没有更糟。 +/// 试算比没有更糟。`sent` 是每一家和发给它的模型名:比价按它算,和数据面一样。 fn order_like_the_data_plane( s: &crate::ControlState, engine: &tw_engine::Engine, d: &tw_engine::Decision, - asked: &[(String, String)], + sent: &[(String, String)], ) -> Vec { let Some(gname) = d.via_group.clone() else { return d.candidates.clone(); @@ -37,7 +37,7 @@ fn order_like_the_data_plane( let facts = tw_engine::Facts { seq: s.gateway.bus.peek_id(), ttfb_ms: s.gateway.latency.snapshot(&d.candidates), - price: s.gateway.unit_prices(&cfg.providers, asked, &d.candidates), + price: s.gateway.unit_prices(&cfg.providers, sent, &d.candidates), }; engine.order(Some(&gname), &d.candidates, &facts) } @@ -142,6 +142,8 @@ pub async fn dry_run( // 它们被跳过了,而实际上它们根本不在这条求值链上。 let mut trace = Vec::new(); let mut decided = false; + // 请求的是别名时它列的模型名:写上游模型名的条件也匹配它,和数据面一样 + let aliased = engine.alias_models(&f.model); // 短路时一条都不求值 —— 空的 `trace` 说的就是这件事, // 而一份「全部未命中」的明细会读成「规则写错了」 for r in rules.iter().filter(|_| short.is_none()) { @@ -156,7 +158,7 @@ pub async fn dry_run( }); continue; } - match r.when.matches(&f) { + match r.when.matches(&f, aliased) { Ok(true) => { // 命中之后起了什么作用。**「命中了但没用上」要说出来** —— 兜底 // 规则在试算里命中,而去向早已由前面的规则决定 @@ -179,7 +181,7 @@ pub async fn dry_run( Ok(false) => trace.push(tw_api::RuleTrace { name: r.name.clone(), verdict: RuleVerdict::Skipped, - mismatch: unmatched(&r.when, &f), + mismatch: unmatched(&r.when, &f, aliased), error: None, effect: None, }), @@ -200,6 +202,7 @@ pub async fn dry_run( rule: None, reason: None, candidates: Vec::new(), + candidate_models: Vec::new(), via_group: None, set: Vec::new(), trace, @@ -227,10 +230,12 @@ pub async fn dry_run( // 清单里没有这个模型的)。**被跳过的要列出来** —— 「规则明明写的 // 是 A」正是用户会来试算的原因 // - // 每一家按它实际要的模型看:规则改写过的按改写后的算,和数据面一样 - let asked = engine.models_asked(rules, &f, &d); - let serving = - tw_gateway::models::serving(&rt.config, &s.gateway.catalog.load(), &asked); + // 每一家按发给它的模型名看:规则改写过的按改写后的算,别名对到各家自己的 + // 名称,和数据面同一个函数(`tw_gateway::sent`) + let asked = engine.asked(rules, &f, &d); + let catalog = s.gateway.catalog.load(); + let sent = tw_gateway::sent::plan(&rt.config, &catalog, &f.model, &asked); + let serving = tw_gateway::sent::serving(&rt.config, &catalog, &sent); out.skipped = serving .skipped .iter() @@ -262,7 +267,21 @@ pub async fn dry_run( // 而它属于哪次会话取决于请求正文,试算没有那个东西。 // 于是它显示的是轮转序列里的当前位置 —— 而那正是一个没有 // 会话指纹的请求真的会走的路。 - out.candidates = order_like_the_data_plane(&s, engine, &d, &asked); + out.candidates = + order_like_the_data_plane(&s, engine, &d, &tw_gateway::sent::pairs(&sent)); + // 每一家收到的模型名,和为什么不是请求里写的那个 + out.candidate_models = out + .candidates + .iter() + .map(|name| { + let one = sent.iter().find(|x| x.provider == *name); + tw_api::DryRunCandidate { + provider: name.clone(), + sent_model: one.and_then(|x| x.model.clone()).filter(|m| !m.is_empty()), + model_via: one.and_then(|x| x.via).map(|v| v.slug().to_string()), + } + }) + .collect(); // 哪些候选要转换格式。**试算里要说出来**:转换可能丢掉请求里的字段, // 而「规则把我分到了一个别的格式的上游」本身就是用户来试算想知道的事。 // 协议认不出来的上游直通,不算 @@ -322,12 +341,19 @@ fn describe(set: &tw_engine::SetAction) -> Vec { /// /// **逐条试,报第一个不满足的。**报「不匹配」等于什么都没说 —— 用户看 /// 试算就是为了知道差在哪儿。 -fn unmatched(when: &tw_engine::rule::When, f: &RequestFacts) -> Option { +/// +/// `aliased` 是请求的别名列的模型名(见 [`tw_engine::rule::When::matches`]):模型这一条 +/// 对它们也算,和求值时一样。 +fn unmatched( + when: &tw_engine::rule::When, + f: &RequestFacts, + aliased: &[String], +) -> Option { let miss = |field: ConditionField, want: Vec, got: String| { Some(tw_api::MismatchView { field, want, got }) }; if let Some(w) = &when.model - && !tw_engine::rule::glob_match(w, &f.model) + && !when.model_matches(&f.model, aliased) { return miss(ConditionField::Model, vec![w.clone()], f.model.clone()); } diff --git a/crates/tw-control/tests/dryrun.rs b/crates/tw-control/tests/dryrun.rs index 64cdfd9f..7d7383f1 100644 --- a/crates/tw-control/tests/dryrun.rs +++ b/crates/tw-control/tests/dryrun.rs @@ -385,3 +385,179 @@ async fn an_intent_rule_catches_a_forwarded_probe() { .await; assert_eq!(r.rule.as_deref(), Some("兜底"), "{r:?}"); } + +// ─────────────────────────────────────────────────────────── 别名与指定模型 + +/// 三家上游各用各的写法:官方 `claude-*`、中转 `anthropic/*`、智谱 `glm-*` +const ALIASED: &str = r#"version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 +clients: + - name: 我 + key: tw-k +providers: + - name: 官方 + base_url: https://api.anthropic.com + key: sk-a + models_only: ["claude-*"] + - name: 中转 + base_url: https://relay.example.com + key: sk-b + models_only: ["anthropic/*"] + - name: 智谱 + base_url: https://zhipu.example.com + key: sk-c + models_only: ["glm-*"] +aliases: + opus: [claude-opus-5, anthropic/claude-opus-5] + glm-air: glm-5-air-0414 +routes: + - name: default + rules: + - name: 指定 + when: { model: pinned } + to: + - { provider: 智谱, model: glm-5 } + - { provider: 官方, model: claude-opus-5 } + - name: 中转写法的 Opus + when: { model: "anthropic/*" } + to: 中转 + - name: Sonnet 换成 Opus + when: { model: claude-sonnet-* } + set: { model: opus } + - name: 智谱的叫法 + when: { provider_would_be: 智谱, model: glm-* } + set: { model: glm-air } + - name: 其余 + to: __all__ +"#; + +/// 每个候选:(上游,发给它的名字,来历) +fn sent(r: &tw_api::DryRunResult) -> Vec<(&str, Option<&str>, Option<&str>)> { + assert_eq!( + r.candidate_models + .iter() + .map(|c| c.provider.as_str()) + .collect::>(), + r.candidates, + "和候选一一对应" + ); + r.candidate_models + .iter() + .map(|c| { + ( + c.provider.as_str(), + c.sent_model.as_deref(), + c.model_via.as_deref(), + ) + }) + .collect() +} + +/// 每个候选发出去的名字和来历:别名对到各家、规则改写(改成别名也照常对)、指定模型、 +/// 阶段二原样;和客户端写的一样时没有来历 +#[tokio::test] +async fn each_candidate_shows_the_name_it_is_sent_and_why() { + let (_d, app) = app_with(ALIASED); + + // 请求别名:写中转写法(真名)的那条规则也管到它 + let r = run(&app, r#"{"model":"opus"}"#).await; + assert_eq!(r.rule.as_deref(), Some("中转写法的 Opus"), "{r:?}"); + assert_eq!( + sent(&r), + [("中转", Some("anthropic/claude-opus-5"), Some("alias"))] + ); + let t = r + .trace + .iter() + .find(|t| t.name == "中转写法的 Opus") + .unwrap(); + assert_eq!(t.verdict, tw_api::RuleVerdict::Matched); + + // 请求真名:那条规则不管它,没对上的条件照实说 + let r = run(&app, r#"{"model":"claude-opus-5"}"#).await; + assert_eq!(r.rule.as_deref(), Some("其余"), "{r:?}"); + assert_eq!(sent(&r), [("官方", Some("claude-opus-5"), None)]); + let t = r + .trace + .iter() + .find(|t| t.name == "中转写法的 Opus") + .unwrap(); + assert_eq!(t.mismatch.as_ref().unwrap().got, "claude-opus-5"); + + // 规则改写成别名:照常对到各家;对不到的那一家跳过 + let r = run(&app, r#"{"model":"claude-sonnet-5"}"#).await; + assert_eq!( + sent(&r), + [ + ("官方", Some("claude-opus-5"), Some("rule")), + ("中转", Some("anthropic/claude-opus-5"), Some("rule")), + ] + ); + assert_eq!( + r.skipped + .iter() + .map(|s| s.provider.as_str()) + .collect::>(), + ["智谱"] + ); + + // 指定模型:按列表的顺序,原样 + let r = run(&app, r#"{"model":"pinned"}"#).await; + assert_eq!( + sent(&r), + [ + ("智谱", Some("glm-5"), Some("pinned")), + ("官方", Some("claude-opus-5"), Some("pinned")), + ] + ); + + // 阶段二改的名字原样发:`glm-air` 是别名也不对到 `glm-5-air-0414` + let r = run(&app, r#"{"model":"glm-5"}"#).await; + assert_eq!(sent(&r), [("智谱", Some("glm-air"), Some("rule"))]); +} + +/// `cheapest` 按每一家发出去的名字比价:同一个别名在两家是两个模型、两个价 +#[tokio::test] +async fn cheapest_prices_each_upstream_by_the_name_it_is_sent() { + let (_d, app) = app_with( + r#"version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 +clients: + - name: 我 + key: tw-k +providers: + - name: 贵的 + base_url: https://a.example.com + key: sk-a + models_only: ["claude-opus-*"] + - name: 便宜的 + base_url: https://b.example.com + key: sk-b + models_only: ["claude-haiku-*"] +aliases: + 快: [claude-opus-4-1, claude-haiku-4-5] +groups: + - name: 省钱 + type: cheapest + providers: [贵的, 便宜的] +routes: + - name: default + rules: + - name: 省钱 + to: 省钱 +"#, + ); + let r = run(&app, r#"{"model":"快"}"#).await; + // 按别名本身比的话两家都算不出价钱,次序不变 + assert_eq!( + sent(&r), + [ + ("便宜的", Some("claude-haiku-4-5"), Some("alias")), + ("贵的", Some("claude-opus-4-1"), Some("alias")), + ] + ); +} diff --git a/crates/tw-engine/src/engine.rs b/crates/tw-engine/src/engine.rs index 045b6d63..63f3e9ee 100644 --- a/crates/tw-engine/src/engine.rs +++ b/crates/tw-engine/src/engine.rs @@ -489,6 +489,9 @@ pub enum Outcome2 { set: SetAction, /// 这一轮又附加了改写的规则(只有阶段二的),按求值的顺序 rewritten_by: Vec, + /// 阶段二的规则把模型改成了这个(`set` 里也是它)。**原样发出,不经过别名表**: + /// 阶段二已经知道是哪一家,写的就是那一家的名字 + model: Option, }, Deny { rule: String, @@ -617,6 +620,37 @@ impl RouteError { /// 规则的去向解出来的样子:候选、经过的策略组、指定的模型(见 [`Decision`])。 type Resolved = (Vec, Option, Vec); +/// 一个候选要的模型([`Engine::asked`]):哪一家、要什么名字、这个名字从哪来。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Asked { + pub provider: String, + /// `origin` 是 [`Origin::Client`] 或 [`Origin::Rule`] 时是**客户端那一侧的名称**(可能 + /// 是别名,发出去之前按别名表对到这一家);另外两种是原样发出的名字 + pub model: String, + pub origin: Origin, +} + +/// 一个候选要的模型名从哪来。决定发给这一家时还要不要按别名表对。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Origin { + /// 客户端写的那个 + Client, + /// 阶段一的规则改写的(`set.model`)。**和客户端写的一样算客户端那一侧的名称**:可以 + /// 写别名,按别名表对到每一家 + Rule, + /// 阶段二的规则改写的:原样发出 + PhaseTwo, + /// 规则指定的模型:原样发出 + Pinned, +} + +impl Origin { + /// 这个名字原样发出,不按别名表对到这一家 + pub fn as_written(self) -> bool { + matches!(self, Origin::PhaseTwo | Origin::Pinned) + } +} + pub struct Engine { sets: Vec, /// 没绑定时走哪条。 @@ -637,6 +671,8 @@ pub struct Engine { bound: std::collections::HashMap, groups: Vec, providers: Vec, + /// 别名 → 它列的模型名([`Self::with_aliases`])。`when.model` 的继承要它 + aliases: std::collections::HashMap>, } impl Engine { @@ -699,9 +735,28 @@ impl Engine { bound, groups, providers, + aliases: Default::default(), } } + /// 带上全局的别名表:别名 → 它列的模型名。 + /// + /// 引擎只用它做一件事:**规则条件 `when.model` 的继承**([`When::model_matches`])—— + /// 写上游模型名的条件也匹配列了它的别名。别名对到每一家发什么名字不归引擎管:那要 + /// 知道每一家有什么模型,是网关的事。 + pub fn with_aliases( + mut self, + aliases: impl IntoIterator)>, + ) -> Self { + self.aliases = aliases.into_iter().collect(); + self + } + + /// `name` 是别名时它列的模型名,不是别名时是空的。名字要完全相等:别名没有通配 + pub fn alias_models(&self, name: &str) -> &[String] { + self.aliases.get(name).map_or(&[], Vec::as_slice) + } + /// 只有一条默认路由的引擎。 /// /// **这不是测试便利,是「层 0」本身** —— 没有把规则分给任何密钥的 @@ -874,9 +929,10 @@ impl Engine { let mut rewritten_by = Vec::new(); let mut chosen: Option<&Rule> = None; + let aliased = self.alias_models(&facts.model); for r in rules { // 阶段二的规则在这一轮完全跳过 —— 它们的条件还没法求值。 - if r.when.is_phase_two() || !r.when.matches(facts)? { + if r.when.is_phase_two() || !r.when.matches(facts, aliased)? { continue; } if let Some(s) = &r.set { @@ -933,8 +989,10 @@ impl Engine { ) -> Result { let mut set = base.clone(); let mut rewritten_by = Vec::new(); + let mut model = None; + let aliased = self.alias_models(&facts.model); for r in rules { - if !r.when.is_phase_two() || !r.when.matches_with_provider(facts, provider)? { + if !r.when.is_phase_two() || !r.when.matches_with_provider(facts, provider, aliased)? { continue; } if let Some(reason) = &r.deny { @@ -945,12 +1003,19 @@ impl Engine { } if let Some(s) = &r.set { set.merge(s); + if s.model.is_some() { + model = s.model.clone(); + } if r.adds() { rewritten_by.push(r.name.clone()); } } } - Ok(Outcome2::Proceed { set, rewritten_by }) + Ok(Outcome2::Proceed { + set, + rewritten_by, + model, + }) } /// 每个候选实际会被要哪个模型,按候选的顺序。 @@ -960,6 +1025,10 @@ impl Engine { /// 名字 —— 按客户端写的名字看,一条把 `claude-*` 改成 `glm-*` 的规则永远 /// 用不上:智谱的清单里没有 `claude-*`。 /// + /// **给的是客户端那一侧的名称**:请求的是别名,这里就是别名;阶段一改写成别名,也是 + /// 别名。每一家实际发什么由网关按别名表对(`tw_gateway::models::resolve`)。指定模型 + /// 和阶段二改写的名字原样发出,这里给的就是发出的那个(来历见 [`Self::asked`])。 + /// /// 阶段二拒绝或求不了值时按阶段一算:那一跳本来就发不出去,尝试那一步会 /// 报出来。请求里读不出模型时一律是空的 —— 请求体都解不开,改写也写不进去。 /// @@ -970,25 +1039,60 @@ impl Engine { facts: &RequestFacts, d: &Decision, ) -> Vec<(String, String)> { + self.asked(rules, facts, d) + .into_iter() + .map(|a| (a.provider, a.model)) + .collect() + } + + /// [`Self::models_asked`],带上每个名字的来历:要不要按别名表对到那一家,看它。 + pub fn asked(&self, rules: &[Rule], facts: &RequestFacts, d: &Decision) -> Vec { d.candidates .iter() .map(|c| { - let model = if facts.model.is_empty() { - String::new() - } else if let Some(m) = d.pinned_model(c) { - m.to_string() - } else { - let set = match self.phase_two_with(rules, facts, c, &d.set) { - Ok(Outcome2::Proceed { set, .. }) => set.model, - _ => d.set.model.clone(), + if facts.model.is_empty() { + return Asked { + provider: c.clone(), + model: String::new(), + origin: Origin::Client, }; - set.unwrap_or_else(|| facts.model.clone()) + } + let renamed = match self.phase_two_with(rules, facts, c, &d.set) { + Ok(Outcome2::Proceed { model, .. }) => model, + _ => None, }; - (c.clone(), model) + self.asked_of(facts, d, c, renamed.as_deref()) }) .collect() } + /// 候选 `provider` 要的模型。`renamed` 是阶段二对这一家改成的名字 + /// ([`Outcome2::Proceed`] 的 `model`):网关每一跳自己跑阶段二,拿它来问。 + /// + /// 依次:指定的模型 → 阶段二改的 → 阶段一改的 → 客户端写的。 + pub fn asked_of( + &self, + facts: &RequestFacts, + d: &Decision, + provider: &str, + renamed: Option<&str>, + ) -> Asked { + let (model, origin) = if let Some(m) = d.pinned_model(provider) { + (m.to_string(), Origin::Pinned) + } else if let Some(m) = renamed { + (m.to_string(), Origin::PhaseTwo) + } else if let Some(m) = &d.set.model { + (m.clone(), Origin::Rule) + } else { + (facts.model.clone(), Origin::Client) + }; + Asked { + provider: provider.to_string(), + model, + origin, + } + } + /// 规则的去向 → 候选、经过的策略组、指定的模型。 fn resolve_target(&self, r: &Rule) -> Result { let to = match &r.to { @@ -2261,6 +2365,214 @@ to: } } +#[cfg(test)] +mod alias_tests { + use super::*; + + fn rule(yaml: &str) -> Rule { + serde_yaml_ng::from_str(yaml).unwrap() + } + + /// `opus` 是别名:官方叫 `claude-opus-5`,Bedrock 叫 `us.anthropic.claude-opus-5-v1:0` + fn engine(rules: Vec) -> Engine { + let e = Engine::with_default_rules( + vec!["bedrock".into(), "anthropic".into(), "relay".into()], + vec![], + rules, + ) + .with_aliases([( + "opus".to_string(), + vec![ + "claude-opus-5".to_string(), + "us.anthropic.claude-opus-5-v1:0".to_string(), + ], + )]); + assert!(e.validate().is_ok(), "{:?}", e.validate()); + e + } + + fn facts(model: &str) -> RequestFacts { + RequestFacts { + model: model.into(), + client: "claude-code".into(), + dialect: "anthropic".into(), + ..Default::default() + } + } + + fn decision(e: &Engine, model: &str) -> Decision { + match e.route(&facts(model)).unwrap() { + Outcome::Route(d) => d, + other => panic!("该路由,实际 {other:?}"), + } + } + + #[test] + fn the_alias_table_is_looked_up_by_the_exact_name() { + let e = engine(vec![]); + assert_eq!(e.alias_models("opus").len(), 2); + assert!(e.alias_models("Opus").is_empty()); + assert!(e.alias_models("claude-opus-5").is_empty()); + } + + /// 条件写上游模型名(glob):也匹配列了它的别名。写别名:只匹配请求这个别名本身 + #[test] + fn a_condition_on_a_real_name_matches_the_aliases_that_list_it_and_not_back() { + let e = engine(vec![ + rule("{name: Bedrock 上的 Opus, when: {model: 'us.anthropic.*'}, to: bedrock}"), + rule("{name: 别名本身, when: {model: opus}, to: anthropic}"), + rule("{name: 兜底, to: relay}"), + ]); + // 请求别名:它列的 `us.anthropic.claude-opus-5-v1:0` 对上了第一条 + assert_eq!(decision(&e, "opus").matched_rule, "Bedrock 上的 Opus"); + // 请求真名:第一条对不上,写别名的那条也不管它 + assert_eq!(decision(&e, "claude-opus-5").matched_rule, "兜底"); + + let e = engine(vec![ + rule("{name: 别名本身, when: {model: opus}, to: anthropic}"), + rule("{name: 兜底, to: relay}"), + ]); + assert_eq!(decision(&e, "opus").candidates, ["anthropic"]); + assert_eq!(decision(&e, "claude-opus-5").candidates, ["relay"]); + } + + /// 阶段二的条件一样继承 + #[test] + fn phase_two_conditions_inherit_too() { + let e = engine(vec![ + rule( + "{name: 中转不给 Opus, when: {provider_would_be: relay, model: claude-opus-*}, deny: 不行}", + ), + rule("{name: 兜底, to: __all__}"), + ]); + let base = SetAction::default(); + assert!(matches!( + e.phase_two(&facts("opus"), "relay", &base).unwrap(), + Outcome2::Deny { .. } + )); + assert!(matches!( + e.phase_two(&facts("opus"), "bedrock", &base).unwrap(), + Outcome2::Proceed { .. } + )); + assert!(matches!( + e.phase_two(&facts("sonnet"), "relay", &base).unwrap(), + Outcome2::Proceed { .. } + )); + } + + /// 阶段一改写成的名字是客户端那一侧的(可以是别名);阶段二改的、指定的原样发出 + #[test] + fn each_candidate_says_where_its_name_came_from() { + let rules = vec![ + rule("{name: 改成别名, set: {model: opus}}"), + rule( + "{name: 中转的叫法, when: {provider_would_be: relay}, set: {model: relay/opus, max_tokens: 100}}", + ), + rule("{name: 兜底, to: __all__}"), + ]; + let e = engine(rules.clone()); + let f = facts("claude-sonnet-5"); + let d = decision(&e, "claude-sonnet-5"); + let asked = e.asked(&rules, &f, &d); + let got: Vec<(&str, &str, Origin)> = asked + .iter() + .map(|a| (a.provider.as_str(), a.model.as_str(), a.origin)) + .collect(); + assert_eq!( + got, + [ + ("bedrock", "opus", Origin::Rule), + ("anthropic", "opus", Origin::Rule), + ("relay", "relay/opus", Origin::PhaseTwo), + ] + ); + assert!(!Origin::Rule.as_written() && Origin::PhaseTwo.as_written()); + // `models_asked` 是同一份,只是不带来历 + assert_eq!( + e.models_asked(&rules, &f, &d), + asked + .iter() + .map(|a| (a.provider.clone(), a.model.clone())) + .collect::>() + ); + // 阶段二说出它改成了什么;没改的那一家是 None + match e.phase_two(&f, "relay", &d.set).unwrap() { + Outcome2::Proceed { model, set, .. } => { + assert_eq!(model.as_deref(), Some("relay/opus")); + assert_eq!(set.max_tokens, Some(100)); + } + other => panic!("{other:?}"), + } + match e.phase_two(&f, "bedrock", &d.set).unwrap() { + Outcome2::Proceed { model, set, .. } => { + assert_eq!(model, None); + assert_eq!(set.model.as_deref(), Some("opus"), "阶段一的照常带着"); + } + other => panic!("{other:?}"), + } + // 没有任何改写:客户端写的那个 + let plain = vec![rule("{name: 兜底, to: anthropic}")]; + let e = engine(plain.clone()); + let d = decision(&e, "opus"); + assert_eq!( + e.asked(&plain, &facts("opus"), &d), + [Asked { + provider: "anthropic".into(), + model: "opus".into(), + origin: Origin::Client, + }] + ); + } + + /// 指定模型:按列表的顺序,每一家原样发它的名字;阶段二改名盖不过它,别的改写照常 + #[test] + fn pinned_entries_keep_their_order_and_names_and_other_rewrites_still_apply() { + let rules = vec![ + rule("{name: 降上限, set: {max_tokens: 10}}"), + rule( + "name: 指定 +when: {model: claude-opus-*} +to: + - { provider: relay, model: opus } + - { provider: bedrock, model: us.anthropic.claude-opus-5-v1:0 } +", + ), + rule( + "{name: 阶段二, when: {provider_would_be: relay}, set: {model: x, thinking: false}}", + ), + rule("{name: 兜底, to: __all__}"), + ]; + let e = engine(rules.clone()); + // 请求别名也命中写真名的指定规则(继承),候选就是列出来的两家,按列表顺序 + let d = decision(&e, "opus"); + assert_eq!(d.matched_rule, "指定"); + assert_eq!(d.candidates, ["relay", "bedrock"]); + assert_eq!(d.via_group, None); + assert_eq!(d.set.max_tokens, Some(10), "别的规则的改写照常"); + let asked = e.asked(&rules, &facts("opus"), &d); + assert_eq!( + asked + .iter() + .map(|a| (a.model.as_str(), a.origin)) + .collect::>(), + [ + // 指定的名字恰好是别名也原样发:不经过别名表 + ("opus", Origin::Pinned), + ("us.anthropic.claude-opus-5-v1:0", Origin::Pinned), + ] + ); + // 阶段二照常跑:改名没用,别的改写照常 + match e.phase_two(&facts("opus"), "relay", &d.set).unwrap() { + Outcome2::Proceed { model, set, .. } => { + let a = e.asked_of(&facts("opus"), &d, "relay", model.as_deref()); + assert_eq!((a.model.as_str(), a.origin), ("opus", Origin::Pinned)); + assert_eq!((set.thinking, set.max_tokens), (Some(false), Some(10))); + } + other => panic!("{other:?}"), + } + } +} + #[cfg(test)] mod msg_codes { use super::*; diff --git a/crates/tw-engine/src/lib.rs b/crates/tw-engine/src/lib.rs index 35888538..44c6540d 100644 --- a/crates/tw-engine/src/lib.rs +++ b/crates/tw-engine/src/lib.rs @@ -6,8 +6,8 @@ pub mod rule; pub use catalog::{Catalog, ProviderModels}; pub use engine::{ - ALL_UPSTREAMS, CATCH_ALL_RULE, DEFAULT_ROUTE, Decision, Engine, Facts, Group, GroupType, - Outcome, Outcome2, Pinned, RESERVED_PREFIX, RouteError, RouteSet, Rule, RuleNotes, SetAction, - Target, has_catch_all, is_builtin_group, notes, order_by, + ALL_UPSTREAMS, Asked, CATCH_ALL_RULE, DEFAULT_ROUTE, Decision, Engine, Facts, Group, GroupType, + Origin, Outcome, Outcome2, Pinned, RESERVED_PREFIX, RouteError, RouteSet, Rule, RuleNotes, + SetAction, Target, has_catch_all, is_builtin_group, notes, order_by, }; pub use facts::{RequestFacts, estimate_strings, estimate_tokens}; diff --git a/crates/tw-engine/src/rule.rs b/crates/tw-engine/src/rule.rs index e9a3909d..ad76ffb9 100644 --- a/crates/tw-engine/src/rule.rs +++ b/crates/tw-engine/src/rule.rs @@ -99,18 +99,31 @@ impl When { self.provider_would_be.is_some() } - /// 阶段二的匹配:在阶段一的条件之上,再看选中的是谁。 + /// 阶段二的匹配:在阶段一的条件之上,再看选中的是谁。`aliased` 见 [`Self::matches`]。 pub fn matches_with_provider( &self, f: &RequestFacts, provider: &str, + aliased: &[String], ) -> Result { if let Some(p) = &self.provider_would_be && !p.contains(provider) { return Ok(false); } - self.matches(f) + self.matches(f, aliased) + } + + /// `model` 这一条对请求的模型名 `model` 成立吗。没写这一条时成立。 + /// + /// **继承只从真名到别名**:请求的名称是别名时,`aliased` 是它列的那些模型名,glob 匹配 + /// 其中任一个也算 —— 写 `claude-opus-*` 的规则照样管到指向 Opus 的别名。反过来不成立: + /// 条件写的是别名,只匹配请求这个别名本身,请求真名时它不管。 + pub fn model_matches(&self, model: &str, aliased: &[String]) -> bool { + match &self.model { + None => true, + Some(pat) => glob_match(pat, model) || aliased.iter().any(|m| glob_match(pat, m)), + } } /// 写死的条件语法对吗。**在加载配置时查,不要等到请求进来** @@ -129,10 +142,12 @@ impl When { Ok(()) } - pub fn matches(&self, f: &RequestFacts) -> Result { - if let Some(pat) = &self.model - && !glob_match(pat, &f.model) - { + /// 这些条件对这个请求都成立吗。 + /// + /// `aliased`:请求的模型名是别名时它列的模型名([`crate::Engine::alias_models`]),不是 + /// 别名时是空的。`model` 这一条按 [`Self::model_matches`] 继承到别名。 + pub fn matches(&self, f: &RequestFacts, aliased: &[String]) -> Result { + if !self.model_matches(&f.model, aliased) { return Ok(false); } // client 和 dialect 是精确匹配:它们的取值是我们自己定义的一个 @@ -385,15 +400,15 @@ mod tests { // 「同一个 when 里多个条件默认 AND」覆盖九成需求,于是 AND // 关键字和括号嵌套直接消失。 let w = when("{ model: claude-sonnet-*, stream: true }"); - assert!(w.matches(&f()).unwrap()); + assert!(w.matches(&f(), &[]).unwrap()); let w = when("{ model: claude-sonnet-*, stream: false }"); - assert!(!w.matches(&f()).unwrap(), "有一个不满足就整条不命中"); + assert!(!w.matches(&f(), &[]).unwrap(), "有一个不满足就整条不命中"); } #[test] fn an_empty_when_is_the_catch_all() { assert!(When::default().is_catch_all()); - assert!(When::default().matches(&f()).unwrap()); + assert!(When::default().matches(&f(), &[]).unwrap()); assert!(!when("{ model: x }").is_catch_all()); // 只写了辅助请求类别的规则不是兜底:它只匹配那一类请求 assert!(!when("{ intent: titling }").is_catch_all()); @@ -401,9 +416,21 @@ mod tests { #[test] fn numeric_conditions_compare() { - assert!(when(r#"{ input_tokens: "<200k" }"#).matches(&f()).unwrap()); - assert!(!when(r#"{ input_tokens: ">200k" }"#).matches(&f()).unwrap()); - assert!(when(r#"{ max_tokens: ">=4k" }"#).matches(&f()).unwrap()); + assert!( + when(r#"{ input_tokens: "<200k" }"#) + .matches(&f(), &[]) + .unwrap() + ); + assert!( + !when(r#"{ input_tokens: ">200k" }"#) + .matches(&f(), &[]) + .unwrap() + ); + assert!( + when(r#"{ max_tokens: ">=4k" }"#) + .matches(&f(), &[]) + .unwrap() + ); } #[test] @@ -412,17 +439,17 @@ mod tests { // 的请求 —— 而那和用户的意思正好相反。 let mut x = f(); x.max_tokens = None; - assert!(!when(r#"{ max_tokens: "<4k" }"#).matches(&x).unwrap()); - assert!(!when(r#"{ max_tokens: ">4k" }"#).matches(&x).unwrap()); + assert!(!when(r#"{ max_tokens: "<4k" }"#).matches(&x, &[]).unwrap()); + assert!(!when(r#"{ max_tokens: ">4k" }"#).matches(&x, &[]).unwrap()); } #[test] fn boolean_conditions_match_both_ways() { let mut x = f(); x.cache = true; - assert!(when("{ cache: true }").matches(&x).unwrap()); - assert!(!when("{ cache: false }").matches(&x).unwrap()); - assert!(when("{ cache: false }").matches(&f()).unwrap()); + assert!(when("{ cache: true }").matches(&x, &[]).unwrap()); + assert!(!when("{ cache: false }").matches(&x, &[]).unwrap()); + assert!(when("{ cache: false }").matches(&f(), &[]).unwrap()); } #[test] @@ -445,8 +472,40 @@ mod tests { #[test] fn client_and_dialect_are_exact_not_globbed() { // 它们的取值是我们自己定义的小集合,通配符只会掩盖打错的名字。 - assert!(when("{ client: claude-code }").matches(&f()).unwrap()); - assert!(!when("{ client: claude-* }").matches(&f()).unwrap()); + assert!(when("{ client: claude-code }").matches(&f(), &[]).unwrap()); + assert!(!when("{ client: claude-* }").matches(&f(), &[]).unwrap()); + } + + /// 写真名(glob)的条件也管到列了它的别名;写别名的条件只管别名本身 + #[test] + fn a_model_condition_on_a_real_name_also_matches_an_alias_that_lists_it() { + let opus = vec![ + "claude-opus-5".to_string(), + "us.anthropic.claude-opus-5-v1:0".to_string(), + ]; + let mut x = f(); + x.model = "opus".into(); + assert!(when("{ model: claude-opus-* }").matches(&x, &opus).unwrap()); + assert!( + when("{ model: '*.claude-opus-5-v1:0' }") + .matches(&x, &opus) + .unwrap() + ); + assert!(when("{ model: opus }").matches(&x, &opus).unwrap()); + assert!( + !when("{ model: claude-sonnet-* }") + .matches(&x, &opus) + .unwrap() + ); + // 请求的是真名:写别名的条件不管它 + let mut real = f(); + real.model = "claude-opus-5".into(); + assert!(!when("{ model: opus }").matches(&real, &[]).unwrap()); + assert!( + when("{ model: claude-opus-5 }") + .matches(&real, &[]) + .unwrap() + ); } } @@ -475,7 +534,7 @@ mod intent_tests { "topic_detect", "suggestion", ] { - assert!(r.matches(&probe(k)).unwrap(), "{k}"); + assert!(r.matches(&probe(k), &[]).unwrap(), "{k}"); } } @@ -483,16 +542,16 @@ mod intent_tests { fn a_specific_intent_matches_only_itself() { // 「所有辅助请求走便宜的那家」和「只有标题走」是两个都合理的需求。 let r = w("{ intent: titling }"); - assert!(r.matches(&probe("titling")).unwrap()); - assert!(!r.matches(&probe("warmup")).unwrap()); + assert!(r.matches(&probe("titling"), &[]).unwrap()); + assert!(!r.matches(&probe("warmup"), &[]).unwrap()); } #[test] fn a_list_of_intents_works_like_anywhere_else() { let r = w("{ intent: [titling, topic_detect] }"); - assert!(r.matches(&probe("titling")).unwrap()); - assert!(r.matches(&probe("topic_detect")).unwrap()); - assert!(!r.matches(&probe("warmup")).unwrap()); + assert!(r.matches(&probe("titling"), &[]).unwrap()); + assert!(r.matches(&probe("topic_detect"), &[]).unwrap()); + assert!(!r.matches(&probe("warmup"), &[]).unwrap()); } #[test] @@ -500,7 +559,7 @@ mod intent_tests { // **这条是安全边界。**真实请求被一条 intent 规则捞走的话,用户的 // 活会被送去一个他为「省钱」准备的地方。 let r = w("{ intent: assistant_internal }"); - assert!(!r.matches(&probe("")).unwrap()); + assert!(!r.matches(&probe(""), &[]).unwrap()); } #[test] diff --git a/crates/tw-gateway/src/lib.rs b/crates/tw-gateway/src/lib.rs index 05743fa3..ed2f09e6 100644 --- a/crates/tw-gateway/src/lib.rs +++ b/crates/tw-gateway/src/lib.rs @@ -39,6 +39,7 @@ pub mod probe; pub mod quota; pub mod quote; pub mod seal; +pub mod sent; pub mod server; pub mod session; pub mod state; diff --git a/crates/tw-gateway/src/sent.rs b/crates/tw-gateway/src/sent.rs new file mode 100644 index 00000000..b7ae5c89 --- /dev/null +++ b/crates/tw-gateway/src/sent.rs @@ -0,0 +1,270 @@ +//! 每一家实际收到的模型名。 +//! +//! 路由给出的是每个候选**要**的名字([`tw_engine::Engine::asked`]):客户端写的、阶段一 +//! 改写的,都还是客户端那一侧的名称,可能是别名 —— 发给每一家之前按别名表对到那一家 +//! 自己的名称([`crate::models::resolve`]),故障转移换一家就重新对一次。规则指定的模型、 +//! 阶段二改的名字原样发出:写下它们的时候已经知道是哪一家。 +//! +//! **路由(跳过服务不了的、按价钱排序)、每一跳、试算用的是同一个函数**:试算说发给 +//! Bedrock 的是哪个名字,真实转发发的就是那个。 + +use tw_engine::{Asked, Catalog, Origin}; + +/// 一个候选发出去的模型名。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Sent { + pub provider: String, + /// 它要的名字([`Asked::model`]) + pub asked: String, + /// 发给它的名字。`None` = 这家服务不了这个别名:别名列的名字它一个都没有 + pub model: Option, + /// 发出的名字为什么和客户端写的不一样。一样时是 `None` + pub via: Option, +} + +/// 发出的名字和客户端写的不一样的原因。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Via { + /// 客户端写的是别名,对到了这一家的名称 + Alias, + /// 规则改写了模型(`set.model`,哪个阶段都算) + Rule, + /// 规则指定了这一家发什么模型 + Pinned, +} + +impl Via { + /// 控制面发给界面的那个词 + pub fn slug(self) -> &'static str { + match self { + Via::Alias => "alias", + Via::Rule => "rule", + Via::Pinned => "pinned", + } + } +} + +/// 发给 `p` 的名字:原样发出的照写,客户端那一侧的名称按别名表对到这一家。 +/// `None` = 这家服务不了这个别名。 +pub fn name( + cfg: &tw_config::Config, + catalog: &Catalog, + p: &tw_config::Provider, + asked: &Asked, +) -> Option { + if asked.origin.as_written() { + Some(asked.model.clone()) + } else { + crate::models::resolve(cfg, catalog, p, &asked.model) + } +} + +/// 发出的名字 `sent` 为什么不是客户端写的 `client`。 +pub fn via(client: &str, asked: &Asked, sent: &str) -> Option { + if sent == client { + return None; + } + Some(match asked.origin { + Origin::Pinned => Via::Pinned, + Origin::Rule | Origin::PhaseTwo => Via::Rule, + // 客户端写的,发出去却不一样:只能是别名对过了 + Origin::Client => Via::Alias, + }) +} + +/// 每个候选发出去的名字,和 `asked` 一一对应。`client` 是客户端写的模型名。 +/// +/// 配置里没有的候选按要的名字算:尝试那一步会报出它不在配置里。 +pub fn plan( + cfg: &tw_config::Config, + catalog: &Catalog, + client: &str, + asked: &[Asked], +) -> Vec { + asked + .iter() + .map(|a| { + let model = match cfg.providers.iter().find(|p| p.name == a.provider) { + Some(p) => name(cfg, catalog, p, a), + None => Some(a.model.clone()), + }; + let via = model.as_deref().and_then(|m| via(client, a, m)); + Sent { + provider: a.provider.clone(), + asked: a.model.clone(), + model, + via, + } + }) + .collect() +} + +/// 每个候选和发给它的名字:按价钱排序([`crate::AppState::unit_prices`])、看上下文窗口用。 +/// 服务不了这个别名的那一家给它要的名字 —— 它会被 [`serving`] 跳过,排不排都一样。 +pub fn pairs(sent: &[Sent]) -> Vec<(String, String)> { + sent.iter() + .map(|s| { + let m = s.model.as_ref().unwrap_or(&s.asked); + (s.provider.clone(), m.clone()) + }) + .collect() +} + +/// 去掉服务不了这个请求的候选([`crate::models::serving`]),**按发出去的名字看**:一家 +/// 有没有这个模型,问的是它会收到的那个名字。 +/// +/// 服务不了这个别名的那一家(别名列的名字它一个都没有)记成「没有这个模型」;它停用了 +/// 的话照常记成停用。 +pub fn serving( + cfg: &tw_config::Config, + catalog: &Catalog, + sent: &[Sent], +) -> crate::models::Serving { + let mut out = crate::models::serving(cfg, catalog, &pairs(sent)); + for s in sent.iter().filter(|s| s.model.is_none()) { + if let Some(i) = out.usable.iter().position(|p| *p == s.provider) { + out.usable.remove(i); + out.skipped + .push((s.provider.clone(), crate::models::Skip::NotOffered)); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn provider(name: &str, scope: &[&str]) -> tw_config::Provider { + tw_config::Provider { + name: name.into(), + base_url: format!("https://{name}.example"), + key: Some("sk".into()), + models_only: (!scope.is_empty()).then(|| scope.iter().map(|s| s.to_string()).collect()), + ..Default::default() + } + } + + fn cfg() -> tw_config::Config { + tw_config::Config { + providers: vec![ + provider("bedrock", &["us.anthropic.*"]), + provider("official", &["claude-*"]), + provider("zhipu", &["glm-*"]), + ], + aliases: serde_yaml_ng::from_str( + "opus: [claude-opus-5, us.anthropic.claude-opus-5-v1:0]\n", + ) + .unwrap(), + ..Default::default() + } + } + + fn asked(provider: &str, model: &str, origin: Origin) -> Asked { + Asked { + provider: provider.into(), + model: model.into(), + origin, + } + } + + /// 别名对到每一家自己的名称;原样发出的不对;对不到的那一家是 None,被跳过 + #[test] + fn each_upstream_gets_its_own_name_and_one_that_has_none_is_skipped() { + let c = cfg(); + let catalog = Catalog::default(); + let sent = plan( + &c, + &catalog, + "opus", + &[ + asked("bedrock", "opus", Origin::Client), + asked("official", "opus", Origin::Client), + asked("zhipu", "opus", Origin::Client), + ], + ); + let got: Vec<_> = sent.iter().map(|s| (s.model.as_deref(), s.via)).collect(); + assert_eq!( + got, + [ + (Some("us.anthropic.claude-opus-5-v1:0"), Some(Via::Alias)), + (Some("claude-opus-5"), Some(Via::Alias)), + (None, None), + ] + ); + // 排价钱时给它要的名字:反正会被跳过 + assert_eq!(pairs(&sent)[2], ("zhipu".to_string(), "opus".to_string())); + // 智谱:别名列的名字一个都不在它的启用范围里 + let s = serving(&c, &catalog, &sent); + assert_eq!(s.usable, ["bedrock", "official"]); + assert_eq!( + s.skipped, + [("zhipu".to_string(), crate::models::Skip::OutOfScope)] + ); + // 没有启用范围、也没有清单的一家什么都当作有:对到第一个 + let mut open = c.clone(); + open.providers[2].models_only = None; + let sent = plan( + &open, + &catalog, + "opus", + &[asked("zhipu", "opus", Origin::Client)], + ); + assert_eq!(sent[0].model.as_deref(), Some("claude-opus-5")); + // 有清单、清单里一个都没有:没有这个模型 + let listed = Catalog::build(&[tw_engine::ProviderModels { + provider: "zhipu".into(), + protocol: "anthropic".into(), + known: true, + models: vec!["glm-5".into()], + }]); + let sent = plan( + &open, + &listed, + "opus", + &[asked("zhipu", "opus", Origin::Client)], + ); + assert_eq!(sent[0].model, None); + assert_eq!( + serving(&open, &listed, &sent).skipped, + [("zhipu".to_string(), crate::models::Skip::NotOffered)] + ); + } + + /// 指定的、阶段二改的原样发(名字恰好是别名也一样);阶段一改写成别名的照常对 + #[test] + fn names_written_for_an_upstream_go_out_as_written() { + let c = cfg(); + let catalog = Catalog::default(); + let sent = plan( + &c, + &catalog, + "claude-sonnet-5", + &[ + asked("bedrock", "opus", Origin::Pinned), + asked("official", "opus", Origin::Rule), + asked("zhipu", "opus", Origin::PhaseTwo), + ], + ); + let got: Vec<_> = sent + .iter() + .map(|s| (s.model.as_deref(), s.via.map(Via::slug))) + .collect(); + assert_eq!( + got, + [ + (Some("opus"), Some("pinned")), + (Some("claude-opus-5"), Some("rule")), + (Some("opus"), Some("rule")), + ] + ); + // 发出的和客户端写的一样:没有来历可说 + let same = plan( + &c, + &catalog, + "claude-opus-5", + &[asked("official", "claude-opus-5", Origin::Rule)], + ); + assert_eq!(same[0].via, None); + } +} diff --git a/crates/tw-gateway/src/server/pipeline.rs b/crates/tw-gateway/src/server/pipeline.rs index cae95184..26fe0486 100644 --- a/crates/tw-gateway/src/server/pipeline.rs +++ b/crates/tw-gateway/src/server/pipeline.rs @@ -355,7 +355,8 @@ fn conversation( /// 输入超出了这个决定所选模型的上下文窗口:这一轮沿用的决定要重新求值。 /// /// 按候选里最小的那个窗口算,留 5% 的余量(输入是估的)。**知道窗口的才算**:价目表 -/// 里没写的模型,说不出它装不装得下,照常沿用。 +/// 里没写的模型,说不出它装不装得下,照常沿用。窗口按每一家发出去的名字查:别名在各家 +/// 是各家的名字(见 [`crate::sent`])。 fn outgrown( state: &AppState, rt: &Runtime, @@ -364,10 +365,16 @@ fn outgrown( d: &tw_engine::Decision, ) -> bool { let book = state.pricing.load(); - rt.engine - .models_asked(rt.engine.rules_for_client(&req.client_name), facts, d) + let asked = rt + .engine + .asked(rt.engine.rules_for_client(&req.client_name), facts, d); + crate::sent::plan(&rt.config, &state.catalog.load(), &facts.model, &asked) .iter() - .filter_map(|(p, m)| book.resolve_for(p, m)?.price.max_input_tokens) + .filter_map(|s| { + book.resolve_for(&s.provider, s.model.as_deref()?)? + .price + .max_input_tokens + }) .min() .is_some_and(|limit| facts.input_tokens.saturating_mul(100) >= limit.saturating_mul(95)) } @@ -523,19 +530,27 @@ fn route( stayed: None, }), }; - // 每个候选实际要的模型:规则改写过的按改写后的算。准入、跳过、比价都看它 - let asked = rt.engine.models_asked( + // 每个候选实际要的模型:规则改写过的按改写后的算,还是客户端那一侧的名称(可能是 + // 别名)。准入看它 + let asked = rt.engine.asked( rt.engine.rules_for_client(&req.client_name), facts, &decision, ); - admit(state, rt, req, reading, &decision, &asked)?; - // 去掉服务不了这个请求的候选:停用的、范围外的、清单里没有这个模型的。 + let pairs: Vec<(String, String)> = asked + .iter() + .map(|a| (a.provider.clone(), a.model.clone())) + .collect(); + admit(state, rt, req, reading, &decision, &pairs)?; + // 每一家发出去的名字:别名对到各家自己的名称。跳过、比价看它(见 `crate::sent`) + let catalog = state.catalog.load(); + let sent = crate::sent::plan(&rt.config, &catalog, &facts.model, &asked); + // 去掉服务不了这个请求的候选:停用的、范围外的、清单里没有这个模型的、别名对不到的。 // **在排序之前** —— `cheapest` 和 `url-test` 要在能服务的上游里挑。 // // 不跳过的话,一家没有这个模型的上游排在前面,它回的 404 不触发故障 // 转移,请求就在一家能服务它的上游旁边失败了。 - let serving = crate::models::serving(&rt.config, &state.catalog.load(), &asked); + let serving = crate::sent::serving(&rt.config, &catalog, &sent); let model = decision.set.model.as_deref().unwrap_or(&facts.model); if serving.usable.is_empty() { return Ok(Routed::Refused(choice, serving.explain(model))); @@ -566,9 +581,12 @@ fn route( _ => Default::default(), }, price: match kind { - tw_engine::GroupType::Cheapest => { - state.unit_prices(&rt.config.providers, &asked, &decision.candidates) - } + // 每一家按发给它的名字算价钱:同一个别名在各家是各家的模型名、各家的价目 + tw_engine::GroupType::Cheapest => state.unit_prices( + &rt.config.providers, + &crate::sent::pairs(&sent), + &decision.candidates, + ), _ => Default::default(), }, }; diff --git a/crates/tw-gateway/src/server/pipeline/hop.rs b/crates/tw-gateway/src/server/pipeline/hop.rs index 48a4cdea..1f37bc88 100644 --- a/crates/tw-gateway/src/server/pipeline/hop.rs +++ b/crates/tw-gateway/src/server/pipeline/hop.rs @@ -130,6 +130,8 @@ pub(super) async fn try_upstreams<'a>( // 最后发出去的那一跳,插件改过的话改过之后的请求和那一跳的账:存下来的「插件改过的 // 请求」就是它 —— 回答的那一家收到的那一份 let mut after_plugins: Option<(Bytes, tw_guard::redact::replace::Ledger)> = None; + // 别名对到每一家时看的清单:整个请求用同一份 + let catalog = state.catalog.load(); for (i, name) in started.alive.iter().enumerate() { // 后面没有别的候选了 @@ -173,7 +175,7 @@ pub(super) async fn try_upstreams<'a>( // **在循环里面,因为故障转移换了 provider 之后必须重算**。 // 否则「走中转的一律脱敏」这条规则,在从官方转移到中转时会漏掉 // —— 而那正是最需要它的时刻。 - let mut effective_set = match rt.engine.phase_two( + let (mut effective_set, renamed) = match rt.engine.phase_two( &reading.facts, &provider.name, &decision.set, @@ -181,13 +183,14 @@ pub(super) async fn try_upstreams<'a>( Ok(tw_engine::Outcome2::Proceed { set, rewritten_by: more, + model, }) => { for r in more { if !rewritten_by.contains(&r) { rewritten_by.push(r); } } - set + (set, model) } Ok(tw_engine::Outcome2::Deny { rule, reason }) => { tracing::info!(%rule, provider = %provider.name, "a phase-two rule denied the request"); @@ -215,20 +218,40 @@ pub(super) async fn try_upstreams<'a>( } }; - // 指定模型:这一家要的名字原样发出,规则改写(两个阶段的 `set.model`)盖不过它 - if let Some(m) = decision.pinned_model(&provider.name) { - effective_set.model = Some(m.to_string()); - } - // 这一跳要发的模型名:规则改写过的是改写之后的 - let sent = effective_set - .model - .clone() - .unwrap_or_else(|| reading.facts.model.clone()); + // 这一家要的模型(见 `Engine::asked_of`)和发给它的名字:客户端那一侧的名称(客户端 + // 写的、阶段一改写的)按别名表对到**这一家**自己的名称,故障转移换一家就重新对; + // 指定的模型、阶段二改的名字原样发出(见 `crate::sent`) + let asked_model = + rt.engine + .asked_of(&reading.facts, decision, &provider.name, renamed.as_deref()); + let Some(sent) = crate::sent::name(&rt.config, &catalog, provider, &asked_model) else { + // 别名列的名字这一家一个都没有。路由时已经跳过了这样的候选,能到这儿说明它的 + // 清单刚刚换过:**不把别名原样发给它**,换下一家 + let err = GatewayError::new( + crate::error::Source::Request, + msg!( + "gw.model.alias_not_served", + upstream = provider.name.clone(), alias = asked_model.model.clone() => + "Upstream `{upstream}` offers none of the models that alias {alias} lists." + ), + ); + chain.push(hop_failed( + &provider.name, + None, + err.detail.clone(), + hop_started, + )); + last_err = Some(err); + continue; + }; + // 和客户端写的一样就不动请求体里的模型名(改写过又对回来的也一样) + effective_set.model = (sent != reading.facts.model).then(|| sent.clone()); // 改写过、和客户端要的不一样的才记(见 `AttemptView::model`) let asked_other = |m: &String| *m != reading.facts.model; - // 数 token 不换模型:另一个模型的 tokenizer 数出来的不是这个数 + // 数 token 不换模型:另一个模型的 tokenizer 数出来的不是这个数。**比的是要的模型**: + // 同一个别名在各家名字不同,是同一个模型 if counting { - let model = Some(sent.clone()).filter(asked_other); + let model = Some(asked_model.model.clone()).filter(asked_other); match &count_model { None => count_model = Some(model), Some(first) if *first != model => { diff --git a/crates/tw-gateway/tests/alias_routing.rs b/crates/tw-gateway/tests/alias_routing.rs new file mode 100644 index 00000000..409c9700 --- /dev/null +++ b/crates/tw-gateway/tests/alias_routing.rs @@ -0,0 +1,320 @@ +//! 别名、规则改写、指定模型:每一家实际收到的模型名。 +//! +//! 起真网关、真上游:每个上游记下收到的请求体,断言落在「每一家收到的是哪个名字」和 +//! 尝试链每一跳记下的模型上。上游都不给模型清单,名字靠启用范围(`models_only`)区分 +//! —— 没有清单的上游当作什么都有,别名对到它启用范围里的第一个名称。 + +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::Router; +use axum::routing::post; +use tokio::sync::broadcast::Receiver; +use tw_api::Event; +use tw_config::{Client, Config, Protocol, Provider}; + +/// 一个上游收到过的请求体 +type Seen = Arc>>; + +/// 一个上游:记下收到的请求体,一律回 `status`,回答里写着自己是谁、收到的模型名。 +async fn upstream(name: &'static str, status: u16) -> (SocketAddr, Seen) { + let seen: Seen = Default::default(); + let log = seen.clone(); + let app = Router::new().route( + "/v1/messages", + post(move |body: axum::body::Bytes| { + let log = log.clone(); + async move { + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let sent = v["model"].clone(); + log.lock().unwrap().push(v); + ( + axum::http::StatusCode::from_u16(status).unwrap(), + axum::Json(serde_json::json!({ "by": name, "sent": sent })), + ) + } + }), + ); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let a = l.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(l, app).await.unwrap() }); + (a, seen) +} + +/// 一个上游,只用 `scope` 里的模型(空的就是全部) +fn provider(name: &str, at: SocketAddr, scope: &[&str]) -> Provider { + Provider { + name: name.into(), + base_url: format!("http://{at}"), + key: Some("k".into()), + protocol: Some(Protocol::Anthropic), + models_only: (!scope.is_empty()).then(|| scope.iter().map(|s| s.to_string()).collect()), + ..Default::default() + } +} + +/// `opus` 在官方叫 `claude-opus-5`,在 Bedrock 叫 `us.anthropic.claude-opus-5-v1:0` +const OPUS: &str = "opus: [claude-opus-5, us.anthropic.claude-opus-5-v1:0]\n"; + +/// `routes` 是默认路由的规则(YAML 列表);空的就是只配上游 +fn config(providers: Vec, aliases: &str, rules: &str) -> Config { + let mut c = Config { + clients: vec![Client { + name: "c".into(), + key: "tw-k".into(), + ..Default::default() + }], + providers, + aliases: serde_yaml_ng::from_str(aliases).unwrap(), + ..Default::default() + }; + if !rules.is_empty() { + c.routes = vec![tw_engine::RouteSet::default_with( + serde_yaml_ng::from_str(rules).unwrap(), + )]; + } + // 规则写得对(指定模型、阶段二不带去向……) + c.engine().validate().unwrap(); + c +} + +async fn serve(cfg: Config) -> (SocketAddr, Receiver) { + let state = tw_gateway::AppState::new(cfg).unwrap(); + let events = state.bus.subscribe(); + let addr = tw_gateway::serve(state, ([127, 0, 0, 1], 0).into()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + (addr, events) +} + +async fn ask(gw: SocketAddr, model: &str) -> (u16, serde_json::Value) { + let r = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .post(format!("http://{gw}/v1/messages")) + .header("x-api-key", "tw-k") + .body(serde_json::json!({ "model": model, "max_tokens": 1000, "messages": [] }).to_string()) + .send() + .await + .unwrap(); + let status = r.status().as_u16(); + (status, r.json().await.unwrap_or_default()) +} + +/// 这个请求的尝试链:每一跳的上游和记下的模型(和客户端写的一样时不记) +async fn attempts(rx: &mut Receiver) -> Vec<(String, Option)> { + loop { + let ev = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("5 秒内没等到路由事件") + .expect("事件流断了"); + if let Event::RequestRouted { attempts, .. } = ev { + return attempts + .into_iter() + .map(|a| (a.provider, a.model)) + .collect(); + } + } +} + +/// 一个上游收到的模型名,按收到的顺序 +fn models(seen: &Seen) -> Vec { + seen.lock() + .unwrap() + .iter() + .map(|v| v["model"].as_str().unwrap_or_default().to_string()) + .collect() +} + +fn some(s: &str) -> Option { + Some(s.to_string()) +} + +/// 请求别名:每一家发它自己的名字,故障转移到下一家时重新对。别名列的名字一个都没有的 +/// 那一家跳过,什么都收不到 +#[tokio::test] +async fn an_alias_fails_over_and_each_upstream_gets_its_own_name() { + let (zhipu, zhipu_seen) = upstream("zhipu", 200).await; + let (bedrock, bedrock_seen) = upstream("bedrock", 500).await; + let (official, official_seen) = upstream("official", 200).await; + let cfg = config( + vec![ + provider("zhipu", zhipu, &["glm-*"]), + provider("bedrock", bedrock, &["us.anthropic.*"]), + provider("official", official, &["claude-*"]), + ], + OPUS, + "", + ); + let (gw, mut rx) = serve(cfg).await; + + let (status, body) = ask(gw, "opus").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["by"], "official"); + assert_eq!(body["sent"], "claude-opus-5"); + assert!(models(&zhipu_seen).is_empty(), "智谱服务不了这个别名"); + assert_eq!(models(&bedrock_seen), ["us.anthropic.claude-opus-5-v1:0"]); + assert_eq!(models(&official_seen), ["claude-opus-5"]); + // 尝试链每一跳记的是发给那一家的名字 + assert_eq!( + attempts(&mut rx).await, + [ + ( + "bedrock".to_string(), + some("us.anthropic.claude-opus-5-v1:0") + ), + ("official".to_string(), some("claude-opus-5")), + ] + ); +} + +/// 规则把模型改成一个别名:照常按别名表对到每一家 +#[tokio::test] +async fn a_rule_can_rewrite_the_model_to_an_alias() { + let (bedrock, bedrock_seen) = upstream("bedrock", 500).await; + let (official, official_seen) = upstream("official", 200).await; + let cfg = config( + vec![ + provider("bedrock", bedrock, &["us.anthropic.*"]), + provider("official", official, &["claude-*"]), + ], + OPUS, + " +- { name: Sonnet 换成 Opus, when: { model: claude-sonnet-* }, set: { model: opus } } +- { name: 兜底, to: __all__ } +", + ); + let (gw, mut rx) = serve(cfg).await; + + let (status, body) = ask(gw, "claude-sonnet-5").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["by"], "official"); + assert_eq!(models(&bedrock_seen), ["us.anthropic.claude-opus-5-v1:0"]); + assert_eq!(models(&official_seen), ["claude-opus-5"]); + assert_eq!( + attempts(&mut rx).await, + [ + ( + "bedrock".to_string(), + some("us.anthropic.claude-opus-5-v1:0") + ), + ("official".to_string(), some("claude-opus-5")), + ] + ); +} + +/// 指定模型:按列表的顺序(不是上游声明的顺序)故障转移,每一家原样发指定的名字。阶段二 +/// 的改名盖不过它,别的改写照常 +#[tokio::test] +async fn pinned_models_fail_over_in_list_order_and_go_out_as_written() { + let (bedrock, bedrock_seen) = upstream("bedrock", 200).await; + let (official, official_seen) = upstream("official", 500).await; + let cfg = config( + vec![ + provider("bedrock", bedrock, &[]), + provider("official", official, &[]), + ], + OPUS, + " +- { name: 降上限, set: { max_tokens: 64 } } +- name: 指定 + to: + - { provider: official, model: claude-opus-5 } + - { provider: bedrock, model: us.anthropic.claude-opus-5-v1:0 } +- { name: Bedrock 改名, when: { provider_would_be: bedrock }, set: { model: something-else } } +", + ); + let (gw, mut rx) = serve(cfg).await; + + let (status, body) = ask(gw, "opus").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["by"], "bedrock"); + assert_eq!(body["sent"], "us.anthropic.claude-opus-5-v1:0"); + assert_eq!(models(&official_seen), ["claude-opus-5"]); + assert_eq!(models(&bedrock_seen), ["us.anthropic.claude-opus-5-v1:0"]); + // 别的规则改写的输出上限照常 + for seen in [&official_seen, &bedrock_seen] { + assert_eq!(seen.lock().unwrap()[0]["max_tokens"], 64); + } + assert_eq!( + attempts(&mut rx).await, + [ + ("official".to_string(), some("claude-opus-5")), + ( + "bedrock".to_string(), + some("us.anthropic.claude-opus-5-v1:0") + ), + ] + ); +} + +/// 阶段二(选定上游之后)改的名字原样发出:恰好是别名也不对 —— 写它的时候已经知道是 +/// 哪一家 +#[tokio::test] +async fn a_phase_two_rename_goes_out_as_written() { + let (relay, relay_seen) = upstream("relay", 200).await; + let cfg = config( + vec![provider("relay", relay, &[])], + OPUS, + " +- { name: 中转的叫法, when: { provider_would_be: relay }, set: { model: opus } } +- { name: 兜底, to: __all__ } +", + ); + let (gw, mut rx) = serve(cfg).await; + + let (status, body) = ask(gw, "claude-sonnet-5").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(models(&relay_seen), ["opus"]); + assert_eq!( + attempts(&mut rx).await, + [("relay".to_string(), some("opus"))] + ); + + // 同一个别名换成阶段一改写:按别名表对,发的是这一家的名称 + let (relay, relay_seen) = upstream("relay", 200).await; + let cfg = config( + vec![provider("relay", relay, &[])], + OPUS, + " +- { name: 改成别名, set: { model: opus } } +- { name: 兜底, to: __all__ } +", + ); + let (gw, _rx) = serve(cfg).await; + let (status, body) = ask(gw, "claude-sonnet-5").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(models(&relay_seen), ["claude-opus-5"]); +} + +/// 写上游模型名的规则条件也管到列了它的别名:请求别名,命中的是写 Bedrock 名字的那条 +#[tokio::test] +async fn a_condition_on_a_real_name_routes_its_alias() { + let (bedrock, bedrock_seen) = upstream("bedrock", 200).await; + let (official, official_seen) = upstream("official", 200).await; + let cfg = config( + vec![ + provider("bedrock", bedrock, &[]), + provider("official", official, &[]), + ], + "opus: [us.anthropic.claude-opus-5-v1:0, claude-opus-5]\n", + " +- { name: Bedrock 写法, when: { model: 'us.anthropic.*' }, to: bedrock } +- { name: 兜底, to: official } +", + ); + let (gw, _rx) = serve(cfg).await; + + let (status, body) = ask(gw, "opus").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["by"], "bedrock"); + assert_eq!(models(&bedrock_seen), ["us.anthropic.claude-opus-5-v1:0"]); + // 请求真名不受写别名的规则影响;这里请求的官方写法也对不上 Bedrock 那条 + let (status, body) = ask(gw, "claude-opus-5").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["by"], "official"); + assert_eq!(models(&official_seen), ["claude-opus-5"]); +} diff --git a/crates/tw-gateway/tests/models.rs b/crates/tw-gateway/tests/models.rs index 5d027254..b138eca1 100644 --- a/crates/tw-gateway/tests/models.rs +++ b/crates/tw-gateway/tests/models.rs @@ -419,7 +419,7 @@ to: let (status, body) = ask(gw, "claude-opus-5").await; assert_eq!(status, 200, "{body}"); assert_eq!(body["by"], "bedrock"); - assert_eq!(body["model"], "us.anthropic.claude-opus-5-v1:0"); + assert_eq!(body["sent"], "us.anthropic.claude-opus-5-v1:0"); // 第一家停用了:下一家,用它自己的名字 cfg.providers[0].disabled = true; @@ -428,5 +428,5 @@ to: let (status, body) = ask(gw, "claude-opus-5").await; assert_eq!(status, 200, "{body}"); assert_eq!(body["by"], "official"); - assert_eq!(body["model"], "claude-opus-5"); + assert_eq!(body["sent"], "claude-opus-5"); } From e29ba277a97f45b668f9e8137a55cbae70ae3f44 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:25:53 +0800 Subject: [PATCH 10/18] Model aliases, the control plane: /aliases endpoints, suggestions, preview, usage, rename - tw-api: AliasInput/AliasSave/AliasModel/AliasView/AliasSuggestion/AliasesView/ AliasPreviewRequest/AliasPreview/AliasRuleRef/AliasUsage/AliasWritten, and six endpoints: GET/POST /aliases, POST /aliases/preview, PUT/DELETE /aliases/{name}, GET /aliases/{name}/usage. - tw-config: upsert_alias/remove_alias edit the aliases table in place (a new alias goes to the end, a rename keeps its place, one model is written as a string, several as a list; an inline or empty table is rewritten as a block); refs::alias_refs/rename_alias find and rewrite the whole-value references in key allow lists and in when.model / set.model (not phase-two set.model, which is sent as written). - tw-yaml: rename_key swaps a mapping key's bytes in place, with a self-check; keys added by put are rendered as scalars, so a user-chosen name that needs quotes ("a: b", "yes", "1.5") reads back as itself. - tw-control: served_by via resolve per enabled upstream, shadows and per-model providers from each upstream's own listing, context window from the default price sheet, 24-hour requests and cost from the per-model summary; Claude-only same-model suggestions on an exact key (path prefixes, Bedrock geo/anthropic./ -vN[:M], Vertex @date, dots in versions; dates kept); a preview reports the same problems as config validation, with `original` so an edit is not a duplicate of itself. Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/src/ep.rs | 15 + crates/tw-api/src/lib.rs | 142 ++++++ crates/tw-config/src/edit.rs | 343 ++++++++++++++ crates/tw-config/src/refs.rs | 204 ++++++++ crates/tw-control/src/aliases.rs | 718 +++++++++++++++++++++++++++++ crates/tw-control/src/lib.rs | 2 + crates/tw-control/tests/aliases.rs | 716 ++++++++++++++++++++++++++++ crates/tw-yaml/src/edit.rs | 157 ++++++- crates/tw-yaml/src/lib.rs | 26 +- 9 files changed, 2320 insertions(+), 3 deletions(-) create mode 100644 crates/tw-control/src/aliases.rs create mode 100644 crates/tw-control/tests/aliases.rs diff --git a/crates/tw-api/src/ep.rs b/crates/tw-api/src/ep.rs index 1f473477..112f453d 100644 --- a/crates/tw-api/src/ep.rs +++ b/crates/tw-api/src/ep.rs @@ -100,6 +100,21 @@ endpoints! { UpdateProxy: PUT "/proxies/{name}" [name], api::ProxySave => api::ConfigWritten; DeleteProxy: DELETE "/proxies/{name}" [name], api::BaseVersion => api::ConfigWritten; + // ─────────────────────────────────────────────── 模型别名 + /// 全部别名,按书写顺序:各家发出的名称、同名被挡住的上游、24 小时用量;以及同一个 + /// 模型在各家叫不同名称的建议(只认 Claude) + Aliases: GET "/aliases", () => api::AliasesView; + CreateAlias: POST "/aliases", api::AliasSave => api::ConfigWritten; + /// 预览一个还没保存的别名:挡着保存的问题、发往各家的名称、别家上的同一个模型 + PreviewAlias: POST "/aliases/preview", api::AliasPreviewRequest => api::AliasPreview; + /// 保存,可以改名:引用旧名的密钥(`allow` 里的整项)和规则(`when.model`、 + /// `set.model`)在同一个版本里跟着改 + UpdateAlias: PUT "/aliases/{name}" [name], api::AliasSave => api::AliasWritten; + /// 删掉。**引用它的密钥和规则不拦**:删之前先看 `AliasUsage` + DeleteAlias: DELETE "/aliases/{name}" [name], api::BaseVersion => api::ConfigWritten; + /// 谁在用它:24 小时的请求、密钥、规则 + AliasUsage: GET "/aliases/{name}/usage" [name], () => api::AliasUsage; + // ─────────────────────────────────────────────── 路由 CreateRoute: POST "/routes", api::RouteSave => api::ConfigWritten; UpdateRoute: PUT "/routes/{name}" [name], api::RouteSave => api::ConfigWritten; diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index b8339167..31b77444 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -2926,6 +2926,148 @@ pub struct ProxyTest { pub current: Option, } +// ─────────────────────────────────────────────── 模型别名 + +/// 一个别名上用户能改的东西:名字,和它在各家上游叫什么(有序)。 +/// +/// 和配置里 `aliases` 的一项是同一件事:哪家上游提供列表里的任一名称,就能服务这个 +/// 别名,发过去用它自己的那个名称(按列表顺序取它有的第一个)。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasInput { + pub name: String, + pub models: Vec, +} + +/// 新建或保存一个别名(`POST /aliases`、`PUT /aliases/{name}`)。保存时名字可以改: +/// 引用它的密钥和规则在同一个版本里跟着改(见 [`AliasWritten`])。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasSave { + pub alias: AliasInput, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_version: Option, +} + +/// 别名列表里的一个名称,以及哪些上游的模型清单里有它。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasModel { + pub model: String, + /// 清单里有这个名称、而且它在启用范围里的上游,按配置里的顺序。**没有清单的上游不在 + /// 这里**:不知道它有什么 + pub providers: Vec, +} + +/// 别名页的一行。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasView { + pub name: String, + /// 按书写顺序 + pub models: Vec, + /// 每家能服务它的上游实际发出的名称,按配置里的顺序。停用的上游不在里面;没有清单 + /// 的上游当作能服务(取列表里第一个在它启用范围里的名称) + pub served_by: Vec, + /// 清单里有一个和别名同名的真模型、而别名的列表里没有这个名称的上游:**这个名称 + /// 不会再发给它们**(别名优先) + pub shadows: Vec, + /// 上下文窗口,来自默认价目表:第一家能服务它的上游发出的那个模型的 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_window: Option, + /// 最近 24 小时里客户端用这个名称发来的请求 + pub requests_24h: u64, + /// 那些请求的费用(百万分之一美元)。没有请求、或者一条都算不出钱时是空 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_micros_24h: Option, +} + +/// 一条建议:几家上游上名称不同、其实是同一个模型。**只认 Claude**(见 +/// [`AliasesView::suggestions`])。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasSuggestion { + /// 给人看的名字:`Claude Opus 5`;带日期的快照后面跟着日期: + /// `Claude Sonnet 4.5 (2025-09-29)` + pub label: String, + /// 每家上游上的名称,按配置里上游的顺序 + pub models: Vec, +} + +/// 别名页(`GET /aliases`)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasesView { + /// 按书写顺序 + pub aliases: Vec, + /// 同一个模型在不同上游上叫不同名称的几组。**只认 Claude**:各家清单里的名称归一之后 + /// 完全相等才算同一个 —— 去掉路径前缀(`anthropic/`)、Bedrock 的地域前缀、 + /// `anthropic.` 和 `-v1:0`,Vertex 的 `@日期` 写成 `-日期`,版本号里的点写成横线; + /// **日期保留**,带日期和不带日期的不算同一个。至少两家上游、至少两种写法才成组; + /// 已经被某一个别名全部列进去的组不出 + pub suggestions: Vec, +} + +/// 预览一个还没保存的别名(`POST /aliases/preview`)。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasPreviewRequest { + pub alias: AliasInput, + /// 正在编辑的那个别名原来的名字。新建时不给。**给了才不会把改名当成和自己重名** + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original: Option, +} + +/// 预览的结果:存得存不了,存了之后发往哪儿。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasPreview { + /// 挡着保存的问题,和保存时配置校验说的是同一句。空 = 存得了 + pub problems: Vec, + /// 存了之后每家上游发出的名称(同 [`AliasView::served_by`]) + pub served_by: Vec, + /// 列表里没有哪家上游的清单里有的名称 + pub unserved: Vec, + /// 同 [`AliasView::shadows`] + pub shadows: Vec, + /// 别的上游上的同一个模型(建议勾选):清单里的名称和列表里某个名称归一之后相等, + /// 规则同 [`AliasesView::suggestions`]。列表里已经有的名称、已经有列表里某个名称的 + /// 上游不在里面 + pub same_model: Vec, +} + +/// 一条写着这个名称的规则。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasRuleRef { + pub route: String, + pub rule: String, + /// `when.model` 或 `set.model` + pub field: String, +} + +/// 谁在用这个别名(`GET /aliases/{name}/usage`):删除、改名之前摆给人看。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasUsage { + /// 最近 24 小时里客户端用这个名称发来的请求 + pub requests_24h: u64, + /// 可见模型(`allow`)里有一项正是这个名称的密钥。**按整项比**,通配不算 + pub keys: Vec, + /// 条件(`when.model`)或改写(`set.model`)正是这个名称的规则。阶段二规则的 + /// `set.model` 不算:它原样发出,不经过别名表 + pub rules: Vec, +} + +/// 保存了一个别名(`PUT /aliases/{name}`)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct AliasWritten { + pub version: String, + /// 改名时在同一个版本里一起改掉的引用。没改名时是空的;`requests_24h` 总是 0 + pub renamed_in: AliasUsage, +} + // ─────────────────────────────────────────────── 路由与策略组的增删改 /// 新建或修改一条路由时交过来的定义。**规则的顺序就是数组的顺序。** diff --git a/crates/tw-config/src/edit.rs b/crates/tw-config/src/edit.rs index 61ff0256..93e1da4d 100644 --- a/crates/tw-config/src/edit.rs +++ b/crates/tw-config/src/edit.rs @@ -506,6 +506,196 @@ fn sync_fields( Ok(out) } +// ─────────────────────────────────────────────────────────── 别名表 + +/// 别名表在错误信息里叫什么。 +pub const ALIAS: &str = "alias"; + +/// 别名表的键。**它是一张映射**(名字 → 模型),不是按 `name` 认的列表,所以不走 +/// [`Section`]:新加的接在表的末尾,改名在原位换键([`tw_yaml::rename_key`]),表的 +/// 顺序是用户定的。 +const ALIASES: &str = "aliases"; + +/// 新建一个别名(`current` 为 `None`),或者把叫 `current` 的那一个改成 `alias` +/// —— 名字可以不同,那是改名,**位置不变**;引用它的地方由调用方跟着改 +/// ([`crate::refs::rename_alias`])。 +/// +/// 一个模型写成字符串,几个写成列表。模型没变就不碰它的值(原来写成 `[a]` 的照旧)。 +/// 表是块式的时候只动这一项;表写成了行内(`aliases: {a: b}`)、或者空着,整张表换成 +/// 块式。 +pub fn upsert_alias( + text: &str, + current: Option<&str>, + alias: &crate::Alias, +) -> Result { + reject_multiline(&Value::String(alias.name.clone()))?; + alias + .models + .iter() + .try_for_each(|m| reject_multiline(&Value::String(m.clone())))?; + let doc = parse(text)?; + let mut table = alias_table(&doc); + let taken = table.iter().position(|(k, _)| *k == alias.name); + let value = alias_value(&alias.models); + let (index, changed) = match current { + None => { + if taken.is_some() { + return Err(EditError::NameTaken { + what: ALIAS, + name: alias.name.clone(), + }); + } + table.push((alias.name.clone(), value.clone())); + (table.len() - 1, true) + } + Some(old) => { + let index = + table + .iter() + .position(|(k, _)| k == old) + .ok_or_else(|| EditError::NotFound { + what: ALIAS, + name: old.to_string(), + })?; + if taken.is_some_and(|i| i != index) { + return Err(EditError::NameTaken { + what: ALIAS, + name: alias.name.clone(), + }); + } + let changed = models_of(&table[index].1).as_deref() != Some(alias.models.as_slice()); + table[index].0 = alias.name.clone(); + if changed { + table[index].1 = value.clone(); + } + (index, changed) + } + }; + let out = if block_table(text, &doc)? { + let mut out = text.to_string(); + if let Some(old) = current + && old != alias.name + { + out = tw_yaml::rename_key(&out, &[Step::key(ALIASES), Step::key(old)], &alias.name)?; + } + if changed { + out = put_value( + &out, + &[Step::key(ALIASES), Step::key(&table[index].0)], + &value, + )?; + } + out + } else if doc.get(ALIASES).is_none() { + // 第一个别名:表和这一项一起补出来,缩进和按项加的一样 + put_value(text, &[Step::key(ALIASES), Step::key(&alias.name)], &value)? + } else { + put_value(text, &[Step::key(ALIASES)], &table_value(&table))? + }; + check_table(&doc, &out, &table, &alias.name)?; + Ok(out) +} + +/// 删掉叫 `name` 的别名。**删掉的是最后一个时,整张表一起删** —— 不写和空表是一回事, +/// 默认值不写进文件。引用它的地方由调用方决定要不要管。 +pub fn remove_alias(text: &str, name: &str) -> Result { + let doc = parse(text)?; + let mut table = alias_table(&doc); + let index = table + .iter() + .position(|(k, _)| k == name) + .ok_or_else(|| EditError::NotFound { + what: ALIAS, + name: name.to_string(), + })?; + table.remove(index); + let out = if table.is_empty() { + tw_yaml::remove_key(text, &[Step::key(ALIASES)])? + } else if block_table(text, &doc)? { + tw_yaml::remove_key(text, &[Step::key(ALIASES), Step::key(name)])? + } else { + put_value(text, &[Step::key(ALIASES)], &table_value(&table))? + }; + check_table(&doc, &out, &table, name)?; + Ok(out) +} + +/// 文件里的别名表,按书写顺序。键一律当字符串(配置读进来时就是这么读的) +fn alias_table(doc: &Value) -> Vec<(String, Value)> { + let Some(Value::Mapping(m)) = doc.get(ALIASES) else { + return Vec::new(); + }; + m.iter() + .filter_map(|(k, v)| Some((key_string(k)?, v.clone()))) + .collect() +} + +fn key_string(k: &Value) -> Option { + match k { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + +/// 一个模型写成字符串,几个写成列表 —— 和 `Aliases` 写回去的样子一样 +fn alias_value(models: &[String]) -> Value { + match models { + [one] => Value::String(one.clone()), + many => Value::Sequence(many.iter().cloned().map(Value::String).collect()), + } +} + +/// 文件里一个别名的值读成模型列表。读不成(写成了别的类型)就是 `None`,当作变了 +fn models_of(v: &Value) -> Option> { + match v { + Value::String(s) => Some(vec![s.clone()]), + Value::Sequence(xs) => xs.iter().map(|x| x.as_str().map(str::to_string)).collect(), + Value::Null => Some(Vec::new()), + _ => None, + } +} + +fn table_value(table: &[(String, Value)]) -> Value { + Value::Mapping( + table + .iter() + .map(|(k, v)| (Value::String(k.clone()), v.clone())) + .collect(), + ) +} + +/// 别名表是不是一张有内容的块式映射:是的话按项改,不是(没有、空着、行内)就整张换 +fn block_table(text: &str, doc: &Value) -> Result { + let non_empty = matches!(doc.get(ALIASES), Some(Value::Mapping(m)) if !m.is_empty()); + Ok(non_empty && !tw_yaml::is_flow_at(text, &[Step::key(ALIASES)])?) +} + +/// 语义核对:别名表读回来正是 `table`(**顺序也对**),其余部分和写之前一样。 +fn check_table( + before: &Value, + out: &str, + table: &[(String, Value)], + name: &str, +) -> Result<(), EditError> { + let mut expected = before.clone(); + if let Value::Mapping(m) = &mut expected { + if table.is_empty() { + m.remove(ALIASES); + } else { + m.insert(Value::String(ALIASES.into()), table_value(table)); + } + } + let got = parse(out).map_err(|e| EditError::SelfCheck(e.to_string()))?; + let order = |t: &[(String, Value)]| t.iter().map(|(k, _)| k.clone()).collect::>(); + // `Mapping` 比相等不看顺序,顺序单独比 + if got != expected || order(&alias_table(&got)) != order(table) { + return Err(EditError::SelfCheck(format!("{ALIAS} `{name}`"))); + } + Ok(()) +} + fn lookup<'a>(doc: &'a Value, path: &[Step]) -> Option<&'a Value> { let mut cur = doc; for st in path { @@ -865,4 +1055,157 @@ providers: assert_eq!(v["pricing"]["auto_update"], false); assert!(v["pricing"].get("sheets").is_none(), "{out}"); } + + fn alias(name: &str, models: &[&str]) -> crate::Alias { + crate::Alias { + name: name.into(), + models: models.iter().map(|m| m.to_string()).collect(), + } + } + + const ALIASES_CFG: &str = "version: 1 +# 别名,顺序是我排的 +aliases: + deepseek: DeepSeek-v4 # 只有一家 + sonnet: + - claude-sonnet-5 + # Bedrock 上叫这个 + - us.anthropic.claude-sonnet-5-v1:0 + opus: [claude-opus-5] +routes: [] +"; + + fn aliases_of(text: &str) -> Vec { + crate::try_parse(&format!("{text}listen:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: default\n key: tw-a\n")) + .unwrap_or_else(|e| panic!("{e}\n{text}")) + .aliases + .0 + } + + /// 第一个别名:表和它一起补出来;一个模型写成字符串,几个写成缩进的列表 + #[test] + fn the_first_alias_starts_the_table() { + let out = upsert_alias(CFG, None, &alias("x", &["a"])).unwrap(); + assert!(out.ends_with("aliases:\n x: a\n"), "{out}"); + let out = upsert_alias(CFG, None, &alias("z", &["a", "b"])).unwrap(); + assert!(out.ends_with("aliases:\n z:\n - a\n - b\n"), "{out}"); + assert!(out.contains("# 两家上游"), "{out}"); + } + + /// 新的接在末尾;改一个只改它的值,别的项和注释原样;模型没变就不碰值 + #[test] + fn an_alias_is_appended_or_changed_in_place() { + let out = upsert_alias(ALIASES_CFG, None, &alias("haiku", &["claude-haiku-5"])).unwrap(); + assert!( + out.contains(" opus: [claude-opus-5]\n haiku: claude-haiku-5\n"), + "{out}" + ); + let names: Vec<_> = aliases_of(&out).into_iter().map(|a| a.name).collect(); + assert_eq!(names, ["deepseek", "sonnet", "opus", "haiku"]); + + let out = upsert_alias( + ALIASES_CFG, + Some("deepseek"), + &alias("deepseek", &["DeepSeek-v4", "deepseek-v4"]), + ) + .unwrap(); + assert!(out.contains("# Bedrock 上叫这个"), "{out}"); + assert_eq!( + aliases_of(&out)[0], + alias("deepseek", &["DeepSeek-v4", "deepseek-v4"]) + ); + // 没变:一个字节都不动 + let same = upsert_alias( + ALIASES_CFG, + Some("opus"), + &alias("opus", &["claude-opus-5"]), + ) + .unwrap(); + assert_eq!(same, ALIASES_CFG); + } + + /// 改名在原位:位置、值、注释都不动 + #[test] + fn renaming_an_alias_keeps_its_place() { + let out = upsert_alias( + ALIASES_CFG, + Some("sonnet"), + &alias( + "claude-sonnet-5", + &["claude-sonnet-5", "us.anthropic.claude-sonnet-5-v1:0"], + ), + ) + .unwrap(); + assert_eq!( + out, + ALIASES_CFG.replace(" sonnet:\n", " claude-sonnet-5:\n") + ); + // 改名同时改模型 + let out = upsert_alias(ALIASES_CFG, Some("deepseek"), &alias("ds", &["a", "b"])).unwrap(); + let all = aliases_of(&out); + assert_eq!(all[0], alias("ds", &["a", "b"])); + assert_eq!(all[1].name, "sonnet"); + assert!(out.contains("# 别名,顺序是我排的"), "{out}"); + } + + #[test] + fn an_alias_name_that_is_taken_or_missing_is_refused() { + let e = upsert_alias(ALIASES_CFG, None, &alias("opus", &["x"])).unwrap_err(); + assert!( + matches!(e, EditError::NameTaken { what: "alias", .. }), + "{e}" + ); + let e = upsert_alias(ALIASES_CFG, Some("deepseek"), &alias("opus", &["x"])).unwrap_err(); + assert!(matches!(e, EditError::NameTaken { .. }), "{e}"); + let e = upsert_alias(ALIASES_CFG, Some("nope"), &alias("nope", &["x"])).unwrap_err(); + assert!( + matches!(e, EditError::NotFound { what: "alias", .. }), + "{e}" + ); + let e = remove_alias(ALIASES_CFG, "nope").unwrap_err(); + assert!(matches!(e, EditError::NotFound { .. }), "{e}"); + let e = upsert_alias(ALIASES_CFG, None, &alias("a\nb", &["x"])).unwrap_err(); + assert!(matches!(e, EditError::Multiline), "{e}"); + } + + /// 删到最后一个,整张表一起没了 + #[test] + fn removing_aliases_down_to_none_removes_the_table() { + let out = remove_alias(ALIASES_CFG, "sonnet").unwrap(); + assert!( + out.contains(" deepseek: DeepSeek-v4 # 只有一家\n opus:"), + "{out}" + ); + let out = remove_alias(&out, "deepseek").unwrap(); + let out = remove_alias(&out, "opus").unwrap(); + assert!(!out.contains("aliases"), "{out}"); + assert!(out.ends_with("routes: []\n"), "{out}"); + } + + /// 写成行内的表、空着的表:整张换成块式,顺序照旧 + #[test] + fn an_inline_or_empty_table_is_rewritten_as_a_block() { + let inline = "version: 1\naliases: {b: x, a: [y, z]}\n"; + let out = upsert_alias(inline, Some("b"), &alias("c", &["x"])).unwrap(); + let names: Vec<_> = aliases_of(&out).into_iter().map(|a| a.name).collect(); + assert_eq!(names, ["c", "a"], "{out}"); + let out = remove_alias(inline, "b").unwrap(); + assert_eq!(aliases_of(&out), [alias("a", &["y", "z"])], "{out}"); + let out = remove_alias(&out, "a").unwrap(); + assert_eq!(out, "version: 1\n"); + let empty = "version: 1\naliases: {}\n"; + let out = upsert_alias(empty, None, &alias("x", &["y"])).unwrap(); + assert_eq!(aliases_of(&out), [alias("x", &["y"])], "{out}"); + } + + /// 要加引号的名字加上引号,读回来还是它 + #[test] + fn an_alias_name_that_needs_quotes_reads_back_as_written() { + for name in ["yes", "1.5", "a: b", "x #y"] { + let out = upsert_alias(ALIASES_CFG, None, &alias(name, &["m"])).unwrap(); + assert_eq!(aliases_of(&out)[3], alias(name, &["m"]), "{out}"); + let out = upsert_alias(ALIASES_CFG, Some("opus"), &alias(name, &["m"])).unwrap(); + assert_eq!(aliases_of(&out)[2], alias(name, &["m"]), "{out}"); + } + } } diff --git a/crates/tw-config/src/refs.rs b/crates/tw-config/src/refs.rs index 8d988de2..8bd5aefd 100644 --- a/crates/tw-config/src/refs.rs +++ b/crates/tw-config/src/refs.rs @@ -305,6 +305,117 @@ pub fn rename_group(text: &str, cfg: &Config, old: &str, new: &str) -> Result, + pub rules: Vec, +} + +impl AliasRefs { + pub fn is_empty(&self) -> bool { + self.keys.is_empty() && self.rules.is_empty() + } +} + +/// 写着别名 `name` 的密钥和规则,按配置里出现的顺序。**按整个值比**:`allow` 里的 +/// `claude-*` 放行的是一批名字,不是在说这个别名;改名时也不该去改它。 +/// +/// 阶段二规则(带 `provider_would_be`)的 `set.model` 不算:它原样发给已经选定的那家 +/// 上游,不经过别名表 —— 写的是那家上游的模型名,碰巧和别名同名而已。 +pub fn alias_refs(cfg: &Config, name: &str) -> AliasRefs { + let mut out = AliasRefs::default(); + for c in &cfg.clients { + if c.allow.iter().flatten().any(|m| m == name) { + out.keys.push(c.name.clone()); + } + } + for route in &cfg.routes { + for rule in &route.rules { + let mut push = |field| { + out.rules.push(AliasRuleRef { + route: route.name.clone(), + rule: rule.name.clone(), + field, + }) + }; + if rule.when.model.as_deref() == Some(name) { + push("when.model"); + } + if sets_alias(rule, name) { + push("set.model"); + } + } + } + out +} + +/// 这条规则的 `set.model` 是不是在说别名 `name`(阶段二的不是,见 [`alias_refs`]) +fn sets_alias(rule: &tw_engine::Rule, name: &str) -> bool { + rule.when.provider_would_be.is_none() + && rule.set.as_ref().and_then(|s| s.model.as_deref()) == Some(name) +} + +/// 把 `text` 里写着别名 `old` 的地方都改成 `new`(见 [`alias_refs`])。`cfg` 是 `text` +/// 解析出来的那一份 —— 下标要对得上。 +/// +/// **只换那一个值**([`tw_yaml::set`]):`allow` 里别的项、它们的写法和注释原样不动。 +pub fn rename_alias(text: &str, cfg: &Config, old: &str, new: &str) -> Result { + let mut out = text.to_string(); + let to = tw_yaml::Scalar::s(new); + for (i, c) in cfg.clients.iter().enumerate() { + for (j, _) in c + .allow + .iter() + .flatten() + .enumerate() + .filter(|(_, m)| *m == old) + { + out = tw_yaml::set( + &out, + &[ + Step::key("clients"), + Step::Index(i), + Step::key("allow"), + Step::Index(j), + ], + &to, + )?; + } + } + for (r, route) in cfg.routes.iter().enumerate() { + for (i, rule) in route.rules.iter().enumerate() { + let at = |a: &str, b: &str| { + [ + Step::key("routes"), + Step::Index(r), + Step::key("rules"), + Step::Index(i), + Step::key(a), + Step::key(b), + ] + }; + if rule.when.model.as_deref() == Some(old) { + out = tw_yaml::set(&out, &at("when", "model"), &to)?; + } + if sets_alias(rule, old) { + out = tw_yaml::set(&out, &at("set", "model"), &to)?; + } + } + } + Ok(out) +} + /// 把 `text` 里用着代理 `old` 的上游都改成用 `new`。 pub fn rename_proxy(text: &str, cfg: &Config, old: &str, new: &str) -> Result { let mut out = text.to_string(); @@ -570,4 +681,97 @@ routes: assert_eq!(proxy_users(&after, "hk-socks"), ["relay"]); assert!(proxy_users(&after, "hk").is_empty()); } + + const ALIASED: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 +clients: + - name: c + key: tw-k + allow: [sonnet, 'claude-*', sonnet-x] # 只给这几个 + - name: d + key: tw-d + allow: + - gpt-5 +providers: + - name: relay + base_url: https://relay.example + key: sk-a +aliases: + sonnet: [claude-sonnet-5, anthropic/claude-sonnet-5] +routes: + - name: default + rules: + - name: 降级 + when: { model: sonnet } + set: { model: sonnet } + - name: 阶段二 + when: + provider_would_be: relay + set: + model: sonnet + - name: 通配 + when: { model: 'son*' } + to: relay +"; + + /// 只认整个值:`claude-*`、`son*` 是一批名字,阶段二的 `set.model` 原样发出 —— 都不算 + #[test] + fn an_alias_is_referenced_by_whole_values_only() { + let refs = alias_refs(&cfg(ALIASED), "sonnet"); + assert_eq!(refs.keys, ["c"]); + assert_eq!( + refs.rules, + [ + AliasRuleRef { + route: "default".into(), + rule: "降级".into(), + field: "when.model" + }, + AliasRuleRef { + route: "default".into(), + rule: "降级".into(), + field: "set.model" + }, + ] + ); + assert!(alias_refs(&cfg(ALIASED), "gpt").is_empty()); + } + + /// 改名只换那几个值:`allow` 里别的项、行内写法、注释原样 + #[test] + fn renaming_an_alias_rewrites_exactly_the_values_that_name_it() { + let c = cfg(ALIASED); + let text = edit::upsert_alias( + ALIASED, + Some("sonnet"), + &crate::Alias { + name: "claude-sonnet".into(), + models: vec!["claude-sonnet-5".into(), "anthropic/claude-sonnet-5".into()], + }, + ) + .unwrap(); + let out = rename_alias(&text, &c, "sonnet", "claude-sonnet").unwrap(); + assert!( + out.contains(" allow: [claude-sonnet, 'claude-*', sonnet-x] # 只给这几个\n"), + "{out}" + ); + let after = cfg(&out); + let rules = &after.routes[0].rules; + assert_eq!(rules[0].when.model.as_deref(), Some("claude-sonnet")); + assert_eq!( + rules[0].set.as_ref().unwrap().model.as_deref(), + Some("claude-sonnet") + ); + // 阶段二的那条原样发给上游,不是在说这个别名 + assert_eq!( + rules[1].set.as_ref().unwrap().model.as_deref(), + Some("sonnet") + ); + assert_eq!(rules[2].when.model.as_deref(), Some("son*")); + assert!(alias_refs(&after, "sonnet").is_empty()); + assert_eq!(alias_refs(&after, "claude-sonnet").keys, ["c"]); + assert_eq!(after.aliases[0].name, "claude-sonnet"); + } } diff --git a/crates/tw-control/src/aliases.rs b/crates/tw-control/src/aliases.rs new file mode 100644 index 00000000..9ec607a8 --- /dev/null +++ b/crates/tw-control/src/aliases.rs @@ -0,0 +1,718 @@ +//! 模型别名:增删改、预览、谁在用,以及「同一个模型在各家叫不同名称」的建议。 +//! +//! 别名是什么见 `tw_config::aliases`;请求里的别名怎么对到各家上游见 +//! `tw_gateway::models::resolve`。这里回答的是界面要问的几件事: +//! +//! - **它现在发往哪儿**(`served_by`):每家启用的上游按 `resolve` 取名。 +//! - **它挡住了谁**(`shadows`):别名优先,某家清单里有一个同名的真模型、而别名的 +//! 列表里没有这个名称,这个名称就不会再发给那一家。 +//! - **还有谁有同一个模型**(建议、`same_model`):只认 Claude,见 [`claude_key`]。 +//! +//! 看「某家有没有某个名称」时读**每家原始的清单**(`tw_gateway::models::Directory`), +//! 按启用范围过滤,不读汇总的目录:目录里别名和真名是登记在一起的(同名时只留别名), +//! 而这里要问的恰恰是真名。 + +use std::collections::HashSet; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use tw_api::{AliasRuleRef, PinnedModel, ep}; +use tw_config::edit; +use tw_config::history::Origin; +use tw_config::refs::{self, AliasRefs}; + +use crate::contract::RouterExt; +use crate::{ApplyError, ControlState, Fail, apply_fail}; + +pub fn router() -> axum::Router { + axum::Router::new() + .at(ep::Aliases, list) + .at(ep::CreateAlias, create) + .at(ep::PreviewAlias, preview) + .at(ep::UpdateAlias, update) + .at(ep::DeleteAlias, delete_alias) + .at(ep::AliasUsage, usage) +} + +fn not_found(name: &str) -> ApplyError { + crate::resources::not_found(edit::ALIAS, name) +} + +// ---------------------------------------------------------------- 读 + +async fn list(State(s): State) -> Json { + let cfg = s.config(); + let catalog = s.gateway.catalog.load(); + let lists = lists(&s, &cfg); + let used = usage_24h(&s).await; + let book = s.gateway.pricing.load(); + let aliases = cfg + .aliases + .iter() + .map(|a| { + let served_by = served_by(&cfg, &catalog, &a.name); + let (requests_24h, cost_micros_24h) = used + .iter() + .find(|u| u.name == a.name) + .map(|u| (u.requests, u.cost)) + .unwrap_or_default(); + tw_api::AliasView { + name: a.name.clone(), + models: a + .models + .iter() + .map(|m| tw_api::AliasModel { + model: m.clone(), + providers: offering(&lists, m), + }) + .collect(), + shadows: shadows(&lists, a), + context_window: served_by.first().and_then(|first| { + book.resolve_for(&first.provider, &first.model) + .and_then(|r| r.price.max_input_tokens) + }), + served_by, + requests_24h, + cost_micros_24h, + } + }) + .collect(); + Json(tw_api::AliasesView { + aliases, + suggestions: suggestions(&lists, &cfg.aliases), + }) +} + +async fn usage( + State(s): State, + Path(name): Path, +) -> Result, Fail> { + let cfg = s.config(); + if !cfg.aliases.contains(&name) { + return Err(apply_fail(not_found(&name))); + } + let requests_24h = usage_24h(&s) + .await + .into_iter() + .find(|u| u.name == name) + .map(|u| u.requests) + .unwrap_or_default(); + Ok(Json(usage_view( + refs::alias_refs(&cfg, &name), + requests_24h, + ))) +} + +/// 预览一个还没保存的别名。**什么都不写**;问题照保存时的校验说,但不挡着别的几项: +/// 界面边填边看,名字还空着的时候「发往各上游」照样有用。 +async fn preview( + State(s): State, + Json(req): Json, +) -> Json { + let cfg = s.config(); + let catalog = s.gateway.catalog.load(); + let lists = lists(&s, &cfg); + let draft = draft(&req.alias); + let mut problems = Vec::new(); + if let Err(m) = crate::resources::checked_name(&req.alias.name, edit::ALIAS) { + problems.push(m); + } + // 和保存之后的那张表一起校验:重名、指向别的别名,要看整张表才说得出 + let mut table = cfg.aliases.clone(); + match req.original.as_deref() { + Some(old) => match table.0.iter().position(|a| a.name == old) { + Some(i) => table.0[i] = draft.clone(), + None => { + problems.push( + edit::EditError::NotFound { + what: edit::ALIAS, + name: old.to_string(), + } + .msg(), + ); + table.0.push(draft.clone()); + } + }, + None => table.0.push(draft.clone()), + } + // 名字空着上面已经说过了(同一件事,那一句说的是这个对话框) + if let Err(e) = tw_config::check_aliases(&table) + && !(matches!(e, tw_config::ValidationError::AliasEmptyName) && !problems.is_empty()) + { + problems.push(e.msg()); + } + + // 「存了之后」:这张表里只有这一个别名就够 resolve 用 —— 重名的时候也不会认错 + let mut with = (*cfg).clone(); + with.aliases = tw_config::Aliases(vec![draft.clone()]); + let listed = |m: &String| draft.models.contains(m); + let reached: HashSet<&str> = lists + .iter() + .filter(|(_, ms)| ms.iter().any(listed)) + .map(|(p, _)| p.as_str()) + .collect(); + let keys: HashSet = draft.models.iter().filter_map(|m| claude_key(m)).collect(); + let same_model = lists + .iter() + .filter(|(p, _)| !reached.contains(p.as_str())) + .flat_map(|(p, ms)| { + ms.iter() + .filter(|m| !listed(m) && claude_key(m).is_some_and(|k| keys.contains(&k))) + .map(|m| PinnedModel { + provider: p.clone(), + model: m.clone(), + }) + }) + .collect(); + Json(tw_api::AliasPreview { + problems, + served_by: served_by(&with, &catalog, &draft.name), + unserved: draft + .models + .iter() + .filter(|m| !m.trim().is_empty() && offering(&lists, m).is_empty()) + .cloned() + .collect(), + shadows: shadows(&lists, &draft), + same_model, + }) +} + +// ---------------------------------------------------------------- 写 + +async fn create( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let version = s + .cfg + .transform(req.base_version.as_deref(), Origin::Ui, |text, _| { + let a = to_alias(&req.alias)?; + Ok(edit::upsert_alias(text, None, &a)?) + }) + .await + .map_err(apply_fail)?; + Ok(Json(tw_api::ConfigWritten { version })) +} + +async fn update( + State(s): State, + Path(name): Path, + Json(req): Json, +) -> Result, Fail> { + let mut renamed = AliasRefs::default(); + let version = s + .cfg + .transform(req.base_version.as_deref(), Origin::Ui, |text, cfg| { + if !cfg.aliases.contains(&name) { + return Err(not_found(&name)); + } + let a = to_alias(&req.alias)?; + let mut out = edit::upsert_alias(text, Some(&name), &a)?; + if a.name != name { + // **和别名在同一个版本里改**:分两次写的话,中间那一版里密钥放行的、 + // 规则匹配的是一个已经不存在的名字 —— 配置照样通过校验,请求却悄悄变了 + renamed = refs::alias_refs(cfg, &name); + out = refs::rename_alias(&out, cfg, &name, &a.name)?; + } + Ok(out) + }) + .await + .map_err(apply_fail)?; + Ok(Json(tw_api::AliasWritten { + version, + renamed_in: usage_view(renamed, 0), + })) +} + +/// 删掉。**引用它的地方不拦**:密钥的 `allow`、规则的 `when.model` 写着一个不再是别名 +/// 的名字,配置照样成立(那是一个上游模型名)。删之前界面先拿 `AliasUsage` 摆给人看。 +async fn delete_alias( + State(s): State, + Path(name): Path, + Query(q): Query, +) -> Result, Fail> { + let version = s + .cfg + .transform(q.base_version.as_deref(), Origin::Ui, |text, _| { + Ok(edit::remove_alias(text, &name)?) + }) + .await + .map_err(apply_fail)?; + Ok(Json(tw_api::ConfigWritten { version })) +} + +// ---------------------------------------------------------------- 表单 → 配置 + +/// 交过来的别名,模型名去掉首尾空白、去掉重复(顺序不变)。**不在这里挡空的**: +/// 那一句由校验说(`config.alias_blank_model`),和手改配置时说的一样 +fn draft(input: &tw_api::AliasInput) -> tw_config::Alias { + let mut models: Vec = Vec::new(); + for m in input.models.iter().map(|m| m.trim()) { + if !models.iter().any(|x| x == m) { + models.push(m.to_string()); + } + } + tw_config::Alias { + name: input.name.clone(), + models, + } +} + +fn to_alias(input: &tw_api::AliasInput) -> Result { + crate::resources::checked_name(&input.name, edit::ALIAS).map_err(crate::resources::invalid)?; + Ok(draft(input)) +} + +fn usage_view(r: AliasRefs, requests_24h: u64) -> tw_api::AliasUsage { + tw_api::AliasUsage { + requests_24h, + keys: r.keys, + rules: r + .rules + .into_iter() + .map(|x| AliasRuleRef { + route: x.route, + rule: x.rule, + field: x.field.to_string(), + }) + .collect(), + } +} + +// ---------------------------------------------------------------- 各家有什么 + +/// 每家启用的上游的清单:原始的(不含别名),按启用范围过滤过,按配置里的顺序。 +/// **不知道有什么的上游不在里面**(没有清单,也没有手写的)。 +fn lists(s: &ControlState, cfg: &tw_config::Config) -> Vec<(String, Vec)> { + cfg.providers + .iter() + .filter(|p| !p.disabled) + .filter_map(|p| { + let l = s.gateway.models.listing(p); + (l.source != tw_gateway::models::Source::None).then(|| { + ( + p.name.clone(), + l.models.into_iter().filter(|m| p.uses_model(m)).collect(), + ) + }) + }) + .collect() +} + +/// 清单里有 `model` 的上游 +fn offering(lists: &[(String, Vec)], model: &str) -> Vec { + lists + .iter() + .filter(|(_, ms)| ms.iter().any(|m| m == model)) + .map(|(p, _)| p.clone()) + .collect() +} + +/// 每家启用的上游发出的名称(`resolve`)。服务不了的不在里面 +fn served_by( + cfg: &tw_config::Config, + catalog: &tw_engine::Catalog, + name: &str, +) -> Vec { + cfg.providers + .iter() + .filter(|p| !p.disabled) + .filter_map(|p| { + tw_gateway::models::resolve(cfg, catalog, p, name).map(|model| PinnedModel { + provider: p.name.clone(), + model, + }) + }) + .collect() +} + +/// 清单里有一个和别名同名的真模型、别名却没列这个名称的上游 +fn shadows(lists: &[(String, Vec)], a: &tw_config::Alias) -> Vec { + if a.name.trim().is_empty() || a.models.contains(&a.name) { + return Vec::new(); + } + offering(lists, &a.name) +} + +/// 一个名称最近 24 小时的请求数和费用。 +struct Used { + name: String, + requests: u64, + cost: Option, +} + +/// 最近 24 小时按客户端写的模型名分的请求和费用。**读不了记录就当没有**:别名页不该 +/// 因为请求记录坏了就打不开 +async fn usage_24h(s: &ControlState) -> Vec { + let Some(store) = &s.store else { + return Vec::new(); + }; + let now = crate::now_ms(); + let groups = store + .lock() + .await + .db() + .cost_by(tw_api::CostDim::Model, now - 24 * 3600 * 1000, now) + .unwrap_or_else(|e| { + tracing::warn!("the requests of the last 24 hours could not be read: {e}"); + Vec::new() + }); + groups + .into_iter() + .map(|g| { + // 一条都算不出钱(没有价格、没有用量)时是「不知道」,不是 $0 + let priced = g.requests > g.unpriced_requests + g.no_usage_requests; + Used { + requests: g.requests.max(0) as u64, + cost: priced.then_some(g.cost_micros), + name: g.name, + } + }) + .collect() +} + +// ---------------------------------------------------------------- 同一个模型 + +/// 几家清单里名称不同、归一之后相同的 Claude 模型。至少两家上游、至少两种写法才成组; +/// 已经有一个别名替这一组的每一家都列了它在那一家的名称,这一组就不出。 +fn suggestions( + lists: &[(String, Vec)], + aliases: &tw_config::Aliases, +) -> Vec { + let mut groups: Vec<(String, Vec)> = Vec::new(); + for (p, ms) in lists { + for m in ms { + let Some(key) = claude_key(m) else { continue }; + let pinned = PinnedModel { + provider: p.clone(), + model: m.clone(), + }; + match groups.iter_mut().find(|(k, _)| *k == key) { + Some((_, g)) if !g.contains(&pinned) => g.push(pinned), + Some(_) => {} + None => groups.push((key, vec![pinned])), + } + } + } + groups + .into_iter() + .filter(|(_, g)| { + let providers: HashSet<&str> = g.iter().map(|x| x.provider.as_str()).collect(); + let names: HashSet<&str> = g.iter().map(|x| x.model.as_str()).collect(); + providers.len() >= 2 && names.len() >= 2 + }) + .filter(|(_, g)| !aliases.iter().any(|a| covers(a, g))) + .map(|(key, models)| tw_api::AliasSuggestion { + label: label(&key), + models, + }) + .collect() +} + +/// 别名 `a` 替这一组里的每一家上游都列了它在那一家的某个名称。**按上游算,不按名称算**: +/// Bedrock 一家就列着同一个模型的好几种写法(基础 id、`us.`、`global.`),别名只要 +/// 列其中一种,那一家就到得了 +fn covers(a: &tw_config::Alias, group: &[PinnedModel]) -> bool { + group.iter().all(|x| { + group + .iter() + .any(|y| y.provider == x.provider && a.models.contains(&y.model)) + }) +} + +/// 一个名称归一成 Claude 模型的身份;不是 Claude、认不出的是 `None`。 +/// +/// **两个名称归一之后完全相等,才算同一个模型**(拿来给建议,所以宁缺毋滥): +/// +/// - 只留路径的最后一段:`anthropic/claude-sonnet-4.5`、Vertex 的资源路径; +/// - Vertex 的 `@日期` 写成 `-日期`(`claude-opus-4-1@20250805`);别的 `@` 认不出; +/// - Bedrock:地域前缀(`us.`、`global.`……)、`anthropic.`,以及跟在后面的 `-v1:0` / `-v1`; +/// - 版本号里的点写成横线(`4.5` → `4-5`); +/// - **日期保留**:`claude-opus-4-1` 和 `claude-opus-4-1-20250805` 不算同一个,两个日期 +/// 也不算。 +/// +/// 剩下的必须以 `claude-` 开头、只有小写字母、数字和单个横线 —— OpenRouter 的 +/// `:thinking` 这类变体、`[1m]` 这类标记都认不出,不进建议。 +pub(crate) fn claude_key(id: &str) -> Option { + let lower = id.trim().to_ascii_lowercase(); + let last = lower.rsplit('/').find(|s| !s.is_empty())?; + let s = match last.split_once('@') { + Some((base, date)) if is_date(date) => format!("{base}-{date}"), + Some(_) => return None, + None => last.to_string(), + }; + let s = match s.split_once('.') { + None => s, + Some(_) => bedrock(&s)?.to_string(), + }; + let s = dots_between_digits(&s); + let clean = s.strip_prefix("claude-").is_some_and(|rest| { + !rest.is_empty() + && rest.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) + }); + clean.then_some(s) +} + +/// Bedrock 的写法剥成 Anthropic 自己的名字:地域前缀(可以没有)、`anthropic.`、版本。 +/// 不是 Anthropic 的模型是 `None`。版本号里的点(`claude-sonnet-4.5`)不归这里管 +fn bedrock(id: &str) -> Option<&str> { + let id = match id.split_once('.') { + Some((geo, rest)) if tw_pricing::name::BEDROCK_GEOS.contains(&geo) => rest, + _ => id, + }; + let Some(name) = id.strip_prefix("anthropic.") else { + // `claude-sonnet-4.5` 这种只是版本号里有点 + return (!id.contains("anthropic.")).then_some(id); + }; + let Some(i) = name.rfind("-v") else { + return Some(name); + }; + let (major, minor) = match name[i + 2..].split_once(':') { + Some((a, b)) => (a, Some(b)), + None => (&name[i + 2..], None), + }; + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + if digits(major) && minor.is_none_or(digits) { + Some(&name[..i]) + } else { + Some(name) + } +} + +/// 数字之间的点换成横线:`4.5` 和 `4-5` 是同一个版本的两种写法 +fn dots_between_digits(s: &str) -> String { + let b = s.as_bytes(); + s.char_indices() + .map(|(i, c)| { + let between = c == '.' + && i > 0 + && b[i - 1].is_ascii_digit() + && b.get(i + 1).is_some_and(u8::is_ascii_digit); + if between { '-' } else { c } + }) + .collect() +} + +fn is_date(s: &str) -> bool { + s.len() == 8 && s.bytes().all(|b| b.is_ascii_digit()) && s.starts_with("20") +} + +/// 建议的名字:`claude-opus-4-1-20250805` → `Claude Opus 4.1 (2025-08-05)`。和 +/// `/v1/models` 的 `display_name` 同一种推法(那一份在 tw-gateway 里,不对外):相邻的 +/// 数字用点连起来,单词首字母大写;日期不进名字,写在后面的括号里 —— 同一个模型的两个 +/// 快照各成一组,名字要分得开 +fn label(key: &str) -> String { + let (base, date) = match key.rsplit_once('-') { + Some((base, d)) if is_date(d) => (base, Some(d)), + _ => (key, None), + }; + let mut words: Vec = vec!["Claude".into()]; + let mut number = false; + for part in base.strip_prefix("claude-").unwrap_or(base).split('-') { + if part.is_empty() { + continue; + } + if part.bytes().all(|b| b.is_ascii_digit()) { + match words.last_mut() { + Some(w) if number => { + w.push('.'); + w.push_str(part); + } + _ => words.push(part.to_string()), + } + number = true; + } else { + let mut c = part.chars(); + let first = c.next().map(|f| f.to_ascii_uppercase()); + words.push(first.into_iter().chain(c).collect()); + number = false; + } + } + let name = words.join(" "); + match date { + Some(d) => format!("{name} ({}-{}-{})", &d[..4], &d[4..6], &d[6..]), + None => name, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(id: &str) -> Option { + claude_key(id) + } + + /// 各家对同一个模型的写法,归一之后一样 + #[test] + fn the_same_claude_model_under_each_upstreams_name_has_one_key() { + let k = Some("claude-sonnet-4-5-20250929".to_string()); + for id in [ + "claude-sonnet-4-5-20250929", + "anthropic.claude-sonnet-4-5-20250929-v1:0", + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "global.anthropic.claude-sonnet-4-5-20250929-v1:0", + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0", + "claude-sonnet-4-5@20250929", + "publishers/anthropic/models/claude-sonnet-4-5@20250929", + "anthropic/claude-sonnet-4.5-20250929", + " Claude-Sonnet-4-5-20250929 ", + ] { + assert_eq!(key(id), k, "{id}"); + } + // 没有日期的那一档 + let k = Some("claude-sonnet-4-5".to_string()); + for id in [ + "claude-sonnet-4-5", + "anthropic/claude-sonnet-4.5", + "us.anthropic.claude-sonnet-4-5-v1", + "global.anthropic.claude-sonnet-4-5", + ] { + assert_eq!(key(id), k, "{id}"); + } + assert_eq!( + key("anthropic.claude-3-5-sonnet-20241022-v2:0").as_deref(), + Some("claude-3-5-sonnet-20241022") + ); + assert_eq!( + key("anthropic/claude-opus-4.1").as_deref(), + Some("claude-opus-4-1") + ); + assert_eq!( + key("claude-opus-4-1@20250805").as_deref(), + Some("claude-opus-4-1-20250805") + ); + } + + /// 不同的模型、不同的快照、带日期和不带日期的,都不是同一个 + #[test] + fn different_models_dates_and_undated_names_stay_apart() { + let pairs = [ + ("claude-opus-4-1-20250805", "claude-sonnet-4-1-20250805"), + ("claude-opus-5", "anthropic/claude-sonnet-5"), + ("claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20241022"), + ( + "us.anthropic.claude-3-5-sonnet-20240620-v1:0", + "claude-3-5-sonnet-20241022", + ), + ("claude-opus-4-1", "claude-opus-4-1-20250805"), + ("anthropic/claude-sonnet-4.5", "claude-sonnet-4-5-20250929"), + ("claude-opus-4", "claude-opus-4-1"), + ("claude-haiku-4-5", "claude-3-5-haiku"), + ]; + for (a, b) in pairs { + let (ka, kb) = (key(a), key(b)); + assert!(ka.is_some() && kb.is_some(), "{a} / {b}"); + assert_ne!(ka, kb, "{a} / {b}"); + } + } + + /// 只认 Claude;认不出的变体不进建议 + #[test] + fn only_plain_claude_names_get_a_key() { + for id in [ + "gpt-5", + "deepseek.v3-v1:0", + "us.meta.llama3-3-70b-instruct-v1:0", + "anthropic/claude-3.7-sonnet:thinking", + "claude-opus-4-1[1m]", + "claude-opus-4@latest", + "claude-", + "claude--x", + "anthropic.titan.claude-x", + "", + ] { + assert_eq!(key(id), None, "{id}"); + } + } + + #[test] + fn a_suggestion_is_named_like_the_listing_with_its_date() { + assert_eq!(label("claude-opus-5"), "Claude Opus 5"); + assert_eq!( + label("claude-opus-4-1-20250805"), + "Claude Opus 4.1 (2025-08-05)" + ); + assert_eq!( + label("claude-3-5-sonnet-20241022"), + "Claude 3.5 Sonnet (2024-10-22)" + ); + assert_eq!(label("claude-sonnet-4-5"), "Claude Sonnet 4.5"); + } + + fn lists(x: &[(&str, &[&str])]) -> Vec<(String, Vec)> { + x.iter() + .map(|(p, ms)| (p.to_string(), ms.iter().map(|m| m.to_string()).collect())) + .collect() + } + + fn alias(name: &str, models: &[&str]) -> tw_config::Alias { + tw_config::Alias { + name: name.into(), + models: models.iter().map(|m| m.to_string()).collect(), + } + } + + #[test] + fn suggestions_need_two_upstreams_and_two_spellings_and_skip_covered_groups() { + let l = lists(&[ + ( + "anthropic", + &["claude-opus-5", "claude-sonnet-5", "claude-haiku-5"], + ), + ( + "bedrock", + &[ + "anthropic.claude-opus-5-v1:0", + "us.anthropic.claude-opus-5-v1:0", + "global.anthropic.claude-sonnet-5-v1:0", + ], + ), + // 和官方同一种写法:用不着别名 + ("relay", &["claude-haiku-5", "anthropic/claude-opus-5"]), + ]); + let got = suggestions(&l, &tw_config::Aliases::default()); + let labels: Vec<&str> = got.iter().map(|s| s.label.as_str()).collect(); + assert_eq!(labels, ["Claude Opus 5", "Claude Sonnet 5"]); + let opus: Vec<(&str, &str)> = got[0] + .models + .iter() + .map(|m| (m.provider.as_str(), m.model.as_str())) + .collect(); + assert_eq!( + opus, + [ + ("anthropic", "claude-opus-5"), + ("bedrock", "anthropic.claude-opus-5-v1:0"), + ("bedrock", "us.anthropic.claude-opus-5-v1:0"), + ("relay", "anthropic/claude-opus-5"), + ] + ); + + // 每一家都列了一种写法:这一组不出。Bedrock 只列了 `us.` 那一种也算 + let covered = tw_config::Aliases(vec![alias( + "opus", + &[ + "claude-opus-5", + "us.anthropic.claude-opus-5-v1:0", + "anthropic/claude-opus-5", + ], + )]); + let got = suggestions(&l, &covered); + assert_eq!(got.len(), 1); + assert_eq!(got[0].label, "Claude Sonnet 5"); + // 少了一家:照样出 + let partial = tw_config::Aliases(vec![alias( + "opus", + &["claude-opus-5", "us.anthropic.claude-opus-5-v1:0"], + )]); + assert_eq!(suggestions(&l, &partial).len(), 2); + } +} diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index 4da38f5e..9e914589 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -22,6 +22,7 @@ use tw_types::{Msg, msg}; use contract::RouterExt; +pub mod aliases; pub mod chatgpt; pub mod clients; pub mod config; @@ -139,6 +140,7 @@ pub fn router(state: ControlState) -> Router { // 为客户端发专用密钥。接管本身在桌面端做 .at(ep::ClientKey, clients::client_key) .merge(resources::router()) + .merge(aliases::router()) .merge(routes::router()) .merge(security::router()) .merge(plugins::router()) diff --git a/crates/tw-control/tests/aliases.rs b/crates/tw-control/tests/aliases.rs new file mode 100644 index 00000000..d2795b7b --- /dev/null +++ b/crates/tw-control/tests/aliases.rs @@ -0,0 +1,716 @@ +//! 模型别名:列表(发往哪儿、挡住了谁、用量、建议)、增删改、改名带着引用、预览、 +//! 谁在用。 +//! +//! 各家上游的清单用配置里手写的 `models`(不连网);断言落在 JSON 和**配置文件本身**上: +//! 注释留没留、顺序变没变、改名有没有把引用一起改掉。 + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use serde_json::{Value, json}; +use tower::ServiceExt; +use tw_control::{ConfigManager, ControlState}; + +const BASE: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 +clients: + - name: default + key: tw-aaaa + allow: [sonnet, 'claude-*'] # 只给 Claude + - name: codex + key: tw-bbbb +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: sk-a + models: [claude-sonnet-4-5, claude-opus-4-1-20250805, sonnet] + - name: bedrock + base_url: https://bedrock.example + key: sk-b + protocol: anthropic + models: + - us.anthropic.claude-sonnet-4-5-v1:0 + - global.anthropic.claude-sonnet-4-5-v1:0 + - anthropic.claude-opus-4-1-20250805-v1:0 + - name: relay + base_url: https://relay.example + key: sk-c + protocol: anthropic + models: [anthropic/claude-sonnet-4.5, anthropic/claude-opus-4.1] + - name: off + base_url: https://off.example + key: sk-d + protocol: anthropic + disabled: true + models: [claude-sonnet-4-5, sonnet] + # 没有清单:不知道它有什么 + - name: blind + base_url: https://blind.example + key: sk-e + protocol: anthropic + models_only: ['claude-*'] +# 别名,顺序是我排的 +aliases: + sonnet: # Bedrock 上有两种写法,列一种就够 + - claude-sonnet-4-5 + - us.anthropic.claude-sonnet-4-5-v1:0 +routes: + - name: default + rules: + - name: 降级 + when: { model: sonnet } + set: { model: sonnet } + - name: 兜底 + to: anthropic +"; + +struct Bed { + dir: tempfile::TempDir, + app: axum::Router, +} + +impl Bed { + fn file(&self) -> String { + std::fs::read_to_string(self.dir.path().join("config.yaml")).unwrap() + } + fn parsed(&self) -> tw_config::Config { + tw_config::try_parse(&self.file()).unwrap() + } + async fn version(&self) -> String { + let (_, v) = call(&self.app, "GET", "/config", Value::Null).await; + v["version"].as_str().unwrap().to_string() + } +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64 +} + +/// 一条客户端用 `model` 发来的请求,`ago_ms` 毫秒之前。 +fn row(id: i64, model: &str, ago_ms: i64, cost: Option) -> tw_store::db::RequestRow { + tw_store::db::RequestRow { + session_log_bytes: None, + key_masked: None, + peer: None, + id, + at_ms: now_ms() - ago_ms, + client: "default".into(), + client_hint: None, + session: None, + provider: "anthropic".into(), + model: model.into(), + sent_model: "claude-sonnet-4-5".into(), + answered_model: None, + path: "/v1/messages".into(), + status: Some(200), + ttfb_ms: Some(100), + ttft_ms: None, + duration_ms: Some(200), + tokens_per_sec: None, + bytes: Some(10), + input_tokens: Some(50), + output_tokens: Some(20), + cache_read_tokens: None, + cache_write_tokens: None, + input_estimate: None, + cost_micros: cost, + cost_estimated: false, + error: None, + local: false, + cancelled: false, + routing: None, + billing: tw_api::Billing::PerToken, + cache_saved_micros: None, + price_source: None, + translated: None, + } +} + +fn bed(yaml: &str, rows: Option<&[tw_store::db::RequestRow]>) -> Bed { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("config.yaml"); + std::fs::write(&p, yaml).unwrap(); + let store = rows.map(|rows| { + let db = tw_store::Db::open(&d.path().join("data.db")).unwrap(); + for r in rows { + db.insert(r).unwrap(); + } + let rec = tw_store::Recorder::new( + db, + tw_store::Blobs::new(d.path().join("blobs")), + tw_pricing::shared(tw_pricing::PriceBook::builtin().unwrap()), + ); + Arc::new(tokio::sync::Mutex::new(rec)) + }); + let cfg = tw_config::try_parse(yaml).unwrap(); + let gw = tw_gateway::AppState::new(cfg).unwrap(); + let bus = gw.bus.clone(); + let state = ControlState { + shutdown: Default::default(), + remote: Default::default(), + cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), + gateway: gw, + store, + started: std::time::Instant::now(), + price_updater: Default::default(), + chatgpt: Default::default(), + zai: Default::default(), + }; + Bed { + app: tw_control::router(state), + dir: d, + } +} + +async fn call(app: &axum::Router, method: &str, path: &str, body: Value) -> (StatusCode, Value) { + let r = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let st = r.status(); + let b = axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap(); + let text = String::from_utf8_lossy(&b).to_string(); + ( + st, + serde_json::from_str(&text).unwrap_or(Value::String(text)), + ) +} + +fn pinned(v: &Value) -> Vec<(String, String)> { + v.as_array() + .unwrap_or_else(|| panic!("not a list: {v}")) + .iter() + .map(|x| { + ( + x["provider"].as_str().unwrap().to_string(), + x["model"].as_str().unwrap().to_string(), + ) + }) + .collect() +} + +fn pairs(x: &[(&str, &str)]) -> Vec<(String, String)> { + x.iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect() +} + +fn codes(v: &Value) -> Vec { + v["problems"] + .as_array() + .unwrap() + .iter() + .map(|m| m["code"].as_str().unwrap().to_string()) + .collect() +} + +// ─────────────────────────────────────────────────────────── 读 + +/// 一行别名:每家发出的名称、谁有列表里的哪个名称、同名被挡住的上游、上下文窗口、 +/// 24 小时用量;以及只认 Claude 的建议 +#[tokio::test] +async fn the_list_says_where_each_alias_goes_and_what_it_shadows() { + let b = bed( + BASE, + Some(&[ + row(1, "sonnet", 60_000, Some(1_000)), + // 算不出钱的不进费用,照样算一次请求 + row(2, "sonnet", 120_000, None), + // 24 小时之前的不算 + row(3, "sonnet", 2 * 24 * 3600 * 1000, Some(5_000)), + row(4, "claude-sonnet-4-5", 60_000, Some(7_000)), + ]), + ); + let (st, v) = call(&b.app, "GET", "/aliases", Value::Null).await; + assert_eq!(st, StatusCode::OK, "{v}"); + let a = &v["aliases"][0]; + assert_eq!(a["name"], "sonnet"); + // 停用的上游不在里面;没有清单的当作能服务,取第一个在启用范围里的名称;有清单 + // 而两个名称都没有的(relay)服务不了 + assert_eq!( + pinned(&a["served_by"]), + pairs(&[ + ("anthropic", "claude-sonnet-4-5"), + ("bedrock", "us.anthropic.claude-sonnet-4-5-v1:0"), + ("blind", "claude-sonnet-4-5"), + ]) + ); + assert_eq!(a["models"][0]["model"], "claude-sonnet-4-5"); + assert_eq!(a["models"][0]["providers"], json!(["anthropic"])); + assert_eq!(a["models"][1]["providers"], json!(["bedrock"])); + // anthropic 有一个叫 sonnet 的真模型,别名没列它:这个名称不会再发给它 + assert_eq!(a["shadows"], json!(["anthropic"])); + assert_eq!(a["context_window"], 200_000); + assert_eq!(a["requests_24h"], 2); + assert_eq!(a["cost_micros_24h"], 1_000); + + // Sonnet 4.5:relay 那一家别名还没列 → 出;Opus 4.1 带日期的两家 → 出;relay 的 + // `claude-opus-4.1` 不带日期,不是同一个 + let s = v["suggestions"].as_array().unwrap(); + let labels: Vec<&str> = s.iter().map(|x| x["label"].as_str().unwrap()).collect(); + assert_eq!( + labels, + ["Claude Sonnet 4.5", "Claude Opus 4.1 (2025-08-05)"], + "{v}" + ); + assert_eq!( + pinned(&s[0]["models"]), + pairs(&[ + ("anthropic", "claude-sonnet-4-5"), + ("bedrock", "us.anthropic.claude-sonnet-4-5-v1:0"), + ("bedrock", "global.anthropic.claude-sonnet-4-5-v1:0"), + ("relay", "anthropic/claude-sonnet-4.5"), + ]) + ); + assert_eq!( + pinned(&s[1]["models"]), + pairs(&[ + ("anthropic", "claude-opus-4-1-20250805"), + ("bedrock", "anthropic.claude-opus-4-1-20250805-v1:0"), + ]) + ); +} + +/// 没有请求记录(库没起来)也打得开:用量是 0,费用不知道 +#[tokio::test] +async fn the_list_opens_without_request_records() { + let b = bed(BASE, None); + let (st, v) = call(&b.app, "GET", "/aliases", Value::Null).await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(v["aliases"][0]["requests_24h"], 0); + assert!(v["aliases"][0].get("cost_micros_24h").is_none(), "{v}"); +} + +/// 别名替一组里的每一家都列了一种写法:这一组的建议不再出 +#[tokio::test] +async fn a_group_every_upstream_of_which_an_alias_reaches_is_not_suggested() { + let yaml = BASE.replace( + " - us.anthropic.claude-sonnet-4-5-v1:0\nroutes:", + " - us.anthropic.claude-sonnet-4-5-v1:0\n - anthropic/claude-sonnet-4.5\nroutes:", + ); + let b = bed(&yaml, None); + let (_, v) = call(&b.app, "GET", "/aliases", Value::Null).await; + let labels: Vec<&str> = v["suggestions"] + .as_array() + .unwrap() + .iter() + .map(|x| x["label"].as_str().unwrap()) + .collect(); + assert_eq!(labels, ["Claude Opus 4.1 (2025-08-05)"], "{v}"); + // 列全了之后,relay 也发得到 + assert!( + pinned(&v["aliases"][0]["served_by"]) + .contains(&("relay".into(), "anthropic/claude-sonnet-4.5".into())) + ); +} + +#[tokio::test] +async fn usage_lists_the_keys_and_rules_that_name_the_alias() { + let b = bed(BASE, Some(&[row(1, "sonnet", 1_000, Some(10))])); + let (st, v) = call(&b.app, "GET", "/aliases/sonnet/usage", Value::Null).await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(v["requests_24h"], 1); + // `claude-*` 是一批名字,不算 + assert_eq!(v["keys"], json!(["default"])); + assert_eq!( + v["rules"], + json!([ + { "route": "default", "rule": "降级", "field": "when.model" }, + { "route": "default", "rule": "降级", "field": "set.model" }, + ]) + ); + let (st, v) = call(&b.app, "GET", "/aliases/nope/usage", Value::Null).await; + assert_eq!(st, StatusCode::NOT_FOUND, "{v}"); + assert_eq!(v["code"], "config.edit.not_found"); +} + +// ─────────────────────────────────────────────────────────── 写 + +/// 新的接在表的末尾;模型名去掉首尾空白;注释和别的项原样 +#[tokio::test] +async fn a_new_alias_goes_to_the_end_and_keeps_the_comments() { + let b = bed(BASE, None); + let (st, v) = call( + &b.app, + "POST", + "/aliases", + json!({ "alias": { "name": "opus", "models": [ + "claude-opus-4-1-20250805", " anthropic.claude-opus-4-1-20250805-v1:0 " + ] } }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(v["version"].as_str().unwrap().starts_with("blake3:"), "{v}"); + let f = b.file(); + assert!( + f.contains(" sonnet: # Bedrock 上有两种写法,列一种就够\n"), + "{f}" + ); + assert!(f.contains("# 别名,顺序是我排的"), "{f}"); + let all = b.parsed().aliases; + let names: Vec<&str> = all.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["sonnet", "opus"]); + assert_eq!( + all[1].models, + [ + "claude-opus-4-1-20250805", + "anthropic.claude-opus-4-1-20250805-v1:0" + ] + ); + // 一个模型写成字符串 + let (st, _) = call( + &b.app, + "POST", + "/aliases", + json!({ "alias": { "name": "haiku", "models": ["claude-haiku-4-5"] } }), + ) + .await; + assert_eq!(st, StatusCode::OK); + assert!( + b.file().contains("\n haiku: claude-haiku-4-5\n"), + "{}", + b.file() + ); + // 新的一版生效了:列表里有它 + let (_, v) = call(&b.app, "GET", "/aliases", Value::Null).await; + assert_eq!(v["aliases"][1]["name"], "opus"); +} + +#[tokio::test] +async fn a_taken_name_a_bad_alias_or_a_stale_version_is_refused() { + let b = bed(BASE, None); + let before = b.file(); + let (st, v) = call( + &b.app, + "POST", + "/aliases", + json!({ "alias": { "name": "sonnet", "models": ["x"] } }), + ) + .await; + assert_eq!( + (st, v["code"].as_str()), + (StatusCode::CONFLICT, Some("config.edit.name_taken")), + "{v}" + ); + // 配置校验说的那一句 + let (st, v) = call( + &b.app, + "POST", + "/aliases", + json!({ "alias": { "name": "x", "models": [] } }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); + assert_eq!(v["code"], "config.alias_no_models", "{v}"); + let (st, v) = call( + &b.app, + "POST", + "/aliases", + json!({ "alias": { "name": "x", "models": ["sonnet"] } }), + ) + .await; + assert_eq!( + (st, v["code"].as_str()), + (StatusCode::BAD_REQUEST, Some("config.alias_chained")), + "{v}" + ); + let (st, v) = call( + &b.app, + "POST", + "/aliases", + json!({ "alias": { "name": " x", "models": ["a"] } }), + ) + .await; + assert_eq!( + (st, v["code"].as_str()), + (StatusCode::BAD_REQUEST, Some("control.name_whitespace")), + "{v}" + ); + // 有人抢先改了:409,什么都不写 + for (method, path) in [("POST", "/aliases"), ("PUT", "/aliases/sonnet")] { + let (st, v) = call( + &b.app, + method, + path, + json!({ "alias": { "name": "y", "models": ["a"] }, "base_version": "blake3:000000000000" }), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{method} {path}: {v}"); + assert_eq!(v["code"], "control.config_stale"); + } + let (st, _) = call( + &b.app, + "DELETE", + "/aliases/sonnet?base_version=blake3:000000000000", + Value::Null, + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + assert_eq!(b.file(), before); +} + +/// 改名在原位,引用旧名的密钥(整项)和规则(`when.model`、`set.model`)在同一个版本里 +/// 跟着改,返回改了哪些 +#[tokio::test] +async fn renaming_an_alias_carries_its_references_in_the_same_version() { + let b = bed(BASE, None); + let base = b.version().await; + let (st, v) = call( + &b.app, + "PUT", + "/aliases/sonnet", + json!({ + "alias": { "name": "claude-sonnet", "models": [ + "claude-sonnet-4-5", "us.anthropic.claude-sonnet-4-5-v1:0" + ] }, + "base_version": base, + }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_ne!(v["version"], base.as_str()); + assert_eq!(v["renamed_in"]["keys"], json!(["default"])); + assert_eq!(v["renamed_in"]["requests_24h"], 0); + assert_eq!( + v["renamed_in"]["rules"], + json!([ + { "route": "default", "rule": "降级", "field": "when.model" }, + { "route": "default", "rule": "降级", "field": "set.model" }, + ]) + ); + let f = b.file(); + // 位置、注释、别的写法原样 + assert!( + f.contains(" claude-sonnet: # Bedrock 上有两种写法,列一种就够\n"), + "{f}" + ); + assert!( + f.contains(" allow: [claude-sonnet, 'claude-*'] # 只给 Claude\n"), + "{f}" + ); + assert!( + f.contains(" when: { model: claude-sonnet }\n"), + "{f}" + ); + let cfg = b.parsed(); + assert_eq!(cfg.aliases[0].name, "claude-sonnet"); + assert_eq!( + cfg.routes[0].rules[0] + .set + .as_ref() + .unwrap() + .model + .as_deref(), + Some("claude-sonnet") + ); + + // 只改模型:没有引用要改 + let (st, v) = call( + &b.app, + "PUT", + "/aliases/claude-sonnet", + json!({ "alias": { "name": "claude-sonnet", "models": ["claude-sonnet-4-5"] } }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(v["renamed_in"]["keys"], json!([])); + assert_eq!(v["renamed_in"]["rules"], json!([])); + // 一个模型写成字符串,还在原来的位置(列表里的注释跟着旧的列表走了) + assert!( + b.file() + .contains("aliases:\n claude-sonnet: claude-sonnet-4-5\nroutes:"), + "{}", + b.file() + ); + + let (st, v) = call( + &b.app, + "PUT", + "/aliases/nope", + json!({ "alias": { "name": "nope", "models": ["x"] } }), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND, "{v}"); +} + +/// 删掉;引用它的地方不拦(那是一个上游模型名,配置照样成立);删到最后一个,整张表 +/// 一起没了 +#[tokio::test] +async fn deleting_the_last_alias_removes_the_table() { + let b = bed(BASE, None); + let base = b.version().await; + let (st, v) = call( + &b.app, + "DELETE", + &format!("/aliases/sonnet?base_version={base}"), + Value::Null, + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let f = b.file(); + assert!(!f.contains("aliases:"), "{f}"); + // 规则和密钥原样 + assert!(f.contains(" when: { model: sonnet }\n"), "{f}"); + let (_, v) = call(&b.app, "GET", "/aliases", Value::Null).await; + assert_eq!(v["aliases"], json!([])); + let (st, v) = call(&b.app, "DELETE", "/aliases/sonnet", Value::Null).await; + assert_eq!(st, StatusCode::NOT_FOUND, "{v}"); +} + +// ─────────────────────────────────────────────────────────── 预览 + +#[tokio::test] +async fn a_preview_says_where_a_draft_would_go_and_suggests_the_same_model_elsewhere() { + let b = bed(BASE, None); + let before = b.file(); + let (st, v) = call( + &b.app, + "POST", + "/aliases/preview", + json!({ "alias": { "name": "opus", "models": ["claude-opus-4-1-20250805", "nope-model"] } }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(codes(&v), Vec::::new()); + assert_eq!( + pinned(&v["served_by"]), + pairs(&[ + ("anthropic", "claude-opus-4-1-20250805"), + ("blind", "claude-opus-4-1-20250805"), + ]) + ); + assert_eq!(v["unserved"], json!(["nope-model"])); + assert_eq!(v["shadows"], json!([])); + // relay 的 `claude-opus-4.1` 没有日期,不是同一个 + assert_eq!( + pinned(&v["same_model"]), + pairs(&[("bedrock", "anthropic.claude-opus-4-1-20250805-v1:0")]) + ); + // 什么都没写 + assert_eq!(b.file(), before); +} + +/// 编辑时带上原名:不算和自己重名;别的上游上的同一个模型,已经到得了的上游不再建议 +#[tokio::test] +async fn editing_an_alias_is_not_a_duplicate_of_itself() { + let b = bed(BASE, None); + let draft = json!({ "name": "sonnet", "models": ["claude-sonnet-4-5"] }); + let (_, v) = call( + &b.app, + "POST", + "/aliases/preview", + json!({ "alias": draft }), + ) + .await; + assert_eq!(codes(&v), ["config.alias_duplicate"], "{v}"); + + let (_, v) = call( + &b.app, + "POST", + "/aliases/preview", + json!({ "alias": draft, "original": "sonnet" }), + ) + .await; + assert_eq!(codes(&v), Vec::::new(), "{v}"); + assert_eq!(v["shadows"], json!(["anthropic"])); + assert_eq!( + pinned(&v["same_model"]), + pairs(&[ + ("bedrock", "us.anthropic.claude-sonnet-4-5-v1:0"), + ("bedrock", "global.anthropic.claude-sonnet-4-5-v1:0"), + ("relay", "anthropic/claude-sonnet-4.5"), + ]) + ); + // 改名也一样 + let (_, v) = call( + &b.app, + "POST", + "/aliases/preview", + json!({ "alias": { "name": "sonnet-4.5", "models": ["claude-sonnet-4-5"] }, "original": "sonnet" }), + ) + .await; + assert_eq!(codes(&v), Vec::::new(), "{v}"); + // 列了自己的名字:不挡任何一家 + let (_, v) = call( + &b.app, + "POST", + "/aliases/preview", + json!({ "alias": { "name": "sonnet", "models": ["sonnet", "claude-sonnet-4-5"] }, "original": "sonnet" }), + ) + .await; + assert_eq!(v["shadows"], json!([]), "{v}"); + assert_eq!( + pinned(&v["served_by"])[0], + ("anthropic".into(), "sonnet".into()) + ); +} + +/// 挡着保存的问题,和保存时说的同一句 +#[tokio::test] +async fn a_preview_lists_what_would_stop_the_save() { + let b = bed(BASE, None); + let case = |alias: Value, original: Option<&str>| { + let app = b.app.clone(); + let body = match original { + Some(o) => json!({ "alias": alias, "original": o }), + None => json!({ "alias": alias }), + }; + async move { + let (st, v) = call(&app, "POST", "/aliases/preview", body).await; + assert_eq!(st, StatusCode::OK, "{v}"); + codes(&v) + } + }; + assert_eq!( + case(json!({ "name": "x", "models": [] }), None).await, + ["config.alias_no_models"] + ); + assert_eq!( + case(json!({ "name": "x", "models": ["sonnet"] }), None).await, + ["config.alias_chained"] + ); + assert_eq!( + case(json!({ "name": "claude-*", "models": ["a"] }), None).await, + ["config.alias_wildcard"] + ); + assert_eq!( + case(json!({ "name": "x", "models": ["a", " "] }), None).await, + ["config.alias_blank_model"] + ); + // 名字空着只说一次;发往哪儿照样算 + assert_eq!( + case(json!({ "name": "", "models": ["a"] }), None).await, + ["control.name_empty"] + ); + let (_, v) = call( + &b.app, + "POST", + "/aliases/preview", + json!({ "alias": { "name": "", "models": ["claude-opus-4-1-20250805"] } }), + ) + .await; + assert_eq!(pinned(&v["served_by"]).len(), 2, "{v}"); + // 名字的问题和模型的问题一起说 + assert_eq!( + case(json!({ "name": " x", "models": [] }), None).await, + ["control.name_whitespace", "config.alias_no_models"] + ); + assert_eq!( + case(json!({ "name": "x", "models": ["a"] }), Some("gone")).await, + ["config.edit.not_found"] + ); +} diff --git a/crates/tw-yaml/src/edit.rs b/crates/tw-yaml/src/edit.rs index 80d878f6..4f4d42db 100644 --- a/crates/tw-yaml/src/edit.rs +++ b/crates/tw-yaml/src/edit.rs @@ -179,6 +179,78 @@ pub fn remove_key(text: &str, path: &[Step]) -> Result { Ok(out) } +/// 给映射里的一个键改名,**位置不变**:只换键本身那几个字节,它的值、值里的注释、 +/// 前后的键原样不动。 +/// +/// 给「按名字认的映射」用(别名表 `aliases: { 名字: 模型 }`):删掉再加一个会把它挪到 +/// 末尾,而那张表的顺序是用户定的。 +/// +/// 新名字按纯量的规矩写([`render_scalar`]):原来加了引号的沿用那种引号,原来没加、 +/// 新名字又非加不可的(`a: b`、`yes`、`1.5`)加双引号。同一层已经有这个名字时拒绝 +/// ([`PatchError::Duplicate`])。改完核对:每个节点的路径只是把这个键换成了新名字, +/// 顺序和形状一个不变。 +pub fn rename_key(text: &str, path: &[Step], new: &str) -> Result { + let Some((Step::Key(_), parent)) = path.split_last() else { + return Err(PatchError::NotFound(show(path))); + }; + let mut keys = Vec::new(); + let before = walk(text, |k| keys.push(k))?; + let mut hits = keys.iter().filter(|k| k.path == path); + let Some(key) = hits.next() else { + return Err(PatchError::NotFound(show(path))); + }; + if hits.next().is_some() { + return Err(PatchError::Duplicate(show(path))); + } + if key.anchored { + return Err(PatchError::AnchorOrAlias(show(path))); + } + let mut to = parent.to_vec(); + to.push(Step::key(new)); + if to == path { + return Ok(text.to_string()); + } + if keys.iter().any(|k| k.path == to) { + return Err(PatchError::Duplicate(show(&to))); + } + let rendered = render_scalar(&Scalar::s(new), key.style); + let mut out = String::with_capacity(text.len() + rendered.len()); + out.push_str(&text[..key.bytes.start]); + out.push_str(&rendered); + out.push_str(&text[key.bytes.end..]); + + let after = nodes(&out).map_err(|e| { + PatchError::SelfCheck(format!( + "the configuration could not be parsed after renaming {}: {e}", + show(path) + )) + })?; + let moved = |p: &[Step]| -> Vec { + if p.starts_with(path) { + let mut q = to.clone(); + q.extend_from_slice(&p[path.len()..]); + q + } else { + p.to_vec() + } + }; + let want: Vec<_> = before + .iter() + .map(|n| (moved(&n.path), shape(&n.kind))) + .collect(); + let got: Vec<_> = after + .iter() + .map(|n| (n.path.clone(), shape(&n.kind))) + .collect(); + if want != got { + return Err(PatchError::SelfCheck(format!( + "renaming {} to `{new}` changed something else as well", + show(path) + ))); + } + Ok(out) +} + /// 原地换掉列表的第 `index` 项。**位置不变**,缩进抄原来那一项的。 /// /// 给「这一项原来是行内写法」那种情况用:`- { name: a, key: b }` 里的键 @@ -427,7 +499,7 @@ fn add_key(text: &str, all: &[Node], path: &[Step], value: Put<'_>) -> Result) -> Result) -> Result String { + render_scalar(&Scalar::s(k), ScalarStyle::Plain) +} + /// 把一段零缩进的块逐行缩进后接在 `out` 后面,每行前面换行。 fn push_block(out: &mut String, block: &str, indent: &str) { for line in block.lines() { @@ -816,4 +895,78 @@ mod tests { 0.8 ); } + + const TABLE: &str = "version: 1\n# 别名\naliases:\n # 第一个\n deepseek: DeepSeek-v4 # 只有一家\n sonnet:\n - claude-sonnet-5\n - us.anthropic.claude-sonnet-5-v1:0\n 'quoted': x\n flow: {a: [b]}\nafter: 1\n"; + + /// 改名只换键那几个字节:位置、值、注释原样 + #[test] + fn renaming_a_key_keeps_its_place_its_value_and_the_comments() { + let out = rename_key(TABLE, &p(&["aliases", "deepseek"]), "deepseek-v4").unwrap(); + assert_eq!( + out, + TABLE.replace(" deepseek: DeepSeek", " deepseek-v4: DeepSeek") + ); + let out = rename_key(TABLE, &p(&["aliases", "sonnet"]), "claude-sonnet-5").unwrap(); + assert!( + out.contains( + " deepseek: DeepSeek-v4 # 只有一家\n claude-sonnet-5:\n - claude-sonnet-5\n" + ), + "{out}" + ); + // 原来加了引号的沿用引号 + let out = rename_key(TABLE, &p(&["aliases", "quoted"]), "it's").unwrap(); + assert!(out.contains(" 'it''s': x\n"), "{out}"); + // 行内映射里的键也能改 + let out = rename_key(TABLE, &p(&["aliases", "flow", "a"]), "c").unwrap(); + assert!(out.contains(" flow: {c: [b]}\n"), "{out}"); + assert_eq!(back(&out)["aliases"]["flow"]["c"][0], "b"); + } + + /// 新名字非加引号不可的,加上;读回来就是那个名字 + #[test] + fn a_new_name_that_needs_quotes_gets_them() { + for name in ["a: b", "yes", "1.5", "#x", "-x", "a\tb", "x [1m]"] { + let out = rename_key(TABLE, &p(&["aliases", "deepseek"]), name) + .unwrap_or_else(|e| panic!("{name:?}: {e}")); + let v = back(&out); + let keys: Vec = v["aliases"] + .as_mapping() + .unwrap() + .keys() + .map(|k| serde_yaml_ng::from_value::(k.clone()).unwrap()) + .collect(); + assert_eq!(keys[0], name, "{out}"); + assert_eq!(v["aliases"][name], "DeepSeek-v4", "{out}"); + } + } + + #[test] + fn renaming_onto_a_sibling_or_a_missing_key_is_refused() { + let e = rename_key(TABLE, &p(&["aliases", "deepseek"]), "sonnet").unwrap_err(); + assert!(matches!(e, PatchError::Duplicate(_)), "{e}"); + let e = rename_key(TABLE, &p(&["aliases", "nope"]), "x").unwrap_err(); + assert!(matches!(e, PatchError::NotFound(_)), "{e}"); + // 同名:什么都不改 + assert_eq!( + rename_key(TABLE, &p(&["aliases", "sonnet"]), "sonnet").unwrap(), + TABLE + ); + } + + /// 新加的键也按纯量的规矩写:用户起的名字原样写出去,读回来可能是另一个键 + #[test] + fn a_new_key_is_quoted_when_it_has_to_be() { + for name in ["a: b", "yes", "1.5", "#x"] { + let out = put(TABLE, &p(&["aliases", name]), Put::Inline("m")) + .unwrap_or_else(|e| panic!("{name:?}: {e}")); + let v = back(&out); + assert_eq!(v["aliases"][name], "m", "{out}"); + let flow = put(TABLE, &p(&["aliases", "flow", name]), Put::Inline("m")) + .unwrap_or_else(|e| panic!("{name:?}: {e}")); + assert_eq!(back(&flow)["aliases"]["flow"][name], "m", "{flow}"); + } + // 固定的字段名照旧不加 + let out = put(TABLE, &p(&["aliases", "claude-opus-5"]), Put::Inline("m")).unwrap(); + assert!(out.contains("\n claude-opus-5: m\n"), "{out}"); + } } diff --git a/crates/tw-yaml/src/lib.rs b/crates/tw-yaml/src/lib.rs index 84e1ba1b..d44becad 100644 --- a/crates/tw-yaml/src/lib.rs +++ b/crates/tw-yaml/src/lib.rs @@ -19,7 +19,7 @@ use tw_types::{Msg, msg}; mod edit; mod render; -pub use edit::{Put, is_flow_at, put, remove_key, reorder, replace_item}; +pub use edit::{Put, is_flow_at, put, remove_key, rename_key, reorder, replace_item}; pub use render::{Scalar, double_quoted, must_escape, render_scalar}; /// 到某个节点的路径。`providers[1].base_url` 写成 @@ -289,6 +289,22 @@ pub fn path_at(text: &str, offset: usize) -> Option> { /// 走一遍就把所有位置算出来,比「每改一个字段解析一遍」省事,也让 /// 「这份配置里有哪些字段」成为一个能回答的问题(表单模式要用)。 pub fn nodes(text: &str) -> Result, PatchError> { + walk(text, |_| {}) +} + +/// 映射里的一个键在原文里的位置。**键不是节点**([`nodes`] 只列值):只有给键改名 +/// 时才要知道它在哪儿。 +pub(crate) struct KeyNode { + /// 这个键的值的路径(就是键本身的路径) + pub path: Vec, + /// 字节区间,和标量节点一样收到了真正的最后一个字节 + pub bytes: Range, + pub style: ScalarStyle, + pub anchored: bool, +} + +/// 走一遍文档:值的节点按顺序交回来,每个键交给 `on_key`。 +pub(crate) fn walk(text: &str, mut on_key: impl FnMut(KeyNode)) -> Result, PatchError> { let c2b = CharToByte::new(text); let mut out = Vec::new(); let mut stack: Vec = Vec::new(); @@ -300,7 +316,15 @@ pub fn nodes(text: &str) -> Result, PatchError> { match ev { Event::Scalar(v, style, anchor_id, _) => { if matches!(stack.last(), Some(Frame::Map { expect_key: true })) { + let mut bytes = c2b.range(sp); + bytes.end = bytes.start + tighten(&text[bytes.clone()], style); pending = Some(Step::Key(v.to_string())); + on_key(KeyNode { + path: node_path(&pending, &cur), + bytes, + style, + anchored: anchor_id != 0, + }); if let Some(Frame::Map { expect_key }) = stack.last_mut() { *expect_key = false; } From f33e8b861f7de55d999f38347156ce4b84d737db Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:42:59 +0800 Subject: [PATCH 11/18] v0.62.0: release notes, and alias inheritance in the allow and when.model rows of the manual Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 38 ++++++++++++------------- Cargo.toml | 2 +- crates/tw-config/tests/manual/schema.rs | 8 +++--- docs/config.md | 4 +-- docs/config.zh-CN.md | 4 +-- release-notes/0.62.0.md | 35 +++++++++++++++++++++++ 6 files changed, 63 insertions(+), 28 deletions(-) create mode 100644 release-notes/0.62.0.md diff --git a/Cargo.lock b/Cargo.lock index 3a7a1303..61f48374 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3143,7 +3143,7 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.61.0" +version = "0.62.0" dependencies = [ "serde", "serde_json", @@ -3154,7 +3154,7 @@ dependencies = [ [[package]] name = "tw-bedrock" -version = "0.61.0" +version = "0.62.0" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -3175,7 +3175,7 @@ dependencies = [ [[package]] name = "tw-breaker" -version = "0.61.0" +version = "0.62.0" dependencies = [ "serde", "serde_json", @@ -3183,7 +3183,7 @@ dependencies = [ [[package]] name = "tw-config" -version = "0.61.0" +version = "0.62.0" dependencies = [ "blake3", "libc", @@ -3208,7 +3208,7 @@ dependencies = [ [[package]] name = "tw-control" -version = "0.61.0" +version = "0.62.0" dependencies = [ "axum", "base64", @@ -3249,7 +3249,7 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.61.0" +version = "0.62.0" dependencies = [ "serde", "serde_json", @@ -3257,7 +3257,7 @@ dependencies = [ [[package]] name = "tw-engine" -version = "0.61.0" +version = "0.62.0" dependencies = [ "serde", "serde_json", @@ -3270,7 +3270,7 @@ dependencies = [ [[package]] name = "tw-gateway" -version = "0.61.0" +version = "0.62.0" dependencies = [ "arc-swap", "async-stream", @@ -3319,7 +3319,7 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.61.0" +version = "0.62.0" dependencies = [ "base64", "bytes", @@ -3334,7 +3334,7 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.61.0" +version = "0.62.0" dependencies = [ "serde", "serde_json", @@ -3348,7 +3348,7 @@ dependencies = [ [[package]] name = "tw-observe" -version = "0.61.0" +version = "0.62.0" dependencies = [ "tokio", "tracing", @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "tw-plugin" -version = "0.61.0" +version = "0.62.0" dependencies = [ "libc", "rand 0.10.2", @@ -3372,7 +3372,7 @@ dependencies = [ [[package]] name = "tw-pricing" -version = "0.61.0" +version = "0.62.0" dependencies = [ "arc-swap", "flate2", @@ -3385,14 +3385,14 @@ dependencies = [ [[package]] name = "tw-secret" -version = "0.61.0" +version = "0.62.0" dependencies = [ "thiserror", ] [[package]] name = "tw-store" -version = "0.61.0" +version = "0.62.0" dependencies = [ "blake3", "bytes", @@ -3412,7 +3412,7 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.61.0" +version = "0.62.0" dependencies = [ "serde", "serde_json", @@ -3421,7 +3421,7 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.61.0" +version = "0.62.0" dependencies = [ "notify", "tempfile", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.61.0" +version = "0.62.0" dependencies = [ "saphyr-parser", "serde_yaml_ng", @@ -3441,7 +3441,7 @@ dependencies = [ [[package]] name = "twcore" -version = "0.61.0" +version = "0.62.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 04e56e6d..b5dd7398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ categories = ["network-programming", "web-programming::http-server"] # 二进制走 CalVer,crate 走 SemVer —— 两者是两回事,卖给不同的人。 # 这里是 crate 的版本。 -version = "0.61.0" +version = "0.62.0" [workspace.dependencies] # ── 内部 crate ─────────────────────────────────────────── diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index d31c45e8..bf490fbb 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -394,8 +394,8 @@ pub fn sections() -> Vec
{ Kind::Strs, Def::Unset, t( - "Models this key may use, as model ids or globs (`claude-*`). Unset: every model. `[]`: none at all.", - "这把密钥可用的模型,写模型 ID 或通配(`claude-*`)。不写:全部模型。`[]`:一个都不给。", + "Models this key may use, as model ids or globs (`claude-*`). An upstream model name also allows the aliases that list it; an alias allows only the alias. Unset: every model. `[]`: none at all.", + "这把密钥可用的模型,写模型 ID 或通配(`claude-*`)。写上游模型名,列有它的别名一并可用;写别名只放行别名。不写:全部模型。`[]`:一个都不给。", ), ), row( @@ -1357,8 +1357,8 @@ pub fn sections() -> Vec
{ Kind::Str, Def::Unset, t( - "Requested model, glob (`claude-opus-*`).", - "请求的模型,可用通配(`claude-opus-*`)。", + "Requested model, glob (`claude-opus-*`). An upstream model name also matches requests for the aliases that list it; an alias matches only requests for the alias.", + "请求的模型,可用通配(`claude-opus-*`)。写上游模型名,也匹配请求列有它的别名的请求;写别名只匹配请求这个别名的。", ), ), row( diff --git a/docs/config.md b/docs/config.md index 42a84788..cf116393 100644 --- a/docs/config.md +++ b/docs/config.md @@ -304,7 +304,7 @@ gateway. A key is an identity. Limits, model scope and route are per key. | `name` | string | **required** | Name of the key; unique. Routing rules match it with `when.client`. | | `key` | string | **required** | The key clients send (as `x-api-key` or `Authorization: Bearer`). Generated keys start with `tw-` so they are not mistaken for an upstream's key. Unique. | | `max_concurrent` | integer | — | Requests with this key that may run at once; the rest wait. Unset: no limit. `0` is refused. | -| `allow` | list of strings | — | Models this key may use, as model ids or globs (`claude-*`). Unset: every model. `[]`: none at all. | +| `allow` | list of strings | — | Models this key may use, as model ids or globs (`claude-*`). An upstream model name also allows the aliases that list it; an alias allows only the alias. Unset: every model. `[]`: none at all. | | `route` | string | — | Name of the route requests with this key take. Unset: `default_route`. | | `client` | string | — | The client this key was made for (`claude-code`, `codex`, …), recorded when the desktop app points a client at the gateway. A client has at most one. | | `disabled` | bool | `false` | Refuse every request made with this key, and keep the key. | @@ -1036,7 +1036,7 @@ routes: | Field | Type | Default | Description | |---|---|---|---| -| `model` | string | — | Requested model, glob (`claude-opus-*`). | +| `model` | string | — | Requested model, glob (`claude-opus-*`). An upstream model name also matches requests for the aliases that list it; an alias matches only requests for the alias. | | `client` | string | — | Name of the gateway key the request used, exactly. | | `dialect` | string | — | API format the client spoke: `anthropic`, `openai-chat`, `openai-responses`, `gemini`. | | `input_tokens` | comparison (`>200k`, `<=4k`, `==3`) | — | Estimated input tokens. | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 5915e4fd..cb7d939c 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -213,7 +213,7 @@ listen: | `name` | 字符串 | **必填** | 密钥的名字,不能重复。路由规则用 `when.client` 匹配它。 | | `key` | 字符串 | **必填** | 客户端发送的密钥(放在 `x-api-key` 或 `Authorization: Bearer` 中)。生成的密钥以 `tw-` 开头,以免被误认作上游的密钥。不能重复。 | | `max_concurrent` | 整数 | — | 用这把密钥同时进行的请求数上限,超出的排队等待。不写:不限。`0` 会被拒绝。 | -| `allow` | 字符串列表 | — | 这把密钥可用的模型,写模型 ID 或通配(`claude-*`)。不写:全部模型。`[]`:一个都不给。 | +| `allow` | 字符串列表 | — | 这把密钥可用的模型,写模型 ID 或通配(`claude-*`)。写上游模型名,列有它的别名一并可用;写别名只放行别名。不写:全部模型。`[]`:一个都不给。 | | `route` | 字符串 | — | 这把密钥的请求走哪条路由。不写:`default_route`。 | | `client` | 字符串 | — | 这把密钥是为哪个客户端生成的(`claude-code`、`codex` 等),由桌面应用接管客户端时写入。一个客户端最多一把。 | | `disabled` | 布尔 | `false` | 拒绝使用这把密钥的所有请求,密钥本身保留。 | @@ -814,7 +814,7 @@ routes: | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| -| `model` | 字符串 | — | 请求的模型,可用通配(`claude-opus-*`)。 | +| `model` | 字符串 | — | 请求的模型,可用通配(`claude-opus-*`)。写上游模型名,也匹配请求列有它的别名的请求;写别名只匹配请求这个别名的。 | | `client` | 字符串 | — | 请求所用网关密钥的名字,精确匹配。 | | `dialect` | 字符串 | — | 客户端使用的接口格式:`anthropic`、`openai-chat`、`openai-responses`、`gemini`。 | | `input_tokens` | 比较式(`>200k`、`<=4k`、`==3`) | — | 估算的输入 token 数。 | diff --git a/release-notes/0.62.0.md b/release-notes/0.62.0.md new file mode 100644 index 00000000..2eed2e85 --- /dev/null +++ b/release-notes/0.62.0.md @@ -0,0 +1,35 @@ +This release adds model aliases: one name for the same model across upstreams, each upstream receiving the name it uses for it. Routing rules can also pin requests to a particular upstream's model, answers carry the model name the client asked for, and the model listing tells clients each model's context window. Tool-call inspection gains a rule that guards ThinkWatch's own data directory, and an upstream URL that ends in its own API version no longer gets a second one. + +**Upgrade notes** + +- The control-plane protocol version (`CONTROL_API_VERSION`) goes from 37 to 38. ThinkWatch Lite connects only to a core with the same protocol version: ThinkWatch Lite 2026.10.4 includes 0.61.0 (protocol 37) and does not connect to 0.62.0. A server used with it stays on 0.61.0 until the app is updated to a release that includes 0.62.0; `sudo twcore upgrade --version 0.61.0 --restart` switches a server back. +- The request store's schema is unchanged: upgrading from 0.61.0 keeps the request history. +- The configuration format only gains fields: a top-level `aliases` table, and `to` in a routing rule may now be a list of pinned models. Every 0.61.0 configuration loads unchanged. +- Answers to requests whose model was renamed on the way (by an alias, a rule's `set.model`, a pinned model or a plugin) now carry the name the client asked for, as long as the upstream answered with the model it was sent. Before, they carried the upstream's name. +- Changed in the protocol: + - New: `GET /aliases` (`AliasesView`), `POST /aliases` and `PUT /aliases/{name}` (`AliasSave` → `ConfigWritten` / `AliasWritten`), `DELETE /aliases/{name}`, `POST /aliases/preview` (`AliasPreviewRequest` → `AliasPreview`) and `GET /aliases/{name}/usage` (`AliasUsage`). + - `RuleView.to` and `RuleInput.to` are a `RuleTarget`: an upstream or group name as before, or a list of `PinnedModel { provider, model }`. + - `KnownModel` gains `alias` (the alias's models, when the entry is an alias) and `aliases` (the aliases that list a model); `GET /models` lists aliases. `ModelRow` gains `aliases`. + - The dry-run result gains `candidate_models`, one `DryRunCandidate { provider, sent_model, model_via }` per candidate. +- Message codes, compared with 0.61.0: + - New: `config.alias_blank_model`, `config.alias_chained`, `config.alias_duplicate`, `config.alias_empty_name`, `config.alias_no_models`, `config.alias_only_itself`, `config.alias_reserved`, `config.alias_wildcard`; `engine.pinned_blank_model`, `engine.pinned_duplicate_provider`, `engine.pinned_empty`, `engine.pinned_unknown_provider`, `engine.pinned_with_provider_would_be`, `engine.pinned_with_set_model`; `gw.model.alias_unserved`, `gw.model.alias_unserved_rewritten`, `gw.model.pinned_not_allowed`, `gw.model.pinned_not_offered`, `gw.plugin.alias_unserved`; `control.replay_alias_unserved`. + - None removed. + +**Model aliases.** An alias is another name for the same model, written in a top-level `aliases` table as the alias and the names upstreams use for it, in order. +- An upstream serves an alias when it offers one of the listed names within its `models_only`, and receives the first such name in the list. Every upstream that serves an alias takes part in failover for it, each receiving its own name. +- `GET /v1/models` lists aliases next to the upstream models; the original names stay available. +- An alias takes precedence over an upstream model of the same name that it doesn't list. +- A key's `allow` and a rule's `when.model` written for an upstream model name also cover the aliases that list it; written for an alias, they cover only the alias. +- `set.model` may name an alias. Prices, the upstream check-up and each recorded attempt use the name sent upstream; the request log keeps the name the client asked for. +- Replay, the inference speed test and plugin renames resolve an alias for each upstream the same way. +- The control plane manages aliases (`/aliases`), previews a draft (which upstream would receive which name, upstream models of the same name it would take over, other upstreams' names for the same Claude model) and renames an alias together with the keys' `allow` entries and the rule conditions that name it. + +**Pinned models.** A routing rule's `to` may list upstreams with the model to send each, tried in order. The model is sent as written: aliases and `set.model` do not apply. A rule can so send one key's requests to one upstream's model even when an alias of the same name points elsewhere. + +**The model name in answers.** When the model sent upstream differs from the one the client asked for and the upstream answered with the model it was sent, the answer carries the client's name: in all four API formats, streamed or not, and in the `openai-model` and `x-openai-model` headers. An answer naming a different model than the one sent is passed on unchanged, so a substitution by the upstream stays visible. The request log and the upstream check-up keep the upstream's own answer. + +**Context windows in the model listing.** Each model in `GET /v1/models` carries its context window where the price table knows it: `context_window`, `context_length` and `max_input_tokens`, plus `supports_1m` on the Anthropic shape and `inputTokenLimit`/`outputTokenLimit` on the Gemini shape. Claude family tiers now include `fable` and `mythos`. + +**ThinkWatch's own data directory.** Tool-call inspection has a new built-in rule, `thinkwatch-data`, for a tool call whose path or command points into ThinkWatch's data directory (`~/.thinkwatch`, `%APPDATA%\ThinkWatch`, `/var/lib/thinkwatch`, `/etc/thinkwatch`). Under `enforce` it cuts the response off. Text that only mentions the directory, such as a document being edited, does not count. The configuration reference now says what protects `config.yaml` on the machine. + +**Upstream URLs with their own version.** An upstream whose base URL already ends in an API version (`…/api/paas/v4`, `…/api/v3`) receives requests under that version instead of a second `/v1` appended to it, which used to return 404. Requests, connection checks and model-list fetches all use it. From 774286c49d67884f3f5dd7c46a048ab704312fde Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:01:29 +0800 Subject: [PATCH 12/18] Aliases control plane: preview path, renames, served_by, bare-number names - Move the alias preview to `POST /alias-preview`: `POST /aliases/preview` shadowed `PUT`/`DELETE /aliases/{name}`, so an alias named `preview` could be neither updated nor deleted (405). A test keeps any fixed path out from under `/aliases/`. - Renaming an alias whose old name stays in its own model list leaves the keys' `allow` entries and the rules' `when.model` alone: the old name now means that upstream model, and inheritance already carries it to the renamed alias. `set.model` is still rewritten, since it is a name to send and does not inherit. `rename_alias` takes the renamed alias and returns what it rewrote, which is what `renamed_in` reports. - `served_by` in `GET /aliases` and the preview follow admission: when some upstream has a model list and no listed upstream offers any of the alias's models, the alias is refused before any candidate is tried, so it is served by nobody (and every model is unserved in the preview). With no lists at all the answer stays resolve-based. - A rule's `to` and an alias's single model accept unquoted numbers and booleans as names (`to: 2024`, `x: 1.5`), as 0.61.0 did for `to`, instead of failing to load and sending the gateway into safe mode. Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/src/ep.rs | 22 +++- crates/tw-api/src/lib.rs | 20 ++-- crates/tw-config/src/aliases.rs | 45 ++++++++ crates/tw-config/src/refs.rs | 118 ++++++++++++++++++--- crates/tw-config/src/reload.rs | 27 +++++ crates/tw-control/src/aliases.rs | 58 ++++++++--- crates/tw-control/tests/aliases.rs | 162 ++++++++++++++++++++++++++--- crates/tw-engine/src/engine.rs | 77 ++++++++++++++ crates/tw-engine/src/lib.rs | 2 +- release-notes/0.62.0.md | 4 +- 10 files changed, 480 insertions(+), 55 deletions(-) diff --git a/crates/tw-api/src/ep.rs b/crates/tw-api/src/ep.rs index 112f453d..cf35dba7 100644 --- a/crates/tw-api/src/ep.rs +++ b/crates/tw-api/src/ep.rs @@ -105,10 +105,13 @@ endpoints! { /// 模型在各家叫不同名称的建议(只认 Claude) Aliases: GET "/aliases", () => api::AliasesView; CreateAlias: POST "/aliases", api::AliasSave => api::ConfigWritten; - /// 预览一个还没保存的别名:挡着保存的问题、发往各家的名称、别家上的同一个模型 - PreviewAlias: POST "/aliases/preview", api::AliasPreviewRequest => api::AliasPreview; + /// 预览一个还没保存的别名:挡着保存的问题、发往各家的名称、别家上的同一个模型。 + /// **不在 `/aliases/` 底下**:`/aliases/preview` 会盖住 `/aliases/{name}`,一个叫 + /// `preview` 的别名就改不了、删不掉了 + PreviewAlias: POST "/alias-preview", api::AliasPreviewRequest => api::AliasPreview; /// 保存,可以改名:引用旧名的密钥(`allow` 里的整项)和规则(`when.model`、 - /// `set.model`)在同一个版本里跟着改 + /// `set.model`)在同一个版本里跟着改。旧名还在改名后的列表里时,`allow` 和 + /// `when.model` 不改(见 [`api::AliasWritten::renamed_in`]) UpdateAlias: PUT "/aliases/{name}" [name], api::AliasSave => api::AliasWritten; /// 删掉。**引用它的密钥和规则不拦**:删之前先看 `AliasUsage` DeleteAlias: DELETE "/aliases/{name}" [name], api::BaseVersion => api::ConfigWritten; @@ -245,6 +248,19 @@ mod tests { } } + /// 别名的名字是用户起的,什么词都可能:`/aliases/` 下面的第二段只能是 `{name}`。 + /// 写死一个词(`/aliases/preview`)的话,axum 先认写死的那个,叫这个词的别名就只剩 + /// 那一个方法,改和删都是 405 + #[test] + fn no_fixed_path_under_aliases_shadows_an_alias_name() { + for e in ALL { + let segments: Vec<&str> = e.path.split('/').skip(1).collect(); + if segments.first() == Some(&"aliases") && segments.len() > 1 { + assert_eq!(segments[1], "{name}", "{}: {}", e.name, e.path); + } + } + } + #[test] fn paths_are_filled_and_encoded() { assert_eq!(RequestDetail::path(42), "/request/42"); diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index 4eb7f608..a6cc75d4 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -741,8 +741,9 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// /// **38 起有模型别名**:配置多了顶层的 `aliases`(一个名称 → 同一个模型在各家上游的名称, /// 有序),新端点 `GET /aliases`、`POST /aliases`、`PUT /aliases/{name}`(可改名,密钥范围和 -/// 规则里等于旧名的地方在同一个版本里一起改)、`DELETE /aliases/{name}`、 -/// `POST /aliases/preview`、`GET /aliases/{name}/usage`。`GET /models` 的列表含别名 +/// 规则里等于旧名的地方在同一个版本里一起改;旧名还在列表里时只改 `set.model`)、 +/// `DELETE /aliases/{name}`、`POST /alias-preview`、`GET /aliases/{name}/usage`(预览不在 +/// `/aliases/` 底下,免得盖住一个叫 `preview` 的别名)。`GET /models` 的列表含别名 /// ([`KnownModel::alias`];和真模型同名时只有别名那一项),真模型带着列出它的别名 /// ([`KnownModel::aliases`]),上游模型清单的每一行也带([`ModelRow::aliases`])。规则的 /// 去向可以是指定模型:[`RuleView::to`]、[`RuleInput::to`] 是 [`RuleTarget`],字符串或 @@ -3004,7 +3005,9 @@ pub struct AliasView { /// 按书写顺序 pub models: Vec, /// 每家能服务它的上游实际发出的名称,按配置里的顺序。停用的上游不在里面;没有清单 - /// 的上游当作能服务(取列表里第一个在它启用范围里的名称) + /// 的上游当作能服务(取列表里第一个在它启用范围里的名称)。**和网关一致**:有上游 + /// 有清单、而有清单的上游谁都不提供列表里的名称时是空的 —— 请求在准入就被拒了, + /// 没有清单的上游也轮不到 pub served_by: Vec, /// 清单里有一个和别名同名的真模型、而别名的列表里没有这个名称的上游:**这个名称 /// 不会再发给它们**(别名优先) @@ -3045,7 +3048,7 @@ pub struct AliasesView { pub suggestions: Vec, } -/// 预览一个还没保存的别名(`POST /aliases/preview`)。 +/// 预览一个还没保存的别名(`POST /alias-preview`)。 #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct AliasPreviewRequest { @@ -3063,7 +3066,8 @@ pub struct AliasPreview { pub problems: Vec, /// 存了之后每家上游发出的名称(同 [`AliasView::served_by`]) pub served_by: Vec, - /// 列表里没有哪家上游的清单里有的名称 + /// 列表里没有哪家上游的清单里有的名称。准入会拒掉这个别名时(`served_by` 是空的) + /// 是列表里的全部名称 pub unserved: Vec, /// 同 [`AliasView::shadows`] pub shadows: Vec, @@ -3101,7 +3105,11 @@ pub struct AliasUsage { #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct AliasWritten { pub version: String, - /// 改名时在同一个版本里一起改掉的引用。没改名时是空的;`requests_24h` 总是 0 + /// 改名时在同一个版本里一起改掉的引用。没改名时是空的;`requests_24h` 总是 0。 + /// + /// **旧名也是改名后列表里的一个模型名时**,密钥的 `allow` 和规则的 `when.model` + /// 不改,也不在这里:改名之后旧名说的是那个真模型,写真名的放行和条件照样继承到 + /// 改名后的别名。`set.model` 照样改(它是要发的名称,不继承) pub renamed_in: AliasUsage, } diff --git a/crates/tw-config/src/aliases.rs b/crates/tw-config/src/aliases.rs index 2e05c1ae..da9eb168 100644 --- a/crates/tw-config/src/aliases.rs +++ b/crates/tw-config/src/aliases.rs @@ -113,6 +113,26 @@ impl<'de> Deserialize<'de> for Models { fn visit_string(self, v: String) -> Result { Ok(Models(vec![v])) } + // 没加引号、YAML 读成数或真假的模型名(`x: 1.5`):照名字收下,见 + // `tw_engine::scalar_name`。列表里的、别名的名字本身读的是字符串,本来就是原文 + fn visit_bool(self, v: bool) -> Result { + Ok(Models(vec![v.to_string()])) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Models(vec![v.to_string()])) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Models(vec![v.to_string()])) + } + fn visit_i128(self, v: i128) -> Result { + Ok(Models(vec![v.to_string()])) + } + fn visit_u128(self, v: u128) -> Result { + Ok(Models(vec![v.to_string()])) + } + fn visit_f64(self, v: f64) -> Result { + Ok(Models(vec![tw_engine::scalar_name(v)])) + } /// `x:` 后面什么都没写:读成空列表,让校验说「这个别名没有模型」, /// 比一句「类型不对」好懂 fn visit_unit(self) -> Result { @@ -192,6 +212,31 @@ a-model: [x] assert_eq!(back, t); } + /// 没加引号的数和真假是名字:别名的名字、列表里的模型名读的是原文,单个模型名照名字 + /// 收下 —— 手写的配置不该因为 YAML 把 `1.5` 读成了数就读不进 + #[test] + fn names_that_look_like_numbers_or_bools_read_as_names() { + let t: Aliases = serde_yaml_ng::from_str( + "x: 1.5\n2024: 7\ny: true\nz: [1.50, 0x1F, false]\nw: -3\nv: 4.0\n", + ) + .unwrap(); + assert_eq!( + t.0, + [ + alias("x", &["1.5"]), + alias("2024", &["7"]), + alias("y", &["true"]), + alias("z", &["1.50", "0x1F", "false"]), + alias("w", &["-3"]), + alias("v", &["4.0"]), + ] + ); + // 写回去是带引号的字符串,再读进来不变 + let out = serde_yaml_ng::to_string(&t).unwrap(); + let back: Aliases = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(back, t, "{out}"); + } + #[test] fn an_empty_value_reads_as_no_models_and_a_wrong_type_says_what_to_write() { let t: Aliases = serde_yaml_ng::from_str("x:\ny: []\n").unwrap(); diff --git a/crates/tw-config/src/refs.rs b/crates/tw-config/src/refs.rs index 8bd5aefd..0e47e9f3 100644 --- a/crates/tw-config/src/refs.rs +++ b/crates/tw-config/src/refs.rs @@ -366,14 +366,29 @@ fn sets_alias(rule: &tw_engine::Rule, name: &str) -> bool { && rule.set.as_ref().and_then(|s| s.model.as_deref()) == Some(name) } -/// 把 `text` 里写着别名 `old` 的地方都改成 `new`(见 [`alias_refs`])。`cfg` 是 `text` -/// 解析出来的那一份 —— 下标要对得上。 +/// 别名 `old` 改名成 `renamed`(改名之后的那一项:新名字和它的模型列表)时,把 `text` +/// 里跟着改的引用改成新名,返回改了哪些。`cfg` 是 `text` 解析出来的那一份 —— 下标要 +/// 对得上。 +/// +/// 一般就是写着旧名的那些([`alias_refs`])。**旧名也是改名后列表里的一个模型名时, +/// 密钥的 `allow` 和规则的 `when.model` 不动**:改名之后旧名说的是那个真模型,而继承 +/// (真名 → 列着它的别名)照样放行、匹配改名后的别名;改成新名反而把直接用真名的客户端 +/// 挡在外面、让条件不再匹配它。`set.model` 照样改:它是要发的名称,不是在匹配,不继承 +/// —— 留着旧名就成了到处发那个真名,原来按别名对到别的名称的那几家就到不了了。 /// /// **只换那一个值**([`tw_yaml::set`]):`allow` 里别的项、它们的写法和注释原样不动。 -pub fn rename_alias(text: &str, cfg: &Config, old: &str, new: &str) -> Result { +pub fn rename_alias( + text: &str, + cfg: &Config, + old: &str, + renamed: &crate::Alias, +) -> Result<(String, AliasRefs), EditError> { + let matchers = !renamed.models.iter().any(|m| m == old); let mut out = text.to_string(); - let to = tw_yaml::Scalar::s(new); - for (i, c) in cfg.clients.iter().enumerate() { + let mut done = AliasRefs::default(); + let to = tw_yaml::Scalar::s(&renamed.name); + for (i, c) in cfg.clients.iter().enumerate().filter(|_| matchers) { + let mut hit = false; for (j, _) in c .allow .iter() @@ -391,6 +406,10 @@ pub fn rename_alias(text: &str, cfg: &Config, old: &str, new: &str) -> Result Result) -> Json { .aliases .iter() .map(|a| { - let served_by = served_by(&cfg, &catalog, &a.name); + let served_by = served_by(&cfg, &catalog, a); let (requests_24h, cost_micros_24h) = used .iter() .find(|u| u.name == a.name) @@ -164,13 +165,16 @@ async fn preview( }) }) .collect(); + // 准入就会拒掉的话,列表里的每个名称都算没有上游提供:没有清单的上游当作有,也 + // 轮不到它(见 `served_by`) + let admitted = admitted(&with, &catalog, &draft); Json(tw_api::AliasPreview { problems, - served_by: served_by(&with, &catalog, &draft.name), + served_by: served_by(&with, &catalog, &draft), unserved: draft .models .iter() - .filter(|m| !m.trim().is_empty() && offering(&lists, m).is_empty()) + .filter(|m| !m.trim().is_empty() && (!admitted || offering(&lists, m).is_empty())) .cloned() .collect(), shadows: shadows(&lists, &draft), @@ -208,13 +212,15 @@ async fn update( return Err(not_found(&name)); } let a = to_alias(&req.alias)?; - let mut out = edit::upsert_alias(text, Some(&name), &a)?; - if a.name != name { - // **和别名在同一个版本里改**:分两次写的话,中间那一版里密钥放行的、 - // 规则匹配的是一个已经不存在的名字 —— 配置照样通过校验,请求却悄悄变了 - renamed = refs::alias_refs(cfg, &name); - out = refs::rename_alias(&out, cfg, &name, &a.name)?; + let out = edit::upsert_alias(text, Some(&name), &a)?; + if a.name == name { + return Ok(out); } + // **和别名在同一个版本里改**:分两次写的话,中间那一版里密钥放行的、 + // 规则匹配的是一个已经不存在的名字 —— 配置照样通过校验,请求却悄悄变了。 + // 旧名还在列表里时哪些不改,见 `rename_alias` + let (out, done) = refs::rename_alias(&out, cfg, &name, &a)?; + renamed = done; Ok(out) }) .await @@ -309,17 +315,26 @@ fn offering(lists: &[(String, Vec)], model: &str) -> Vec { .collect() } -/// 每家启用的上游发出的名称(`resolve`)。服务不了的不在里面 +/// 每家启用的上游发出的名称,**和网关实际做的一样**。服务不了的不在里面。 +/// +/// 一家一家按 `resolve` 取名,没有清单的上游当作能服务。但请求先过准入,准入看的是 +/// 目录:目录里有清单、而有清单的上游没有一家提供列表里的任一名称时,这个别名在准入 +/// 就被拒了([`admitted`]),没有清单的上游也轮不到 —— 这时一家都不算。 +/// +/// `alias` 要在 `cfg.aliases` 里(`resolve` 按名字查它)。 fn served_by( cfg: &tw_config::Config, catalog: &tw_engine::Catalog, - name: &str, + alias: &tw_config::Alias, ) -> Vec { + if !admitted(cfg, catalog, alias) { + return Vec::new(); + } cfg.providers .iter() .filter(|p| !p.disabled) .filter_map(|p| { - tw_gateway::models::resolve(cfg, catalog, p, name).map(|model| PinnedModel { + tw_gateway::models::resolve(cfg, catalog, p, &alias.name).map(|model| PinnedModel { provider: p.name.clone(), model, }) @@ -327,6 +342,23 @@ fn served_by( .collect() } +/// 准入放不放这个别名进来(不看密钥):和真模型同一条规矩 —— 目录里一张清单都没有时 +/// 不拦(探测还没回来,或者上游都不给清单);有清单时,要有一家有清单的上游提供它 +/// 列表里的一个名称。目录里的清单已经去掉了停用的上游、按启用范围过滤过 +fn admitted( + cfg: &tw_config::Config, + catalog: &tw_engine::Catalog, + alias: &tw_config::Alias, +) -> bool { + catalog.is_empty() + || cfg.providers.iter().any(|p| { + alias + .models + .iter() + .any(|m| catalog.offers(&p.name, m) == Some(true)) + }) +} + /// 清单里有一个和别名同名的真模型、别名却没列这个名称的上游 fn shadows(lists: &[(String, Vec)], a: &tw_config::Alias) -> Vec { if a.name.trim().is_empty() || a.models.contains(&a.name) { diff --git a/crates/tw-control/tests/aliases.rs b/crates/tw-control/tests/aliases.rs index d2795b7b..1d0d5759 100644 --- a/crates/tw-control/tests/aliases.rs +++ b/crates/tw-control/tests/aliases.rs @@ -295,6 +295,81 @@ async fn the_list_opens_without_request_records() { assert!(v["aliases"][0].get("cost_micros_24h").is_none(), "{v}"); } +/// 有清单的上游谁都不提供它列的名称:准入就拒了(真模型也是这条规矩),没有清单的 +/// 上游(blind)也轮不到 —— 不能说它发往 blind +#[tokio::test] +async fn an_alias_that_admission_refuses_is_served_by_nobody() { + let yaml = BASE.replace( + "routes:\n", + " ghost: [claude-ghost-1, claude-ghost-1-v2]\nroutes:\n", + ); + let b = bed(&yaml, None); + let (st, v) = call(&b.app, "GET", "/aliases", Value::Null).await; + assert_eq!(st, StatusCode::OK, "{v}"); + let ghost = &v["aliases"][1]; + assert_eq!(ghost["name"], "ghost"); + assert_eq!(ghost["served_by"], json!([]), "{v}"); + assert!(ghost.get("context_window").is_none(), "{v}"); + // 有一家有清单的提供它,blind 照样算(准入放进来了,挑候选时它当作能服务) + assert!( + pinned(&v["aliases"][0]["served_by"]) + .contains(&("blind".into(), "claude-sonnet-4-5".into())), + "{v}" + ); + + let (st, v) = call( + &b.app, + "POST", + "/alias-preview", + json!({ "alias": { "name": "ghost-2", "models": ["claude-ghost-1", "nope"] } }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(v["served_by"], json!([]), "{v}"); + assert_eq!(v["unserved"], json!(["claude-ghost-1", "nope"]), "{v}"); +} + +/// 一张清单都没有时准入不拦(探测还没回来,或者上游都不给清单):没有清单的上游照样 +/// 当作能服务 +#[tokio::test] +async fn without_any_model_list_an_alias_goes_where_it_resolves() { + let yaml = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 +clients: + - name: default + key: tw-aaaa +providers: + - name: blind + base_url: https://blind.example + key: sk-e + protocol: anthropic + models_only: ['claude-*'] +aliases: + ghost: [gpt-ghost, claude-ghost-1] +"; + let b = bed(yaml, None); + let (_, v) = call(&b.app, "GET", "/aliases", Value::Null).await; + assert_eq!( + pinned(&v["aliases"][0]["served_by"]), + pairs(&[("blind", "claude-ghost-1")]), + "{v}" + ); + let (_, v) = call( + &b.app, + "POST", + "/alias-preview", + json!({ "alias": { "name": "ghost-2", "models": ["claude-ghost-1"] } }), + ) + .await; + assert_eq!( + pinned(&v["served_by"]), + pairs(&[("blind", "claude-ghost-1")]), + "{v}" + ); +} + /// 别名替一组里的每一家都列了一种写法:这一组的建议不再出 #[tokio::test] async fn a_group_every_upstream_of_which_an_alias_reaches_is_not_suggested() { @@ -547,6 +622,73 @@ async fn renaming_an_alias_carries_its_references_in_the_same_version() { assert_eq!(st, StatusCode::NOT_FOUND, "{v}"); } +/// 别名和它的一个模型同名、改名后还列着那个名称:旧名说的是那个真模型,写着它的 +/// `allow` 和 `when.model` 照样继承到改名后的别名 —— 不改、不报;改成新名的话,直接用 +/// 真名的客户端就被挡在外面了。`set.model` 是要发的名称、不继承,照样改 +#[tokio::test] +async fn renaming_an_alias_that_lists_its_own_name_keeps_the_keys_and_conditions() { + let yaml = BASE + .replace(" sonnet: # Bedrock", " claude-sonnet-4-5: # Bedrock") + .replace("[sonnet, 'claude-*']", "[claude-sonnet-4-5, 'claude-*']") + .replace("{ model: sonnet }", "{ model: claude-sonnet-4-5 }"); + let b = bed(&yaml, None); + let (st, v) = call( + &b.app, + "PUT", + "/aliases/claude-sonnet-4-5", + json!({ "alias": { "name": "sonnet-4.5", "models": [ + "claude-sonnet-4-5", "us.anthropic.claude-sonnet-4-5-v1:0" + ] } }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(v["renamed_in"]["keys"], json!([]), "{v}"); + assert_eq!( + v["renamed_in"]["rules"], + json!([{ "route": "default", "rule": "降级", "field": "set.model" }]), + "{v}" + ); + let f = b.file(); + assert!( + f.contains(" allow: [claude-sonnet-4-5, 'claude-*'] # 只给 Claude\n"), + "{f}" + ); + assert!( + f.contains(" when: { model: claude-sonnet-4-5 }\n"), + "{f}" + ); + assert!(f.contains(" set: { model: sonnet-4.5 }\n"), "{f}"); + assert_eq!(b.parsed().aliases[0].name, "sonnet-4.5"); +} + +/// 别名的名字是用户起的:叫 `preview` 也改得了、删得掉(预览不在 `/aliases/` 底下) +#[tokio::test] +async fn an_alias_named_preview_can_be_edited_and_deleted() { + let b = bed(BASE, None); + let (st, v) = call( + &b.app, + "POST", + "/aliases", + json!({ "alias": { "name": "preview", "models": ["claude-opus-4-1-20250805"] } }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let (st, v) = call( + &b.app, + "PUT", + "/aliases/preview", + json!({ "alias": { "name": "preview", "models": ["claude-sonnet-4-5"] } }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(b.parsed().aliases[1].models, ["claude-sonnet-4-5"]); + let (st, v) = call(&b.app, "GET", "/aliases/preview/usage", Value::Null).await; + assert_eq!(st, StatusCode::OK, "{v}"); + let (st, v) = call(&b.app, "DELETE", "/aliases/preview", Value::Null).await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(!b.parsed().aliases.contains("preview")); +} + /// 删掉;引用它的地方不拦(那是一个上游模型名,配置照样成立);删到最后一个,整张表 /// 一起没了 #[tokio::test] @@ -580,7 +722,7 @@ async fn a_preview_says_where_a_draft_would_go_and_suggests_the_same_model_elsew let (st, v) = call( &b.app, "POST", - "/aliases/preview", + "/alias-preview", json!({ "alias": { "name": "opus", "models": ["claude-opus-4-1-20250805", "nope-model"] } }), ) .await; @@ -609,19 +751,13 @@ async fn a_preview_says_where_a_draft_would_go_and_suggests_the_same_model_elsew async fn editing_an_alias_is_not_a_duplicate_of_itself() { let b = bed(BASE, None); let draft = json!({ "name": "sonnet", "models": ["claude-sonnet-4-5"] }); - let (_, v) = call( - &b.app, - "POST", - "/aliases/preview", - json!({ "alias": draft }), - ) - .await; + let (_, v) = call(&b.app, "POST", "/alias-preview", json!({ "alias": draft })).await; assert_eq!(codes(&v), ["config.alias_duplicate"], "{v}"); let (_, v) = call( &b.app, "POST", - "/aliases/preview", + "/alias-preview", json!({ "alias": draft, "original": "sonnet" }), ) .await; @@ -639,7 +775,7 @@ async fn editing_an_alias_is_not_a_duplicate_of_itself() { let (_, v) = call( &b.app, "POST", - "/aliases/preview", + "/alias-preview", json!({ "alias": { "name": "sonnet-4.5", "models": ["claude-sonnet-4-5"] }, "original": "sonnet" }), ) .await; @@ -648,7 +784,7 @@ async fn editing_an_alias_is_not_a_duplicate_of_itself() { let (_, v) = call( &b.app, "POST", - "/aliases/preview", + "/alias-preview", json!({ "alias": { "name": "sonnet", "models": ["sonnet", "claude-sonnet-4-5"] }, "original": "sonnet" }), ) .await; @@ -670,7 +806,7 @@ async fn a_preview_lists_what_would_stop_the_save() { None => json!({ "alias": alias }), }; async move { - let (st, v) = call(&app, "POST", "/aliases/preview", body).await; + let (st, v) = call(&app, "POST", "/alias-preview", body).await; assert_eq!(st, StatusCode::OK, "{v}"); codes(&v) } @@ -699,7 +835,7 @@ async fn a_preview_lists_what_would_stop_the_save() { let (_, v) = call( &b.app, "POST", - "/aliases/preview", + "/alias-preview", json!({ "alias": { "name": "", "models": ["claude-opus-4-1-20250805"] } }), ) .await; diff --git a/crates/tw-engine/src/engine.rs b/crates/tw-engine/src/engine.rs index 63f3e9ee..88800010 100644 --- a/crates/tw-engine/src/engine.rs +++ b/crates/tw-engine/src/engine.rs @@ -305,6 +305,25 @@ impl<'de> Deserialize<'de> for Target { fn visit_string(self, v: String) -> Result { Ok(Target::Name(v)) } + // 没加引号、YAML 读成数或真假的名字(`to: 2024`):照名字收下,见 [`scalar_name`] + fn visit_bool(self, v: bool) -> Result { + Ok(Target::Name(v.to_string())) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Target::Name(v.to_string())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Target::Name(v.to_string())) + } + fn visit_i128(self, v: i128) -> Result { + Ok(Target::Name(v.to_string())) + } + fn visit_u128(self, v: u128) -> Result { + Ok(Target::Name(v.to_string())) + } + fn visit_f64(self, v: f64) -> Result { + Ok(Target::Name(scalar_name(v))) + } fn visit_seq>(self, seq: A) -> Result { // 列表里每一项照 `Pinned` 自己的规矩读:写错的字段名由 serde 说出来 Vec::::deserialize(serde::de::value::SeqAccessDeserializer::new(seq)) @@ -315,6 +334,25 @@ impl<'de> Deserialize<'de> for Target { } } +/// 没加引号、被 YAML 读成了浮点数的名字,写回字符串:`1.5` 还是 `1.5`,`4.0` 还是 +/// `4.0`,`.inf`、`.nan` 照 YAML 的写法。整数和真假就是 `to_string()`。 +/// +/// 名字的字段(规则的 `to`、别名的模型)能写成字符串或列表,读的时候要按类型分支 +/// (`deserialize_any`),YAML 就先把没加引号的 `2024`、`1.5`、`true` 读成了数和真假。 +/// 0.61.0 的 `to` 只是字符串,serde_yaml 给的是原文 —— 不照名字收下的话,一份原来读得 +/// 进的配置就读不进了,网关进安全模式。十进制整数、`true`/`false`、`1.5` 这类和原文一样; +/// 原文还原不了的写法(`0x1F`、`1.50`、`1e3`、`True`)给的是规范写法,要原样就加引号。 +pub fn scalar_name(v: f64) -> String { + if v.is_nan() { + ".nan".to_string() + } else if v.is_infinite() { + if v > 0.0 { ".inf" } else { "-.inf" }.to_string() + } else { + // `{:?}` 给整数值的浮点数留着 `.0`(`4.0`),`{}` 会写成 `4` + format!("{v:?}") + } +} + impl Rule { /// 命中就决定去向:转发或拒绝。 pub fn decides(&self) -> bool { @@ -2270,6 +2308,45 @@ to: ); } + /// 没加引号、YAML 读成数或真假的名字照名字收下:0.61.0 的 `to` 是字符串,这样的 + /// 配置原来读得进 + #[test] + fn a_target_written_as_a_number_or_a_bool_is_a_name() { + for (yaml, name) in [ + ("2024", "2024"), + ("-7", "-7"), + ("1.5", "1.5"), + ("4.0", "4.0"), + ("true", "true"), + // 超出 u64 的整数 + ( + "340282366920938463463374607431768211455", + "340282366920938463463374607431768211455", + ), + // 前面是 0 的一串数字 YAML 本来就当字符串 + ("007", "007"), + ("'1.50'", "1.50"), + ] { + let r = rule(&format!("{{name: r, to: {yaml}}}")); + assert_eq!(r.to, Some(Target::Name(name.to_string())), "{yaml}"); + } + // 块式写法也一样 + let r = rule("name: r\nto: 2024\n"); + assert_eq!(r.to, Some(Target::Name("2024".into()))); + // 写回去是带引号的字符串,再读进来不变 + let yaml = serde_yaml_ng::to_string(&r).unwrap(); + let back: Rule = serde_yaml_ng::from_str(&yaml).unwrap(); + assert_eq!(back.to, r.to, "{yaml}"); + assert_eq!(scalar_name(f64::INFINITY), ".inf"); + assert_eq!(scalar_name(f64::NEG_INFINITY), "-.inf"); + assert_eq!(scalar_name(f64::NAN), ".nan"); + // 对象不是名字:照样说要写什么 + let e = serde_yaml_ng::from_str::("{name: r, to: {a: b}}") + .unwrap_err() + .to_string(); + assert!(e.contains("the name of an upstream or a group"), "{e}"); + } + /// 拼错的字段要说出是哪个,而不是「哪一种都不像」 #[test] fn a_typo_in_a_pinned_entry_names_the_field() { diff --git a/crates/tw-engine/src/lib.rs b/crates/tw-engine/src/lib.rs index 44c6540d..80eb1d09 100644 --- a/crates/tw-engine/src/lib.rs +++ b/crates/tw-engine/src/lib.rs @@ -8,6 +8,6 @@ pub use catalog::{Catalog, ProviderModels}; pub use engine::{ ALL_UPSTREAMS, Asked, CATCH_ALL_RULE, DEFAULT_ROUTE, Decision, Engine, Facts, Group, GroupType, Origin, Outcome, Outcome2, Pinned, RESERVED_PREFIX, RouteError, RouteSet, Rule, RuleNotes, - SetAction, Target, has_catch_all, is_builtin_group, notes, order_by, + SetAction, Target, has_catch_all, is_builtin_group, notes, order_by, scalar_name, }; pub use facts::{RequestFacts, estimate_strings, estimate_tokens}; diff --git a/release-notes/0.62.0.md b/release-notes/0.62.0.md index 2eed2e85..e3da5263 100644 --- a/release-notes/0.62.0.md +++ b/release-notes/0.62.0.md @@ -7,7 +7,7 @@ This release adds model aliases: one name for the same model across upstreams, e - The configuration format only gains fields: a top-level `aliases` table, and `to` in a routing rule may now be a list of pinned models. Every 0.61.0 configuration loads unchanged. - Answers to requests whose model was renamed on the way (by an alias, a rule's `set.model`, a pinned model or a plugin) now carry the name the client asked for, as long as the upstream answered with the model it was sent. Before, they carried the upstream's name. - Changed in the protocol: - - New: `GET /aliases` (`AliasesView`), `POST /aliases` and `PUT /aliases/{name}` (`AliasSave` → `ConfigWritten` / `AliasWritten`), `DELETE /aliases/{name}`, `POST /aliases/preview` (`AliasPreviewRequest` → `AliasPreview`) and `GET /aliases/{name}/usage` (`AliasUsage`). + - New: `GET /aliases` (`AliasesView`), `POST /aliases` and `PUT /aliases/{name}` (`AliasSave` → `ConfigWritten` / `AliasWritten`), `DELETE /aliases/{name}`, `POST /alias-preview` (`AliasPreviewRequest` → `AliasPreview`) and `GET /aliases/{name}/usage` (`AliasUsage`). - `RuleView.to` and `RuleInput.to` are a `RuleTarget`: an upstream or group name as before, or a list of `PinnedModel { provider, model }`. - `KnownModel` gains `alias` (the alias's models, when the entry is an alias) and `aliases` (the aliases that list a model); `GET /models` lists aliases. `ModelRow` gains `aliases`. - The dry-run result gains `candidate_models`, one `DryRunCandidate { provider, sent_model, model_via }` per candidate. @@ -22,7 +22,7 @@ This release adds model aliases: one name for the same model across upstreams, e - A key's `allow` and a rule's `when.model` written for an upstream model name also cover the aliases that list it; written for an alias, they cover only the alias. - `set.model` may name an alias. Prices, the upstream check-up and each recorded attempt use the name sent upstream; the request log keeps the name the client asked for. - Replay, the inference speed test and plugin renames resolve an alias for each upstream the same way. -- The control plane manages aliases (`/aliases`), previews a draft (which upstream would receive which name, upstream models of the same name it would take over, other upstreams' names for the same Claude model) and renames an alias together with the keys' `allow` entries and the rule conditions that name it. +- The control plane manages aliases (`/aliases`), previews a draft (which upstream would receive which name, upstream models of the same name it would take over, other upstreams' names for the same Claude model) and renames an alias together with the keys' `allow` entries and the rules' `when.model` and `set.model` that name it. When the old name stays in the alias's list, `allow` and `when.model` keep it: the name of a listed model already covers the alias. **Pinned models.** A routing rule's `to` may list upstreams with the model to send each, tried in order. The model is sent as written: aliases and `set.model` do not apply. A rule can so send one key's requests to one upstream's model even when an alias of the same name points elsewhere. From e779761d71a0589517667d83a5188e7e5b1e9976 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:04:46 +0800 Subject: [PATCH 13/18] WebSocket: send each response.create under the connected upstream's own model name The Responses WebSocket path sent every frame's model as written: a client alias, or a plugin renaming to an alias, went out as the alias itself, and a rule that pinned models for the client routed the upgrade to the pinned upstream but still sent the client's model. Each response.create now follows the same naming as an HTTP hop: the pinned model when the deciding rule pinned one for the connected upstream, a phase-two rename as written, otherwise the client-side name (the frame's, after a phase-one set.model, or after a plugin rename) resolved through the alias table for that upstream. Phase two runs per frame with the frame's facts; a phase-two deny refuses that frame. When the upstream serves none of the requested alias's models, that frame is not sent: the client gets a response.failed (gw.ws.alias_unserved, gw.ws.alias_unserved_rewritten, or gw.plugin.alias_unserved for a plugin rename) and the connection stays open. The answer's model name is written back to the client's name for every case, and the attempt records the model fixed at the upgrade (a pinned model or a phase-one rewrite). Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/msg-codes.txt | 2 + crates/tw-gateway/src/server/upgrade.rs | 16 +- crates/tw-gateway/src/ws.rs | 313 +++++++++++++++--- crates/tw-gateway/tests/alias_ws.rs | 416 ++++++++++++++++++++++++ 4 files changed, 697 insertions(+), 50 deletions(-) create mode 100644 crates/tw-gateway/tests/alias_ws.rs diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index e08f06e2..88c35cf9 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -366,6 +366,8 @@ gw.upstream.stream_exception gw.upstream.stream_opening_error gw.upstream.timeout gw.upstream.unreachable +gw.ws.alias_unserved +gw.ws.alias_unserved_rewritten gw.ws.bad_header gw.ws.bad_url gw.ws.connect_failed diff --git a/crates/tw-gateway/src/server/upgrade.rs b/crates/tw-gateway/src/server/upgrade.rs index 73198ccc..72a6e450 100644 --- a/crates/tw-gateway/src/server/upgrade.rs +++ b/crates/tw-gateway/src/server/upgrade.rs @@ -30,7 +30,8 @@ pub(super) async fn ws_upgrade( from: Sender, ) -> Result { // 升级请求没有体,所以性质里只有客户端名字 —— 按模型路由的规则 - // 对它不适用,而那是对的:这条连接上会跑什么模型,现在还不知道 + // 对它不适用,而那是对的:这条连接上会跑什么模型,现在还不知道。这次的决定 + // 里定下的指定模型、模型改写照样作用在之后每一帧上(见 `crate::ws::Naming`) let facts = tw_engine::RequestFacts { client: client_name.clone(), ..Default::default() @@ -146,6 +147,17 @@ pub(super) async fn ws_upgrade( set: rt.plugins.clone(), client: crate::hint::client_hint(&headers), }); + // 每个 `response.create` 发出去的模型名:和 HTTP 那条路的一跳同一套,按这次的决定定 + // (指定模型、阶段一的改写),别名对到这一家(见 `crate::ws::Naming`)。和插件一样只有 + // Responses 的连接有 + let naming = responses.then(|| crate::ws::Naming { + config: rt.config.clone(), + engine: rt.engine.clone(), + decision: decision.clone(), + provider: provider.clone(), + client: client_name.clone(), + path: uri.path().to_string(), + }); let upstream = crate::ws::Upstream { url: crate::ws::upstream_url(&provider.base_url, uri.path(), query.as_deref()), headers: upstream_headers, @@ -166,6 +178,6 @@ pub(super) async fn ws_upgrade( let _live = live; let mut ending = ending; ending.responded(101); - crate::ws::proxy(state, sock, upstream, rules, id, ending, plugins).await; + crate::ws::proxy(state, sock, upstream, rules, id, ending, plugins, naming).await; })) } diff --git a/crates/tw-gateway/src/ws.rs b/crates/tw-gateway/src/ws.rs index 8b90beb5..4616edd9 100644 --- a/crates/tw-gateway/src/ws.rs +++ b/crates/tw-gateway/src/ws.rs @@ -17,10 +17,15 @@ //! 观察档记录,处置档拒绝、删除或替换; //! - 上游 → 客户端:先把占位符换回去,再喂给工具调用审查。 //! +//! 每个 `response.create` 发出去的模型名**和 HTTP 那条路的一跳同一套**(见 [`Naming`]): +//! 规则给这一家指定的模型原样发,阶段二改的名字原样发,别的 —— 这一帧写的、阶段一改写成的、 +//! 插件改成的 —— 按别名表对到这一家自己的名称。这一家服务不了要的别名,这一帧不发,替它回 +//! 一个 `response.failed`,连接照常。回答里的模型名写回客户端用的那个([`crate::answer_model`])。 +//! //! 脚本插件也在这条路上跑(见 [`crate::plugin`]):客户端发来的每个 //! `response.create` 是一次请求。**这条路只有一跳**(升级时就连定了那一家,不换), -//! 所以每个 `response.create` 过一遍请求钩子:上游是这条连接连的那一家,模型名是这一帧 -//! 写的(WebSocket 上没有规则改写)。位置和 HTTP 那条路的一跳一样 —— 内容过滤先查 +//! 所以每个 `response.create` 过一遍请求钩子:上游是这条连接连的那一家,模型名是发给它的 +//! 那个(上面那一套定的)。位置和 HTTP 那条路的一跳一样 —— 内容过滤先查 //! 客户端的原话(删过的话插件拿到的是删过的那一帧),插件改过的再查一遍、只报插件加进来 //! 的,然后才脱敏、发出。上游每一次回答(`response.created` 到 `response.completed`) //! 起一组回答钩子的实例,排在占位符还原之后、工具墙之前。插件出错而策略是拒绝时,切掉的 @@ -45,6 +50,7 @@ use axum::extract::ws::{Message, WebSocket}; use futures::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::protocol::Message as UpMsg; +use crate::error::GatewayError; use crate::state::AppState; use tw_types::{Msg, msg}; @@ -134,6 +140,125 @@ pub struct Plugins { pub client: Option, } +/// 这条连接上每个 `response.create` 发给上游的模型名怎么定:**和 HTTP 那条路的一跳同一套** +/// (见 [`crate::sent`])。依次: +/// +/// - 规则给这一家指定了模型:原样发指定的那个; +/// - 阶段二的规则改了名字:原样发(写的时候已经知道是哪一家); +/// - 别的是客户端那一侧的名称 —— 这一帧写的,或者阶段一改写成的 —— 按别名表对到这一家自己的 +/// 名称([`crate::models::resolve`])。插件再改名的,改出来的名字一样对。 +/// +/// 路由在升级时就做完了:升级请求没有正文,按模型路由的规则对它不适用(见 `server::upgrade`), +/// 指定模型和阶段一的改写都在那次的决定里。**阶段二每一帧跑一遍**:到那时才知道这一帧要的 +/// 模型,和 HTTP 那条路每一跳跑一遍是同一个道理。 +/// +/// 这一家服务不了要的别名时**这一帧不发**:不把别名本身发给一个不认识它的上游。不是别名的 +/// 名字照旧原样发,这一家有没有它由它自己回答。只有 Responses 的连接有这一步:别的连接上 +/// 没有 `response.create` 这种请求。 +pub struct Naming { + /// 升级那一刻的配置和路由引擎:一条连接活多久就用它多久,和 [`Rules`] 一样 + pub config: Arc, + pub engine: Arc, + /// 升级时路由的决定 + pub decision: tw_engine::Decision, + /// 这条连接连的那一家 + pub provider: tw_config::Provider, + /// 网关密钥的名字:规则按它认客户端 + pub client: String, + /// 升级的路径。一帧按这条路径上的一次请求读([`crate::client_api::read`]) + pub path: String, +} + +impl Naming { + /// 客户端那一侧的名称发给这一家时叫什么([`crate::models::resolve`])。`None` = 这一家 + /// 服务不了这个别名 + fn resolve(&self, catalog: &tw_engine::Catalog, name: &str) -> Option { + crate::models::resolve(&self.config, catalog, &self.provider, name) + } + + /// 每一帧都发的那个名字,升级时就定了的话:规则给这一家指定了模型,或者阶段一把模型 + /// 改写了(按别名表对到这一家)。**尝试链上记它**,和 HTTP 那条路每一跳记发出去的名字 + /// 一样;要看每一帧写的是什么的(别名、阶段二、插件改名)升级时说不上来,是 None + fn fixed(&self, catalog: &tw_engine::Catalog) -> Option { + let facts = tw_engine::RequestFacts { + client: self.client.clone(), + ..Default::default() + }; + let asked = self + .engine + .asked_of(&facts, &self.decision, &self.provider.name, None); + match asked.origin { + tw_engine::Origin::Pinned => Some(asked.model), + tw_engine::Origin::Rule => self.resolve(catalog, &asked.model), + _ => None, + } + } + + /// 一帧 `response.create`(`frame`)发给这一家的模型名。这一帧发不出去时是告诉客户端的 + /// 那个错误:阶段二的规则拒绝了它、规则求不了值,或者这一家服务不了要的别名。 + fn sent( + &self, + catalog: &tw_engine::Catalog, + frame: &serde_json::Value, + ) -> Result { + // 一帧的其余字段就是一个 Responses 请求:阶段二的条件按它读出的性质求值 + let mut facts = crate::client_api::read(&self.path, None, Some(frame)).facts; + facts.client = self.client.clone(); + let p = &self.provider; + let renamed = match self.engine.phase_two(&facts, &p.name, &self.decision.set) { + Ok(tw_engine::Outcome2::Proceed { model, .. }) => model, + Ok(tw_engine::Outcome2::Deny { rule, reason }) => { + tracing::info!(%rule, provider = %p.name, "a phase-two rule denied a WebSocket request"); + return Err(GatewayError::denied(msg!( + "gw.route.denied", rule = rule.clone(), reason = reason => + "Rule `{rule}` denied this request: {reason}" + ))); + } + Err(e) => { + return Err(GatewayError::config(msg!( + "gw.route.rule_failed", detail = e => "A rule could not be evaluated: {detail}" + ))); + } + }; + let asked = self + .engine + .asked_of(&facts, &self.decision, &p.name, renamed.as_deref()); + // 指定的、阶段二改的原样发;要的是空的(这一帧没写模型名)什么都不改 + if asked.model.is_empty() || asked.origin.as_written() { + return Ok(asked.model); + } + self.resolve(catalog, &asked.model) + .ok_or_else(|| self.unserved(&facts.model, &asked.model)) + } + + /// 这一家服务不了别名 `alias`。`client` 是这一帧写的:和别名不一样就是阶段一的规则改写的 + fn unserved(&self, client: &str, alias: &str) -> GatewayError { + let models = self + .config + .aliases + .find(alias) + .map(|a| a.models.join(", ")) + .unwrap_or_default(); + let upstream = self.provider.name.clone(); + let why = if client == alias { + msg!( + "gw.ws.alias_unserved", model = alias, upstream = upstream, models = models => + "This WebSocket connection goes to upstream `{upstream}`, which offers none of the \ + models of alias {model} ({models}), so the request was not sent." + ) + } else { + msg!( + "gw.ws.alias_unserved_rewritten", + from = client, model = alias, upstream = upstream, models = models => + "A routing rule rewrote model {from} to alias {model}. This WebSocket connection \ + goes to upstream `{upstream}`, which offers none of its models ({models}), so the \ + request was not sent." + ) + }; + GatewayError::request(why) + } +} + /// 一次连接里两个方向各自的状态。 struct Pipes { /// **整条连接一本账。**每帧各起一本的话,第二帧的 @@ -152,14 +277,17 @@ struct Pipes { id: u64, /// 范围里可能有插件时才有 plugins: Option, - /// 最近一次 `response.create`:客户端要的模型、发出去的模型(插件可能换了它)和 - /// 它的密钥映射。回答钩子用 + /// 每个 `response.create` 发出去的模型名怎么定。Responses 的连接才有 + naming: Option, + /// 最近一次 `response.create`:客户端要的模型、发出去的模型(别名对过、规则或插件 + /// 换过的那个)和它的密钥映射。回答钩子用 requested_model: String, sent_model: String, bridge: Option, /// 这一次回答的回答钩子 reply: Option, - /// 插件换了发出去的模型名时,回答里的模型名换回客户端用的(见 [`crate::answer_model`]) + /// 发出去的模型名和客户端要的不一样时,回答里的模型名换回客户端用的(见 + /// [`crate::answer_model`]) rename: Option, } @@ -172,6 +300,7 @@ struct Pipes { /// /// **这条连接怎么断的,就是这个请求的结局**(`ending`)。每一条收场的 /// 路径都先报结局、再去关连接:关连接要等对面,而对面可能已经不在了。 +#[allow(clippy::too_many_arguments)] pub async fn proxy( state: AppState, client: WebSocket, @@ -180,19 +309,29 @@ pub async fn proxy( id: u64, ending: crate::ending::Ending, plugins: Option, + naming: Option, ) { let hop_started = std::time::Instant::now(); let connected = connect(&upstream).await; // **连不上也要报。**和 HTTP 那条路一样,失败的时候恰恰最需要看这一跳; // 报在结局之前,存储层落库时手上才有它 let name = &upstream.provider.name; + // 这一跳记发出去的模型名,和 HTTP 那条路一样。一条连接跑好几轮、每一帧写的模型可能 + // 不一样,升级时定得下来的只有每一帧都发的那个(指定模型、阶段一的改写,见 + // [`Naming::fixed`]) + let model = naming.as_ref().and_then(|n| n.fixed(&state.catalog.load())); let attempt = match &connected { - // WebSocket 不改写模型名:升级请求没有正文可改 - Ok(_) => crate::server::hop(name, None, tw_api::AttemptOutcome::Served, 101, hop_started), + Ok(_) => crate::server::hop( + name, + model, + tw_api::AttemptOutcome::Served, + 101, + hop_started, + ), Err(NotConnected { status: Some(s), .. - }) => crate::server::hop(name, None, tw_api::AttemptOutcome::Status, *s, hop_started), - Err(e) => crate::server::hop_failed(name, None, e.why.clone(), hop_started), + }) => crate::server::hop(name, model, tw_api::AttemptOutcome::Status, *s, hop_started), + Err(e) => crate::server::hop_failed(name, model, e.why.clone(), hop_started), }; // 和 HTTP 那条路同一个规矩:没接下的不按那一家记账 let billing = match &connected { @@ -204,7 +343,8 @@ pub async fn proxy( route: upstream.route, rule: upstream.rule, group: upstream.group, - // 升级请求没有正文,没有什么可改写的;第二阶段也不在这条路上跑 + // 升级请求没有正文,规则附加的参数改写不作用在这条路上(只有模型名,见 [`Naming`]); + // 阶段二每一帧才跑,那时路由事件早就发了 rewritten_by: Vec::new(), denied_by: None, affinity: None, @@ -232,6 +372,7 @@ pub async fn proxy( provider: upstream.provider.name, id, plugins, + naming, requested_model: String::new(), sent_model: String::new(), bridge: None, @@ -388,16 +529,26 @@ async fn pump( break End::Cut(why); } }; - // 插件的请求钩子拿到的是查过(删过)的那一帧。改过的那一版再查一遍内容 - // 过滤(只报插件加进来的),脱敏换的是改过的那一版 - let text = match plugin_request(&state, p, &text).await { + // 发出去的模型名和插件的请求钩子:钩子拿到的是查过(删过)的那一帧。改过 + // 的那一版再查一遍内容过滤(只报插件加进来的),脱敏换的是改过的那一版 + let text = match request(&state, p, &text).await { Ok(text) => text, - Err(why) => { + Err(Refusal::Cut(why)) => { let _ = c_tx.send(Message::Text( format!("[ThinkWatch] {}", why.text).into(), )).await; break End::Cut(why); } + // 只是这一帧不发:替它回一个 `response.failed`,连接照常 + Err(Refusal::Frame(err)) => { + tracing::info!(provider = %p.provider, why = %err.detail.text, + "a WebSocket request was not sent"); + let failed = failed_frame(err, None); + if c_tx.send(Message::Text(failed.into())).await.is_err() { + break End::Closed; + } + continue; + } }; let mode = p.rules.redact_mode; let found = crate::guard::find(mode, &p.rules.redact, text.as_bytes()); @@ -615,7 +766,7 @@ async fn upstream_text( /// 切掉这一次回答:替它发 `response.failed`,它剩下的帧不再发 async fn fail_response(p: &mut Pipes, c_tx: &mut ClientSink, why: Msg) -> Flow { - let failed = failed_frame(why, p.response.as_deref()); + let failed = failed_frame(GatewayError::denied(why), p.response.as_deref()); p.dropping = true; p.reply = None; if c_tx.send(Message::Text(failed.into())).await.is_err() { @@ -624,15 +775,24 @@ async fn fail_response(p: &mut Pipes, c_tx: &mut ClientSink, why: Msg) -> Flow { Flow::Sent } -/// 一次 `response.create` 过插件的请求钩子。`text` 是查过内容过滤的那一帧(删过的话是 -/// 删过的样子)。返回要发给上游的那一帧(插件改过的话是改过的),被拒了返回告诉客户端的 -/// 那句话。别的帧原样。 +/// 客户端发来的一帧为什么不发。 +enum Refusal { + /// 切断这条连接,告诉客户端的是这句话:插件拒绝了这个请求,和内容过滤拒掉一帧一样 + Cut(Msg), + /// 只是这一帧不发,替它回一个 `response.failed`(见 [`failed_frame`]),连接照常:要的 + /// 别名这一家服务不了、阶段二的规则拒绝了它。下一帧要的可能就是这一家有的 + Frame(GatewayError), +} + +/// 一次 `response.create`:定发给这一家的模型名([`Naming`]),过插件的请求钩子。`text` 是 +/// 查过内容过滤的那一帧(删过的话是删过的样子)。返回要发给上游的那一帧:插件改过的话是 +/// 改过的,模型名写成发给这一家的那个。别的帧原样。 /// -/// 这条路只有一跳:上游是这条连接连的那一家,发给它的模型名就是这一帧写的,运行记在 -/// 第 0 跳上。插件改过的那一版**再查一遍内容过滤**,只报插件加进来的(客户端的原话已经 -/// 在 [`screen_frame`] 查过了,见 [`screen_changed`])。 -async fn plugin_request(state: &AppState, p: &mut Pipes, text: &str) -> Result { - let Some(pc) = p.plugins.as_ref() else { +/// 这条路只有一跳:上游是这条连接连的那一家,插件的运行记在第 0 跳上。插件改过的那一版 +/// **再查一遍内容过滤**,只报插件加进来的(客户端的原话已经在 [`screen_frame`] 查过了,见 +/// [`screen_changed`])。 +async fn request(state: &AppState, p: &mut Pipes, text: &str) -> Result { + let Some(naming) = p.naming.as_ref() else { return Ok(text.to_string()); }; let Some(frame) = serde_json::from_str::(text) @@ -646,6 +806,39 @@ async fn plugin_request(state: &AppState, p: &mut Pipes, text: &str) -> Result Result<(String, String), Refusal> { + let Some(pc) = p.plugins.as_ref() else { + return Ok((text.to_string(), sent)); + }; let body = bytes::Bytes::copy_from_slice(text.as_bytes()); let mut hook = crate::plugin::request::Hook::new( &pc.set, @@ -657,38 +850,62 @@ async fn plugin_request(state: &AppState, p: &mut Pipes, text: &str) -> Result plugged, Err(refused) => { crate::plugin::request::record(state, p.id, &refused.runs); - return Err(refused.why); + return Err(Refusal::Cut(refused.why)); } }; crate::plugin::request::record(state, p.id, &plugged.runs); - let sent = plugged - .changed - .as_ref() - .and_then(|c| c.renamed.as_ref()) - .map_or_else(|| requested.clone(), |r| r.model.clone()); p.bridge = plugged.bridge; - let out = match plugged.changed { - None => text.to_string(), - Some(c) => screen_changed( - state, - p, - text, - String::from_utf8_lossy(&c.body).into_owned(), - )?, + let Some(c) = plugged.changed else { + return Ok((text.to_string(), sent)); }; - p.rename = - crate::answer_model::Rename::new(&requested, &sent).map(crate::answer_model::Rename::body); - p.requested_model = requested; - p.sent_model = sent; - Ok(out) + let sent = match &c.renamed { + None => sent, + Some(r) => p + .naming + .as_ref() + .and_then(|n| n.resolve(catalog, &r.model)) + .ok_or_else(|| { + // 和 HTTP 那条路同一句(`server::pipeline::plug` 的 `sent_name`):这条路只有 + // 一跳,「不发往那一家」就是这一帧不发 + Refusal::Frame(GatewayError::request(msg!( + "gw.plugin.alias_unserved", + plugin = r.by.clone(), model = r.model.clone(), upstream = p.provider.clone() => + "Plugin `{plugin}` changed the model to the alias {model}, and upstream \ + `{upstream}` offers none of its models, so the request was not sent there." + ))) + })?, + }; + let out = screen_changed( + state, + p, + text, + String::from_utf8_lossy(&c.body).into_owned(), + ) + .map_err(Refusal::Cut)?; + Ok((out, sent)) +} + +/// 这一帧的模型名写成 `model`。已经是它(或者 `model` 是空的)就一个字节都不动 +fn with_model(text: String, model: &str) -> String { + if model.is_empty() { + return text; + } + let Ok(mut v) = serde_json::from_str::(&text) else { + return text; + }; + if v.get("model").and_then(|m| m.as_str()) == Some(model) { + return text; + } + tw_dialect::params::set_model(tw_dialect::ir::Dialect::Responses, &mut v, model); + v.to_string() } /// 插件改过的那一帧再查一遍内容过滤:**只报插件加进来的**(见 [`crate::guard::rescreen`])。 @@ -769,10 +986,10 @@ fn response_id(frame: &str) -> Option { v.pointer("/response/id")?.as_str().map(str::to_string) } -/// 替被切掉的那次回答发的 `response.failed`:和 SSE 那条路同一个形状 -/// (`tw_dialect` 的错误帧),id 换成这次回答的 -fn failed_frame(why: Msg, response: Option<&str>) -> String { - let sse = crate::error::GatewayError::denied(why) +/// 替被切掉的那次回答(或者没发出去的那一帧)发的 `response.failed`:和 SSE 那条路同一个 +/// 形状(`tw_dialect` 的错误帧),id 换成这次回答的。没发出去的那一帧没有回答,id 是新的 +fn failed_frame(err: GatewayError, response: Option<&str>) -> String { + let sse = err .in_dialect(tw_dialect::ir::Dialect::Responses) .sse_frame(); let Some(mut v) = tw_dialect::frame::parse(sse.as_bytes()) diff --git a/crates/tw-gateway/tests/alias_ws.rs b/crates/tw-gateway/tests/alias_ws.rs new file mode 100644 index 00000000..774e8eaa --- /dev/null +++ b/crates/tw-gateway/tests/alias_ws.rs @@ -0,0 +1,416 @@ +//! WebSocket 那条路上发出去的模型名:别名、指定模型、规则改写、插件改名,和 HTTP 那条路的 +//! 一跳同一套(见 `tw_gateway::ws::Naming`)。回答里写着的、和发出去的是同一个模型的名称 +//! 写回客户端用的那个。 +//! +//! 起真网关、真上游:上游记下收到的每一帧,回答里的模型名是收到的那个的带日期快照(上游 +//! 常这么写)。上游不给模型清单,名字靠启用范围(`models_only`)区分 —— 没有清单的上游当作 +//! 什么都有,别名对到它启用范围里的第一个名称。 + +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::Router; +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use futures::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::sync::broadcast::Receiver; +use tokio_tungstenite::tungstenite::Message as WsMsg; +use tw_api::{Event, Permission}; +use tw_config::{Client, Config, Provider}; +use tw_gateway::plugin::host::double::{self, Double}; +use tw_gateway::plugin::{Active, Invocation, PluginSet, RequestOutcome}; + +/// 假上游:记下收到的每一帧,每个 `response.create` 回一次完整的回答 +async fn upstream() -> (SocketAddr, Arc>>) { + let seen: Arc>> = Arc::default(); + let app = Router::new() + .route( + "/backend-api/codex/responses", + axum::routing::any( + |State(seen): State>>>, ws: WebSocketUpgrade| async move { + ws.on_upgrade(move |sock| answer(sock, seen)) + }, + ), + ) + .with_state(seen.clone()); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(l, app).await.unwrap() }); + (addr, seen) +} + +async fn answer(mut sock: WebSocket, seen: Arc>>) { + while let Some(Ok(m)) = sock.recv().await { + let Message::Text(t) = m else { continue }; + let v: Value = serde_json::from_str(&t).unwrap_or(Value::Null); + let model = format!("{}-2026-09-30", v["model"].as_str().unwrap_or_default()); + let n = { + let mut s = seen.lock().unwrap(); + s.push(v); + s.len() + }; + let id = format!("resp_{n}"); + let frames = [ + json!({"type":"response.created","sequence_number":0, + "response":{"id":id,"status":"in_progress","model":model,"output":[]}}), + json!({"type":"response.output_text.delta","sequence_number":1, + "output_index":0,"content_index":0,"item_id":"msg","delta":"hello"}), + json!({"type":"response.completed","sequence_number":2, + "response":{"id":id,"status":"completed","model":model,"output":[]}}), + ]; + for f in frames { + if sock + .send(Message::Text(f.to_string().into())) + .await + .is_err() + { + return; + } + } + } +} + +/// 一个上游,只用 `scope` 里的模型(空的就是全部) +fn provider(up: SocketAddr, scope: &[&str]) -> Provider { + Provider { + name: "up".into(), + base_url: format!("http://{up}"), + key: Some("sk-upstream".into()), + protocol: Some(tw_config::Protocol::OpenaiResponses), + models_only: (!scope.is_empty()).then(|| scope.iter().map(|s| s.to_string()).collect()), + ..Default::default() + } +} + +/// `aliases` 是别名表、`rules` 是默认路由的规则(YAML;空的就是不写) +fn config(p: Provider, aliases: &str, rules: &str) -> Config { + let mut c = Config { + version: 1, + clients: vec![Client { + name: "codex".into(), + key: "tw-wskey".into(), + ..Default::default() + }], + providers: vec![p], + aliases: if aliases.is_empty() { + Default::default() + } else { + serde_yaml_ng::from_str(aliases).unwrap() + }, + ..Default::default() + }; + if !rules.is_empty() { + c.routes = vec![tw_engine::RouteSet::default_with( + serde_yaml_ng::from_str(rules).unwrap(), + )]; + } + c.engine().validate().unwrap(); + c +} + +/// 起网关。**先订阅事件再起服务** —— 之后才订的话,早到的事件就看不见了 +async fn gateway(cfg: Config, plugins: Vec>) -> (SocketAddr, Receiver) { + let state = tw_gateway::AppState::new(cfg).unwrap(); + if !plugins.is_empty() { + state.swap_plugins(PluginSet::new(plugins)); + } + let events = state.bus.subscribe(); + let addr = tw_gateway::serve(state, ([127, 0, 0, 1], 0).into()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(40)).await; + (addr, events) +} + +type Socket = + tokio_tungstenite::WebSocketStream>; + +async fn connect(gw: SocketAddr) -> Socket { + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let mut req = format!("ws://{gw}/backend-api/codex/responses") + .into_client_request() + .unwrap(); + req.headers_mut() + .insert("x-api-key", "tw-wskey".parse().unwrap()); + tokio_tungstenite::connect_async(req).await.unwrap().0 +} + +/// 一帧 `response.create`,要的是 `model` +fn create(model: &str) -> WsMsg { + WsMsg::Text( + json!({ + "type": "response.create", + "model": model, + "instructions": "You are Codex.", + "input": [{ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "hi" }] }], + "stream": true + }) + .to_string() + .into(), + ) +} + +/// 收到这一次回答的结尾为止的每一帧(解成 JSON) +async fn one_answer(c: &mut Socket) -> Vec { + let mut out = Vec::new(); + while let Ok(Some(Ok(m))) = tokio::time::timeout(Duration::from_secs(3), c.next()).await { + let WsMsg::Text(t) = m else { continue }; + let v: Value = serde_json::from_str(&t).unwrap_or_else(|_| json!({ "raw": t.as_str() })); + let end = matches!( + v["type"].as_str(), + Some("response.completed" | "response.failed") + ); + out.push(v); + if end { + break; + } + } + out +} + +/// 回答里写着的模型名(`response.created`、`response.completed` 各一个) +fn answered(frames: &[Value]) -> Vec { + frames + .iter() + .filter_map(|v| v["response"]["model"].as_str().map(str::to_string)) + .collect() +} + +/// 这一次没发出去:替它回的 `response.failed` 里那句话 +fn refusal(frames: &[Value]) -> String { + let last = frames.last().expect("no frame at all"); + assert_eq!(last["type"], "response.failed", "{frames:?}"); + last["response"]["error"]["message"] + .as_str() + .unwrap() + .to_string() +} + +/// 握手后发的路由事件里那一跳记下的模型名 +async fn attempt_model(rx: &mut Receiver) -> Option { + loop { + let ev = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("5 秒内没等到路由事件") + .expect("事件流断了"); + if let Event::RequestRouted { attempts, .. } = ev { + assert_eq!(attempts.len(), 1, "{attempts:?}"); + return attempts[0].model.clone(); + } + } +} + +/// `codex-fast` 在这一家叫 `gpt-5.1-codex-mini`(列表里头一个名字不在它的启用范围里) +const FAST: &str = "codex-fast: [gpt-6-fast, gpt-5.1-codex-mini]\n"; +/// 只在 Claude 那几家有的别名 +const SONNET: &str = "claude-sonnet: [claude-sonnet-5, us.anthropic.claude-sonnet-5-v1:0]\n"; +const SCOPE: &[&str] = &["gpt-5.1-*", "codex-fast", "special"]; + +/// 别名:发给这一家的是它自己的名称,不是别名本身;回答里写回别名。不是别名的照旧原样, +/// 回答也原样 +#[tokio::test] +async fn an_alias_goes_out_as_the_upstreams_own_name_and_the_answer_shows_the_alias() { + let (up, seen) = upstream().await; + let (gw, mut events) = gateway(config(provider(up, SCOPE), FAST, ""), vec![]).await; + let mut c = connect(gw).await; + // 别名要看每一帧写的是什么:升级时说不上来 + assert_eq!(attempt_model(&mut events).await, None); + + c.send(create("codex-fast")).await.unwrap(); + let frames = one_answer(&mut c).await; + let sent = seen.lock().unwrap()[0].clone(); + assert_eq!(sent["model"], "gpt-5.1-codex-mini"); + // 别的字段原样 + assert_eq!(sent["type"], "response.create"); + assert_eq!(sent["instructions"], "You are Codex."); + assert_eq!( + answered(&frames), + ["codex-fast", "codex-fast"], + "{frames:?}" + ); + + c.send(create("gpt-5.1-codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(seen.lock().unwrap()[1]["model"], "gpt-5.1-codex"); + assert_eq!( + answered(&frames), + ["gpt-5.1-codex-2026-09-30", "gpt-5.1-codex-2026-09-30"] + ); +} + +/// 只按客户端认的规则指定了模型:升级时选中那一家,每一帧发的是指定的名字(不是这一帧 +/// 写的),尝试链上记它;回答里写回客户端要的 +#[tokio::test] +async fn a_rule_that_pins_a_model_for_the_client_sends_the_pinned_name() { + let (up, seen) = upstream().await; + let rules = " +- { name: Codex 指定模型, when: { client: codex }, to: [{ provider: up, model: gpt-5.1-codex-max }] } +- { name: 兜底, to: __all__ } +"; + let (gw, mut events) = gateway(config(provider(up, &[]), FAST, rules), vec![]).await; + let mut c = connect(gw).await; + assert_eq!( + attempt_model(&mut events).await.as_deref(), + Some("gpt-5.1-codex-max") + ); + // 指定的原样发,写的是别名也不对 + for asked in ["gpt-5.1-codex", "codex-fast"] { + c.send(create(asked)).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + seen.lock().unwrap().last().unwrap()["model"], + "gpt-5.1-codex-max" + ); + assert_eq!(answered(&frames), [asked, asked], "{frames:?}"); + } +} + +/// 阶段一改写成别名:按别名表对到这一家,尝试链上记对到的名字。阶段二改的名字原样发 +/// (名字恰好是别名也一样)。阶段二拒绝的只是那一帧,连接照常 +#[tokio::test] +async fn a_rule_rewrite_to_an_alias_is_resolved_and_a_phase_two_name_goes_out_as_written() { + let (up, seen) = upstream().await; + let rules = " +- { name: Codex 用别名, when: { client: codex }, set: { model: codex-fast } } +- { name: 阶段二改名, when: { provider_would_be: up, model: special }, set: { model: codex-fast } } +- { name: 不给, when: { provider_would_be: up, model: banned }, deny: 这个模型不走这里 } +- { name: 兜底, to: __all__ } +"; + let (gw, mut events) = gateway(config(provider(up, SCOPE), FAST, rules), vec![]).await; + let mut c = connect(gw).await; + assert_eq!( + attempt_model(&mut events).await.as_deref(), + Some("gpt-5.1-codex-mini") + ); + + c.send(create("gpt-5.1-codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(seen.lock().unwrap()[0]["model"], "gpt-5.1-codex-mini"); + assert_eq!(answered(&frames), ["gpt-5.1-codex", "gpt-5.1-codex"]); + + c.send(create("special")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(seen.lock().unwrap()[1]["model"], "codex-fast"); + assert_eq!(answered(&frames), ["special", "special"]); + + c.send(create("banned")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] Rule `不给` denied this request: 这个模型不走这里" + ); + assert_eq!(seen.lock().unwrap().len(), 2); + + c.send(create("gpt-5.1-codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(answered(&frames), ["gpt-5.1-codex", "gpt-5.1-codex"]); + assert_eq!(seen.lock().unwrap().len(), 3); +} + +/// 这一家服务不了要的别名:这一帧不发,替它回一个说清楚的 `response.failed`;连接照常, +/// 下一帧要的是这一家有的就照发。规则改写成的别名一样,那句话说出是规则改的 +#[tokio::test] +async fn an_alias_the_upstream_cannot_serve_fails_that_request_and_the_connection_stays() { + let (up, seen) = upstream().await; + let aliases = format!("{FAST}{SONNET}"); + let (gw, _) = gateway(config(provider(up, SCOPE), &aliases, ""), vec![]).await; + let mut c = connect(gw).await; + c.send(create("claude-sonnet")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] This WebSocket connection goes to upstream `up`, which offers none of the \ + models of alias claude-sonnet (claude-sonnet-5, us.anthropic.claude-sonnet-5-v1:0), so \ + the request was not sent." + ); + assert!( + seen.lock().unwrap().is_empty(), + "{:?}", + seen.lock().unwrap() + ); + + c.send(create("codex-fast")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(answered(&frames), ["codex-fast", "codex-fast"]); + assert_eq!(seen.lock().unwrap()[0]["model"], "gpt-5.1-codex-mini"); + + // 阶段一的规则把模型改写成了这一家服务不了的别名 + let (up, seen) = upstream().await; + let rules = " +- { name: Codex 用 Sonnet, when: { client: codex }, set: { model: claude-sonnet } } +- { name: 兜底, to: __all__ } +"; + let (gw, mut events) = gateway(config(provider(up, SCOPE), &aliases, rules), vec![]).await; + let mut c = connect(gw).await; + assert_eq!(attempt_model(&mut events).await, None); + c.send(create("gpt-5.1-codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] A routing rule rewrote model gpt-5.1-codex to alias claude-sonnet. This \ + WebSocket connection goes to upstream `up`, which offers none of its models \ + (claude-sonnet-5, us.anthropic.claude-sonnet-5-v1:0), so the request was not sent." + ); + assert!(seen.lock().unwrap().is_empty()); +} + +/// 插件把模型名换成别名:发给这一家的是它自己的名称,回答里写回客户端要的。插件的 +/// `ctx.model` 是发给这一家的名字。换成这一家服务不了的别名:这一帧不发,连接照常 +#[tokio::test] +async fn a_plugin_renaming_to_an_alias_sends_the_upstreams_own_name() { + let (up, seen) = upstream().await; + let ctx_models: Arc>> = Arc::default(); + let cm = ctx_models.clone(); + let swap = Double::new("swap") + .permit(&[Permission::Params]) + .on_request(move |mut view, ctx| { + cm.lock() + .unwrap() + .push(ctx["model"].as_str().unwrap_or_default().to_string()); + match ctx["requested_model"].as_str() { + Some("codex") => view["params"]["model"] = json!("codex-fast"), + Some("sonnet-please") => view["params"]["model"] = json!("claude-sonnet"), + _ => {} + } + Invocation::ok(RequestOutcome::Changed(view)) + }); + let mut a = double::active("swap", swap); + a.name = "Plugin swap".into(); + let aliases = format!("{FAST}{SONNET}"); + let (gw, _) = gateway( + config( + provider(up, &["gpt-5.1-*", "codex", "sonnet-please"]), + &aliases, + "", + ), + vec![Arc::new(a)], + ) + .await; + let mut c = connect(gw).await; + + c.send(create("codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(seen.lock().unwrap()[0]["model"], "gpt-5.1-codex-mini"); + assert_eq!(answered(&frames), ["codex", "codex"], "{frames:?}"); + + c.send(create("sonnet-please")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] Plugin `Plugin swap` changed the model to the alias claude-sonnet, and \ + upstream `up` offers none of its models, so the request was not sent there." + ); + assert_eq!(seen.lock().unwrap().len(), 1); + + // 客户端自己要别名:插件看到的 `ctx.model` 是这一家的名称 + c.send(create("codex-fast")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(seen.lock().unwrap()[1]["model"], "gpt-5.1-codex-mini"); + assert_eq!(answered(&frames), ["codex-fast", "codex-fast"]); + assert_eq!( + ctx_models.lock().unwrap().as_slice(), + ["codex", "sonnet-please", "gpt-5.1-codex-mini"] + ); +} From c52548fda2a33acec83ea56196eb928fa42767c2 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:01:59 +0800 Subject: [PATCH 14/18] Model aliases: judge a key's allow per candidate, and phase-two renames as written - Failover no longer reaches a candidate whose model the key may not use. Admission lets a request through when any candidate's model is allowed; each candidate is now checked again when routing and on every hop, and one the key's allow does not cover is skipped as `not_allowed`. Pinned models and phase-two renames are matched against `allow` as written; names on the client side (including aliases) keep the inheritance from upstream model names. - Admission takes the origin-tagged candidates (`Engine::asked`), so a phase-two rename is judged like a pinned model: by the upstream's own list and the name itself, not through the alias table. A rename that happens to share an alias's name is admitted when that upstream lists it. - The key's allow is enforced at admission even while no upstream has listed its models, with the usual "may not use" message. - A plugin that renames the model to an alias gets the same allow inheritance as a request for that alias (`Catalog::allows` is public). - `ServeSkip::NotAllowed` (`not_allowed`): dry runs given a key list the candidates skipped for it. Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/src/lib.rs | 8 +- crates/tw-control/src/dryrun.rs | 7 +- crates/tw-control/tests/dryrun.rs | 40 ++++ crates/tw-engine/src/catalog.rs | 5 +- crates/tw-gateway/src/models.rs | 104 +++++++++- crates/tw-gateway/src/sent.rs | 131 +++++++++++-- crates/tw-gateway/src/server/pipeline.rs | 177 +++++++++++------- crates/tw-gateway/src/server/pipeline/hop.rs | 14 +- crates/tw-gateway/src/server/pipeline/plug.rs | 45 +++-- crates/tw-gateway/src/wire.rs | 1 + crates/tw-gateway/tests/alias_catalog.rs | 44 +++++ crates/tw-gateway/tests/alias_routing.rs | 81 ++++++++ crates/tw-gateway/tests/plugins_request.rs | 47 +++++ 13 files changed, 588 insertions(+), 116 deletions(-) diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index 4eb7f608..089a4161 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -455,6 +455,9 @@ slug_enum! { OutOfScope = "out_of_scope", /// 模型清单里没有 NotOffered = "not_offered", + /// 发给它的名字这把密钥不让用(`allow`):指定模型、阶段二改的名字一家一个, + /// 只在试算给了密钥时出现 + NotAllowed = "not_allowed", } } @@ -747,7 +750,8 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// ([`KnownModel::aliases`]),上游模型清单的每一行也带([`ModelRow::aliases`])。规则的 /// 去向可以是指定模型:[`RuleView::to`]、[`RuleInput::to`] 是 [`RuleTarget`],字符串或 /// `[{provider, model}]` 的列表,和配置里的 `to` 一个写法。试算多了 `candidate_models`:每个 -/// 候选发出的模型名,以及它是别名、规则改写还是指定模型来的。发出的模型名和客户端写的不同、 +/// 候选发出的模型名,以及它是别名、规则改写还是指定模型来的;给了密钥时,发给哪一家的名字 +/// 这把密钥不让用,那一家跳过(`skipped` 的 `not_allowed`)。发出的模型名和客户端写的不同、 /// 上游答的又是同一个模型时,回答里的模型名(含 `openai-model`、`x-openai-model` 回应头) /// 写成客户端写的名称。照 37 写的界面读不懂列表形状的 `to`,保存规则时会把指定模型丢掉。 pub const CONTROL_API_VERSION: u32 = 38; @@ -4745,7 +4749,7 @@ pub struct ConvertedView { #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct SkippedView { pub provider: String, - /// `disabled` / `out_of_scope` / `not_offered` + /// `disabled` / `out_of_scope` / `not_offered` / `not_allowed` pub reason: ServeSkip, } diff --git a/crates/tw-control/src/dryrun.rs b/crates/tw-control/src/dryrun.rs index 7accd472..f86213b6 100644 --- a/crates/tw-control/src/dryrun.rs +++ b/crates/tw-control/src/dryrun.rs @@ -232,10 +232,13 @@ pub async fn dry_run( // // 每一家按发给它的模型名看:规则改写过的按改写后的算,别名对到各家自己的 // 名称,指定的原样,和数据面同一个函数(`tw_gateway::sent`) + // + // 给了密钥的话,发给哪一家的名字这把密钥不让用,那一家也跳过(和数据面一样) let asked = engine.asked(rules, &f, &d); let catalog = s.gateway.catalog.load(); - let sent = tw_gateway::sent::plan(&rt.config, &catalog, &d, &f.model, &asked); - let serving = tw_gateway::sent::serving(&rt.config, &catalog, &d, &asked); + let allow = tw_gateway::models::key_allow(&rt.config, &f.client); + let sent = tw_gateway::sent::plan(&rt.config, &catalog, &d, &f.model, &asked, allow); + let serving = tw_gateway::sent::serving(&rt.config, &catalog, &d, &asked, allow); out.skipped = serving .skipped .iter() diff --git a/crates/tw-control/tests/dryrun.rs b/crates/tw-control/tests/dryrun.rs index 7d7383f1..cba3300e 100644 --- a/crates/tw-control/tests/dryrun.rs +++ b/crates/tw-control/tests/dryrun.rs @@ -561,3 +561,43 @@ routes: ] ); } + +/// 给了密钥:发给哪一家的名字这把密钥不让用,那一家跳过,说是密钥的事 —— 和数据面一样, +/// 故障转移到不了它。指定的、阶段二改的名字按名字本身看 +#[tokio::test] +async fn a_candidate_whose_model_the_key_may_not_use_is_skipped() { + let cfg = ALIASED.replace( + "clients:\n - name: 我\n key: tw-k\n", + "clients:\n - name: 我\n key: tw-k\n - name: 只用智谱\n key: tw-z\n allow: [glm-*]\n - name: 只用 glm-5\n key: tw-5\n allow: [glm-5]\n", + ); + let (_d, app) = app_with(&cfg); + let skipped = |r: &tw_api::DryRunResult| { + r.skipped + .iter() + .map(|s| (s.provider.clone(), s.reason)) + .collect::>() + }; + + // 指定模型:官方的 claude-opus-5 这把密钥不让用 + let r = run(&app, r#"{"model":"pinned","client":"只用智谱"}"#).await; + assert_eq!(sent(&r), [("智谱", Some("glm-5"), Some("pinned"))]); + assert_eq!( + skipped(&r), + [("官方".to_string(), tw_api::ServeSkip::NotAllowed)] + ); + + // 阶段二给智谱改的 glm-air 原样看:只许 glm-5 的密钥不让用它,别的两家本来就不在范围里 + let r = run(&app, r#"{"model":"glm-5","client":"只用 glm-5"}"#).await; + assert_eq!(r.outcome, tw_api::DryRunOutcome::Unavailable, "{r:?}"); + assert_eq!( + skipped(&r), + [ + ("官方".to_string(), tw_api::ServeSkip::OutOfScope), + ("中转".to_string(), tw_api::ServeSkip::OutOfScope), + ("智谱".to_string(), tw_api::ServeSkip::NotAllowed), + ] + ); + // 不给密钥(按路由试算)不看范围 + let r = run(&app, r#"{"model":"glm-5","route":"default"}"#).await; + assert_eq!(sent(&r), [("智谱", Some("glm-air"), Some("rule"))]); +} diff --git a/crates/tw-engine/src/catalog.rs b/crates/tw-engine/src/catalog.rs index a639c3cb..4bd67062 100644 --- a/crates/tw-engine/src/catalog.rs +++ b/crates/tw-engine/src/catalog.rs @@ -179,7 +179,10 @@ impl Catalog { /// /// **继承只从真名到别名**:写上游的模型名(glob)时,指向它的别名也放行 —— 别名 /// 列表里任一名称匹配就算;写别名时只管别名本身,它列表里的模型不跟着放出来。 - fn allows(&self, name: &str, patterns: &[String]) -> bool { + /// + /// `name` 是**客户端那一侧的名称**:客户端写的、规则改写的、插件改的。原样发出的名字 + /// (指定模型、阶段二改的)不经过别名表,按名字本身对 glob 就是了。 + pub fn allows(&self, name: &str, patterns: &[String]) -> bool { let hit = |m: &str| patterns.iter().any(|p| glob_match(p, m)); hit(name) || self diff --git a/crates/tw-gateway/src/models.rs b/crates/tw-gateway/src/models.rs index 7a4276c5..2473f664 100644 --- a/crates/tw-gateway/src/models.rs +++ b/crates/tw-gateway/src/models.rs @@ -564,6 +564,9 @@ pub enum Skip { OutOfScope, /// 它的模型清单里没有这个模型 NotOffered, + /// 发给它的名字这把密钥不让用(`allow`):指定模型、阶段二改的名字一家一个, + /// 准入放行了别的那几家,故障转移也到不了这一家 + NotAllowed, } impl Skip { @@ -572,6 +575,7 @@ impl Skip { Skip::Disabled => "disabled", Skip::OutOfScope => "out_of_scope", Skip::NotOffered => "not_offered", + Skip::NotAllowed => "not_allowed", } } /// 进给客户端的那句话里。**界面不用它** —— 界面按 `slug` 自己说。 @@ -580,6 +584,7 @@ impl Skip { Skip::Disabled => "disabled", Skip::OutOfScope => "out of scope", Skip::NotOffered => "does not offer this model", + Skip::NotAllowed => "not allowed for this key", } } } @@ -641,6 +646,41 @@ pub fn fit(catalog: &tw_engine::Catalog, p: &tw_config::Provider, model: &str) - } } +/// 密钥 `key` 的模型范围(`allow`)。没有这把密钥、或者它没写 `allow` 时是 `None`:什么都放行。 +pub fn key_allow<'a>(cfg: &'a tw_config::Config, key: &str) -> Option<&'a [String]> { + cfg.clients + .iter() + .find(|c| c.name == key) + .and_then(|c| c.allow.as_deref()) +} + +/// 密钥的 `allow` 放不放行要发给一家的名字 `model`。`allow` 是 `None`(不写)时什么都放行。 +/// +/// - **客户端那一侧的名称**(客户端写的、阶段一改写的、插件改的,可能是别名)按目录的规矩 +/// 看([`tw_engine::Catalog::allows`]):写上游的模型名也放行列了它的别名,和准入、 +/// `/v1/models` 是同一套。 +/// - **原样发出的名字**(`as_written`:指定模型、阶段二改的)按这个名字本身对 glob:它不经过 +/// 别名表,也就没有别名可继承。 +/// +/// **每个候选各看各的**:指定模型、阶段二改的名字一家一个,准入只要有一家过得去就放行, +/// 剩下那几家要在这里拦住 —— 不然故障转移会把请求带到密钥不让用的模型上。 +pub fn allowed( + catalog: &tw_engine::Catalog, + allow: Option<&[String]>, + model: &str, + as_written: bool, +) -> bool { + allow.is_none_or(|patterns| { + if as_written { + patterns + .iter() + .any(|p| tw_engine::rule::glob_match(p, model)) + } else { + catalog.allows(model, patterns) + } + }) +} + /// 发给上游 `p` 时,客户端说的 `name` 叫什么。`None` = 这家服务不了这个别名。 /// /// - `name` 是别名:按列表顺序取第一个**这家能服务**的名称 —— 在它的启用范围里 @@ -714,6 +754,9 @@ pub fn sent_to( /// **每一家按它实际要的那个模型看**,规则改写过的就是改写后的;别名按这家能不能 /// 服务它看,指定模型按指定的那个名字看(见 [`sent_to`])。 /// +/// 密钥的 `allow`([`key_allow`])也一家一家地看([`allowed`]):指定模型按指定的名字, +/// 别的按它要的、客户端那一侧的名称。不让用的跳过([`Skip::NotAllowed`])。 +/// /// **没有模型清单的上游不跳过**:不知道它有什么,不等于它没有。模型是空的 /// (请求体解析不了)时只看停用。 pub fn serving( @@ -721,6 +764,7 @@ pub fn serving( catalog: &tw_engine::Catalog, decision: &tw_engine::Decision, asked: &[(String, String)], + allow: Option<&[String]>, ) -> Serving { let mut out = Serving { asked: asked.to_vec(), @@ -732,10 +776,13 @@ pub fn serving( out.usable.push(name.clone()); continue; }; + let pinned = decision.pinned_model(name); let skip = if p.disabled { Some(Skip::Disabled) } else if model.is_empty() { None + } else if !allowed(catalog, allow, pinned.unwrap_or(model), pinned.is_some()) { + Some(Skip::NotAllowed) } else { sent_to(cfg, catalog, decision, p, model).err() }; @@ -941,7 +988,7 @@ mod tests { assert_eq!(cat.offers("relay", "gpt-x"), Some(true)); let all = ["bedrock", "relay", "openai", "no-list", "scoped"]; - let s = serving(&c, &cat, &decided(&[]), &asked(&all, "gpt-x")); + let s = serving(&c, &cat, &decided(&[]), &asked(&all, "gpt-x"), None); // relay 只有同名的真模型:别名优先,到不了它。没有清单的照样能 assert_eq!(s.usable, ["openai", "no-list"]); assert_eq!( @@ -975,6 +1022,7 @@ mod tests { ("relay".into(), "gpt-x".into()), ("bedrock".into(), "claude-sonnet-5".into()), ], + None, ); assert_eq!(s.usable, ["relay"]); assert_eq!(s.skipped, [("bedrock".to_string(), Skip::NotOffered)]); @@ -982,6 +1030,50 @@ mod tests { sent_to(&c, &cat, &pinned, &c.providers[1], "gpt-x"), Ok("gpt-x".into()) ); + + // 密钥的 allow 一家一家地看。指定的名字原样对 glob:只许 gpt-x-* 的密钥,relay 的 + // gpt-x 不让用,跳过;openai 的 gpt-x-2026 照常 + let narrow = ["gpt-x-*".to_string()]; + let pinned = decided(&[("relay", "gpt-x"), ("openai", "gpt-x-2026")]); + let both: [(String, String); 2] = [ + ("relay".into(), "gpt-x".into()), + ("openai".into(), "gpt-x-2026".into()), + ]; + let s = serving(&c, &cat, &pinned, &both, Some(&narrow)); + assert_eq!(s.usable, ["openai"]); + assert_eq!(s.skipped, [("relay".to_string(), Skip::NotAllowed)]); + // 写别名的密钥只管别名本身:指定的名字恰好和别名同名,不算 + let s = serving(&c, &cat, &pinned, &both, Some(&["gpt-x".to_string()])); + assert_eq!(s.usable, ["relay"]); + assert_eq!(s.skipped, [("openai".to_string(), Skip::NotAllowed)]); + // 按名称要别名:写上游模型名的密钥继承到别名(gpt-x 列了 gpt-x-2026) + let s = serving( + &c, + &cat, + &decided(&[]), + &asked(&["openai"], "gpt-x"), + Some(&narrow), + ); + assert_eq!(s.usable, ["openai"]); + let s = serving( + &c, + &cat, + &decided(&[]), + &asked(&["openai"], "gpt-x"), + Some(&["claude-*".to_string()]), + ); + assert_eq!(s.skipped, [("openai".to_string(), Skip::NotAllowed)]); + // 停用的照旧说停用:那一条更要紧 + let mut off = c.clone(); + off.providers[2].disabled = true; + let s = serving(&off, &cat, &pinned, &both, Some(&["claude-*".to_string()])); + assert_eq!( + s.skipped, + [ + ("relay".to_string(), Skip::NotAllowed), + ("openai".to_string(), Skip::Disabled), + ] + ); } fn listed(p: &str, models: &[&str]) -> tw_engine::ProviderModels { @@ -1022,7 +1114,13 @@ mod tests { c.providers[2].models_only = Some(vec!["c-1".into()]); let cat = published(&d, &c); assert_eq!(cat.all(), ["a-1", "a-2", "c-1"]); - let s = serving(&c, &cat, &decided(&[]), &asked(&["a", "b", "c"], "c-2")); + let s = serving( + &c, + &cat, + &decided(&[]), + &asked(&["a", "b", "c"], "c-2"), + None, + ); assert_eq!( s.skipped, [ @@ -1053,7 +1151,7 @@ mod tests { ); let cat = published(&d, &c); // 不知道它们有什么:不跳过 - let s = serving(&c, &cat, &decided(&[]), &asked(&["a", "b"], "m")); + let s = serving(&c, &cat, &decided(&[]), &asked(&["a", "b"], "m"), None); assert_eq!(s.usable, ["a", "b"]); // 失败的一小时后重问,没有接口的一天后 let hour = RETRY_AFTER.as_millis() as u64; diff --git a/crates/tw-gateway/src/sent.rs b/crates/tw-gateway/src/sent.rs index aa11497c..012cc0e9 100644 --- a/crates/tw-gateway/src/sent.rs +++ b/crates/tw-gateway/src/sent.rs @@ -7,6 +7,10 @@ //! //! **路由(跳过服务不了的、按价钱排序、看上下文窗口)、每一跳、试算用的是同一份**:试算 //! 说发给 Bedrock 的是哪个名字,真实转发发的就是那个。 +//! +//! 密钥的模型范围(`allow`)也在这里一家一家地看([`crate::models::allowed`]):指定模型、 +//! 阶段二改的名字一家一个,准入只要有一家过得去就放行,不让用的那几家要在这里跳过 —— +//! 故障转移到不了它们。 use tw_engine::{Asked, Catalog, Decision, Origin, Pinned}; @@ -48,19 +52,25 @@ impl Via { /// 发给 `p` 的名字;这家服务不了时是为什么。 /// -/// 客户端那一侧的名称和指定模型交给 [`crate::models::sent_to`]:别名对到这一家,指定的原样, -/// 再看这一家有没有。**阶段二改的名字原样看**,和指定模型一样 —— `sent_to` 只认得 -/// 指定模型。要的是空的(请求体解不开):什么都不改,不判断。 +/// 先看密钥让不让用它要的名字(`allow`,见 [`crate::models::allowed`]):客户端那一侧的 +/// 名称按目录的规矩继承,指定的、阶段二改的按名字本身。再把客户端那一侧的名称和指定模型 +/// 交给 [`crate::models::sent_to`]:别名对到这一家,指定的原样,再看这一家有没有。**阶段二 +/// 改的名字原样看**,和指定模型一样 —— `sent_to` 只认得指定模型。要的是空的(请求体解 +/// 不开):什么都不改,不判断。 pub fn name( cfg: &tw_config::Config, catalog: &Catalog, decision: &Decision, p: &tw_config::Provider, asked: &Asked, + allow: Option<&[String]>, ) -> Result { if asked.model.is_empty() { return Ok(String::new()); } + if !crate::models::allowed(catalog, allow, &asked.model, asked.origin.as_written()) { + return Err(Skip::NotAllowed); + } match asked.origin { Origin::PhaseTwo => match crate::models::fit(catalog, p, &asked.model) { Some(skip) => Err(skip), @@ -83,7 +93,8 @@ pub fn via(client: &str, asked: &Asked, sent: &str) -> Option { }) } -/// 每个候选发出去的名字,和 `asked` 一一对应。`client` 是客户端写的模型名。 +/// 每个候选发出去的名字,和 `asked` 一一对应。`client` 是客户端写的模型名,`allow` 是 +/// 这把密钥的模型范围([`crate::models::key_allow`])。 /// /// 配置里没有的候选按要的名字算:尝试那一步会报出它不在配置里。 pub fn plan( @@ -92,12 +103,13 @@ pub fn plan( decision: &Decision, client: &str, asked: &[Asked], + allow: Option<&[String]>, ) -> Vec { asked .iter() .map(|a| { let model = match cfg.providers.iter().find(|p| p.name == a.provider) { - Some(p) => name(cfg, catalog, decision, p, a), + Some(p) => name(cfg, catalog, decision, p, a, allow), None => Ok(a.model.clone()), }; let via = model.as_deref().ok().and_then(|m| via(client, a, m)); @@ -123,7 +135,8 @@ pub fn pairs(sent: &[Sent]) -> Vec<(String, String)> { } /// 去掉服务不了这个请求的候选([`crate::models::serving`]),**按发出去的名字看**:别名按 -/// 这一家对到的那个名字,指定模型和阶段二改的名字原样。 +/// 这一家对到的那个名字,指定模型和阶段二改的名字原样。密钥不让用的也去掉(`allow`,和 +/// [`name`] 同一套)。 /// /// 阶段二改的名字当成指定给这一家的模型交给它:两者都是写给这一家、原样发出的名字。 pub fn serving( @@ -131,13 +144,14 @@ pub fn serving( catalog: &Catalog, decision: &Decision, asked: &[Asked], + allow: Option<&[String]>, ) -> Serving { let pairs: Vec<(String, String)> = asked .iter() .map(|a| (a.provider.clone(), a.model.clone())) .collect(); if !asked.iter().any(|a| a.origin == Origin::PhaseTwo) { - return crate::models::serving(cfg, catalog, decision, &pairs); + return crate::models::serving(cfg, catalog, decision, &pairs, allow); } let mut d = decision.clone(); d.pinned.extend( @@ -149,7 +163,7 @@ pub fn serving( model: a.model.clone(), }), ); - crate::models::serving(cfg, catalog, &d, &pairs) + crate::models::serving(cfg, catalog, &d, &pairs, allow) } #[cfg(test)] @@ -218,7 +232,7 @@ mod tests { asked("official", "opus", Origin::Client), asked("zhipu", "opus", Origin::Client), ]; - let sent = plan(&c, &catalog, &d, "opus", &asks); + let sent = plan(&c, &catalog, &d, "opus", &asks, None); let got: Vec<_> = sent .iter() .map(|s| (s.model.as_deref().map_err(|e| *e), s.via)) @@ -234,11 +248,18 @@ mod tests { ); // 排价钱时给它要的名字:反正会被跳过 assert_eq!(pairs(&sent)[2], ("zhipu".to_string(), "opus".to_string())); - let s = serving(&c, &catalog, &d, &asks); + let s = serving(&c, &catalog, &d, &asks, None); assert_eq!(s.usable, ["bedrock", "official"]); assert_eq!(s.skipped, [("zhipu".to_string(), Skip::OutOfScope)]); // 要的是空的(请求体解不开):不改,也不判断 - let blank = plan(&c, &catalog, &d, "", &[asked("zhipu", "", Origin::Client)]); + let blank = plan( + &c, + &catalog, + &d, + "", + &[asked("zhipu", "", Origin::Client)], + None, + ); assert_eq!(blank[0].model, Ok(String::new())); } @@ -259,6 +280,7 @@ mod tests { asked("zhipu", "glm-air", Origin::PhaseTwo), asked("zhipu", "glm-air", Origin::Rule), ], + None, ); let got: Vec<_> = sent .iter() @@ -282,6 +304,7 @@ mod tests { &d, "x", &[asked("zhipu", "glm-air", Origin::Pinned)], + None, ); assert_eq!(one[0].model.as_deref(), Ok("glm-air")); // 发出的和客户端写的一样:没有来历可说 @@ -291,6 +314,7 @@ mod tests { &decided(&[]), "claude-opus-5", &[asked("official", "claude-opus-5", Origin::Rule)], + None, ); assert_eq!(same[0].via, None); } @@ -317,6 +341,7 @@ mod tests { &listed, &d, &[asked("zhipu", "glm-air", Origin::PhaseTwo)], + None, ); assert_eq!(s.usable, ["zhipu"]); let s = serving( @@ -324,6 +349,7 @@ mod tests { &listed, &d, &[asked("zhipu", "glm-air", Origin::Client)], + None, ); assert_eq!(s.skipped, [("zhipu".to_string(), Skip::NotOffered)]); let sent = plan( @@ -332,7 +358,90 @@ mod tests { &d, "x", &[asked("zhipu", "glm-air", Origin::PhaseTwo)], + None, ); assert_eq!(sent[0].model.as_deref(), Ok("glm-air")); } + + /// 密钥的 allow 一家一家地看,和准入同一套:客户端那一侧的名称继承(写上游模型名的放行 + /// 列了它的别名),原样发出的名字(指定的、阶段二改的)按名字本身对 glob + #[test] + fn the_keys_allow_is_judged_per_candidate_by_the_name_it_asks_for() { + let c = cfg(); + let catalog = Catalog::default().with_aliases( + c.aliases + .iter() + .map(|a| (a.name.as_str(), a.models.as_slice())), + ); + let d = decided(&[("bedrock", "us.anthropic.claude-opus-5-v1:0")]); + let glm5 = ["glm-5-*".to_string()]; + let skipped = |a: Asked, allow: &[String]| { + let p = c.providers.iter().find(|p| p.name == a.provider).unwrap(); + name(&c, &catalog, &d, p, &a, Some(allow)).err() + }; + // 别名 glm-air 列了 glm-5-air-0414:写 glm-5-* 的密钥按名称要它放行 + assert_eq!( + skipped(asked("zhipu", "glm-air", Origin::Client), &glm5), + None + ); + assert_eq!( + skipped(asked("zhipu", "glm-air", Origin::Rule), &glm5), + None + ); + // 阶段二写的 glm-air 原样发出、不是别名,不继承 + assert_eq!( + skipped(asked("zhipu", "glm-air", Origin::PhaseTwo), &glm5), + Some(Skip::NotAllowed) + ); + assert_eq!( + skipped( + asked("zhipu", "glm-air", Origin::PhaseTwo), + &["glm-air".to_string()] + ), + None + ); + // 指定的名字按它本身看 + let pinned = asked("bedrock", "us.anthropic.claude-opus-5-v1:0", Origin::Pinned); + assert_eq!( + skipped(pinned.clone(), &["claude-*".to_string()]), + Some(Skip::NotAllowed) + ); + assert_eq!(skipped(pinned, &["us.anthropic.*".to_string()]), None); + + // 试算、比价用的那一份:不让用的那一家说得出为什么,没有来历 + let sent = plan( + &c, + &catalog, + &d, + "claude-opus-5", + &[ + asked("bedrock", "us.anthropic.claude-opus-5-v1:0", Origin::Pinned), + asked("official", "opus", Origin::Client), + ], + Some(&["claude-*".to_string()]), + ); + assert_eq!(sent[0].model, Err(Skip::NotAllowed)); + assert_eq!(sent[0].via, None); + // 别名 opus 列了 claude-opus-5:继承 + assert_eq!(sent[1].model.as_deref(), Ok("claude-opus-5")); + let s = serving( + &c, + &catalog, + &d, + &[ + asked("bedrock", "us.anthropic.claude-opus-5-v1:0", Origin::Pinned), + asked("zhipu", "glm-air", Origin::PhaseTwo), + asked("official", "opus", Origin::Client), + ], + Some(&["claude-*".to_string()]), + ); + assert_eq!(s.usable, ["official"]); + assert_eq!( + s.skipped, + [ + ("bedrock".to_string(), Skip::NotAllowed), + ("zhipu".to_string(), Skip::NotAllowed), + ] + ); + } } diff --git a/crates/tw-gateway/src/server/pipeline.rs b/crates/tw-gateway/src/server/pipeline.rs index c2442479..d1e82006 100644 --- a/crates/tw-gateway/src/server/pipeline.rs +++ b/crates/tw-gateway/src/server/pipeline.rs @@ -368,15 +368,23 @@ fn outgrown( let asked = rt .engine .asked(rt.engine.rules_for_client(&req.client_name), facts, d); - crate::sent::plan(&rt.config, &state.catalog.load(), d, &facts.model, &asked) - .iter() - .filter_map(|s| { - book.resolve_for(&s.provider, s.model.as_deref().ok()?)? - .price - .max_input_tokens - }) - .min() - .is_some_and(|limit| facts.input_tokens.saturating_mul(100) >= limit.saturating_mul(95)) + let allow = crate::models::key_allow(&rt.config, &req.client_name); + crate::sent::plan( + &rt.config, + &state.catalog.load(), + d, + &facts.model, + &asked, + allow, + ) + .iter() + .filter_map(|s| { + book.resolve_for(&s.provider, s.model.as_deref().ok()?)? + .price + .max_input_tokens + }) + .min() + .is_some_and(|limit| facts.input_tokens.saturating_mul(100) >= limit.saturating_mul(95)) } /// 管线第 2 步的中段:模型准入。**和 `GET /v1/models` 共用同一个函数** @@ -384,35 +392,37 @@ fn outgrown( /// /// **看的是每个候选实际要的模型,不是客户端写的那个**,所以放在规则做完决定之后: /// 一条把 `claude-*` 改成 `glm-*` 的规则,请求里的名字哪一家的清单里都没有, -/// 改写后的才有。`asked` 里有一个过得去就放行 —— 哪一家服务不了,由下一步 -/// 把它跳过。 +/// 改写后的才有。`asked` 里有一个过得去就放行 —— 哪一家服务不了、哪一家要的名字 +/// 密钥不让用,由下一步把它跳过(见 [`crate::sent::serving`])。 +/// +/// 每个候选要的名字按它的来历看([`tw_engine::Origin`]): /// -/// - 要的是别名:按别名在目录里的样子看 —— 有清单的上游里有谁服务它,`allow` 写它 -/// 的名称、或者写它列表里任一模型名都算放行(继承只从真名到别名)。 -/// - 指定模型的候选:看这一家有没有指定的那个名字(没有清单的当作有,和挑候选时 -/// 一样),`allow` 按这个名字看。 +/// - 客户端那一侧的名称(客户端写的、阶段一改写的,可能是别名):按目录看 —— 有清单的 +/// 上游里有谁服务它,`allow` 写它的名称、或者写它列表里任一模型名都算放行(继承只从 +/// 真名到别名)。 +/// - 原样发出的名字(指定模型、阶段二改的):看这一家自己的清单里有没有这个名字(没有 +/// 清单的当作有,和挑候选时一样),`allow` 按这个名字本身看。**不经过别名表**:阶段二 +/// 给中转站写的 `glm-air` 恰好和一个别名同名,发出去的也是 `glm-air` 本身,按别名看 +/// 就会拿别名的模型列表去问这一家。 /// -/// 目录空着时不拦:那说明探测还没回来或者上游都不给列表,这时候拦 -/// 等于把整个网关关掉。 +/// 目录空着时不看清单:那说明探测还没回来或者上游都不给列表,这时候拦等于把整个网关 +/// 关掉。**密钥的 `allow` 照样看**:它写在配置里,和清单问没问到无关 —— 不看的话, +/// 下一步按密钥跳过候选时,请求会以「没有上游可用」被拒,说不清是密钥的事。 fn admit( state: &AppState, rt: &Runtime, req: &Inbound, reading: &crate::client_api::Reading, decision: &tw_engine::Decision, - asked: &[(String, String)], + asked: &[tw_engine::Asked], ) -> Result<(), GatewayError> { let facts = &reading.facts; - let catalog = state.catalog.load(); - if catalog.is_empty() || facts.model.is_empty() { + if facts.model.is_empty() { return Ok(()); } - let allow = rt - .config - .clients - .iter() - .find(|c| c.name == req.client_name) - .and_then(|c| c.allow.clone()); + let catalog = state.catalog.load(); + let listed = !catalog.is_empty(); + let allow = crate::models::key_allow(&rt.config, &req.client_name); // 数 token 也不挑格式:别的格式的上游由网关本地估算(见 `crate::count`) let any = reading.generates || crate::client_api::ClientApi::counts_tokens(req.uri.path()); let servable = req @@ -420,35 +430,41 @@ fn admit( .map(|a| crate::client_api::slugs(a.servable_by(any))); // 阶段一之后要的模型。规则一家候选都没给时就看它 let model = decision.set.model.as_deref().unwrap_or(&facts.model); - // 按名称要的(别名还是别名),和指定模型的(哪一家、哪个名字)分开看 + // 按名称要的(客户端那一侧,别名还是别名),和原样发出的(指定模型、阶段二改的: + // 哪一家、哪个名字)分开看 let mut named: Vec<&str> = Vec::new(); - let mut pinned: Vec<(&str, &str)> = Vec::new(); - for (c, m) in asked { - match decision.pinned_model(c) { - Some(p) => pinned.push((c, p)), - None => named.push(m), + let mut written: Vec<&tw_engine::Asked> = Vec::new(); + for a in asked { + if a.origin.as_written() { + written.push(a); + } else { + named.push(&a.model); } } - if named.is_empty() && pinned.is_empty() { + if named.is_empty() && written.is_empty() { named.push(model); } - let allowed = |m: &str| { - allow - .as_deref() - .is_none_or(|ps| ps.iter().any(|p| tw_engine::rule::glob_match(p, m))) + let named_ok = |m: &str| { + if listed { + catalog.admits(m, servable.as_deref(), allow) + } else { + crate::models::allowed(&catalog, allow, m, false) + } }; - // 这一家有没有指定的名字。配置里没有这一家的留给尝试那一步报出来 - let offered_at = |p: &str, m: &str| { - rt.config - .providers - .iter() - .find(|x| x.name == p) - .is_none_or(|x| crate::models::fit(&catalog, x, m).is_none()) + // 这一家有没有这个名字。配置里没有这一家的留给尝试那一步报出来 + let offered_at = |a: &tw_engine::Asked| { + !listed + || rt + .config + .providers + .iter() + .find(|x| x.name == a.provider) + .is_none_or(|x| crate::models::fit(&catalog, x, &a.model).is_none()) }; - if named - .iter() - .any(|m| catalog.admits(m, servable.as_deref(), allow.as_deref())) - || pinned.iter().any(|(p, m)| offered_at(p, m) && allowed(m)) + if named.iter().any(|m| named_ok(m)) + || written + .iter() + .any(|a| offered_at(a) && crate::models::allowed(&catalog, allow, &a.model, true)) { return Ok(()); } @@ -456,19 +472,26 @@ fn admit( // 该去改的地方不一样。**改写过的两个名字都要说**:客户端写的是一个, // 报错里说的是另一个,不说清楚像是网关认错了模型 let key = req.client_name.clone(); - let why = if named.is_empty() { + // 原样发出的名字里有上游提供、只是密钥不让用的:要改的是密钥 + let barred = written.iter().find(|a| offered_at(a)); + let why = if named.is_empty() + && written + .iter() + .all(|a| a.origin == tw_engine::Origin::Pinned) + { // 去向是指定模型:说出是哪条规则指定的、指定在哪一家 let rule = decision.matched_rule.clone(); - match pinned.iter().find(|(p, m)| offered_at(p, m)) { - Some((p, m)) => msg!( - "gw.model.pinned_not_allowed", rule = rule, model = m, upstream = p, key = key => + match barred { + Some(a) => msg!( + "gw.model.pinned_not_allowed", rule = rule, model = a.model.clone(), + upstream = a.provider.clone(), key = key => "Rule `{rule}` pins model {model} on `{upstream}`, which gateway key `{key}` may \ not use." ), None => { - let detail = pinned + let detail = written .iter() - .map(|(p, m)| format!("{p} ({m})")) + .map(|a| format!("{} ({})", a.provider, a.model)) .collect::>() .join(", "); msg!( @@ -478,16 +501,27 @@ fn admit( } } } else { - let offered = named.iter().any(|m| !catalog.providers_for(m).is_empty()); - let alias = catalog.alias(model).map(|ms| ms.join(", ")); - match (offered, model != facts.model, alias) { + // 按名称要的有上游提供(目录空着时当作有),那就是密钥不让用 + let named_offered = named + .iter() + .any(|m| !listed || !catalog.providers_for(m).is_empty()); + // 说哪个名字、有没有上游提供它、它是不是别名。按名称要的说不出什么(谁都不提供)时, + // 说阶段二给某一家改的那个:先说有上游提供、密钥不让用的 + let (shown, offered, alias) = match (named_offered, barred, named.is_empty()) { + (true, _, _) => (model, true, catalog.alias(model)), + (false, Some(a), _) => (a.model.as_str(), true, None), + (false, None, false) => (model, false, catalog.alias(model)), + (false, None, true) => (written[0].model.as_str(), false, None), + }; + let alias = alias.map(|ms| ms.join(", ")); + match (offered, shown != facts.model, alias) { (false, false, Some(models)) => msg!( "gw.model.alias_unserved", model = facts.model.clone(), models = models => "No upstream serves alias {model}: none offers any of {models}. GET /v1/models \ lists the models that are available." ), (false, true, Some(models)) => msg!( - "gw.model.alias_unserved_rewritten", from = facts.model.clone(), model = model, + "gw.model.alias_unserved_rewritten", from = facts.model.clone(), model = shown, models = models => "A routing rule rewrote model {from} to alias {model}, and no upstream offers any \ of {models}. GET /v1/models lists the models that are available." @@ -503,12 +537,12 @@ fn admit( models that are available." ), (false, true, None) => msg!( - "gw.model.no_upstream_rewritten", from = facts.model.clone(), model = model => + "gw.model.no_upstream_rewritten", from = facts.model.clone(), model = shown => "A routing rule rewrote model {from} to {model}, and no upstream serves {model}. \ GET /v1/models lists the models that are available." ), (true, true, _) => msg!( - "gw.model.not_allowed_rewritten", from = facts.model.clone(), model = model, + "gw.model.not_allowed_rewritten", from = facts.model.clone(), model = shown, key = key => "A routing rule rewrote model {from} to {model}, which gateway key `{key}` may not \ use. GET /v1/models lists the models that are available." @@ -593,27 +627,26 @@ fn route( stayed: None, }), }; - // 每个候选实际要的模型:规则改写过的按改写后的算,还是客户端那一侧的名称(可能是 - // 别名)。准入看它 + // 每个候选实际要的模型和它的来历:规则改写过的按改写后的算;客户端写的、阶段一改写的 + // 是客户端那一侧的名称(可能是别名),指定的、阶段二改的原样发出。准入看它 let asked = rt.engine.asked( rt.engine.rules_for_client(&req.client_name), facts, &decision, ); - let pairs: Vec<(String, String)> = asked - .iter() - .map(|a| (a.provider.clone(), a.model.clone())) - .collect(); - admit(state, rt, req, reading, &decision, &pairs)?; + admit(state, rt, req, reading, &decision, &asked)?; // 每一家发出去的名字:别名对到各家自己的名称。跳过、比价看它(见 `crate::sent`) let catalog = state.catalog.load(); - let sent = crate::sent::plan(&rt.config, &catalog, &decision, &facts.model, &asked); - // 去掉服务不了这个请求的候选:停用的、范围外的、清单里没有这个模型的、别名对不到的。 - // **在排序之前** —— `cheapest` 和 `url-test` 要在能服务的上游里挑。 + let allow = crate::models::key_allow(&rt.config, &req.client_name); + let sent = crate::sent::plan(&rt.config, &catalog, &decision, &facts.model, &asked, allow); + // 去掉服务不了这个请求的候选:停用的、范围外的、清单里没有这个模型的、别名对不到的, + // 和发给它的名字密钥不让用的。**在排序之前** —— `cheapest` 和 `url-test` 要在能服务的 + // 上游里挑。 // // 不跳过的话,一家没有这个模型的上游排在前面,它回的 404 不触发故障 - // 转移,请求就在一家能服务它的上游旁边失败了。 - let serving = crate::sent::serving(&rt.config, &catalog, &decision, &asked); + // 转移,请求就在一家能服务它的上游旁边失败了;一家指定了密钥不让用的模型的上游 + // 排在后面,前一家失败时请求就被转到了它那里。 + let serving = crate::sent::serving(&rt.config, &catalog, &decision, &asked, allow); let model = decision.set.model.as_deref().unwrap_or(&facts.model); if serving.usable.is_empty() { return Ok(Routed::Refused(choice, serving.explain(model))); diff --git a/crates/tw-gateway/src/server/pipeline/hop.rs b/crates/tw-gateway/src/server/pipeline/hop.rs index dfa4ddb3..dd4cdb1a 100644 --- a/crates/tw-gateway/src/server/pipeline/hop.rs +++ b/crates/tw-gateway/src/server/pipeline/hop.rs @@ -132,6 +132,8 @@ pub(super) async fn try_upstreams<'a>( let mut after_plugins: Option<(Bytes, tw_guard::redact::replace::Ledger)> = None; // 别名对到每一家时看的清单:整个请求用同一份 let catalog = state.catalog.load(); + // 这把密钥的模型范围:每一跳发出的名字都要过它(见 `crate::sent::name`) + let allow = crate::models::key_allow(&rt.config, &req.client_name); for (i, name) in started.alive.iter().enumerate() { // 后面没有别的候选了 @@ -224,10 +226,18 @@ pub(super) async fn try_upstreams<'a>( let asked_model = rt.engine .asked_of(&reading.facts, decision, &provider.name, renamed.as_deref()); - let sent = match crate::sent::name(&rt.config, &catalog, decision, provider, &asked_model) { + let sent = match crate::sent::name( + &rt.config, + &catalog, + decision, + provider, + &asked_model, + allow, + ) { Ok(sent) => sent, // 这一家服务不了(别名列的名字它一个都没有,或者清单里没有这个名字)。路由时已经 - // 跳过了这样的候选,能到这儿说明它的清单刚刚换过:**不把别名原样发给它**,换下一家 + // 跳过了这样的候选,能到这儿说明它的清单刚刚换过:**不把别名原样发给它**,换下一家。 + // 发给它的名字密钥不让用的,路由时也跳过了;这里再看一遍,哪一跳都发不出它 Err(skip) => { let mut serving = crate::models::Serving::default(); serving.skipped.push((provider.name.clone(), skip)); diff --git a/crates/tw-gateway/src/server/pipeline/plug.rs b/crates/tw-gateway/src/server/pipeline/plug.rs index ce484d8d..3c21d3ed 100644 --- a/crates/tw-gateway/src/server/pipeline/plug.rs +++ b/crates/tw-gateway/src/server/pipeline/plug.rs @@ -10,7 +10,8 @@ //! 那一遍一样;处置档下插件加进来的字命中了删除规则的,删掉之后再发; //! - 插件换了发出去的模型名:**密钥的模型范围照样管**。规则改写的模型名要过这一关,插件 //! 改的也要;上游的模型清单不再对(契约附录二)。插件写的是客户端那一侧的名字,**可以是 -//! 别名**:和客户端要的一样,按这一家对上它自己的那个名字([`crate::models::resolve`]) +//! 别名**:和客户端要的一样,范围按目录的规矩看(写上游模型名的密钥也放行列了它的别名, +//! [`crate::models::allowed`]),按这一家对上它自己的那个名字([`crate::models::resolve`]) //! 再发;这一家服务不了这个别名的,这一跳不发、换下一家([`Stop::Hop`]); //! - 出站脱敏接着插件那本账编号:插件写进来的新值拿到新的号,报一条记录; //! - 重新解码:格式转换用改过的这一份。 @@ -107,7 +108,7 @@ pub(super) async fn attempt( // 插件换上的名字发给这一家时叫什么:别名对到这一家自己的那个名字 let renamed = match &c.renamed { Some(r) => { - allowed(rt, req, r)?; + allowed(state, rt, req, r)?; Some(sent_name(state, rt, provider, r)?) } None => None, @@ -304,26 +305,24 @@ fn sent_name( /// 插件换上的模型名,这把密钥用不用得了。**和路由规则改写的模型名过同一关**(见 /// `super::admit` 里的说法):密钥的模型范围管的是发出去的模型,谁改的都一样。 -fn allowed(rt: &Runtime, req: &Inbound, r: &crate::plugin::request::Renamed) -> Result<(), Msg> { - let allow = rt - .config - .clients - .iter() - .find(|c| c.name == req.client_name) - .and_then(|c| c.allow.as_deref()); - match allow { - Some(patterns) - if !patterns - .iter() - .any(|p| tw_engine::rule::glob_match(p, &r.model)) => - { - Err(msg!( - "gw.plugin.model_not_allowed", - plugin = r.by.clone(), model = r.model.clone(), key = req.client_name.clone() => - "Plugin `{plugin}` changed the model to {model}, which gateway key `{key}` may not \ - use, so the request was not sent." - )) - } - _ => Ok(()), +/// +/// 插件写的是客户端那一侧的名字,范围也按那一侧的规矩看([`crate::models::allowed`]): +/// 写 `claude-sonnet-*` 的密钥放行列了 `claude-sonnet-5` 的别名 `sonnet`,和客户端直接要 +/// `sonnet` 时一样。 +fn allowed( + state: &AppState, + rt: &Runtime, + req: &Inbound, + r: &crate::plugin::request::Renamed, +) -> Result<(), Msg> { + let allow = crate::models::key_allow(&rt.config, &req.client_name); + if crate::models::allowed(&state.catalog.load(), allow, &r.model, false) { + return Ok(()); } + Err(msg!( + "gw.plugin.model_not_allowed", + plugin = r.by.clone(), model = r.model.clone(), key = req.client_name.clone() => + "Plugin `{plugin}` changed the model to {model}, which gateway key `{key}` may not \ + use, so the request was not sent." + )) } diff --git a/crates/tw-gateway/src/wire.rs b/crates/tw-gateway/src/wire.rs index cbd2e903..d957680a 100644 --- a/crates/tw-gateway/src/wire.rs +++ b/crates/tw-gateway/src/wire.rs @@ -61,6 +61,7 @@ impl From for tw_api::ServeSkip { Skip::Disabled => Self::Disabled, Skip::OutOfScope => Self::OutOfScope, Skip::NotOffered => Self::NotOffered, + Skip::NotAllowed => Self::NotAllowed, } } } diff --git a/crates/tw-gateway/tests/alias_catalog.rs b/crates/tw-gateway/tests/alias_catalog.rs index cdf8fbf9..5426c9ba 100644 --- a/crates/tw-gateway/tests/alias_catalog.rs +++ b/crates/tw-gateway/tests/alias_catalog.rs @@ -304,3 +304,47 @@ async fn a_pinned_model_is_judged_at_its_own_upstream_and_as_written() { "{body}" ); } + +/// 阶段二给中转站改的名字恰好和一个别名同名:准入看这一家自己的清单里有没有这个名字本身 +/// (中转站列着 glm-air),不按别名看(别名 glm-air 列的 glm-5-air-0414 谁都没有)—— 和 +/// 发出去的一样。密钥的 `allow` 也按名字本身看:写 glm-5-* 的不因为别名列了 glm-5-air-0414 +/// 就放行它 +#[tokio::test] +async fn a_phase_two_rename_is_admitted_as_written_even_when_an_alias_has_its_name() { + let relay = upstream("relay", &["glm-air"]).await; + let mut cfg = Config { + clients: vec![ + client("tw-k", None, None), + client("narrow", Some(&["glm-5-*"]), None), + client("exact", Some(&["glm-air"]), None), + ], + providers: vec![provider("relay", relay)], + ..Default::default() + }; + cfg.aliases = serde_yaml_ng::from_str("glm-air: glm-5-air-0414\n").unwrap(); + cfg.routes = serde_yaml_ng::from_str( + "- name: default + rules: + - { name: 中转的叫法, when: { provider_would_be: relay }, set: { model: glm-air } } + - { name: 兜底, to: relay } +", + ) + .unwrap(); + let gw = serve(cfg).await; + + let (status, body) = ask(gw, "tw-k", "glm-5").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["sent"], "glm-air"); + let (status, body) = ask(gw, "exact", "glm-5").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["sent"], "glm-air"); + + let (status, body) = ask(gw, "narrow", "glm-5").await; + assert_eq!(status, 400, "{body}"); + assert!( + message(&body).contains( + "A routing rule rewrote model glm-5 to glm-air, which gateway key `narrow` may not use" + ), + "{body}" + ); +} diff --git a/crates/tw-gateway/tests/alias_routing.rs b/crates/tw-gateway/tests/alias_routing.rs index 409c9700..2c78be55 100644 --- a/crates/tw-gateway/tests/alias_routing.rs +++ b/crates/tw-gateway/tests/alias_routing.rs @@ -318,3 +318,84 @@ async fn a_condition_on_a_real_name_routes_its_alias() { assert_eq!(body["by"], "official"); assert_eq!(models(&official_seen), ["claude-opus-5"]); } + +/// 错误信息里的那句话 +fn message(body: &serde_json::Value) -> &str { + body["error"]["message"].as_str().unwrap_or_default() +} + +/// 密钥的 `allow` 一家一家地管:指定模型列了两家,密钥只让用 a 的那个名字 —— a 失败了, +/// 故障转移也不去 b,b 什么都收不到。两个都不让用的,准入就拒了,说出是哪条规则指定的 +/// 哪个名字。这些上游都不给清单,密钥照样管 +#[tokio::test] +async fn failover_never_reaches_a_pinned_model_the_key_may_not_use() { + let rules = " +- name: 指定 + to: + - { provider: a, model: m-a } + - { provider: b, model: m-b } +"; + let gateway = |allow: &[&str]| { + let allow: Vec = allow.iter().map(|s| s.to_string()).collect(); + async move { + let (a, a_seen) = upstream("a", 500).await; + let (b, b_seen) = upstream("b", 200).await; + let mut cfg = config( + vec![provider("a", a, &[]), provider("b", b, &[])], + "{}", + rules, + ); + cfg.clients[0].allow = Some(allow); + let (gw, rx) = serve(cfg).await; + (gw, rx, a_seen, b_seen) + } + }; + + let (gw, mut rx, a_seen, b_seen) = gateway(&["m-a"]).await; + let (status, body) = ask(gw, "anything").await; + assert_ne!(status, 200, "{body}"); + assert_eq!(models(&a_seen), ["m-a"]); + assert!(models(&b_seen).is_empty(), "密钥不让用 m-b,却发给了 b"); + assert_eq!(attempts(&mut rx).await, [("a".to_string(), some("m-a"))]); + + // 两个都让用:照常转到 b + let (gw, _rx, _, b_seen) = gateway(&["m-*"]).await; + let (status, body) = ask(gw, "anything").await; + assert_eq!(status, 200, "{body}"); + assert_eq!(models(&b_seen), ["m-b"]); + + // 一个都不让用:准入就拒,哪一家都不发 + let (gw, _rx, a_seen, b_seen) = gateway(&["m-x"]).await; + let (status, body) = ask(gw, "anything").await; + assert_eq!(status, 400, "{body}"); + assert!( + message(&body) + .contains("Rule `指定` pins model m-a on `a`, which gateway key `c` may not use"), + "{body}" + ); + assert!(models(&a_seen).is_empty() && models(&b_seen).is_empty()); +} + +/// 阶段二给某一家改的名字也一家一家地按密钥管,按名字本身对 glob:只许 `m-a` 的密钥,b 被 +/// 改成 `m-b`,a 失败了不转到 b +#[tokio::test] +async fn failover_never_reaches_a_phase_two_rename_the_key_may_not_use() { + let (a, a_seen) = upstream("a", 500).await; + let (b, b_seen) = upstream("b", 200).await; + let mut cfg = config( + vec![provider("a", a, &[]), provider("b", b, &[])], + "{}", + " +- { name: b 的叫法, when: { provider_would_be: b }, set: { model: m-b } } +- { name: 兜底, to: __all__ } +", + ); + cfg.clients[0].allow = Some(vec!["m-a".into()]); + let (gw, mut rx) = serve(cfg).await; + + let (status, body) = ask(gw, "m-a").await; + assert_ne!(status, 200, "{body}"); + assert_eq!(models(&a_seen), ["m-a"]); + assert!(models(&b_seen).is_empty(), "密钥不让用 m-b,却发给了 b"); + assert_eq!(attempts(&mut rx).await, [("a".to_string(), None)]); +} diff --git a/crates/tw-gateway/tests/plugins_request.rs b/crates/tw-gateway/tests/plugins_request.rs index 45047361..2adbf2ba 100644 --- a/crates/tw-gateway/tests/plugins_request.rs +++ b/crates/tw-gateway/tests/plugins_request.rs @@ -546,6 +546,53 @@ async fn a_plugin_renaming_to_an_alias_sends_each_upstream_its_own_name() { ); } +/// 插件换上的别名,密钥的范围按客户端那一侧的规矩看:只许 `claude-sonnet-*` 的密钥,插件 +/// 换成列了 `claude-sonnet-5` 的别名 `sonnet` 照常发(和客户端直接要 `sonnet` 一样放行); +/// 换成只列了 `claude-opus-5` 的 `opus` 不发 +#[tokio::test] +async fn a_plugin_renaming_to_an_alias_inherits_the_keys_allow_like_a_request_for_it() { + for (alias, allowed) in [("sonnet", true), ("opus", false)] { + let up = Upstream::default(); + let base = start_upstream(up.clone()).await; + let rename = Double::new("rename") + .permit(&[Permission::Params]) + .on_request(move |mut view, _| { + view["params"]["model"] = json!(alias); + Invocation::ok(RequestOutcome::Changed(view)) + }); + let mut cfg = Config { + providers: vec![provider("a", base, Protocol::Anthropic)], + aliases: tw_config::Aliases(vec![ + tw_config::Alias { + name: "sonnet".into(), + models: vec!["claude-sonnet-5".into()], + }, + tw_config::Alias { + name: "opus".into(), + models: vec!["claude-opus-5".into()], + }, + ]), + ..config() + }; + cfg.clients[0].allow = Some(vec!["claude-sonnet-*".into()]); + let gw = gateway_of(cfg, vec![entry("rename", rename)]).await; + let (status, body) = post(&gw, "/v1/messages", &anthropic_body()).await; + if allowed { + assert_eq!(status, 200, "{body}"); + assert_eq!(up.seen.lock().unwrap()[0].1["model"], "claude-sonnet-5"); + } else { + assert_eq!(status, 403, "{body}"); + assert!( + body["error"]["message"].as_str().unwrap().contains( + "changed the model to opus, which gateway key `claude-code` may not use" + ), + "{body}" + ); + assert_eq!(up.hits(), 0); + } + } +} + #[tokio::test] async fn a_rejection_is_answered_in_the_clients_format_and_nothing_goes_upstream() { let up = Upstream::default(); From 5d93599bbf8013b25ab1502daeebe91ef671d251 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:19:36 +0800 Subject: [PATCH 15/18] Move fixed control-plane paths out from under {name} and {id} axum prefers a fixed path segment to a {param} one, so a fixed endpoint under /providers/, /proxies/ or /plugins/ shadowed every resource of that name: an upstream named `test` or `preview`, or a proxy named `test`, got 405 on PUT and DELETE, and the plugin ids `order`, `inspect`, `rewrite` and `confirmed` had to be reserved (PUT /plugins/order reached the reorder handler). Moved, endpoint names unchanged: POST /providers/test -> POST /provider-test POST /providers/preview -> POST /provider-preview POST /proxies/test -> POST /proxy-test POST /plugins/inspect -> POST /plugin-inspect POST /plugins/rewrite -> POST /plugin-rewrite POST /plugins/confirmed -> POST /plugin-confirmed PUT /plugins/order -> PUT /plugin-order POST /aliases/preview -> POST /alias-preview (same edit as fix/ma-control) - ep.rs: a test that no endpoint has a fixed segment where another endpoint with the same prefix takes a parameter, whatever the method. {guard} is a closed set; a fixed word there must not be a guard. - Plugin ids are no longer reserved: config.plugin.reserved_id and control.plugin.reserved_id are removed, and a plugin named "Order" gets the id `order`. - Handler tests: upstreams named `test` and `preview`, a proxy named `test`, and plugins with the ids `order`, `inspect`, `rewrite` and `confirmed` can be edited and deleted. - Protocol 38 notes, 0.62.0 release notes and the configuration reference follow. Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/msg-codes.txt | 2 - crates/tw-api/src/ep.rs | 67 +++++++++++++--- crates/tw-api/src/lib.rs | 27 ++++--- crates/tw-api/src/ts.rs | 2 +- crates/tw-config/src/plugins.rs | 35 ++------- crates/tw-config/src/validate.rs | 6 -- crates/tw-control/src/plugins.rs | 19 +---- crates/tw-control/tests/aliases.rs | 20 ++--- crates/tw-control/tests/bedrock.rs | 6 +- crates/tw-control/tests/models.rs | 8 +- crates/tw-control/tests/plugin_defaults.rs | 8 +- crates/tw-control/tests/plugins.rs | 59 +++++++++----- crates/tw-control/tests/resources.rs | 82 ++++++++++++++++++-- crates/tw-gateway/src/plugin/defaults/mod.rs | 3 +- docs/config.md | 2 +- docs/config.zh-CN.md | 2 +- release-notes/0.62.0.md | 5 +- 17 files changed, 226 insertions(+), 127 deletions(-) diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index e08f06e2..90b12df5 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -72,7 +72,6 @@ config.no_clients config.plugin.bad_id config.plugin.duplicate config.plugin.file -config.plugin.reserved_id config.plugin.sha256 config.rejected config.rejected_at @@ -161,7 +160,6 @@ control.plugin.id_taken control.plugin.needs_confirmation control.plugin.not_found control.plugin.order -control.plugin.reserved_id control.plugin.trial_changed control.plugin.unreadable control.plugin.write_failed diff --git a/crates/tw-api/src/ep.rs b/crates/tw-api/src/ep.rs index 112f453d..8dd71119 100644 --- a/crates/tw-api/src/ep.rs +++ b/crates/tw-api/src/ep.rs @@ -88,15 +88,17 @@ endpoints! { // ─────────────────────────────────────────────── 上游与代理 CreateProvider: POST "/providers", api::ProviderSave => api::ConfigWritten; - TestProvider: POST "/providers/test", api::ProviderTest => api::ProviderTestResult; - PreviewProvider: POST "/providers/preview", api::ProviderPreviewRequest => api::ProviderPreview; + /// 检测和预览一家还没保存的上游。**不在 `/providers/` 底下**,代理的检测也一样:写死 + /// 的一段会盖住 `/providers/{name}`,叫 `test` 的上游就改不了、删不掉了 + TestProvider: POST "/provider-test", api::ProviderTest => api::ProviderTestResult; + PreviewProvider: POST "/provider-preview", api::ProviderPreviewRequest => api::ProviderPreview; UpdateProvider: PUT "/providers/{name}" [name], api::ProviderSave => api::ConfigWritten; DeleteProvider: DELETE "/providers/{name}" [name], api::BaseVersion => api::ConfigWritten; ProviderModels: GET "/providers/{name}/models" [name], () => api::ProviderModelsView; RefreshProviderModels: POST "/providers/{name}/models/refresh" [name], () => api::ProviderModelsView; RefreshStaleModels: POST "/models/refresh", () => api::ModelsRefreshing; CreateProxy: POST "/proxies", api::ProxySave => api::ConfigWritten; - TestProxy: POST "/proxies/test", api::ProxyTest => api::L1Result; + TestProxy: POST "/proxy-test", api::ProxyTest => api::L1Result; UpdateProxy: PUT "/proxies/{name}" [name], api::ProxySave => api::ConfigWritten; DeleteProxy: DELETE "/proxies/{name}" [name], api::BaseVersion => api::ConfigWritten; @@ -105,8 +107,10 @@ endpoints! { /// 模型在各家叫不同名称的建议(只认 Claude) Aliases: GET "/aliases", () => api::AliasesView; CreateAlias: POST "/aliases", api::AliasSave => api::ConfigWritten; - /// 预览一个还没保存的别名:挡着保存的问题、发往各家的名称、别家上的同一个模型 - PreviewAlias: POST "/aliases/preview", api::AliasPreviewRequest => api::AliasPreview; + /// 预览一个还没保存的别名:挡着保存的问题、发往各家的名称、别家上的同一个模型。 + /// **不在 `/aliases/` 底下**:`/aliases/preview` 会盖住 `/aliases/{name}`,一个叫 + /// `preview` 的别名就改不了、删不掉了 + PreviewAlias: POST "/alias-preview", api::AliasPreviewRequest => api::AliasPreview; /// 保存,可以改名:引用旧名的密钥(`allow` 里的整项)和规则(`when.model`、 /// `set.model`)在同一个版本里跟着改 UpdateAlias: PUT "/aliases/{name}" [name], api::AliasSave => api::AliasWritten; @@ -157,23 +161,26 @@ endpoints! { // 网页的 `call` 白名单**,它的 Rust 先自己再编一遍源码(或者读一遍插件现在的样子),在系统 // 的确认框里把名字、权限和要改的地方摆给人看,点了头才发。网页里注入的脚本调不到它们。 // 每个端点说明里写着桌面端能不能把它给网页。 + // + // 不针对某一个插件的那几个(试编、改写、点过头的装、排顺序)**不在 `/plugins/` 底下**: + // 插件的 id 是用户起的,写死的一段会盖住 `/plugins/{id}`。 /// 全部插件,按运行的顺序:状态、计数。**网页可以调** Plugins: GET "/plugins", () => Vec; /// 编一份源码看看它是什么插件,**什么都不留下**。**网页可以调** - PluginInspect: POST "/plugins/inspect", api::PluginSource => api::PluginInspection; + PluginInspect: POST "/plugin-inspect", api::PluginSource => api::PluginInspection; /// 改写一份源码里的数据(出错时怎么办、范围、设置的值),交回改写之后的源码:只换 /// manifest 那一段,别的字节一个不动。**没有副作用**,不读不写任何文件和配置。 /// 界面的设置表单靠它更新代码视图(反过来,代码 → 表单用 `PluginInspect`)。 /// **网页可以调** - PluginRewrite: POST "/plugins/rewrite", api::PluginRewriteRequest => api::PluginSource; + PluginRewrite: POST "/plugin-rewrite", api::PluginRewriteRequest => api::PluginSource; /// 装一个:写插件文件和它的底稿,配置里加一条。改得了工具调用的插件在这里拒绝(403, /// `control.plugin.needs_confirmation`),要走 `CreatePluginConfirmed`。**网页可以调** CreatePlugin: POST "/plugins", api::PluginCreate => api::ConfigWritten; /// 同一件事,在系统的确认框里点过头了:改得了工具调用的插件也装得上。**网页不能调, /// 桌面端也不许把它放进网页的白名单** - CreatePluginConfirmed: POST "/plugins/confirmed", api::PluginCreate => api::ConfigWritten; + CreatePluginConfirmed: POST "/plugin-confirmed", api::PluginCreate => api::ConfigWritten; /// 排顺序,也就是运行的顺序。**网页可以调** - ReorderPlugins: PUT "/plugins/order", api::PluginOrder => api::ConfigWritten; + ReorderPlugins: PUT "/plugin-order", api::PluginOrder => api::ConfigWritten; /// 保存:源码和开关。core 拿源码和批准的那一份比,分成「只改了数据」和「改了代码」 /// (见 `PluginSave`);写文件、底稿和配置里的哈希是一件事,中间没有「文件变了」 /// 的那一刻。改得了工具调用的插件,改代码、打开它在这里拒绝(403, @@ -229,6 +236,48 @@ mod tests { } } + /// 名字是用户起的,什么词都可能:一个端点的某一段是 `{参数}` 的话,前面几段相同的 + /// 别的端点在这一段上不能写死一个词,不管方法是什么。写死了,axum 先认写死的那个, + /// 叫这个词的上游(代理、插件……)就只剩那一个端点的方法,改和删都是 405 —— + /// `PUT /plugins/order` 更是进了排顺序。 + /// + /// 取值由 core 定死的参数(`{guard}`)除外,只要写死的词不是其中一个。 + #[test] + fn no_fixed_segment_shadows_a_parameter() { + // 这个词能不能是这个参数的一个取值 + let could_be = |param: &str, word: &str| match param { + "{guard}" => api::Guard::from_slug(word).is_some(), + _ => true, + }; + let is_param = |s: &str| s.starts_with('{'); + let segments = |p: &'static str| p.split('/').skip(1).collect::>(); + let mut shadowed = std::collections::BTreeSet::new(); + for a in ALL { + let sa = segments(a.path); + for (i, param) in sa.iter().enumerate().filter(|(_, s)| is_param(s)) { + for b in ALL { + let sb = segments(b.path); + let Some(word) = sb.get(i).filter(|s| !is_param(s)) else { + continue; + }; + let same_prefix = sa[..i] + .iter() + .zip(&sb[..i]) + .all(|(x, y)| x == y || (is_param(x) && is_param(y))); + if same_prefix && could_be(param, word) { + shadowed.insert(format!( + "{} {}: `{word}` where /{} takes any name", + b.method.as_str(), + b.path, + sa[..=i].join("/") + )); + } + } + } + } + assert!(shadowed.is_empty(), "{shadowed:#?}"); + } + /// 同一个路径上同一个方法只能有一个端点,名字也不能重。 #[test] fn no_two_endpoints_collide() { diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index 4eb7f608..49cf0ff3 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -750,6 +750,15 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// 候选发出的模型名,以及它是别名、规则改写还是指定模型来的。发出的模型名和客户端写的不同、 /// 上游答的又是同一个模型时,回答里的模型名(含 `openai-model`、`x-openai-model` 回应头) /// 写成客户端写的名称。照 37 写的界面读不懂列表形状的 `to`,保存规则时会把指定模型丢掉。 +/// 同一版起**写死的一段不再盖住用户起的名字**:`/providers/`、`/proxies/`、`/plugins/` 底下 +/// 不针对某一个的端点挪了出来(端点名不变)—— `POST /providers/test` → `POST /provider-test`、 +/// `POST /providers/preview` → `POST /provider-preview`、`POST /proxies/test` → +/// `POST /proxy-test`、`POST /plugins/inspect` → `POST /plugin-inspect`、 +/// `POST /plugins/rewrite` → `POST /plugin-rewrite`、`POST /plugins/confirmed` → +/// `POST /plugin-confirmed`、`PUT /plugins/order` → `PUT /plugin-order`。以前叫 `test`、 +/// `preview` 的上游和叫 `test` 的代理改和删都是 405;插件 id 不再保留 `order`、`inspect`、 +/// `rewrite`、`confirmed` 这几个词,消息码 `config.plugin.reserved_id`、 +/// `control.plugin.reserved_id` 跟着删。 pub const CONTROL_API_VERSION: u32 = 38; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -2291,7 +2300,7 @@ pub struct ProviderTestResult { /// L1 测速:只握手,不发业务请求。**零成本零副作用**。 /// /// 给了名字就测那一家,不给就测所有上游。代理自己的检测走 -/// `/proxies/test`,还没保存的上游走 `/providers/test`。 +/// `/proxy-test`,还没保存的上游走 `/provider-test`。 /// /// **不接受一个「候选 URL 列表」。** cc-switch 有那么一张表,测完还得手动 /// 点一下填进去,运行时永远只认当前保存的那一个 —— 同一个概念在一个程序 @@ -3045,7 +3054,7 @@ pub struct AliasesView { pub suggestions: Vec, } -/// 预览一个还没保存的别名(`POST /aliases/preview`)。 +/// 预览一个还没保存的别名(`POST /alias-preview`)。 #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct AliasPreviewRequest { @@ -5161,7 +5170,7 @@ pub struct PluginHooks { /// **都是插件写的字**。 /// /// 出错时怎么办、范围、设置的值**都在文件里**:这里读到的就是这份源码装上之后的样子。 -/// 要改它们,用 `POST /plugins/rewrite` 改写源码 +/// 要改它们,用 `POST /plugin-rewrite` 改写源码 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct ManifestView { @@ -5227,7 +5236,7 @@ pub struct PluginView { pub stats: PluginStats, } -/// 一份源码(`POST /plugins/inspect`)。 +/// 一份源码(`POST /plugin-inspect`)。 #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct PluginSource { @@ -5255,12 +5264,12 @@ pub struct PluginLoadError { pub column: Option, } -/// 装一个插件(`POST /plugins`、`POST /plugins/confirmed`)。出错时怎么办、范围、设置的值 -/// 都在源码里(`POST /plugins/rewrite` 改写)。 +/// 装一个插件(`POST /plugins`、`POST /plugin-confirmed`)。出错时怎么办、范围、设置的值 +/// 都在源码里(`POST /plugin-rewrite` 改写)。 /// /// 插件改得了回答里的工具调用(权限有 [`Permission::ReplyToolCalls`])时,`POST /plugins` /// 拒绝(403,`control.plugin.needs_confirmation`),要在系统的确认框里点过头、走 -/// `POST /plugins/confirmed`。 +/// `POST /plugin-confirmed`。 #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct PluginCreate { @@ -5291,7 +5300,7 @@ pub struct PluginSave { pub base_version: Option, } -/// 改写一份源码里的数据(`POST /plugins/rewrite`):出错时怎么办、范围、设置的值。**什么都不 +/// 改写一份源码里的数据(`POST /plugin-rewrite`):出错时怎么办、范围、设置的值。**什么都不 /// 留下**,只交回改写之后的源码([`PluginSource`])—— 只换 manifest 字面量那一段,别的字节 /// 一个不动;字面量里的注释不保留。 /// @@ -5336,7 +5345,7 @@ pub struct PluginSourceView { pub current_sha256: Option, } -/// 排顺序(`PUT /plugins/order`):**全部 id**,按新的顺序。 +/// 排顺序(`PUT /plugin-order`):**全部 id**,按新的顺序。 #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct PluginOrder { diff --git a/crates/tw-api/src/ts.rs b/crates/tw-api/src/ts.rs index 789bae44..6ba442ac 100644 --- a/crates/tw-api/src/ts.rs +++ b/crates/tw-api/src/ts.rs @@ -384,7 +384,7 @@ mod tests { for line in [ " CreatePlugin: { req: PluginCreate; res: ConfigWritten };", " CreatePluginConfirmed: { req: PluginCreate; res: ConfigWritten };", - " CreatePluginConfirmed: { method: \"POST\", path: \"/plugins/confirmed\", params: [], format: \"json\" },", + " CreatePluginConfirmed: { method: \"POST\", path: \"/plugin-confirmed\", params: [], format: \"json\" },", " SavePlugin: { req: PluginSave; res: ConfigWritten };", " SavePluginConfirmed: { req: PluginSave; res: ConfigWritten };", " SavePluginConfirmed: { method: \"PUT\", path: \"/plugins/{id}/confirmed\", params: [\"id\"], format: \"json\" },", diff --git a/crates/tw-config/src/plugins.rs b/crates/tw-config/src/plugins.rs index fb266b4b..4393de0c 100644 --- a/crates/tw-config/src/plugins.rs +++ b/crates/tw-config/src/plugins.rs @@ -26,10 +26,6 @@ pub const APPROVED_DIR: &str = ".approved"; /// id 最长多少个字符 pub const ID_MAX: usize = 40; -/// 不能当 id 的词:控制面上 `/plugins/` 底下这几个是固定的端点(排顺序、试编、改写、 -/// 确认过的装),叫这几个名字的插件会和它们撞在同一个路径上 -pub const RESERVED_IDS: &[&str] = &["order", "inspect", "rewrite", "confirmed"]; - /// 0.58.0 写在这里、现在挪进了插件文件的字段。读到了不认,写插件这一节时去掉 pub const LEGACY_FIELDS: &[&str] = &["on_error", "scope", "settings"]; @@ -152,9 +148,6 @@ pub(crate) fn check(plugins: &[Plugin]) -> Result<(), crate::ValidationError> { if !valid_id(&p.id) { return Err(E::PluginId { id: p.id.clone() }); } - if RESERVED_IDS.contains(&p.id.as_str()) { - return Err(E::PluginIdReserved { id: p.id.clone() }); - } if !seen.insert(p.id.as_str()) { return Err(E::DuplicatePlugin { id: p.id.clone() }); } @@ -272,33 +265,15 @@ mod tests { assert!(parse(&entry("a-1-b")).is_ok()); } - /// `/plugins/order`、`/plugins/inspect`、`/plugins/rewrite`、`/plugins/confirmed` 是控制面上 - /// 几个固定的端点 + /// 没有不能当 id 的词:控制面上 `/plugins/` 底下只有 `{id}`(见 `tw_api::ep` 的 + /// `no_fixed_segment_shadows_a_parameter`),以前写死在那儿的几个词也是普通的 id #[test] - fn the_words_the_control_plane_uses_are_not_ids() { - for id in RESERVED_IDS { - let e = parse(&entry(id)).unwrap_err(); - assert!(e.starts_with("config.plugin.reserved_id"), "{id}: {e}"); + fn the_words_the_control_plane_once_used_are_ids_too() { + for id in ["order", "inspect", "rewrite", "confirmed"] { + assert!(parse(&entry(id)).is_ok(), "{id}"); } } - /// 控制面上 `/plugins/` 底下每一个固定的词都不能当 id:不然那个插件的 - /// `/plugins/{id}` 和固定的端点落在同一个路径上 - #[test] - fn every_fixed_word_under_plugins_on_the_control_plane_is_reserved() { - let fixed: std::collections::BTreeSet<&str> = tw_api::ep::ALL - .iter() - .filter_map(|e| e.path.strip_prefix("/plugins/")) - .map(|rest| rest.split('/').next().unwrap_or_default()) - .filter(|seg| !seg.starts_with('{')) - .collect(); - assert_eq!( - fixed, - RESERVED_IDS.iter().copied().collect(), - "the reserved ids and the control plane disagree" - ); - } - #[test] fn an_id_appears_once() { let e = parse(&format!("{}{}", entry("a"), entry("a"))).unwrap_err(); diff --git a/crates/tw-config/src/validate.rs b/crates/tw-config/src/validate.rs index a4d00c1d..20c263af 100644 --- a/crates/tw-config/src/validate.rs +++ b/crates/tw-config/src/validate.rs @@ -77,8 +77,6 @@ pub enum ValidationError { #[error("{}", self.msg())] PluginId { id: String }, #[error("{}", self.msg())] - PluginIdReserved { id: String }, - #[error("{}", self.msg())] DuplicatePlugin { id: String }, #[error("{}", self.msg())] PluginFile { id: String, file: String }, @@ -239,10 +237,6 @@ impl ValidationError { "the plugin id `{plugin}` is written wrongly: lowercase letters, digits and \ hyphens, 1 to {max} characters" ), - PluginIdReserved { id } => msg!( - "config.plugin.reserved_id", plugin = id => - "`{plugin}` cannot be a plugin id: the control plane uses that word itself" - ), DuplicatePlugin { id } => msg!( "config.plugin.duplicate", plugin = id => "the plugin id `{plugin}` appears twice" diff --git a/crates/tw-control/src/plugins.rs b/crates/tw-control/src/plugins.rs index 73299710..a603f235 100644 --- a/crates/tw-control/src/plugins.rs +++ b/crates/tw-control/src/plugins.rs @@ -371,15 +371,6 @@ fn new_id(given: Option<&str>, name: &str, taken: &[&str]) -> Result - "`{plugin}` cannot be a plugin id: the control plane uses that word itself." - ), - )); - } if taken.contains(&id) { return Err(fail( StatusCode::CONFLICT, @@ -407,12 +398,8 @@ fn id_from_name(name: &str, taken: &[&str]) -> String { } base.truncate(tw_config::plugins::ID_MAX); let mut base = base.trim_end_matches('-').to_string(); - if base.is_empty() || tw_config::plugins::RESERVED_IDS.contains(&base.as_str()) { - base = if base.is_empty() { - "plugin".into() - } else { - format!("{base}-plugin") - }; + if base.is_empty() { + base = "plugin".into(); } let free = |id: &str| !taken.contains(&id); if free(&base) { @@ -1162,7 +1149,7 @@ mod tests { id_from_name("附加日期", &["plugin", "plugin-2"]), "plugin-3" ); - assert_eq!(id_from_name("Order", &[]), "order-plugin"); + assert_eq!(id_from_name("Order", &[]), "order"); let long = "x".repeat(60); let id = id_from_name(&long, &[&"x".repeat(40)]); assert!(id.len() <= 40 && id.ends_with("-2"), "{id}"); diff --git a/crates/tw-control/tests/aliases.rs b/crates/tw-control/tests/aliases.rs index d2795b7b..9dc08613 100644 --- a/crates/tw-control/tests/aliases.rs +++ b/crates/tw-control/tests/aliases.rs @@ -580,7 +580,7 @@ async fn a_preview_says_where_a_draft_would_go_and_suggests_the_same_model_elsew let (st, v) = call( &b.app, "POST", - "/aliases/preview", + "/alias-preview", json!({ "alias": { "name": "opus", "models": ["claude-opus-4-1-20250805", "nope-model"] } }), ) .await; @@ -609,19 +609,13 @@ async fn a_preview_says_where_a_draft_would_go_and_suggests_the_same_model_elsew async fn editing_an_alias_is_not_a_duplicate_of_itself() { let b = bed(BASE, None); let draft = json!({ "name": "sonnet", "models": ["claude-sonnet-4-5"] }); - let (_, v) = call( - &b.app, - "POST", - "/aliases/preview", - json!({ "alias": draft }), - ) - .await; + let (_, v) = call(&b.app, "POST", "/alias-preview", json!({ "alias": draft })).await; assert_eq!(codes(&v), ["config.alias_duplicate"], "{v}"); let (_, v) = call( &b.app, "POST", - "/aliases/preview", + "/alias-preview", json!({ "alias": draft, "original": "sonnet" }), ) .await; @@ -639,7 +633,7 @@ async fn editing_an_alias_is_not_a_duplicate_of_itself() { let (_, v) = call( &b.app, "POST", - "/aliases/preview", + "/alias-preview", json!({ "alias": { "name": "sonnet-4.5", "models": ["claude-sonnet-4-5"] }, "original": "sonnet" }), ) .await; @@ -648,7 +642,7 @@ async fn editing_an_alias_is_not_a_duplicate_of_itself() { let (_, v) = call( &b.app, "POST", - "/aliases/preview", + "/alias-preview", json!({ "alias": { "name": "sonnet", "models": ["sonnet", "claude-sonnet-4-5"] }, "original": "sonnet" }), ) .await; @@ -670,7 +664,7 @@ async fn a_preview_lists_what_would_stop_the_save() { None => json!({ "alias": alias }), }; async move { - let (st, v) = call(&app, "POST", "/aliases/preview", body).await; + let (st, v) = call(&app, "POST", "/alias-preview", body).await; assert_eq!(st, StatusCode::OK, "{v}"); codes(&v) } @@ -699,7 +693,7 @@ async fn a_preview_lists_what_would_stop_the_save() { let (_, v) = call( &b.app, "POST", - "/aliases/preview", + "/alias-preview", json!({ "alias": { "name": "", "models": ["claude-opus-4-1-20250805"] } }), ) .await; diff --git a/crates/tw-control/tests/bedrock.rs b/crates/tw-control/tests/bedrock.rs index 21b36f92..4fded445 100644 --- a/crates/tw-control/tests/bedrock.rs +++ b/crates/tw-control/tests/bedrock.rs @@ -81,7 +81,7 @@ async fn a_bedrock_address_is_previewed_with_its_protocol_and_region() { let (st, v) = call( &b.app, "POST", - "/providers/preview", + "/provider-preview", json!({"base_url": "https://bedrock-runtime.eu-west-3.amazonaws.com"}), ) .await; @@ -93,7 +93,7 @@ async fn a_bedrock_address_is_previewed_with_its_protocol_and_region() { let (_, v) = call( &b.app, "POST", - "/providers/preview", + "/provider-preview", json!({"base_url": "https://api.anthropic.com"}), ) .await; @@ -240,7 +240,7 @@ async fn test_with(b: &Bed, up: std::net::SocketAddr) -> Value { let (st, v) = call( &b.app, "POST", - "/providers/test", + "/provider-test", json!({"provider": { "name": "b", "base_url": format!("http://{up}"), diff --git a/crates/tw-control/tests/models.rs b/crates/tw-control/tests/models.rs index 96e909ba..327c6ab4 100644 --- a/crates/tw-control/tests/models.rs +++ b/crates/tw-control/tests/models.rs @@ -407,7 +407,7 @@ async fn an_address_being_typed_previews_what_automatic_detection_will_pick() { let (st, v) = call( &b.app, "POST", - "/providers/preview", + "/provider-preview", serde_json::json!({ "base_url": "https://api.anthropic.com" }), ) .await; @@ -424,7 +424,7 @@ async fn an_address_being_typed_previews_what_automatic_detection_will_pick() { let (st, v) = call( &b.app, "POST", - "/providers/preview", + "/provider-preview", serde_json::json!({ "base_url": "https://api.anthropic.com", "protocol": "openai-chat" }), ) .await; @@ -435,7 +435,7 @@ async fn an_address_being_typed_previews_what_automatic_detection_will_pick() { let (st, _) = call( &b.app, "POST", - "/providers/preview", + "/provider-preview", serde_json::json!({ "base_url": "https://api.anthropic.com", "protocol": "grpc" }), ) .await; @@ -445,7 +445,7 @@ async fn an_address_being_typed_previews_what_automatic_detection_will_pick() { let (_, v) = call( &b.app, "POST", - "/providers/preview", + "/provider-preview", serde_json::json!({ "base_url": "https://relay.example/v1" }), ) .await; diff --git a/crates/tw-control/tests/plugin_defaults.rs b/crates/tw-control/tests/plugin_defaults.rs index 95787273..ec04226c 100644 --- a/crates/tw-control/tests/plugin_defaults.rs +++ b/crates/tw-control/tests/plugin_defaults.rs @@ -118,7 +118,7 @@ impl Bed { let (st, v) = call( &self.app, "POST", - "/plugins/rewrite", + "/plugin-rewrite", Some(json!({"source": src, "on_error": "skip", "scope": {"clients": [], "models": ["deepseek-chat"], "upstreams": []}, "settings": settings})), @@ -764,7 +764,7 @@ async fn enabling_a_default_compiles_it_and_then_it_runs() { let (st, v) = call( &b.app, "POST", - "/plugins/rewrite", + "/plugin-rewrite", Some( json!({"source": b.read(b.file("reply-language")), "on_error": "reject", "scope": {"clients": [], "models": [], "upstreams": []}, @@ -826,7 +826,7 @@ async fn install_calls(b: &Bed) -> String { let (st, v) = call( &b.app, "POST", - "/plugins/confirmed", + "/plugin-confirmed", Some(json!({"source": src, "id": "calls", "enabled": false})), ) .await; @@ -959,7 +959,7 @@ async fn wsl_paths_turns_on_only_with_a_confirmation() { let (st, v) = call( &b.app, "POST", - "/plugins/rewrite", + "/plugin-rewrite", Some(json!({"source": src, "on_error": "reject", "scope": {"clients": [], "models": [], "upstreams": []}, "settings": {"windows_client": true}})), diff --git a/crates/tw-control/tests/plugins.rs b/crates/tw-control/tests/plugins.rs index 5aba9ee0..526fa188 100644 --- a/crates/tw-control/tests/plugins.rs +++ b/crates/tw-control/tests/plugins.rs @@ -74,7 +74,7 @@ impl Bed { } /// 装一个(点过头的那条路),返回 id async fn install_confirmed(&self, src: &str, extra: Value) -> String { - self.install_at("/plugins/confirmed", src, extra).await + self.install_at("/plugin-confirmed", src, extra).await } async fn install_at(&self, path: &str, src: &str, extra: Value) -> String { let mut body = json!({ @@ -108,12 +108,12 @@ impl Bed { ) .await } - /// 改写源码里的数据(`POST /plugins/rewrite`),交回改写之后的源码 + /// 改写源码里的数据(`POST /plugin-rewrite`),交回改写之后的源码 async fn rewrite(&self, src: &str, on_error: &str, models: Value, settings: Value) -> String { let (st, v) = call( &self.app, "POST", - "/plugins/rewrite", + "/plugin-rewrite", Some(json!({"source": src, "on_error": on_error, "scope": {"clients": [], "models": models, "upstreams": []}, "settings": settings})), @@ -251,7 +251,7 @@ async fn inspecting_a_source_says_what_it_is_and_leaves_nothing_behind() { let (st, v) = call( &b.app, "POST", - "/plugins/inspect", + "/plugin-inspect", Some(json!({"source": src})), ) .await; @@ -278,7 +278,7 @@ async fn inspecting_a_source_says_what_it_is_and_leaves_nothing_behind() { let (st, v) = call( &b.app, "POST", - "/plugins/inspect", + "/plugin-inspect", Some(json!({"source": bad})), ) .await; @@ -293,7 +293,7 @@ async fn inspecting_a_source_says_what_it_is_and_leaves_nothing_behind() { let (_, v) = call( &b.app, "POST", - "/plugins/inspect", + "/plugin-inspect", Some(json!({"source": expr})), ) .await; @@ -380,9 +380,6 @@ async fn an_id_is_checked_and_a_second_plugin_of_the_same_name_gets_its_own() { for (id, code) in [ ("Bad_Id", "control.plugin.bad_id"), - ("order", "control.plugin.reserved_id"), - ("rewrite", "control.plugin.reserved_id"), - ("confirmed", "control.plugin.reserved_id"), ("shout", "control.plugin.id_taken"), ] { let (st, v) = call( @@ -615,7 +612,7 @@ async fn rewriting_returns_the_new_source_and_writes_nothing() { let (_, v) = call( &b.app, "POST", - "/plugins/inspect", + "/plugin-inspect", Some(json!({"source": out})), ) .await; @@ -629,7 +626,7 @@ async fn rewriting_returns_the_new_source_and_writes_nothing() { let (st, v) = call( &b.app, "POST", - "/plugins/rewrite", + "/plugin-rewrite", Some(json!({"source": src, "on_error": "reject", "scope": {"clients": ["claude-code"], "models": [], "upstreams": []}, "settings": {"note": "今天"}})), @@ -670,7 +667,7 @@ async fn rewriting_returns_the_new_source_and_writes_nothing() { "gw.plugin.manifest_not_data", ), ] { - let (st, v) = call(&b.app, "POST", "/plugins/rewrite", Some(body)).await; + let (st, v) = call(&b.app, "POST", "/plugin-rewrite", Some(body)).await; assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); assert_eq!(v["code"], code); } @@ -809,7 +806,7 @@ async fn reordering_changes_the_run_order_and_needs_every_plugin_once() { let (st, v) = call( &b.app, "PUT", - "/plugins/order", + "/plugin-order", Some(json!({"ids": [d, a, c], "base_version": b.version().await})), ) .await; @@ -831,7 +828,7 @@ async fn reordering_changes_the_run_order_and_needs_every_plugin_once() { json!(["d", "a", "a"]), json!(["d", "a", "x"]), ] { - let (st, v) = call(&b.app, "PUT", "/plugins/order", Some(json!({"ids": ids}))).await; + let (st, v) = call(&b.app, "PUT", "/plugin-order", Some(json!({"ids": ids}))).await; assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); assert_eq!(v["code"], "control.plugin.order"); } @@ -857,6 +854,32 @@ async fn deleting_removes_the_entry_and_both_files() { assert_eq!(st, StatusCode::NOT_FOUND); } +/// id 是用户起的,什么词都可能。试编、改写、点过头的装、排顺序以前在 `/plugins/inspect`、 +/// `/plugins/rewrite`、`/plugins/confirmed`、`/plugins/order` 上:axum 先认写死的那一段, +/// 这几个 id 的插件保存和删都到不了它(`PUT /plugins/order` 还进了排顺序) +#[tokio::test] +async fn a_plugin_named_like_a_control_plane_word_can_be_saved_and_deleted() { + let b = bed(); + for id in ["order", "inspect", "rewrite", "confirmed"] { + assert_eq!(b.install(&shout(), json!({"id": id})).await, id); + let (st, v) = b.save(id, &shout(), false).await; + assert_eq!(st, StatusCode::OK, "{id}: {v}"); + let saved = b.parsed(); + assert!(!saved.plugins.iter().find(|p| p.id == id).unwrap().enabled); + + let (st, v) = call( + &b.app, + "DELETE", + &format!("/plugins/{id}?base_version={}", b.version().await), + None, + ) + .await; + assert_eq!(st, StatusCode::OK, "{id}: {v}"); + assert!(b.parsed().plugins.is_empty(), "{id}"); + assert!(!b.file(id).exists() && !b.approved(id).exists(), "{id}"); + } +} + /// 计数和日志:数据面每跑一次报一次,界面从这里取 #[tokio::test] async fn runs_show_up_in_the_stats_and_the_logs() { @@ -1444,7 +1467,7 @@ async fn disabling_deleting_and_reordering_never_need_a_confirmation() { let (st, v) = call( &b.app, "PUT", - "/plugins/order", + "/plugin-order", Some(json!({"ids": [other, id], "base_version": b.version().await})), ) .await; @@ -1685,7 +1708,7 @@ export function onRequest(req, ctx) { let (st, v) = call( &b.app, "POST", - "/plugins/inspect", + "/plugin-inspect", Some(json!({"source": src})), ) .await; @@ -1698,7 +1721,7 @@ export function onRequest(req, ctx) { let (_, v) = call( &b.app, "POST", - "/plugins/inspect", + "/plugin-inspect", Some(json!({"source": broken})), ) .await; @@ -1773,7 +1796,7 @@ export function onRequest(req, ctx) { let (_, v) = call( &b.app, "POST", - "/plugins/inspect", + "/plugin-inspect", Some(json!({"source": scrub})), ) .await; diff --git a/crates/tw-control/tests/resources.rs b/crates/tw-control/tests/resources.rs index 905610cc..4da817a2 100644 --- a/crates/tw-control/tests/resources.rs +++ b/crates/tw-control/tests/resources.rs @@ -551,6 +551,76 @@ async fn renaming_a_proxy_moves_the_upstreams_that_use_it_and_a_used_proxy_is_no ); } +/// 名字是用户起的,什么词都可能。检测和预览以前在 `/providers/test`、`/providers/preview` +/// 上:axum 先认写死的那一段,叫 `test`、`preview` 的上游改和删都是 405 +#[tokio::test] +async fn an_upstream_named_test_or_preview_can_be_edited_and_deleted() { + let b = bed(BASE); + for name in ["test", "preview"] { + let (st, body) = call( + &b.app, + "POST", + "/providers", + serde_json::json!({ "provider": relay(name) }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{name}: {body}"); + + let mut edited = relay(name); + edited["base_url"] = "https://relay2.example".into(); + let (st, body) = call( + &b.app, + "PUT", + &format!("/providers/{name}"), + serde_json::json!({ "provider": edited }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{name}: {body}"); + let cfg = b.parsed(); + let p = cfg.providers.iter().find(|p| p.name == name).unwrap(); + assert_eq!(p.base_url, "https://relay2.example"); + + let (st, body) = call( + &b.app, + "DELETE", + &format!("/providers/{name}"), + serde_json::json!({}), + ) + .await; + assert_eq!(st, StatusCode::OK, "{name}: {body}"); + assert!(b.parsed().providers.iter().all(|p| p.name != name)); + } +} + +/// 同上:检测代理以前在 `/proxies/test` 上 +#[tokio::test] +async fn a_proxy_named_test_can_be_edited_and_deleted() { + let b = bed(BASE); + let proxy = |addr: &str| serde_json::json!({ "name": "test", "kind": "http", "addr": addr }); + let (st, body) = call( + &b.app, + "POST", + "/proxies", + serde_json::json!({ "proxy": proxy("10.0.0.1:8080") }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{body}"); + + let (st, body) = call( + &b.app, + "PUT", + "/proxies/test", + serde_json::json!({ "proxy": proxy("10.0.0.2:8080") }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{body}"); + assert_eq!(b.parsed().proxies[0].addr, "10.0.0.2:8080"); + + let (st, body) = call(&b.app, "DELETE", "/proxies/test", serde_json::json!({})).await; + assert_eq!(st, StatusCode::OK, "{body}"); + assert!(b.parsed().proxies.is_empty()); +} + // ─────────────────────────────────────────────────────────── 检测 /// 一个假的上游:`/v1/models` 只认 `x-api-key: sk-good`。 @@ -580,7 +650,7 @@ async fn testing_an_unsaved_upstream_uses_the_protocol_and_lists_the_models() { let (st, body) = call( &b.app, "POST", - "/providers/test", + "/provider-test", serde_json::json!({ "provider": { "name": "relay", "base_url": format!("http://{up}"), @@ -610,9 +680,9 @@ async fn testing_an_edited_upstream_checks_what_the_form_holds() { "current": "官方", }) }; - let (_, body) = call(&b.app, "POST", "/providers/test", test("sk-good")).await; + let (_, body) = call(&b.app, "POST", "/provider-test", test("sk-good")).await; assert_eq!(json(&body)["ok"], true, "{body}"); - let (_, body) = call(&b.app, "POST", "/providers/test", test("sk-official")).await; + let (_, body) = call(&b.app, "POST", "/provider-test", test("sk-official")).await; assert_eq!(json(&body)["ok"], false, "{body}"); } @@ -623,7 +693,7 @@ async fn an_unsaved_oauth_credential_is_not_refreshed_just_to_test_it() { let (_, body) = call( &b.app, "POST", - "/providers/test", + "/provider-test", serde_json::json!({ "provider": { "name": "corp-llm", "base_url": "https://llm.corp.example", @@ -681,7 +751,7 @@ async fn testing_a_proxy_checks_its_credentials() { let (_, body) = call( &b.app, "POST", - "/proxies/test", + "/proxy-test", serde_json::json!({ "proxy": with(serde_json::json!({ "user": "svc", "pass": "bad" })) }), ) .await; @@ -693,7 +763,7 @@ async fn testing_a_proxy_checks_its_credentials() { let (_, body) = call( &b.app, "POST", - "/proxies/test", + "/proxy-test", serde_json::json!({ "proxy": with(serde_json::json!({ "user": "svc", "pass": "good" })) }), ) .await; diff --git a/crates/tw-gateway/src/plugin/defaults/mod.rs b/crates/tw-gateway/src/plugin/defaults/mod.rs index 503eed6f..429e82d0 100644 --- a/crates/tw-gateway/src/plugin/defaults/mod.rs +++ b/crates/tw-gateway/src/plugin/defaults/mod.rs @@ -57,13 +57,12 @@ mod tests { assert_eq!(ids, ["reply-language", "wsl-paths"]); } - /// 每个 id 都装得进配置:写法对、不是控制面占用的词、不重复 + /// 每个 id 都装得进配置:写法对、不重复 #[test] fn every_id_can_be_a_plugin_id() { let mut seen = std::collections::HashSet::new(); for (id, _) in ALL { assert!(tw_config::plugins::valid_id(id), "{id}"); - assert!(!tw_config::plugins::RESERVED_IDS.contains(id), "{id}"); assert!(seen.insert(*id), "{id} is listed twice"); } } diff --git a/docs/config.md b/docs/config.md index cf116393..90667647 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1149,7 +1149,7 @@ Requests of a kind a plugin does not handle pass without it, whatever its | Field | Type | Default | Description | |---|---|---|---| -| `id` | string | **required** | Lowercase letters, digits and hyphens, 1 to 40 characters; unique. `order`, `inspect`, `rewrite` and `confirmed` are taken by the control plane. | +| `id` | string | **required** | Lowercase letters, digits and hyphens, 1 to 40 characters; unique. | | `file` | string | **required** | The plugin's code, relative to this file's directory. It is always `plugins/.js`; the app writes it. | | `sha256` | string | **required** | SHA-256 of the approved code, 64 lowercase hexadecimal characters. When the file no longer has this hash, the plugin stops running until the change is approved in the app. The approved code is kept in `plugins/.approved/.js`. | | `enabled` | bool | `true` | Run the plugin. `false` keeps it installed and out of every request. | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index cb7d939c..a0b9ad72 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -887,7 +887,7 @@ default_route: default | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| -| `id` | 字符串 | **必填** | 小写字母、数字和连字符,1 到 40 个字符,不能重复。`order`、`inspect`、`rewrite` 和 `confirmed` 被控制面占用。 | +| `id` | 字符串 | **必填** | 小写字母、数字和连字符,1 到 40 个字符,不能重复。 | | `file` | 字符串 | **必填** | 插件的代码,相对本文件所在的目录。只能是 `plugins/.js`,由应用写入。 | | `sha256` | 字符串 | **必填** | 批准过的代码的 SHA-256,64 个小写十六进制字符。文件的哈希与它不符时插件停止运行,直到在应用里批准这次改动。批准过的代码另存在 `plugins/.approved/.js`。 | | `enabled` | 布尔 | `true` | 是否运行这个插件。`false`:插件保留,不参与任何请求。 | diff --git a/release-notes/0.62.0.md b/release-notes/0.62.0.md index 2eed2e85..00da3fc9 100644 --- a/release-notes/0.62.0.md +++ b/release-notes/0.62.0.md @@ -7,13 +7,14 @@ This release adds model aliases: one name for the same model across upstreams, e - The configuration format only gains fields: a top-level `aliases` table, and `to` in a routing rule may now be a list of pinned models. Every 0.61.0 configuration loads unchanged. - Answers to requests whose model was renamed on the way (by an alias, a rule's `set.model`, a pinned model or a plugin) now carry the name the client asked for, as long as the upstream answered with the model it was sent. Before, they carried the upstream's name. - Changed in the protocol: - - New: `GET /aliases` (`AliasesView`), `POST /aliases` and `PUT /aliases/{name}` (`AliasSave` → `ConfigWritten` / `AliasWritten`), `DELETE /aliases/{name}`, `POST /aliases/preview` (`AliasPreviewRequest` → `AliasPreview`) and `GET /aliases/{name}/usage` (`AliasUsage`). + - New: `GET /aliases` (`AliasesView`), `POST /aliases` and `PUT /aliases/{name}` (`AliasSave` → `ConfigWritten` / `AliasWritten`), `DELETE /aliases/{name}`, `POST /alias-preview` (`AliasPreviewRequest` → `AliasPreview`) and `GET /aliases/{name}/usage` (`AliasUsage`). - `RuleView.to` and `RuleInput.to` are a `RuleTarget`: an upstream or group name as before, or a list of `PinnedModel { provider, model }`. - `KnownModel` gains `alias` (the alias's models, when the entry is an alias) and `aliases` (the aliases that list a model); `GET /models` lists aliases. `ModelRow` gains `aliases`. - The dry-run result gains `candidate_models`, one `DryRunCandidate { provider, sent_model, model_via }` per candidate. + - Moved, with the endpoint names unchanged: `POST /providers/test` → `POST /provider-test`, `POST /providers/preview` → `POST /provider-preview`, `POST /proxies/test` → `POST /proxy-test`, `POST /plugins/inspect` → `POST /plugin-inspect`, `POST /plugins/rewrite` → `POST /plugin-rewrite`, `POST /plugins/confirmed` → `POST /plugin-confirmed` and `PUT /plugins/order` → `PUT /plugin-order`. At the old paths they shadowed an upstream, proxy or plugin of the same name: an upstream named `test` or `preview`, or a proxy named `test`, could not be edited or deleted. `order`, `inspect`, `rewrite` and `confirmed` are now valid plugin ids. - Message codes, compared with 0.61.0: - New: `config.alias_blank_model`, `config.alias_chained`, `config.alias_duplicate`, `config.alias_empty_name`, `config.alias_no_models`, `config.alias_only_itself`, `config.alias_reserved`, `config.alias_wildcard`; `engine.pinned_blank_model`, `engine.pinned_duplicate_provider`, `engine.pinned_empty`, `engine.pinned_unknown_provider`, `engine.pinned_with_provider_would_be`, `engine.pinned_with_set_model`; `gw.model.alias_unserved`, `gw.model.alias_unserved_rewritten`, `gw.model.pinned_not_allowed`, `gw.model.pinned_not_offered`, `gw.plugin.alias_unserved`; `control.replay_alias_unserved`. - - None removed. + - Removed: `config.plugin.reserved_id` and `control.plugin.reserved_id`, since no plugin id is reserved any more. **Model aliases.** An alias is another name for the same model, written in a top-level `aliases` table as the alias and the names upstreams use for it, in order. - An upstream serves an alias when it offers one of the listed names within its `models_only`, and receives the first such name in the list. Every upstream that serves an alias takes part in failover for it, each receiving its own name. From 79f6aa2a840d9aec9041e05cb665e65f271f697b Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:28:05 +0800 Subject: [PATCH 16/18] Manual: plugin ids are no longer reserved Co-Authored-By: Claude Opus 5.5 --- crates/tw-config/tests/manual/schema.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index bf490fbb..dd0838e7 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -1505,8 +1505,8 @@ pub fn sections() -> Vec
{ Kind::Str, Def::Required, t( - "Lowercase letters, digits and hyphens, 1 to 40 characters; unique. `order`, `inspect`, `rewrite` and `confirmed` are taken by the control plane.", - "小写字母、数字和连字符,1 到 40 个字符,不能重复。`order`、`inspect`、`rewrite` 和 `confirmed` 被控制面占用。", + "Lowercase letters, digits and hyphens, 1 to 40 characters; unique.", + "小写字母、数字和连字符,1 到 40 个字符,不能重复。", ), ), row( From 8641ac5b716e9e86715f40c007689d0707c9206d Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:33:15 +0800 Subject: [PATCH 17/18] WebSocket: check the key's allow, apply rule parameters per frame, and resolve Realtime's model Brings the WebSocket paths to parity with HTTP for model access and rule parameters. - Each response.create on a Responses connection is checked against the key's allow the way HTTP checks a request: names on the client side (the frame's, a phase-one rewrite, a plugin rename) inherit from upstream model names listed by an alias (Catalog::allows); a pinned model or a phase-two rename is matched as written. A refused frame gets a response.failed with the HTTP messages (gw.model.not_allowed, not_allowed_rewritten, pinned_not_allowed, gw.plugin.model_not_allowed) and the connection stays open. - The routing target stays the one decided at the upgrade, but the rest of the rules is evaluated per frame with that frame's facts, as for an HTTP request: phase one's accumulated set (and a deny that matches the frame's model), then phase two. set.max_tokens and set.thinking are written into the frame with the same code HTTP uses for a passthrough Responses body (forward::apply_set); max_tokens is left out for a Codex backend upstream, which HTTP strips too. - A Realtime connection (/v1/realtime) takes its model from the query string: the upgrade is routed with it, refused when the key may not use it (no upstream that serves it and is allowed: 400 with the HTTP message), and the query sent upstream carries the connected upstream's own name (pinned as written, alias resolved). The attempt records the sent name and the request row the client's. - Catalog::allows is public (same change as fix/ma-admission). Co-Authored-By: Claude Opus 5.5 --- crates/tw-engine/src/catalog.rs | 5 +- crates/tw-gateway/src/server/upgrade.rs | 133 +++++-- crates/tw-gateway/src/ws.rs | 467 ++++++++++++++++++++---- crates/tw-gateway/tests/alias_ws.rs | 391 ++++++++++++++++++++ release-notes/0.62.0.md | 3 +- 5 files changed, 909 insertions(+), 90 deletions(-) diff --git a/crates/tw-engine/src/catalog.rs b/crates/tw-engine/src/catalog.rs index a639c3cb..4bd67062 100644 --- a/crates/tw-engine/src/catalog.rs +++ b/crates/tw-engine/src/catalog.rs @@ -179,7 +179,10 @@ impl Catalog { /// /// **继承只从真名到别名**:写上游的模型名(glob)时,指向它的别名也放行 —— 别名 /// 列表里任一名称匹配就算;写别名时只管别名本身,它列表里的模型不跟着放出来。 - fn allows(&self, name: &str, patterns: &[String]) -> bool { + /// + /// `name` 是**客户端那一侧的名称**:客户端写的、规则改写的、插件改的。原样发出的名字 + /// (指定模型、阶段二改的)不经过别名表,按名字本身对 glob 就是了。 + pub fn allows(&self, name: &str, patterns: &[String]) -> bool { let hit = |m: &str| patterns.iter().any(|p| glob_match(p, m)); hit(name) || self diff --git a/crates/tw-gateway/src/server/upgrade.rs b/crates/tw-gateway/src/server/upgrade.rs index 72a6e450..c9b3d495 100644 --- a/crates/tw-gateway/src/server/upgrade.rs +++ b/crates/tw-gateway/src/server/upgrade.rs @@ -31,9 +31,17 @@ pub(super) async fn ws_upgrade( ) -> Result { // 升级请求没有体,所以性质里只有客户端名字 —— 按模型路由的规则 // 对它不适用,而那是对的:这条连接上会跑什么模型,现在还不知道。这次的决定 - // 里定下的指定模型、模型改写照样作用在之后每一帧上(见 `crate::ws::Naming`) + // 里定下的指定模型、模型改写照样作用在之后每一帧上(见 `crate::ws::Naming`)。 + // + // **Realtime 的连接例外**:它的模型写在查询串里(`?model=gpt-realtime`),一条连接只用 + // 这一个。和 HTTP 那条路按请求体里的模型一样,路由、密钥的模型范围、别名都按它来 + let realtime = crate::ws::realtime(uri.path()); let facts = tw_engine::RequestFacts { client: client_name.clone(), + model: realtime + .then(|| crate::ws::query_model(query.as_deref())) + .flatten() + .unwrap_or_default(), ..Default::default() }; let route = rt.engine.route_of(&client_name).to_string(); @@ -56,7 +64,8 @@ pub(super) async fn ws_upgrade( rewritten_by: choice.rewritten_by.clone(), provider: provider.to_string(), billing, - model: String::new(), + // 客户端写的模型名:Realtime 的连接写在查询串里,别的连接升级时还不知道 + model: facts.model.clone(), method: "WS".to_string(), path: uri.path().to_string(), // 升级请求没有正文,没有可估的 @@ -100,17 +109,77 @@ pub(super) async fn ws_upgrade( return Err(err); } }; - // 升级请求没有正文,规则附加的改写无从谈起 - let choice = Choice { - route, - rule: decision.matched_rule.clone(), - group: decision.via_group.clone(), - rewritten_by: Vec::new(), - // WebSocket 那条路一条连接跑好几轮,不按对话记 - affinity: None, + // 发给哪一家、每个 `response.create` 发出去的模型名怎么定:和 HTTP 那条路的一跳同一套, + // 按这次的决定定(指定模型、阶段一的改写),别名对到这一家(见 `crate::ws::Naming`) + let naming_for = |provider: &tw_config::Provider| crate::ws::Naming { + config: rt.config.clone(), + engine: rt.engine.clone(), + decision: decision.clone(), + provider: provider.clone(), + client: client_name.clone(), + path: uri.path().to_string(), }; let (alive, _) = state.health.filter(&decision.candidates); - let Some(name) = alive.first().map(|s| s.to_string()) else { + // 升级请求带着模型的(Realtime):挑头一家连得上的 —— 别名对得上、要发给它的名字密钥 + // 让用,和 HTTP 那条路挑候选时一样。别的连接升级时不知道模型,连头一家活着的 + let picked = if facts.model.is_empty() { + alive.first().map(|s| (s.to_string(), None)) + } else { + let catalog = state.catalog.load(); + let mut unserved = None; + let mut barred = None; + let mut picked = None; + for c in &alive { + let Some(p) = rt.config.providers.iter().find(|p| p.name == **c) else { + continue; + }; + match naming_for(p).connect(&catalog, &facts) { + crate::ws::Connect::To { + model, + rewritten_by, + } => { + picked = Some((c.to_string(), Some((model, rewritten_by)))); + break; + } + crate::ws::Connect::Skip { barred: true, why } => { + barred.get_or_insert(why); + } + crate::ws::Connect::Skip { barred: false, why } => { + unserved.get_or_insert(why); + } + // 阶段二的规则拒绝了:**和规则在阶段一拒绝一样留一行**,拒绝它的是哪条规则 + // 写进路由事件 + crate::ws::Connect::Refused { rule, why } => { + let choice = Choice { + route, + rule: decision.matched_rule.clone(), + group: decision.via_group.clone(), + rewritten_by: decision.rewritten_by.clone(), + affinity: None, + }; + let Some(rule) = rule else { return Err(why) }; + tracing::info!(%rule, provider = %c, "a phase-two rule denied the WebSocket upgrade"); + let (id, ending) = open(&choice, "", tw_api::Billing::PerToken); + let mut routed = super::routed_nowhere(id, choice); + if let tw_api::Event::RequestRouted { denied_by, .. } = &mut routed { + *denied_by = Some(rule); + } + state.bus.emit(routed); + ending.failed(why.source.into(), why.detail.clone()); + return Err(why); + } + } + } + // 一家都连不上。每一家要发的名字密钥都不让用的,说是密钥的事(和 HTTP 那条路的准入 + // 一样);有让用的,说它为什么连不上(别名对不上)。**不留这一行**,和 HTTP 那条路 + // 准入没过一样 + match (picked, unserved, barred) { + (Some(p), _, _) => Some(p), + (None, Some(why), _) | (None, None, Some(why)) => return Err(why), + (None, None, None) => None, + } + }; + let Some((name, realtime_model)) = picked else { return Err(GatewayError::config(msg!( "gw.route.no_upstream_alive" => "No upstream is available." ))); @@ -121,6 +190,27 @@ pub(super) async fn ws_upgrade( "`{upstream}` is not in the configuration." ))); }; + // 附加了参数改写的规则:Realtime 的连接升级时就作用上了(查询串里的模型),和 HTTP 那条路 + // 一样报阶段一、阶段二的。Responses 的连接上参数改写每一帧按这一帧求,那时没有事件可报 + let (sent_model, rewritten_by) = match realtime_model { + Some((model, more)) => { + let mut by = decision.rewritten_by.clone(); + by.extend( + more.into_iter() + .filter(|r| !decision.rewritten_by.contains(r)), + ); + (Some(model), by) + } + None => (None, Vec::new()), + }; + let choice = Choice { + route, + rule: decision.matched_rule.clone(), + group: decision.via_group.clone(), + rewritten_by, + // WebSocket 那条路一条连接跑好几轮,不按对话记 + affinity: None, + }; // **走代理的上游不代理 WS**,而且要明说。悄悄绕过用户配的代理, // 等于把他以为在代理后面的流量直接发出去 if provider.proxy != tw_config::DIRECT { @@ -147,17 +237,14 @@ pub(super) async fn ws_upgrade( set: rt.plugins.clone(), client: crate::hint::client_hint(&headers), }); - // 每个 `response.create` 发出去的模型名:和 HTTP 那条路的一跳同一套,按这次的决定定 - // (指定模型、阶段一的改写),别名对到这一家(见 `crate::ws::Naming`)。和插件一样只有 - // Responses 的连接有 - let naming = responses.then(|| crate::ws::Naming { - config: rt.config.clone(), - engine: rt.engine.clone(), - decision: decision.clone(), - provider: provider.clone(), - client: client_name.clone(), - path: uri.path().to_string(), - }); + // 每个 `response.create` 发出去的模型名和参数改写(见 `crate::ws::Naming`)。和插件一样 + // 只有 Responses 的连接有 + let naming = responses.then(|| naming_for(provider)); + // Realtime 的连接:查询串里的模型写成发给这一家的名字,别的项原样 + let query = match (&sent_model, query) { + (Some(m), Some(q)) if *m != facts.model => Some(crate::ws::with_query_model(&q, m)), + (_, q) => q, + }; let upstream = crate::ws::Upstream { url: crate::ws::upstream_url(&provider.base_url, uri.path(), query.as_deref()), headers: upstream_headers, @@ -165,6 +252,8 @@ pub(super) async fn ws_upgrade( route: choice.route, rule: choice.rule, group: choice.group, + rewritten_by: choice.rewritten_by, + model: sent_model.filter(|m| *m != facts.model), }; let rules = crate::ws::Rules { redact_mode: rt.config.security.redact.mode, diff --git a/crates/tw-gateway/src/ws.rs b/crates/tw-gateway/src/ws.rs index 4616edd9..8ac6d655 100644 --- a/crates/tw-gateway/src/ws.rs +++ b/crates/tw-gateway/src/ws.rs @@ -19,8 +19,13 @@ //! //! 每个 `response.create` 发出去的模型名**和 HTTP 那条路的一跳同一套**(见 [`Naming`]): //! 规则给这一家指定的模型原样发,阶段二改的名字原样发,别的 —— 这一帧写的、阶段一改写成的、 -//! 插件改成的 —— 按别名表对到这一家自己的名称。这一家服务不了要的别名,这一帧不发,替它回 -//! 一个 `response.failed`,连接照常。回答里的模型名写回客户端用的那个([`crate::answer_model`])。 +//! 插件改成的 —— 按别名表对到这一家自己的名称。发出去之前过密钥的模型范围(`allow`),规则的 +//! 参数改写(`set` 的最大输出、关思考)照 HTTP 那条路改。这一家服务不了要的别名、密钥不让用 +//! 要发的模型、规则拒绝了这一帧,这一帧不发,替它回一个 `response.failed`,连接照常。回答里 +//! 的模型名写回客户端用的那个([`crate::answer_model`])。 +//! +//! Realtime 的连接(`/v1/realtime`)模型写在升级请求的查询串里:升级时按它路由、过密钥的 +//! 模型范围、对别名([`Naming::connect`]),发给上游的查询串写这一家自己的名称。 //! //! 脚本插件也在这条路上跑(见 [`crate::plugin`]):客户端发来的每个 //! `response.create` 是一次请求。**这条路只有一跳**(升级时就连定了那一家,不换), @@ -107,6 +112,75 @@ pub fn upstream_url(base: &str, path: &str, query: Option<&str>) -> String { } } +/// Realtime 的连接:`/v1/realtime`(不带 `/v1` 的也认)。它的模型写在查询串里 +/// (`?model=gpt-realtime`),不在帧里。 +pub fn realtime(path: &str) -> bool { + let p = path.trim_end_matches('/'); + p.strip_prefix("/v1").unwrap_or(p) == "/realtime" +} + +/// 查询串里的 `model`,百分号编码解开。没有这一项(或者是空的)是 `None`。 +pub fn query_model(query: Option<&str>) -> Option { + query? + .split('&') + .find_map(|pair| pair.strip_prefix("model=")) + .map(percent_decode) + .filter(|m| !m.is_empty()) +} + +/// 查询串里的 `model` 换成 `model`。**别的项一个字节都不动**:重新编码整个查询串会改掉 +/// 上游认的写法。 +pub fn with_query_model(query: &str, model: &str) -> String { + query + .split('&') + .map(|pair| { + if pair.starts_with("model=") { + format!("model={}", percent_encode(model)) + } else { + pair.to_string() + } + }) + .collect::>() + .join("&") +} + +/// 查询串一项的值:`%XX` 解成字节,`+` 是空格(表单编码)。不是 UTF-8 的照替换字符算 +fn percent_decode(s: &str) -> String { + let hex = |c: u8| (c as char).to_digit(16); + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + let pair = (b[i] == b'%' && i + 2 < b.len()) + .then(|| hex(b[i + 1]).zip(hex(b[i + 2]))) + .flatten(); + match (pair, b[i]) { + (Some((h, l)), _) => { + out.push((h * 16 + l) as u8); + i += 3; + continue; + } + (None, b'+') => out.push(b' '), + (None, c) => out.push(c), + } + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// 写进查询串的值:字母数字和 `-._~:` 原样,别的按字节编码 +fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || b"-._~:".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + /// 要连的那一家,以及路由是怎么选中它的。 pub struct Upstream { /// `ws(s)://` 开头的完整地址(见 [`upstream_url`]) @@ -119,6 +193,11 @@ pub struct Upstream { pub route: String, pub rule: String, pub group: Option, + /// 附加了参数改写的规则(见 `tw_engine::Decision::rewritten_by`)。路由事件里报 + pub rewritten_by: Vec, + /// 升级时就定下的、发给这一家的模型名,和客户端写的不一样时才有(尝试链上记它): + /// Realtime 的连接查询串里的模型对过的名字。Responses 的连接看 [`Naming::fixed`] + pub model: Option, } /// 各项防护此刻的档位和规则。**升级那一刻取一次**:一条连接活多久,就按 @@ -140,21 +219,28 @@ pub struct Plugins { pub client: Option, } -/// 这条连接上每个 `response.create` 发给上游的模型名怎么定:**和 HTTP 那条路的一跳同一套** -/// (见 [`crate::sent`])。依次: +/// 这条连接上每个 `response.create` 发给上游的模型名和参数怎么定:**和 HTTP 那条路的一跳 +/// 同一套**(见 [`crate::sent`])。模型名依次: /// /// - 规则给这一家指定了模型:原样发指定的那个; /// - 阶段二的规则改了名字:原样发(写的时候已经知道是哪一家); /// - 别的是客户端那一侧的名称 —— 这一帧写的,或者阶段一改写成的 —— 按别名表对到这一家自己的 /// 名称([`crate::models::resolve`])。插件再改名的,改出来的名字一样对。 /// -/// 路由在升级时就做完了:升级请求没有正文,按模型路由的规则对它不适用(见 `server::upgrade`), -/// 指定模型和阶段一的改写都在那次的决定里。**阶段二每一帧跑一遍**:到那时才知道这一帧要的 -/// 模型,和 HTTP 那条路每一跳跑一遍是同一个道理。 +/// 要发的名字先过密钥的模型范围(`allow`,[`Self::allows`]),和 HTTP 那条路的准入、每一跳 +/// 同一个判据。 +/// +/// **去向在升级时就定了**:升级请求没有正文,按模型路由的规则对它不适用(见 +/// `server::upgrade`),连哪一家、指定的模型都在那次的决定里,一条连接用到底。**规则的其余 +/// 部分每一帧按这一帧求**:到那时才知道这一帧要的模型、多长、带不带工具,和 HTTP 那条路每个 +/// 请求求一遍是同一个道理 —— 阶段一的参数改写(`set`,从所有命中的规则累积)和拒绝,阶段二。 +/// +/// 发不出去时**这一帧不发**,替它回一个 `response.failed`,连接照常:这一家服务不了要的别名 +/// (不把别名本身发给一个不认识它的上游)、密钥不让用要发的模型、规则拒绝了它。不是别名的 +/// 名字照旧原样发,这一家有没有它由它自己回答。 /// -/// 这一家服务不了要的别名时**这一帧不发**:不把别名本身发给一个不认识它的上游。不是别名的 -/// 名字照旧原样发,这一家有没有它由它自己回答。只有 Responses 的连接有这一步:别的连接上 -/// 没有 `response.create` 这种请求。 +/// Responses 的连接每一帧用它([`Self::frame`])。Realtime 的连接没有 `response.create` 这种 +/// 请求,模型写在升级请求的查询串里:升级时用一次([`Self::connect`])。 pub struct Naming { /// 升级那一刻的配置和路由引擎:一条连接活多久就用它多久,和 [`Rules`] 一样 pub config: Arc, @@ -163,12 +249,54 @@ pub struct Naming { pub decision: tw_engine::Decision, /// 这条连接连的那一家 pub provider: tw_config::Provider, - /// 网关密钥的名字:规则按它认客户端 + /// 网关密钥的名字:规则按它认客户端,模型范围是它的 pub client: String, /// 升级的路径。一帧按这条路径上的一次请求读([`crate::client_api::read`]) pub path: String, } +/// 一帧 `response.create` 发出去的样子([`Naming::frame`])。 +struct Outgoing { + /// 发给这一家的模型名。这一帧没写模型名时是空的:什么都不改 + model: String, + /// 规则的参数改写:阶段一(按这一帧求)和阶段二累积的。模型名不看这里,看 `model` + set: tw_engine::SetAction, +} + +/// 要发的名字为什么发不出去([`Naming::name`])。 +enum Unsent { + /// 密钥不让用 + Barred(GatewayError), + /// 这一家服务不了要的别名 + Unserved(GatewayError), +} + +impl Unsent { + fn into_error(self) -> GatewayError { + match self { + Unsent::Barred(e) | Unsent::Unserved(e) => e, + } + } +} + +/// 模型写在升级请求里的连接(Realtime)连不连这一家([`Naming::connect`])。 +pub enum Connect { + /// 连:发给它的模型名,和阶段二附加了参数改写的规则 + To { + model: String, + rewritten_by: Vec, + }, + /// 不连这一家,看下一家:它服务不了要的别名,或者要发给它的名字密钥不让用(`barred`)。 + /// 和 HTTP 那条路挑候选时跳过它们一样 + Skip { barred: bool, why: GatewayError }, + /// 整个拒绝,不换下一家:阶段二的规则拒绝了这次升级(`rule` 是哪条),或者规则求不了值。 + /// 和 HTTP 那条路一跳上的阶段二一样 + Refused { + rule: Option, + why: GatewayError, + }, +} + impl Naming { /// 客户端那一侧的名称发给这一家时叫什么([`crate::models::resolve`])。`None` = 这一家 /// 服务不了这个别名 @@ -194,41 +322,189 @@ impl Naming { } } - /// 一帧 `response.create`(`frame`)发给这一家的模型名。这一帧发不出去时是告诉客户端的 - /// 那个错误:阶段二的规则拒绝了它、规则求不了值,或者这一家服务不了要的别名。 - fn sent( + /// 这把密钥的模型范围(`allow`)。不写是 `None`:什么都放行 + fn allow(&self) -> Option<&[String]> { + self.config + .clients + .iter() + .find(|c| c.name == self.client) + .and_then(|c| c.allow.as_deref()) + } + + /// 密钥的 `allow` 放不放行要发的名字 `model`。**和 HTTP 那条路同一个判据**(准入、每一跳、 + /// 插件改名都看它): + /// + /// - 客户端那一侧的名称(客户端写的、阶段一改写的、插件改的,可能是别名)按目录的规矩看: + /// 写上游的模型名也放行列了它的别名([`tw_engine::Catalog::allows`]); + /// - 原样发出的名字(`as_written`:指定模型、阶段二改的)按名字本身对 glob:它不经过 + /// 别名表,也就没有别名可继承。 + /// + /// 和清单问没问到无关:`allow` 写在配置里。 + fn allows(&self, catalog: &tw_engine::Catalog, model: &str, as_written: bool) -> bool { + self.allow().is_none_or(|patterns| { + if as_written { + patterns + .iter() + .any(|p| tw_engine::rule::glob_match(p, model)) + } else { + catalog.allows(model, patterns) + } + }) + } + + /// 这一家要的名字(`asked`,见 [`tw_engine::Engine::asked_of`])发出去叫什么:先过密钥的 + /// `allow`,再按别名表对到这一家(指定的、阶段二改的原样)。`decision` 是这一次的决定, + /// `client` 是客户端写的模型名。要的是空的(没写模型名)什么都不改、不判断 + fn name( + &self, + catalog: &tw_engine::Catalog, + decision: &tw_engine::Decision, + client: &str, + asked: &tw_engine::Asked, + ) -> Result { + if asked.model.is_empty() { + return Ok(String::new()); + } + let written = asked.origin.as_written(); + if !self.allows(catalog, &asked.model, written) { + return Err(Unsent::Barred(self.barred(decision, client, asked))); + } + if written { + return Ok(asked.model.clone()); + } + self.resolve(catalog, &asked.model) + .ok_or_else(|| Unsent::Unserved(self.unserved(client, &asked.model))) + } + + /// 一帧 `response.create`(`frame`)发给这一家的样子。这一帧发不出去时是告诉客户端的那个 + /// 错误:规则拒绝了它、规则求不了值、密钥不让用要发的模型,或者这一家服务不了要的别名。 + fn frame( &self, catalog: &tw_engine::Catalog, frame: &serde_json::Value, - ) -> Result { - // 一帧的其余字段就是一个 Responses 请求:阶段二的条件按它读出的性质求值 + ) -> Result { + // 一帧的其余字段就是一个 Responses 请求:规则的条件按它读出的性质求值 let mut facts = crate::client_api::read(&self.path, None, Some(frame)).facts; facts.client = self.client.clone(); + let decision = self.this_frame(&facts)?; let p = &self.provider; - let renamed = match self.engine.phase_two(&facts, &p.name, &self.decision.set) { - Ok(tw_engine::Outcome2::Proceed { model, .. }) => model, + let (mut set, renamed) = match self.engine.phase_two(&facts, &p.name, &decision.set) { + Ok(tw_engine::Outcome2::Proceed { set, model, .. }) => (set, model), Ok(tw_engine::Outcome2::Deny { rule, reason }) => { tracing::info!(%rule, provider = %p.name, "a phase-two rule denied a WebSocket request"); - return Err(GatewayError::denied(msg!( - "gw.route.denied", rule = rule.clone(), reason = reason => - "Rule `{rule}` denied this request: {reason}" - ))); - } - Err(e) => { - return Err(GatewayError::config(msg!( - "gw.route.rule_failed", detail = e => "A rule could not be evaluated: {detail}" - ))); + return Err(denied(rule, reason)); } + Err(e) => return Err(rule_failed(e)), }; let asked = self .engine - .asked_of(&facts, &self.decision, &p.name, renamed.as_deref()); - // 指定的、阶段二改的原样发;要的是空的(这一帧没写模型名)什么都不改 - if asked.model.is_empty() || asked.origin.as_written() { - return Ok(asked.model); + .asked_of(&facts, &decision, &p.name, renamed.as_deref()); + let model = self + .name(catalog, &decision, &facts.model, &asked) + .map_err(Unsent::into_error)?; + // Codex 后端不认最大输出:HTTP 那条路发给它之前也会删掉(`crate::chatgpt::shape_passthrough`) + if p.effective_protocol() == Some(tw_config::Protocol::Chatgpt) { + set.max_tokens = None; } - self.resolve(catalog, &asked.model) - .ok_or_else(|| self.unserved(&facts.model, &asked.model)) + Ok(Outgoing { model, set }) + } + + /// 这一帧的决定。**去向是升级时的**(候选、经过的组、指定的模型),参数改写按这一帧重新 + /// 求:阶段一从所有命中的规则累积 `set`,条件按这一帧的性质看,和 HTTP 那条路一样。 + /// + /// 按这一帧求出来是拒绝的,这一帧不发:一条按模型拒绝的规则在 HTTP 那条路上拦得住,在这条 + /// 路上也要拦得住 —— 升级时还不知道模型,那时拦不到它。 + fn this_frame( + &self, + facts: &tw_engine::RequestFacts, + ) -> Result { + match self.engine.route(facts) { + Ok(tw_engine::Outcome::Route(d)) => Ok(tw_engine::Decision { + set: d.set, + rewritten_by: d.rewritten_by, + ..self.decision.clone() + }), + Ok(tw_engine::Outcome::Deny { rule, reason }) => { + tracing::info!(%rule, "a rule denied a WebSocket request"); + Err(denied(rule, reason)) + } + Err(e) => Err(GatewayError::config(msg!( + "gw.route.failed", detail = e => "Routing failed: {detail}" + ))), + } + } + + /// 模型写在升级请求里的连接(Realtime 查询串里的 `model`,就是 `facts.model`)连不连这一家、 + /// 发给它的模型名。和一帧 `response.create` 同一套([`Self::name`]),按升级时的决定跑一遍。 + /// + /// 阶段二拒绝了、而这一家本来连得上的,才算拒绝:和 HTTP 那条路一样,服务不了的候选在尝试 + /// 之前就跳过了,轮不到它的阶段二。 + pub fn connect( + &self, + catalog: &tw_engine::Catalog, + facts: &tw_engine::RequestFacts, + ) -> Connect { + let p = &self.provider; + let (renamed, rewritten_by, refused) = + match self.engine.phase_two(facts, &p.name, &self.decision.set) { + Ok(tw_engine::Outcome2::Proceed { + model, + rewritten_by, + .. + }) => (model, rewritten_by, None), + Ok(tw_engine::Outcome2::Deny { rule, reason }) => ( + None, + Vec::new(), + Some((Some(rule.clone()), denied(rule, reason))), + ), + Err(e) => (None, Vec::new(), Some((None, rule_failed(e)))), + }; + let asked = self + .engine + .asked_of(facts, &self.decision, &p.name, renamed.as_deref()); + match ( + self.name(catalog, &self.decision, &facts.model, &asked), + refused, + ) { + (Err(Unsent::Barred(why)), _) => Connect::Skip { barred: true, why }, + (Err(Unsent::Unserved(why)), _) => Connect::Skip { barred: false, why }, + (Ok(_), Some((rule, why))) => Connect::Refused { rule, why }, + (Ok(model), None) => Connect::To { + model, + rewritten_by, + }, + } + } + + /// 密钥不让用要发的名字:和 HTTP 那条路的准入同几句话,说出名字是哪来的。`client` 是 + /// 客户端写的 + fn barred( + &self, + decision: &tw_engine::Decision, + client: &str, + asked: &tw_engine::Asked, + ) -> GatewayError { + let key = self.client.clone(); + let why = match asked.origin { + tw_engine::Origin::Client => msg!( + "gw.model.not_allowed", key = key, model = client => + "Gateway key `{key}` may not use model {model}. GET /v1/models lists the \ + models that are available." + ), + tw_engine::Origin::Rule | tw_engine::Origin::PhaseTwo => msg!( + "gw.model.not_allowed_rewritten", from = client, model = asked.model.clone(), + key = key => + "A routing rule rewrote model {from} to {model}, which gateway key `{key}` may not \ + use. GET /v1/models lists the models that are available." + ), + tw_engine::Origin::Pinned => msg!( + "gw.model.pinned_not_allowed", rule = decision.matched_rule.clone(), + model = asked.model.clone(), upstream = self.provider.name.clone(), key = key => + "Rule `{rule}` pins model {model} on `{upstream}`, which gateway key `{key}` may \ + not use." + ), + }; + GatewayError::request(why) } /// 这一家服务不了别名 `alias`。`client` 是这一帧写的:和别名不一样就是阶段一的规则改写的 @@ -259,6 +535,21 @@ impl Naming { } } +/// 规则拒绝了这个请求:和 HTTP 那条路同一句 +fn denied(rule: String, reason: String) -> GatewayError { + GatewayError::denied(msg!( + "gw.route.denied", rule = rule, reason = reason => + "Rule `{rule}` denied this request: {reason}" + )) +} + +/// 规则求不了值:和 HTTP 那条路同一句 +fn rule_failed(e: tw_engine::RouteError) -> GatewayError { + GatewayError::config(msg!( + "gw.route.rule_failed", detail = e => "A rule could not be evaluated: {detail}" + )) +} + /// 一次连接里两个方向各自的状态。 struct Pipes { /// **整条连接一本账。**每帧各起一本的话,第二帧的 @@ -318,8 +609,11 @@ pub async fn proxy( let name = &upstream.provider.name; // 这一跳记发出去的模型名,和 HTTP 那条路一样。一条连接跑好几轮、每一帧写的模型可能 // 不一样,升级时定得下来的只有每一帧都发的那个(指定模型、阶段一的改写,见 - // [`Naming::fixed`]) - let model = naming.as_ref().and_then(|n| n.fixed(&state.catalog.load())); + // [`Naming::fixed`]);Realtime 的连接是查询串里的模型对过的名字 + let model = upstream + .model + .clone() + .or_else(|| naming.as_ref().and_then(|n| n.fixed(&state.catalog.load()))); let attempt = match &connected { Ok(_) => crate::server::hop( name, @@ -343,9 +637,9 @@ pub async fn proxy( route: upstream.route, rule: upstream.rule, group: upstream.group, - // 升级请求没有正文,规则附加的参数改写不作用在这条路上(只有模型名,见 [`Naming`]); - // 阶段二每一帧才跑,那时路由事件早就发了 - rewritten_by: Vec::new(), + // 升级时就作用上的那几条(Realtime 的连接改写了查询串里的模型)。Responses 的连接上 + // 参数改写每一帧按这一帧求(见 [`Naming`]),那时路由事件早就发了 + rewritten_by: upstream.rewritten_by, denied_by: None, affinity: None, attempts: vec![attempt], @@ -780,13 +1074,13 @@ enum Refusal { /// 切断这条连接,告诉客户端的是这句话:插件拒绝了这个请求,和内容过滤拒掉一帧一样 Cut(Msg), /// 只是这一帧不发,替它回一个 `response.failed`(见 [`failed_frame`]),连接照常:要的 - /// 别名这一家服务不了、阶段二的规则拒绝了它。下一帧要的可能就是这一家有的 + /// 别名这一家服务不了、密钥不让用要发的模型、规则拒绝了它。下一帧要的可能就是能发的 Frame(GatewayError), } -/// 一次 `response.create`:定发给这一家的模型名([`Naming`]),过插件的请求钩子。`text` 是 -/// 查过内容过滤的那一帧(删过的话是删过的样子)。返回要发给上游的那一帧:插件改过的话是 -/// 改过的,模型名写成发给这一家的那个。别的帧原样。 +/// 一次 `response.create`:定发给这一家的模型名和参数改写([`Naming`]),过插件的请求钩子。 +/// `text` 是查过内容过滤的那一帧(删过的话是删过的样子)。返回要发给上游的那一帧:插件改过 +/// 的话是改过的,模型名写成发给这一家的那个,规则的参数改写写进去。别的帧原样。 /// /// 这条路只有一跳:上游是这条连接连的那一家,插件的运行记在第 0 跳上。插件改过的那一版 /// **再查一遍内容过滤**,只报插件加进来的(客户端的原话已经在 [`screen_frame`] 查过了,见 @@ -806,15 +1100,16 @@ async fn request(state: &AppState, p: &mut Pipes, text: &str) -> Result Result sent, - Some(r) => p - .naming - .as_ref() - .and_then(|n| n.resolve(catalog, &r.model)) - .ok_or_else(|| { + let sent = match (&c.renamed, p.naming.as_ref()) { + (Some(r), Some(n)) => { + // 插件换上的名字,这把密钥用不用得了:和 HTTP 那条路同一关、同一句 + // (`server::pipeline::plug` 的 `allowed`)。插件写的是客户端那一侧的名字 + if !n.allows(catalog, &r.model, false) { + return Err(Refusal::Frame(GatewayError::request(msg!( + "gw.plugin.model_not_allowed", + plugin = r.by.clone(), model = r.model.clone(), key = n.client.clone() => + "Plugin `{plugin}` changed the model to {model}, which gateway key `{key}` may not \ + use, so the request was not sent." + )))); + } + n.resolve(catalog, &r.model).ok_or_else(|| { // 和 HTTP 那条路同一句(`server::pipeline::plug` 的 `sent_name`):这条路只有 // 一跳,「不发往那一家」就是这一帧不发 Refusal::Frame(GatewayError::request(msg!( @@ -881,7 +1183,9 @@ async fn plugin_request( "Plugin `{plugin}` changed the model to the alias {model}, and upstream \ `{upstream}` offers none of its models, so the request was not sent there." ))) - })?, + })? + } + _ => sent, }; let out = screen_changed( state, @@ -893,19 +1197,30 @@ async fn plugin_request( Ok((out, sent)) } -/// 这一帧的模型名写成 `model`。已经是它(或者 `model` 是空的)就一个字节都不动 -fn with_model(text: String, model: &str) -> String { - if model.is_empty() { - return text; - } - let Ok(mut v) = serde_json::from_str::(&text) else { - return text; +/// 这一帧发出去的样子:模型名写成 `model`,规则的参数改写(`set` 的最大输出、关思考)照 HTTP +/// 那条路直通时的写法改([`crate::forward::apply_set`],按 Responses 的字段名)。`set` 里的 +/// 模型名不看:发出去的是 `model`。什么都不用改(模型名已经是它或者 `model` 是空的,又没有 +/// 参数改写)就一个字节都不动 +fn rewrite(text: String, model: &str, set: &tw_engine::SetAction) -> String { + let mut set = tw_engine::SetAction { + model: None, + ..set.clone() }; - if v.get("model").and_then(|m| m.as_str()) == Some(model) { + if !model.is_empty() { + let written = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("model")?.as_str().map(str::to_string)); + if written.as_deref() != Some(model) { + set.model = Some(model.to_string()); + } + } + if set.is_empty() { return text; } - tw_dialect::params::set_model(tw_dialect::ir::Dialect::Responses, &mut v, model); - v.to_string() + let body = bytes::Bytes::from(text); + let out = crate::forward::apply_set(&body, &set, Some(tw_dialect::ir::Dialect::Responses)); + String::from_utf8(Vec::from(out)) + .unwrap_or_else(|_| String::from_utf8_lossy(&body).into_owned()) } /// 插件改过的那一帧再查一遍内容过滤:**只报插件加进来的**(见 [`crate::guard::rescreen`])。 @@ -1093,6 +1408,26 @@ mod tests { assert_eq!(upstream_url("http://h", "/x", Some("")), "ws://h/x"); } + #[test] + fn the_realtime_model_is_read_from_and_written_into_the_query() { + assert!(realtime("/v1/realtime")); + assert!(realtime("/realtime/")); + assert!(!realtime("/v1/responses")); + assert_eq!( + query_model(Some("intent=chat&model=gpt-realtime%2Dmini")).as_deref(), + Some("gpt-realtime-mini") + ); + assert_eq!(query_model(Some("model=a+b%")).as_deref(), Some("a b%")); + assert_eq!(query_model(Some("model=&x=1")), None); + assert_eq!(query_model(Some("models=x")), None); + assert_eq!(query_model(None), None); + // 只换 `model` 那一项,别的项一个字节都不动 + assert_eq!( + with_query_model("x=%2F&model=voice&y", "us.anthropic.v1:0/x y"), + "x=%2F&model=us.anthropic.v1:0%2Fx%20y&y" + ); + } + #[test] fn the_gateway_key_in_the_query_never_reaches_the_upstream() { assert_eq!( diff --git a/crates/tw-gateway/tests/alias_ws.rs b/crates/tw-gateway/tests/alias_ws.rs index 774e8eaa..f437a6f3 100644 --- a/crates/tw-gateway/tests/alias_ws.rs +++ b/crates/tw-gateway/tests/alias_ws.rs @@ -414,3 +414,394 @@ async fn a_plugin_renaming_to_an_alias_sends_the_upstreams_own_name() { ["codex", "sonnet-please", "gpt-5.1-codex-mini"] ); } + +// ---------------------------------------------------------------- 密钥的模型范围与参数改写 + +/// 只许用 `allow` 里那几个模型的配置 +fn scoped(p: Provider, aliases: &str, rules: &str, allow: &[&str]) -> Config { + let mut c = config(p, aliases, rules); + c.clients[0].allow = Some(allow.iter().map(|s| s.to_string()).collect()); + c +} + +/// 密钥的 `allow` 每一帧都看:范围外的模型这一帧不发,替它回一个说清楚的 `response.failed`, +/// 连接照常 —— 下一帧要的是范围里的就照发。和 HTTP 那条路同一句话 +#[tokio::test] +async fn a_frame_asking_for_a_model_outside_the_keys_allow_is_refused_and_the_connection_stays() { + let (up, seen) = upstream().await; + let (gw, _) = gateway(scoped(provider(up, &[]), "", "", &["gpt-5*"]), vec![]).await; + let mut c = connect(gw).await; + + c.send(create("other-model")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] Gateway key `codex` may not use model other-model. GET /v1/models lists the \ + models that are available." + ); + assert!(seen.lock().unwrap().is_empty()); + + c.send(create("gpt-5.1-codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(answered(&frames), ["gpt-5.1-codex-2026-09-30"; 2]); + assert_eq!(seen.lock().unwrap().len(), 1); + assert_eq!(seen.lock().unwrap()[0]["model"], "gpt-5.1-codex"); +} + +/// 和 HTTP 那条路同一套继承:写上游模型名的 `allow` 也放行列了它的别名,写别名的只管别名 +/// 本身。原样发出的名字(阶段二改的、指定的)按名字本身看,没有别名可继承;阶段一改写成的 +/// 是客户端那一侧的名称,照样继承 +#[tokio::test] +async fn the_keys_allow_is_inherited_by_aliases_over_websocket_like_over_http() { + let aliases = format!("{FAST}{SONNET}"); + let rules = " +- { name: 阶段一改别名, when: { model: fast-please }, set: { model: codex-fast } } +- { name: 阶段二改名, when: { provider_would_be: up, model: special }, set: { model: codex-fast } } +- { name: 兜底, to: __all__ } +"; + let (up, seen) = upstream().await; + let (gw, _) = gateway( + scoped(provider(up, SCOPE), &aliases, rules, &["gpt-5.1-*"]), + vec![], + ) + .await; + let mut c = connect(gw).await; + + // codex-fast 列了 gpt-5.1-codex-mini:放行,发这一家的名字 + c.send(create("codex-fast")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + answered(&frames), + ["codex-fast", "codex-fast"], + "{frames:?}" + ); + assert_eq!(seen.lock().unwrap()[0]["model"], "gpt-5.1-codex-mini"); + + // claude-sonnet 列的名字一个都不在范围里 + c.send(create("claude-sonnet")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] Gateway key `codex` may not use model claude-sonnet. GET /v1/models lists \ + the models that are available." + ); + + // 阶段一改写成别名:客户端那一侧的名称,照样继承 + c.send(create("fast-please")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + answered(&frames), + ["fast-please", "fast-please"], + "{frames:?}" + ); + assert_eq!(seen.lock().unwrap()[1]["model"], "gpt-5.1-codex-mini"); + + // 阶段二改的名字原样发:按 codex-fast 这个名字本身看,不在范围里 + c.send(create("special")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] A routing rule rewrote model special to codex-fast, which gateway key \ + `codex` may not use. GET /v1/models lists the models that are available." + ); + assert_eq!(seen.lock().unwrap().len(), 2); + + // 写别名的只管别名本身:它列的模型不跟着放出来 + let (up, seen) = upstream().await; + let (gw, _) = gateway( + scoped(provider(up, SCOPE), FAST, "", &["codex-fast"]), + vec![], + ) + .await; + let mut c = connect(gw).await; + c.send(create("gpt-5.1-codex-mini")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] Gateway key `codex` may not use model gpt-5.1-codex-mini. GET /v1/models \ + lists the models that are available." + ); + c.send(create("codex-fast")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(answered(&frames), ["codex-fast", "codex-fast"]); + assert_eq!(seen.lock().unwrap().len(), 1); +} + +/// 规则指定的模型密钥不让用:每一帧都不发,说出是哪条规则指定在哪一家。插件换上范围外的 +/// 名字:那一帧不发,和 HTTP 那条路同一句 +#[tokio::test] +async fn a_pinned_model_or_a_plugin_rename_outside_the_keys_allow_is_refused_per_frame() { + let (up, seen) = upstream().await; + let rules = " +- { name: Codex 指定模型, when: { client: codex }, to: [{ provider: up, model: o3-pro }] } +- { name: 兜底, to: __all__ } +"; + let (gw, _) = gateway(scoped(provider(up, &[]), "", rules, &["gpt-5*"]), vec![]).await; + let mut c = connect(gw).await; + c.send(create("gpt-5.1-codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] Rule `Codex 指定模型` pins model o3-pro on `up`, which gateway key `codex` \ + may not use." + ); + assert!(seen.lock().unwrap().is_empty()); + + let swap = Double::new("swap") + .permit(&[Permission::Params]) + .on_request(|mut view, ctx| { + if ctx["requested_model"] == "swap-me" { + view["params"]["model"] = json!("o3-pro"); + } + Invocation::ok(RequestOutcome::Changed(view)) + }); + let mut a = double::active("swap", swap); + a.name = "Plugin swap".into(); + let (up, seen) = upstream().await; + let (gw, _) = gateway( + scoped(provider(up, &[]), "", "", &["gpt-5*", "swap-me"]), + vec![Arc::new(a)], + ) + .await; + let mut c = connect(gw).await; + c.send(create("swap-me")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] Plugin `Plugin swap` changed the model to o3-pro, which gateway key `codex` \ + may not use, so the request was not sent." + ); + assert!(seen.lock().unwrap().is_empty()); + c.send(create("gpt-5.1-codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(answered(&frames), ["gpt-5.1-codex-2026-09-30"; 2]); +} + +/// 一帧 `response.create`,要的是 `model`,开着推理 +fn create_reasoning(model: &str) -> WsMsg { + let WsMsg::Text(t) = create(model) else { + unreachable!() + }; + let mut v: Value = serde_json::from_str(&t).unwrap(); + v["reasoning"] = json!({ "effort": "high" }); + WsMsg::Text(v.to_string().into()) +} + +/// 规则的参数改写每一帧照 HTTP 那条路改(Responses 的字段名):`set.max_tokens` 写成 +/// `max_output_tokens`,`set.thinking: false` 去掉 `reasoning`。条件按这一帧求 —— 按模型附加的 +/// 改写、按模型拒绝的规则,升级时还不知道模型,每一帧照样生效 +#[tokio::test] +async fn rule_parameter_rewrites_and_model_conditions_apply_to_each_frame() { + let (up, seen) = upstream().await; + let rules = " +- { name: Codex 限长, when: { client: codex }, set: { max_tokens: 1234 } } +- { name: Max 不思考, when: { model: gpt-5.1-codex-max }, set: { thinking: false, max_tokens: 99 } } +- { name: 不给, when: { model: banned }, deny: 这个模型不走这里 } +- { name: 兜底, to: __all__ } +"; + let (gw, _) = gateway(config(provider(up, &[]), "", rules), vec![]).await; + let mut c = connect(gw).await; + + c.send(create_reasoning("gpt-5.1-codex")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!(answered(&frames), ["gpt-5.1-codex-2026-09-30"; 2]); + { + let seen = seen.lock().unwrap(); + assert_eq!(seen[0]["max_output_tokens"], 1234, "{:?}", seen[0]); + assert_eq!(seen[0]["reasoning"]["effort"], "high"); + // 别的字段原样 + assert_eq!(seen[0]["instructions"], "You are Codex."); + assert_eq!(seen[0]["type"], "response.create"); + } + + c.send(create_reasoning("gpt-5.1-codex-max")).await.unwrap(); + one_answer(&mut c).await; + { + let seen = seen.lock().unwrap(); + assert_eq!(seen[1]["max_output_tokens"], 99, "{:?}", seen[1]); + assert!(seen[1].get("reasoning").is_none(), "{:?}", seen[1]); + assert_eq!(seen[1]["model"], "gpt-5.1-codex-max"); + } + + c.send(create("banned")).await.unwrap(); + let frames = one_answer(&mut c).await; + assert_eq!( + refusal(&frames), + "[ThinkWatch] Rule `不给` denied this request: 这个模型不走这里" + ); + assert_eq!(seen.lock().unwrap().len(), 2); + + // 没有改写的帧一个字节都不动 + let (up, seen) = upstream().await; + let (gw, _) = gateway(config(provider(up, &[]), "", ""), vec![]).await; + let mut c = connect(gw).await; + c.send(create_reasoning("gpt-5.1-codex")).await.unwrap(); + one_answer(&mut c).await; + let WsMsg::Text(t) = create_reasoning("gpt-5.1-codex") else { + unreachable!() + }; + assert_eq!( + seen.lock().unwrap()[0], + serde_json::from_str::(&t).unwrap() + ); +} + +// ---------------------------------------------------------------- Realtime + +/// Realtime 那样的上游:记下升级请求的查询串,回一帧 `session.created` +async fn realtime_upstream() -> (SocketAddr, Arc>>) { + let queries: Arc>> = Arc::default(); + let app = Router::new() + .route( + "/v1/realtime", + axum::routing::any( + |State(q): State>>>, + axum::extract::RawQuery(query): axum::extract::RawQuery, + ws: WebSocketUpgrade| async move { + q.lock().unwrap().push(query.unwrap_or_default()); + ws.on_upgrade(|mut sock: WebSocket| async move { + let created = json!({ "type": "session.created" }).to_string(); + let _ = sock.send(Message::Text(created.into())).await; + while let Some(Ok(_)) = sock.recv().await {} + }) + }, + ), + ) + .with_state(queries.clone()); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(l, app).await.unwrap() }); + (addr, queries) +} + +/// 连 Realtime:`query` 是网关这边的查询串 +async fn realtime( + gw: SocketAddr, + query: &str, +) -> Result { + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let mut req = format!("ws://{gw}/v1/realtime?{query}") + .into_client_request() + .unwrap(); + req.headers_mut() + .insert("x-api-key", "tw-wskey".parse().unwrap()); + tokio_tungstenite::connect_async(req).await.map(|(s, _)| s) +} + +/// 升级被拒时回的那句话 +fn upgrade_refusal(e: tokio_tungstenite::tungstenite::Error) -> (u16, String) { + let tokio_tungstenite::tungstenite::Error::Http(r) = e else { + panic!("the upgrade failed without an answer: {e}"); + }; + let body = String::from_utf8_lossy(r.body().as_deref().unwrap_or_default()).into_owned(); + (r.status().as_u16(), body) +} + +/// Realtime 的模型写在查询串里:别名对到这一家自己的名称再发(别的项原样),尝试链上记它, +/// 流量里记客户端写的那个 +#[tokio::test] +async fn a_realtime_alias_in_the_query_connects_with_the_upstreams_own_name() { + let (up, queries) = realtime_upstream().await; + let aliases = "voice: [gpt-realtime-2, gpt-realtime]\n"; + let (gw, mut events) = + gateway(config(provider(up, &["gpt-realtime"]), aliases, ""), vec![]).await; + let mut c = realtime(gw, "model=voice&intent=chat").await.unwrap(); + let first = tokio::time::timeout(Duration::from_secs(3), c.next()) + .await + .expect("no frame") + .unwrap() + .unwrap(); + assert!(first.into_text().unwrap().contains("session.created")); + assert_eq!( + queries.lock().unwrap().as_slice(), + ["model=gpt-realtime&intent=chat"] + ); + let mut started = None; + let attempt = loop { + let ev = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .expect("no routing event") + .unwrap(); + match ev { + Event::RequestStarted { model, .. } => started = Some(model), + Event::RequestRouted { attempts, .. } => break attempts[0].model.clone(), + _ => {} + } + }; + assert_eq!(started.as_deref(), Some("voice")); + assert_eq!(attempt.as_deref(), Some("gpt-realtime")); + + // 不是别名的照旧原样,查询串一个字节都不动 + let mut c = realtime(gw, "model=gpt-realtime%2Bx&intent=chat") + .await + .unwrap(); + c.next().await; + assert_eq!( + queries.lock().unwrap()[1], + "model=gpt-realtime%2Bx&intent=chat" + ); + + // 规则指定的模型原样发 + let (up, queries) = realtime_upstream().await; + let rules = " +- { name: 语音指定, when: { model: voice }, to: [{ provider: up, model: gpt-realtime-pinned }] } +- { name: 兜底, to: __all__ } +"; + let (gw, _) = gateway( + config(provider(up, &["gpt-realtime"]), aliases, rules), + vec![], + ) + .await; + let mut c = realtime(gw, "model=voice").await.unwrap(); + c.next().await; + assert_eq!( + queries.lock().unwrap().as_slice(), + ["model=gpt-realtime-pinned"] + ); +} + +/// Realtime 的模型密钥不让用:升级本身被拒,和 HTTP 那条路同一句话;上游连都没连。别名照样 +/// 继承写上游模型名的 `allow`。这一家服务不了要的别名:也不连 +#[tokio::test] +async fn a_realtime_model_outside_the_keys_allow_refuses_the_upgrade() { + let (up, queries) = realtime_upstream().await; + let aliases = format!("voice: [gpt-realtime-2, gpt-realtime]\n{SONNET}"); + let (gw, _) = gateway( + scoped( + provider(up, &["gpt-realtime", "other-model"]), + &aliases, + "", + &["gpt-realtime*"], + ), + vec![], + ) + .await; + + let (status, body) = upgrade_refusal(realtime(gw, "model=other-model").await.err().unwrap()); + assert_eq!(status, 400); + assert!( + body.contains( + "Gateway key `codex` may not use model other-model. GET /v1/models lists the models \ + that are available." + ), + "{body}" + ); + assert!(queries.lock().unwrap().is_empty()); + + let mut c = realtime(gw, "model=voice").await.unwrap(); + c.next().await; + assert_eq!(queries.lock().unwrap().as_slice(), ["model=gpt-realtime"]); + + let (gw, _) = gateway( + config(provider(up, &["gpt-realtime"]), &aliases, ""), + vec![], + ) + .await; + let (status, body) = upgrade_refusal(realtime(gw, "model=claude-sonnet").await.err().unwrap()); + assert_eq!(status, 400); + assert!( + body.contains("which offers none of the models of alias claude-sonnet"), + "{body}" + ); + assert_eq!(queries.lock().unwrap().len(), 1); +} diff --git a/release-notes/0.62.0.md b/release-notes/0.62.0.md index 2eed2e85..c2e1935a 100644 --- a/release-notes/0.62.0.md +++ b/release-notes/0.62.0.md @@ -12,7 +12,7 @@ This release adds model aliases: one name for the same model across upstreams, e - `KnownModel` gains `alias` (the alias's models, when the entry is an alias) and `aliases` (the aliases that list a model); `GET /models` lists aliases. `ModelRow` gains `aliases`. - The dry-run result gains `candidate_models`, one `DryRunCandidate { provider, sent_model, model_via }` per candidate. - Message codes, compared with 0.61.0: - - New: `config.alias_blank_model`, `config.alias_chained`, `config.alias_duplicate`, `config.alias_empty_name`, `config.alias_no_models`, `config.alias_only_itself`, `config.alias_reserved`, `config.alias_wildcard`; `engine.pinned_blank_model`, `engine.pinned_duplicate_provider`, `engine.pinned_empty`, `engine.pinned_unknown_provider`, `engine.pinned_with_provider_would_be`, `engine.pinned_with_set_model`; `gw.model.alias_unserved`, `gw.model.alias_unserved_rewritten`, `gw.model.pinned_not_allowed`, `gw.model.pinned_not_offered`, `gw.plugin.alias_unserved`; `control.replay_alias_unserved`. + - New: `config.alias_blank_model`, `config.alias_chained`, `config.alias_duplicate`, `config.alias_empty_name`, `config.alias_no_models`, `config.alias_only_itself`, `config.alias_reserved`, `config.alias_wildcard`; `engine.pinned_blank_model`, `engine.pinned_duplicate_provider`, `engine.pinned_empty`, `engine.pinned_unknown_provider`, `engine.pinned_with_provider_would_be`, `engine.pinned_with_set_model`; `gw.model.alias_unserved`, `gw.model.alias_unserved_rewritten`, `gw.model.pinned_not_allowed`, `gw.model.pinned_not_offered`, `gw.plugin.alias_unserved`, `gw.ws.alias_unserved`, `gw.ws.alias_unserved_rewritten`; `control.replay_alias_unserved`. - None removed. **Model aliases.** An alias is another name for the same model, written in a top-level `aliases` table as the alias and the names upstreams use for it, in order. @@ -22,6 +22,7 @@ This release adds model aliases: one name for the same model across upstreams, e - A key's `allow` and a rule's `when.model` written for an upstream model name also cover the aliases that list it; written for an alias, they cover only the alias. - `set.model` may name an alias. Prices, the upstream check-up and each recorded attempt use the name sent upstream; the request log keeps the name the client asked for. - Replay, the inference speed test and plugin renames resolve an alias for each upstream the same way. +- WebSocket connections follow the same rules as HTTP requests. Each `response.create` on a Responses connection is sent under the connected upstream's own name for an alias (a pinned model or a phase-two rename as written), is checked against the key's `allow` with the same inheritance, and gets the rules' `set.max_tokens`, `set.thinking` and `deny` as evaluated for that request; one that can't be sent is answered with `response.failed` and the connection stays open. A Realtime connection (`/v1/realtime`) is routed by the model in its query string, which the key must be allowed to use and which reaches the upstream under its own name. Before, WebSocket connections were not checked against `allow`, and the rules' parameter rewrites and model conditions did not apply to them. - The control plane manages aliases (`/aliases`), previews a draft (which upstream would receive which name, upstream models of the same name it would take over, other upstreams' names for the same Claude model) and renames an alias together with the keys' `allow` entries and the rule conditions that name it. **Pinned models.** A routing rule's `to` may list upstreams with the model to send each, tried in order. The model is sent as written: aliases and `set.model` do not apply. A rule can so send one key's requests to one upstream's model even when an alias of the same name points elsewhere. From 4fce88f75e9f8c0149b8446951ee213fe5d45eb1 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:40:08 +0800 Subject: [PATCH 18/18] Release notes: allow enforcement that 0.62.0 adds, and the new skip reason Co-Authored-By: Claude Opus 5.5 --- release-notes/0.62.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/release-notes/0.62.0.md b/release-notes/0.62.0.md index 1807467d..f81a8a2e 100644 --- a/release-notes/0.62.0.md +++ b/release-notes/0.62.0.md @@ -6,11 +6,13 @@ This release adds model aliases: one name for the same model across upstreams, e - The request store's schema is unchanged: upgrading from 0.61.0 keeps the request history. - The configuration format only gains fields: a top-level `aliases` table, and `to` in a routing rule may now be a list of pinned models. Every 0.61.0 configuration loads unchanged. - Answers to requests whose model was renamed on the way (by an alias, a rule's `set.model`, a pinned model or a plugin) now carry the name the client asked for, as long as the upstream answered with the model it was sent. Before, they carried the upstream's name. +- A key's `allow` is now enforced in three places it used to be skipped: when no upstream reports a model list, on WebSocket connections (Responses and Realtime), and on every failover candidate, including pinned models. A client that was using a model outside its key's `allow` through one of these gaps is now refused. - Changed in the protocol: - New: `GET /aliases` (`AliasesView`), `POST /aliases` and `PUT /aliases/{name}` (`AliasSave` → `ConfigWritten` / `AliasWritten`), `DELETE /aliases/{name}`, `POST /alias-preview` (`AliasPreviewRequest` → `AliasPreview`) and `GET /aliases/{name}/usage` (`AliasUsage`). - `RuleView.to` and `RuleInput.to` are a `RuleTarget`: an upstream or group name as before, or a list of `PinnedModel { provider, model }`. - `KnownModel` gains `alias` (the alias's models, when the entry is an alias) and `aliases` (the aliases that list a model); `GET /models` lists aliases. `ModelRow` gains `aliases`. - The dry-run result gains `candidate_models`, one `DryRunCandidate { provider, sent_model, model_via }` per candidate. + - `ServeSkip` gains `not_allowed`: a candidate skipped because the key may not use the model it would be sent. - Moved, with the endpoint names unchanged: `POST /providers/test` → `POST /provider-test`, `POST /providers/preview` → `POST /provider-preview`, `POST /proxies/test` → `POST /proxy-test`, `POST /plugins/inspect` → `POST /plugin-inspect`, `POST /plugins/rewrite` → `POST /plugin-rewrite`, `POST /plugins/confirmed` → `POST /plugin-confirmed` and `PUT /plugins/order` → `PUT /plugin-order`. At the old paths they shadowed an upstream, proxy or plugin of the same name: an upstream named `test` or `preview`, or a proxy named `test`, could not be edited or deleted. `order`, `inspect`, `rewrite` and `confirmed` are now valid plugin ids. - Message codes, compared with 0.61.0: - New: `config.alias_blank_model`, `config.alias_chained`, `config.alias_duplicate`, `config.alias_empty_name`, `config.alias_no_models`, `config.alias_only_itself`, `config.alias_reserved`, `config.alias_wildcard`; `engine.pinned_blank_model`, `engine.pinned_duplicate_provider`, `engine.pinned_empty`, `engine.pinned_unknown_provider`, `engine.pinned_with_provider_would_be`, `engine.pinned_with_set_model`; `gw.model.alias_unserved`, `gw.model.alias_unserved_rewritten`, `gw.model.pinned_not_allowed`, `gw.model.pinned_not_offered`, `gw.plugin.alias_unserved`, `gw.ws.alias_unserved`, `gw.ws.alias_unserved_rewritten`; `control.replay_alias_unserved`.