fix(grokbuild): point session_summary at the active provider and keep a link's model profile

Session titles: when `models.session_summary` is unset, Grok Build
(verified on 1.0.50) generates titles with the built-in `grok-4.6`
model but sends the request to the default model table's endpoint, so
third-party upstreams reject it and title generation fails. Switching
never wrote this key, so fixing the row templates alone would not
reach live. The Grok switch patch now makes session_summary follow the
provider when it is unset or names a table CC Switch wrote (the
target, the write record, or a table retired in this switch): it points
at the target table, or is removed when switching to the official
account. A user's own choice (a built-in model or their own table) is
left alone.

Deep links: a Grok Build link carrying a config.toml no longer gets
rebuilt into a fixed template that dropped the profile's display name,
description, API backend and context window. The table named by
`[models] default` is kept through an allowlist of Grok 1.0.50 profile
fields; the endpoint, key and model come from the link parameters.
Links whose table uses auth_provider, env_http_headers or
mtls_cert_dir are rejected, and env_key is still dropped. Only one
model table is imported, since a provider owns one table in live.
Links without a config keep the old template byte for byte.

Fixes #7003
Refs #6457
This commit is contained in:
Jason committed 2026-10-12 23:26:43 +08:00
1 parent c63c702c70
commit db93ac2083
16 files changed
+547 -33

No files matched your search

