diff --git a/crates/host-core/src/plugins/providers.rs b/crates/host-core/src/plugins/providers.rs index d55a1954cc..99064164a3 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,45 @@ 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 @@ -138,8 +179,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 33016d9fa9..c4230defca 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -473,6 +473,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; }; /**