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