From e8ead44d4992f982f59a1e5ec5080a814e55bbe6 Mon Sep 17 00:00:00 2001 From: ZxlDragonDoctor <2252406579@qq.com> Date: Mon, 21 Sep 2026 19:20:09 +0800 Subject: [PATCH 1/2] fix(plugins): preserve declared thinkingLevels on plugin providers --- crates/host-core/src/plugins/providers.rs | 44 ++++++++++++- .../host-core/src/plugins/providers/tests.rs | 64 +++++++++++++++++++ packages/plugin-sdk/src/index.ts | 11 ++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/crates/host-core/src/plugins/providers.rs b/crates/host-core/src/plugins/providers.rs index 791de08061..111e1e590e 100644 --- a/crates/host-core/src/plugins/providers.rs +++ b/crates/host-core/src/plugins/providers.rs @@ -2,7 +2,9 @@ use super::*; use rusqlite::{params, OptionalExtension}; use crate::db::{now_ms, Database}; -use crate::providers::{self, delete_provider_row, provider_owner_plugin, ModelBinding}; +use crate::providers::{ + self, delete_provider_row, normalize_thinking_levels, provider_owner_plugin, ModelBinding, +}; use crate::secrets::SecretStore; /// Upper bound on `contributes.providers` entries. Matches the SDK constant. @@ -69,6 +71,42 @@ pub(crate) struct DeclaredPluginProvider { pub(crate) fn plugin_provider_row_id(plugin_id: &str, declared_id: &str) -> String { format!("{PLUGIN_PROVIDER_ID_PREFIX}{plugin_id}:{declared_id}") } + +/// Thinking levels a declared model offers, as stated by the plugin. +/// +/// Only canonical names survive, and each appears once, so a declaration cannot +/// publish a menu entry the runtime would refuse to send. An absent or unusable +/// list stays empty, which readers already treat as "no menu to offer". +fn declared_thinking_levels(model: &serde_json::Map) -> Vec { + let Some(levels) = model.get("thinkingLevels").and_then(Value::as_array) else { + return Vec::new(); + }; + let declared: Vec = levels + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(); + normalize_thinking_levels(&declared) +} + +/// The level a declared model opens on, when the plugin names one that exists. +/// +/// A name outside the model's own list is dropped rather than stored: the +/// runtime selects a default by matching it against the available levels, and a +/// value that matches nothing would silently fall back to the first entry while +/// the row claimed otherwise. +fn declared_default_thinking_level(model: &serde_json::Map) -> Option { + let named = model.get("defaultThinkingLevel").and_then(Value::as_str)?.trim(); + if named.is_empty() { + return None; + } + let levels = declared_thinking_levels(model); + levels + .iter() + .any(|level| level == named) + .then(|| named.to_string()) +} + /// Read `contributes.providers` off a manifest that has already passed /// `validate_contributions`. Shapes that validation rejects are skipped here /// rather than re-reported: this function runs on every load and must not be @@ -137,8 +175,8 @@ pub(crate) fn declared_providers(manifest: &PluginManifest) -> Vec PluginManifest { + let mut value = serde_json::to_value(manifest()).unwrap(); + value["contributes"]["providers"][0]["models"][0]["thinkingLevels"] = + json!(["off", "low", "high"]); + value["contributes"]["providers"][0]["models"][0]["defaultThinkingLevel"] = json!("high"); + serde_json::from_value(value).unwrap() +} + +#[test] +fn declared_thinking_levels_survive_into_the_row() { + let (_dir, db, secrets) = test_context(); + let declared = declared_providers(&manifest_with_thinking_levels()); + assert_eq!(declared[0].models[0].thinking_levels, ["off", "low", "high"]); + assert_eq!( + declared[0].models[0].default_thinking_level.as_deref(), + Some("high") + ); + // The second model declares nothing, so it keeps offering no menu. + assert!(declared[0].models[1].thinking_levels.is_empty()); + assert!(declared[0].models[1].default_thinking_level.is_none()); + + sync_plugin_providers(&db, &secrets, "demo.provider", &declared, true).unwrap(); + let row = &providers::list_providers(&db, &secrets, true).unwrap()[0]; + assert_eq!(row.models[0].thinking_levels, ["off", "low", "high"]); + assert_eq!( + row.models[0].default_thinking_level.as_deref(), + Some("high") + ); +} + +#[test] +fn declared_thinking_levels_are_normalized() { + let mut value = serde_json::to_value(manifest()).unwrap(); + // "ultra" is not a canonical level and "low" repeats, so the stored list + // must come back canonical and deduplicated rather than as declared. + value["contributes"]["providers"][0]["models"][0]["thinkingLevels"] = + json!(["low", "ultra", "low", " high ", 7]); + let manifest: PluginManifest = serde_json::from_value(value).unwrap(); + let declared = declared_providers(&manifest); + assert_eq!(declared[0].models[0].thinking_levels, ["low", "high"]); +} + +#[test] +fn a_default_outside_the_declared_list_is_dropped() { + let mut value = serde_json::to_value(manifest_with_thinking_levels()).unwrap(); + // Naming a level the model does not offer must not be stored: the runtime + // would silently open on a different one while the row claimed otherwise. + value["contributes"]["providers"][0]["models"][0]["defaultThinkingLevel"] = json!("max"); + let manifest: PluginManifest = serde_json::from_value(value).unwrap(); + let declared = declared_providers(&manifest); + assert_eq!(declared[0].models[0].thinking_levels, ["off", "low", "high"]); + assert!(declared[0].models[0].default_thinking_level.is_none()); +} + +#[test] +fn a_malformed_thinking_levels_field_is_ignored() { + let mut value = serde_json::to_value(manifest()).unwrap(); + value["contributes"]["providers"][0]["models"][0]["thinkingLevels"] = json!("high"); + let manifest: PluginManifest = serde_json::from_value(value).unwrap(); + let declared = declared_providers(&manifest); + assert!(declared[0].models[0].thinking_levels.is_empty()); +} diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index fc46d98c33..4774b7e009 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -387,6 +387,17 @@ export type PluginProviderModelContrib = { maxTokens?: number; /** Whether the model accepts image input. */ supportsImages?: boolean; + /** + * Thinking levels the picker may offer for this model, most restrictive + * first (e.g. `["off", "low", "high"]`). Names outside the canonical set are + * dropped. Omit for a model that exposes no reasoning control. + */ + thinkingLevels?: string[]; + /** + * Which of `thinkingLevels` a new session opens on. Ignored unless it names + * one of them. Omit to let the runtime pick the first available level. + */ + defaultThinkingLevel?: string; }; /** From c10c25db574fc54856c5e3fe87bcfbd54354aa38 Mon Sep 17 00:00:00 2001 From: vastsa Date: Mon, 21 Sep 2026 21:41:37 +0800 Subject: [PATCH 2/2] style(host-core): format plugin provider thinking levels --- crates/host-core/src/plugins/providers.rs | 5 ++++- crates/host-core/src/plugins/providers/tests.rs | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/host-core/src/plugins/providers.rs b/crates/host-core/src/plugins/providers.rs index 111e1e590e..1350b333d0 100644 --- a/crates/host-core/src/plugins/providers.rs +++ b/crates/host-core/src/plugins/providers.rs @@ -96,7 +96,10 @@ fn declared_thinking_levels(model: &serde_json::Map) -> Vec) -> Option { - let named = model.get("defaultThinkingLevel").and_then(Value::as_str)?.trim(); + let named = model + .get("defaultThinkingLevel") + .and_then(Value::as_str)? + .trim(); if named.is_empty() { return None; } diff --git a/crates/host-core/src/plugins/providers/tests.rs b/crates/host-core/src/plugins/providers/tests.rs index 0e5bbc5802..339058d719 100644 --- a/crates/host-core/src/plugins/providers/tests.rs +++ b/crates/host-core/src/plugins/providers/tests.rs @@ -607,7 +607,10 @@ fn manifest_with_thinking_levels() -> PluginManifest { fn declared_thinking_levels_survive_into_the_row() { let (_dir, db, secrets) = test_context(); let declared = declared_providers(&manifest_with_thinking_levels()); - assert_eq!(declared[0].models[0].thinking_levels, ["off", "low", "high"]); + assert_eq!( + declared[0].models[0].thinking_levels, + ["off", "low", "high"] + ); assert_eq!( declared[0].models[0].default_thinking_level.as_deref(), Some("high") @@ -645,7 +648,10 @@ fn a_default_outside_the_declared_list_is_dropped() { value["contributes"]["providers"][0]["models"][0]["defaultThinkingLevel"] = json!("max"); let manifest: PluginManifest = serde_json::from_value(value).unwrap(); let declared = declared_providers(&manifest); - assert_eq!(declared[0].models[0].thinking_levels, ["off", "low", "high"]); + assert_eq!( + declared[0].models[0].thinking_levels, + ["off", "low", "high"] + ); assert!(declared[0].models[0].default_thinking_level.is_none()); }