Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions crates/host-core/src/plugins/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String, Value>) -> Vec<String> {
let Some(levels) = model.get("thinkingLevels").and_then(Value::as_array) else {
return Vec::new();
};
let declared: Vec<String> = 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<String, Value>) -> Option<String> {
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
Expand Down Expand Up @@ -138,8 +179,8 @@ pub(crate) fn declared_providers(manifest: &PluginManifest) -> Vec<DeclaredPlugi
.and_then(Value::as_u64)
.and_then(|value| u32::try_from(value).ok())
.unwrap_or(0),
thinking_levels: Vec::new(),
default_thinking_level: None,
thinking_levels: declared_thinking_levels(model),
default_thinking_level: declared_default_thinking_level(model),
supports_images: model
.get("supportsImages")
.and_then(Value::as_bool),
Expand Down
70 changes: 70 additions & 0 deletions crates/host-core/src/plugins/providers/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,3 +595,73 @@ fn dropping_the_api_key_auth_kind_clears_the_stored_key() {
.unwrap()
.is_none());
}

/// A manifest whose first model declares a reasoning menu.
fn manifest_with_thinking_levels() -> 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());
}
11 changes: 11 additions & 0 deletions packages/plugin-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down
Loading