feat(acp+): models/providers in desktop on ACP+ (#9987)
Some checks failed
Canary / bundle-desktop-windows-cuda (push) Blocked by required conditions
Canary / Release (push) Blocked by required conditions
Canary / Prepare Version (push) Waiting to run
Canary / build-cli (push) Blocked by required conditions
Canary / Upload Install Script (push) Blocked by required conditions
Canary / bundle-desktop (push) Blocked by required conditions
Canary / bundle-desktop-intel (push) Blocked by required conditions
Canary / bundle-desktop-linux (push) Blocked by required conditions
Canary / bundle-desktop-windows (push) Blocked by required conditions
Unused Dependencies / machete (push) Waiting to run
CI / changes (push) Waiting to run
CI / Check Rust Code Format (push) Blocked by required conditions
CI / Build and Test Rust Project (push) Blocked by required conditions
CI / Build Rust Project on Windows (push) Waiting to run
CI / Check MSRV (push) Blocked by required conditions
CI / Lint Rust Code (push) Blocked by required conditions
CI / Check Generated Schemas are Up-to-Date (push) Blocked by required conditions
CI / Test and Lint Electron Desktop App (push) Blocked by required conditions
Create Minor Release PR / check-version-bump-pr (push) Waiting to run
Create Minor Release PR / release (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (Code Execution) (push) Blocked by required conditions
Live Provider Tests / check-fork (push) Waiting to run
Live Provider Tests / changes (push) Blocked by required conditions
Live Provider Tests / Build Binary (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (push) Blocked by required conditions
Live Provider Tests / Compaction Tests (push) Blocked by required conditions
Live Provider Tests / goose server HTTP integration tests (push) Blocked by required conditions
Publish Docker Image / docker (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Cargo Deny / deny (push) Has been cancelled
Deploy Documentation / deploy (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled

This commit is contained in:
Alex Hancock 2026-06-26 13:04:24 -04:00 committed by GitHub
parent d1d2166190
commit 2fda760641
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 2734 additions and 1687 deletions

4
Cargo.lock generated
View file

@ -6450,9 +6450,9 @@ checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "lopdf"
version = "0.41.0"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67513274c50a2b51e5f75d9e682fcf4ab064a8a9c9ae2c3c59309084882bb24d"
checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c"
dependencies = [
"aes",
"bitflags 2.11.1",

View file

@ -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 }

View file

@ -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 {

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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",

View file

@ -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": [
{

View file

@ -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 {

View file

@ -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,

View file

@ -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 })
}
}

View file

@ -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,
};

View file

@ -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()));
}
}

View file

@ -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::*;

View file

@ -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
));
}
}

View file

