mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
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:
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):
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
//!
|
||||
|
||||
mod codex_link;
|
||||
mod grok_link;
|
||||
mod mcp;
|
||||
mod parser;
|
||||
mod prompt;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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\"");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3385,6 +3385,7 @@
|
||||
"importError": "インポートに失敗しました",
|
||||
"configSource": "設定ソース",
|
||||
"codexConfigScopeHint": "このプロバイダーに属する設定(エンドポイント、認証、モデル、推論レベルなど)のみをインポートします。[features]、MCP、権限などのグローバル設定はインポートされません。",
|
||||
"grokConfigScopeHint": "[models] default が指すモデルテーブル(表示名、説明、API バックエンド、コンテキストウィンドウなど)のみをインポートします。エンドポイントとキーはリンクのパラメーターを使用し、他のモデルテーブルはインポートされません。",
|
||||
"configEmbedded": "埋め込み設定",
|
||||
"configRemote": "リモート設定",
|
||||
"configDetails": "設定の詳細",
|
||||
|
||||
@@ -3386,6 +3386,7 @@
|
||||
"importError": "匯入失敗",
|
||||
"configSource": "設定來源",
|
||||
"codexConfigScopeHint": "只匯入其中屬於這個供應商的設定(位址、驗證、模型、推理強度等);[features]、MCP、權限這類全域設定不會匯入。",
|
||||
"grokConfigScopeHint": "只匯入 [models] default 指向的那張模型表(顯示名稱、說明、介面類型、視窗等);位址和 Key 使用連結參數,其他模型表不會匯入。",
|
||||
"configEmbedded": "內嵌設定",
|
||||
"configRemote": "遠端設定",
|
||||
"configDetails": "設定詳情",
|
||||
|
||||
@@ -3385,6 +3385,7 @@
|
||||
"importError": "导入失败",
|
||||
"configSource": "配置来源",
|
||||
"codexConfigScopeHint": "只导入其中属于这个供应商的设置(地址、鉴权、模型、推理强度等);[features]、MCP、权限这类全局设置不会导入。",
|
||||
"grokConfigScopeHint": "只导入 [models] default 指向的那张模型表(显示名、说明、接口类型、窗口等);地址和 Key 用链接参数,其他模型表不导入。",
|
||||
"configEmbedded": "内嵌配置",
|
||||
"configRemote": "远程配置",
|
||||
"configDetails": "配置详情",
|
||||
|
||||
Reference in new issue
Block a user