@@ -67,6 +67,8 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `usageUserId` | No | Usage query user ID |
| `usageAutoInterval` | No | Auto query interval (minutes) |
For Grok Build, `config` likewise takes `{"config": "<config.toml text>"}` or the whole config.toml with `configFormat=toml`. Only the `[model."<name>"]` table named by `[models] default` is imported (display name, `description`, `api_backend`, `context_window`, sampling and retry settings, etc.); the endpoint and key always come from the link parameters, and other model tables are skipped. Links whose table uses `auth_provider`, `env_http_headers` or `mtls_cert_dir` are rejected, and `env_key` is ignored.
**Prompt parameters** (resource=prompt):
| Parameter | Required | Description |
@@ -67,6 +67,8 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `usageUserId` | いいえ | 使用量クエリユーザー ID |
| `usageAutoInterval` | いいえ | 自動クエリ間隔(分) |
Grok Build の `config` も `{"config": "<config.toml のテキスト>"}` の形式、または `configFormat=toml` で config.toml 全体を渡せます。`[models] default` が指す `[model."<名前>"]` テーブル(表示名、`description`、`api_backend`、`context_window`、サンプリング・リトライ設定など)のみをインポートし、エンドポイントとキーは常にリンクのパラメーターを使用します。他のモデルテーブルはインポートされません。テーブルに `auth_provider`、`env_http_headers`、`mtls_cert_dir` を含むリンクは拒否され、`env_key` は無視されます。
**プロンプトパラメータ**(resource=prompt):
| パラメータ | 必須 | 説明 |
@@ -67,6 +67,8 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `usageUserId` | 否 | 用量查询用户 ID |
| `usageAutoInterval` | 否 | 自动查询间隔(分钟) |
Grok Build 的 `config` 同样可写成 `{"config": "<config.toml 文本>"}` 或用 `configFormat=toml` 给整份:只导入 `[models] default` 指向的那张 `[model."<名称>"]` 表(显示名、`description`、`api_backend`、`context_window`、采样和重试参数等),地址和 Key 一律用链接参数,其他模型表不导入;表里带 `auth_provider`、`env_http_headers`、`mtls_cert_dir` 的链接会被拒绝,`env_key` 会被忽略。
**提示词参数**(resource=prompt):
| 参数 | 必填 | 说明 |
+225
View File
@@ -0,0 +1,225 @@
//! Grok Build 供应商链接附带的 `config.toml`:只收选中的那张 `[model."<名称>"]` 表。
//!
//! 一个供应商在 live 里只拥有一张模型表(`live/project/grok.rs`),所以链接里的其他表不
//! 导入。选中的表按白名单收:显示名、说明、接口类型、窗口、采样和重试参数等照搬;地址、
//! Key、模型名用合并后的值(URL 参数优先)。
//!
//! 链接是不可信输入:让 Grok 运行凭据提供器(`auth_provider`)、读环境变量
//! (`env_http_headers`)或本机证书(`mtls_cert_dir`)的表直接拒绝;`env_key` 丢掉,Key
//! 用链接自己给的(只给了 `env_key` 的链接由调用方拒绝)。
use toml_edit::{DocumentMut, Item, Table, TableLike, Value};
use crate::error::AppError;
use crate::grok_config::{DEFAULT_API_BACKEND, DEFAULT_CONTEXT_WINDOW};
/// 可以从链接带进来的键(Grok Build 1.0.50 的模型表,去掉地址、凭据和下面拒绝的)。
const PROFILE_KEYS: &[&str] = &[
"name",
"description",
"notice",
"model_family",
"api_backend",
"auth_scheme",
"extra_headers",
"query_params",
"context_window",
"context_windows",
"max_completion_tokens",
"temperature",
"top_p",
"max_request_bytes",
"auto_compact_threshold_percent",
"system_prompt_label",
"use_concise",
"agent_type",
"inference_idle_timeout_secs",
"max_retries",
"rate_limit_retry_threshold",
"subagent_rate_limit_max_attempts",
"hidden",
"supports_reasoning_effort",
"reasoning_efforts",
"supports_backend_search",
"compaction_at_tokens",
"show_model_fingerprint",
"stream_tool_calls",
"reasoning_summary",
];
/// 运行凭据提供器、读环境变量或本机证书:链接带了就拒绝导入。
const REJECTED_PROFILE_KEYS: &[&str] = &["auth_provider", "env_http_headers", "mtls_cert_dir"];
/// 链接里选中的模型表,已按白名单过滤。
pub(super) struct GrokLinkProfile {
/// 表名(`models.default` 指向的名字)。
pub name: String,
table: Table,
model: Option<String>,
base_url: Option<String>,
api_key: Option<String>,
/// 表用 `env_key` 取 Key。
pub declares_env_key: bool,
}
impl GrokLinkProfile {
pub(super) fn parse(text: &str) -> Result<Self, AppError> {
let doc = text
.parse::<DocumentMut>()
.map_err(|e| AppError::InvalidInput(format!("Invalid Grok Build config.toml: {e}")))?;
let (name, source) = selected_profile(&doc)?;
if let Some(key) = REJECTED_PROFILE_KEYS
.iter()
.find(|key| source.get(key).is_some())
{
return Err(AppError::InvalidInput(format!(
"The Grok Build config uses '{key}', which makes Grok run a credential provider or \
read credentials from this computer; it cannot be imported from a link. Add the \
provider manually instead."
)));
}
let mut table = Table::new();
for key in PROFILE_KEYS {
if let Some(item) = source.get(key) {
table.insert(key, undecorated(item));
}
}
Ok(Self {
name,
table,
model: non_empty_str(source.get("model")),
base_url: non_empty_str(source.get("base_url")),
api_key: non_empty_str(source.get("api_key")),
declares_env_key: source.get("env_key").is_some(),
})
}
pub(super) fn model(&self) -> Option<String> {
self.model.clone()
}
pub(super) fn base_url(&self) -> Option<String> {
self.base_url.clone()
}
pub(super) fn api_key(&self) -> Option<String> {
self.api_key.clone()
}
/// 写成供应商行的 `config`。地址、Key、模型名用合并后的值;表里没写显示名、接口类型、
/// 窗口时补上和模板一样的默认值(live 的形状校验要求它们)。
pub(super) fn render(
&self,
display_name: &str,
model: &str,
endpoint: &str,
api_key: &str,
) -> String {
let mut table = Table::new();
table.insert("model", toml_edit::value(model));
table.insert("base_url", toml_edit::value(endpoint));
table.insert(
"name",
self.table
.get("name")
.filter(|item| non_empty_str(Some(item)).is_some())
.cloned()
.unwrap_or_else(|| toml_edit::value(display_name)),
);
table.insert("api_key", toml_edit::value(api_key));
table.insert(
"api_backend",
self.table
.get("api_backend")
.cloned()
.unwrap_or_else(|| toml_edit::value(DEFAULT_API_BACKEND)),
);
table.insert(
"context_window",
self.table
.get("context_window")
.cloned()
.unwrap_or_else(|| toml_edit::value(DEFAULT_CONTEXT_WINDOW)),
);
for (key, item) in self.table.iter() {
if !table.contains_key(key) {
table.insert(key, item.clone());
}
}
let mut doc = DocumentMut::new();
let mut models = Table::new();
models.insert("default", toml_edit::value(&self.name));
doc.insert("models", Item::Table(models));
let mut model_tables = Table::new();
model_tables.set_implicit(true);
model_tables.insert(&self.name, Item::Table(table));
doc.insert("model", Item::Table(model_tables));
doc.to_string()
}
}
/// `models.default` 指向的表;没写或指不到时取唯一的一张表。
fn selected_profile(doc: &DocumentMut) -> Result<(String, &dyn TableLike), AppError> {
let tables = doc
.get("model")
.and_then(Item::as_table_like)
.ok_or_else(|| {
AppError::InvalidInput(
"The Grok Build config has no [model.\"<name>\"] table to import".to_string(),
)
})?;
let default = non_empty_str(doc.get("models").and_then(|models| models.get("default")));
if let Some(name) = default {
if let Some(table) = tables.get(&name).and_then(Item::as_table_like) {
return Ok((name, table));
}
}
let mut candidates = tables
.iter()
.filter_map(|(name, item)| Some((name.to_string(), item.as_table_like()?)));
match (candidates.next(), candidates.next()) {
(Some(only), None) => Ok(only),
(None, _) => Err(AppError::InvalidInput(
"The Grok Build config has no [model.\"<name>\"] table to import".to_string(),
)),
_ => Err(AppError::InvalidInput(
"The Grok Build config has several [model.*] tables; set [models] default to the one \
to import"
.to_string(),
)),
}
}
fn non_empty_str(item: Option<&Item>) -> Option<String> {
item.and_then(Item::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string)
}
fn undecorated_value(value: &Value) -> Value {
let mut value = value.clone();
value.decor_mut().clear();
value
}
/// 去掉原文的注释和空白;子表保留成子表。
fn undecorated(item: &Item) -> Item {
match item {
Item::Value(value) => Item::Value(undecorated_value(value)),
Item::Table(table) => {
let mut table = table.clone();
table.decor_mut().clear();
table.set_implicit(false);
for (_, value) in table.iter_mut() {
if let Some(value) = value.as_value_mut() {
value.decor_mut().clear();
}
}
Item::Table(table)
}
other => other.clone(),
}
}
+1
View File
@@ -9,6 +9,7 @@
//!
mod codex_link;
mod grok_link;
mod mcp;
mod parser;
mod prompt;
+33 -22
View File
@@ -3,6 +3,7 @@
//! Handles importing provider configurations via ccswitch:// URLs.
use super::codex_link::{self, CodexLinkConfig};
use super::grok_link::GrokLinkProfile;
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
use super::DeepLinkImportRequest;
use crate::error::AppError;
@@ -149,7 +150,7 @@ pub(crate) fn build_provider_from_request(
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
AppType::Codex => build_codex_settings(request)?,
AppType::Gemini => build_gemini_settings(request),
AppType::GrokBuild => build_grokbuild_settings(request),
AppType::GrokBuild => build_grokbuild_settings(request)?,
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_additive_app_settings(request),
AppType::Hermes => build_hermes_settings(request),
@@ -491,7 +492,11 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
json!({ "env": env })
}
fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
/// 链接附带了 `config.toml` 时,按白名单收下选中的那张模型表([`grok_link`]);没带时用
/// 固定模板。模板的字节由 golden 测试锁住(降级后旧版会把行里的 config 整份写进 live)。
fn build_grokbuild_settings(
request: &DeepLinkImportRequest,
) -> Result<serde_json::Value, AppError> {
let model = request
.model
.as_deref()
@@ -507,18 +512,23 @@ fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Valu
let endpoint = get_primary_endpoint(request).trim().to_string();
let api_key = request.api_key.as_deref().unwrap_or("").trim();
if let Some(config) = decode_link_config(request)? {
let profile = GrokLinkProfile::parse(&grokbuild_config_text(&config)?)?;
return Ok(json!({ "config": profile.render(name, model, &endpoint, api_key) }));
}
let model_value = toml_edit::Value::from(model).to_string();
let name_value = toml_edit::Value::from(name).to_string();
let endpoint_value = toml_edit::Value::from(endpoint.as_str()).to_string();
let api_key_value = toml_edit::Value::from(api_key).to_string();
json!({
Ok(json!({
"config": format!(
"[models]\ndefault = {model_value}\n\n[model.{model_value}]\nmodel = {model_value}\nbase_url = {endpoint_value}\nname = {name_value}\napi_key = {api_key_value}\napi_backend = \"{}\"\ncontext_window = {}\n",
crate::grok_config::DEFAULT_API_BACKEND,
crate::grok_config::DEFAULT_CONTEXT_WINDOW,
)
})
}))
}
/// Build OpenCode settings configuration
@@ -867,23 +877,24 @@ fn merge_gemini_config(
Ok(())
}
/// 链接 config 参数里的 Grok Build `config.toml` 文本:`{"config": "<toml 文本>"}`,或者
/// 整份就是 config.toml(`configFormat=toml`,或 JSON 写的同一份内容)。
fn grokbuild_config_text(config: &serde_json::Value) -> Result<String, AppError> {
if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) {
return Ok(config_toml.to_string());
}
let toml_value: toml::Value = serde_json::from_value(config.clone())
.map_err(|error| AppError::InvalidInput(format!("Invalid Grok Build config: {error}")))?;
toml::to_string(&toml_value)
.map_err(|error| AppError::InvalidInput(format!("Invalid Grok Build config: {error}")))
}
fn merge_grokbuild_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
let config_toml = if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) {
config_toml.to_string()
} else {
let toml_value: toml::Value = serde_json::from_value(config.clone()).map_err(|error| {
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
})?;
toml::to_string(&toml_value).map_err(|error| {
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
})?
};
let model = crate::grok_config::extract_model_config(&config_toml).ok_or_else(|| {
AppError::InvalidInput("Invalid Grok Build config.toml model profile".to_string())
})?;
// 先过白名单:拒绝的键在确认框打开时就报出来,而不是点了导入才失败
let profile = GrokLinkProfile::parse(&grokbuild_config_text(config)?)?;
if request
.api_key
@@ -896,7 +907,7 @@ fn merge_grokbuild_config(
// victim's environment secret into the imported provider's config.toml
// and ship it to whatever `base_url` the link declares. `env_key` is an
// indirection that must stay a name, not a resolved secret, on import.
request.api_key = model.api_key;
request.api_key = profile.api_key();
// An `env_key`-only link is not importable at all, and saying so beats
// falling through to the generic "API key is required" (which reads like
@@ -909,7 +920,7 @@ fn merge_grokbuild_config(
.api_key
.as_ref()
.is_none_or(|value| value.is_empty())
&& model.env_key.is_some()
&& profile.declares_env_key
{
return Err(AppError::InvalidInput(
"This link supplies its API key indirectly through `env_key`, which cannot be \
@@ -924,10 +935,10 @@ fn merge_grokbuild_config(
.as_ref()
.is_none_or(|value| value.is_empty())
{
request.endpoint = Some(model.base_url);
request.endpoint = profile.base_url();
}
if request.model.is_none() {
request.model = Some(model.model);
request.model = profile.model();
}
if request
.homepage
@@ -1032,7 +1043,7 @@ mod tests {
});
merge_grokbuild_config(&mut request, &config).expect("merge should succeed");
let settings = build_grokbuild_settings(&request);
let settings = build_grokbuild_settings(&request).unwrap();
let rendered = settings["config"].as_str().expect("config string");
assert!(
+102
View File
@@ -1421,3 +1421,105 @@ fn test_codex_link_accepts_a_whole_config_toml() {
Some("Toml Relay")
);
}
// ============ Grok Build:链接附带的 config.toml ============
fn import_grok_with_config(config_toml: &str) -> Result<serde_json::Value, crate::error::AppError> {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("grokbuild".to_string()),
name: Some("Relay Card".to_string()),
endpoint: Some("https://api.relay.example/v1".to_string()),
api_key: Some("xai-link".to_string()),
config: Some(
BASE64_STANDARD.encode(serde_json::json!({ "config": config_toml }).to_string()),
),
config_format: Some("json".to_string()),
..Default::default()
};
let merged = parse_and_merge_config(&request)?;
Ok(build_provider_from_request(&AppType::GrokBuild, &merged)?.settings_config)
}
#[test]
fn test_grok_link_config_keeps_the_selected_profile_fields() {
// #6457:显示名、说明、窗口等来自链接的模型表;地址和 Key 用链接参数
let settings = import_grok_with_config(concat!(
"[models]\ndefault = \"grok-4.6\"\n\n",
"[model.\"grok-4.6\"]\nmodel = \"grok-4.6\"\nname = \"Grok 4.6 (Relay)\"\n",
"description = \"Fast coding model\"\n",
"base_url = \"https://untrusted.example/v1\"\napi_key = \"sk-in-config\"\n",
"env_key = \"RELAY_KEY\"\n",
"api_backend = \"chat_completions\"\ncontext_window = 256000\n",
"reasoning_summary = \"auto\"\nextra_headers = { \"x-relay\" = \"1\" }\n\n",
"[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nname = \"Grok 4.5\"\n",
"base_url = \"https://untrusted.example/v1\"\napi_key = \"sk-in-config\"\n",
"api_backend = \"responses\"\ncontext_window = 500000\n",
))
.unwrap();
let text = settings["config"].as_str().unwrap();
let doc: toml_edit::DocumentMut = text.parse().unwrap();
assert_eq!(doc["models"]["default"].as_str(), Some("grok-4.6"));
let table = &doc["model"]["grok-4.6"];
assert_eq!(table["name"].as_str(), Some("Grok 4.6 (Relay)"));
assert_eq!(table["description"].as_str(), Some("Fast coding model"));
assert_eq!(table["api_backend"].as_str(), Some("chat_completions"));
assert_eq!(table["context_window"].as_integer(), Some(256000));
assert_eq!(table["reasoning_summary"].as_str(), Some("auto"));
assert_eq!(table["extra_headers"]["x-relay"].as_str(), Some("1"));
assert_eq!(
table["base_url"].as_str(),
Some("https://api.relay.example/v1")
);
assert_eq!(table["api_key"].as_str(), Some("xai-link"));
assert!(table.get("env_key").is_none(), "{text}");
// 一个供应商只拥有一张模型表,其余的不导入
assert!(doc["model"].get("grok-4.5").is_none(), "{text}");
// 行能被投影(切换时写进 live 的就是这张表)
let projection = crate::live::project::grok::GrokProjection::of(&settings, false).unwrap();
assert_eq!(projection.table_name(), Some("grok-4.6"));
}
#[test]
fn test_grok_link_config_fills_template_defaults_for_missing_fields() {
let settings = import_grok_with_config(
"[models]\ndefault = \"relay\"\n\n[model.relay]\nmodel = \"relay-model\"\n",
)
.unwrap();
let doc: toml_edit::DocumentMut = settings["config"].as_str().unwrap().parse().unwrap();
let table = &doc["model"]["relay"];
assert_eq!(table["name"].as_str(), Some("Relay Card"));
assert_eq!(table["model"].as_str(), Some("relay-model"));
assert_eq!(
table["api_backend"].as_str(),
Some(crate::grok_config::DEFAULT_API_BACKEND)
);
assert_eq!(
table["context_window"].as_integer(),
Some(crate::grok_config::DEFAULT_CONTEXT_WINDOW)
);
crate::live::project::grok::GrokProjection::of(&settings, false).unwrap();
}
#[test]
fn test_grok_link_config_rejects_credential_providers_and_local_files() {
for (key, line) in [
("auth_provider", "auth_provider = \"corp-sso\"\n"),
(
"env_http_headers",
"env_http_headers = { \"x-key\" = \"HOME\" }\n",
),
("mtls_cert_dir", "mtls_cert_dir = \"/etc/ssl/private\"\n"),
] {
let config = format!(
"[models]\ndefault = \"relay\"\n\n[model.relay]\nmodel = \"relay-model\"\n{line}"
);
let err = import_grok_with_config(&config).unwrap_err();
assert!(err.to_string().contains(key), "{key}: {err}");
}
}
+117 -2
View File
@@ -251,6 +251,8 @@ impl GrokConfigPatch {
}
}
// 这次删掉的表(写入记录里的,加上代理契约留下的)
let mut retired_here: Vec<String> = Vec::new();
if let Some(tables) = table_mut(path, root, "model", false)? {
let placeholder = self.placeholder.as_deref();
let doomed: Vec<String> = tables
@@ -267,10 +269,12 @@ impl GrokConfigPatch {
})
.map(|(name, _)| name.to_string())
.collect();
for name in doomed {
tables.remove(&name);
for name in &doomed {
tables.remove(name);
}
retired_here = doomed;
}
self.follow_session_summary(path, root, target_name, &retired_here)?;
if let Some((name, table)) = &self.target {
let tables = table_mut(path, root, "model", true)?.expect("created");
@@ -305,6 +309,62 @@ impl GrokConfigPatch {
}
}
impl GrokConfigPatch {
/// `models.session_summary`:Grok 生成会话标题和摘要用的模型。没设时 Grok(1.0.50 实测)
/// 用内置的 `grok-4.6`,请求却发到默认模型表的地址,第三方上游不认这个模型名,标题生成
/// 就失败(#7003)。
///
/// 没设、或者指向 CC Switch 写的表(目标表、写入记录里的、这次删掉的)时跟着目标走:有
/// 目标就指向它,切到官方就删掉。指向用户自己的表或内置模型时不动。
fn follow_session_summary(
&self,
path: &Path,
root: &mut Table,
target_name: Option<&str>,
retired_here: &[String],
) -> Result<(), LiveWriteError> {
let current = root
.get("models")
.and_then(|models| models.get("session_summary"))
.and_then(Item::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string);
let follows = match current.as_deref() {
None => true,
Some(name) => {
Some(name) == target_name
|| self.retired.iter().any(|retired| retired == name)
|| retired_here.iter().any(|retired| retired == name)
}
};
if !follows {
return Ok(());
}
match target_name {
Some(name) => {
let models = table_mut(path, root, "models", true)?.expect("created");
match models.get_mut("session_summary") {
Some(Item::Value(slot)) => {
let decor = slot.decor().clone();
*slot = name.into();
*slot.decor_mut() = decor;
}
_ => {
models.insert("session_summary", toml_edit::value(name));
}
}
}
None => {
if let Some(models) = table_mut(path, root, "models", false)? {
models.remove("session_summary");
}
}
}
Ok(())
}
}
impl TomlDocPatch for GrokConfigPatch {
fn apply_to(&self, path: &Path, doc: &mut DocumentMut) -> Result<(), LiveWriteError> {
Self::apply_to(self, path, doc)
@@ -381,6 +441,61 @@ api_backend = "chat_completions"
context_window = 200000
"#;
/// #7003:没设 session_summary 时 Grok 用内置模型生成标题,第三方上游不认。
#[test]
fn session_summary_follows_the_written_table() {
let b = GrokProjection::of(&row(ROW_B), false).unwrap();
let summary = |out: &str| {
let doc: DocumentMut = out.parse().unwrap();
doc.get("models")
.and_then(|models| models.get("session_summary"))
.and_then(Item::as_str)
.map(str::to_string)
};
// 没设:指向目标表
let out = apply(&GrokConfigPatch::direct(&b, vec![], PLACEHOLDER), "");
assert_eq!(summary(&out).as_deref(), Some("b"), "{out}");
// 指向上一家写的表:跟着换
let live = ROW_A.replace(
"default = \"grok-4.5\"\n",
"default = \"grok-4.5\"\nsession_summary = \"grok-4.5\" # titles\n",
);
let out = apply(
&GrokConfigPatch::direct(&b, vec!["grok-4.5".into()], PLACEHOLDER),
&live,
);
assert_eq!(summary(&out).as_deref(), Some("b"), "{out}");
assert!(
out.contains("session_summary = \"b\" # titles"),
"keeps the comment: {out}"
);
// 指向用户自己选的模型:不动
let live = ROW_A.replace(
"default = \"grok-4.5\"\n",
"default = \"grok-4.5\"\nsession_summary = \"grok-4.6\"\n",
);
let out = apply(
&GrokConfigPatch::direct(&b, vec!["grok-4.5".into()], PLACEHOLDER),
&live,
);
assert_eq!(summary(&out).as_deref(), Some("grok-4.6"), "{out}");
// 切到官方:指向上一家的删掉,不留一个指不到表的名字
let official = GrokProjection::of(&row(""), true).unwrap();
let live = ROW_A.replace(
"default = \"grok-4.5\"\n",
"default = \"grok-4.5\"\nsession_summary = \"grok-4.5\"\n",
);
let out = apply(
&GrokConfigPatch::direct(&official, vec!["grok-4.5".into()], PLACEHOLDER),
&live,
);
assert_eq!(out, "");
}
#[test]
fn a_default_that_points_nowhere_falls_back_to_the_only_table() {
let polluted = ROW_A.replace("default = \"grok-4.5\"", "default = \"grok-4.6\"");
+27
View File
@@ -106,6 +106,33 @@ fn deeplink_grokbuild_rows() {
);
}
/// 带 `config.toml` 的 Grok Build 链接:只收 `models.default` 指向的那张表,地址和 Key
/// 用链接参数。
#[test]
fn deeplink_grokbuild_config_rows() {
use base64::prelude::*;
let config = serde_json::json!({
"config": concat!(
"[models]\ndefault = \"grok-4.6\"\n\n",
"[model.\"grok-4.6\"]\nmodel = \"grok-4.6\"\nname = \"Grok 4.6 (Relay)\"\n",
"description = \"Fast coding model\"\n",
"base_url = \"https://ignored.example/v1\"\napi_key = \"sk-ignored\"\n",
"api_backend = \"chat_completions\"\ncontext_window = 256000\n\n",
"[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nname = \"Grok 4.5\"\n",
)
});
let url = format!(
"ccswitch://v1/import?resource=provider&app=grokbuild&name=Relay%20Grok%20Config&endpoint=https%3A%2F%2Fapi.relay.example%2Fv1&apiKey=xai-relay&configFormat=json&config={}",
BASE64_URL_SAFE_NO_PAD.encode(config.to_string())
);
import_deeplinks(
AppType::GrokBuild,
&[&url],
"rows/deeplink-grokbuild-config.txt",
);
}
/// 首启:先把 live 导入成 `default`,再 seed 官方预设(顺序同 `lib.rs` 的启动流程)。
/// live 里供应商以外的部分进了通用配置片段,片段也一起锁。关键字段(模型名、推理档位
/// 等)不进片段、留在 `default` 行里:片段已冻结,收进去就再也写不回 live。
@@ -0,0 +1,13 @@
id: relaygrokconfig-<ts>
name: Relay Grok Config
settings_config: {"config":"[models]\ndefault = \"grok-4.6\"\n\n[model.\"grok-4.6\"]\nmodel = \"grok-4.6\"\nbase_url = \"https://api.relay.example/v1\"\nname = \"Grok 4.6 (Relay)\"\napi_key = \"xai-relay\"\napi_backend = \"chat_completions\"\ncontext_window = 256000\ndescription = \"Fast coding model\"\n"}
website_url: https://relay.example
category: NULL
sort_index: NULL
notes: NULL
icon: NULL
icon_color: NULL
meta: {}
is_current: true
in_failover_queue: false
+13 -3
View File
@@ -35,6 +35,15 @@ context_window = 500000
)
}
/// 切换时 `models.session_summary` 跟着目标模型表走(#7003),live 比行多这一行。
fn with_session_summary(config: &str) -> String {
config.replacen(
"default = \"grok-4.5\"\n",
"default = \"grok-4.5\"\nsession_summary = \"grok-4.5\"\n",
1,
)
}
#[test]
fn grokbuild_import_and_switch_write_live_config() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
@@ -82,7 +91,7 @@ fn grokbuild_import_and_switch_write_live_config() {
assert_eq!(
std::fs::read_to_string(&live_path).expect("read switched Grok Build config"),
next_config
with_session_summary(&next_config)
);
assert_eq!(
state
@@ -128,11 +137,12 @@ fn grokbuild_switch_back_after_client_changed_default_model() {
switch_provider_test_hook(&state, AppType::GrokBuild, "a").expect("switch to provider a");
assert_eq!(
std::fs::read_to_string(&live_path).expect("read live after switching to a"),
relay_a
with_session_summary(&relay_a)
);
// Simulate Grok's `/settings` → Default model picking the built-in grok-4.6.
let client_edited = relay_a.replace("default = \"grok-4.5\"", "default = \"grok-4.6\"");
let client_edited =
with_session_summary(&relay_a).replace("default = \"grok-4.5\"", "default = \"grok-4.6\"");
std::fs::write(&live_path, &client_edited).expect("simulate client edit");
switch_provider_test_hook(&state, AppType::GrokBuild, "b").expect("switch to provider b");
+6 -6
View File
@@ -620,12 +620,12 @@ export function DeepLinkImportDialog() {
<pre className="text-xs font-mono bg-surface p-2 rounded overflow-auto max-h-24 whitespace-pre-wrap break-all">
{parsedConfig.tomlConfig}
</pre>
{/* 后端按白名单收(deeplink/codex_link.rs),原文里的全局设置不会导入 */}
{parsedConfig.type === "codex" && (
<p className="text-xs text-fg-2">
{t("deeplink.codexConfigScopeHint")}
</p>
)}
{/* 后端按白名单收(deeplink/codex_link.rs、grok_link.rs),原文不会整份导入 */}
<p className="text-xs text-fg-2">
{parsedConfig.type === "codex"
? t("deeplink.codexConfigScopeHint")
: t("deeplink.grokConfigScopeHint")}
</p>
</div>
)}
</div>
+1
View File
@@ -3385,6 +3385,7 @@
"importError": "Failed to import",
"configSource": "Config Source",
"codexConfigScopeHint": "Only the settings that belong to this provider (endpoint, auth, model, reasoning effort, etc.) are imported; global settings such as [features], MCP and permissions are skipped.",
"grokConfigScopeHint": "Only the model table named by [models] default is imported (display name, description, API backend, context window, etc.); the endpoint and key come from the link parameters, and other model tables are skipped.",
"configEmbedded": "Embedded Config",
"configRemote": "Remote Config",
"configDetails": "Config Details",
+1
View File
@@ -3385,6 +3385,7 @@
"importError": "インポートに失敗しました",
"configSource": "設定ソース",
"codexConfigScopeHint": "このプロバイダーに属する設定(エンドポイント、認証、モデル、推論レベルなど)のみをインポートします。[features]、MCP、権限などのグローバル設定はインポートされません。",
"grokConfigScopeHint": "[models] default が指すモデルテーブル(表示名、説明、API バックエンド、コンテキストウィンドウなど)のみをインポートします。エンドポイントとキーはリンクのパラメーターを使用し、他のモデルテーブルはインポートされません。",
"configEmbedded": "埋め込み設定",
"configRemote": "リモート設定",
"configDetails": "設定の詳細",
+1
View File
@@ -3386,6 +3386,7 @@
"importError": "匯入失敗",
"configSource": "設定來源",
"codexConfigScopeHint": "只匯入其中屬於這個供應商的設定(位址、驗證、模型、推理強度等);[features]、MCP、權限這類全域設定不會匯入。",
"grokConfigScopeHint": "只匯入 [models] default 指向的那張模型表(顯示名稱、說明、介面類型、視窗等);位址和 Key 使用連結參數,其他模型表不會匯入。",
"configEmbedded": "內嵌設定",
"configRemote": "遠端設定",
"configDetails": "設定詳情",
+1
View File
@@ -3385,6 +3385,7 @@
"importError": "导入失败",
"configSource": "配置来源",
"codexConfigScopeHint": "只导入其中属于这个供应商的设置(地址、鉴权、模型、推理强度等);[features]、MCP、权限这类全局设置不会导入。",
"grokConfigScopeHint": "只导入 [models] default 指向的那张模型表(显示名、说明、接口类型、窗口等);地址和 Key 用链接参数,其他模型表不导入。",
"configEmbedded": "内嵌配置",
"configRemote": "远程配置",
"configDetails": "配置详情",