@ -1207,33 +1207,6 @@
}
}
},
"/config/providers/{name}/oauth": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "configure_provider_oauth",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Provider name",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OAuth configuration completed"
},
"400": {
"description": "OAuth configuration failed"
}
}
}
},
"/config/read": {
"post": {
"tags": [

View file

@ -63,6 +63,15 @@ vi.mock('./sessions', () => ({
createSession: vi.fn(),
}));
// Mock the ACP providers module used by OnboardingGuard so it doesn't try to
// open a real ACP client connection during tests. Returning null defaults
// keeps the app in the "brand new" (no provider configured) onboarding state.
vi.mock('./acp/providers', () => ({
acpReadDefaults: vi.fn().mockResolvedValue({ providerId: null, modelId: null }),
acpSaveDefaults: vi.fn().mockResolvedValue(undefined),
acpListProviderDetails: vi.fn().mockResolvedValue([]),
}));
// Mock the ConfigContext module
vi.mock('./components/ConfigContext', () => ({
useConfig: () => ({

View file

@ -0,0 +1,217 @@
import type {
CanonicalModelInfoDto,
CustomProviderCreateRequest_unstable,
CustomProviderReadResponse_unstable,
ProviderSecretDto,
ProviderTemplateCatalogEntryDto,
ProviderTemplateDto,
} from '@aaif/goose-sdk';
import type { ProviderDetails, ThinkingEffort, UpdateCustomProviderRequest } from '../api';
import { getAcpClient } from './acpConnection';
export type { CanonicalModelInfoDto, ProviderSecretDto };
function updateRequestToCreate(
request: UpdateCustomProviderRequest
): CustomProviderCreateRequest_unstable {
return {
engine: request.engine,
displayName: request.display_name,
apiUrl: request.api_url,
apiKey: request.api_key || null,
models: request.models,
supportsStreaming: request.supports_streaming ?? null,
headers: request.headers ?? undefined,
requiresAuth: request.requires_auth ?? true,
catalogProviderId: request.catalog_provider_id ?? null,
basePath: request.base_path ?? null,
preservesThinking: request.preserves_thinking ?? null,
};
}
export async function acpListProviderDetails(): Promise<ProviderDetails[]> {
const client = await getAcpClient();
const { entries } = await client.goose.providersList_unstable({});
return entries.map((entry) => ({
name: entry.providerId,
is_configured: entry.configured,
provider_type: entry.providerType as ProviderDetails['provider_type'],
metadata: {
name: entry.providerId,
display_name: entry.providerName,
description: entry.description,
default_model: entry.defaultModel,
model_doc_link: '',
model_selection_hint: entry.modelSelectionHint ?? null,
config_keys: entry.configKeys.map((key) => ({
name: key.name,
required: key.required,
secret: key.secret,
default: key.default ?? null,
oauth_flow: key.oauthFlow ?? false,
device_code_flow: key.deviceCodeFlow ?? false,
primary: key.primary ?? false,
})),
known_models: entry.models.map((model) => ({
name: model.id,
context_limit: model.contextLimit ?? 0,
reasoning: model.reasoning ?? undefined,
})),
setup_steps: entry.setupSteps,
},
}));
}
export async function acpListProviderModels(providerId: string) {
const client = await getAcpClient();
const { entries } = await client.goose.providersList_unstable({ providerIds: [providerId] });
return entries.find((e) => e.providerId === providerId)?.models ?? [];
}
export async function acpListProviderCatalogEntries(
format?: string
): Promise<ProviderTemplateCatalogEntryDto[]> {
const client = await getAcpClient();
const { providers } = await client.goose.providersCatalogList_unstable(format ? { format } : {});
return providers;
}
export async function acpGetProviderTemplate(providerId: string): Promise<ProviderTemplateDto> {
const client = await getAcpClient();
const { template } = await client.goose.providersCatalogTemplate_unstable({ providerId });
return template;
}
export async function acpGetCustomProvider(
providerId: string
): Promise<CustomProviderReadResponse_unstable> {
const client = await getAcpClient();
return client.goose.providersCustomRead_unstable({ providerId });
}
export async function acpCreateCustomProviderFromRequest(
request: UpdateCustomProviderRequest
): Promise<{ provider_name: string }> {
const client = await getAcpClient();
const response = await client.goose.providersCustomCreate_unstable(
updateRequestToCreate(request)
);
return { provider_name: response.providerId };
}
export async function acpUpdateCustomProviderFromRequest(
providerId: string,
request: UpdateCustomProviderRequest
): Promise<void> {
const client = await getAcpClient();
await client.goose.providersCustomUpdate_unstable({
providerId,
...updateRequestToCreate(request),
});
}
export async function acpDeleteCustomProvider(providerId: string): Promise<void> {
const client = await getAcpClient();
await client.goose.providersCustomDelete_unstable({ providerId });
}
export async function acpReadProviderConfig(providerId: string) {
const client = await getAcpClient();
const { fields } = await client.goose.providersConfigRead_unstable({ providerId });
return fields;
}
export async function acpDeleteProviderConfig(providerId: string): Promise<void> {
const client = await getAcpClient();
await client.goose.providersConfigDelete_unstable({ providerId });
}
export async function acpSaveProviderConfig(
providerId: string,
fields: { key: string; value: string }[]
): Promise<void> {
const client = await getAcpClient();
await client.goose.providersConfigSave_unstable({ providerId, fields });
}
export async function acpAuthenticateProvider(providerId: string): Promise<void> {
const client = await getAcpClient();
await client.goose.providersConfigAuthenticate_unstable({ providerId });
}
export async function acpListProviderSecrets(): Promise<ProviderSecretDto[]> {
const client = await getAcpClient();
const { secrets } = await client.goose.providersSecretsList_unstable({});
return secrets;
}
export async function acpDeleteProviderSecret(id: string): Promise<void> {
const client = await getAcpClient();
await client.goose.providersSecretsDelete_unstable({ id });
}
export async function acpGetCanonicalModelInfo(
provider: string,
model: string
): Promise<CanonicalModelInfoDto | null> {
const client = await getAcpClient();
const { modelInfo } = await client.goose.providersCanonicalModelInfo_unstable({
provider,
model,
});
return modelInfo ?? null;
}
export async function acpReadDefaults(): Promise<{
providerId: string | null;
modelId: string | null;
}> {
const client = await getAcpClient();
const response = await client.goose.defaultsRead_unstable({});
return {
providerId: response.providerId ?? null,
modelId: response.modelId ?? null,
};
}
export async function acpSaveDefaults(providerId: string, modelId?: string | null): Promise<void> {
const client = await getAcpClient();
await client.goose.defaultsSave_unstable({ providerId, modelId: modelId ?? null });
}
export async function acpClearDefaults(): Promise<void> {
const client = await getAcpClient();
await client.goose.defaultsClear_unstable({});
}
export async function acpReadThinkingEffort(): Promise<ThinkingEffort | null> {
const client = await getAcpClient();
const response = await client.goose.preferencesRead_unstable({ keys: ['gooseThinkingEffort'] });
const value = response.values.find((v) => v.key === 'gooseThinkingEffort')?.value;
return typeof value === 'string' ? (value as ThinkingEffort) : null;
}
export async function acpSaveThinkingEffort(effort: ThinkingEffort): Promise<void> {
const client = await getAcpClient();
await client.goose.preferencesSave_unstable({
values: [{ key: 'gooseThinkingEffort', value: effort }],
});
}
/**
* Switch the provider (and model) for an active session via ACP config options.
*
* Changing the provider on the server resets the session's model, so the model
* is applied as a follow-up step when supplied.
*/
export async function acpSetSessionProviderModel(
sessionId: string,
providerId: string,
modelId?: string | null
): Promise<void> {
const client = await getAcpClient();
await client.setSessionConfigOption({ sessionId, configId: 'provider', value: providerId });
if (modelId) {
await client.setSessionConfigOption({ sessionId, configId: 'model', value: modelId });
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2719,32 +2719,6 @@ export type GetProviderModelsResponses = {
export type GetProviderModelsResponse = GetProviderModelsResponses[keyof GetProviderModelsResponses];
export type ConfigureProviderOauthData = {
body?: never;
path: {
/**
* Provider name
*/
name: string;
};
query?: never;
url: '/config/providers/{name}/oauth';
};
export type ConfigureProviderOauthErrors = {
/**
* OAuth configuration failed
*/
400: unknown;
};
export type ConfigureProviderOauthResponses = {
/**
* OAuth configuration completed
*/
200: unknown;
};
export type ReadConfigData = {
body: ConfigKeyQuery;
path?: never;

View file

@ -14,8 +14,8 @@ import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar';
import { BottomMenuExtensionSelection } from './bottom_menu/BottomMenuExtensionSelection';
import { cn } from '../utils';
import { AlertType, useAlerts } from './alerts';
import { useConfig } from './ConfigContext';
import { useModelAndProvider } from './ModelAndProviderContext';
import { acpListProviderDetails } from '../acp/providers';
import { USE_ACP_CHAT } from '../acpChatFeatureFlag';
import { useAudioRecorder } from '../hooks/useAudioRecorder';
import { toastError } from '../toasts';
@ -286,7 +286,6 @@ export default function ChatInput({
null
) as React.RefObject<HTMLDivElement>;
const intl = useIntl();
const { getProviders } = useConfig();
const {
getCurrentModelAndProvider,
currentModel: configModel,
@ -609,14 +608,14 @@ export default function ChatInput({
// Priority 2: Check canonical model info (source of truth)
const canonicalInfo = await fetchCanonicalModelInfo(provider, model);
if (canonicalInfo?.context_limit) {
setTokenLimit(canonicalInfo.context_limit);
if (canonicalInfo?.contextLimit) {
setTokenLimit(canonicalInfo.contextLimit);
setIsTokenLimitLoaded(true);
return;
}
// Priority 3: Fall back to provider metadata known_models (may be outdated)
const providers = await getProviders(true);
const providers = await acpListProviderDetails();
const currentProvider = providers.find((p) => p.name === provider);
if (currentProvider?.metadata?.known_models) {
const modelConfig = currentProvider.metadata.known_models.find((m) => m.name === model);

View file

@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { readAllConfig, readConfig, removeConfig, upsertConfig, providers } from '../api';
import { readAllConfig, readConfig, removeConfig, upsertConfig } from '../api';
import { acpListProviderDetails } from '../acp/providers';
import {
getConfiguredExtensions,
addConfigExtension,
@ -150,8 +151,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
const getProviders = useCallback(async (forceRefresh = false): Promise<ProviderDetails[]> => {
if (forceRefresh || providersListRef.current.length === 0) {
try {
const response = await providers();
const providersData = response.data || [];
const providersData = await acpListProviderDetails();
providersListRef.current = providersData;
setProvidersList(providersData);
return providersData;
@ -172,8 +172,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
// Load providers
try {
const providersResponse = await providers();
const providersData = providersResponse.data || [];
const providersData = await acpListProviderDetails();
providersListRef.current = providersData;
setProvidersList(providersData);
} catch (error) {

View file

@ -1,8 +1,8 @@
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { toastError, toastSuccess } from '../toasts';
import Model, { getProviderMetadata } from './settings/models/modelInterface';
import { ProviderMetadata, setConfigProvider, updateAgentProvider } from '../api';
import { useConfig } from './ConfigContext';
import { ProviderMetadata } from '../api';
import { acpReadDefaults, acpSaveDefaults, acpSetSessionProviderModel } from '../acp/providers';
import { errorMessage } from '../utils/conversionUtils';
import {
getModelDisplayName,
@ -60,7 +60,6 @@ export { i18n as modelAndProviderMessages };
export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> = ({ children }) => {
const [currentModel, setCurrentModel] = useState<string | null>(null);
const [currentProvider, setCurrentProvider] = useState<string | null>(null);
const { read, getProviders } = useConfig();
const intl = useIntl();
const changeModel = useCallback(
@ -71,31 +70,14 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
try {
if (sessionId) {
const response = await updateAgentProvider({
body: {
session_id: sessionId,
provider: providerName,
model: modelName,
context_limit: model.context_limit,
request_params: model.request_params,
},
});
if (response.error) {
throw new Error(`Failed to update agent provider: ${response.error}`);
}
await acpSetSessionProviderModel(sessionId, providerName, modelName);
}
// Only update the global config default when there's no session
// (i.e. changing from settings, not from within an existing chat)
if (!sessionId) {
phase = 'config';
await setConfigProvider({
body: {
provider: providerName,
model: modelName,
},
throwOnError: true,
});
await acpSaveDefaults(providerName, modelName);
}
if (!sessionId) {
@ -132,13 +114,7 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
const model = window.appConfig.get('GOOSE_DEFAULT_MODEL') as string;
if (provider && model) {
try {
await setConfigProvider({
body: {
provider: provider,
model: model,
},
throwOnError: true,
});
await acpSaveDefaults(provider, model);
} catch (error) {
console.error('[getFallbackModelAndProvider] Failed to write to config', error);
}
@ -147,22 +123,22 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
}, []);
const getCurrentModelAndProvider = useCallback(async () => {
let model: string;
let provider: string;
let model: string | null;
let provider: string | null;
// read from config
try {
model = (await read('GOOSE_MODEL', false)) as string;
provider = (await read('GOOSE_PROVIDER', false)) as string;
const defaults = await acpReadDefaults();
model = defaults.modelId;
provider = defaults.providerId;
} catch {
console.error(`Failed to read GOOSE_MODEL or GOOSE_PROVIDER from config`);
throw new Error('Failed to read GOOSE_MODEL or GOOSE_PROVIDER from config');
console.error(`Failed to read default model or provider`);
throw new Error('Failed to read default model or provider');
}
if (!model || !provider) {
return getFallbackModelAndProvider();
}
return { model: model, provider: provider };
}, [read, getFallbackModelAndProvider]);
}, [getFallbackModelAndProvider]);
const getCurrentModelAndProviderForDisplay = useCallback(async () => {
const modelProvider = await getCurrentModelAndProvider();
@ -173,28 +149,28 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
let metadata: ProviderMetadata;
try {
metadata = await getProviderMetadata(String(gooseProvider), getProviders);
metadata = await getProviderMetadata(String(gooseProvider));
} catch {
return { model: gooseModel, provider: gooseProvider };
}
const providerDisplayName = metadata.display_name;
return { model: gooseModel, provider: providerDisplayName };
}, [getCurrentModelAndProvider, getProviders]);
}, [getCurrentModelAndProvider]);
const getCurrentModelDisplayName = useCallback(async () => {
try {
const currentModelName = (await read('GOOSE_MODEL', false)) as string;
return getModelDisplayName(currentModelName);
const { modelId } = await acpReadDefaults();
return getModelDisplayName(modelId ?? '');
} catch {
return intl.formatMessage(i18n.selectModel);
}
}, [read, intl]);
}, [intl]);
const getCurrentProviderDisplayName = useCallback(async () => {
try {
const currentModelName = (await read('GOOSE_MODEL', false)) as string;
const providerDisplayName = getProviderDisplayName(currentModelName);
const { modelId } = await acpReadDefaults();
const providerDisplayName = getProviderDisplayName(modelId ?? '');
if (providerDisplayName) {
return providerDisplayName;
}
@ -204,7 +180,7 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
} catch {
return '';
}
}, [read, getCurrentModelAndProviderForDisplay]);
}, [getCurrentModelAndProviderForDisplay]);
const refreshCurrentModelAndProvider = useCallback(async () => {
try {

View file

@ -1,8 +1,7 @@
import { useState, useEffect } from 'react';
import { CoinIcon } from '../icons';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
import { fetchCanonicalModelInfo } from '../../utils/canonical';
import type { ModelInfoData } from '../../api';
import { fetchCanonicalModelInfo, type CanonicalModelInfo } from '../../utils/canonical';
import { defineMessages, useIntl } from '../../i18n';
const i18n = defineMessages({
@ -40,7 +39,7 @@ export function CostTracker({
provider: currentProvider,
}: CostTrackerProps) {
const intl = useIntl();
const [costInfo, setCostInfo] = useState<ModelInfoData | null>(null);
const [costInfo, setCostInfo] = useState<CanonicalModelInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showPricing, setShowPricing] = useState(true);
const [pricingFailed, setPricingFailed] = useState(false);
@ -118,7 +117,7 @@ export function CostTracker({
if (
accumulatedCost == null &&
(!costInfo ||
(costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined))
(costInfo.inputTokenCost === undefined && costInfo.outputTokenCost === undefined))
) {
const freeProviders = ['ollama', 'local', 'localhost'];
if (freeProviders.includes(currentProvider.toLowerCase())) {
@ -170,14 +169,14 @@ export function CostTracker({
return intl.formatMessage(i18n.totalSessionCost, { cost: `${currency}${totalCost.toFixed(4)}` })
+ `\n` + intl.formatMessage(i18n.inputOutputTooltip, {
inputTokens: inputTokens.toLocaleString(),
inputCost: `${currency}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}`,
inputCost: `${currency}${((inputTokens * (costInfo?.inputTokenCost || 0)) / 1_000_000).toFixed(6)}`,
outputTokens: outputTokens.toLocaleString(),
outputCost: `${currency}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)}`,
outputCost: `${currency}${((outputTokens * (costInfo?.outputTokenCost || 0)) / 1_000_000).toFixed(6)}`,
});
}
const inputCostStr = `${currency}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}`;
const outputCostStr = `${currency}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)}`;
const inputCostStr = `${currency}${((inputTokens * (costInfo?.inputTokenCost || 0)) / 1_000_000).toFixed(6)}`;
const outputCostStr = `${currency}${((outputTokens * (costInfo?.outputTokenCost || 0)) / 1_000_000).toFixed(6)}`;
return intl.formatMessage(i18n.inputOutputTooltip, {
inputTokens: inputTokens.toLocaleString(),
inputCost: inputCostStr,

View file

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConfig } from '../ConfigContext';
import { useModelAndProvider } from '../ModelAndProviderContext';
import { acpListProviderDetails, acpReadDefaults, acpSaveDefaults } from '../../acp/providers';
import { Goose } from '../icons';
import { Button } from '../ui/button';
import ProviderSelector from './ProviderSelector';
@ -47,7 +48,7 @@ interface OnboardingGuardProps {
export default function OnboardingGuard({ children }: OnboardingGuardProps) {
const intl = useIntl();
const navigate = useNavigate();
const { read, upsert, getProviders } = useConfig();
const { upsert } = useConfig();
const { getFallbackModelAndProvider, refreshCurrentModelAndProvider } = useModelAndProvider();
const [isCheckingProvider, setIsCheckingProvider] = useState(true);
@ -66,7 +67,7 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) {
setCheckProviderError(false);
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const provider = (await read('GOOSE_PROVIDER', false, { throwOnError: true })) as string | null;
const { providerId: provider } = await acpReadDefaults();
if (provider?.trim()) {
setHasProvider(true);
setIsCheckingProvider(false);
@ -75,8 +76,8 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) {
const fallback = await getFallbackModelAndProvider();
if (fallback.provider?.trim() && fallback.model?.trim()) {
const configuredProvider = (await read('GOOSE_PROVIDER', false)) as string | null;
const configuredModel = (await read('GOOSE_MODEL', false)) as string | null;
const { providerId: configuredProvider, modelId: configuredModel } =
await acpReadDefaults();
if (configuredProvider?.trim() && configuredModel?.trim()) {
await refreshCurrentModelAndProvider();
setHasProvider(true);
@ -113,16 +114,11 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) {
const handleConfigured = async (providerName: string, modelId?: string) => {
trackOnboardingProviderSelected({ provider: providerName });
await upsert('GOOSE_PROVIDER', providerName, false);
const providers = await getProviders(true);
const providers = await acpListProviderDetails();
const matchedProvider = providers.find((p) => p.name === providerName);
if (modelId) {
await upsert('GOOSE_MODEL', modelId, false);
setConfiguredModel(modelId);
} else if (matchedProvider) {
await upsert('GOOSE_MODEL', matchedProvider.metadata.default_model, false);
setConfiguredModel(matchedProvider.metadata.default_model);
}
const resolvedModel = modelId ?? matchedProvider?.metadata.default_model ?? null;
await acpSaveDefaults(providerName, resolvedModel);
setConfiguredModel(resolvedModel);
await refreshCurrentModelAndProvider();
setConfiguredProvider(providerName);
setConfiguredProviderDisplayName(matchedProvider?.metadata.display_name || providerName);

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { configureProviderOauth, ProviderDetails } from '../../api';
import { useConfig } from '../ConfigContext';
import { ProviderDetails } from '../../api';
import { acpAuthenticateProvider } from '../../acp/providers';
import DefaultProviderSetupForm, {
ConfigInput,
} from '../settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm';
@ -78,10 +78,7 @@ function OAuthForm({
const handleLogin = async () => {
setIsLoading(true);
try {
await configureProviderOauth({
path: { name: provider.name },
throwOnError: true,
});
await acpAuthenticateProvider(provider.name);
onConfigured(provider.name);
} catch (err) {
onError(`Sign-in failed: ${err instanceof Error ? err.message : String(err)}`);
@ -101,7 +98,9 @@ function OAuthForm({
size="lg"
>
<LogIn size={20} />
{isLoading ? intl.formatMessage(i18n.signingIn) : intl.formatMessage(i18n.signInWith, { providerName: provider.metadata.display_name })}
{isLoading
? intl.formatMessage(i18n.signingIn)
: intl.formatMessage(i18n.signInWith, { providerName: provider.metadata.display_name })}
</Button>
<p className="text-xs text-text-muted text-center">
{isDeviceCodeFlow
@ -122,7 +121,6 @@ function ApiKeyForm({
onError: (msg: string) => void;
}) {
const intl = useIntl();
const { upsert } = useConfig();
const [configValues, setConfigValues] = useState<Record<string, ConfigInput>>({});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
@ -158,7 +156,7 @@ function ApiKeyForm({
setIsSubmitting(true);
try {
await providerConfigSubmitHandler(upsert, provider, toSubmit);
await providerConfigSubmitHandler(provider, toSubmit);
onConfigured(provider.name);
} catch (err) {
const msg =

View file

@ -1,10 +1,9 @@
import { useState, useEffect, useMemo } from 'react';
import { ProviderDetails, UpdateCustomProviderRequest } from '../../api';
import {
providers as fetchProviders,
createCustomProvider,
ProviderDetails,
UpdateCustomProviderRequest,
} from '../../api';
acpCreateCustomProviderFromRequest,
acpListProviderDetails,
} from '../../acp/providers';
import { Select } from '../ui/Select';
import ProviderConfigForm from './ProviderConfigForm';
import FreeOptionCards from './FreeOptionCards';
@ -73,13 +72,8 @@ export default function ProviderSelector({
useEffect(() => {
const load = async () => {
try {
const response = await fetchProviders({ throwOnError: true });
if (response.data) {
const list = Array.isArray(response.data)
? response.data
: (response.data as { providers: ProviderDetails[] }).providers || [];
setProviderList(list);
}
const list = await acpListProviderDetails();
setProviderList(list);
} catch (err) {
console.error('Failed to fetch providers:', err);
}
@ -127,10 +121,10 @@ export default function ProviderSelector({
};
const handleCreateCustomProvider = async (data: UpdateCustomProviderRequest) => {
const result = await createCustomProvider({ body: data, throwOnError: true });
const result = await acpCreateCustomProviderFromRequest(data);
setShowCustomModal(false);
if (result.data?.provider_name) {
onConfigured(result.data.provider_name);
if (result.provider_name) {
onConfigured(result.provider_name);
}
};

View file

@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react';
import { Select } from '../../ui/Select';
import { Input } from '../../ui/input';
import { useConfig } from '../../ConfigContext';
import { acpListProviderDetails } from '../../../acp/providers';
import { fetchModelsForProviders } from '../../settings/models/modelInterface';
import { defineMessages, useIntl } from '../../../i18n';
@ -70,7 +70,6 @@ export const RecipeModelSelector = ({
onModelChange,
}: RecipeModelSelectorProps) => {
const intl = useIntl();
const { getProviders } = useConfig();
const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]);
const [modelOptions, setModelOptions] = useState<
{ options: { value: string; label: string; provider: string }[] }[]
@ -83,7 +82,7 @@ export const RecipeModelSelector = ({
(async () => {
try {
setFetchError(null);
const providersResponse = await getProviders(false);
const providersResponse = await acpListProviderDetails();
const activeProviders = providersResponse.filter((provider) => provider.is_configured);
setProviderOptions([
@ -132,7 +131,7 @@ export const RecipeModelSelector = ({
setLoadingModels(false);
}
})();
}, [getProviders, intl]);
}, [intl]);
useEffect(() => {
if (!loadingModels && selectedModel && selectedProvider) {

View file

@ -3,23 +3,19 @@ import { render, screen, waitFor, type RenderOptions } from '@testing-library/re
import userEvent from '@testing-library/user-event';
import AuthSettingsSection from './AuthSettingsSection';
import {
configureProviderOauth,
deleteProviderSecret,
listProviderSecrets,
ProviderSecret,
} from '../../../api';
acpAuthenticateProvider,
acpDeleteProviderSecret,
acpListProviderSecrets,
type ProviderSecretDto,
} from '../../../acp/providers';
import { IntlTestWrapper } from '../../../i18n/test-utils';
import { toast } from 'react-toastify';
vi.mock('../../../api', async () => {
const actual = await vi.importActual<typeof import('../../../api')>('../../../api');
return {
...actual,
configureProviderOauth: vi.fn(),
listProviderSecrets: vi.fn(),
deleteProviderSecret: vi.fn(),
};
});
vi.mock('../../../acp/providers', () => ({
acpAuthenticateProvider: vi.fn(),
acpListProviderSecrets: vi.fn(),
acpDeleteProviderSecret: vi.fn(),
}));
vi.mock('../../ModelAndProviderContext', () => ({
useModelAndProvider: () => ({
@ -34,41 +30,35 @@ vi.mock('react-toastify', () => ({
},
}));
const mockedListProviderSecrets = vi.mocked(listProviderSecrets);
const mockedDeleteProviderSecret = vi.mocked(deleteProviderSecret);
const mockedConfigureProviderOauth = vi.mocked(configureProviderOauth);
const mockedListProviderSecrets = vi.mocked(acpListProviderSecrets);
const mockedDeleteProviderSecret = vi.mocked(acpDeleteProviderSecret);
const mockedAcpAuthenticateProvider = vi.mocked(acpAuthenticateProvider);
const mockedToast = vi.mocked(toast);
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
render(ui, { wrapper: IntlTestWrapper, ...options });
const providerSecret: ProviderSecret = {
const providerSecret: ProviderSecretDto = {
id: 'secret_store:openai:OPENAI_API_KEY',
provider: 'openai',
provider_display_name: 'OpenAI',
providerDisplayName: 'OpenAI',
name: 'OPENAI_API_KEY',
storage: 'secret_store',
expires_at: null,
expiresAt: null,
status: 'unknown',
configured: true,
has_secret: true,
can_delete: true,
can_configure: false,
configure_provider: null,
hasSecret: true,
canDelete: true,
canConfigure: false,
configureProvider: null,
};
const apiResult = <T,>(data: T) => ({
data,
request: {} as never,
response: {} as never,
});
describe('AuthSettingsSection', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedListProviderSecrets.mockResolvedValue(apiResult({ secrets: [] }));
mockedDeleteProviderSecret.mockResolvedValue(apiResult('ok'));
mockedConfigureProviderOauth.mockResolvedValue(apiResult('ok'));
mockedListProviderSecrets.mockResolvedValue([]);
mockedDeleteProviderSecret.mockResolvedValue(undefined);
mockedAcpAuthenticateProvider.mockResolvedValue(undefined);
});
it('renders an empty state when no credentials are stored', async () => {
@ -79,17 +69,13 @@ describe('AuthSettingsSection', () => {
});
it('renders provider credentials with storage and expiry status', async () => {
mockedListProviderSecrets.mockResolvedValue(
apiResult({
secrets: [
{
...providerSecret,
expires_at: '2027-01-01T12:00:00Z',
status: 'valid',
},
],
})
);
mockedListProviderSecrets.mockResolvedValue([
{
...providerSecret,
expiresAt: '2027-01-01T12:00:00Z',
status: 'valid',
},
]);
renderWithIntl(<AuthSettingsSection />);
@ -100,7 +86,7 @@ describe('AuthSettingsSection', () => {
});
it('does not render an expiry badge when expiry is unknown', async () => {
mockedListProviderSecrets.mockResolvedValue(apiResult({ secrets: [providerSecret] }));
mockedListProviderSecrets.mockResolvedValue([providerSecret]);
renderWithIntl(<AuthSettingsSection />);
@ -113,8 +99,8 @@ describe('AuthSettingsSection', () => {
it('deletes a credential after confirmation and refreshes the list', async () => {
const user = userEvent.setup();
mockedListProviderSecrets
.mockResolvedValueOnce(apiResult({ secrets: [providerSecret] }))
.mockResolvedValueOnce(apiResult({ secrets: [] }));
.mockResolvedValueOnce([providerSecret])
.mockResolvedValueOnce([]);
renderWithIntl(<AuthSettingsSection />);
@ -132,10 +118,7 @@ describe('AuthSettingsSection', () => {
await user.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(mockedDeleteProviderSecret).toHaveBeenCalledWith({
path: { id: 'secret_store:openai:OPENAI_API_KEY' },
throwOnError: true,
});
expect(mockedDeleteProviderSecret).toHaveBeenCalledWith('secret_store:openai:OPENAI_API_KEY');
});
await waitFor(() => {
expect(mockedToast.success).toHaveBeenCalledWith('Credential deleted');
@ -145,35 +128,31 @@ describe('AuthSettingsSection', () => {
it('configures the permanent Hugging Face credential row', async () => {
const user = userEvent.setup();
const huggingFaceSecret: ProviderSecret = {
const huggingFaceSecret: ProviderSecretDto = {
id: 'provider_cache:huggingface',
provider: 'huggingface',
provider_display_name: 'Hugging Face',
providerDisplayName: 'Hugging Face',
name: 'OAuth token',
storage: 'provider_cache',
expires_at: null,
expiresAt: null,
status: 'unknown',
configured: false,
has_secret: false,
can_delete: false,
can_configure: true,
configure_provider: 'huggingface',
hasSecret: false,
canDelete: false,
canConfigure: true,
configureProvider: 'huggingface',
};
mockedListProviderSecrets
.mockResolvedValueOnce(apiResult({ secrets: [huggingFaceSecret] }))
.mockResolvedValueOnce(
apiResult({
secrets: [
{
...huggingFaceSecret,
configured: true,
has_secret: true,
can_delete: true,
},
],
})
);
.mockResolvedValueOnce([huggingFaceSecret])
.mockResolvedValueOnce([
{
...huggingFaceSecret,
configured: true,
hasSecret: true,
canDelete: true,
},
]);
renderWithIntl(<AuthSettingsSection />);
@ -182,10 +161,7 @@ describe('AuthSettingsSection', () => {
await user.click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => {
expect(mockedConfigureProviderOauth).toHaveBeenCalledWith({
path: { name: 'huggingface' },
throwOnError: true,
});
expect(mockedAcpAuthenticateProvider).toHaveBeenCalledWith('huggingface');
});
await waitFor(() => {
expect(mockedToast.success).toHaveBeenCalledWith('Credential configured');

View file

@ -2,11 +2,11 @@ import { useCallback, useEffect, useState } from 'react';
import { KeyRound, Loader2, LogIn, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'react-toastify';
import {
configureProviderOauth,
deleteProviderSecret,
listProviderSecrets,
ProviderSecret,
} from '../../../api';
acpAuthenticateProvider,
acpDeleteProviderSecret,
acpListProviderSecrets,
type ProviderSecretDto,
} from '../../../acp/providers';
import { errorMessage } from '../../../utils/conversionUtils';
import { useModelAndProvider } from '../../ModelAndProviderContext';
import { Button } from '../../ui/button';
@ -97,26 +97,26 @@ const i18n = defineMessages({
},
});
function storageLabel(secret: ProviderSecret, intl: ReturnType<typeof useIntl>) {
function storageLabel(secret: ProviderSecretDto, intl: ReturnType<typeof useIntl>) {
if (secret.storage === 'provider_cache') {
return intl.formatMessage(i18n.storageProviderCache);
}
return intl.formatMessage(i18n.storageSecretStore);
}
function expiryLabel(secret: ProviderSecret, intl: ReturnType<typeof useIntl>) {
if (!secret.expires_at) {
function expiryLabel(secret: ProviderSecretDto, intl: ReturnType<typeof useIntl>) {
if (!secret.expiresAt) {
return null;
}
return intl.formatMessage(i18n.expiresAt, {
date: intl.formatDate(new Date(secret.expires_at), {
date: intl.formatDate(new Date(secret.expiresAt), {
dateStyle: 'medium',
timeStyle: 'short',
}),
});
}
function expiryClass(secret: ProviderSecret) {
function expiryClass(secret: ProviderSecretDto) {
if (secret.status === 'expired') {
return 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300';
}
@ -126,17 +126,17 @@ function expiryClass(secret: ProviderSecret) {
export default function AuthSettingsSection() {
const intl = useIntl();
const { currentProvider } = useModelAndProvider();
const [secrets, setSecrets] = useState<ProviderSecret[]>([]);
const [secrets, setSecrets] = useState<ProviderSecretDto[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [configuringId, setConfiguringId] = useState<string | null>(null);
const [secretToDelete, setSecretToDelete] = useState<ProviderSecret | null>(null);
const [secretToDelete, setSecretToDelete] = useState<ProviderSecretDto | null>(null);
const loadSecrets = useCallback(async () => {
setLoading(true);
try {
const response = await listProviderSecrets({ throwOnError: true });
setSecrets(response.data?.secrets ?? []);
const secrets = await acpListProviderSecrets();
setSecrets(secrets);
} catch {
toast.error(intl.formatMessage(i18n.failedToLoad));
setSecrets([]);
@ -156,10 +156,7 @@ export default function AuthSettingsSection() {
setDeletingId(secretToDelete.id);
try {
await deleteProviderSecret({
path: { id: secretToDelete.id },
throwOnError: true,
});
await acpDeleteProviderSecret(secretToDelete.id);
toast.success(intl.formatMessage(i18n.deleted));
setSecretToDelete(null);
await loadSecrets();
@ -174,17 +171,14 @@ export default function AuthSettingsSection() {
}
};
const configureSecret = async (secret: ProviderSecret) => {
if (!secret.configure_provider) {
const configureSecret = async (secret: ProviderSecretDto) => {
if (!secret.configureProvider) {
return;
}
setConfiguringId(secret.id);
try {
await configureProviderOauth({
path: { name: secret.configure_provider },
throwOnError: true,
});
await acpAuthenticateProvider(secret.configureProvider);
toast.success(intl.formatMessage(i18n.signedIn));
await loadSecrets();
} catch (error) {
@ -229,7 +223,7 @@ export default function AuthSettingsSection() {
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-medium text-text-primary">
{secret.provider_display_name}
{secret.providerDisplayName}
</h3>
<span className="rounded border border-border-primary bg-background-secondary px-2 py-0.5 text-xs text-text-secondary">
{storageLabel(secret, intl)}
@ -247,7 +241,7 @@ export default function AuthSettingsSection() {
</p>
</div>
<div className="flex items-center gap-2 self-start sm:self-auto">
{secret.can_configure && secret.configure_provider && (
{secret.canConfigure && secret.configureProvider && (
<Button
variant="outline"
size="sm"
@ -257,17 +251,17 @@ export default function AuthSettingsSection() {
>
{configuringId === secret.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : secret.has_secret || secret.configured ? (
) : secret.hasSecret || secret.configured ? (
<RefreshCw className="h-4 w-4" />
) : (
<LogIn className="h-4 w-4" />
)}
{secret.has_secret || secret.configured
{secret.hasSecret || secret.configured
? intl.formatMessage(i18n.reauthorize)
: intl.formatMessage(i18n.signIn)}
</Button>
)}
{secret.can_delete && (
{secret.canDelete && (
<Button
variant="ghost"
size="sm"
@ -300,7 +294,7 @@ export default function AuthSettingsSection() {
secretToDelete
? intl.formatMessage(i18n.deleteMessage, {
name: secretToDelete.name,
provider: secretToDelete.provider_display_name,
provider: secretToDelete.providerDisplayName,
})
: ''
}

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Loader2, LogIn } from 'lucide-react';
import { toast } from 'react-toastify';
import { configureProviderOauth, listProviderSecrets } from '../../../api';
import { acpAuthenticateProvider, acpListProviderSecrets } from '../../../acp/providers';
import { errorMessage } from '../../../utils/conversionUtils';
import { defineMessages, useIntl } from '../../../i18n';
import { Button } from '../../ui/button';
@ -51,11 +51,11 @@ export default function HuggingFaceSignInPrompt({
const loadStatus = useCallback(async () => {
setLoading(true);
try {
const response = await listProviderSecrets({ throwOnError: true });
const huggingFaceSecret = response.data?.secrets.find(
const secrets = await acpListProviderSecrets();
const huggingFaceSecret = secrets.find(
(secret) => secret.id === HUGGINGFACE_OAUTH_SECRET_ID
);
setLoggedIn(Boolean(huggingFaceSecret?.has_secret && huggingFaceSecret.status !== 'expired'));
setLoggedIn(Boolean(huggingFaceSecret?.hasSecret && huggingFaceSecret.status !== 'expired'));
} catch {
setLoggedIn(false);
} finally {
@ -70,10 +70,7 @@ export default function HuggingFaceSignInPrompt({
const signIn = async () => {
setSigningIn(true);
try {
await configureProviderOauth({
path: { name: HUGGINGFACE_PROVIDER },
throwOnError: true,
});
await acpAuthenticateProvider(HUGGINGFACE_PROVIDER);
toast.success(intl.formatMessage(i18n.signedIn));
setLoggedIn(true);
onSignedIn?.();

View file

@ -19,7 +19,6 @@ import {
getLocalModelDownloadProgress,
cancelLocalModelDownload,
deleteLocalModel,
setConfigProvider,
type DownloadProgress,
type DownloadModelRequest,
type LocalModelResponse,
@ -28,6 +27,7 @@ import { HuggingFaceModelSearch } from './HuggingFaceModelSearch';
import { ModelSettingsPanel } from './ModelSettingsPanel';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
import HuggingFaceSignInPrompt from '../auth/HuggingFaceSignInPrompt';
import { acpSaveDefaults } from '../../../acp/providers';
const i18n = defineMessages({
title: {
@ -247,10 +247,7 @@ export const LocalInferenceSettings = () => {
const selectModel = async (modelId: string) => {
try {
await setConfigProvider({
body: { provider: 'local', model: modelId },
throwOnError: true,
});
await acpSaveDefaults('local', modelId);
await refreshCurrentModelAndProvider();
} catch (error) {
console.error('Failed to select model:', error);

View file

@ -1,11 +1,8 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { View } from '../../../utils/navigationUtils';
import ModelSettingsButtons from './subcomponents/ModelSettingsButtons';
import { useConfig } from '../../ConfigContext';
import {
modelAndProviderMessages,
useModelAndProvider,
} from '../../ModelAndProviderContext';
import { acpListProviderDetails, acpReadDefaults } from '../../../acp/providers';
import { modelAndProviderMessages, useModelAndProvider } from '../../ModelAndProviderContext';
import { toastError } from '../../../toasts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
@ -32,7 +29,6 @@ export default function ModelsSection({ setView }: ModelsSectionProps) {
const [provider, setProvider] = useState<string | null>(null);
const [displayModelName, setDisplayModelName] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const { read, getProviders } = useConfig();
const {
getCurrentModelDisplayName,
getCurrentProviderDisplayName,
@ -54,8 +50,8 @@ export default function ModelsSection({ setView }: ModelsSectionProps) {
setProvider(providerDisplayName);
} else {
// Fallback to original provider lookup
const gooseProvider = (await read('GOOSE_PROVIDER', false)) as string;
const providers = await getProviders(true);
const { providerId: gooseProvider } = await acpReadDefaults();
const providers = await acpListProviderDetails();
const providerDetailsList = providers.filter((provider) => provider.name === gooseProvider);
if (providerDetailsList.length != 1) {
@ -74,7 +70,7 @@ export default function ModelsSection({ setView }: ModelsSectionProps) {
} finally {
setIsLoading(false);
}
}, [read, getProviders, getCurrentModelDisplayName, getCurrentProviderDisplayName, intl]);
}, [getCurrentModelDisplayName, getCurrentProviderDisplayName, intl]);
useEffect(() => {
loadModelData();
@ -117,9 +113,7 @@ export default function ModelsSection({ setView }: ModelsSectionProps) {
<Card className="pb-2 rounded-lg">
<CardHeader className="pb-0">
<CardTitle className="">{intl.formatMessage(i18n.resetTitle)}</CardTitle>
<CardDescription>
{intl.formatMessage(i18n.resetDescription)}
</CardDescription>
<CardDescription>{intl.formatMessage(i18n.resetDescription)}</CardDescription>
</CardHeader>
<CardContent className="px-2">
<ResetProviderSection setView={setView} />

View file

@ -9,7 +9,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '../../../ui/dropdown-menu';
import { useConfig } from '../../../ConfigContext';
import { getProviderMetadata } from '../modelInterface';
import { getModelDisplayName } from '../predefinedModelsUtils';
@ -77,9 +76,10 @@ export default function ModelsBottomBar({
const currentProvider = sessionProvider ?? configProvider;
const intl = useIntl();
const { getProviders } = useConfig();
const [displayProvider, setDisplayProvider] = useState<string | null>(null);
const [displayModelName, setDisplayModelName] = useState<string>(intl.formatMessage(i18n.selectModel));
const [displayModelName, setDisplayModelName] = useState<string>(
intl.formatMessage(i18n.selectModel)
);
const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false);
const [isLocalModelSettingsOpen, setIsLocalModelSettingsOpen] = useState(false);
const [providerDefaultModel, setProviderDefaultModel] = useState<string | null>(null);
@ -91,10 +91,10 @@ export default function ModelsBottomBar({
const resolvedModel = latestInference?.resolvedModel ?? null;
const shouldShowResolvedModel = Boolean(
!isModelLoading &&
resolvedModel &&
latestInference?.provider === currentProvider &&
latestInference?.requestedModel === currentModel &&
resolvedModel !== currentModel
resolvedModel &&
latestInference?.provider === currentProvider &&
latestInference?.requestedModel === currentModel &&
resolvedModel !== currentModel
);
const loadingModelLabel = intl.formatMessage(i18n.loadingModel);
const triggerLabel = isModelLoading ? loadingModelLabel : displayModel;
@ -102,21 +102,21 @@ export default function ModelsBottomBar({
useEffect(() => {
if (!currentProvider) return;
getProviderMetadata(currentProvider, getProviders)
getProviderMetadata(currentProvider)
.then((metadata) => {
setDisplayProvider(metadata.display_name || currentProvider);
})
.catch(() => {
setDisplayProvider(currentProvider);
});
}, [currentProvider, currentModel, getProviders]);
}, [currentProvider, currentModel]);
// Fetch provider default model when provider changes and no current model
useEffect(() => {
if (currentProvider && !currentModel) {
(async () => {
try {
const metadata = await getProviderMetadata(currentProvider, getProviders);
const metadata = await getProviderMetadata(currentProvider);
setProviderDefaultModel(metadata.default_model);
} catch (error) {
console.error('Failed to get provider default model:', error);
@ -126,7 +126,7 @@ export default function ModelsBottomBar({
} else if (currentModel) {
setProviderDefaultModel(null);
}
}, [currentProvider, currentModel, getProviders]);
}, [currentProvider, currentModel]);
useEffect(() => {
if (!currentModel) return;
@ -162,14 +162,18 @@ export default function ModelsBottomBar({
</div>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="center" className="w-64 text-sm">
<h6 className="text-xs text-text-primary mt-2 ml-2">{intl.formatMessage(i18n.currentModel)}</h6>
<h6 className="text-xs text-text-primary mt-2 ml-2">
{intl.formatMessage(i18n.currentModel)}
</h6>
<p className="flex items-center justify-between text-sm mx-2 pb-2 border-b mb-2">
{menuModelLabel}
{!isModelLoading && displayProvider && `${displayProvider}`}
</p>
{shouldShowResolvedModel && resolvedDisplayModelName && (
<div className="mx-2 pb-2 border-b mb-2">
<h6 className="text-xs text-text-primary">{intl.formatMessage(i18n.resolvedModel)}</h6>
<h6 className="text-xs text-text-primary">
{intl.formatMessage(i18n.resolvedModel)}
</h6>
<p className="text-xs text-text-primary truncate" title={resolvedModel ?? undefined}>
{resolvedDisplayModelName}
</p>
@ -204,7 +208,9 @@ export default function ModelsBottomBar({
<div className="bg-background-primary border border-border-primary rounded-lg shadow-lg w-[480px] max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border-subtle">
<h3 className="text-sm font-medium text-text-default">
{intl.formatMessage(i18n.localModelSettingsTitle, { modelName: getModelDisplayName(currentModel) })}
{intl.formatMessage(i18n.localModelSettingsTitle, {
modelName: getModelDisplayName(currentModel),
})}
</h3>
<button
onClick={() => setIsLocalModelSettingsOpen(false)}

View file

@ -1,10 +1,5 @@
import {
ProviderDetails,
ThinkingEffort,
getProviderModelInfo,
getProviderModels,
listLocalModels,
} from '../../../api';
import { ProviderDetails, ThinkingEffort, listLocalModels } from '../../../api';
import { acpListProviderDetails, acpListProviderModels } from '../../../acp/providers';
import { errorMessage as getErrorMessage } from '../../../utils/conversionUtils';
export default interface Model {
@ -38,11 +33,8 @@ export function createModelStruct(
};
}
export async function getProviderMetadata(
providerName: string,
getProvidersFunc: (b: boolean) => Promise<ProviderDetails[]>
) {
const providers = await getProvidersFunc(false);
export async function getProviderMetadata(providerName: string) {
const providers = await acpListProviderDetails();
const matches = providers.find((providerMatch) => providerMatch.name === providerName);
if (!matches) {
throw Error(`No match for provider: ${providerName}`);
@ -72,16 +64,13 @@ export async function fetchModelsForProviders(
return { provider: p, models: downloadedModels, error: null, warning: null };
}
const response = await getProviderModels({
path: { name: p.name },
throwOnError: true,
});
const models = (response.data || []).map(
const providerModels = await acpListProviderModels(p.name);
const models = providerModels.map(
(m) =>
({
name: m.name,
name: m.id,
provider: p.name,
context_limit: m.context_limit,
context_limit: m.contextLimit ?? undefined,
reasoning: m.reasoning ?? undefined,
}) as Model
);
@ -129,11 +118,9 @@ export async function fetchModelReasoning(
fallback?: boolean
): Promise<boolean | null> {
try {
const response = await getProviderModelInfo({
path: { name: provider },
body: { model },
});
return response.data?.reasoning ?? fallback ?? null;
const models = await acpListProviderModels(provider);
const match = models.find((m) => m.id === model);
return match?.reasoning ?? fallback ?? null;
} catch {
return fallback ?? null;
}

View file

@ -14,7 +14,11 @@ import { Button } from '../../../ui/button';
import { QUICKSTART_GUIDE_URL } from '../../providers/modal/constants';
import { Input } from '../../../ui/input';
import { Select } from '../../../ui/Select';
import { useConfig } from '../../../ConfigContext';
import {
acpListProviderDetails,
acpReadThinkingEffort,
acpSaveThinkingEffort,
} from '../../../../acp/providers';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import type { View } from '../../../../utils/navigationUtils';
import Model, {
@ -109,7 +113,8 @@ const i18n = defineMessages({
},
localModelsDescription: {
id: 'switchModelModal.localModelsDescription',
defaultMessage: 'To use local inference, you need to download a model to your computer first. Go to Settings → Models to manage local models.',
defaultMessage:
'To use local inference, you need to download a model to your computer first. Go to Settings → Models to manage local models.',
},
goToSettings: {
id: 'switchModelModal.goToSettings',
@ -263,7 +268,6 @@ export const SwitchModelModal = ({
{ value: 'max', label: intl.formatMessage(i18n.claudeEffortMax) },
];
const { getProviders, read, upsert } = useConfig();
const {
changeModel,
currentModel: configModel,
@ -327,13 +331,13 @@ export const SwitchModelModal = ({
useEffect(() => {
(async () => {
try {
const effort = (await read('GOOSE_THINKING_EFFORT', false)) as ThinkingEffort;
const effort = await acpReadThinkingEffort();
if (effort) setThinkingEffort(effort);
} catch (e) {
console.warn('Could not read GOOSE_THINKING_EFFORT, using default:', e);
console.warn('Could not read thinking effort, using default:', e);
}
})();
}, [read]);
}, []);
useEffect(() => {
if (!provider || !model) return;
@ -399,7 +403,7 @@ export const SwitchModelModal = ({
if (usePredefinedModels && selectedPredefinedModel) {
modelObj = selectedPredefinedModel;
} else {
const providerMetaData = await getProviderMetadata(provider || '', getProviders);
const providerMetaData = await getProviderMetadata(provider || '');
const providerDisplayName = providerMetaData.display_name;
modelObj = {
name: model,
@ -418,7 +422,7 @@ export const SwitchModelModal = ({
...modelObj,
request_params: { ...modelObj.request_params, thinking_effort: effort },
};
upsert('GOOSE_THINKING_EFFORT', effort, false).catch(console.warn);
acpSaveThinkingEffort(effort).catch(console.warn);
}
const success = await changeModel(sessionId, modelObj);
@ -477,10 +481,7 @@ export const SwitchModelModal = ({
(async () => {
try {
// Force a refresh so the list reflects providers (un)configured since the
// cache was populated; otherwise a provider deleted in Settings still
// shows up here (#9364).
const providersResponse = await getProviders(true);
const providersResponse = await acpListProviderDetails();
const activeProviders = providersResponse.filter((provider) => provider.is_configured);
setActiveProvidersList(activeProviders);
setProviderOptions([
@ -497,7 +498,7 @@ export const SwitchModelModal = ({
console.error('Failed to query providers:', error);
}
})();
}, [getProviders, usePredefinedModels, read, intl]);
}, [usePredefinedModels, intl]);
useEffect(() => {
if (!provider || usePredefinedModels) return;
@ -617,7 +618,15 @@ export const SwitchModelModal = ({
setModel(preferredModel);
}
}
}, [provider, modelOptions, loadingModels, model, isCustomModel, userClearedModel, activeProvidersList]);
}, [
provider,
modelOptions,
loadingModels,
model,
isCustomModel,
userClearedModel,
activeProvidersList,
]);
const handlePredefinedModelChange = (model: Model) => {
setSelectedPredefinedModel(model);
@ -732,16 +741,16 @@ export const SwitchModelModal = ({
<Bot size={24} className="text-text-primary" />
{titleOverride || intl.formatMessage(i18n.title)}
</DialogTitle>
<DialogDescription>
{intl.formatMessage(i18n.description)}
</DialogDescription>
<DialogDescription>{intl.formatMessage(i18n.description)}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
{usePredefinedModels ? (
<div className="w-full flex flex-col gap-4">
<div className="flex justify-between items-center">
<label className="text-sm font-medium text-text-primary">{intl.formatMessage(i18n.chooseModel)}</label>
<label className="text-sm font-medium text-text-primary">
{intl.formatMessage(i18n.chooseModel)}
</label>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
@ -876,7 +885,9 @@ export const SwitchModelModal = ({
</div>
</div>
</div>
<label className="text-sm text-text-secondary">{intl.formatMessage(i18n.customModelName)}</label>
<label className="text-sm text-text-secondary">
{intl.formatMessage(i18n.customModelName)}
</label>
<Input
className="border-2 px-4 py-5"
placeholder={intl.formatMessage(i18n.typeModelName)}
@ -901,7 +912,11 @@ export const SwitchModelModal = ({
onInputChange={handleInputChange}
value={
loadingModels
? { value: '', label: intl.formatMessage(i18n.loadingModels), isDisabled: true }
? {
value: '',
label: intl.formatMessage(i18n.loadingModels),
isDisabled: true,
}
: model
? { value: model, label: model }
: null
@ -925,7 +940,9 @@ export const SwitchModelModal = ({
) : (
<div className="flex flex-col gap-2">
<div className="flex justify-between">
<label className="text-sm text-text-secondary">{intl.formatMessage(i18n.customModelName)}</label>
<label className="text-sm text-text-secondary">
{intl.formatMessage(i18n.customModelName)}
</label>
<button
onClick={() => setIsCustomModel(false)}
className="text-sm text-text-secondary"

View file

@ -2,11 +2,14 @@ import React, { memo, useMemo, useCallback, useState } from 'react';
import { ProviderCard } from './subcomponents/ProviderCard';
import CardContainer from './subcomponents/CardContainer';
import ProviderConfigurationModal from './modal/ProviderConfigurationModal';
import { ProviderDetails, UpdateCustomProviderRequest } from '../../../api';
import type { CustomProviderConfigDto } from '@aaif/goose-sdk';
import {
DeclarativeProviderConfig,
ProviderDetails,
UpdateCustomProviderRequest,
} from '../../../api';
acpCreateCustomProviderFromRequest,
acpGetCustomProvider,
acpDeleteCustomProvider,
acpUpdateCustomProviderFromRequest,
} from '../../../acp/providers';
import { Plus } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
import CustomProviderForm from './modal/subcomponents/forms/CustomProviderForm';
@ -102,7 +105,7 @@ function ProviderCards({
const { getCurrentModelAndProvider } = useModelAndProvider();
const [editingProvider, setEditingProvider] = useState<{
id: string;
config: DeclarativeProviderConfig;
config: CustomProviderConfigDto;
isEditable: boolean;
providerType: string;
} | null>(null);
@ -120,14 +123,13 @@ function ProviderCards({
const configureProviderViaModal = useCallback(
async (provider: ProviderDetails) => {
if (provider.provider_type === 'Custom') {
const { getCustomProvider } = await import('../../../api');
const result = await getCustomProvider({ path: { id: provider.name }, throwOnError: true });
const result = await acpGetCustomProvider(provider.name);
if (result.data) {
if (result) {
setEditingProvider({
id: provider.name,
config: result.data.config,
isEditable: result.data.is_editable,
config: result.provider,
isEditable: result.editable,
providerType: provider.provider_type,
});
@ -152,12 +154,7 @@ function ProviderCards({
async (data: UpdateCustomProviderRequest) => {
if (!editingProvider) return;
const { updateCustomProvider } = await import('../../../api');
await updateCustomProvider({
path: { id: editingProvider.id },
body: data,
throwOnError: true,
});
await acpUpdateCustomProviderFromRequest(editingProvider.id, data);
const providerId = editingProvider.id;
setShowCustomProviderModal(false);
setEditingProvider(null);
@ -173,11 +170,7 @@ function ProviderCards({
const handleDeleteCustomProvider = useCallback(async () => {
if (!editingProvider) return;
const { removeCustomProvider } = await import('../../../api');
await removeCustomProvider({
path: { id: editingProvider.id },
throwOnError: true,
});
await acpDeleteCustomProvider(editingProvider.id);
setShowCustomProviderModal(false);
setEditingProvider(null);
setIsActiveProvider(false);
@ -227,9 +220,8 @@ function ProviderCards({
const handleCreateCustomProvider = useCallback(
async (data: UpdateCustomProviderRequest) => {
const { createCustomProvider } = await import('../../../api');
const result = await createCustomProvider({ body: data, throwOnError: true });
const providerId = result.data?.provider_name;
const result = await acpCreateCustomProviderFromRequest(data);
const providerId = result.provider_name;
setShowCustomProviderModal(false);
if (refreshProviders) {
await refreshProviders();
@ -266,15 +258,15 @@ function ProviderCards({
const initialData = editingProvider && {
engine: editingProvider.config.engine,
display_name: editingProvider.config.display_name,
api_url: editingProvider.config.base_url,
base_path: editingProvider.config.base_path ?? undefined,
display_name: editingProvider.config.displayName,
api_url: editingProvider.config.apiUrl,
base_path: editingProvider.config.basePath ?? undefined,
api_key: '',
models: editingProvider.config.models.map((m) => m.name),
supports_streaming: editingProvider.config.supports_streaming ?? true,
requires_auth: editingProvider.config.requires_auth ?? true,
models: editingProvider.config.models ?? [],
supports_streaming: editingProvider.config.supportsStreaming ?? true,
requires_auth: editingProvider.config.requiresAuth ?? true,
headers: editingProvider.config.headers ?? undefined,
catalog_provider_id: editingProvider.config.catalog_provider_id ?? undefined,
catalog_provider_id: editingProvider.config.catalogProviderId ?? undefined,
};
const editable = editingProvider ? editingProvider.isEditable : true;

View file

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { ScrollArea } from '../../ui/scroll-area';
import BackButton from '../../ui/BackButton';
import ProviderGrid from './ProviderGrid';
import { useConfig } from '../../ConfigContext';
import { acpListProviderDetails } from '../../../acp/providers';
import { ProviderDetails } from '../../../api';
import { createNavigationHandler } from '../../../utils/navigationUtils';
import { defineMessages, useIntl } from '../../../i18n';
@ -40,7 +40,6 @@ export default function ProviderSettings({
onProviderLaunched,
}: ProviderSettingsProps) {
const intl = useIntl();
const { getProviders } = useConfig();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [providers, setProviders] = useState<ProviderDetails[]>([]);
@ -52,8 +51,7 @@ export default function ProviderSettings({
const loadProviders = useCallback(async () => {
setLoading(true);
try {
// Only force refresh when explicitly requested, not on initial load
const result = await getProviders(!initialLoadDone.current);
const result = await acpListProviderDetails();
if (result) {
setProviders(result);
initialLoadDone.current = true;
@ -63,7 +61,7 @@ export default function ProviderSettings({
} finally {
setLoading(false);
}
}, [getProviders]);
}, []);
// Load providers only once when component mounts
useEffect(() => {
@ -74,10 +72,10 @@ export default function ProviderSettings({
// This function will be passed to ProviderGrid for manual refreshes after config changes
const refreshProviders = useCallback(async () => {
if (initialLoadDone.current) {
const result = await getProviders(true);
const result = await acpListProviderDetails();
if (result) setProviders(result);
}
}, [getProviders]);
}, []);
return (
<div className="h-screen w-full flex flex-col bg-background-primary text-text-primary">

View file

@ -14,15 +14,15 @@ import ProviderSetupActions from './subcomponents/ProviderSetupActions';
import ProviderLogo from './subcomponents/ProviderLogo';
import { SecureStorageNotice } from './subcomponents/SecureStorageNotice';
import { providerConfigSubmitHandler } from './subcomponents/handlers/DefaultSubmitHandler';
import { useConfig } from '../../../ConfigContext';
import {
acpAuthenticateProvider,
acpDeleteCustomProvider,
acpDeleteProviderConfig,
acpSaveProviderConfig,
} from '../../../../acp/providers';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { AlertTriangle, LogIn } from 'lucide-react';
import {
ProviderDetails,
removeCustomProvider,
configureProviderOauth,
cleanupProviderCache,
} from '../../../../api';
import { ProviderDetails } from '../../../../api';
import { Button } from '../../../../components/ui/button';
import { errorMessage } from '../../../../utils/conversionUtils';
import { defineMessages, useIntl } from '../../../../i18n';
@ -165,7 +165,6 @@ export default function ProviderConfigurationModal({
}: ProviderConfigurationModalProps) {
const intl = useIntl();
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const { upsert, remove } = useConfig();
const { getCurrentModelAndProvider } = useModelAndProvider();
const [configValues, setConfigValues] = useState<Record<string, ConfigInput>>({});
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
@ -211,25 +210,20 @@ export default function ProviderConfigurationModal({
setError(null);
try {
if (hasConfig) {
const fields: { key: string; value: string }[] = [];
for (const key of configKeys) {
const entry = configValues[key.name];
const value =
entry?.value ?? (typeof entry?.serverValue === 'string' ? entry.serverValue : null);
if (value) {
await upsert(key.name, value, key.secret);
fields.push({ key: key.name, value });
}
}
if (fields.length > 0) {
await acpSaveProviderConfig(provider.name, fields);
}
}
const oauthResult = await configureProviderOauth({
path: { name: provider.name },
});
if (oauthResult.error) {
const err = oauthResult.error as Record<string, unknown>;
const errDetail = typeof oauthResult.error === 'string'
? oauthResult.error
: (err?.message as string) ?? (err?.detail as string) ?? JSON.stringify(oauthResult.error);
throw new Error(errDetail);
}
await acpAuthenticateProvider(provider.name);
if (onConfigured) {
onConfigured(provider);
} else {
@ -280,7 +274,7 @@ export default function ProviderConfigurationModal({
);
try {
await providerConfigSubmitHandler(upsert, provider, toSubmit);
await providerConfigSubmitHandler(provider, toSubmit);
if (onConfigured) {
onConfigured(provider);
} else {
@ -316,30 +310,14 @@ export default function ProviderConfigurationModal({
return;
}
// Clean up provider-specific cache files (e.g., OAuth tokens) before removing config
try {
await cleanupProviderCache({ path: { name: provider.name } });
} catch {
// Cleanup is best-effort — proceed with deletion even if it fails
}
const isCustomProvider = provider.provider_type === 'Custom';
if (isCustomProvider) {
await removeCustomProvider({
path: { id: provider.name },
});
await acpDeleteCustomProvider(provider.name);
} else {
const params = provider.metadata.config_keys;
for (const param of params) {
await remove(param.name, param.secret);
}
const hasOAuthKey = params.some((key) => key.oauth_flow);
if (hasOAuthKey) {
const configuredMarker = `${provider.name}_configured`;
await remove(configuredMarker, false);
}
// Deletes all config/secret fields and cleans up provider-specific cache
// (e.g. OAuth tokens) server-side.
await acpDeleteProviderConfig(provider.name);
}
onClose();

View file

@ -3,12 +3,11 @@ import { Button } from '../../../../ui/button';
import { Search, ExternalLink, Check } from 'lucide-react';
import { Input } from '../../../../ui/input';
import { Select } from '../../../../ui/Select';
import type { ProviderTemplateCatalogEntryDto, ProviderTemplateDto } from '@aaif/goose-sdk';
import {
getProviderCatalog,
getProviderCatalogTemplate,
type ProviderCatalogEntry,
type ProviderTemplate,
} from '../../../../../api';
acpGetProviderTemplate,
acpListProviderCatalogEntries,
} from '../../../../../acp/providers';
import { defineMessages, useIntl } from '../../../../../i18n';
const i18n = defineMessages({
@ -67,7 +66,7 @@ const i18n = defineMessages({
});
interface ProviderCatalogPickerProps {
onSelect: (template: ProviderTemplate) => void;
onSelect: (template: ProviderTemplateDto) => void;
onCancel: () => void;
embedded?: boolean;
}
@ -79,8 +78,8 @@ export default function ProviderCatalogPicker({
}: ProviderCatalogPickerProps) {
const intl = useIntl();
const [selectedFormat, setSelectedFormat] = useState<string>('openai');
const [providers, setProviders] = useState<ProviderCatalogEntry[]>([]);
const [filteredProviders, setFilteredProviders] = useState<ProviderCatalogEntry[]>([]);
const [providers, setProviders] = useState<ProviderTemplateCatalogEntryDto[]>([]);
const [filteredProviders, setFilteredProviders] = useState<ProviderTemplateCatalogEntryDto[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -103,7 +102,7 @@ export default function ProviderCatalogPicker({
const query = searchQuery.toLowerCase();
setFilteredProviders(
providers.filter(
(p) => p.name.toLowerCase().includes(query) || p.id.toLowerCase().includes(query)
(p) => p.name.toLowerCase().includes(query) || p.providerId.toLowerCase().includes(query)
)
);
}
@ -113,10 +112,7 @@ export default function ProviderCatalogPicker({
setLoading(true);
setError(null);
try {
const { data } = await getProviderCatalog({
query: { format },
throwOnError: true,
});
const data = await acpListProviderCatalogEntries(format);
setProviders(data || []);
setFilteredProviders(data || []);
} catch (err) {
@ -130,10 +126,7 @@ export default function ProviderCatalogPicker({
setLoading(true);
setError(null);
try {
const { data: template } = await getProviderCatalogTemplate({
path: { id: providerId },
throwOnError: true,
});
const template = await acpGetProviderTemplate(providerId);
if (template) {
onSelect(template);
}
@ -148,7 +141,9 @@ export default function ProviderCatalogPicker({
<div className="space-y-4">
{/* Header */}
<div>
<h3 className="text-lg font-semibold text-textStandard mb-2">{intl.formatMessage(i18n.chooseProvider)}</h3>
<h3 className="text-lg font-semibold text-textStandard mb-2">
{intl.formatMessage(i18n.chooseProvider)}
</h3>
<p className="text-sm text-textSubtle">
{intl.formatMessage(i18n.selectFormatDescription)}
</p>
@ -156,7 +151,9 @@ export default function ProviderCatalogPicker({
{/* Format Selection */}
<div>
<label className="text-sm font-medium text-textStandard mb-2 block">{intl.formatMessage(i18n.apiFormat)}</label>
<label className="text-sm font-medium text-textStandard mb-2 block">
{intl.formatMessage(i18n.apiFormat)}
</label>
<Select
options={formatOptions}
value={formatOptions.find((opt) => opt.value === selectedFormat)}
@ -183,8 +180,16 @@ export default function ProviderCatalogPicker({
</div>
{/* Loading/Error */}
{loading && <div className="text-center py-8 text-textSubtle">{intl.formatMessage(i18n.loadingProviders)}</div>}
{error && <div className="text-center py-8 text-red-500">{intl.formatMessage(i18n.errorPrefix, { error })}</div>}
{loading && (
<div className="text-center py-8 text-textSubtle">
{intl.formatMessage(i18n.loadingProviders)}
</div>
)}
{error && (
<div className="text-center py-8 text-red-500">
{intl.formatMessage(i18n.errorPrefix, { error })}
</div>
)}
{/* Provider List */}
{!loading && !error && (
@ -198,17 +203,17 @@ export default function ProviderCatalogPicker({
) : (
filteredProviders.map((provider) => (
<button
key={provider.id}
onClick={() => handleProviderSelect(provider.id)}
key={provider.providerId}
onClick={() => handleProviderSelect(provider.providerId)}
className="w-full p-4 text-left border border-border rounded-lg hover:bg-surfaceHover hover:border-primary transition-colors group"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium text-textStandard">{provider.name}</div>
{provider.doc_url && (
{provider.docUrl && (
<a
href={provider.doc_url}
href={provider.docUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
@ -218,10 +223,11 @@ export default function ProviderCatalogPicker({
</a>
)}
</div>
<div className="text-sm text-textSubtle mt-1 break-all">{provider.api_url}</div>
<div className="text-sm text-textSubtle mt-1 break-all">{provider.apiUrl}</div>
<div className="text-xs text-textSubtle mt-2">
{intl.formatMessage(i18n.modelsAvailable, { count: provider.model_count })}
{provider.env_var && intl.formatMessage(i18n.requiresEnvVar, { envVar: provider.env_var })}
{intl.formatMessage(i18n.modelsAvailable, { count: provider.modelCount })}
{provider.envVar &&
intl.formatMessage(i18n.requiresEnvVar, { envVar: provider.envVar })}
</div>
</div>
<Check className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />

View file

@ -3,7 +3,8 @@ import { Input } from '../../../../../ui/input';
import { Select } from '../../../../../ui/Select';
import { Button } from '../../../../../ui/button';
import { SecureStorageNotice } from '../SecureStorageNotice';
import { UpdateCustomProviderRequest, type ProviderTemplate } from '../../../../../../api';
import { UpdateCustomProviderRequest } from '../../../../../../api';
import type { ProviderTemplateDto } from '@aaif/goose-sdk';
import { Plus, X, Trash2, AlertTriangle, ExternalLink, Search, Settings } from 'lucide-react';
import { cn } from '../../../../../../utils';
import ProviderCatalogPicker from '../ProviderCatalogPicker';
@ -92,7 +93,8 @@ const i18n = defineMessages({
},
apiBasePathHint: {
id: 'customProviderForm.apiBasePathHint',
defaultMessage: "Override the default API path. Leave blank to use the provider's default path.",
defaultMessage:
"Override the default API path. Leave blank to use the provider's default path.",
},
authentication: {
id: 'customProviderForm.authentication',
@ -264,7 +266,7 @@ export default function CustomProviderForm({
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
// Template + step state
const [selectedTemplate, setSelectedTemplate] = useState<ProviderTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<ProviderTemplateDto | null>(null);
const [step, setStep] = useState<Step>(initialData ? 'form' : 'choice');
useEffect(() => {
@ -294,14 +296,14 @@ export default function CustomProviderForm({
}
}, [initialData]);
const handleTemplateSelect = (template: ProviderTemplate) => {
const handleTemplateSelect = (template: ProviderTemplateDto) => {
setSelectedTemplate(template);
// Prefill fields from template
setDisplayName(template.name);
setApiUrl(template.api_url);
setApiUrl(template.apiUrl);
setBasePath('');
setSupportsStreaming(template.supports_streaming);
setSupportsStreaming(template.supportsStreaming);
setRequiresAuth(true);
const formatToEngine: Record<string, string> = {
@ -411,7 +413,8 @@ export default function CustomProviderForm({
if (!displayName) errors.displayName = intl.formatMessage(i18n.displayNameRequired);
if (!apiUrl) errors.apiUrl = intl.formatMessage(i18n.apiUrlRequired);
const existingHadAuth = initialData && (initialData.requires_auth ?? true);
if (requiresAuth && !apiKey && !existingHadAuth) errors.apiKey = intl.formatMessage(i18n.apiKeyRequired);
if (requiresAuth && !apiKey && !existingHadAuth)
errors.apiKey = intl.formatMessage(i18n.apiKeyRequired);
if (!models) errors.models = intl.formatMessage(i18n.modelsRequired);
if (Object.keys(errors).length > 0) {
@ -456,7 +459,8 @@ export default function CustomProviderForm({
supports_streaming: supportsStreaming,
requires_auth: requiresAuth,
headers: headersObject,
catalog_provider_id: selectedTemplate?.id ?? initialData?.catalog_provider_id ?? undefined,
catalog_provider_id:
selectedTemplate?.providerId ?? initialData?.catalog_provider_id ?? undefined,
base_path: basePath || undefined,
});
} catch (error) {
@ -470,7 +474,7 @@ export default function CustomProviderForm({
.filter((m) => !m.deprecated)
.reduce(
(acc, m) => {
if (m.capabilities.tool_call) acc.tool_call = true;
if (m.capabilities.toolCall) acc.tool_call = true;
if (m.capabilities.reasoning) acc.reasoning = true;
if (m.capabilities.attachment) acc.attachment = true;
return acc;
@ -491,7 +495,9 @@ export default function CustomProviderForm({
<div className="flex items-center gap-3">
<Search className="w-5 h-5 text-primary flex-shrink-0" />
<div>
<div className="font-medium text-textStandard">{intl.formatMessage(i18n.startFromTemplate)}</div>
<div className="font-medium text-textStandard">
{intl.formatMessage(i18n.startFromTemplate)}
</div>
<div className="text-sm text-textSubtle mt-0.5">
{intl.formatMessage(i18n.startFromTemplateDesc)}
</div>
@ -506,7 +512,9 @@ export default function CustomProviderForm({
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 text-textSubtle flex-shrink-0" />
<div>
<div className="font-medium text-textStandard">{intl.formatMessage(i18n.configureManually)}</div>
<div className="font-medium text-textStandard">
{intl.formatMessage(i18n.configureManually)}
</div>
<div className="text-sm text-textSubtle mt-0.5">
{intl.formatMessage(i18n.configureManuallyDesc)}
</div>
@ -550,12 +558,12 @@ export default function CustomProviderForm({
<div className="font-medium text-textStandard">
{intl.formatMessage(i18n.usingTemplate, { name: selectedTemplate.name })}
</div>
<div className="text-textSubtle mt-1">{selectedTemplate.api_url}</div>
<div className="text-textSubtle mt-1">{selectedTemplate.apiUrl}</div>
</div>
<div className="flex items-center gap-2">
{selectedTemplate.doc_url && (
{selectedTemplate.docUrl && (
<a
href={selectedTemplate.doc_url}
href={selectedTemplate.docUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm flex items-center gap-1"
@ -600,7 +608,10 @@ export default function CustomProviderForm({
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
options={[
{ value: 'openai_compatible', label: intl.formatMessage(i18n.openaiCompatible) },
{ value: 'anthropic_compatible', label: intl.formatMessage(i18n.anthropicCompatible) },
{
value: 'anthropic_compatible',
label: intl.formatMessage(i18n.anthropicCompatible),
},
{ value: 'ollama_compatible', label: intl.formatMessage(i18n.ollamaCompatible) },
]}
value={{
@ -695,18 +706,16 @@ export default function CustomProviderForm({
onChange={(e) => setBasePath(e.target.value)}
placeholder={intl.formatMessage(i18n.apiBasePathPlaceholder)}
/>
<p className="text-xs text-textSubtle mt-1">
{intl.formatMessage(i18n.apiBasePathHint)}
</p>
<p className="text-xs text-textSubtle mt-1">{intl.formatMessage(i18n.apiBasePathHint)}</p>
</div>
)}
{/* Authentication */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">{intl.formatMessage(i18n.authentication)}</label>
<p className="text-sm text-text-secondary mb-3">
{intl.formatMessage(i18n.authHint)}
</p>
<label className="block text-sm font-medium text-text-primary mb-2">
{intl.formatMessage(i18n.authentication)}
</label>
<p className="text-sm text-text-secondary mb-3">{intl.formatMessage(i18n.authHint)}</p>
<div className="flex items-center space-x-2">
<input
type="checkbox"
@ -727,9 +736,9 @@ export default function CustomProviderForm({
className="flex items-center text-sm font-medium text-text-primary mb-2"
>
{intl.formatMessage(i18n.apiKey)}
{selectedTemplate?.env_var && (
{selectedTemplate?.envVar && (
<span className="text-textSubtle ml-1 font-normal">
({selectedTemplate.env_var})
({selectedTemplate.envVar})
</span>
)}
{!initialData && <span className="text-red-500 ml-1">*</span>}
@ -739,7 +748,11 @@ export default function CustomProviderForm({
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={initialData ? intl.formatMessage(i18n.apiKeyPlaceholderExisting) : intl.formatMessage(i18n.apiKeyPlaceholderNew)}
placeholder={
initialData
? intl.formatMessage(i18n.apiKeyPlaceholderExisting)
: intl.formatMessage(i18n.apiKeyPlaceholderNew)
}
aria-invalid={!!validationErrors.apiKey}
aria-describedby={validationErrors.apiKey ? 'api-key-error' : undefined}
className={validationErrors.apiKey ? 'border-red-500' : ''}
@ -819,7 +832,9 @@ export default function CustomProviderForm({
{/* Custom headers */}
{isEditable && (
<div>
<label className="text-sm font-medium text-textStandard mb-2 block">{intl.formatMessage(i18n.customHeaders)}</label>
<label className="text-sm font-medium text-textStandard mb-2 block">
{intl.formatMessage(i18n.customHeaders)}
</label>
<p className="text-xs text-textSubtle mb-4">
{intl.formatMessage(i18n.customHeadersHint)}
</p>
@ -900,16 +915,12 @@ export default function CustomProviderForm({
<div className="px-4 py-3 bg-yellow-600/20 border border-yellow-500/30 rounded">
<p className="text-yellow-500 text-sm flex items-start">
<AlertTriangle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
<span>
{intl.formatMessage(i18n.cannotDeleteActive)}
</span>
<span>{intl.formatMessage(i18n.cannotDeleteActive)}</span>
</p>
</div>
) : (
<div className="px-4 py-3 bg-red-900/20 border border-red-500/30 rounded">
<p className="text-red-400 text-sm">
{intl.formatMessage(i18n.deleteConfirmation)}
</p>
<p className="text-red-400 text-sm">{intl.formatMessage(i18n.deleteConfirmation)}</p>
</div>
)}
<div className="flex justify-end space-x-2">
@ -944,7 +955,11 @@ export default function CustomProviderForm({
<Button type="button" variant="outline" onClick={onCancel}>
{intl.formatMessage(i18n.cancel)}
</Button>
<Button type="submit">{initialData ? intl.formatMessage(i18n.updateProvider) : intl.formatMessage(i18n.createProvider)}</Button>
<Button type="submit">
{initialData
? intl.formatMessage(i18n.updateProvider)
: intl.formatMessage(i18n.createProvider)}
</Button>
</div>
)}
</form>

View file

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { Input } from '../../../../../ui/input';
import { useConfig } from '../../../../../ConfigContext';
import { acpReadProviderConfig } from '../../../../../../acp/providers';
import { ProviderDetails, ConfigKey } from '../../../../../../api';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../../../../ui/collapsible';
import { configLabels, configPlaceholders } from '../../../../../../utils/configUtils';
@ -95,19 +95,30 @@ export default function DefaultProviderSetupForm({
const intl = useIntl();
const [isLoading, setIsLoading] = useState(true);
const [optionalExpanded, setOptionalExpanded] = useState(false);
const { read } = useConfig();
const loadConfigValues = useCallback(async () => {
setIsLoading(true);
try {
const values: { [k: string]: ConfigInput } = {};
for (const parameter of parameters) {
const configKey = `${parameter.name}`;
const configValue = (await read(configKey, parameter.secret || false)) as ConfigValue;
let fields: Awaited<ReturnType<typeof acpReadProviderConfig>> = [];
try {
fields = await acpReadProviderConfig(provider.name);
} catch {
// Provider may not be in the registry yet; fall back to defaults below.
}
const fieldByKey = new Map(fields.map((field) => [field.key, field]));
if (configValue !== undefined && configValue !== null) {
values[parameter.name] = { serverValue: configValue };
for (const parameter of parameters) {
const field = fieldByKey.get(parameter.name);
if (field?.isSet && field.value != null) {
// Secrets come back masked from the server; preserve the masked shape
// so the form renders a placeholder rather than the raw value.
const serverValue: ConfigValue = parameter.secret
? { maskedValue: field.value }
: field.value;
values[parameter.name] = { serverValue };
} else if (parameter.default !== undefined && parameter.default !== null) {
values[parameter.name] = { value: parameter.default };
}
@ -120,7 +131,7 @@ export default function DefaultProviderSetupForm({
} finally {
setIsLoading(false);
}
}, [parameters, read, setConfigValues]);
}, [parameters, provider.name, setConfigValues]);
useEffect(() => {
loadConfigValues();

View file

@ -1,112 +1,29 @@
import { getProviderModels, readConfig } from '../../../../../../api';
import { acpSaveProviderConfig } from '../../../../../../acp/providers';
/**
* Standalone function to submit provider configuration
* Useful for components that don't want to use the hook
* Submit provider configuration through ACP.
*
* The ACP server validates the supplied fields, persists config/secret values,
* and triggers an inventory refresh in a single call, so no client-side
* rollback is required.
*/
export const providerConfigSubmitHandler = async (
upsertFn: (key: string, value: unknown, isSecret: boolean) => Promise<void>,
provider: {
name: string;
metadata: {
config_keys?: Array<{
name: string;
required?: boolean;
default?: unknown;
secret?: boolean;
}>;
config_keys?: Array<{ name: string; default?: unknown }>;
};
},
configValues: Record<string, string>
) => {
const parameters = provider.metadata.config_keys || [];
// Save current NON-SECRET config values for rollback on failure
// We skip secrets because readConfig returns masked values for secrets,
// and upserting those masked values would corrupt the actual secret
const previousConfigValues: Record<string, { value: unknown; isSecret: boolean }> = {};
const nonSecretParams = parameters.filter((param) => !param.secret);
await Promise.all(
nonSecretParams.map(async (param) => {
try {
const currentValue = await readConfig({
body: { key: param.name, is_secret: false },
});
if (currentValue.data) {
previousConfigValues[param.name] = {
value: currentValue.data,
isSecret: false,
};
}
} catch {
// No previous value exists, that's fine
}
})
);
const requiredParams = parameters.filter((param) => param.required);
if (requiredParams.length === 0 && parameters.length > 0) {
const allOptionalWithDefaults = parameters.every(
(param) => !param.required && param.default !== undefined
);
if (allOptionalWithDefaults) {
const promises: Promise<void>[] = [];
for (const param of parameters) {
if (param.default !== undefined) {
const value =
configValues[param.name] !== undefined ? configValues[param.name] : param.default;
promises.push(upsertFn(param.name, value, param.secret === true));
}
}
await Promise.all(promises);
return;
const fields: { key: string; value: string }[] = [];
for (const { name, default: defaultValue } of provider.metadata.config_keys ?? []) {
const value = configValues[name] ?? defaultValue;
if (value === undefined || value === null || value === '') {
continue;
}
fields.push({ key: name, value: String(value) });
}
const upsertPromises = parameters.map(
async (parameter: {
name: string;
required?: boolean;
default?: unknown;
secret?: boolean;
}) => {
if (!configValues[parameter.name] && !parameter.required) {
return;
}
const value =
configValues[parameter.name] !== undefined
? configValues[parameter.name]
: parameter.default;
if (value === undefined || value === null) {
return;
}
const configKey = `${parameter.name}`;
const isSecret = parameter.secret === true;
await upsertFn(configKey, value, isSecret);
}
);
await Promise.all(upsertPromises);
try {
await getProviderModels({
path: { name: provider.name },
throwOnError: true,
});
} catch (error) {
const rollbackPromises: Promise<void>[] = [];
for (const [key, { value, isSecret }] of Object.entries(previousConfigValues)) {
rollbackPromises.push(upsertFn(key, value, isSecret));
}
await Promise.all(rollbackPromises);
throw error;
}
await acpSaveProviderConfig(provider.name, fields);
};

View file

@ -1,6 +1,6 @@
import { Button } from '../../ui/button';
import { RefreshCw } from 'lucide-react';
import { useConfig } from '../../ConfigContext';
import { acpClearDefaults } from '../../../acp/providers';
import { View, ViewOptions } from '../../../utils/navigationUtils';
import { defineMessages, useIntl } from '../../../i18n';
@ -21,12 +21,10 @@ interface ResetProviderSectionProps {
export default function ResetProviderSection(_props: ResetProviderSectionProps) {
const intl = useIntl();
const { remove } = useConfig();
const handleResetProvider = async () => {
try {
await remove('GOOSE_PROVIDER', false);
await remove('GOOSE_MODEL', false);
await acpClearDefaults();
window.location.reload();
} catch (error) {

View file

@ -2,7 +2,9 @@
* Utilities for fetching canonical model information from the backend
*/
import { getCanonicalModelInfo, type ModelInfoData } from '../api';
import { acpGetCanonicalModelInfo, type CanonicalModelInfoDto } from '../acp/providers';
export type CanonicalModelInfo = CanonicalModelInfoDto;
/**
* Fetch canonical model info (pricing + context limits) for a specific provider/model
@ -10,14 +12,9 @@ import { getCanonicalModelInfo, type ModelInfoData } from '../api';
export async function fetchCanonicalModelInfo(
provider: string,
model: string
): Promise<ModelInfoData | null> {
): Promise<CanonicalModelInfoDto | null> {
try {
const response = await getCanonicalModelInfo({
body: { provider, model },
throwOnError: true,
});
return response.data.model_info ?? null;
return await acpGetCanonicalModelInfo(provider, model);
} catch {
return null;
}

View file

@ -18,6 +18,8 @@ import type {
AppsListRequest_unstable,
AppsListResponse_unstable,
ArchiveSessionRequest_unstable,
CanonicalModelInfoRequest_unstable,
CanonicalModelInfoResponse_unstable,
CreateScheduleRequest_unstable,
CreateScheduleResponse_unstable,
CreateSourceRequest_unstable,
@ -32,6 +34,7 @@ import type {
CustomProviderUpdateResponse_unstable,
DecodeRecipeRequest_unstable,
DecodeRecipeResponse_unstable,
DefaultsClearRequest_unstable,
DefaultsReadRequest_unstable,
DefaultsReadResponse_unstable,
DefaultsSaveRequest_unstable,
@ -124,6 +127,9 @@ import type {
ProviderConfigSaveRequest_unstable,
ProviderConfigStatusRequest_unstable,
ProviderConfigStatusResponse_unstable,
ProviderSecretDeleteRequest_unstable,
ProviderSecretsListRequest_unstable,
ProviderSecretsListResponse_unstable,
ProviderSetupCatalogListRequest_unstable,
ProviderSetupCatalogListResponse_unstable,
ProviderSupportedModelsListRequest_unstable,
@ -171,6 +177,7 @@ import {
zAppsExportResponse_unstable,
zAppsImportResponse_unstable,
zAppsListResponse_unstable,
zCanonicalModelInfoResponse_unstable,
zCreateScheduleResponse_unstable,
zCreateSourceResponse_unstable,
zCustomProviderCreateResponse_unstable,
@ -217,6 +224,7 @@ import {
zProviderConfigChangeResponse_unstable,
zProviderConfigReadResponse_unstable,
zProviderConfigStatusResponse_unstable,
zProviderSecretsListResponse_unstable,
zProviderSetupCatalogListResponse_unstable,
zProviderSupportedModelsListResponse_unstable,
zReadResourceResponse_unstable,
@ -654,6 +662,39 @@ export class GooseExtClient {
) as ProviderConfigChangeResponse_unstable;
}
async providersSecretsList_unstable(
params: ProviderSecretsListRequest_unstable,
): Promise<ProviderSecretsListResponse_unstable> {
const raw = await this.conn.extMethod(
"_goose/unstable/providers/secrets/list",
params,
);
return zProviderSecretsListResponse_unstable.parse(
raw,
) as ProviderSecretsListResponse_unstable;
}
async providersSecretsDelete_unstable(
params: ProviderSecretDeleteRequest_unstable,
): Promise<void> {
await this.conn.extMethod(
"_goose/unstable/providers/secrets/delete",
params,
);
}
async providersCanonicalModelInfo_unstable(
params: CanonicalModelInfoRequest_unstable,
): Promise<CanonicalModelInfoResponse_unstable> {
const raw = await this.conn.extMethod(
"_goose/unstable/providers/canonical-model-info",
params,
);
return zCanonicalModelInfoResponse_unstable.parse(
raw,
) as CanonicalModelInfoResponse_unstable;
}
async preferencesRead_unstable(
params: PreferencesReadRequest_unstable,
): Promise<PreferencesReadResponse_unstable> {
@ -702,6 +743,18 @@ export class GooseExtClient {
) as DefaultsReadResponse_unstable;
}
async defaultsClear_unstable(
params: DefaultsClearRequest_unstable,
): Promise<DefaultsReadResponse_unstable> {
const raw = await this.conn.extMethod(
"_goose/unstable/defaults/clear",
params,
);
return zDefaultsReadResponse_unstable.parse(
raw,
) as DefaultsReadResponse_unstable;
}
async onboardingImportScan_unstable(
params: OnboardingImportScanRequest_unstable,
): Promise<OnboardingImportScanResponse_unstable> {

File diff suppressed because one or more lines are too long

View file

@ -1124,6 +1124,68 @@ export type ProviderConfigAuthenticateRequest_unstable = {
providerId: string;
};
/**
* List provider credentials stored locally by Goose.
*/
export type ProviderSecretsListRequest_unstable = {
[key: string]: unknown;
};
export type ProviderSecretsListResponse_unstable = {
secrets: Array<ProviderSecretDto>;
};
export type ProviderSecretDto = {
id: string;
provider: string;
providerDisplayName: string;
name: string;
storage: ProviderSecretStorageDto;
expiresAt?: string | null;
status: ProviderSecretStatusDto;
configured: boolean;
hasSecret: boolean;
canDelete: boolean;
canConfigure: boolean;
configureProvider?: string | null;
};
export type ProviderSecretStorageDto = 'secret_store' | 'provider_cache';
export type ProviderSecretStatusDto = 'valid' | 'expired' | 'unknown';
/**
* Delete a locally stored provider credential by id.
*/
export type ProviderSecretDeleteRequest_unstable = {
id: string;
};
/**
* Look up canonical (bundled-registry) model info for a provider/model pair.
*/
export type CanonicalModelInfoRequest_unstable = {
provider: string;
model: string;
};
export type CanonicalModelInfoResponse_unstable = {
modelInfo?: CanonicalModelInfoDto | null;
};
export type CanonicalModelInfoDto = {
provider: string;
model: string;
contextLimit: number;
maxOutputTokens?: number | null;
reasoning: boolean;
inputTokenCost?: number | null;
outputTokenCost?: number | null;
cacheReadTokenCost?: number | null;
cacheWriteTokenCost?: number | null;
currency: string;
};
/**
* Read allowlisted user preferences. Empty `keys` means all supported preferences.
*/
@ -1176,6 +1238,13 @@ export type DefaultsSaveRequest_unstable = {
modelId?: string | null;
};
/**
* Clear Goose default provider and model configuration.
*/
export type DefaultsClearRequest_unstable = {
[key: string]: unknown;
};
/**
* Scan for existing Goose and compatible app data that onboarding can import.
*/
@ -2126,14 +2195,14 @@ export type RecipeParamsAction = 'submit' | 'cancel';
export type ExtRequest = {
id: string;
method: string;
params?: AddSessionExtensionRequest_unstable | RemoveSessionExtensionRequest_unstable | GetToolsRequest_unstable | SetToolPermissionsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | AppsListRequest_unstable | AppsExportRequest_unstable | AppsImportRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DiagnosticsGetRequest_unstable | ListPromptsRequest_unstable | GetPromptRequest_unstable | SavePromptRequest_unstable | ResetPromptRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ShareSessionNostrRequest_unstable | EncodeRecipeRequest_unstable | DecodeRecipeRequest_unstable | ScanRecipeRequest_unstable | ListRecipesRequest_unstable | DeleteRecipeRequest_unstable | ScheduleRecipeRequest_unstable | SetRecipeSlashCommandRequest_unstable | SaveRecipeRequest_unstable | ParseRecipeRequest_unstable | RecipeToYamlRequest_unstable | ListSchedulesRequest_unstable | ListScheduleSessionsRequest_unstable | CreateScheduleRequest_unstable | DeleteScheduleRequest_unstable | PauseScheduleRequest_unstable | UnpauseScheduleRequest_unstable | UpdateScheduleRequest_unstable | RunScheduleNowRequest_unstable | KillRunningJobRequest_unstable | InspectRunningJobRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | ListAgentMentionsRequest_unstable | ListSlashCommandsRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | {
params?: AddSessionExtensionRequest_unstable | RemoveSessionExtensionRequest_unstable | GetToolsRequest_unstable | SetToolPermissionsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | AppsListRequest_unstable | AppsExportRequest_unstable | AppsImportRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DiagnosticsGetRequest_unstable | ListPromptsRequest_unstable | GetPromptRequest_unstable | SavePromptRequest_unstable | ResetPromptRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | ProviderSecretsListRequest_unstable | ProviderSecretDeleteRequest_unstable | CanonicalModelInfoRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | DefaultsClearRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ShareSessionNostrRequest_unstable | EncodeRecipeRequest_unstable | DecodeRecipeRequest_unstable | ScanRecipeRequest_unstable | ListRecipesRequest_unstable | DeleteRecipeRequest_unstable | ScheduleRecipeRequest_unstable | SetRecipeSlashCommandRequest_unstable | SaveRecipeRequest_unstable | ParseRecipeRequest_unstable | RecipeToYamlRequest_unstable | ListSchedulesRequest_unstable | ListScheduleSessionsRequest_unstable | CreateScheduleRequest_unstable | DeleteScheduleRequest_unstable | PauseScheduleRequest_unstable | UnpauseScheduleRequest_unstable | UpdateScheduleRequest_unstable | RunScheduleNowRequest_unstable | KillRunningJobRequest_unstable | InspectRunningJobRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | ListAgentMentionsRequest_unstable | ListSlashCommandsRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | {
[key: string]: unknown;
} | null;
};
export type ExtResponse = {
id: string;
result?: EmptyResponse | GetToolsResponse_unstable | SetToolPermissionsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | AppsListResponse_unstable | AppsExportResponse_unstable | AppsImportResponse_unstable | SteerSessionResponse_unstable | DiagnosticsGetResponse_unstable | ListPromptsResponse_unstable | GetPromptResponse_unstable | PromptOperationResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | ShareSessionNostrResponse_unstable | EncodeRecipeResponse_unstable | DecodeRecipeResponse_unstable | ScanRecipeResponse_unstable | ListRecipesResponse_unstable | SaveRecipeResponse_unstable | ParseRecipeResponse_unstable | RecipeToYamlResponse_unstable | ListSchedulesResponse_unstable | ListScheduleSessionsResponse_unstable | CreateScheduleResponse_unstable | UpdateScheduleResponse_unstable | RunScheduleNowResponse_unstable | KillRunningJobResponse_unstable | InspectRunningJobResponse_unstable | GetSessionInfoResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | ListAgentMentionsResponse_unstable | ListSlashCommandsResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown;
result?: EmptyResponse | GetToolsResponse_unstable | SetToolPermissionsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | AppsListResponse_unstable | AppsExportResponse_unstable | AppsImportResponse_unstable | SteerSessionResponse_unstable | DiagnosticsGetResponse_unstable | ListPromptsResponse_unstable | GetPromptResponse_unstable | PromptOperationResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | ProviderSecretsListResponse_unstable | CanonicalModelInfoResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | ShareSessionNostrResponse_unstable | EncodeRecipeResponse_unstable | DecodeRecipeResponse_unstable | ScanRecipeResponse_unstable | ListRecipesResponse_unstable | SaveRecipeResponse_unstable | ParseRecipeResponse_unstable | RecipeToYamlResponse_unstable | ListSchedulesResponse_unstable | ListScheduleSessionsResponse_unstable | CreateScheduleResponse_unstable | UpdateScheduleResponse_unstable | RunScheduleNowResponse_unstable | KillRunningJobResponse_unstable | InspectRunningJobResponse_unstable | GetSessionInfoResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | ListAgentMentionsResponse_unstable | ListSlashCommandsResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown;
} | {
error: {
code: number;

File diff suppressed because it is too large Load diff