feat(acp+): models/providers in desktop on ACP+ (#9987)
This commit is contained in:
@@ -35,7 +35,7 @@ etcetera = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
include_dir = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
lopdf = { version = "0.41", default-features = false }
|
||||
lopdf = { version = "0.42", default-features = false }
|
||||
docx-rs = { version = "0.4.18", default-features = false, features = ["image"] }
|
||||
image = { version = "0.24.4", default-features = false, features = ["bmp", "dds", "dxt", "farbfeld", "gif", "hdr", "ico", "jpeg", "jpeg_rayon", "openexr", "png", "pnm", "tga", "tiff", "webp"] }
|
||||
umya-spreadsheet = { version = "2", default-features = false }
|
||||
|
||||
@@ -527,6 +527,12 @@ pub struct DefaultsSaveRequest {
|
||||
pub model_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Clear Goose default provider and model configuration.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/unstable/defaults/clear", response = DefaultsReadResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DefaultsClearRequest {}
|
||||
|
||||
/// Sources that onboarding knows how to discover and import.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -862,6 +868,107 @@ pub struct ProviderConfigChangeResponse {
|
||||
pub refresh: RefreshProviderInventoryResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderSecretStorageDto {
|
||||
#[default]
|
||||
SecretStore,
|
||||
ProviderCache,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderSecretStatusDto {
|
||||
Valid,
|
||||
Expired,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderSecretDto {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub provider_display_name: String,
|
||||
pub name: String,
|
||||
pub storage: ProviderSecretStorageDto,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
pub status: ProviderSecretStatusDto,
|
||||
pub configured: bool,
|
||||
pub has_secret: bool,
|
||||
pub can_delete: bool,
|
||||
pub can_configure: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub configure_provider: Option<String>,
|
||||
}
|
||||
|
||||
/// List provider credentials stored locally by Goose.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(
|
||||
method = "_goose/unstable/providers/secrets/list",
|
||||
response = ProviderSecretsListResponse
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderSecretsListRequest {}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderSecretsListResponse {
|
||||
pub secrets: Vec<ProviderSecretDto>,
|
||||
}
|
||||
|
||||
/// Delete a locally stored provider credential by id.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(
|
||||
method = "_goose/unstable/providers/secrets/delete",
|
||||
response = EmptyResponse
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderSecretDeleteRequest {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CanonicalModelInfoDto {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub context_limit: usize,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<usize>,
|
||||
pub reasoning: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_token_cost: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub output_token_cost: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_token_cost: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_write_token_cost: Option<f64>,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
/// Look up canonical (bundled-registry) model info for a provider/model pair.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(
|
||||
method = "_goose/unstable/providers/canonical-model-info",
|
||||
response = CanonicalModelInfoResponse
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CanonicalModelInfoRequest {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CanonicalModelInfoResponse {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_info: Option<CanonicalModelInfoDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderTemplateCatalogEntryDto {
|
||||
|
||||
@@ -418,7 +418,6 @@ derive_utoipa!(IconTheme as IconThemeSchema);
|
||||
super::routes::config_management::cleanup_provider_cache,
|
||||
super::routes::config_management::check_provider,
|
||||
super::routes::config_management::set_config_provider,
|
||||
super::routes::config_management::configure_provider_oauth,
|
||||
super::routes::config_management::get_canonical_model_info,
|
||||
super::routes::prompts::get_prompts,
|
||||
super::routes::prompts::get_prompt,
|
||||
|
||||
@@ -7,7 +7,6 @@ use axum::{
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use goose::config::declarative_providers::LoadedProvider;
|
||||
use goose::config::paths::Paths;
|
||||
use goose::config::ExtensionEntry;
|
||||
@@ -20,7 +19,6 @@ use goose::providers::catalog::{
|
||||
ProviderTemplate,
|
||||
};
|
||||
use goose::providers::create_with_default_model;
|
||||
use goose::providers::huggingface_auth;
|
||||
use goose::providers::providers as get_providers;
|
||||
use goose::{
|
||||
agents::execute_commands, agents::ExtensionConfig, slash_commands::recipe_slash_command,
|
||||
@@ -29,10 +27,7 @@ use goose_providers::model::ModelConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_yaml;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -134,37 +129,9 @@ pub enum ConfigValueResponse {
|
||||
MaskedValue(MaskedSecret),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderSecretStorage {
|
||||
SecretStore,
|
||||
ProviderCache,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderSecretStatus {
|
||||
Valid,
|
||||
Expired,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProviderSecret {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub provider_display_name: String,
|
||||
pub name: String,
|
||||
pub storage: ProviderSecretStorage,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub status: ProviderSecretStatus,
|
||||
pub configured: bool,
|
||||
pub has_secret: bool,
|
||||
pub can_delete: bool,
|
||||
pub can_configure: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub configure_provider: Option<String>,
|
||||
}
|
||||
pub use goose::providers::provider_secrets::{
|
||||
ProviderSecret, ProviderSecretStatus, ProviderSecretStorage,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProviderSecretsResponse {
|
||||
@@ -204,11 +171,8 @@ pub async fn upsert_config(
|
||||
) -> Result<Json<Value>, ErrorResponse> {
|
||||
let config = Config::global();
|
||||
|
||||
// Intercept legacy keys to write structured provider config
|
||||
if query.key == "GOOSE_PROVIDER" {
|
||||
if let Some(name) = query.value.as_str() {
|
||||
// Preserve the target provider's saved model rather than copying
|
||||
// the current active provider's model into the new entry.
|
||||
let model = goose::config::get_provider_entry(config, name)
|
||||
.map(|e| e.model)
|
||||
.or_else(|| config.get_goose_model().ok())
|
||||
@@ -278,360 +242,6 @@ fn mask_secret(secret: Value) -> String {
|
||||
format!("{}{}", visible, mask)
|
||||
}
|
||||
|
||||
const SECRET_STORE_ID_PREFIX: &str = "secret_store:";
|
||||
const PROVIDER_CACHE_ID_PREFIX: &str = "provider_cache:";
|
||||
|
||||
fn provider_secret_status(expires_at: Option<DateTime<Utc>>) -> ProviderSecretStatus {
|
||||
match expires_at {
|
||||
Some(expires_at) if expires_at <= Utc::now() => ProviderSecretStatus::Expired,
|
||||
Some(_) => ProviderSecretStatus::Valid,
|
||||
None => ProviderSecretStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_expiry_value(value: &Value) -> Option<DateTime<Utc>> {
|
||||
match value {
|
||||
Value::String(value) => DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
Value::Number(value) => value
|
||||
.as_i64()
|
||||
.and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_expires_at(value: &Value) -> Option<DateTime<Utc>> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if map
|
||||
.get("refresh_token")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|token| !token.is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(expires_at) = map.get("expires_at").and_then(parse_expiry_value) {
|
||||
return Some(expires_at);
|
||||
}
|
||||
if let Some(expires_at) = map.get("expires_on").and_then(parse_expiry_value) {
|
||||
return Some(expires_at);
|
||||
}
|
||||
map.values().find_map(find_expires_at)
|
||||
}
|
||||
Value::Array(values) => values.iter().find_map(find_expires_at),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ProviderCacheSecretDefinition {
|
||||
provider: &'static str,
|
||||
name: &'static str,
|
||||
path: &'static str,
|
||||
is_directory: bool,
|
||||
}
|
||||
|
||||
const PROVIDER_CACHE_SECRET_DEFINITIONS: &[ProviderCacheSecretDefinition] = &[
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "gemini_oauth",
|
||||
name: "OAuth token",
|
||||
path: "gemini_oauth/tokens.json",
|
||||
is_directory: false,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "chatgpt_codex",
|
||||
name: "OAuth token",
|
||||
path: "chatgpt_codex/tokens.json",
|
||||
is_directory: false,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "kimi_code",
|
||||
name: "OAuth token",
|
||||
path: "kimicode/token.json",
|
||||
is_directory: false,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "github_copilot",
|
||||
name: "OAuth token",
|
||||
path: "githubcopilot",
|
||||
is_directory: true,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "xai_oauth",
|
||||
name: "OAuth token",
|
||||
path: "xai_oauth/tokens.json",
|
||||
is_directory: false,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "databricks",
|
||||
name: "OAuth token",
|
||||
path: "databricks/oauth",
|
||||
is_directory: true,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "databricks_v2",
|
||||
name: "OAuth token",
|
||||
path: "databricks/oauth",
|
||||
is_directory: true,
|
||||
},
|
||||
];
|
||||
|
||||
fn provider_cache_definitions_for_display() -> Vec<ProviderCacheSecretDefinition> {
|
||||
let mut seen_paths = HashSet::new();
|
||||
PROVIDER_CACHE_SECRET_DEFINITIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|definition| seen_paths.insert(definition.path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn provider_cache_definition(provider: &str) -> Option<ProviderCacheSecretDefinition> {
|
||||
PROVIDER_CACHE_SECRET_DEFINITIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|definition| definition.provider == provider)
|
||||
}
|
||||
|
||||
fn provider_cache_providers_sharing_cache(provider: &str) -> Vec<&'static str> {
|
||||
let Some(definition) = provider_cache_definition(provider) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
PROVIDER_CACHE_SECRET_DEFINITIONS
|
||||
.iter()
|
||||
.filter(|other| other.path == definition.path)
|
||||
.map(|definition| definition.provider)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_json_file(path: &std::path::Path) -> Option<Value> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|contents| serde_json::from_str(&contents).ok())
|
||||
}
|
||||
|
||||
fn collect_json_expiries(path: &std::path::Path, is_directory: bool) -> Vec<DateTime<Utc>> {
|
||||
if !is_directory {
|
||||
return read_json_file(path)
|
||||
.and_then(|value| find_expires_at(&value))
|
||||
.into_iter()
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut expiries = Vec::new();
|
||||
let mut stack = vec![path.to_path_buf()];
|
||||
|
||||
while let Some(current) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(current) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
if let Some(expires_at) =
|
||||
read_json_file(&path).and_then(|value| find_expires_at(&value))
|
||||
{
|
||||
expiries.push(expires_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expiries
|
||||
}
|
||||
|
||||
fn provider_cache_exists(path: &std::path::Path, is_directory: bool) -> bool {
|
||||
if !is_directory {
|
||||
return path.is_file();
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
entries.flatten().any(|entry| {
|
||||
let path = entry.path();
|
||||
path.is_file() || provider_cache_exists(&path, true)
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_cache_expiry(definition: ProviderCacheSecretDefinition) -> Option<DateTime<Utc>> {
|
||||
let path = Paths::in_config_dir(definition.path);
|
||||
let expiries = collect_json_expiries(&path, definition.is_directory);
|
||||
expiries.into_iter().min()
|
||||
}
|
||||
|
||||
fn build_provider_cache_secret(
|
||||
definition: ProviderCacheSecretDefinition,
|
||||
display_names: &HashMap<String, String>,
|
||||
) -> Option<ProviderSecret> {
|
||||
let path = Paths::in_config_dir(definition.path);
|
||||
if !provider_cache_exists(&path, definition.is_directory) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let expires_at = provider_cache_expiry(definition);
|
||||
Some(ProviderSecret {
|
||||
id: format!("{}{}", PROVIDER_CACHE_ID_PREFIX, definition.provider),
|
||||
provider: definition.provider.to_string(),
|
||||
provider_display_name: display_names
|
||||
.get(definition.provider)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| definition.provider.to_string()),
|
||||
name: definition.name.to_string(),
|
||||
storage: ProviderSecretStorage::ProviderCache,
|
||||
expires_at,
|
||||
status: provider_secret_status(expires_at),
|
||||
configured: true,
|
||||
has_secret: true,
|
||||
can_delete: true,
|
||||
can_configure: false,
|
||||
configure_provider: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_huggingface_oauth_secret(
|
||||
token: Option<huggingface_auth::HuggingFaceTokenData>,
|
||||
) -> ProviderSecret {
|
||||
let expires_at = token.as_ref().and_then(|token| token.expires_at);
|
||||
let has_secret = token.is_some();
|
||||
|
||||
ProviderSecret {
|
||||
id: format!(
|
||||
"{}{}",
|
||||
PROVIDER_CACHE_ID_PREFIX,
|
||||
huggingface_auth::HUGGINGFACE_PROVIDER_NAME
|
||||
),
|
||||
provider: huggingface_auth::HUGGINGFACE_PROVIDER_NAME.to_string(),
|
||||
provider_display_name: huggingface_auth::HUGGINGFACE_DISPLAY_NAME.to_string(),
|
||||
name: huggingface_auth::HUGGINGFACE_OAUTH_TOKEN_NAME.to_string(),
|
||||
storage: ProviderSecretStorage::ProviderCache,
|
||||
expires_at,
|
||||
status: provider_secret_status(expires_at),
|
||||
configured: has_secret,
|
||||
has_secret,
|
||||
can_delete: has_secret,
|
||||
can_configure: true,
|
||||
configure_provider: Some(huggingface_auth::HUGGINGFACE_PROVIDER_NAME.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_secret_store_secrets(
|
||||
stored_secrets: &HashMap<String, Value>,
|
||||
providers: &[(ProviderMetadata, ProviderType)],
|
||||
) -> Vec<ProviderSecret> {
|
||||
let mut secrets = Vec::new();
|
||||
|
||||
for (metadata, _) in providers {
|
||||
for config_key in metadata.config_keys.iter().filter(|key| key.secret) {
|
||||
if !stored_secrets.contains_key(&config_key.name) {
|
||||
continue;
|
||||
}
|
||||
secrets.push(ProviderSecret {
|
||||
id: format!(
|
||||
"{}{}:{}",
|
||||
SECRET_STORE_ID_PREFIX, metadata.name, config_key.name
|
||||
),
|
||||
provider: metadata.name.clone(),
|
||||
provider_display_name: metadata.display_name.clone(),
|
||||
name: config_key.name.clone(),
|
||||
storage: ProviderSecretStorage::SecretStore,
|
||||
expires_at: None,
|
||||
status: ProviderSecretStatus::Unknown,
|
||||
configured: true,
|
||||
has_secret: true,
|
||||
can_delete: true,
|
||||
can_configure: false,
|
||||
configure_provider: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
secrets
|
||||
}
|
||||
|
||||
fn is_known_provider_secret(
|
||||
providers: &[(ProviderMetadata, ProviderType)],
|
||||
provider: &str,
|
||||
key: &str,
|
||||
) -> bool {
|
||||
providers
|
||||
.iter()
|
||||
.filter(|(metadata, _)| metadata.name == provider)
|
||||
.flat_map(|(metadata, _)| metadata.config_keys.iter())
|
||||
.any(|config_key| config_key.secret && config_key.name == key)
|
||||
}
|
||||
|
||||
fn unconfigure_provider(config: &Config, provider_name: &str) -> Result<(), ConfigError> {
|
||||
if let Some(mut entry) = goose::config::get_provider_entry(config, provider_name) {
|
||||
entry.configured = false;
|
||||
goose::config::set_provider_entry(config, provider_name, &entry)?;
|
||||
}
|
||||
|
||||
let configured_marker = format!("{}_configured", provider_name);
|
||||
config.delete(&configured_marker)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_provider_configured(config: &Config, provider_name: &str) -> Result<(), ConfigError> {
|
||||
if let Some(mut entry) = goose::config::get_provider_entry(config, provider_name) {
|
||||
entry.configured = true;
|
||||
goose::config::set_provider_entry(config, provider_name, &entry)?;
|
||||
} else {
|
||||
let model = if goose::config::get_active_provider(config).as_deref() == Some(provider_name)
|
||||
{
|
||||
config.get_goose_model().unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
goose::config::set_provider_entry(
|
||||
config,
|
||||
provider_name,
|
||||
&goose::config::ProviderEntry {
|
||||
enabled: true,
|
||||
model,
|
||||
configured: true,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_secret_store_id(id: &str) -> Option<(&str, &str)> {
|
||||
let rest = id.strip_prefix(SECRET_STORE_ID_PREFIX)?;
|
||||
let (provider, key) = rest.split_once(':')?;
|
||||
Some((provider, key))
|
||||
}
|
||||
|
||||
fn parse_provider_cache_id(id: &str) -> Option<&str> {
|
||||
id.strip_prefix(PROVIDER_CACHE_ID_PREFIX)
|
||||
}
|
||||
|
||||
fn is_valid_provider_name(provider_name: &str) -> bool {
|
||||
!provider_name.is_empty()
|
||||
&& provider_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
fn should_unconfigure_after_secret_delete(
|
||||
provider: &str,
|
||||
key: &str,
|
||||
has_usable_huggingface_oauth_token: impl FnOnce() -> bool,
|
||||
) -> bool {
|
||||
provider == huggingface_auth::HUGGINGFACE_PROVIDER_NAME
|
||||
&& key == huggingface_auth::HUGGINGFACE_TOKEN_SECRET_KEY
|
||||
&& !has_usable_huggingface_oauth_token()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/config/provider-secrets",
|
||||
@@ -641,40 +251,7 @@ fn should_unconfigure_after_secret_delete(
|
||||
)
|
||||
)]
|
||||
pub async fn list_provider_secrets() -> Result<Json<ProviderSecretsResponse>, ErrorResponse> {
|
||||
let config = Config::global();
|
||||
let stored_secrets = config.all_secrets()?;
|
||||
let providers = get_providers().await;
|
||||
let display_names: HashMap<String, String> = providers
|
||||
.iter()
|
||||
.map(|(metadata, _)| (metadata.name.clone(), metadata.display_name.clone()))
|
||||
.collect();
|
||||
|
||||
let mut secrets = build_secret_store_secrets(&stored_secrets, &providers);
|
||||
|
||||
for definition in provider_cache_definitions_for_display() {
|
||||
if let Some(secret) = build_provider_cache_secret(definition, &display_names) {
|
||||
if !secrets.iter().any(|existing| existing.id == secret.id) {
|
||||
secrets.push(secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let huggingface_secret = build_huggingface_oauth_secret(huggingface_auth::load_oauth_token());
|
||||
if let Some(existing) = secrets
|
||||
.iter_mut()
|
||||
.find(|existing| existing.id == huggingface_secret.id)
|
||||
{
|
||||
*existing = huggingface_secret;
|
||||
} else {
|
||||
secrets.push(huggingface_secret);
|
||||
}
|
||||
|
||||
secrets.sort_by(|a, b| {
|
||||
a.provider_display_name
|
||||
.cmp(&b.provider_display_name)
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
|
||||
let secrets = goose::providers::provider_secrets::list_provider_secrets().await?;
|
||||
Ok(Json(ProviderSecretsResponse { secrets }))
|
||||
}
|
||||
|
||||
@@ -691,52 +268,17 @@ pub async fn list_provider_secrets() -> Result<Json<ProviderSecretsResponse>, Er
|
||||
)
|
||||
)]
|
||||
pub async fn delete_provider_secret(Path(id): Path<String>) -> Result<Json<String>, ErrorResponse> {
|
||||
let config = Config::global();
|
||||
use goose::providers::provider_secrets::DeleteProviderSecretError;
|
||||
|
||||
if let Some((provider, key)) = parse_secret_store_id(&id) {
|
||||
let providers = get_providers().await;
|
||||
if !is_known_provider_secret(&providers, provider, key) {
|
||||
return Err(ErrorResponse::bad_request(format!(
|
||||
"Invalid provider secret id: '{}'",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
config.delete_secret(key)?;
|
||||
if should_unconfigure_after_secret_delete(provider, key, || {
|
||||
huggingface_auth::has_configured_token().unwrap_or(false)
|
||||
}) {
|
||||
unconfigure_provider(config, provider)?;
|
||||
}
|
||||
return Ok(Json(format!("Deleted provider secret {}", id)));
|
||||
match goose::providers::provider_secrets::delete_provider_secret(&id).await {
|
||||
Ok(()) => Ok(Json(format!("Deleted provider secret {}", id))),
|
||||
Err(DeleteProviderSecretError::InvalidId(id)) => Err(ErrorResponse::bad_request(format!(
|
||||
"Invalid provider secret id: '{}'",
|
||||
id
|
||||
))),
|
||||
Err(DeleteProviderSecretError::Config(e)) => Err(e.into()),
|
||||
Err(DeleteProviderSecretError::Other(e)) => Err(ErrorResponse::internal(e.to_string())),
|
||||
}
|
||||
|
||||
if let Some(provider) = parse_provider_cache_id(&id) {
|
||||
if provider == huggingface_auth::HUGGINGFACE_PROVIDER_NAME {
|
||||
huggingface_auth::clear_oauth_token()?;
|
||||
unconfigure_provider(config, provider)?;
|
||||
return Ok(Json(format!("Deleted provider secret {}", id)));
|
||||
}
|
||||
|
||||
let cache_definition = provider_cache_definition(provider);
|
||||
|
||||
if !is_valid_provider_name(provider) || cache_definition.is_none() {
|
||||
return Err(ErrorResponse::bad_request(format!(
|
||||
"Invalid provider name: '{}'",
|
||||
provider
|
||||
)));
|
||||
}
|
||||
goose::providers::cleanup_provider(provider).await?;
|
||||
for shared_provider in provider_cache_providers_sharing_cache(provider) {
|
||||
unconfigure_provider(config, shared_provider)?;
|
||||
}
|
||||
return Ok(Json(format!("Deleted provider secret {}", id)));
|
||||
}
|
||||
|
||||
Err(ErrorResponse::bad_request(format!(
|
||||
"Invalid provider secret id: '{}'",
|
||||
id
|
||||
)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -753,7 +295,6 @@ pub async fn read_config(
|
||||
) -> Result<Json<ConfigValueResponse>, ErrorResponse> {
|
||||
let config = Config::global();
|
||||
|
||||
// Intercept legacy keys to return structured provider config
|
||||
if query.key == "GOOSE_PROVIDER" || query.key == "active_provider" {
|
||||
if let Ok(val) = config.get_goose_provider() {
|
||||
return Ok(Json(ConfigValueResponse::Value(Value::String(val))));
|
||||
@@ -1288,7 +829,6 @@ pub async fn update_custom_provider(
|
||||
pub async fn check_provider(
|
||||
Json(CheckProviderRequest { provider }): Json<CheckProviderRequest>,
|
||||
) -> Result<(), ErrorResponse> {
|
||||
// Provider check does not use extensions.
|
||||
create_with_default_model(&provider, Vec::new())
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@@ -1305,7 +845,6 @@ pub async fn check_provider(
|
||||
pub async fn set_config_provider(
|
||||
Json(SetProviderRequest { provider, model }): Json<SetProviderRequest>,
|
||||
) -> Result<(), ErrorResponse> {
|
||||
// Provider validation does not use extensions.
|
||||
create_with_default_model(&provider, Vec::new())
|
||||
.await
|
||||
.and_then(|_| {
|
||||
@@ -1370,60 +909,6 @@ pub async fn get_provider_catalog_template(
|
||||
Ok(Json(template))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/config/providers/{name}/oauth",
|
||||
params(
|
||||
("name" = String, Path, description = "Provider name")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "OAuth configuration completed"),
|
||||
(status = 400, description = "OAuth configuration failed")
|
||||
)
|
||||
)]
|
||||
pub async fn configure_provider_oauth(
|
||||
Path(provider_name): Path<String>,
|
||||
) -> Result<Json<String>, ErrorResponse> {
|
||||
use goose::providers::create;
|
||||
|
||||
if !is_valid_provider_name(&provider_name) {
|
||||
return Err(ErrorResponse::bad_request(format!(
|
||||
"Invalid provider name: '{}'",
|
||||
provider_name
|
||||
)));
|
||||
}
|
||||
|
||||
if provider_name == huggingface_auth::HUGGINGFACE_PROVIDER_NAME {
|
||||
huggingface_auth::configure_oauth().await.map_err(|e| {
|
||||
ErrorResponse::bad_request(format!(
|
||||
"OAuth configuration failed for provider '{}': {}",
|
||||
provider_name, e
|
||||
))
|
||||
})?;
|
||||
mark_provider_configured(goose::config::Config::global(), &provider_name)?;
|
||||
return Ok(Json("OAuth configuration completed".to_string()));
|
||||
}
|
||||
|
||||
// OAuth configuration does not use extensions.
|
||||
let provider = create(&provider_name, Vec::new()).await.map_err(|e| {
|
||||
ErrorResponse::bad_request(format!(
|
||||
"Failed to create provider '{}': {}",
|
||||
provider_name, e
|
||||
))
|
||||
})?;
|
||||
|
||||
provider.configure_oauth().await.map_err(|e| {
|
||||
ErrorResponse::bad_request(format!(
|
||||
"OAuth configuration failed for provider '{}': {}",
|
||||
provider_name, e
|
||||
))
|
||||
})?;
|
||||
|
||||
mark_provider_configured(goose::config::Config::global(), &provider_name)?;
|
||||
|
||||
Ok(Json("OAuth configuration completed".to_string()))
|
||||
}
|
||||
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/config", get(read_all_config))
|
||||
@@ -1468,289 +953,5 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
||||
.route("/config/custom-providers/{id}", get(get_custom_provider))
|
||||
.route("/config/check_provider", post(check_provider))
|
||||
.route("/config/set_provider", post(set_config_provider))
|
||||
.route(
|
||||
"/config/providers/{name}/oauth",
|
||||
post(configure_provider_oauth),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use goose::config::ProviderEntry;
|
||||
use goose::providers::base::ConfigKey;
|
||||
use serde_json::json;
|
||||
|
||||
fn new_test_config() -> Config {
|
||||
let unique = format!(
|
||||
"goose-server-config-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let config_path = std::env::temp_dir().join(format!("{unique}-config.yaml"));
|
||||
let secrets_path = std::env::temp_dir().join(format!("{unique}-secrets.yaml"));
|
||||
Config::new_with_file_secrets(config_path, secrets_path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_store_listing_only_includes_provider_secret_keys() {
|
||||
let metadata = ProviderMetadata::new(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
"OpenAI provider",
|
||||
"gpt-4o",
|
||||
vec![],
|
||||
"https://example.com",
|
||||
vec![
|
||||
ConfigKey::new("OPENAI_API_KEY", true, true, None, true),
|
||||
ConfigKey::new("OPENAI_HOST", false, false, None, false),
|
||||
],
|
||||
);
|
||||
let providers = vec![(metadata, ProviderType::Builtin)];
|
||||
let stored_secrets = HashMap::from([
|
||||
(
|
||||
"OPENAI_API_KEY".to_string(),
|
||||
Value::String("secret-value".to_string()),
|
||||
),
|
||||
(
|
||||
"UNRELATED_SECRET".to_string(),
|
||||
Value::String("other-secret".to_string()),
|
||||
),
|
||||
(
|
||||
"OPENAI_HOST".to_string(),
|
||||
Value::String("https://api.openai.com".to_string()),
|
||||
),
|
||||
]);
|
||||
|
||||
let secrets = build_secret_store_secrets(&stored_secrets, &providers);
|
||||
|
||||
assert_eq!(secrets.len(), 1);
|
||||
assert_eq!(secrets[0].id, "secret_store:openai:OPENAI_API_KEY");
|
||||
assert_eq!(secrets[0].provider_display_name, "OpenAI");
|
||||
assert_eq!(secrets[0].name, "OPENAI_API_KEY");
|
||||
assert_eq!(secrets[0].storage, ProviderSecretStorage::SecretStore);
|
||||
assert_eq!(secrets[0].status, ProviderSecretStatus::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_secret_delete_validation_requires_provider_secret_key() {
|
||||
let metadata = ProviderMetadata::new(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
"OpenAI provider",
|
||||
"gpt-4o",
|
||||
vec![],
|
||||
"https://example.com",
|
||||
vec![
|
||||
ConfigKey::new("OPENAI_API_KEY", true, true, None, true),
|
||||
ConfigKey::new("OPENAI_HOST", false, false, None, false),
|
||||
],
|
||||
);
|
||||
let providers = vec![(metadata, ProviderType::Builtin)];
|
||||
|
||||
assert!(is_known_provider_secret(
|
||||
&providers,
|
||||
"openai",
|
||||
"OPENAI_API_KEY"
|
||||
));
|
||||
assert!(!is_known_provider_secret(
|
||||
&providers,
|
||||
"openai",
|
||||
"OPENAI_HOST"
|
||||
));
|
||||
assert!(!is_known_provider_secret(
|
||||
&providers,
|
||||
"openai",
|
||||
"UNRELATED_SECRET"
|
||||
));
|
||||
assert!(!is_known_provider_secret(
|
||||
&providers,
|
||||
"anthropic",
|
||||
"OPENAI_API_KEY"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_extraction_handles_nested_rfc3339_values() {
|
||||
let expires_at = Utc::now() + chrono::Duration::hours(1);
|
||||
let value = json!({
|
||||
"project_id": "project",
|
||||
"token": {
|
||||
"access_token": "secret",
|
||||
"expires_at": expires_at.to_rfc3339(),
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = find_expires_at(&value).expect("expected expiry");
|
||||
|
||||
assert_eq!(parsed.timestamp(), expires_at.timestamp());
|
||||
assert_eq!(
|
||||
provider_secret_status(Some(parsed)),
|
||||
ProviderSecretStatus::Valid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_extraction_ignores_refreshable_access_tokens() {
|
||||
let expires_at = Utc::now() - chrono::Duration::hours(1);
|
||||
let value = json!({
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"expires_at": expires_at.to_rfc3339(),
|
||||
});
|
||||
|
||||
assert_eq!(find_expires_at(&value), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_extraction_handles_expired_unix_timestamps() {
|
||||
let value = json!({
|
||||
"info": {
|
||||
"expires_at": 1
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = find_expires_at(&value).expect("expected expiry");
|
||||
|
||||
assert_eq!(parsed.timestamp(), 1);
|
||||
assert_eq!(
|
||||
provider_secret_status(Some(parsed)),
|
||||
ProviderSecretStatus::Expired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_secret_ids_parse_expected_prefixes() {
|
||||
assert_eq!(
|
||||
parse_secret_store_id("secret_store:openai:OPENAI_API_KEY"),
|
||||
Some(("openai", "OPENAI_API_KEY"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_provider_cache_id("provider_cache:gemini_oauth"),
|
||||
Some("gemini_oauth")
|
||||
);
|
||||
assert_eq!(parse_secret_store_id("provider_cache:openai"), None);
|
||||
assert_eq!(parse_provider_cache_id("secret_store:openai:key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_databricks_cache_is_displayed_once() {
|
||||
let databricks_definitions: Vec<_> = provider_cache_definitions_for_display()
|
||||
.into_iter()
|
||||
.filter(|definition| definition.path == "databricks/oauth")
|
||||
.collect();
|
||||
|
||||
assert_eq!(databricks_definitions.len(), 1);
|
||||
assert_eq!(databricks_definitions[0].provider, "databricks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_databricks_cache_unconfigures_both_providers() {
|
||||
assert_eq!(
|
||||
provider_cache_providers_sharing_cache("databricks"),
|
||||
vec!["databricks", "databricks_v2"]
|
||||
);
|
||||
assert_eq!(
|
||||
provider_cache_providers_sharing_cache("databricks_v2"),
|
||||
vec!["databricks", "databricks_v2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconfigure_provider_clears_structured_entry() {
|
||||
let config = new_test_config();
|
||||
goose::config::set_provider_entry(
|
||||
&config,
|
||||
"huggingface",
|
||||
&ProviderEntry {
|
||||
enabled: true,
|
||||
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct".to_string(),
|
||||
configured: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
unconfigure_provider(&config, "huggingface").unwrap();
|
||||
|
||||
let entry = goose::config::get_provider_entry(&config, "huggingface").unwrap();
|
||||
assert!(entry.enabled);
|
||||
assert_eq!(entry.model, "Qwen/Qwen3-Coder-480B-A35B-Instruct");
|
||||
assert!(!entry.configured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconfigure_provider_deletes_legacy_configured_marker() {
|
||||
let config = new_test_config();
|
||||
config.set_param("huggingface_configured", true).unwrap();
|
||||
|
||||
unconfigure_provider(&config, "huggingface").unwrap();
|
||||
|
||||
assert!(config.get_param::<bool>("huggingface_configured").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_huggingface_token_unconfigures_without_oauth() {
|
||||
assert!(should_unconfigure_after_secret_delete(
|
||||
"huggingface",
|
||||
"HF_TOKEN",
|
||||
|| false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_huggingface_token_keeps_configured_with_oauth() {
|
||||
assert!(!should_unconfigure_after_secret_delete(
|
||||
"huggingface",
|
||||
"HF_TOKEN",
|
||||
|| true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_other_provider_secret_does_not_unconfigure_huggingface() {
|
||||
assert!(!should_unconfigure_after_secret_delete(
|
||||
"openai",
|
||||
"OPENAI_API_KEY",
|
||||
|| false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huggingface_oauth_secret_is_permanent_without_token() {
|
||||
let secret = build_huggingface_oauth_secret(None);
|
||||
|
||||
assert_eq!(secret.id, "provider_cache:huggingface");
|
||||
assert_eq!(secret.provider_display_name, "Hugging Face");
|
||||
assert_eq!(secret.name, "OAuth token");
|
||||
assert_eq!(secret.storage, ProviderSecretStorage::ProviderCache);
|
||||
assert_eq!(secret.status, ProviderSecretStatus::Unknown);
|
||||
assert!(!secret.configured);
|
||||
assert!(!secret.has_secret);
|
||||
assert!(!secret.can_delete);
|
||||
assert!(secret.can_configure);
|
||||
assert_eq!(secret.configure_provider.as_deref(), Some("huggingface"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huggingface_oauth_secret_reports_cached_token_metadata() {
|
||||
let expires_at = Utc::now() + chrono::Duration::hours(1);
|
||||
let secret = build_huggingface_oauth_secret(Some(huggingface_auth::HuggingFaceTokenData {
|
||||
access_token: "hidden".to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(expires_at),
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
secret.expires_at.map(|value| value.timestamp()),
|
||||
Some(expires_at.timestamp())
|
||||
);
|
||||
assert_eq!(secret.status, ProviderSecretStatus::Valid);
|
||||
assert!(secret.configured);
|
||||
assert!(secret.has_secret);
|
||||
assert!(secret.can_delete);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,21 @@
|
||||
"requestType": "ProviderConfigAuthenticateRequest_unstable",
|
||||
"responseType": "ProviderConfigChangeResponse_unstable"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/providers/secrets/list",
|
||||
"requestType": "ProviderSecretsListRequest_unstable",
|
||||
"responseType": "ProviderSecretsListResponse_unstable"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/providers/secrets/delete",
|
||||
"requestType": "ProviderSecretDeleteRequest_unstable",
|
||||
"responseType": "EmptyResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/providers/canonical-model-info",
|
||||
"requestType": "CanonicalModelInfoRequest_unstable",
|
||||
"responseType": "CanonicalModelInfoResponse_unstable"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/preferences/read",
|
||||
"requestType": "PreferencesReadRequest_unstable",
|
||||
@@ -220,6 +235,11 @@
|
||||
"requestType": "DefaultsSaveRequest_unstable",
|
||||
"responseType": "DefaultsReadResponse_unstable"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/defaults/clear",
|
||||
"requestType": "DefaultsClearRequest_unstable",
|
||||
"responseType": "DefaultsReadResponse_unstable"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/onboarding/import/scan",
|
||||
"requestType": "OnboardingImportScanRequest_unstable",
|
||||
|
||||
@@ -2668,6 +2668,214 @@
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/providers/config/authenticate"
|
||||
},
|
||||
"ProviderSecretsListRequest_unstable": {
|
||||
"type": "object",
|
||||
"description": "List provider credentials stored locally by Goose.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/providers/secrets/list"
|
||||
},
|
||||
"ProviderSecretsListResponse_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"secrets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/ProviderSecretDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"secrets"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/providers/secrets/list"
|
||||
},
|
||||
"ProviderSecretDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerDisplayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"storage": {
|
||||
"$ref": "#/$defs/ProviderSecretStorageDto"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/$defs/ProviderSecretStatusDto"
|
||||
},
|
||||
"configured": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasSecret": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canDelete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canConfigure": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"configureProvider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"provider",
|
||||
"providerDisplayName",
|
||||
"name",
|
||||
"storage",
|
||||
"status",
|
||||
"configured",
|
||||
"hasSecret",
|
||||
"canDelete",
|
||||
"canConfigure"
|
||||
]
|
||||
},
|
||||
"ProviderSecretStorageDto": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"secret_store",
|
||||
"provider_cache"
|
||||
]
|
||||
},
|
||||
"ProviderSecretStatusDto": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"valid",
|
||||
"expired",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"ProviderSecretDeleteRequest_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"description": "Delete a locally stored provider credential by id.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/providers/secrets/delete"
|
||||
},
|
||||
"CanonicalModelInfoRequest_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"provider",
|
||||
"model"
|
||||
],
|
||||
"description": "Look up canonical (bundled-registry) model info for a provider/model pair.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/providers/canonical-model-info"
|
||||
},
|
||||
"CanonicalModelInfoResponse_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modelInfo": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CanonicalModelInfoDto"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/providers/canonical-model-info"
|
||||
},
|
||||
"CanonicalModelInfoDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"contextLimit": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"maxOutputTokens": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"inputTokenCost": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"outputTokenCost": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"cacheReadTokenCost": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"cacheWriteTokenCost": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"provider",
|
||||
"model",
|
||||
"contextLimit",
|
||||
"reasoning",
|
||||
"currency"
|
||||
]
|
||||
},
|
||||
"PreferencesReadRequest_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2797,6 +3005,12 @@
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/defaults/save"
|
||||
},
|
||||
"DefaultsClearRequest_unstable": {
|
||||
"type": "object",
|
||||
"description": "Clear Goose default provider and model configuration.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/defaults/clear"
|
||||
},
|
||||
"OnboardingImportScanRequest_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5775,6 +5989,33 @@
|
||||
"description": "Params for _goose/unstable/providers/config/authenticate",
|
||||
"title": "ProviderConfigAuthenticateRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ProviderSecretsListRequest_unstable"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/unstable/providers/secrets/list",
|
||||
"title": "ProviderSecretsListRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ProviderSecretDeleteRequest_unstable"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/unstable/providers/secrets/delete",
|
||||
"title": "ProviderSecretDeleteRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CanonicalModelInfoRequest_unstable"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/unstable/providers/canonical-model-info",
|
||||
"title": "CanonicalModelInfoRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
@@ -5820,6 +6061,15 @@
|
||||
"description": "Params for _goose/unstable/defaults/save",
|
||||
"title": "DefaultsSaveRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/DefaultsClearRequest_unstable"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/unstable/defaults/clear",
|
||||
"title": "DefaultsClearRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
@@ -6523,6 +6773,22 @@
|
||||
],
|
||||
"title": "ProviderConfigChangeResponse_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ProviderSecretsListResponse_unstable"
|
||||
}
|
||||
],
|
||||
"title": "ProviderSecretsListResponse_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CanonicalModelInfoResponse_unstable"
|
||||
}
|
||||
],
|
||||
"title": "CanonicalModelInfoResponse_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -127,6 +127,20 @@ impl GooseAcpAgent {
|
||||
model_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn on_defaults_clear(
|
||||
&self,
|
||||
_req: DefaultsClearRequest,
|
||||
) -> Result<DefaultsReadResponse, agent_client_protocol::Error> {
|
||||
let config = self.config()?;
|
||||
crate::config::clear_active_provider(config)
|
||||
.internal_err_ctx("Failed to clear default provider")?;
|
||||
|
||||
Ok(DefaultsReadResponse {
|
||||
provider_id: None,
|
||||
model_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct PreferenceDef {
|
||||
|
||||
@@ -328,6 +328,30 @@ impl GooseAcpAgent {
|
||||
self.on_authenticate_provider_config(req).await
|
||||
}
|
||||
|
||||
#[custom_method(ProviderSecretsListRequest)]
|
||||
async fn dispatch_list_provider_secrets(
|
||||
&self,
|
||||
req: ProviderSecretsListRequest,
|
||||
) -> Result<ProviderSecretsListResponse, agent_client_protocol::Error> {
|
||||
self.on_list_provider_secrets(req).await
|
||||
}
|
||||
|
||||
#[custom_method(ProviderSecretDeleteRequest)]
|
||||
async fn dispatch_delete_provider_secret(
|
||||
&self,
|
||||
req: ProviderSecretDeleteRequest,
|
||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||
self.on_delete_provider_secret(req).await
|
||||
}
|
||||
|
||||
#[custom_method(CanonicalModelInfoRequest)]
|
||||
async fn dispatch_canonical_model_info(
|
||||
&self,
|
||||
req: CanonicalModelInfoRequest,
|
||||
) -> Result<CanonicalModelInfoResponse, agent_client_protocol::Error> {
|
||||
self.on_canonical_model_info(req).await
|
||||
}
|
||||
|
||||
#[custom_method(PreferencesReadRequest)]
|
||||
async fn dispatch_preferences_read(
|
||||
&self,
|
||||
@@ -368,6 +392,14 @@ impl GooseAcpAgent {
|
||||
self.on_defaults_save(req).await
|
||||
}
|
||||
|
||||
#[custom_method(DefaultsClearRequest)]
|
||||
async fn dispatch_defaults_clear(
|
||||
&self,
|
||||
req: DefaultsClearRequest,
|
||||
) -> Result<DefaultsReadResponse, agent_client_protocol::Error> {
|
||||
self.on_defaults_clear(req).await
|
||||
}
|
||||
|
||||
#[custom_method(OnboardingImportScanRequest)]
|
||||
async fn dispatch_onboarding_import_scan(
|
||||
&self,
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
use super::*;
|
||||
use crate::config::declarative_providers;
|
||||
use crate::providers::inventory::ensure_refresh_identity_current;
|
||||
use crate::providers::provider_secrets;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn provider_secret_to_dto(secret: provider_secrets::ProviderSecret) -> ProviderSecretDto {
|
||||
let storage = match secret.storage {
|
||||
provider_secrets::ProviderSecretStorage::SecretStore => {
|
||||
ProviderSecretStorageDto::SecretStore
|
||||
}
|
||||
provider_secrets::ProviderSecretStorage::ProviderCache => {
|
||||
ProviderSecretStorageDto::ProviderCache
|
||||
}
|
||||
};
|
||||
let status = match secret.status {
|
||||
provider_secrets::ProviderSecretStatus::Valid => ProviderSecretStatusDto::Valid,
|
||||
provider_secrets::ProviderSecretStatus::Expired => ProviderSecretStatusDto::Expired,
|
||||
provider_secrets::ProviderSecretStatus::Unknown => ProviderSecretStatusDto::Unknown,
|
||||
};
|
||||
ProviderSecretDto {
|
||||
id: secret.id,
|
||||
provider: secret.provider,
|
||||
provider_display_name: secret.provider_display_name,
|
||||
name: secret.name,
|
||||
storage,
|
||||
expires_at: secret.expires_at.map(|value| value.to_rfc3339()),
|
||||
status,
|
||||
configured: secret.configured,
|
||||
has_secret: secret.has_secret,
|
||||
can_delete: secret.can_delete,
|
||||
can_configure: secret.can_configure,
|
||||
configure_provider: secret.configure_provider,
|
||||
}
|
||||
}
|
||||
|
||||
fn inventory_entry_to_dto(entry: ProviderInventoryEntry) -> ProviderInventoryEntryDto {
|
||||
let stale = ProviderInventoryService::is_stale(&entry);
|
||||
ProviderInventoryEntryDto {
|
||||
@@ -927,22 +958,29 @@ impl GooseAcpAgent {
|
||||
let entry = crate::providers::get_from_registry(&req.provider_id)
|
||||
.await
|
||||
.invalid_params_err_ctx("Unknown provider")?;
|
||||
let metadata = entry.metadata().clone();
|
||||
if !metadata.config_keys.iter().any(|key| key.oauth_flow) {
|
||||
return Err(agent_client_protocol::Error::invalid_params().data(format!(
|
||||
"Provider does not support native authentication: {}",
|
||||
req.provider_id
|
||||
)));
|
||||
}
|
||||
|
||||
let provider = entry
|
||||
.create_with_default_model(Vec::new())
|
||||
.await
|
||||
.internal_err_ctx("Failed to initialize provider")?;
|
||||
provider
|
||||
.configure_oauth()
|
||||
.await
|
||||
.internal_err_ctx("Failed to authenticate provider")?;
|
||||
if req.provider_id == crate::providers::huggingface_auth::HUGGINGFACE_PROVIDER_NAME {
|
||||
crate::providers::huggingface_auth::configure_oauth()
|
||||
.await
|
||||
.internal_err_ctx("Failed to authenticate provider")?;
|
||||
} else {
|
||||
let metadata = entry.metadata().clone();
|
||||
if !metadata.config_keys.iter().any(|key| key.oauth_flow) {
|
||||
return Err(agent_client_protocol::Error::invalid_params().data(format!(
|
||||
"Provider does not support native authentication: {}",
|
||||
req.provider_id
|
||||
)));
|
||||
}
|
||||
|
||||
let provider = entry
|
||||
.create_with_default_model(Vec::new())
|
||||
.await
|
||||
.internal_err_ctx("Failed to initialize provider")?;
|
||||
provider
|
||||
.configure_oauth()
|
||||
.await
|
||||
.internal_err_ctx("Failed to authenticate provider")?;
|
||||
}
|
||||
Config::global().invalidate_secrets_cache();
|
||||
|
||||
let provider_ids = [req.provider_id.clone()];
|
||||
@@ -950,4 +988,58 @@ impl GooseAcpAgent {
|
||||
let refresh = self.start_provider_inventory_refresh(&provider_ids).await?;
|
||||
Ok(ProviderConfigChangeResponse { status, refresh })
|
||||
}
|
||||
|
||||
pub(super) async fn on_list_provider_secrets(
|
||||
&self,
|
||||
_req: ProviderSecretsListRequest,
|
||||
) -> Result<ProviderSecretsListResponse, agent_client_protocol::Error> {
|
||||
let secrets = provider_secrets::list_provider_secrets()
|
||||
.await
|
||||
.internal_err_ctx("Failed to list provider secrets")?
|
||||
.into_iter()
|
||||
.map(provider_secret_to_dto)
|
||||
.collect();
|
||||
Ok(ProviderSecretsListResponse { secrets })
|
||||
}
|
||||
|
||||
pub(super) async fn on_delete_provider_secret(
|
||||
&self,
|
||||
req: ProviderSecretDeleteRequest,
|
||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||
match provider_secrets::delete_provider_secret(&req.id).await {
|
||||
Ok(()) => Ok(EmptyResponse {}),
|
||||
Err(provider_secrets::DeleteProviderSecretError::InvalidId(id)) => {
|
||||
Err(agent_client_protocol::Error::invalid_params()
|
||||
.data(format!("Invalid provider secret id: '{}'", id)))
|
||||
}
|
||||
Err(e) => Err(agent_client_protocol::Error::internal_error().data(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn on_canonical_model_info(
|
||||
&self,
|
||||
req: CanonicalModelInfoRequest,
|
||||
) -> Result<CanonicalModelInfoResponse, agent_client_protocol::Error> {
|
||||
use goose_providers::model::ModelConfig;
|
||||
|
||||
let model_info =
|
||||
crate::providers::canonical::maybe_get_canonical_model(&req.provider, &req.model).map(
|
||||
|canonical_model| CanonicalModelInfoDto {
|
||||
provider: req.provider.clone(),
|
||||
model: req.model.clone(),
|
||||
context_limit: canonical_model.limit.context,
|
||||
max_output_tokens: canonical_model.limit.output,
|
||||
reasoning: canonical_model
|
||||
.reasoning
|
||||
.unwrap_or_else(|| ModelConfig::new(&req.model).is_reasoning_model()),
|
||||
input_token_cost: canonical_model.cost.input,
|
||||
output_token_cost: canonical_model.cost.output,
|
||||
cache_read_token_cost: canonical_model.cost.cache_read,
|
||||
cache_write_token_cost: canonical_model.cost.cache_write,
|
||||
currency: "$".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(CanonicalModelInfoResponse { model_info })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,6 @@ pub use extensions::DEFAULT_EXTENSION;
|
||||
pub use extensions::DEFAULT_EXTENSION_DESCRIPTION;
|
||||
pub use extensions::DEFAULT_EXTENSION_TIMEOUT;
|
||||
pub use providers::{
|
||||
get_active_model, get_active_provider, get_provider_entry, set_active_provider,
|
||||
set_provider_entry, ProviderEntry,
|
||||
clear_active_provider, get_active_model, get_active_provider, get_provider_entry,
|
||||
set_active_provider, set_provider_entry, ProviderEntry,
|
||||
};
|
||||
|
||||
@@ -8,11 +8,6 @@ use tracing::warn;
|
||||
const PROVIDERS_CONFIG_KEY: &str = "providers";
|
||||
const ACTIVE_PROVIDER_KEY: &str = "active_provider";
|
||||
|
||||
/// A single provider's persisted configuration within the `providers:` block.
|
||||
///
|
||||
/// The `providers` block in config.yaml is the authoritative source for
|
||||
/// per-provider settings, replacing the old flat-key scheme where switching
|
||||
/// providers destructively overwrote `GOOSE_PROVIDER` / `GOOSE_MODEL`.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct ProviderEntry {
|
||||
#[serde(default)]
|
||||
@@ -23,10 +18,6 @@ pub struct ProviderEntry {
|
||||
pub configured: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_providers_map(raw: Mapping) -> IndexMap<String, ProviderEntry> {
|
||||
let mut map = IndexMap::with_capacity(raw.len());
|
||||
for (k, v) in raw {
|
||||
@@ -53,16 +44,10 @@ fn get_providers_map(config: &Config) -> IndexMap<String, ProviderEntry> {
|
||||
parse_providers_map(raw)
|
||||
}
|
||||
|
||||
/// Retrieve the [`ProviderEntry`] for a named provider, if it exists.
|
||||
pub fn get_provider_entry(config: &Config, name: &str) -> Option<ProviderEntry> {
|
||||
get_providers_map(config).get(name).cloned()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Persist a [`ProviderEntry`] under `providers.{name}`.
|
||||
pub fn set_provider_entry(
|
||||
config: &Config,
|
||||
name: &str,
|
||||
@@ -77,45 +62,20 @@ pub fn set_provider_entry(
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active-provider accessors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Return the currently active provider name.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `GOOSE_PROVIDER` environment variable (uppercase check performed by
|
||||
/// `get_param`)
|
||||
/// 2. `active_provider` key in config.yaml
|
||||
/// 3. Legacy flat `GOOSE_PROVIDER` key in config.yaml (backward compat)
|
||||
pub fn get_active_provider(config: &Config) -> Option<String> {
|
||||
// Env var takes precedence (get_param checks env automatically)
|
||||
if let Ok(val) = env::var("GOOSE_PROVIDER") {
|
||||
return Some(val);
|
||||
}
|
||||
|
||||
// New structured key
|
||||
if let Ok(val) = config.get_param::<String>(ACTIVE_PROVIDER_KEY) {
|
||||
return Some(val);
|
||||
}
|
||||
|
||||
// Legacy flat key fallback
|
||||
config.get_param::<String>("GOOSE_PROVIDER").ok()
|
||||
}
|
||||
|
||||
/// Return the model for the currently active provider.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `GOOSE_MODEL` environment variable
|
||||
/// 2. Model recorded in the active provider's entry (`providers.{name}.model`)
|
||||
/// 3. Legacy flat `GOOSE_MODEL` key in config.yaml
|
||||
pub fn get_active_model(config: &Config) -> Option<String> {
|
||||
// Env var takes precedence
|
||||
if let Ok(val) = env::var("GOOSE_MODEL") {
|
||||
return Some(val);
|
||||
}
|
||||
|
||||
// Try provider entry model
|
||||
if let Some(provider_name) = get_active_provider(config) {
|
||||
if let Some(entry) = get_provider_entry(config, &provider_name) {
|
||||
if !entry.model.is_empty() {
|
||||
@@ -123,17 +83,9 @@ pub fn get_active_model(config: &Config) -> Option<String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy flat key fallback
|
||||
config.get_param::<String>("GOOSE_MODEL").ok()
|
||||
}
|
||||
|
||||
/// Set the active provider and update its entry in the `providers` block.
|
||||
///
|
||||
/// This writes:
|
||||
/// - `active_provider: {name}` at the top level
|
||||
/// - `providers.{name}` with `configured: true`, `enabled: true`, and the
|
||||
/// supplied model.
|
||||
pub fn set_active_provider(config: &Config, name: &str, model: &str) -> Result<(), ConfigError> {
|
||||
config.set_param(ACTIVE_PROVIDER_KEY, name)?;
|
||||
let entry = ProviderEntry {
|
||||
@@ -144,6 +96,16 @@ pub fn set_active_provider(config: &Config, name: &str, model: &str) -> Result<(
|
||||
set_provider_entry(config, name, &entry)
|
||||
}
|
||||
|
||||
pub fn clear_active_provider(config: &Config) -> Result<(), ConfigError> {
|
||||
for key in [ACTIVE_PROVIDER_KEY, "GOOSE_PROVIDER", "GOOSE_MODEL"] {
|
||||
match config.delete(key) {
|
||||
Ok(()) | Err(ConfigError::NotFound(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -171,12 +133,6 @@ mod tests {
|
||||
assert!(loaded.configured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_provider_entry_missing() {
|
||||
let config = new_test_config();
|
||||
assert!(get_provider_entry(&config, "nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_active_provider_writes_structured_keys() {
|
||||
let config = new_test_config();
|
||||
@@ -192,28 +148,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_active_provider_from_new_key() {
|
||||
fn test_clear_active_provider_preserves_provider_entries() {
|
||||
let config = new_test_config();
|
||||
config.set_param(ACTIVE_PROVIDER_KEY, "openai").unwrap();
|
||||
set_active_provider(&config, "openai", "gpt-4o").unwrap();
|
||||
|
||||
let result = get_active_provider(&config);
|
||||
assert_eq!(result, Some("openai".to_string()));
|
||||
clear_active_provider(&config).unwrap();
|
||||
|
||||
assert!(get_active_provider(&config).is_none());
|
||||
let entry = get_provider_entry(&config, "openai").unwrap();
|
||||
assert_eq!(entry.model, "gpt-4o");
|
||||
assert!(entry.configured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_active_provider_falls_back_to_legacy() {
|
||||
fn test_clear_active_provider_removes_legacy_keys() {
|
||||
let config = new_test_config();
|
||||
config.set_param("GOOSE_PROVIDER", "anthropic").unwrap();
|
||||
config.set_param("GOOSE_MODEL", "claude").unwrap();
|
||||
|
||||
let result = get_active_provider(&config);
|
||||
assert_eq!(result, Some("anthropic".to_string()));
|
||||
}
|
||||
clear_active_provider(&config).unwrap();
|
||||
|
||||
#[test]
|
||||
fn test_get_active_provider_none_when_empty() {
|
||||
let config = new_test_config();
|
||||
let result = get_active_provider(&config);
|
||||
assert_eq!(result, None);
|
||||
assert!(get_active_provider(&config).is_none());
|
||||
assert!(get_active_model(&config).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -225,25 +181,12 @@ mod tests {
|
||||
assert_eq!(result, Some("gpt-4o".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_active_model_falls_back_to_legacy() {
|
||||
let config = new_test_config();
|
||||
// Only set the legacy key, no providers block
|
||||
config.set_param("GOOSE_MODEL", "gpt-3.5-turbo").unwrap();
|
||||
|
||||
let result = get_active_model(&config);
|
||||
assert_eq!(result, Some("gpt-3.5-turbo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_providers_preserved() {
|
||||
let config = new_test_config();
|
||||
|
||||
// Set up two providers
|
||||
set_active_provider(&config, "openai", "gpt-4o").unwrap();
|
||||
set_active_provider(&config, "anthropic", "claude-3-opus").unwrap();
|
||||
|
||||
// Both entries should exist
|
||||
let openai = get_provider_entry(&config, "openai").unwrap();
|
||||
assert_eq!(openai.model, "gpt-4o");
|
||||
assert!(openai.configured);
|
||||
@@ -252,8 +195,6 @@ mod tests {
|
||||
assert_eq!(anthropic.model, "claude-3-opus");
|
||||
assert!(anthropic.configured);
|
||||
|
||||
// Active provider should be the last one set
|
||||
let active = get_active_provider(&config);
|
||||
assert_eq!(active, Some("anthropic".to_string()));
|
||||
assert_eq!(get_active_provider(&config), Some("anthropic".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ pub mod openai_compatible {
|
||||
pub mod openrouter;
|
||||
pub mod pi_acp;
|
||||
pub mod provider_registry;
|
||||
pub mod provider_secrets;
|
||||
pub mod provider_test;
|
||||
mod retry {
|
||||
pub use goose_providers::retry::*;
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::config::paths::Paths;
|
||||
use crate::config::{Config, ConfigError};
|
||||
use crate::providers::base::{ProviderMetadata, ProviderType};
|
||||
use crate::providers::huggingface_auth;
|
||||
|
||||
pub const SECRET_STORE_ID_PREFIX: &str = "secret_store:";
|
||||
pub const PROVIDER_CACHE_ID_PREFIX: &str = "provider_cache:";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderSecretStorage {
|
||||
SecretStore,
|
||||
ProviderCache,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderSecretStatus {
|
||||
Valid,
|
||||
Expired,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProviderSecret {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub provider_display_name: String,
|
||||
pub name: String,
|
||||
pub storage: ProviderSecretStorage,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub status: ProviderSecretStatus,
|
||||
pub configured: bool,
|
||||
pub has_secret: bool,
|
||||
pub can_delete: bool,
|
||||
pub can_configure: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub configure_provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DeleteProviderSecretError {
|
||||
#[error("Invalid provider secret id: '{0}'")]
|
||||
InvalidId(String),
|
||||
#[error(transparent)]
|
||||
Config(#[from] ConfigError),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
fn provider_secret_status(expires_at: Option<DateTime<Utc>>) -> ProviderSecretStatus {
|
||||
match expires_at {
|
||||
Some(expires_at) if expires_at <= Utc::now() => ProviderSecretStatus::Expired,
|
||||
Some(_) => ProviderSecretStatus::Valid,
|
||||
None => ProviderSecretStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_expiry_value(value: &Value) -> Option<DateTime<Utc>> {
|
||||
match value {
|
||||
Value::String(value) => DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
Value::Number(value) => value
|
||||
.as_i64()
|
||||
.and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_expires_at(value: &Value) -> Option<DateTime<Utc>> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if map
|
||||
.get("refresh_token")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|token| !token.is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(expires_at) = map.get("expires_at").and_then(parse_expiry_value) {
|
||||
return Some(expires_at);
|
||||
}
|
||||
if let Some(expires_at) = map.get("expires_on").and_then(parse_expiry_value) {
|
||||
return Some(expires_at);
|
||||
}
|
||||
map.values().find_map(find_expires_at)
|
||||
}
|
||||
Value::Array(values) => values.iter().find_map(find_expires_at),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ProviderCacheSecretDefinition {
|
||||
provider: &'static str,
|
||||
name: &'static str,
|
||||
path: &'static str,
|
||||
is_directory: bool,
|
||||
}
|
||||
|
||||
const PROVIDER_CACHE_SECRET_DEFINITIONS: &[ProviderCacheSecretDefinition] = &[
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "gemini_oauth",
|
||||
name: "OAuth token",
|
||||
path: "gemini_oauth/tokens.json",
|
||||
is_directory: false,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "chatgpt_codex",
|
||||
name: "OAuth token",
|
||||
path: "chatgpt_codex/tokens.json",
|
||||
is_directory: false,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "kimi_code",
|
||||
name: "OAuth token",
|
||||
path: "kimicode/token.json",
|
||||
is_directory: false,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "github_copilot",
|
||||
name: "OAuth token",
|
||||
path: "githubcopilot",
|
||||
is_directory: true,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "xai_oauth",
|
||||
name: "OAuth token",
|
||||
path: "xai_oauth/tokens.json",
|
||||
is_directory: false,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "databricks",
|
||||
name: "OAuth token",
|
||||
path: "databricks/oauth",
|
||||
is_directory: true,
|
||||
},
|
||||
ProviderCacheSecretDefinition {
|
||||
provider: "databricks_v2",
|
||||
name: "OAuth token",
|
||||
path: "databricks/oauth",
|
||||
is_directory: true,
|
||||
},
|
||||
];
|
||||
|
||||
fn provider_cache_definitions_for_display() -> Vec<ProviderCacheSecretDefinition> {
|
||||
let mut seen_paths = HashSet::new();
|
||||
PROVIDER_CACHE_SECRET_DEFINITIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|definition| seen_paths.insert(definition.path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn provider_cache_definition(provider: &str) -> Option<ProviderCacheSecretDefinition> {
|
||||
PROVIDER_CACHE_SECRET_DEFINITIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|definition| definition.provider == provider)
|
||||
}
|
||||
|
||||
fn provider_cache_providers_sharing_cache(provider: &str) -> Vec<&'static str> {
|
||||
let Some(definition) = provider_cache_definition(provider) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
PROVIDER_CACHE_SECRET_DEFINITIONS
|
||||
.iter()
|
||||
.filter(|other| other.path == definition.path)
|
||||
.map(|definition| definition.provider)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_json_file(path: &Path) -> Option<Value> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|contents| serde_json::from_str(&contents).ok())
|
||||
}
|
||||
|
||||
fn collect_json_expiries(path: &Path, is_directory: bool) -> Vec<DateTime<Utc>> {
|
||||
if !is_directory {
|
||||
return read_json_file(path)
|
||||
.and_then(|value| find_expires_at(&value))
|
||||
.into_iter()
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut expiries = Vec::new();
|
||||
let mut stack = vec![path.to_path_buf()];
|
||||
|
||||
while let Some(current) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(current) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
if let Some(expires_at) =
|
||||
read_json_file(&path).and_then(|value| find_expires_at(&value))
|
||||
{
|
||||
expiries.push(expires_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expiries
|
||||
}
|
||||
|
||||
fn provider_cache_exists(path: &Path, is_directory: bool) -> bool {
|
||||
if !is_directory {
|
||||
return path.is_file();
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
entries.flatten().any(|entry| {
|
||||
let path = entry.path();
|
||||
path.is_file() || provider_cache_exists(&path, true)
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_cache_expiry(definition: ProviderCacheSecretDefinition) -> Option<DateTime<Utc>> {
|
||||
let path = Paths::in_config_dir(definition.path);
|
||||
collect_json_expiries(&path, definition.is_directory)
|
||||
.into_iter()
|
||||
.min()
|
||||
}
|
||||
|
||||
fn build_provider_cache_secret(
|
||||
definition: ProviderCacheSecretDefinition,
|
||||
display_names: &HashMap<String, String>,
|
||||
) -> Option<ProviderSecret> {
|
||||
let path = Paths::in_config_dir(definition.path);
|
||||
if !provider_cache_exists(&path, definition.is_directory) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let expires_at = provider_cache_expiry(definition);
|
||||
Some(ProviderSecret {
|
||||
id: format!("{}{}", PROVIDER_CACHE_ID_PREFIX, definition.provider),
|
||||
provider: definition.provider.to_string(),
|
||||
provider_display_name: display_names
|
||||
.get(definition.provider)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| definition.provider.to_string()),
|
||||
name: definition.name.to_string(),
|
||||
storage: ProviderSecretStorage::ProviderCache,
|
||||
expires_at,
|
||||
status: provider_secret_status(expires_at),
|
||||
configured: true,
|
||||
has_secret: true,
|
||||
can_delete: true,
|
||||
can_configure: false,
|
||||
configure_provider: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_huggingface_oauth_secret(
|
||||
token: Option<huggingface_auth::HuggingFaceTokenData>,
|
||||
) -> ProviderSecret {
|
||||
let expires_at = token.as_ref().and_then(|token| token.expires_at);
|
||||
let has_secret = token.is_some();
|
||||
|
||||
ProviderSecret {
|
||||
id: format!(
|
||||
"{}{}",
|
||||
PROVIDER_CACHE_ID_PREFIX,
|
||||
huggingface_auth::HUGGINGFACE_PROVIDER_NAME
|
||||
),
|
||||
provider: huggingface_auth::HUGGINGFACE_PROVIDER_NAME.to_string(),
|
||||
provider_display_name: huggingface_auth::HUGGINGFACE_DISPLAY_NAME.to_string(),
|
||||
name: huggingface_auth::HUGGINGFACE_OAUTH_TOKEN_NAME.to_string(),
|
||||
storage: ProviderSecretStorage::ProviderCache,
|
||||
expires_at,
|
||||
status: provider_secret_status(expires_at),
|
||||
configured: has_secret,
|
||||
has_secret,
|
||||
can_delete: has_secret,
|
||||
can_configure: true,
|
||||
configure_provider: Some(huggingface_auth::HUGGINGFACE_PROVIDER_NAME.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_secret_store_secrets(
|
||||
stored_secrets: &HashMap<String, Value>,
|
||||
providers: &[(ProviderMetadata, ProviderType)],
|
||||
) -> Vec<ProviderSecret> {
|
||||
let mut secrets = Vec::new();
|
||||
|
||||
for (metadata, _) in providers {
|
||||
for config_key in metadata.config_keys.iter().filter(|key| key.secret) {
|
||||
if !stored_secrets.contains_key(&config_key.name) {
|
||||
continue;
|
||||
}
|
||||
secrets.push(ProviderSecret {
|
||||
id: format!(
|
||||
"{}{}:{}",
|
||||
SECRET_STORE_ID_PREFIX, metadata.name, config_key.name
|
||||
),
|
||||
provider: metadata.name.clone(),
|
||||
provider_display_name: metadata.display_name.clone(),
|
||||
name: config_key.name.clone(),
|
||||
storage: ProviderSecretStorage::SecretStore,
|
||||
expires_at: None,
|
||||
status: ProviderSecretStatus::Unknown,
|
||||
configured: true,
|
||||
has_secret: true,
|
||||
can_delete: true,
|
||||
can_configure: false,
|
||||
configure_provider: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
secrets
|
||||
}
|
||||
|
||||
fn is_known_provider_secret(
|
||||
providers: &[(ProviderMetadata, ProviderType)],
|
||||
provider: &str,
|
||||
key: &str,
|
||||
) -> bool {
|
||||
providers
|
||||
.iter()
|
||||
.filter(|(metadata, _)| metadata.name == provider)
|
||||
.flat_map(|(metadata, _)| metadata.config_keys.iter())
|
||||
.any(|config_key| config_key.secret && config_key.name == key)
|
||||
}
|
||||
|
||||
fn unconfigure_provider(config: &Config, provider_name: &str) -> Result<(), ConfigError> {
|
||||
if let Some(mut entry) = crate::config::get_provider_entry(config, provider_name) {
|
||||
entry.configured = false;
|
||||
crate::config::set_provider_entry(config, provider_name, &entry)?;
|
||||
}
|
||||
|
||||
let configured_marker = format!("{}_configured", provider_name);
|
||||
match config.delete(&configured_marker) {
|
||||
Ok(()) | Err(ConfigError::NotFound(_)) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_secret_store_id(id: &str) -> Option<(&str, &str)> {
|
||||
let rest = id.strip_prefix(SECRET_STORE_ID_PREFIX)?;
|
||||
rest.split_once(':')
|
||||
}
|
||||
|
||||
fn parse_provider_cache_id(id: &str) -> Option<&str> {
|
||||
id.strip_prefix(PROVIDER_CACHE_ID_PREFIX)
|
||||
}
|
||||
|
||||
fn is_valid_provider_name(provider_name: &str) -> bool {
|
||||
!provider_name.is_empty()
|
||||
&& provider_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
fn should_unconfigure_after_secret_delete(
|
||||
provider: &str,
|
||||
key: &str,
|
||||
has_usable_huggingface_oauth_token: impl FnOnce() -> bool,
|
||||
) -> bool {
|
||||
provider == huggingface_auth::HUGGINGFACE_PROVIDER_NAME
|
||||
&& key == huggingface_auth::HUGGINGFACE_TOKEN_SECRET_KEY
|
||||
&& !has_usable_huggingface_oauth_token()
|
||||
}
|
||||
|
||||
pub async fn list_provider_secrets() -> Result<Vec<ProviderSecret>, ConfigError> {
|
||||
let config = Config::global();
|
||||
let stored_secrets = config.all_secrets()?;
|
||||
let providers = crate::providers::providers().await;
|
||||
let display_names: HashMap<String, String> = providers
|
||||
.iter()
|
||||
.map(|(metadata, _)| (metadata.name.clone(), metadata.display_name.clone()))
|
||||
.collect();
|
||||
|
||||
let mut secrets = build_secret_store_secrets(&stored_secrets, &providers);
|
||||
|
||||
for definition in provider_cache_definitions_for_display() {
|
||||
if let Some(secret) = build_provider_cache_secret(definition, &display_names) {
|
||||
if !secrets.iter().any(|existing| existing.id == secret.id) {
|
||||
secrets.push(secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let huggingface_secret = build_huggingface_oauth_secret(huggingface_auth::load_oauth_token());
|
||||
if let Some(existing) = secrets
|
||||
.iter_mut()
|
||||
.find(|existing| existing.id == huggingface_secret.id)
|
||||
{
|
||||
*existing = huggingface_secret;
|
||||
} else {
|
||||
secrets.push(huggingface_secret);
|
||||
}
|
||||
|
||||
secrets.sort_by(|a, b| {
|
||||
a.provider_display_name
|
||||
.cmp(&b.provider_display_name)
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
pub async fn delete_provider_secret(id: &str) -> Result<(), DeleteProviderSecretError> {
|
||||
let config = Config::global();
|
||||
|
||||
if let Some((provider, key)) = parse_secret_store_id(id) {
|
||||
let providers = crate::providers::providers().await;
|
||||
if !is_known_provider_secret(&providers, provider, key) {
|
||||
return Err(DeleteProviderSecretError::InvalidId(id.to_string()));
|
||||
}
|
||||
|
||||
config.delete_secret(key)?;
|
||||
if should_unconfigure_after_secret_delete(provider, key, || {
|
||||
huggingface_auth::has_configured_token().unwrap_or(false)
|
||||
}) {
|
||||
unconfigure_provider(config, provider)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(provider) = parse_provider_cache_id(id) {
|
||||
if provider == huggingface_auth::HUGGINGFACE_PROVIDER_NAME {
|
||||
huggingface_auth::clear_oauth_token()?;
|
||||
unconfigure_provider(config, provider)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !is_valid_provider_name(provider) || provider_cache_definition(provider).is_none() {
|
||||
return Err(DeleteProviderSecretError::InvalidId(id.to_string()));
|
||||
}
|
||||
crate::providers::cleanup_provider(provider).await?;
|
||||
for shared_provider in provider_cache_providers_sharing_cache(provider) {
|
||||
unconfigure_provider(config, shared_provider)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(DeleteProviderSecretError::InvalidId(id.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ProviderEntry;
|
||||
use crate::providers::base::ConfigKey;
|
||||
use serde_json::json;
|
||||
|
||||
fn new_test_config() -> Config {
|
||||
let unique = format!(
|
||||
"goose-provider-secrets-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let config_path = std::env::temp_dir().join(format!("{unique}-config.yaml"));
|
||||
let secrets_path = std::env::temp_dir().join(format!("{unique}-secrets.yaml"));
|
||||
Config::new_with_file_secrets(config_path, secrets_path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_store_listing_only_includes_provider_secret_keys() {
|
||||
let metadata = ProviderMetadata::new(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
"OpenAI provider",
|
||||
"gpt-4o",
|
||||
vec![],
|
||||
"https://example.com",
|
||||
vec![
|
||||
ConfigKey::new("OPENAI_API_KEY", true, true, None, true),
|
||||
ConfigKey::new("OPENAI_HOST", false, false, None, false),
|
||||
],
|
||||
);
|
||||
let providers = vec![(metadata, ProviderType::Builtin)];
|
||||
let stored_secrets = HashMap::from([
|
||||
(
|
||||
"OPENAI_API_KEY".to_string(),
|
||||
Value::String("secret-value".to_string()),
|
||||
),
|
||||
(
|
||||
"UNRELATED_SECRET".to_string(),
|
||||
Value::String("other-secret".to_string()),
|
||||
),
|
||||
(
|
||||
"OPENAI_HOST".to_string(),
|
||||
Value::String("https://api.openai.com".to_string()),
|
||||
),
|
||||
]);
|
||||
|
||||
let secrets = build_secret_store_secrets(&stored_secrets, &providers);
|
||||
|
||||
assert_eq!(secrets.len(), 1);
|
||||
assert_eq!(secrets[0].id, "secret_store:openai:OPENAI_API_KEY");
|
||||
assert_eq!(secrets[0].provider_display_name, "OpenAI");
|
||||
assert_eq!(secrets[0].name, "OPENAI_API_KEY");
|
||||
assert_eq!(secrets[0].storage, ProviderSecretStorage::SecretStore);
|
||||
assert_eq!(secrets[0].status, ProviderSecretStatus::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_secret_delete_validation_requires_provider_secret_key() {
|
||||
let metadata = ProviderMetadata::new(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
"OpenAI provider",
|
||||
"gpt-4o",
|
||||
vec![],
|
||||
"https://example.com",
|
||||
vec![
|
||||
ConfigKey::new("OPENAI_API_KEY", true, true, None, true),
|
||||
ConfigKey::new("OPENAI_HOST", false, false, None, false),
|
||||
],
|
||||
);
|
||||
let providers = vec![(metadata, ProviderType::Builtin)];
|
||||
|
||||
assert!(is_known_provider_secret(
|
||||
&providers,
|
||||
"openai",
|
||||
"OPENAI_API_KEY"
|
||||
));
|
||||
assert!(!is_known_provider_secret(
|
||||
&providers,
|
||||
"openai",
|
||||
"OPENAI_HOST"
|
||||
));
|
||||
assert!(!is_known_provider_secret(
|
||||
&providers,
|
||||
"openai",
|
||||
"UNRELATED_SECRET"
|
||||
));
|
||||
assert!(!is_known_provider_secret(
|
||||
&providers,
|
||||
"anthropic",
|
||||
"OPENAI_API_KEY"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_extraction_handles_nested_rfc3339_values() {
|
||||
let expires_at = Utc::now() + chrono::Duration::hours(1);
|
||||
let value = json!({
|
||||
"project_id": "project",
|
||||
"token": {
|
||||
"access_token": "secret",
|
||||
"expires_at": expires_at.to_rfc3339(),
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = find_expires_at(&value).expect("expected expiry");
|
||||
|
||||
assert_eq!(parsed.timestamp(), expires_at.timestamp());
|
||||
assert_eq!(
|
||||
provider_secret_status(Some(parsed)),
|
||||
ProviderSecretStatus::Valid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_extraction_ignores_refreshable_access_tokens() {
|
||||
let expires_at = Utc::now() - chrono::Duration::hours(1);
|
||||
let value = json!({
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"expires_at": expires_at.to_rfc3339(),
|
||||
});
|
||||
|
||||
assert_eq!(find_expires_at(&value), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_extraction_handles_expired_unix_timestamps() {
|
||||
let value = json!({
|
||||
"info": {
|
||||
"expires_at": 1
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = find_expires_at(&value).expect("expected expiry");
|
||||
|
||||
assert_eq!(parsed.timestamp(), 1);
|
||||
assert_eq!(
|
||||
provider_secret_status(Some(parsed)),
|
||||
ProviderSecretStatus::Expired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconfigure_provider_clears_structured_entry() {
|
||||
let config = new_test_config();
|
||||
crate::config::set_provider_entry(
|
||||
&config,
|
||||
"huggingface",
|
||||
&ProviderEntry {
|
||||
enabled: true,
|
||||
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct".to_string(),
|
||||
configured: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
unconfigure_provider(&config, "huggingface").unwrap();
|
||||
|
||||
let entry = crate::config::get_provider_entry(&config, "huggingface").unwrap();
|
||||
assert!(entry.enabled);
|
||||
assert_eq!(entry.model, "Qwen/Qwen3-Coder-480B-A35B-Instruct");
|
||||
assert!(!entry.configured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconfigure_provider_deletes_legacy_configured_marker() {
|
||||
let config = new_test_config();
|
||||
config.set_param("huggingface_configured", true).unwrap();
|
||||
|
||||
unconfigure_provider(&config, "huggingface").unwrap();
|
||||
|
||||
assert!(config.get_param::<bool>("huggingface_configured").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_huggingface_token_unconfigures_without_oauth() {
|
||||
assert!(should_unconfigure_after_secret_delete(
|
||||
"huggingface",
|
||||
"HF_TOKEN",
|
||||
|| false
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user