goose2 distribution bundling (#8911)
This commit is contained in:
@@ -151,14 +151,10 @@ fn system_config_path() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
fn bundled_defaults_path() -> Option<PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let path = exe.parent()?.join("defaults.yaml");
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
fn additional_config_paths_from_env() -> Vec<PathBuf> {
|
||||
env::var_os("GOOSE_ADDITIONAL_CONFIG_FILES")
|
||||
.map(|value| env::split_paths(&value).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -167,9 +163,7 @@ impl Default for Config {
|
||||
let user_config_path = config_dir.join(CONFIG_YAML_NAME);
|
||||
|
||||
let mut config_paths = vec![system_config_path()];
|
||||
if let Some(defaults) = bundled_defaults_path() {
|
||||
config_paths.insert(0, defaults);
|
||||
}
|
||||
config_paths.extend(additional_config_paths_from_env());
|
||||
config_paths.push(user_config_path.clone());
|
||||
|
||||
let no_secrets_config = Self {
|
||||
|
||||
@@ -130,6 +130,7 @@ ThemeProvider manages three axes:
|
||||
- Title bar uses `titleBarStyle: "Overlay"` with `hiddenTitle: true` for a custom titlebar.
|
||||
- `tauri-plugin-window-state` persists window size and position.
|
||||
- Traffic light offset: `pl-20` (80px) to accommodate macOS window controls.
|
||||
- Distro bundle behavior, including feature flags, is documented in `distro/README.md`.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Goose2 distro bundles
|
||||
|
||||
A Goose2 distro bundle is an optional app-specific package of configuration and policy that the Tauri shell loads at startup.
|
||||
|
||||
## What a distro bundle is
|
||||
|
||||
A distro bundle lives under `ui/goose2/distro/` in development, and is bundled into the packaged app as a Tauri resource in production.
|
||||
|
||||
Current supported files:
|
||||
|
||||
- `distro.json` — distro manifest
|
||||
- `config.yaml` — optional Goose config passed to `goose serve`
|
||||
- `bin/` — optional executables or helper scripts prepended to `PATH` for `goose serve`
|
||||
|
||||
## How it is discovered
|
||||
|
||||
The Tauri app resolves the distro bundle in this order:
|
||||
|
||||
1. `GOOSE_DISTRO_DIR`, if set
|
||||
2. bundled Tauri resource dir at `resource_dir()/distro`
|
||||
|
||||
In development, `just dev` and `just dev-debug` automatically export `GOOSE_DISTRO_DIR` to `ui/goose2/distro` when that directory exists.
|
||||
|
||||
## Manifest shape
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"appVersion": "development",
|
||||
"featureToggles": {
|
||||
"costTracking": false
|
||||
},
|
||||
"providerAllowlist": "databricks"
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- `appVersion?: string`
|
||||
- optional app version tag supplied by the distro
|
||||
|
||||
- `featureToggles?: Record<string, boolean>`
|
||||
- optional UI/product flags controlled by the distro
|
||||
- currently supported:
|
||||
- `costTracking`
|
||||
- `false` hides cost UI in the token/context usage surfaces
|
||||
- omitted behaves as enabled
|
||||
|
||||
- `providerAllowlist?: string`
|
||||
- comma-separated provider ids
|
||||
- suggests which model providers to show in Settings
|
||||
- suggests which Goose model options to show in the chat model picker
|
||||
|
||||
- `extensionAllowlist?: string`
|
||||
- comma-separated extension ids
|
||||
- reserved for future UI suggestions
|
||||
|
||||
## Runtime effects
|
||||
|
||||
When a distro bundle is present, Goose2 does two kinds of things with it.
|
||||
|
||||
### Frontend behavior
|
||||
|
||||
The frontend loads `get_distro_bundle` during app startup and stores the manifest in Zustand.
|
||||
|
||||
Today it uses that manifest to:
|
||||
|
||||
- filter model providers shown in provider settings via `providerAllowlist`
|
||||
- filter Goose model options shown in the chat input model picker via `providerAllowlist`
|
||||
- hide cost UI when `featureToggles.costTracking === false`
|
||||
|
||||
These allowlists are UI suggestions only. They do not enforce backend access control and do not invalidate existing sessions or saved model choices.
|
||||
|
||||
### Backend / shell behavior
|
||||
|
||||
When the Tauri shell launches the long-lived `goose serve` process, it applies the distro bundle like this:
|
||||
|
||||
- prepends `distro/bin` to `PATH` when present
|
||||
- adds `distro/config.yaml` to `GOOSE_ADDITIONAL_CONFIG_FILES` when present
|
||||
- sets `GOOSE_DISTRO_DIR` to the resolved distro root
|
||||
|
||||
This is shell-level behavior, so it is implemented as Tauri-side setup rather than an ACP method.
|
||||
|
||||
## Development notes
|
||||
|
||||
- packaged apps discover distro content from bundled Tauri resources
|
||||
- local development uses `GOOSE_DISTRO_DIR`
|
||||
- after changing `distro.json`, restart `just dev` so startup reloads the manifest
|
||||
|
||||
## Scope guidance
|
||||
|
||||
Use distro bundles for packaged-app policy and shell-level defaults.
|
||||
|
||||
Good fits:
|
||||
|
||||
- feature flags for Goose2 UI behavior
|
||||
- allowlists that suggest visible product choices
|
||||
- config or helper binaries that should be present when `goose serve` starts
|
||||
|
||||
Avoid using distro bundles as a replacement for normal app state, user settings, or ACP-backed domain data.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"featureToggles": {
|
||||
"costTracking": true
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,10 @@ dev: setup
|
||||
export RUST_LOG="${RUST_LOG:-perf=debug,info}"
|
||||
PROJECT_DIR=$(pwd)
|
||||
REPO_ROOT=$(cd ../.. && pwd)
|
||||
DISTRO_DIR="${PROJECT_DIR}/distro"
|
||||
if [[ -z "${GOOSE_DISTRO_DIR:-}" && -d "${DISTRO_DIR}" ]]; then
|
||||
export GOOSE_DISTRO_DIR="${DISTRO_DIR}"
|
||||
fi
|
||||
LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose"
|
||||
LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose"
|
||||
if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then
|
||||
@@ -107,6 +111,10 @@ dev: setup
|
||||
echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH"
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOSE_DISTRO_DIR:-}" ]]; then
|
||||
echo "Using distro dir: ${GOOSE_DISTRO_DIR}"
|
||||
fi
|
||||
|
||||
# In worktrees, generate a labeled icon so you can tell instances apart
|
||||
if git rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
GIT_DIR=$(git rev-parse --git-dir)
|
||||
@@ -138,6 +146,10 @@ dev-debug: setup
|
||||
# Override with e.g. RUST_LOG=info just dev-debug to disable.
|
||||
export RUST_LOG="${RUST_LOG:-perf=debug,info}"
|
||||
REPO_ROOT=$(cd ../.. && pwd)
|
||||
DISTRO_DIR="$(pwd)/distro"
|
||||
if [[ -z "${GOOSE_DISTRO_DIR:-}" && -d "${DISTRO_DIR}" ]]; then
|
||||
export GOOSE_DISTRO_DIR="${DISTRO_DIR}"
|
||||
fi
|
||||
LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose"
|
||||
LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose"
|
||||
if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then
|
||||
@@ -155,6 +167,10 @@ dev-debug: setup
|
||||
echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH"
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOSE_DISTRO_DIR:-}" ]]; then
|
||||
echo "Using distro dir: ${GOOSE_DISTRO_DIR}"
|
||||
fi
|
||||
|
||||
# In worktrees, generate a labeled icon so you can tell instances apart
|
||||
if git rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
GIT_DIR=$(git rev-parse --git-dir)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
use crate::services::distro_bundle::{DistroBundleInfo, DistroBundleState};
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_distro_bundle(state: State<'_, DistroBundleState>) -> DistroBundleInfo {
|
||||
state.info()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod acp;
|
||||
pub mod agent_setup;
|
||||
pub mod agents;
|
||||
pub mod distro;
|
||||
pub mod doctor;
|
||||
pub mod git;
|
||||
pub mod git_changes;
|
||||
|
||||
@@ -2,7 +2,9 @@ mod commands;
|
||||
mod services;
|
||||
mod types;
|
||||
|
||||
use services::distro_bundle::DistroBundleState;
|
||||
use services::personas::PersonaStore;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_window_state::StateFlags;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -30,6 +32,10 @@ pub fn run() {
|
||||
let builder = builder.plugin(tauri_plugin_app_test_driver::init());
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
app.manage(DistroBundleState::new(app.handle()));
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::agents::list_personas,
|
||||
commands::agents::create_persona,
|
||||
@@ -72,6 +78,7 @@ pub fn run() {
|
||||
commands::agent_setup::install_agent,
|
||||
commands::agent_setup::authenticate_agent,
|
||||
commands::path_resolver::resolve_path,
|
||||
commands::distro::get_distro_bundle,
|
||||
commands::system::get_home_dir,
|
||||
commands::system::save_exported_session_file,
|
||||
commands::system::path_exists,
|
||||
@@ -80,7 +87,6 @@ pub fn run() {
|
||||
commands::system::list_files_for_mentions,
|
||||
commands::system::read_image_attachment,
|
||||
])
|
||||
.setup(|_app| Ok(()))
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|_app, _event| {});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::services::distro_bundle::DistroBundleState;
|
||||
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
@@ -68,6 +72,18 @@ impl GooseServeProcess {
|
||||
let mut command: Command = get_goose_command(&app_handle)?;
|
||||
let binary_display = command.as_std().get_program().to_string_lossy().to_string();
|
||||
|
||||
if let Some(distro_state) = app_handle.try_state::<DistroBundleState>() {
|
||||
if let Some(bundle) = distro_state.bundle() {
|
||||
if let Some(bin_dir) = &bundle.bin_dir {
|
||||
prepend_path_env(&mut command, bin_dir);
|
||||
}
|
||||
if let Some(config_path) = &bundle.config_path {
|
||||
append_additional_config_env(&mut command, config_path);
|
||||
}
|
||||
command.env("GOOSE_DISTRO_DIR", &bundle.root_dir);
|
||||
}
|
||||
}
|
||||
|
||||
command
|
||||
.arg("serve")
|
||||
.arg("--host")
|
||||
@@ -149,6 +165,54 @@ fn default_serve_working_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
}
|
||||
|
||||
fn prepend_path_env(command: &mut Command, extra_dir: &std::path::Path) {
|
||||
let mut paths = vec![extra_dir.to_path_buf()];
|
||||
if let Some(existing) = std::env::var_os("PATH") {
|
||||
paths.extend(std::env::split_paths(&existing));
|
||||
}
|
||||
|
||||
set_path_list_env(command, "PATH", paths, Some(extra_dir.as_os_str()));
|
||||
}
|
||||
|
||||
fn append_additional_config_env(command: &mut Command, config_path: &std::path::Path) {
|
||||
let existing = std::env::var_os("GOOSE_ADDITIONAL_CONFIG_FILES");
|
||||
let mut paths: Vec<PathBuf> = existing
|
||||
.as_ref()
|
||||
.map(std::env::split_paths)
|
||||
.map(Iterator::collect)
|
||||
.unwrap_or_default();
|
||||
paths.push(config_path.to_path_buf());
|
||||
|
||||
if let Ok(joined) = std::env::join_paths(&paths) {
|
||||
command.env("GOOSE_ADDITIONAL_CONFIG_FILES", joined);
|
||||
} else {
|
||||
let mut fallback = existing.unwrap_or_default();
|
||||
if !fallback.is_empty() {
|
||||
fallback.push(if cfg!(windows) { ";" } else { ":" });
|
||||
}
|
||||
fallback.push(config_path.as_os_str());
|
||||
command.env("GOOSE_ADDITIONAL_CONFIG_FILES", fallback);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_path_list_env(
|
||||
command: &mut Command,
|
||||
key: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
fallback_prefix: Option<&std::ffi::OsStr>,
|
||||
) {
|
||||
if let Ok(joined) = std::env::join_paths(&paths) {
|
||||
command.env(key, joined);
|
||||
} else if let Some(prefix) = fallback_prefix {
|
||||
let mut fallback = OsString::from(prefix);
|
||||
for path in paths.iter().skip(1) {
|
||||
fallback.push(if cfg!(windows) { ";" } else { ":" });
|
||||
fallback.push(path.as_os_str());
|
||||
}
|
||||
command.env(key, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
fn reserve_free_port() -> Result<u16, String> {
|
||||
let listener = std::net::TcpListener::bind((LOCALHOST, 0))
|
||||
.map_err(|error| format!("Failed to reserve Goose serve port: {error}"))?;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const DISTRO_DIR_NAME: &str = "distro";
|
||||
const DISTRO_JSON_NAME: &str = "distro.json";
|
||||
const DISTRO_CONFIG_NAME: &str = "config.yaml";
|
||||
const DISTRO_BIN_DIR_NAME: &str = "bin";
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DistroManifest {
|
||||
pub app_version: Option<String>,
|
||||
pub feature_toggles: Option<HashMap<String, bool>>,
|
||||
pub extension_allowlist: Option<String>,
|
||||
pub provider_allowlist: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DistroBundleInfo {
|
||||
pub present: bool,
|
||||
pub app_version: Option<String>,
|
||||
pub feature_toggles: Option<HashMap<String, bool>>,
|
||||
pub extension_allowlist: Option<String>,
|
||||
pub provider_allowlist: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DistroBundle {
|
||||
pub root_dir: PathBuf,
|
||||
pub config_path: Option<PathBuf>,
|
||||
pub bin_dir: Option<PathBuf>,
|
||||
pub manifest: DistroManifest,
|
||||
}
|
||||
|
||||
pub struct DistroBundleState {
|
||||
bundle: Option<DistroBundle>,
|
||||
}
|
||||
|
||||
impl DistroBundleState {
|
||||
pub fn new(app_handle: &AppHandle) -> Self {
|
||||
let bundle = load_distro_bundle(app_handle)
|
||||
.map_err(|error| {
|
||||
log::warn!("Failed to load distro bundle: {error}");
|
||||
error
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
Self { bundle }
|
||||
}
|
||||
|
||||
pub fn info(&self) -> DistroBundleInfo {
|
||||
let Some(bundle) = &self.bundle else {
|
||||
return DistroBundleInfo {
|
||||
present: false,
|
||||
app_version: None,
|
||||
feature_toggles: None,
|
||||
extension_allowlist: None,
|
||||
provider_allowlist: None,
|
||||
};
|
||||
};
|
||||
|
||||
DistroBundleInfo {
|
||||
present: true,
|
||||
app_version: bundle.manifest.app_version.clone(),
|
||||
feature_toggles: bundle.manifest.feature_toggles.clone(),
|
||||
extension_allowlist: bundle.manifest.extension_allowlist.clone(),
|
||||
provider_allowlist: bundle.manifest.provider_allowlist.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bundle(&self) -> Option<&DistroBundle> {
|
||||
self.bundle.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
fn load_distro_bundle(app_handle: &AppHandle) -> Result<Option<DistroBundle>, String> {
|
||||
let Some(root_dir) = resolve_distro_root(app_handle)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let manifest_path = root_dir.join(DISTRO_JSON_NAME);
|
||||
if !manifest_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let manifest = read_manifest(&manifest_path)?;
|
||||
let config_path = root_dir.join(DISTRO_CONFIG_NAME);
|
||||
let bin_dir = root_dir.join(DISTRO_BIN_DIR_NAME);
|
||||
|
||||
Ok(Some(DistroBundle {
|
||||
root_dir,
|
||||
config_path: config_path.exists().then_some(config_path),
|
||||
bin_dir: bin_dir.is_dir().then_some(bin_dir),
|
||||
manifest,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_distro_root(app_handle: &AppHandle) -> Result<Option<PathBuf>, String> {
|
||||
if let Ok(override_dir) = env::var("GOOSE_DISTRO_DIR") {
|
||||
let path = PathBuf::from(override_dir);
|
||||
if path.is_dir() {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
return Err(format!(
|
||||
"GOOSE_DISTRO_DIR points to a non-directory path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let resource_dir = app_handle
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|error| format!("Failed to resolve Tauri resource dir: {error}"))?;
|
||||
let distro_dir = resource_dir.join(DISTRO_DIR_NAME);
|
||||
|
||||
Ok(distro_dir.is_dir().then_some(distro_dir))
|
||||
}
|
||||
|
||||
fn read_manifest(path: &Path) -> Result<DistroManifest, String> {
|
||||
let contents = std::fs::read_to_string(path).map_err(|error| {
|
||||
format!(
|
||||
"Failed to read distro manifest '{}': {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
serde_json::from_str::<DistroManifest>(&contents).map_err(|error| {
|
||||
format!(
|
||||
"Failed to parse distro manifest '{}': {error}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_partial_manifest() {
|
||||
let manifest = serde_json::from_str::<DistroManifest>(
|
||||
r#"{
|
||||
"appVersion": "development",
|
||||
"featureToggles": {"foo": true}
|
||||
}"#,
|
||||
)
|
||||
.expect("manifest should parse");
|
||||
|
||||
assert_eq!(manifest.app_version.as_deref(), Some("development"));
|
||||
assert_eq!(
|
||||
manifest
|
||||
.feature_toggles
|
||||
.as_ref()
|
||||
.and_then(|toggles| toggles.get("foo"))
|
||||
.copied(),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod acp;
|
||||
pub mod distro_bundle;
|
||||
pub mod personas;
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"../distro": "distro"
|
||||
},
|
||||
"externalBin": ["../../../target/release/goose"],
|
||||
"macOS": {
|
||||
"entitlements": "entitlements.plist",
|
||||
|
||||
@@ -6,6 +6,9 @@ import { discoverAcpProvidersFromEntries } from "@/shared/api/acp";
|
||||
import { setNotificationHandler, getClient } from "@/shared/api/acpConnection";
|
||||
import notificationHandler from "@/shared/api/acpNotificationHandler";
|
||||
import { perfLog } from "@/shared/lib/perfLog";
|
||||
import { parseProviderAllowlist } from "@/features/providers/distroProviderConstraints";
|
||||
import { getModelProviders } from "@/features/providers/providerCatalog";
|
||||
import { useDistroStore } from "@/features/settings/stores/distroStore";
|
||||
|
||||
export function useAppStartup() {
|
||||
useEffect(() => {
|
||||
@@ -25,6 +28,18 @@ export function useAppStartup() {
|
||||
|
||||
const store = useAgentStore.getState();
|
||||
const inventoryStore = useProviderInventoryStore.getState();
|
||||
const distroStore = useDistroStore.getState();
|
||||
const loadDistroBundle = async () => {
|
||||
try {
|
||||
const { getDistroBundle } = await import("@/shared/api/distro");
|
||||
const manifest = await getDistroBundle();
|
||||
distroStore.setManifest(manifest);
|
||||
} catch (err) {
|
||||
console.error("Failed to load distro bundle on startup:", err);
|
||||
distroStore.setManifest({ present: false });
|
||||
}
|
||||
};
|
||||
|
||||
const loadPersonas = async () => {
|
||||
const t0 = performance.now();
|
||||
store.setPersonasLoading(true);
|
||||
@@ -57,7 +72,22 @@ export function useAppStartup() {
|
||||
|
||||
// Derive ACP providers from the same response
|
||||
const providers = discoverAcpProvidersFromEntries(entries);
|
||||
store.setProviders(providers);
|
||||
const providerAllowlist = parseProviderAllowlist(
|
||||
useDistroStore.getState().manifest,
|
||||
);
|
||||
if (!providerAllowlist) {
|
||||
store.setProviders(providers);
|
||||
} else {
|
||||
const hasAllowedModelProvider = getModelProviders().some(
|
||||
(provider) => providerAllowlist.has(provider.id),
|
||||
);
|
||||
store.setProviders(
|
||||
providers.filter(
|
||||
(provider) =>
|
||||
provider.id !== "goose" || hasAllowedModelProvider,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
perfLog(
|
||||
`[perf:startup] loadProvidersAndInventory done in ${(performance.now() - t0).toFixed(1)}ms (entries=${entries.length}, providers=${providers.length})`,
|
||||
@@ -87,6 +117,8 @@ export function useAppStartup() {
|
||||
setActiveSession(null);
|
||||
};
|
||||
|
||||
await loadDistroBundle();
|
||||
|
||||
const providersAndInventoryLoad = loadProvidersAndInventory();
|
||||
|
||||
await Promise.allSettled([
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterModelProvidersForDistro } from "./distroProviderConstraints";
|
||||
|
||||
describe("filterModelProvidersForDistro", () => {
|
||||
const providers = [
|
||||
{
|
||||
id: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
category: "model",
|
||||
description: "Claude models",
|
||||
setupMethod: "single_api_key",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
displayName: "OpenAI",
|
||||
category: "model",
|
||||
description: "GPT models",
|
||||
setupMethod: "single_api_key",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "ollama",
|
||||
displayName: "Ollama",
|
||||
category: "model",
|
||||
description: "Local models",
|
||||
setupMethod: "local",
|
||||
tier: "promoted",
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("returns all providers when no distro is present", () => {
|
||||
expect(
|
||||
filterModelProvidersForDistro([...providers], { present: false }),
|
||||
).toEqual(providers);
|
||||
});
|
||||
|
||||
it("returns all providers when no allowlist is configured", () => {
|
||||
expect(
|
||||
filterModelProvidersForDistro([...providers], {
|
||||
present: true,
|
||||
}),
|
||||
).toEqual(providers);
|
||||
});
|
||||
|
||||
it("filters providers to the configured allowlist", () => {
|
||||
expect(
|
||||
filterModelProvidersForDistro([...providers], {
|
||||
present: true,
|
||||
providerAllowlist: "openai, ollama",
|
||||
}),
|
||||
).toEqual([providers[1], providers[2]]);
|
||||
});
|
||||
|
||||
it("ignores whitespace and empty allowlist items", () => {
|
||||
expect(
|
||||
filterModelProvidersForDistro([...providers], {
|
||||
present: true,
|
||||
providerAllowlist: " anthropic ,, openai ",
|
||||
}),
|
||||
).toEqual([providers[0], providers[1]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
import type { DistroBundleInfo } from "@/shared/types/distro";
|
||||
|
||||
export function parseProviderAllowlist(
|
||||
distro: DistroBundleInfo | null | undefined,
|
||||
): Set<string> | null {
|
||||
if (!distro?.present) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = distro.providerAllowlist?.trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerIds = raw
|
||||
.split(",")
|
||||
.map((providerId) => providerId.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return providerIds.length > 0 ? new Set(providerIds) : null;
|
||||
}
|
||||
|
||||
export function filterModelProvidersForDistro(
|
||||
providers: ProviderCatalogEntry[],
|
||||
distro: DistroBundleInfo | null | undefined,
|
||||
): ProviderCatalogEntry[] {
|
||||
const allowlist = parseProviderAllowlist(distro);
|
||||
if (!allowlist) {
|
||||
return providers;
|
||||
}
|
||||
|
||||
return providers.filter((provider) => allowlist.has(provider.id));
|
||||
}
|
||||
@@ -6,27 +6,22 @@ import type {
|
||||
ProviderInventoryModelDto,
|
||||
} from "@aaif/goose-sdk";
|
||||
import { getModelProviders } from "../providerCatalog";
|
||||
|
||||
const MODEL_PROVIDER_IDS = new Set(getModelProviders().map((p) => p.id));
|
||||
import { useDistroStore } from "@/features/settings/stores/distroStore";
|
||||
import { filterModelProvidersForDistro } from "../distroProviderConstraints";
|
||||
|
||||
function isConfiguredGooseModelProvider(
|
||||
entry: ProviderInventoryEntryDto,
|
||||
modelProviderIds: Set<string>,
|
||||
): boolean {
|
||||
if (!entry.configured) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isCuratedModelProvider = MODEL_PROVIDER_IDS.has(entry.providerId);
|
||||
|
||||
if (entry.providerType === "Custom") {
|
||||
return entry.providerId.startsWith("custom_");
|
||||
}
|
||||
|
||||
if (entry.providerType === "Declarative") {
|
||||
return isCuratedModelProvider;
|
||||
}
|
||||
|
||||
return isCuratedModelProvider;
|
||||
return modelProviderIds.has(entry.providerId);
|
||||
}
|
||||
|
||||
function inventoryModelToOption(
|
||||
@@ -48,6 +43,7 @@ function inventoryModelToOption(
|
||||
export function useProviderInventory() {
|
||||
const entries = useProviderInventoryStore((s) => s.entries);
|
||||
const loading = useProviderInventoryStore((s) => s.loading);
|
||||
const distro = useDistroStore((s) => s.manifest);
|
||||
|
||||
const getEntry = useCallback(
|
||||
(providerId: string) => entries.get(providerId),
|
||||
@@ -63,9 +59,22 @@ export function useProviderInventory() {
|
||||
[entries],
|
||||
);
|
||||
|
||||
const modelProviderIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
filterModelProvidersForDistro(getModelProviders(), distro).map(
|
||||
(provider) => provider.id,
|
||||
),
|
||||
),
|
||||
[distro],
|
||||
);
|
||||
|
||||
const configuredModelProviderEntries = useMemo(
|
||||
() => [...entries.values()].filter(isConfiguredGooseModelProvider),
|
||||
[entries],
|
||||
() =>
|
||||
[...entries.values()].filter((entry) =>
|
||||
isConfiguredGooseModelProvider(entry, modelProviderIds),
|
||||
),
|
||||
[entries, modelProviderIds],
|
||||
);
|
||||
|
||||
const getModelsForAgent = useCallback(
|
||||
@@ -84,8 +93,8 @@ export function useProviderInventory() {
|
||||
const configuredProviderIds = useMemo(
|
||||
() =>
|
||||
[...entries.values()]
|
||||
.filter((e) => e.configured)
|
||||
.map((e) => e.providerId),
|
||||
.filter((entry) => entry.configured)
|
||||
.map((entry) => entry.providerId),
|
||||
[entries],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { create } from "zustand";
|
||||
import type { DistroBundleInfo } from "@/shared/types/distro";
|
||||
|
||||
interface DistroState {
|
||||
loaded: boolean;
|
||||
manifest: DistroBundleInfo;
|
||||
setManifest: (manifest: DistroBundleInfo) => void;
|
||||
}
|
||||
|
||||
const EMPTY_DISTRO: DistroBundleInfo = {
|
||||
present: false,
|
||||
};
|
||||
|
||||
export const useDistroStore = create<DistroState>((set) => ({
|
||||
loaded: false,
|
||||
manifest: EMPTY_DISTRO,
|
||||
setManifest: (manifest) => set({ manifest, loaded: true }),
|
||||
}));
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
getModelProviders,
|
||||
} from "@/features/providers/providerCatalog";
|
||||
import { useCredentials } from "@/features/providers/hooks/useCredentials";
|
||||
import { useDistroStore } from "@/features/settings/stores/distroStore";
|
||||
import { filterModelProvidersForDistro } from "@/features/providers/distroProviderConstraints";
|
||||
import { useCustomProviders } from "@/features/providers/hooks/useCustomProviders";
|
||||
import {
|
||||
CustomProviderChoice,
|
||||
@@ -100,6 +102,7 @@ interface PendingCustomProviderDelete {
|
||||
|
||||
export function ProvidersSettings() {
|
||||
const { t } = useTranslation(["settings", "common"]);
|
||||
const distro = useDistroStore((state) => state.manifest);
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const [modelOrder, setModelOrder] = useState<string[] | null>(null);
|
||||
const [customDialogOpen, setCustomDialogOpen] = useState(false);
|
||||
@@ -137,8 +140,12 @@ export function ProvidersSettings() {
|
||||
);
|
||||
|
||||
const allModels = useMemo(
|
||||
() => toDisplayInfo(getModelProviders(), configuredIds),
|
||||
[configuredIds],
|
||||
() =>
|
||||
toDisplayInfo(
|
||||
filterModelProvidersForDistro(getModelProviders(), distro),
|
||||
configuredIds,
|
||||
),
|
||||
[configuredIds, distro],
|
||||
);
|
||||
|
||||
const sortedModels = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { DistroBundleInfo } from "@/shared/types/distro";
|
||||
|
||||
export async function getDistroBundle(): Promise<DistroBundleInfo> {
|
||||
return invoke("get_distro_bundle");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./agents";
|
||||
export * from "./acp";
|
||||
export * from "./distro";
|
||||
export * from "./git";
|
||||
export * from "./pathResolver";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface DistroBundleInfo {
|
||||
present: boolean;
|
||||
appVersion?: string;
|
||||
featureToggles?: Record<string, boolean>;
|
||||
extensionAllowlist?: string;
|
||||
providerAllowlist?: string;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./distro";
|
||||
export * from "./messages";
|
||||
export * from "./agents";
|
||||
export * from "./chat";
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getUsage } from "tokenlens";
|
||||
import { useDistroStore } from "@/features/settings/stores/distroStore";
|
||||
|
||||
const PERCENT_MAX = 100;
|
||||
const ICON_RADIUS = 10;
|
||||
@@ -61,6 +62,13 @@ export const Context = ({
|
||||
);
|
||||
};
|
||||
|
||||
function useCostTrackingEnabled() {
|
||||
const featureToggles = useDistroStore(
|
||||
(state) => state.manifest.featureToggles,
|
||||
);
|
||||
return featureToggles?.costTracking !== false;
|
||||
}
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
@@ -199,6 +207,7 @@ export const ContextContentFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const costTrackingEnabled = useCostTrackingEnabled();
|
||||
const { t } = useTranslation("common");
|
||||
const { formatNumber } = useLocaleFormatting();
|
||||
const { modelId, usage } = useContextValue();
|
||||
@@ -216,6 +225,10 @@ export const ContextContentFooter = ({
|
||||
style: "currency",
|
||||
});
|
||||
|
||||
if (!costTrackingEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -241,6 +254,7 @@ const TokensWithCost = ({
|
||||
tokens?: number;
|
||||
costText?: string;
|
||||
}) => {
|
||||
const costTrackingEnabled = useCostTrackingEnabled();
|
||||
const { formatNumber } = useLocaleFormatting();
|
||||
|
||||
return (
|
||||
@@ -250,7 +264,7 @@ const TokensWithCost = ({
|
||||
: formatNumber(tokens, {
|
||||
notation: "compact",
|
||||
})}
|
||||
{costText ? (
|
||||
{costTrackingEnabled && costText ? (
|
||||
<span className="ml-2 text-muted-foreground">• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -34,6 +34,9 @@ export function buildInitScript(options?: {
|
||||
const PERSONAS = ${personas};
|
||||
const SKILLS = ${skills};
|
||||
const PROJECTS = ${projects};
|
||||
const DISTRO = {
|
||||
present: false,
|
||||
};
|
||||
const FAKE_ACP_URL = "ws://127.0.0.1:0/mock-acp";
|
||||
const ACP_SESSIONS = [];
|
||||
const PROVIDER_INVENTORY = [
|
||||
@@ -285,6 +288,8 @@ export function buildInitScript(options?: {
|
||||
// ---- ACP transport ----
|
||||
case "get_goose_serve_url":
|
||||
return Promise.resolve(FAKE_ACP_URL);
|
||||
case "get_distro_bundle":
|
||||
return Promise.resolve(DISTRO);
|
||||
|
||||
// ---- Personas ----
|
||||
case "list_personas":
|
||||
|
||||
Reference in New Issue
Block a user