From a90430e8e5bf7c7234f6695dd271fbedf4cdf46f Mon Sep 17 00:00:00 2001 From: Lucas Alvares Gomes Date: Tue, 30 Jun 2026 21:00:12 +0100 Subject: [PATCH 003/139] Make OpenAI Responses API store param configurable (#10040) Signed-off-by: Lucas Alvares Gomes --- .../src/formats/openai_responses.rs | 24 ++++++++++++++++++- crates/goose/src/config/base.rs | 4 ++++ crates/goose/src/model_config.rs | 20 ++++++++++++++-- .../docs/getting-started/providers.md | 1 + 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/goose-providers/src/formats/openai_responses.rs b/crates/goose-providers/src/formats/openai_responses.rs index 54cbc0b09c..d6daef7853 100644 --- a/crates/goose-providers/src/formats/openai_responses.rs +++ b/crates/goose-providers/src/formats/openai_responses.rs @@ -596,10 +596,11 @@ pub fn create_responses_request( None }; + let store = model_config.request_param::("store").unwrap_or(false); let mut payload = json!({ "model": model_name, "input": input_items, - "store": false, + "store": store, }); if let Some(effort) = reasoning_effort { @@ -1412,6 +1413,27 @@ mod tests { ); } + #[test] + fn test_request_params_override_store() { + let model_config = ModelConfig { + model_name: "o3".to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + request_params: Some(std::collections::HashMap::from([( + "store".to_string(), + serde_json::json!(true), + )])), + reasoning: None, + }; + + let result = create_responses_request(&model_config, "", &[], &[]).unwrap(); + + assert_eq!(result["store"], true); + } + #[test] fn test_user_image_serialized_in_responses_request() { use crate::conversation::message::Message; diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index 81585e7ad8..0cd9a22ada 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -1172,6 +1172,10 @@ impl Config { self.set_param("GOOSE_THINKING_EFFORT", v) } + pub fn get_openai_store(&self) -> Option { + self.get_param::("OPENAI_STORE").ok() + } + fn legacy_thinking_effort(&self) -> Option { if let Ok(value) = self.get_param::("CLAUDE_THINKING_TYPE") { if let Some(effort) = match value.to_lowercase().as_str() { diff --git a/crates/goose/src/model_config.rs b/crates/goose/src/model_config.rs index cc9a275f5d..145e6ac776 100644 --- a/crates/goose/src/model_config.rs +++ b/crates/goose/src/model_config.rs @@ -27,7 +27,7 @@ pub fn model_config_from_user_config_with_session_settings( ) -> Result { let config = Config::global(); let model = base_model_config_from_user_config(model_name.as_ref())?; - let model = materialize_model_config_inner(model, false)? + let model = materialize_model_config_inner(model, provider_name, false)? .with_context_limit(context_limit) .with_inherited_session_settings_from(previous, request_params) .with_default_thinking_effort(config.get_goose_thinking_effort()); @@ -36,12 +36,13 @@ pub fn model_config_from_user_config_with_session_settings( } pub fn materialize_model_config(provider_name: &str, model: ModelConfig) -> Result { - let model = materialize_model_config_inner(model, true)?; + let model = materialize_model_config_inner(model, provider_name, true)?; Ok(model.with_canonical_limits(provider_name)) } fn materialize_model_config_inner( mut model: ModelConfig, + provider_name: &str, include_default_thinking_effort: bool, ) -> Result { let config = Config::global(); @@ -62,6 +63,10 @@ fn materialize_model_config_inner( model = model.with_default_thinking_effort(config.get_goose_thinking_effort()); } + if provider_name == goose_providers::openai::OPEN_AI_PROVIDER_NAME { + model = apply_openai_request_params(model); + } + Ok(model) } @@ -152,6 +157,17 @@ async fn provider_default_fast_model(provider_name: &str) -> Option { .and_then(|entry| entry.metadata().fast_model.clone()) } +fn apply_openai_request_params(mut model: ModelConfig) -> ModelConfig { + let config = Config::global(); + if let Some(store) = config.get_openai_store() { + model = model.with_merged_request_params(HashMap::from([( + "store".to_string(), + serde_json::json!(store), + )])); + } + model +} + fn base_model_config_from_user_config(model_name: &str) -> Result { let config = Config::global(); let mut model = ModelConfig { diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 494f9d25e6..9480415118 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -289,6 +289,7 @@ Need to connect to multiple OpenAI-compatible endpoints? [Configure custom provi | `OPENAI_ORGANIZATION` | No | Organization ID for usage tracking and governance | | `OPENAI_PROJECT` | No | Project identifier for resource management | | `OPENAI_CUSTOM_HEADERS` | No | Additional headers to include in the request. Can be set via environment variable, configuration file, or CLI, in the format `HEADER_A=VALUE_A,HEADER_B=VALUE_B`. | +| `OPENAI_STORE` | No | Whether to persist the generated Responses API response for later retrieval via API. Defaults to `false`. | #### Example Configurations From 273ddedf21b261045d72908bd9d976d0fbbb81be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:07:41 -0400 Subject: [PATCH 004/139] chore(deps): bump actions/cache from 5.0.2 to 6.0.0 (#10051) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/bundle-desktop-intel.yml | 2 +- .github/workflows/bundle-desktop-linux.yml | 2 +- .github/workflows/bundle-desktop-windows.yml | 2 +- .github/workflows/bundle-desktop.yml | 2 +- .github/workflows/deploy-docs-and-extensions.yml | 2 +- .github/workflows/rebuild-skills-marketplace.yml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index 54d76434ed..d8eecc71bc 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -100,7 +100,7 @@ jobs: cp target/x86_64-apple-darwin/release/goosed ui/desktop/src/bin/goosed - name: Cache pnpm dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ui/desktop/node_modules diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml index dd1a88fcc9..d8e76c5525 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -149,7 +149,7 @@ jobs: ls -la ui/desktop/src/bin/ - name: Cache pnpm dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ui/desktop/node_modules diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index f18af1025d..7990a39d32 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -61,7 +61,7 @@ jobs: run: npm install -g pnpm@10.30.3 - name: Cache node_modules - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | node_modules diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index 4b0073198d..0437a76642 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -139,7 +139,7 @@ jobs: cp target/release/goosed ui/desktop/src/bin/goosed - name: Cache pnpm dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ui/desktop/node_modules diff --git a/.github/workflows/deploy-docs-and-extensions.yml b/.github/workflows/deploy-docs-and-extensions.yml index 4eed0ff625..d5f4bf2952 100644 --- a/.github/workflows/deploy-docs-and-extensions.yml +++ b/.github/workflows/deploy-docs-and-extensions.yml @@ -32,7 +32,7 @@ jobs: node-version: 20 - name: Cache Node.js modules (documentation) - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ./documentation/node_modules key: ${{ runner.os }}-documentation-${{ hashFiles('./documentation/package-lock.json') }} diff --git a/.github/workflows/rebuild-skills-marketplace.yml b/.github/workflows/rebuild-skills-marketplace.yml index 2964d36f19..5aee55c5c0 100644 --- a/.github/workflows/rebuild-skills-marketplace.yml +++ b/.github/workflows/rebuild-skills-marketplace.yml @@ -53,7 +53,7 @@ jobs: node-version: 20 - name: Cache Node.js modules (documentation) - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ./documentation/node_modules key: ${{ runner.os }}-documentation-${{ hashFiles('./documentation/package-lock.json') }} From d056c72a2c7984a6826c5ff99e6171d939300d2e Mon Sep 17 00:00:00 2001 From: Rodolfo Olivieri Date: Tue, 30 Jun 2026 18:15:38 -0300 Subject: [PATCH 005/139] fix(deps): downgrade pkcs8 to v0.10 to match sec1/pkcs1 v0.7 (#10119) Co-authored-by: Claude Sonnet 4.6 --- Cargo.lock | 84 ++++++++++--------------------- crates/goose-providers/Cargo.toml | 3 +- 2 files changed, 29 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2324cf0e8e..9317be3f22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2345,8 +2345,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" dependencies = [ "crmf", - "der 0.7.10", - "spki 0.7.3", + "der", + "spki", "x509-cert", ] @@ -2357,8 +2357,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ "const-oid 0.9.6", - "der 0.7.10", - "spki 0.7.3", + "der", + "spki", "x509-cert", ] @@ -2693,8 +2693,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" dependencies = [ "cms", - "der 0.7.10", - "spki 0.7.3", + "der", + "spki", "x509-cert", ] @@ -3469,16 +3469,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "const-oid 0.10.2", - "zeroize", -] - [[package]] name = "der-parser" version = "10.0.0" @@ -3820,12 +3810,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.10", + "der", "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", - "spki 0.7.3", + "spki", ] [[package]] @@ -3843,7 +3833,7 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8 0.10.2", + "pkcs8", "signature", ] @@ -3884,7 +3874,7 @@ dependencies = [ "group", "hkdf", "pem-rfc7468", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -5117,7 +5107,7 @@ dependencies = [ "once_cell", "pem", "pkcs1", - "pkcs8 0.11.0", + "pkcs8", "rand 0.10.1", "regex", "reqwest 0.13.4", @@ -8225,9 +8215,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", + "der", + "pkcs8", + "spki", ] [[package]] @@ -8236,18 +8226,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der 0.8.0", - "spki 0.8.0", + "der", + "spki", ] [[package]] @@ -9187,10 +9167,10 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "signature", - "spki 0.7.3", + "spki", "subtle", "zeroize", ] @@ -9561,9 +9541,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der 0.7.10", + "der", "generic-array", - "pkcs8 0.10.2", + "pkcs8", "subtle", "zeroize", ] @@ -9976,14 +9956,14 @@ dependencies = [ "aws-lc-rs", "base64 0.22.1", "const-oid 0.9.6", - "der 0.7.10", + "der", "digest 0.10.7", "pem", "rand_core 0.9.5", "sha2 0.10.9", "signature", "sigstore-types", - "spki 0.7.3", + "spki", "thiserror 2.0.18", "tracing", "x509-cert", @@ -10050,7 +10030,7 @@ dependencies = [ "cmpv2", "cms", "const-oid 0.9.6", - "der 0.7.10", + "der", "hex", "jiff", "rand 0.9.4", @@ -10287,17 +10267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", -] - -[[package]] -name = "spki" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" -dependencies = [ - "base64ct", - "der 0.8.0", + "der", ] [[package]] @@ -13870,10 +13840,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid 0.9.6", - "der 0.7.10", + "der", "sha1", "signature", - "spki 0.7.3", + "spki", "tls_codec", ] @@ -13903,7 +13873,7 @@ checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" dependencies = [ "cmpv2", "cms", - "der 0.7.10", + "der", ] [[package]] diff --git a/crates/goose-providers/Cargo.toml b/crates/goose-providers/Cargo.toml index 7fd0180c6f..76f15b746a 100644 --- a/crates/goose-providers/Cargo.toml +++ b/crates/goose-providers/Cargo.toml @@ -52,7 +52,8 @@ rand = { workspace = true } url = { workspace = true } pem = { version = "3.0.2", default-features = false, features = ["std"], optional = true } pkcs1 = { version = "0.7.5", default-features = false, features = ["pkcs8", "std"], optional = true } -pkcs8 = { version = "0.11.0", default-features = false, features = ["alloc", "std"], optional = true } +# v0.10 matches the der/const-oid series used by sec1 v0.7 and pkcs1 v0.7; upgrading to v0.11 causes type mismatches across those crates. +pkcs8 = { version = "0.10", default-features = false, features = ["alloc", "std"], optional = true } sec1 = { version = "0.7", default-features = false, features = ["der", "pkcs8", "std"], optional = true } [dev-dependencies] From c45f757c9da4d8c01fa27c5e65660ebe0dd03f8b Mon Sep 17 00:00:00 2001 From: Kyle E DeFreitas Date: Tue, 30 Jun 2026 17:15:41 -0400 Subject: [PATCH 006/139] fix(providers): don't retry deterministically-permanent 400s (thinking-block immutability) (#10005) Co-authored-by: Douwe M Osinga --- crates/goose-providers/src/retry.rs | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/goose-providers/src/retry.rs b/crates/goose-providers/src/retry.rs index 8660a9f1cf..49afd9bfe8 100644 --- a/crates/goose-providers/src/retry.rs +++ b/crates/goose-providers/src/retry.rs @@ -81,11 +81,27 @@ impl RetryConfig { } } +/// Substrings marking a `RequestFailed` (4xx) as deterministically permanent: +/// Anthropic rejects signed `thinking`/`redacted_thinking` blocks as immutable +/// once a thinking model's config changes mid-conversation, and the identical +/// payload is rebuilt on every retry — so retrying can never succeed. +const PERMANENT_REQUEST_FAILURE_MARKERS: &[&str] = &[ + "blocks in the latest assistant message cannot be modified", + "must remain as they were in the original response", +]; + +fn is_permanent_request_failure(message: &str) -> bool { + PERMANENT_REQUEST_FAILURE_MARKERS + .iter() + .any(|marker| message.contains(marker)) +} + pub fn should_retry(error: &ProviderError, config: &RetryConfig) -> bool { match error { ProviderError::RateLimitExceeded { .. } | ProviderError::ServerError(_) | ProviderError::NetworkError(_) => true, + ProviderError::RequestFailed(message) if is_permanent_request_failure(message) => false, ProviderError::RequestFailed(_) => !config.transient_only, _ => false, } @@ -254,6 +270,33 @@ mod tests { assert!(should_retry(&error, &config)); } + #[test] + fn never_retries_permanent_thinking_block_400() { + let config = RetryConfig::default(); + let error = ProviderError::RequestFailed( + "Bad request (400): {\"message\":\"messages.3.content.1: `thinking` or \ + `redacted_thinking` blocks in the latest assistant message cannot be \ + modified. These blocks must remain as they were in the original \ + response.\"}" + .into(), + ); + assert!(!should_retry(&error, &config)); + } + + #[test] + fn permanent_request_failure_marker_detection() { + assert!(is_permanent_request_failure( + "messages.3.content.1: `thinking` blocks in the latest assistant message \ + cannot be modified" + )); + assert!(is_permanent_request_failure( + "These blocks must remain as they were in the original response." + )); + assert!(!is_permanent_request_failure( + "Bad request (400): model not found" + )); + } + #[test] fn transient_only_skips_request_failed() { let config = RetryConfig::default().transient_only(); From 40bfb137ad1aaa58558859b968fe1c9116a79c6c Mon Sep 17 00:00:00 2001 From: Leo Walker Date: Tue, 30 Jun 2026 14:15:43 -0700 Subject: [PATCH 007/139] feat(providers): add Fireworks AI declarative provider (#9990) Signed-off-by: Leo Walker --- .../src/providers/declarative/fireworks.json | 55 +++++++++++++++++++ crates/goose/src/providers/init.rs | 24 ++++++++ 2 files changed, 79 insertions(+) create mode 100644 crates/goose/src/providers/declarative/fireworks.json diff --git a/crates/goose/src/providers/declarative/fireworks.json b/crates/goose/src/providers/declarative/fireworks.json new file mode 100644 index 0000000000..85b56bb674 --- /dev/null +++ b/crates/goose/src/providers/declarative/fireworks.json @@ -0,0 +1,55 @@ +{ + "name": "fireworks-ai", + "engine": "openai", + "display_name": "Fireworks AI", + "description": "Fast serverless inference for open models with an OpenAI-compatible API", + "api_key_env": "FIREWORKS_API_KEY", + "base_url": "https://api.fireworks.ai/inference/v1/chat/completions", + "catalog_provider_id": "fireworks-ai", + "dynamic_models": false, + "models": [ + { + "name": "accounts/fireworks/models/kimi-k2p7-code", + "context_limit": 262000 + }, + { + "name": "accounts/fireworks/models/kimi-k2p6", + "context_limit": 262000 + }, + { + "name": "accounts/fireworks/models/glm-5p2", + "context_limit": 1048576 + }, + { + "name": "accounts/fireworks/models/deepseek-v4-pro", + "context_limit": 1000000 + }, + { + "name": "accounts/fireworks/models/deepseek-v4-flash", + "context_limit": 1000000 + }, + { + "name": "accounts/fireworks/models/minimax-m3", + "context_limit": 512000 + }, + { + "name": "accounts/fireworks/models/qwen3p7-plus", + "context_limit": 262144 + }, + { + "name": "accounts/fireworks/models/glm-5p1", + "context_limit": 202800 + }, + { + "name": "accounts/fireworks/models/gpt-oss-120b", + "context_limit": 131072 + }, + { + "name": "accounts/fireworks/models/gpt-oss-20b", + "context_limit": 131072 + } + ], + "preserves_thinking": true, + "supports_streaming": true, + "model_doc_link": "https://fireworks.ai/models" +} diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index e2261dcb66..e258f26667 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -409,6 +409,30 @@ mod tests { assert!(api_key.primary, "DASHSCOPE_API_KEY should be primary"); } + #[tokio::test] + async fn test_fireworks_declarative_provider_registry_wiring() { + let fireworks = get_from_registry("fireworks-ai") + .await + .expect("fireworks-ai provider should be registered"); + let meta = fireworks.metadata(); + + assert_eq!(fireworks.provider_type(), ProviderType::Declarative); + assert_eq!(meta.display_name, "Fireworks AI"); + assert_eq!( + meta.default_model, + "accounts/fireworks/models/kimi-k2p7-code" + ); + assert_eq!(meta.model_doc_link, "https://fireworks.ai/models"); + + let api_key = meta + .config_keys + .iter() + .find(|k| k.name == "FIREWORKS_API_KEY") + .expect("FIREWORKS_API_KEY config key should exist"); + assert!(api_key.required, "FIREWORKS_API_KEY should be required"); + assert!(api_key.secret, "FIREWORKS_API_KEY should be secret"); + } + #[tokio::test] async fn test_openai_compatible_providers_config_keys() { let providers_list = providers().await; From d308b13e75c3dfb9c27c91a2dcf33e515f275c38 Mon Sep 17 00:00:00 2001 From: Max Novich Date: Tue, 30 Jun 2026 14:15:46 -0700 Subject: [PATCH 008/139] [codex] Add SessionStart hook parity outside CLI (#9970) Co-authored-by: Max Novich --- crates/goose-cli/src/session/mod.rs | 7 --- crates/goose/src/agents/agent.rs | 86 +++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 73dfe72723..d3565f9024 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -487,10 +487,6 @@ impl CliSession { /// Start an interactive session, optionally with an initial message pub async fn interactive(&mut self, prompt: Option) -> Result<()> { - self.agent - .emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id) - .await; - let result = self.run_interactive(prompt).await; self.agent @@ -1139,9 +1135,6 @@ impl CliSession { /// Process a single message and exit pub async fn headless(&mut self, prompt: String) -> Result<()> { - self.agent - .emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id) - .await; let message = Message::user().with_text(&prompt); let result = self .process_message(message, CancellationToken::default(), false) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 741b06aa99..d4a8f592e9 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1569,6 +1569,19 @@ impl Agent { let message_text = user_message.as_concat_text(); + let session = session_manager + .get_session(&session_config.id, true) + .await?; + let is_first_turn = session + .conversation + .as_ref() + .map(|conversation| conversation.messages().is_empty()) + .unwrap_or(true); + if is_first_turn { + self.emit_hook(crate::hooks::HookEvent::SessionStart, &session_config.id) + .await; + } + if self .hook_manager .has_hooks(crate::hooks::HookEvent::UserPromptSubmit) @@ -3482,6 +3495,64 @@ exit 0 } } + struct SessionStartHookTestEnv { + temp_dir: TempDir, + hook_log: PathBuf, + } + + impl SessionStartHookTestEnv { + fn new() -> Result { + let temp_dir = tempfile::tempdir()?; + let plugin_dir = temp_dir.path().join("session-start"); + std::fs::create_dir_all(plugin_dir.join("hooks"))?; + std::fs::write( + plugin_dir.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "sh ${PLUGIN_ROOT}/start.sh" } + ] + } + ] + } +} +"#, + )?; + std::fs::write( + plugin_dir.join("start.sh"), + r#"#!/bin/sh +echo start >> "$PLUGIN_ROOT/hook.log" +"#, + )?; + + Ok(Self { + temp_dir, + hook_log: plugin_dir.join("hook.log"), + }) + } + + fn hook_manager(&self) -> crate::hooks::HookManager { + crate::hooks::HookManager::from_plugins_for_test(vec![DiscoveredPlugin { + name: "session-start".into(), + root: self.temp_dir.path().join("session-start"), + scope: PluginScope::Project, + }]) + } + + fn data_dir(&self) -> PathBuf { + self.temp_dir.path().join("data") + } + + fn hook_invocations(&self) -> usize { + std::fs::read_to_string(&self.hook_log) + .unwrap_or_default() + .lines() + .count() + } + } + struct CountingTextProvider { call_count: AtomicUsize, } @@ -3694,6 +3765,21 @@ exit 0 .collect() } + #[tokio::test] + async fn session_start_hook_emits_once_for_first_reply_turn() -> Result<()> { + let env = SessionStartHookTestEnv::new()?; + let provider = Arc::new(CountingTextProvider::new()); + let (agent, session_id) = + create_test_agent(env.data_dir(), env.hook_manager(), provider.clone()).await?; + + run_stop_hook_test_turn(&agent, &session_id, "first").await?; + run_stop_hook_test_turn(&agent, &session_id, "second").await?; + + assert_eq!(env.hook_invocations(), 1); + assert_eq!(provider.call_count(), 2); + Ok(()) + } + #[tokio::test] async fn stop_hook_block_cap_allows_configured_consecutive_blocks_then_overrides() -> Result<()> { From 452ea10123ae5d97b8e576c40590a63ab1677e24 Mon Sep 17 00:00:00 2001 From: Harnoor Singh <49549662+harrykamboj1@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:45:48 +0530 Subject: [PATCH 009/139] fix(desktop): dedupe Nostr session deep link imports (#9918) Signed-off-by: harrykamboj1 --- ui/desktop/src/App.tsx | 25 +++++++++---- ui/desktop/src/main.ts | 81 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 19a9fa3032..e067c3302a 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -306,6 +306,8 @@ const ExtensionsRoute = () => { export function AppInner() { const [fatalError, setFatalError] = useState(null); + const nostrImportInFlight = useRef(null); + const navigate = useNavigate(); const setView = useNavigation(); @@ -397,15 +399,22 @@ export function AppInner() { const handleOpenSessionShare = async (_event: IpcRendererEvent, ...args: unknown[]) => { const link = args[0] as string; window.electron.logInfo('Opening session share link'); - try { - if (link.startsWith('goose://sessions/nostr')) { - await importNostrSessionFromDeepLink(link); - navigate('/sessions'); - return; - } + if (!link.startsWith('goose://sessions/nostr')) { toast.error('Unsupported session share link'); navigate('/sessions'); + return; + } + + if (nostrImportInFlight.current === link) { + window.electron.logInfo('Skipping duplicate Nostr deep link import'); + return; + } + nostrImportInFlight.current = link; + + try { + await importNostrSessionFromDeepLink(link); + navigate('/sessions'); } catch (error) { console.error('Unexpected error opening Nostr session share:', error); trackErrorWithContext(error, { @@ -415,6 +424,10 @@ export function AppInner() { }); toast.error(`Failed to import Nostr session: ${errorMessage(error, 'Unknown error')}`); navigate('/sessions'); + } finally { + if (nostrImportInFlight.current === link) { + nostrImportInFlight.current = null; + } } }; window.electron.on('open-shared-session', handleOpenSessionShare); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index c91bb31edb..18b107f1dd 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -495,7 +495,66 @@ if (process.platform !== 'darwin') { } } -const pendingDeepLinks = new Map(); // windowId -> deep link URL +const pendingDeepLinks = new Map(); + +function queuePendingDeepLink(windowId: number, url: string): void { + if (pendingDeepLinks.get(windowId) === url) { + return; + } + pendingDeepLinks.set(windowId, url); +} + +const reactReadyWindows = new Set(); + +const DEEPLINK_BURST_DEDUP_MS = 2000; +const recentSessionDeepLinkSends = new Map(); + +function pruneExpiredSessionDeepLinkSends(now: number): void { + for (const [url, sentAt] of recentSessionDeepLinkSends) { + if (now - sentAt >= DEEPLINK_BURST_DEDUP_MS) { + recentSessionDeepLinkSends.delete(url); + } + } +} + +function isBurstDuplicateSessionDeepLink(url: string): boolean { + const now = Date.now(); + pruneExpiredSessionDeepLinkSends(now); + const sentAt = recentSessionDeepLinkSends.get(url); + return sentAt !== undefined && now - sentAt < DEEPLINK_BURST_DEDUP_MS; +} + +function recordSessionDeepLinkSend(url: string): void { + const now = Date.now(); + recentSessionDeepLinkSends.set(url, now); + pruneExpiredSessionDeepLinkSends(now); +} + +function sendOpenSharedSession(window: BrowserWindow, url: string): void { + if (isBurstDuplicateSessionDeepLink(url)) { + log.info('[Main] Ignoring burst duplicate session deep link'); + return; + } + recordSessionDeepLinkSend(url); + window.webContents.send('open-shared-session', url); +} + +function deliverExtensionOrSessionDeepLink( + url: string, + parsedUrl: URL, + targetWindow: BrowserWindow +): void { + if (!reactReadyWindows.has(targetWindow.id) || targetWindow.webContents.isLoadingMainFrame()) { + queuePendingDeepLink(targetWindow.id, url); + return; + } + + if (parsedUrl.hostname === 'extension') { + targetWindow.webContents.send('add-extension', url); + } else if (parsedUrl.hostname === 'sessions') { + sendOpenSharedSession(targetWindow, url); + } +} function getResumeSessionId(parsedUrl: URL): string | null { try { @@ -555,7 +614,7 @@ async function handleProtocolUrl(url: string, parsedUrl: URL) { } if (targetWindow.webContents.isLoadingMainFrame()) { - pendingDeepLinks.set(targetWindow.id, url); + queuePendingDeepLink(targetWindow.id, url); } else { await processProtocolUrl(url, parsedUrl, targetWindow); } @@ -569,7 +628,7 @@ async function processProtocolUrl(url: string, parsedUrl: URL, window: BrowserWi if (parsedUrl.hostname === 'extension') { window.webContents.send('add-extension', url); } else if (parsedUrl.hostname === 'sessions') { - window.webContents.send('open-shared-session', url); + sendOpenSharedSession(window, url); } else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { const deeplinkData = parseRecipeDeeplink(url); const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); @@ -644,15 +703,13 @@ app.on('open-url', async (_event, url) => { const targetWindow = existingWindows[0]; if (targetWindow.isMinimized()) targetWindow.restore(); targetWindow.focus(); - if (parsedUrl.hostname === 'extension') { - targetWindow.webContents.send('add-extension', url); - } else if (parsedUrl.hostname === 'sessions') { - targetWindow.webContents.send('open-shared-session', url); + if (parsedUrl.hostname === 'extension' || parsedUrl.hostname === 'sessions') { + deliverExtensionOrSessionDeepLink(url, parsedUrl, targetWindow); } } else { openUrlHandledLaunch = true; const newWindow = await createChat(app, { dir: openDir || undefined }); - pendingDeepLinks.set(newWindow.id, url); + queuePendingDeepLink(newWindow.id, url); } } }); @@ -1283,6 +1340,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { pendingInitialMessages.delete(windowId); pendingDeepLinks.delete(windowId); + reactReadyWindows.delete(windowId); if (windowPowerSaveBlockers.has(windowId)) { const blockerId = windowPowerSaveBlockers.get(windowId)!; @@ -1358,6 +1416,7 @@ const createLauncher = () => { activeLauncherWindow = launcherWindow; launcherWindow.on('closed', () => { + reactReadyWindows.delete(launcherWindow.id); activeLauncherWindow = null; }); @@ -1642,6 +1701,10 @@ ipcMain.on('react-ready', (event) => { const window = BrowserWindow.fromWebContents(event.sender); const windowId = window?.id; + if (windowId !== undefined) { + reactReadyWindows.add(windowId); + } + // Send any pending initial message for this window if (windowId && pendingInitialMessages.has(windowId)) { const initialMessage = pendingInitialMessages.get(windowId)!; @@ -1661,7 +1724,7 @@ ipcMain.on('react-ready', (event) => { if (parsedUrl.hostname === 'extension') { window.webContents.send('add-extension', deepLinkUrl); } else if (parsedUrl.hostname === 'sessions') { - window.webContents.send('open-shared-session', deepLinkUrl); + sendOpenSharedSession(window, deepLinkUrl); } } catch (error) { log.error('Error processing pending deep link:', error); From 04da8e8702691e69ef82669c52337ae3ac094abe Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Wed, 1 Jul 2026 05:15:50 +0800 Subject: [PATCH 010/139] feat: add iFlytek Spark and Astron MaaS providers (#9837) Signed-off-by: FenjuFu Co-authored-by: Douwe M Osinga --- .../goose/src/config/declarative_providers.rs | 25 +++++++++++ .../src/providers/declarative/iflytek.json | 22 ++++++++++ .../providers/declarative/iflytek_astron.json | 41 +++++++++++++++++++ .../docs/getting-started/providers.md | 2 + 4 files changed, 90 insertions(+) create mode 100644 crates/goose/src/providers/declarative/iflytek.json create mode 100644 crates/goose/src/providers/declarative/iflytek_astron.json diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 9b3d1ee182..524d8501a2 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -833,6 +833,31 @@ mod tests { ); } + #[test] + fn test_all_bundled_providers_deserialize() { + // `load_fixed_providers` silently skips any bundled JSON that fails to + // deserialize (it only emits a `warn!`), so a malformed provider file would + // ship as a missing provider rather than a build/test failure. Assert every + // bundled file parses through the same path the loader uses. + let mut failures = Vec::new(); + for file in FIXED_PROVIDERS.files() { + if file.path().extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let content = file + .contents_utf8() + .unwrap_or_else(|| panic!("bundled provider {:?} is not valid UTF-8", file.path())); + if let Err(e) = deserialize_provider_config(content) { + failures.push(format!("{:?}: {e}", file.path())); + } + } + assert!( + failures.is_empty(), + "bundled declarative providers failed to deserialize:\n{}", + failures.join("\n") + ); + } + #[test] fn test_existing_json_files_still_deserialize_without_new_fields() { let json = include_str!("../providers/declarative/groq.json"); diff --git a/crates/goose/src/providers/declarative/iflytek.json b/crates/goose/src/providers/declarative/iflytek.json new file mode 100644 index 0000000000..a0c2da0387 --- /dev/null +++ b/crates/goose/src/providers/declarative/iflytek.json @@ -0,0 +1,22 @@ +{ + "name": "iflytek", + "engine": "openai", + "display_name": "iFlytek Spark", + "description": "iFlytek Spark (讯飞星火) models via the OpenAI-compatible HTTP API. Authenticate with your Spark HTTP API password (APIPassword). Lists the 4.0Ultra and Max families, which per the Spark HTTP docs accept system messages (always sent by goose). Best used for chat: Spark only returns OpenAI-style tool_calls when the request body sets tool_calls_switch=true, which this declarative config cannot inject, so tool-using extensions may not work as-is.", + "api_key_env": "SPARK_API_PASSWORD", + "base_url": "https://spark-api-open.xf-yun.com/v1", + "dynamic_models": false, + "skip_canonical_filtering": true, + "models": [ + {"name": "4.0Ultra", "context_limit": 8192}, + {"name": "generalv3.5", "context_limit": 8192}, + {"name": "max-32k", "context_limit": 32768} + ], + "supports_streaming": true, + "model_doc_link": "https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html", + "setup_steps": [ + "Sign in to https://xinghuo.xfyun.cn/sparkapi and create or select a Spark model plan", + "Open the model's 'HTTP 服务接口认证信息' page and copy the APIPassword (each model version has its own password)", + "Paste the APIPassword above as SPARK_API_PASSWORD" + ] +} diff --git a/crates/goose/src/providers/declarative/iflytek_astron.json b/crates/goose/src/providers/declarative/iflytek_astron.json new file mode 100644 index 0000000000..6cffd3ba6a --- /dev/null +++ b/crates/goose/src/providers/declarative/iflytek_astron.json @@ -0,0 +1,41 @@ +{ + "name": "iflytek_astron", + "engine": "openai", + "display_name": "iFlytek Astron MaaS", + "description": "iFlytek Astron MaaS (讯飞星辰) models via the OpenAI-compatible API. Defaults to the Token Plan endpoint; set ASTRON_BASE_URL to https://maas-coding-api.cn-huabei-1.xf-yun.com/v2 to use the Coding Plan (e.g. astron-code-latest). Each plan has its own API key.", + "api_key_env": "ASTRON_API_KEY", + "base_url": "${ASTRON_BASE_URL}", + "env_vars": [ + { + "name": "ASTRON_BASE_URL", + "required": false, + "secret": false, + "default": "https://maas-token-api.cn-huabei-1.xf-yun.com/v2", + "description": "Astron MaaS API base URL. Use https://maas-coding-api.cn-huabei-1.xf-yun.com/v2 for Coding Plan models like astron-code-latest." + } + ], + "dynamic_models": false, + "skip_canonical_filtering": true, + "models": [ + {"name": "xsparkx2", "context_limit": 131072}, + {"name": "xsparkx2flash", "context_limit": 131072}, + {"name": "xopglm51", "context_limit": 204800}, + {"name": "xopglm5", "context_limit": 204800}, + {"name": "xopdeepseekv4pro", "context_limit": 131072}, + {"name": "xopdeepseekv4flash", "context_limit": 131072}, + {"name": "xopdeepseekv32", "context_limit": 131072}, + {"name": "xopkimik26", "context_limit": 262144}, + {"name": "xminimaxm25", "context_limit": 131072}, + {"name": "xopqwen35397b", "context_limit": 262144}, + {"name": "astron-code-latest", "context_limit": 131072} + ], + "supports_streaming": true, + "preserves_thinking": true, + "model_doc_link": "https://www.xfyun.cn/doc/spark/TokenPlan.html", + "setup_steps": [ + "Sign in to https://maas.xfyun.cn and subscribe to a Token Plan (general models) or Coding Plan (astron-code-latest)", + "Copy the dedicated API Key for your plan from the subscription page", + "Paste the API Key above as ASTRON_API_KEY", + "For Coding Plan, set ASTRON_BASE_URL to https://maas-coding-api.cn-huabei-1.xf-yun.com/v2" + ] +} diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 9480415118..e577e9ed00 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -37,6 +37,8 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a | [GCP Vertex AI](https://cloud.google.com/vertex-ai) | Google Cloud's Vertex AI platform, supporting Gemini and Claude models. **Credentials must be [configured in advance](https://cloud.google.com/vertex-ai/docs/authentication).** Filters for allowed models by organization policy (if configured). | `GCP_PROJECT_ID`, `GCP_LOCATION` and optionally `GCP_MAX_RATE_LIMIT_RETRIES` (5), `GCP_MAX_OVERLOADED_RETRIES` (5), `GCP_INITIAL_RETRY_INTERVAL_MS` (5000), `GCP_BACKOFF_MULTIPLIER` (2.0), `GCP_MAX_RETRY_INTERVAL_MS` (320_000). | | [GitHub Copilot](https://docs.github.com/en/copilot/using-github-copilot/ai-models) | Access to AI models from OpenAI, Anthropic, Google, and other providers through GitHub's Copilot infrastructure. **GitHub account with Copilot access required.** | No manual key. Uses [device flow authentication](#github-copilot-authentication) for both CLI and Desktop. | | [Groq](https://groq.com/) | High-performance inference hardware and tools for LLMs. | `GROQ_API_KEY` | +| [iFlytek Spark](https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html) | iFlytek Spark (讯飞星火) models (4.0Ultra, generalv3.5, max-32k) via the OpenAI-compatible HTTP API. Best for chat: Spark needs `tool_calls_switch=true` (not injectable here) to return OpenAI-style tool calls. | `SPARK_API_PASSWORD` | +| [iFlytek Astron MaaS](https://maas.xfyun.cn/) | iFlytek Astron MaaS (讯飞星辰) hosting Spark X2, DeepSeek, GLM, Kimi, MiniMax, Qwen, and Astron coding models via an OpenAI-compatible API. Set `ASTRON_BASE_URL` to switch between the Token Plan and Coding Plan endpoints. | `ASTRON_API_KEY`, `ASTRON_BASE_URL` (optional) | | [LiteLLM](https://docs.litellm.ai/docs/) | LiteLLM proxy supporting multiple models with automatic prompt caching and unified API access. | `LITELLM_HOST`, `LITELLM_BASE_PATH` (optional), `LITELLM_API_KEY` (optional), `LITELLM_CUSTOM_HEADERS` (optional), `LITELLM_TIMEOUT` (optional) | | [LM Studio](https://lmstudio.ai/) | Run local models with LM Studio's OpenAI-compatible server. **Because this provider runs locally, you must first [download a model](#local-llms).** | None required. Connects to local server at `localhost:1234` by default. | | [Mistral AI](https://mistral.ai/) | Provides access to Mistral models including general-purpose models, specialized coding models (Codestral), and multimodal models (Pixtral). | `MISTRAL_API_KEY` | From 20966fd05794a1049c8194c813b0a86080b4f7c6 Mon Sep 17 00:00:00 2001 From: Sean Murphy Date: Tue, 30 Jun 2026 14:15:52 -0700 Subject: [PATCH 011/139] feat: add --edit session flag to edit conversation before forking (#9799) Signed-off-by: Sean Murphy Co-authored-by: Douwe M Osinga --- crates/goose-cli/src/cli.rs | 41 +++- crates/goose-cli/src/session/editor.rs | 180 ++++++++++++------ crates/goose-cli/src/session/mod.rs | 2 +- .../docs/guides/goose-cli-commands.md | 7 + .../guides/sessions/in-session-actions.md | 23 ++- 5 files changed, 189 insertions(+), 64 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 1b0a2ffa22..0ebf44a149 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -872,6 +872,15 @@ enum Command { )] fork: bool, + /// Open the session's conversation in $EDITOR before starting + #[arg( + long, + requires = "resume", + help = "Edit the session conversation in $EDITOR before starting", + long_help = "Open the session's conversation in your editor ($VISUAL / $EDITOR / vi) for modification before resuming. When combined with --fork, creates a new session from the edited result." + )] + edit: bool, + /// Show message history when resuming #[arg( long, @@ -1464,6 +1473,7 @@ async fn handle_interactive_session( identifier: Option, resume: bool, fork: bool, + edit: bool, history: bool, session_opts: SessionOptions, extension_opts: ExtensionOptions, @@ -1503,12 +1513,31 @@ async fn handle_interactive_session( let goose_mode = Config::global().get_goose_mode().unwrap_or_default(); let mut session_id = get_or_create_session_id(identifier, resume, false, goose_mode).await?; - if fork { - if let Some(id) = session_id { + if edit || fork { + if let Some(ref id) = session_id { let session_manager = SessionManager::instance(); - let original = session_manager.get_session(&id, false).await?; - let copied = session_manager.copy_session(&id, original.name).await?; - session_id = Some(copied.id); + let original = session_manager.get_session(id, true).await?; + + let target_id = if fork { + let copied = session_manager + .copy_session(id, original.name.clone()) + .await?; + let copied_id = copied.id.clone(); + session_id = Some(copied.id); + copied_id + } else { + id.clone() + }; + + if edit { + let conversation = original + .conversation + .ok_or_else(|| anyhow::anyhow!("session has no messages to edit"))?; + let edited = crate::session::editor::edit_conversation(&conversation)?; + session_manager + .replace_conversation(&target_id, &edited) + .await?; + } } } @@ -2080,6 +2109,7 @@ pub async fn cli() -> anyhow::Result<()> { identifier, resume, fork, + edit, history, session_opts, extension_opts, @@ -2088,6 +2118,7 @@ pub async fn cli() -> anyhow::Result<()> { identifier, resume, fork, + edit, history, session_opts, extension_opts, diff --git a/crates/goose-cli/src/session/editor.rs b/crates/goose-cli/src/session/editor.rs index 1e42d6069a..6ed3e297cc 100644 --- a/crates/goose-cli/src/session/editor.rs +++ b/crates/goose-cli/src/session/editor.rs @@ -1,7 +1,10 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use goose::config::Config; +use goose::conversation::message::Message; +use goose::conversation::Conversation; use std::fs; use std::io::Read; +use std::io::Write; use std::path::PathBuf; use std::process::Command; use tempfile::Builder; @@ -21,9 +24,6 @@ pub fn resolve_editor_command() -> Option { ) } -/// Inner resolution logic, separated for testability. -/// Checks sources in priority order: config, VISUAL, EDITOR. -/// Skips empty strings at each level. fn resolve_editor_from_sources( config_editor: Option<&str>, visual: Option<&str>, @@ -37,6 +37,57 @@ fn resolve_editor_from_sources( None } +/// Resolve the editor command, falling back to vi (or notepad on Windows). +pub fn resolve_editor_or_default() -> String { + let config = Config::global(); + let config_editor = config.get_goose_prompt_editor().ok().flatten(); + let visual = std::env::var("VISUAL").ok(); + let editor_env = std::env::var("EDITOR").ok(); + resolve_editor_or_default_from_sources( + config_editor.as_deref(), + visual.as_deref(), + editor_env.as_deref(), + ) +} + +fn resolve_editor_default() -> String { + if cfg!(windows) { + "notepad".to_string() + } else { + "vi".to_string() + } +} + +fn resolve_editor_or_default_from_sources( + config_editor: Option<&str>, + visual: Option<&str>, + editor_env: Option<&str>, +) -> String { + resolve_editor_from_sources(config_editor, visual, editor_env) + .unwrap_or_else(resolve_editor_default) +} + +/// Open a YAML temp file with the user's editor to edit a conversation. +/// Returns the edited conversation, or an error if the editor failed or YAML was invalid. +pub fn edit_conversation(conversation: &Conversation) -> Result { + let yaml = serde_yaml::to_string(conversation.messages())?; + + let mut tmp = NamedTempFile::with_suffix(".yaml")?; + tmp.write_all(yaml.as_bytes())?; + tmp.flush()?; + + let editor = resolve_editor_or_default(); + let path = tmp.path().to_path_buf(); + + launch_editor(&editor, &path).with_context(|| format!("failed to launch editor '{editor}'"))?; + + let edited = std::fs::read_to_string(&path)?; + let messages: Vec = + serde_yaml::from_str(&edited).context("invalid YAML — session unchanged")?; + + Ok(Conversation::new_unvalidated(messages)) +} + /// Build the markdown template content for the editor prompt. fn build_template(messages: &[&str], prefill: Option<&str>) -> String { let mut content = String::from("# Goose Prompt Editor\n\n"); @@ -84,21 +135,36 @@ impl SymlinkCleanup { impl Drop for SymlinkCleanup { fn drop(&mut self) { - // Always try to clean up the symlink, ignoring any errors let _ = std::fs::remove_file(&self.symlink_path); } } +/// Split an editor command into program and arguments. +/// +/// Uses shell-word splitting only when the command contains quotes, so values like +/// `"/Applications/Sublime Text.app/.../subl" -w` work. Unquoted commands are split on +/// whitespace to avoid shlex stripping backslashes from Windows paths like +/// `C:\Windows\System32\notepad.exe`. +fn split_editor_command(editor_cmd: &str) -> Result> { + if editor_cmd.contains(['"', '\'']) { + shlex::split(editor_cmd).ok_or_else(|| { + anyhow::anyhow!("Invalid editor command: unmatched quotes in '{editor_cmd}'") + }) + } else { + Ok(editor_cmd.split_whitespace().map(String::from).collect()) + } +} + /// Launch editor and wait for completion fn launch_editor(editor_cmd: &str, file_path: &PathBuf) -> Result<()> { use std::process::Stdio; - let parts: Vec<&str> = editor_cmd.split_whitespace().collect(); + let parts = split_editor_command(editor_cmd)?; if parts.is_empty() { return Err(anyhow::anyhow!("Empty editor command")); } - let mut cmd = Command::new(parts[0]); + let mut cmd = Command::new(&parts[0]); if let Ok(cwd) = std::env::current_dir() { cmd.current_dir(cwd); } @@ -414,54 +480,64 @@ with multiple lines. ); } - // --- resolve_editor_from_sources tests --- - #[test] - fn test_resolve_editor_returns_config_when_set() { - let result = resolve_editor_from_sources(Some("code"), Some("vim"), Some("nano")); - assert_eq!(result.as_deref(), Some("code")); + fn test_resolve_editor_resolution_priority() { + assert_eq!( + resolve_editor_from_sources(Some("config-val"), Some("visual-val"), Some("editor-val")), + Some("config-val".to_string()) + ); + + assert_eq!( + resolve_editor_from_sources(Some(""), Some("visual-val"), Some("editor-val")), + Some("visual-val".to_string()) + ); + + assert_eq!( + resolve_editor_from_sources(None, Some(""), Some("editor-val")), + Some("editor-val".to_string()) + ); + + assert_eq!(resolve_editor_from_sources(None, None, None), None); + assert_eq!( + resolve_editor_from_sources(Some(""), Some(""), Some("")), + None + ); + + let default_val = resolve_editor_default(); + assert_eq!( + resolve_editor_or_default_from_sources(None, None, None), + default_val + ); + assert_eq!( + resolve_editor_or_default_from_sources(Some(""), Some(""), Some("")), + default_val + ); } #[test] - fn test_resolve_editor_falls_back_to_visual() { - let result = resolve_editor_from_sources(None, Some("vim"), Some("nano")); - assert_eq!(result.as_deref(), Some("vim")); - } + fn test_split_editor_command() { + assert_eq!( + split_editor_command("code --wait").unwrap(), + vec!["code", "--wait"] + ); - #[test] - fn test_resolve_editor_falls_back_to_editor_env() { - let result = resolve_editor_from_sources(None, None, Some("nano")); - assert_eq!(result.as_deref(), Some("nano")); - } + assert_eq!( + split_editor_command( + r#""/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl" -w"# + ) + .unwrap(), + vec![ + "/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl", + "-w" + ] + ); - #[test] - fn test_resolve_editor_returns_none_when_nothing_set() { - let result = resolve_editor_from_sources(None, None, None); - assert_eq!(result, None); - } + assert_eq!( + split_editor_command(r"C:\Windows\System32\notepad.exe").unwrap(), + vec![r"C:\Windows\System32\notepad.exe"] + ); - #[test] - fn test_resolve_editor_skips_empty_config() { - let result = resolve_editor_from_sources(Some(""), Some("vim"), None); - assert_eq!(result.as_deref(), Some("vim")); - } - - #[test] - fn test_resolve_editor_skips_empty_visual() { - let result = resolve_editor_from_sources(None, Some(""), Some("nano")); - assert_eq!(result.as_deref(), Some("nano")); - } - - #[test] - fn test_resolve_editor_skips_all_empty() { - let result = resolve_editor_from_sources(Some(""), Some(""), Some("")); - assert_eq!(result, None); - } - - #[test] - fn test_resolve_editor_skips_empty_config_and_visual() { - let result = resolve_editor_from_sources(Some(""), Some(""), Some("emacs")); - assert_eq!(result.as_deref(), Some("emacs")); + assert!(split_editor_command(r#"code --wait "unclosed"#).is_err()); } // --- build_template edge case tests --- @@ -469,9 +545,7 @@ with multiple lines. #[test] fn test_build_template_empty_prefill_string() { let content = build_template(&["## User: Hello"], Some("")); - // Empty prefill should not appear in content assert!(content.contains("# Your prompt:\n\n#")); - // Should go directly to conversation context assert!(content.contains("# Recent conversation for context")); } @@ -498,11 +572,8 @@ with multiple lines. assert!(prefill_pos < context_pos); } - // --- extract_user_input with prefilled content tests --- - #[test] fn test_extract_user_input_with_prefill_kept() { - // Simulates a user who opened the editor with prefill and kept it unchanged let content = build_template(&["## User: Hello"], Some("fix the login bug")); let result = extract_user_input(&content); assert_eq!(result, "fix the login bug"); @@ -510,7 +581,6 @@ with multiple lines. #[test] fn test_extract_user_input_with_prefill_edited() { - // Simulates a user who edited the prefill text let mut content = build_template(&["## User: Hello"], Some("fix the login bug")); content = content.replace( "fix the login bug", @@ -522,7 +592,6 @@ with multiple lines. #[test] fn test_extract_user_input_prefill_replaced() { - // Simulates a user who deleted the prefill and wrote something new let mut content = build_template(&["## User: Hello"], Some("fix the login bug")); content = content.replace("fix the login bug\n", "completely different prompt\n"); let result = extract_user_input(&content); @@ -531,7 +600,6 @@ with multiple lines. #[test] fn test_extract_user_input_prefill_cleared() { - // Simulates a user who deleted the prefill and left nothing let mut content = build_template(&["## User: Hello"], Some("fix the login bug")); content = content.replace("fix the login bug\n", ""); let result = extract_user_input(&content); diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index d3565f9024..c04641df07 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1,6 +1,6 @@ mod builder; mod completion; -mod editor; +pub mod editor; mod elicitation; mod export; mod input; diff --git a/documentation/docs/guides/goose-cli-commands.md b/documentation/docs/guides/goose-cli-commands.md index 09e1d09f2c..af3d1babd6 100644 --- a/documentation/docs/guides/goose-cli-commands.md +++ b/documentation/docs/guides/goose-cli-commands.md @@ -206,6 +206,7 @@ Start or resume interactive chat sessions. - **`-n, --name `**: Give the session a name - **`--path `**: Legacy parameter for specifying session by file path - **`-r, --resume`**: Resume a previous session +- **`--edit`**: Open the session's conversation in your editor (`$VISUAL` / `$EDITOR` / `vi`) as YAML. Edit, trim, or rewrite messages, then save and close to continue the session with the edited conversation. Must be used with `--resume`. Can be combined with `--fork` to create a new session from the edited result. - **`--fork`**: Create a new duplicate session with copied history. Must be used with `--resume`. Provide `--name` or `--session-id` to fork a specific session. Otherwise, forks the most recent session. - **`--history`**: Show previous messages when resuming a session - **`--container `**: Run extensions inside a [Docker container](/docs/tutorials/goose-in-docker#running-extensions-in-docker-containers). @@ -235,6 +236,12 @@ goose session --resume --fork --name my-project # Fork the most recent session and show message history goose session --resume --fork --history +# Edit a session's conversation in your editor +goose session --resume --session-id 20251108_2 --edit + +# Edit and fork — create a new session from the edited conversation +goose session --resume --session-id 20251108_2 --fork --edit --history + # Start with extensions goose session --with-extension "npx -y @modelcontextprotocol/server-memory" goose session --with-builtin developer diff --git a/documentation/docs/guides/sessions/in-session-actions.md b/documentation/docs/guides/sessions/in-session-actions.md index ea5782c62c..c8e4f56a3e 100644 --- a/documentation/docs/guides/sessions/in-session-actions.md +++ b/documentation/docs/guides/sessions/in-session-actions.md @@ -59,7 +59,18 @@ Editing in place is useful when: - Message editing options are not available in the goose CLI. + Use the `--edit` flag with `goose session` to open the session's conversation in your editor as YAML: + + ```bash + goose session --resume --edit + ``` + + This opens `$VISUAL` / `$EDITOR` / `vi` with the conversation serialized as YAML. After editing and saving, goose continues the session from the edited conversation. + + :::warning Deleted Context + With `--edit`, subsequent conversation history is permanently deleted from the session and removed from goose's context. Use this option only if you don't need goose to remember the context that follows the edited message. + ::: + @@ -86,7 +97,15 @@ Forking sessions is useful to: ::: - Message editing is not available in the goose CLI, but you can [duplicate entire sessions](/docs/guides/sessions/session-management#duplicate-sessions) using the `--fork` flag. + Use the `--edit` and `--fork` flags together to edit a session's conversation and create a new session from the result: + + ```bash + goose session --resume --fork --edit + ``` + + This opens `$VISUAL` / `$EDITOR` / `vi` with the conversation serialized as YAML. After editing and saving, goose creates a new session with the edited conversation and resumes from there. The original session remains unchanged. + + You can also [duplicate entire sessions](/docs/guides/sessions/session-management#duplicate-sessions) using `--fork` without `--edit`. From 159fb4d53e40862316aafe489a1c9e2a06f384a6 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 30 Jun 2026 15:29:53 -0700 Subject: [PATCH 012/139] (attempt to) fix disk space errors in linux release builds (#10024) --- .github/workflows/bundle-desktop-linux.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml index d8e76c5525..18ba931a6c 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -148,6 +148,11 @@ jobs: chmod +x ui/desktop/src/bin/goosed ls -la ui/desktop/src/bin/ + - name: Free Rust build artifacts before packaging + run: | + source ./bin/activate-hermit + cargo clean + - name: Cache pnpm dependencies uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: From d1cc1439ecc7a13bc6bb0c92ea226de5d981310b Mon Sep 17 00:00:00 2001 From: Jasper Date: Wed, 1 Jul 2026 00:40:04 +0200 Subject: [PATCH 013/139] Migrate local inference model management to ACP (#10124) --- crates/goose-sdk-types/src/custom_requests.rs | 346 ++++++ crates/goose-server/src/openapi.rs | 27 +- .../src/routes/local_inference.rs | 885 ---------------- crates/goose-server/src/routes/mod.rs | 11 +- crates/goose-server/src/state.rs | 28 - crates/goose/acp-meta.json | 50 + crates/goose/acp-schema.json | 985 ++++++++++++++++++ crates/goose/src/acp/server.rs | 1 + .../goose/src/acp/server/custom_dispatch.rs | 81 ++ .../goose/src/acp/server/local_inference.rs | 183 ++++ crates/goose/src/providers/local_inference.rs | 10 +- .../providers/local_inference/management.rs | 837 +++++++++++++++ ui/desktop/openapi.json | 940 ----------------- ui/desktop/src/acp/local-inference.ts | 99 ++ ui/desktop/src/api/index.ts | 4 +- ui/desktop/src/api/sdk.gen.ts | 38 +- ui/desktop/src/api/types.gen.ts | 407 -------- .../onboarding/LocalModelPicker.tsx | 83 +- .../localInference/HuggingFaceModelSearch.tsx | 160 ++- .../localInference/LocalInferenceSettings.tsx | 62 +- .../localInference/ModelSettingsPanel.tsx | 141 +-- .../settings/models/modelInterface.ts | 6 +- ui/sdk/src/generated/client.gen.ts | 140 +++ ui/sdk/src/generated/index.ts | 52 +- ui/sdk/src/generated/types.gen.ts | 203 +++- ui/sdk/src/generated/zod.gen.ts | 317 +++++- 26 files changed, 3517 insertions(+), 2579 deletions(-) delete mode 100644 crates/goose-server/src/routes/local_inference.rs create mode 100644 crates/goose/src/acp/server/local_inference.rs create mode 100644 crates/goose/src/providers/local_inference/management.rs create mode 100644 ui/desktop/src/acp/local-inference.ts diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index 8bd0ad4683..7243fd1f8b 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -1771,6 +1771,352 @@ pub struct ProviderInventoryEntryDto { pub model_selection_hint: Option, } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum LocalInferenceToolCallingMode { + #[default] + Auto, + ForceNative, + ForceEmulated, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LocalInferenceChatTemplate { + #[default] + Embedded, + Builtin { + name: String, + }, + CustomInline { + template: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all_fields = "camelCase")] +pub enum LocalInferenceSamplingConfig { + Greedy, + Temperature { + temperature: f32, + top_k: i32, + top_p: f32, + min_p: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + seed: Option, + }, + MirostatV2 { + tau: f32, + eta: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + seed: Option, + }, +} + +impl Default for LocalInferenceSamplingConfig { + fn default() -> Self { + Self::Temperature { + temperature: 0.8, + top_k: 40, + top_p: 0.95, + min_p: 0.05, + seed: None, + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelSettingsDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft_model: Option, + #[serde(default)] + pub sampling: LocalInferenceSamplingConfig, + pub repeat_penalty: f32, + pub repeat_last_n: i32, + pub frequency_penalty: f32, + pub presence_penalty: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub n_batch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub n_gpu_layers: Option, + pub use_mlock: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flash_attention: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub n_threads: Option, + #[serde(default)] + pub tool_calling: LocalInferenceToolCallingMode, + #[serde(default)] + pub chat_template: LocalInferenceChatTemplate, + pub enable_thinking: bool, + pub vision_capable: bool, + pub image_token_estimate: usize, + pub mmproj_size_bytes: u64, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum LocalInferenceDownloadState { + #[default] + NotDownloaded, + Downloading, + Downloaded, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelDownloadStatusDto { + pub state: LocalInferenceDownloadState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub progress_percent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bytes_downloaded: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed_bps: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceDownloadProgressDto { + pub model_id: String, + pub status: String, + pub bytes_downloaded: u64, + pub total_bytes: u64, + pub progress_percent: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed_bps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eta_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + pub task_exited: bool, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelDto { + pub id: String, + pub repo_id: String, + pub filename: String, + pub quantization: String, + pub size_bytes: u64, + pub status: LocalInferenceModelDownloadStatusDto, + pub recommended: bool, + pub settings: LocalInferenceModelSettingsDto, + pub vision_capable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mmproj_status: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceHfModelVariantDto { + pub variant_id: String, + pub label: String, + pub backend_id: String, + pub format: String, + pub model_id: String, + pub download_id: String, + pub size_bytes: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub download_url: Option, + pub description: String, + pub quality_rank: u8, + pub sharded: bool, + pub supported: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unsupported_reason: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceHfGgufFileDto { + pub filename: String, + pub size_bytes: u64, + pub quantization: String, + pub download_url: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceHfModelInfoDto { + pub repo_id: String, + pub author: String, + pub model_name: String, + pub downloads: u64, + #[serde(default)] + pub gguf_files: Vec, + #[serde(default)] + pub variants: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/local-inference/models/list", + response = LocalInferenceModelsListResponse +)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelsListRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelsListResponse { + pub models: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/local-inference/models/download", + response = LocalInferenceModelDownloadResponse +)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelDownloadRequest { + pub spec: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant_id: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelDownloadResponse { + pub model_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/local-inference/models/download/progress", + response = LocalInferenceModelDownloadProgressResponse +)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelDownloadProgressRequest { + pub model_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelDownloadProgressResponse { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub progress: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/local-inference/models/download/cancel", + response = EmptyResponse +)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelDownloadCancelRequest { + pub model_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/local-inference/models/delete", + response = EmptyResponse +)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelDeleteRequest { + pub model_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/local-inference/models/settings/read", + response = LocalInferenceModelSettingsReadResponse +)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelSettingsReadRequest { + pub model_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelSettingsReadResponse { + pub settings: LocalInferenceModelSettingsDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/local-inference/models/settings/update", + response = LocalInferenceModelSettingsUpdateResponse +)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelSettingsUpdateRequest { + pub model_id: String, + pub settings: LocalInferenceModelSettingsDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceModelSettingsUpdateResponse { + pub settings: LocalInferenceModelSettingsDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/local-inference/huggingface/search", + response = LocalInferenceHuggingFaceSearchResponse +)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceHuggingFaceSearchRequest { + pub query: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceHuggingFaceSearchResponse { + pub models: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/local-inference/huggingface/repo/variants", + response = LocalInferenceHuggingFaceRepoVariantsResponse +)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceHuggingFaceRepoVariantsRequest { + pub repo_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceHuggingFaceRepoVariantsResponse { + pub variants: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recommended_index: Option, + pub available_memory_bytes: u64, + pub downloaded_quants: Vec, + pub downloaded_variants: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/local-inference/chat-templates/builtin/list", + response = LocalInferenceBuiltinChatTemplatesListResponse +)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceBuiltinChatTemplatesListRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct LocalInferenceBuiltinChatTemplatesListResponse { + pub templates: Vec, +} + /// Empty success response for operations that return no data. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct EmptyResponse {} diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 5f0d34c76f..ec679a7dc9 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -656,33 +656,8 @@ pub struct ApiDoc; super::routes::dictation::get_download_progress, super::routes::dictation::cancel_download, super::routes::dictation::delete_model, - super::routes::local_inference::list_local_models, - super::routes::local_inference::sync_featured_models, - super::routes::local_inference::search_hf_models, - super::routes::local_inference::list_builtin_chat_templates, - super::routes::local_inference::get_repo_files, - super::routes::local_inference::download_hf_model, - super::routes::local_inference::get_local_model_download_progress, - super::routes::local_inference::cancel_local_model_download, - super::routes::local_inference::delete_local_model, - super::routes::local_inference::get_model_settings, - super::routes::local_inference::update_model_settings, ), - components(schemas( - super::routes::dictation::WhisperModelResponse, - super::routes::local_inference::LocalModelResponse, - super::routes::local_inference::ModelDownloadStatus, - super::routes::local_inference::DownloadModelRequest, - goose::providers::local_inference::hf_models::HfModelInfo, - goose::providers::local_inference::hf_models::HfModelVariant, - goose::providers::local_inference::hf_models::HfGgufFile, - goose::providers::local_inference::hf_models::HfQuantVariant, - super::routes::local_inference::RepoVariantsResponse, - goose::providers::local_inference::local_model_registry::ModelSettings, - goose::providers::local_inference::local_model_registry::ChatTemplate, - goose::providers::local_inference::local_model_registry::SamplingConfig, - goose::providers::local_inference::local_model_registry::ToolCallingMode, - )) + components(schemas(super::routes::dictation::WhisperModelResponse,)) )] pub struct LocalInferenceApiDoc; diff --git a/crates/goose-server/src/routes/local_inference.rs b/crates/goose-server/src/routes/local_inference.rs deleted file mode 100644 index 39e84bb463..0000000000 --- a/crates/goose-server/src/routes/local_inference.rs +++ /dev/null @@ -1,885 +0,0 @@ -use std::path::PathBuf; - -use crate::routes::errors::ErrorResponse; -use crate::state::AppState; -use axum::{ - extract::{Path, Query}, - http::StatusCode, - routing::{delete, get, post}, - Json, Router, -}; -use futures::future::join_all; -use goose::config::paths::Paths; -use goose::download_manager::{get_download_manager, DownloadProgress, DownloadStatus}; -use goose::providers::huggingface_auth; -use goose::providers::local_inference::hf_models::{self, HfModelInfo, HfModelVariant}; -use goose::providers::local_inference::{ - available_inference_memory_bytes, builtin_chat_template_names, - hf_models::{ - register_resolved_model, resolve_local_model_selection, resolve_local_model_spec, - resolve_model_spec, HfGgufFile, - }, - local_model_registry::{ - default_settings_for_model, featured_mmproj_spec, get_registry, model_id_from_repo, - LocalModelEntry, LocalModelStorage, ModelDownloadStatus as RegistryDownloadStatus, - ModelSettings, FEATURED_MODELS, - }, - recommend_local_model, -}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tracing::debug; -use utoipa::ToSchema; - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "state")] -pub enum ModelDownloadStatus { - NotDownloaded, - Downloading { - progress_percent: f32, - bytes_downloaded: u64, - total_bytes: u64, - speed_bps: Option, - }, - Downloaded, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct LocalModelResponse { - pub id: String, - pub repo_id: String, - pub filename: String, - pub quantization: String, - pub size_bytes: u64, - pub status: ModelDownloadStatus, - pub recommended: bool, - pub settings: ModelSettings, - pub vision_capable: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub mmproj_status: Option, -} - -async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { - let mut mmproj_downloads_needed: Vec<(String, String, PathBuf)> = Vec::new(); - - struct PendingResolve { - spec: &'static str, - repo_id: String, - quantization: String, - model_id: String, - } - let mut to_resolve = Vec::new(); - - for featured in FEATURED_MODELS { - let (repo_id, quantization) = match hf_models::parse_model_spec(featured.spec) { - Ok(parts) => parts, - Err(_) => continue, - }; - - let model_id = model_id_from_repo(&repo_id, &quantization); - - { - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - if let Some(existing) = registry.get_model(&model_id) { - let needs_backfill = existing.mmproj_path.is_none() && featured.mmproj.is_some(); - let needs_download = existing.is_downloaded() - && featured.mmproj.is_some() - && !existing.mmproj_path.as_ref().is_some_and(|p| p.exists()); - - if needs_download { - if let Some(mmproj) = featured.mmproj.as_ref() { - let path = mmproj.local_path(); - let url = format!( - "https://huggingface.co/{}/resolve/main/{}", - mmproj.repo, mmproj.filename - ); - mmproj_downloads_needed.push((model_id.clone(), url, path)); - } - } - - if !needs_backfill { - continue; - } - // Fall through to resolve for backfill - } - } - - to_resolve.push(PendingResolve { - spec: featured.spec, - repo_id, - quantization, - model_id, - }); - } - - let resolved: Vec<(PendingResolve, HfGgufFile)> = - join_all(to_resolve.into_iter().map(|pending| async move { - let hf_file = match resolve_model_spec(pending.spec).await { - Ok((_repo, file)) => file, - Err(_) => { - let filename = format!( - "{}-{}.gguf", - pending.repo_id.split('/').next_back().unwrap_or("model"), - pending.quantization - ); - HfGgufFile { - filename: filename.clone(), - size_bytes: 0, - quantization: pending.quantization.to_string(), - download_url: format!( - "https://huggingface.co/{}/resolve/main/{}", - pending.repo_id, filename - ), - } - } - }; - (pending, hf_file) - })) - .await; - - let entries_to_add: Vec = resolved - .into_iter() - .map(|(pending, hf_file)| { - let local_path = Paths::in_data_dir("models").join(&hf_file.filename); - let settings = default_settings_for_model(&pending.model_id); - LocalModelEntry { - id: pending.model_id, - repo_id: pending.repo_id, - filename: hf_file.filename, - quantization: pending.quantization, - local_path, - source_url: hf_file.download_url, - backend_id: settings.backend_id.clone(), - storage: LocalModelStorage::GooseManaged, - settings, - size_bytes: hf_file.size_bytes, - mmproj_path: None, - mmproj_source_url: None, - mmproj_size_bytes: 0, - mmproj_checked: false, - shard_files: vec![], - } - }) - .collect(); - - { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - if !entries_to_add.is_empty() { - registry.sync_with_featured(entries_to_add); - } - - // Backfill mmproj data for all registry models and collect any - // needed mmproj downloads for models already on disk. - for model in registry.list_models_mut() { - model.enrich_with_featured_mmproj(); - if model.is_downloaded() { - if let Some(mmproj) = featured_mmproj_spec(&model.id) { - let path = mmproj.local_path(); - if !path.exists() { - let url = format!( - "https://huggingface.co/{}/resolve/main/{}", - mmproj.repo, mmproj.filename - ); - mmproj_downloads_needed.push((model.id.clone(), url, path)); - } - } - } - } - let _ = registry.save(); - } - - // Auto-download mmproj files for models that are already downloaded. - // Deduplicate by path since multiple quants share one mmproj file. - let dm = get_download_manager(); - let hf_token = huggingface_auth::resolve_token_async().await.ok().flatten(); - let mut started_paths = std::collections::HashSet::new(); - for (model_id, url, path) in mmproj_downloads_needed { - if !path.exists() && started_paths.insert(path.clone()) { - let download_id = format!("{}-mmproj", model_id); - let dominated_by_active = dm - .get_progress(&download_id) - .is_some_and(|p| p.status == goose::download_manager::DownloadStatus::Downloading); - if !dominated_by_active { - tracing::info!(model_id = %model_id, "Auto-downloading vision encoder for existing model"); - if let Err(e) = dm - .download_model_with_bearer_token( - download_id, - url, - path, - hf_token.clone(), - None, - ) - .await - { - tracing::warn!(model_id = %model_id, error = %e, "Failed to start mmproj download"); - } - } - } - } - - Ok(()) -} - -#[utoipa::path( - post, - path = "/local-inference/sync-featured", - responses( - (status = 200, description = "Featured models synced to registry") - ) -)] -pub async fn sync_featured_models() -> Result { - ensure_featured_models_in_registry().await?; - Ok(StatusCode::OK) -} - -#[utoipa::path( - get, - path = "/local-inference/models", - responses( - (status = 200, description = "List of available local LLM models", body = Vec) - ) -)] -pub async fn list_local_models( - axum::extract::State(state): axum::extract::State>, -) -> Result>, ErrorResponse> { - let runtime = state.get_inference_runtime()?; - let recommended_id = recommend_local_model(&runtime); - - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - let mut models: Vec = Vec::new(); - - for entry in registry.list_models() { - let goose_status = entry.download_status(); - - let status = match goose_status { - RegistryDownloadStatus::NotDownloaded => ModelDownloadStatus::NotDownloaded, - RegistryDownloadStatus::Downloading { - progress_percent, - bytes_downloaded, - total_bytes, - speed_bps, - } => ModelDownloadStatus::Downloading { - progress_percent, - bytes_downloaded, - total_bytes, - speed_bps: Some(speed_bps), - }, - RegistryDownloadStatus::Downloaded => ModelDownloadStatus::Downloaded, - }; - - let size_bytes = entry.file_size(); - - let vision_capable = entry.settings.vision_capable; - let mmproj_status = if vision_capable { - let ms = entry.mmproj_download_status(); - Some(match ms { - RegistryDownloadStatus::NotDownloaded => ModelDownloadStatus::NotDownloaded, - RegistryDownloadStatus::Downloading { - progress_percent, - bytes_downloaded, - total_bytes, - speed_bps, - } => ModelDownloadStatus::Downloading { - progress_percent, - bytes_downloaded, - total_bytes, - speed_bps: Some(speed_bps), - }, - RegistryDownloadStatus::Downloaded => ModelDownloadStatus::Downloaded, - }) - } else { - None - }; - - models.push(LocalModelResponse { - id: entry.id.clone(), - repo_id: entry.repo_id.clone(), - filename: entry.filename.clone(), - quantization: entry.quantization.clone(), - size_bytes, - status, - recommended: recommended_id == entry.id, - settings: entry.settings.clone(), - vision_capable, - mmproj_status, - }); - } - - models.sort_by(|a, b| { - let a_downloaded = matches!(a.status, ModelDownloadStatus::Downloaded); - let b_downloaded = matches!(b.status, ModelDownloadStatus::Downloaded); - match (b_downloaded, a_downloaded) { - (true, false) => std::cmp::Ordering::Greater, - (false, true) => std::cmp::Ordering::Less, - _ => a.id.cmp(&b.id), - } - }); - - Ok(Json(models)) -} - -#[derive(Debug, Deserialize)] -pub struct SearchQuery { - pub q: String, - pub limit: Option, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct RepoVariantsResponse { - pub variants: Vec, - pub recommended_index: Option, - pub available_memory_bytes: u64, - pub downloaded_quants: Vec, - pub downloaded_variants: Vec, -} - -#[utoipa::path( - get, - path = "/local-inference/search", - params( - ("q" = String, Query, description = "Search query"), - ("limit" = Option, Query, description = "Max results") - ), - responses( - (status = 200, description = "Search results", body = Vec), - (status = 500, description = "Search failed") - ) -)] -pub async fn search_hf_models( - Query(params): Query, -) -> Result>, ErrorResponse> { - let limit = params.limit.unwrap_or(20).min(50); - let results = hf_models::search_local_models(¶ms.q, limit) - .await - .map_err(|e| ErrorResponse::internal(format!("Search failed: {}", e)))?; - Ok(Json(results)) -} - -#[utoipa::path( - get, - path = "/local-inference/repo/{author}/{repo}/files", - responses( - (status = 200, description = "GGUF files in the repo", body = RepoVariantsResponse) - ) -)] -pub async fn get_repo_files( - axum::extract::State(state): axum::extract::State>, - Path((author, repo)): Path<(String, String)>, -) -> Result, ErrorResponse> { - let repo_id = format!("{}/{}", author, repo); - let variants = hf_models::get_repo_local_variants(&repo_id) - .await - .map_err(|e| ErrorResponse::internal(format!("Failed to fetch repo files: {}", e)))?; - - let runtime = state.get_inference_runtime()?; - let available_memory = available_inference_memory_bytes(&runtime); - let gguf_variants: Vec<_> = variants - .iter() - .filter(|variant| variant.backend_id == "llamacpp") - .map( - |variant| goose::providers::local_inference::hf_models::HfQuantVariant { - quantization: variant.variant_id.clone(), - size_bytes: variant.size_bytes, - filename: variant.filename.clone().unwrap_or_default(), - download_url: variant.download_url.clone().unwrap_or_default(), - description: "", - quality_rank: variant.quality_rank, - sharded: variant.sharded, - }, - ) - .collect(); - let recommended_index = hf_models::recommend_variant(&gguf_variants, available_memory); - - let (downloaded_quants, downloaded_variants) = { - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - let models: Vec<_> = registry - .list_models() - .iter() - .filter(|m| m.repo_id == repo_id && m.is_downloaded()) - .collect(); - ( - models.iter().map(|m| m.quantization.clone()).collect(), - models.iter().map(|m| m.id.clone()).collect(), - ) - }; - - Ok(Json(RepoVariantsResponse { - variants, - recommended_index, - available_memory_bytes: available_memory, - downloaded_quants, - downloaded_variants, - })) -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct DownloadModelRequest { - /// Model spec/download id like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" or "google/gemma-4-31B-it" - pub spec: String, - /// Optional backend id for callers selecting a concrete variant row. - pub backend_id: Option, - /// Optional backend-specific variant id, such as a GGUF quantization or MLX dtype. - pub variant_id: Option, -} - -#[derive(Clone)] -struct LocalModelSelection { - repo_id: String, - backend_id: String, - variant_id: Option, -} - -fn explicit_model_selection( - req: &DownloadModelRequest, -) -> anyhow::Result> { - if let Some(backend_id) = req.backend_id.as_deref() { - let (repo_id, parsed_variant_id) = hf_models::parse_model_spec(&req.spec) - .map(|(repo_id, quantization)| (repo_id, Some(quantization))) - .unwrap_or_else(|_| (req.spec.clone(), None)); - let variant_id = req.variant_id.clone().or(parsed_variant_id); - match backend_id { - "mlx" | "llamacpp" => Ok(Some(LocalModelSelection { - repo_id, - backend_id: backend_id.to_string(), - variant_id, - })), - _ => anyhow::bail!("Unknown local inference backend '{}'", backend_id), - } - } else { - Ok(None) - } -} - -async fn local_model_id_from_request( - req: &DownloadModelRequest, - selection: Option<&LocalModelSelection>, -) -> anyhow::Result { - if let Some(selection) = selection { - return match selection.backend_id.as_str() { - "mlx" => Ok(selection.repo_id.clone()), - "llamacpp" => { - let quantization = selection.variant_id.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "llama.cpp model '{}' is missing a quantization", - selection.repo_id - ) - })?; - Ok(model_id_from_repo(&selection.repo_id, quantization)) - } - _ => anyhow::bail!("Unknown local inference backend '{}'", selection.backend_id), - }; - } - - if let Ok((repo_id, quantization)) = hf_models::parse_model_spec(&req.spec) { - return Ok(model_id_from_repo(&repo_id, &quantization)); - } - - let variants = hf_models::get_repo_local_variants(&req.spec).await?; - let has_llamacpp = variants - .iter() - .any(|variant| variant.backend_id == "llamacpp"); - let mlx_variants: Vec<_> = variants - .iter() - .filter(|variant| variant.backend_id == "mlx") - .collect(); - if mlx_variants.len() == 1 && !has_llamacpp { - Ok(req.spec.clone()) - } else { - anyhow::bail!( - "Model spec '{}' is ambiguous; choose one of: {}", - req.spec, - variants - .iter() - .map(|variant| variant.download_id.as_str()) - .collect::>() - .join(", ") - ) - } -} - -fn mark_download_failed(model_id: &str, error: impl std::fmt::Display) { - let manager = get_download_manager(); - let download_id = format!("{}-model", model_id); - if manager.get_progress(&download_id).is_none() { - manager.set_progress(DownloadProgress { - model_id: download_id.clone(), - status: DownloadStatus::Failed, - bytes_downloaded: 0, - total_bytes: 0, - progress_percent: 0.0, - speed_bps: None, - eta_seconds: None, - error: Some(error.to_string()), - task_exited: true, - }); - return; - } - - manager.update_progress(&download_id, |progress| { - if progress.status != DownloadStatus::Cancelled { - progress.status = DownloadStatus::Failed; - progress.error = Some(error.to_string()); - } - progress.task_exited = true; - }); -} - -fn model_download_completed(model_id: &str) -> bool { - get_download_manager() - .get_progress(&format!("{}-model", model_id)) - .is_some_and(|progress| progress.status == DownloadStatus::Completed) -} - -fn register_pending_download_model( - model_id: &str, - req: &DownloadModelRequest, - selection: Option<&LocalModelSelection>, -) -> anyhow::Result<()> { - let (repo_id, backend_id, variant_id) = if let Some(selection) = selection { - ( - selection.repo_id.clone(), - selection.backend_id.clone(), - selection - .variant_id - .clone() - .unwrap_or_else(|| "default".to_string()), - ) - } else if let Ok((repo_id, quantization)) = hf_models::parse_model_spec(&req.spec) { - (repo_id, "llamacpp".to_string(), quantization) - } else { - (req.spec.clone(), "mlx".to_string(), "default".to_string()) - }; - - let mut registry = get_registry() - .lock() - .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; - if registry.has_model(model_id) { - return Ok(()); - } - - let mut settings = default_settings_for_model(model_id); - if backend_id != "llamacpp" { - settings.backend_id = Some(backend_id.clone()); - } - - let filename = variant_id.clone(); - registry.add_model(LocalModelEntry { - id: model_id.to_string(), - repo_id, - filename: filename.clone(), - quantization: variant_id, - local_path: Paths::in_data_dir("models").join(filename), - source_url: req.spec.clone(), - backend_id: settings.backend_id.clone(), - storage: LocalModelStorage::HuggingFaceCache, - settings, - size_bytes: 0, - mmproj_path: None, - mmproj_source_url: None, - mmproj_size_bytes: 0, - mmproj_checked: false, - shard_files: vec![], - }) -} - -#[utoipa::path( - post, - path = "/local-inference/download", - request_body = DownloadModelRequest, - responses( - (status = 202, description = "Download started", body = String), - (status = 400, description = "Invalid request") - ) -)] -pub async fn download_hf_model( - Json(req): Json, -) -> Result<(StatusCode, Json), ErrorResponse> { - let selection = explicit_model_selection(&req) - .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?; - let model_id = local_model_id_from_request(&req, selection.as_ref()) - .await - .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?; - let download_id = format!("{}-model", model_id); - let download_reserved = get_download_manager() - .reserve_download(DownloadProgress { - model_id: download_id, - status: DownloadStatus::Downloading, - bytes_downloaded: 0, - total_bytes: 0, - progress_percent: 0.0, - speed_bps: None, - eta_seconds: None, - error: None, - task_exited: false, - }) - .map_err(|e| ErrorResponse::internal(format!("Download failed: {}", e)))?; - if !download_reserved { - return Ok((StatusCode::ACCEPTED, Json(model_id))); - } - - if let Err(error) = register_pending_download_model(&model_id, &req, selection.as_ref()) { - mark_download_failed(&model_id, &error); - return Err(ErrorResponse::internal(format!( - "Failed to register download: {}", - error - ))); - } - - let spec = req.spec.clone(); - let selection_for_task = selection.clone(); - let model_id_for_task = model_id.clone(); - tokio::spawn(async move { - let resolved = if let Some(selection) = selection_for_task { - resolve_local_model_selection( - &selection.repo_id, - &selection.backend_id, - selection.variant_id.as_deref(), - ) - .await - } else { - resolve_local_model_spec(&spec).await - }; - match resolved { - Ok(resolved) => { - if !model_download_completed(&model_id_for_task) { - return; - } - if let Err(error) = register_resolved_model(resolved, &spec) { - mark_download_failed(&model_id_for_task, error); - } - } - Err(error) => mark_download_failed(&model_id_for_task, error), - } - }); - - Ok((StatusCode::ACCEPTED, Json(model_id))) -} - -#[utoipa::path( - get, - path = "/local-inference/models/{model_id}/download", - responses( - (status = 200, description = "Download progress", body = DownloadProgress), - (status = 404, description = "No active download") - ) -)] -pub async fn get_local_model_download_progress( - Path(model_id): Path, -) -> Result, ErrorResponse> { - let download_id = format!("{}-model", model_id); - debug!(model_id = %model_id, download_id = %download_id, "Getting download progress"); - - let manager = get_download_manager(); - - let model_progress = manager - .get_progress(&download_id) - .ok_or_else(|| ErrorResponse::not_found("No active download"))?; - - Ok(Json(model_progress)) -} - -#[utoipa::path( - delete, - path = "/local-inference/models/{model_id}/download", - responses( - (status = 200, description = "Download cancelled"), - (status = 404, description = "No active download") - ) -)] -pub async fn cancel_local_model_download( - Path(model_id): Path, -) -> Result { - let manager = get_download_manager(); - manager - .cancel_download(&format!("{}-model", model_id)) - .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; - let _ = manager.cancel_download(&format!("{}-mmproj", model_id)); - - Ok(StatusCode::OK) -} - -#[utoipa::path( - delete, - path = "/local-inference/models/{model_id}", - responses( - (status = 200, description = "Model deleted"), - (status = 404, description = "Model not found") - ) -)] -pub async fn delete_local_model(Path(model_id): Path) -> Result { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - if registry.get_model(&model_id).is_none() { - return Err(ErrorResponse::not_found("Model not found")); - } - registry - .delete_model(&model_id) - .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; - - Ok(StatusCode::OK) -} - -#[utoipa::path( - get, - path = "/local-inference/models/{model_id}/settings", - responses( - (status = 200, description = "Model settings", body = ModelSettings), - (status = 404, description = "Model not found") - ) -)] -pub async fn get_model_settings( - Path(model_id): Path, -) -> Result, ErrorResponse> { - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - if let Some(settings) = registry.get_model_settings(&model_id) { - return Ok(Json(settings.clone())); - } - - Err(ErrorResponse::not_found("Model not found")) -} - -#[utoipa::path( - put, - path = "/local-inference/models/{model_id}/settings", - request_body = ModelSettings, - responses( - (status = 200, description = "Settings updated", body = ModelSettings), - (status = 404, description = "Model not found"), - (status = 500, description = "Failed to save settings") - ) -)] -pub async fn update_model_settings( - Path(model_id): Path, - Json(settings): Json, -) -> Result, ErrorResponse> { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - registry - .update_model_settings(&model_id, settings.clone()) - .map_err(|e| ErrorResponse::not_found(format!("{}", e)))?; - - Ok(Json(settings)) -} - -#[utoipa::path( - get, - path = "/local-inference/chat-templates/builtin", - responses( - (status = 200, description = "llama.cpp built-in chat template names", body = Vec) - ) -)] -pub async fn list_builtin_chat_templates() -> Json> { - Json(builtin_chat_template_names()) -} - -pub fn routes(state: Arc) -> Router { - let registered_paths: std::collections::HashSet = get_registry() - .lock() - .map(|reg| { - reg.list_models() - .iter() - .flat_map(|m| { - m.all_local_paths() - .map(|p| p.to_path_buf()) - .chain(m.mmproj_path.as_deref().map(|p| p.to_path_buf())) - }) - .collect() - }) - .unwrap_or_default(); - goose::download_manager::cleanup_partial_downloads( - &Paths::in_data_dir("models"), - ®istered_paths, - ); - - Router::new() - .route("/local-inference/models", get(list_local_models)) - .route("/local-inference/sync-featured", post(sync_featured_models)) - .route("/local-inference/search", get(search_hf_models)) - .route( - "/local-inference/chat-templates/builtin", - get(list_builtin_chat_templates), - ) - .route( - "/local-inference/repo/{author}/{repo}/files", - get(get_repo_files), - ) - .route("/local-inference/download", post(download_hf_model)) - .route( - "/local-inference/models/{model_id}/download", - get(get_local_model_download_progress), - ) - .route( - "/local-inference/models/{model_id}/download", - delete(cancel_local_model_download), - ) - .route( - "/local-inference/models/{model_id}", - delete(delete_local_model), - ) - .route( - "/local-inference/models/{model_id}/settings", - get(get_model_settings), - ) - .route( - "/local-inference/models/{model_id}/settings", - axum::routing::put(update_model_settings), - ) - .with_state(state) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn progress_for(model_id: &str, status: DownloadStatus) -> DownloadProgress { - DownloadProgress { - model_id: format!("{}-model", model_id), - status, - bytes_downloaded: 0, - total_bytes: 0, - progress_percent: 0.0, - speed_bps: None, - eta_seconds: None, - error: None, - task_exited: true, - } - } - - #[test] - fn model_download_completed_requires_completed_progress() { - let model_id = "test-completed-registration-gate"; - let manager = get_download_manager(); - manager.set_progress(progress_for(model_id, DownloadStatus::Completed)); - - assert!(model_download_completed(model_id)); - - manager.clear_completed(&format!("{}-model", model_id)); - } - - #[test] - fn model_download_completed_rejects_cancelled_progress() { - let model_id = "test-cancelled-registration-gate"; - let manager = get_download_manager(); - manager.set_progress(progress_for(model_id, DownloadStatus::Cancelled)); - - assert!(!model_download_completed(model_id)); - - manager.clear_completed(&format!("{}-model", model_id)); - } -} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index 45b0da38aa..83cb42d90d 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -3,8 +3,6 @@ pub mod agent; pub mod config_management; pub mod dictation; pub mod errors; -#[cfg(feature = "local-inference")] -pub mod local_inference; pub mod mcp_app_proxy; pub mod prompts; pub mod recipe; @@ -24,7 +22,7 @@ use axum::Router; // Function to configure all routes pub fn configure(state: Arc, secret_key: String) -> Router { - let router = Router::new() + Router::new() .merge(status::routes(state.clone())) .merge(reply::routes(state.clone())) .merge(action_required::routes(state.clone())) @@ -38,10 +36,5 @@ pub fn configure(state: Arc, secret_key: String) -> Rout .merge(mcp_app_proxy::routes(secret_key)) .merge(session_events::routes(state.clone())) .merge(sampling::routes(state.clone())) - .merge(dictation::routes(state.clone())); - - #[cfg(feature = "local-inference")] - let router = router.merge(local_inference::routes(state)); - - router + .merge(dictation::routes(state.clone())) } diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index afd2c4c0f2..c402935c98 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -6,15 +6,11 @@ use goose::session::SessionManager; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; -#[cfg(feature = "local-inference")] -use std::sync::OnceLock; use tokio::sync::Mutex; use tokio::task::JoinHandle; use crate::session_event_bus::SessionEventBus; use goose::agents::ExtensionLoadResult; -#[cfg(feature = "local-inference")] -use goose::providers::local_inference::InferenceRuntime; type ExtensionLoadingTasks = Arc>>>>>>>; @@ -25,8 +21,6 @@ pub struct AppState { pub recipe_file_hash_map: Arc>>, recipe_session_tracker: Arc>>, pub extension_loading_tasks: ExtensionLoadingTasks, - #[cfg(feature = "local-inference")] - inference_runtime: Arc>>, session_buses: Arc>>>, } @@ -40,32 +34,10 @@ impl AppState { recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())), recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())), extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())), - #[cfg(feature = "local-inference")] - inference_runtime: Arc::new(OnceLock::new()), session_buses: Arc::new(Mutex::new(HashMap::new())), })) } - #[cfg(feature = "local-inference")] - pub fn get_inference_runtime(&self) -> anyhow::Result> { - if let Some(runtime) = self.inference_runtime.get() { - return Ok(runtime.clone()); - } - - let runtime = InferenceRuntime::get_or_init()?; - - // Another thread may win the race to cache the runtime in AppState. - // In that case, return the already-initialized cached runtime. - match self.inference_runtime.set(runtime.clone()) { - Ok(()) => Ok(runtime), - Err(_) => Ok(self - .inference_runtime - .get() - .expect("inference runtime initialized by another thread") - .clone()), - } - } - pub async fn set_extension_loading_task( &self, session_id: String, diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 619d7e53ec..11260c7dcd 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -504,6 +504,56 @@ "method": "_goose/unstable/dictation/models/select", "requestType": "DictationModelSelectRequest_unstable", "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/local-inference/models/list", + "requestType": "LocalInferenceModelsListRequest_unstable", + "responseType": "LocalInferenceModelsListResponse_unstable" + }, + { + "method": "_goose/unstable/local-inference/models/download", + "requestType": "LocalInferenceModelDownloadRequest_unstable", + "responseType": "LocalInferenceModelDownloadResponse_unstable" + }, + { + "method": "_goose/unstable/local-inference/models/download/progress", + "requestType": "LocalInferenceModelDownloadProgressRequest_unstable", + "responseType": "LocalInferenceModelDownloadProgressResponse_unstable" + }, + { + "method": "_goose/unstable/local-inference/models/download/cancel", + "requestType": "LocalInferenceModelDownloadCancelRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/local-inference/models/delete", + "requestType": "LocalInferenceModelDeleteRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/local-inference/models/settings/read", + "requestType": "LocalInferenceModelSettingsReadRequest_unstable", + "responseType": "LocalInferenceModelSettingsReadResponse_unstable" + }, + { + "method": "_goose/unstable/local-inference/models/settings/update", + "requestType": "LocalInferenceModelSettingsUpdateRequest_unstable", + "responseType": "LocalInferenceModelSettingsUpdateResponse_unstable" + }, + { + "method": "_goose/unstable/local-inference/huggingface/search", + "requestType": "LocalInferenceHuggingFaceSearchRequest_unstable", + "responseType": "LocalInferenceHuggingFaceSearchResponse_unstable" + }, + { + "method": "_goose/unstable/local-inference/huggingface/repo/variants", + "requestType": "LocalInferenceHuggingFaceRepoVariantsRequest_unstable", + "responseType": "LocalInferenceHuggingFaceRepoVariantsResponse_unstable" + }, + { + "method": "_goose/unstable/local-inference/chat-templates/builtin/list", + "requestType": "LocalInferenceBuiltinChatTemplatesListRequest_unstable", + "responseType": "LocalInferenceBuiltinChatTemplatesListResponse_unstable" } ], "notifications": [ diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 90ce11dfe2..673c6dd187 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -5684,6 +5684,837 @@ "x-side": "agent", "x-method": "_goose/unstable/dictation/models/select" }, + "LocalInferenceModelsListRequest_unstable": { + "type": "object", + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/list" + }, + "LocalInferenceModelsListResponse_unstable": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/$defs/LocalInferenceModelDto" + } + } + }, + "required": [ + "models" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/list" + }, + "LocalInferenceModelDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "repoId": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "quantization": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": 0 + }, + "status": { + "$ref": "#/$defs/LocalInferenceModelDownloadStatusDto" + }, + "recommended": { + "type": "boolean" + }, + "settings": { + "$ref": "#/$defs/LocalInferenceModelSettingsDto" + }, + "visionCapable": { + "type": "boolean" + }, + "mmprojStatus": { + "anyOf": [ + { + "$ref": "#/$defs/LocalInferenceModelDownloadStatusDto" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "repoId", + "filename", + "quantization", + "sizeBytes", + "status", + "recommended", + "settings", + "visionCapable" + ] + }, + "LocalInferenceModelDownloadStatusDto": { + "type": "object", + "properties": { + "state": { + "$ref": "#/$defs/LocalInferenceDownloadState" + }, + "progressPercent": { + "type": [ + "number", + "null" + ], + "format": "float" + }, + "bytesDownloaded": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "totalBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "speedBps": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "state" + ] + }, + "LocalInferenceDownloadState": { + "type": "string", + "enum": [ + "NotDownloaded", + "Downloading", + "Downloaded" + ] + }, + "LocalInferenceModelSettingsDto": { + "type": "object", + "properties": { + "backendId": { + "type": [ + "string", + "null" + ] + }, + "contextSize": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "maxOutputTokens": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "draftModel": { + "type": [ + "string", + "null" + ] + }, + "sampling": { + "$ref": "#/$defs/LocalInferenceSamplingConfig", + "default": { + "type": "Temperature", + "temperature": 0.800000011920929, + "topK": 40, + "topP": 0.949999988079071, + "minP": 0.05000000074505806 + } + }, + "repeatPenalty": { + "type": "number", + "format": "float" + }, + "repeatLastN": { + "type": "integer" + }, + "frequencyPenalty": { + "type": "number", + "format": "float" + }, + "presencePenalty": { + "type": "number", + "format": "float" + }, + "nBatch": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "nGpuLayers": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "useMlock": { + "type": "boolean" + }, + "flashAttention": { + "type": [ + "boolean", + "null" + ] + }, + "nThreads": { + "type": [ + "integer", + "null" + ] + }, + "toolCalling": { + "$ref": "#/$defs/LocalInferenceToolCallingMode", + "default": "auto" + }, + "chatTemplate": { + "$ref": "#/$defs/LocalInferenceChatTemplate", + "default": { + "type": "embedded" + } + }, + "enableThinking": { + "type": "boolean" + }, + "visionCapable": { + "type": "boolean" + }, + "imageTokenEstimate": { + "type": "integer", + "minimum": 0 + }, + "mmprojSizeBytes": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "repeatPenalty", + "repeatLastN", + "frequencyPenalty", + "presencePenalty", + "useMlock", + "enableThinking", + "visionCapable", + "imageTokenEstimate", + "mmprojSizeBytes" + ] + }, + "LocalInferenceSamplingConfig": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Greedy" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "format": "float" + }, + "topK": { + "type": "integer" + }, + "topP": { + "type": "number", + "format": "float" + }, + "minP": { + "type": "number", + "format": "float" + }, + "seed": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "type": { + "type": "string", + "const": "Temperature" + } + }, + "required": [ + "type", + "temperature", + "topK", + "topP", + "minP" + ] + }, + { + "type": "object", + "properties": { + "tau": { + "type": "number", + "format": "float" + }, + "eta": { + "type": "number", + "format": "float" + }, + "seed": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "type": { + "type": "string", + "const": "MirostatV2" + } + }, + "required": [ + "type", + "tau", + "eta" + ] + } + ] + }, + "LocalInferenceToolCallingMode": { + "type": "string", + "enum": [ + "auto", + "force_native", + "force_emulated" + ] + }, + "LocalInferenceChatTemplate": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "embedded" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "const": "builtin" + } + }, + "required": [ + "type", + "name" + ] + }, + { + "type": "object", + "properties": { + "template": { + "type": "string" + }, + "type": { + "type": "string", + "const": "custom_inline" + } + }, + "required": [ + "type", + "template" + ] + } + ] + }, + "LocalInferenceModelDownloadRequest_unstable": { + "type": "object", + "properties": { + "spec": { + "type": "string" + }, + "backendId": { + "type": [ + "string", + "null" + ] + }, + "variantId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "spec" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/download" + }, + "LocalInferenceModelDownloadResponse_unstable": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/download" + }, + "LocalInferenceModelDownloadProgressRequest_unstable": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/download/progress" + }, + "LocalInferenceModelDownloadProgressResponse_unstable": { + "type": "object", + "properties": { + "progress": { + "anyOf": [ + { + "$ref": "#/$defs/LocalInferenceDownloadProgressDto" + }, + { + "type": "null" + } + ] + } + }, + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/download/progress" + }, + "LocalInferenceDownloadProgressDto": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "bytesDownloaded": { + "type": "integer", + "minimum": 0 + }, + "totalBytes": { + "type": "integer", + "minimum": 0 + }, + "progressPercent": { + "type": "number", + "format": "float" + }, + "speedBps": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "etaSeconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "taskExited": { + "type": "boolean" + } + }, + "required": [ + "modelId", + "status", + "bytesDownloaded", + "totalBytes", + "progressPercent", + "taskExited" + ] + }, + "LocalInferenceModelDownloadCancelRequest_unstable": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/download/cancel" + }, + "LocalInferenceModelDeleteRequest_unstable": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/delete" + }, + "LocalInferenceModelSettingsReadRequest_unstable": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/settings/read" + }, + "LocalInferenceModelSettingsReadResponse_unstable": { + "type": "object", + "properties": { + "settings": { + "$ref": "#/$defs/LocalInferenceModelSettingsDto" + } + }, + "required": [ + "settings" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/settings/read" + }, + "LocalInferenceModelSettingsUpdateRequest_unstable": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + }, + "settings": { + "$ref": "#/$defs/LocalInferenceModelSettingsDto" + } + }, + "required": [ + "modelId", + "settings" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/settings/update" + }, + "LocalInferenceModelSettingsUpdateResponse_unstable": { + "type": "object", + "properties": { + "settings": { + "$ref": "#/$defs/LocalInferenceModelSettingsDto" + } + }, + "required": [ + "settings" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/models/settings/update" + }, + "LocalInferenceHuggingFaceSearchRequest_unstable": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "limit": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "query" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/huggingface/search" + }, + "LocalInferenceHuggingFaceSearchResponse_unstable": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/$defs/LocalInferenceHfModelInfoDto" + } + } + }, + "required": [ + "models" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/huggingface/search" + }, + "LocalInferenceHfModelInfoDto": { + "type": "object", + "properties": { + "repoId": { + "type": "string" + }, + "author": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "downloads": { + "type": "integer", + "minimum": 0 + }, + "ggufFiles": { + "type": "array", + "items": { + "$ref": "#/$defs/LocalInferenceHfGgufFileDto" + }, + "default": [] + }, + "variants": { + "type": "array", + "items": { + "$ref": "#/$defs/LocalInferenceHfModelVariantDto" + }, + "default": [] + } + }, + "required": [ + "repoId", + "author", + "modelName", + "downloads" + ] + }, + "LocalInferenceHfGgufFileDto": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": 0 + }, + "quantization": { + "type": "string" + }, + "downloadUrl": { + "type": "string" + } + }, + "required": [ + "filename", + "sizeBytes", + "quantization", + "downloadUrl" + ] + }, + "LocalInferenceHfModelVariantDto": { + "type": "object", + "properties": { + "variantId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "backendId": { + "type": "string" + }, + "format": { + "type": "string" + }, + "modelId": { + "type": "string" + }, + "downloadId": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": 0 + }, + "filename": { + "type": [ + "string", + "null" + ] + }, + "downloadUrl": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "qualityRank": { + "type": "integer", + "maximum": 255, + "minimum": 0 + }, + "sharded": { + "type": "boolean" + }, + "supported": { + "type": "boolean" + }, + "unsupportedReason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "variantId", + "label", + "backendId", + "format", + "modelId", + "downloadId", + "sizeBytes", + "description", + "qualityRank", + "sharded", + "supported" + ] + }, + "LocalInferenceHuggingFaceRepoVariantsRequest_unstable": { + "type": "object", + "properties": { + "repoId": { + "type": "string" + } + }, + "required": [ + "repoId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/huggingface/repo/variants" + }, + "LocalInferenceHuggingFaceRepoVariantsResponse_unstable": { + "type": "object", + "properties": { + "variants": { + "type": "array", + "items": { + "$ref": "#/$defs/LocalInferenceHfModelVariantDto" + } + }, + "recommendedIndex": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "availableMemoryBytes": { + "type": "integer", + "minimum": 0 + }, + "downloadedQuants": { + "type": "array", + "items": { + "type": "string" + } + }, + "downloadedVariants": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "variants", + "availableMemoryBytes", + "downloadedQuants", + "downloadedVariants" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/huggingface/repo/variants" + }, + "LocalInferenceBuiltinChatTemplatesListRequest_unstable": { + "type": "object", + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/chat-templates/builtin/list" + }, + "LocalInferenceBuiltinChatTemplatesListResponse_unstable": { + "type": "object", + "properties": { + "templates": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "templates" + ], + "x-side": "agent", + "x-method": "_goose/unstable/local-inference/chat-templates/builtin/list" + }, "GooseSessionNotification_unstable": { "type": "object", "properties": { @@ -6788,6 +7619,96 @@ ], "description": "Params for _goose/unstable/dictation/models/select", "title": "DictationModelSelectRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelsListRequest_unstable" + } + ], + "description": "Params for _goose/unstable/local-inference/models/list", + "title": "LocalInferenceModelsListRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelDownloadRequest_unstable" + } + ], + "description": "Params for _goose/unstable/local-inference/models/download", + "title": "LocalInferenceModelDownloadRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelDownloadProgressRequest_unstable" + } + ], + "description": "Params for _goose/unstable/local-inference/models/download/progress", + "title": "LocalInferenceModelDownloadProgressRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelDownloadCancelRequest_unstable" + } + ], + "description": "Params for _goose/unstable/local-inference/models/download/cancel", + "title": "LocalInferenceModelDownloadCancelRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelDeleteRequest_unstable" + } + ], + "description": "Params for _goose/unstable/local-inference/models/delete", + "title": "LocalInferenceModelDeleteRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelSettingsReadRequest_unstable" + } + ], + "description": "Params for _goose/unstable/local-inference/models/settings/read", + "title": "LocalInferenceModelSettingsReadRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelSettingsUpdateRequest_unstable" + } + ], + "description": "Params for _goose/unstable/local-inference/models/settings/update", + "title": "LocalInferenceModelSettingsUpdateRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceHuggingFaceSearchRequest_unstable" + } + ], + "description": "Params for _goose/unstable/local-inference/huggingface/search", + "title": "LocalInferenceHuggingFaceSearchRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceHuggingFaceRepoVariantsRequest_unstable" + } + ], + "description": "Params for _goose/unstable/local-inference/huggingface/repo/variants", + "title": "LocalInferenceHuggingFaceRepoVariantsRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceBuiltinChatTemplatesListRequest_unstable" + } + ], + "description": "Params for _goose/unstable/local-inference/chat-templates/builtin/list", + "title": "LocalInferenceBuiltinChatTemplatesListRequest_unstable" } ] }, @@ -7346,6 +8267,70 @@ } ], "title": "DictationModelDownloadProgressResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelsListResponse_unstable" + } + ], + "title": "LocalInferenceModelsListResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelDownloadResponse_unstable" + } + ], + "title": "LocalInferenceModelDownloadResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelDownloadProgressResponse_unstable" + } + ], + "title": "LocalInferenceModelDownloadProgressResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelSettingsReadResponse_unstable" + } + ], + "title": "LocalInferenceModelSettingsReadResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceModelSettingsUpdateResponse_unstable" + } + ], + "title": "LocalInferenceModelSettingsUpdateResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceHuggingFaceSearchResponse_unstable" + } + ], + "title": "LocalInferenceHuggingFaceSearchResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceHuggingFaceRepoVariantsResponse_unstable" + } + ], + "title": "LocalInferenceHuggingFaceRepoVariantsResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/LocalInferenceBuiltinChatTemplatesListResponse_unstable" + } + ], + "title": "LocalInferenceBuiltinChatTemplatesListResponse_unstable" } ] }, diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index a9e862fee8..14c5b446bb 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -93,6 +93,7 @@ mod extensions; mod fork_session; mod list_sessions; mod load_session; +mod local_inference; mod manage_sessions; mod new_session; mod onboarding; diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index b15693746d..4cf02d1b7d 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -823,4 +823,85 @@ impl GooseAcpAgent { ) -> Result { self.on_dictation_model_select(req).await } + + #[custom_method(LocalInferenceModelsListRequest)] + async fn dispatch_local_inference_models_list( + &self, + req: LocalInferenceModelsListRequest, + ) -> Result { + self.on_local_inference_models_list(req).await + } + + #[custom_method(LocalInferenceModelDownloadRequest)] + async fn dispatch_local_inference_model_download( + &self, + req: LocalInferenceModelDownloadRequest, + ) -> Result { + self.on_local_inference_model_download(req).await + } + + #[custom_method(LocalInferenceModelDownloadProgressRequest)] + async fn dispatch_local_inference_model_download_progress( + &self, + req: LocalInferenceModelDownloadProgressRequest, + ) -> Result { + self.on_local_inference_model_download_progress(req).await + } + + #[custom_method(LocalInferenceModelDownloadCancelRequest)] + async fn dispatch_local_inference_model_download_cancel( + &self, + req: LocalInferenceModelDownloadCancelRequest, + ) -> Result { + self.on_local_inference_model_download_cancel(req).await + } + + #[custom_method(LocalInferenceModelDeleteRequest)] + async fn dispatch_local_inference_model_delete( + &self, + req: LocalInferenceModelDeleteRequest, + ) -> Result { + self.on_local_inference_model_delete(req).await + } + + #[custom_method(LocalInferenceModelSettingsReadRequest)] + async fn dispatch_local_inference_model_settings_read( + &self, + req: LocalInferenceModelSettingsReadRequest, + ) -> Result { + self.on_local_inference_model_settings_read(req).await + } + + #[custom_method(LocalInferenceModelSettingsUpdateRequest)] + async fn dispatch_local_inference_model_settings_update( + &self, + req: LocalInferenceModelSettingsUpdateRequest, + ) -> Result { + self.on_local_inference_model_settings_update(req).await + } + + #[custom_method(LocalInferenceHuggingFaceSearchRequest)] + async fn dispatch_local_inference_huggingface_search( + &self, + req: LocalInferenceHuggingFaceSearchRequest, + ) -> Result { + self.on_local_inference_huggingface_search(req).await + } + + #[custom_method(LocalInferenceHuggingFaceRepoVariantsRequest)] + async fn dispatch_local_inference_huggingface_repo_variants( + &self, + req: LocalInferenceHuggingFaceRepoVariantsRequest, + ) -> Result { + self.on_local_inference_huggingface_repo_variants(req).await + } + + #[custom_method(LocalInferenceBuiltinChatTemplatesListRequest)] + async fn dispatch_local_inference_builtin_chat_templates_list( + &self, + req: LocalInferenceBuiltinChatTemplatesListRequest, + ) -> Result { + self.on_local_inference_builtin_chat_templates_list(req) + .await + } } diff --git a/crates/goose/src/acp/server/local_inference.rs b/crates/goose/src/acp/server/local_inference.rs new file mode 100644 index 0000000000..06cb8b5d71 --- /dev/null +++ b/crates/goose/src/acp/server/local_inference.rs @@ -0,0 +1,183 @@ +use super::*; + +#[cfg(not(feature = "local-inference"))] +fn local_inference_unavailable() -> agent_client_protocol::Error { + agent_client_protocol::Error::invalid_params().data("Local inference not enabled") +} + +impl GooseAcpAgent { + pub(super) async fn on_local_inference_models_list( + &self, + _req: LocalInferenceModelsListRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + crate::providers::local_inference::management::list_models() + .await + .internal_err() + } + + #[cfg(not(feature = "local-inference"))] + Err(local_inference_unavailable()) + } + + pub(super) async fn on_local_inference_model_download( + &self, + req: LocalInferenceModelDownloadRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + crate::providers::local_inference::management::download_model(req) + .await + .invalid_params_err() + } + + #[cfg(not(feature = "local-inference"))] + { + let _ = req; + Err(local_inference_unavailable()) + } + } + + pub(super) async fn on_local_inference_model_download_progress( + &self, + req: LocalInferenceModelDownloadProgressRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + crate::providers::local_inference::management::download_progress(&req.model_id) + .map(|progress| LocalInferenceModelDownloadProgressResponse { progress }) + .internal_err() + } + + #[cfg(not(feature = "local-inference"))] + { + let _ = req; + Err(local_inference_unavailable()) + } + } + + pub(super) async fn on_local_inference_model_download_cancel( + &self, + req: LocalInferenceModelDownloadCancelRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + crate::providers::local_inference::management::cancel_download(&req.model_id) + .internal_err()?; + Ok(EmptyResponse {}) + } + + #[cfg(not(feature = "local-inference"))] + { + let _ = req; + Err(local_inference_unavailable()) + } + } + + pub(super) async fn on_local_inference_model_delete( + &self, + req: LocalInferenceModelDeleteRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + crate::providers::local_inference::management::delete_model(&req.model_id) + .invalid_params_err()?; + Ok(EmptyResponse {}) + } + + #[cfg(not(feature = "local-inference"))] + { + let _ = req; + Err(local_inference_unavailable()) + } + } + + pub(super) async fn on_local_inference_model_settings_read( + &self, + req: LocalInferenceModelSettingsReadRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + crate::providers::local_inference::management::get_model_settings(&req.model_id) + .invalid_params_err() + } + + #[cfg(not(feature = "local-inference"))] + { + let _ = req; + Err(local_inference_unavailable()) + } + } + + pub(super) async fn on_local_inference_model_settings_update( + &self, + req: LocalInferenceModelSettingsUpdateRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + crate::providers::local_inference::management::update_model_settings( + &req.model_id, + req.settings, + ) + .invalid_params_err() + } + + #[cfg(not(feature = "local-inference"))] + { + let _ = req; + Err(local_inference_unavailable()) + } + } + + pub(super) async fn on_local_inference_huggingface_search( + &self, + req: LocalInferenceHuggingFaceSearchRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + crate::providers::local_inference::management::search_huggingface_models( + req.query, req.limit, + ) + .await + .internal_err() + } + + #[cfg(not(feature = "local-inference"))] + { + let _ = req; + Err(local_inference_unavailable()) + } + } + + pub(super) async fn on_local_inference_huggingface_repo_variants( + &self, + req: LocalInferenceHuggingFaceRepoVariantsRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + crate::providers::local_inference::management::huggingface_repo_variants(req.repo_id) + .await + .internal_err() + } + + #[cfg(not(feature = "local-inference"))] + { + let _ = req; + Err(local_inference_unavailable()) + } + } + + pub(super) async fn on_local_inference_builtin_chat_templates_list( + &self, + _req: LocalInferenceBuiltinChatTemplatesListRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + Ok(crate::providers::local_inference::management::list_builtin_chat_templates()) + } + + #[cfg(not(feature = "local-inference"))] + Err(local_inference_unavailable()) + } +} diff --git a/crates/goose/src/providers/local_inference.rs b/crates/goose/src/providers/local_inference.rs index 504a17a5ae..8f84811ee4 100644 --- a/crates/goose/src/providers/local_inference.rs +++ b/crates/goose/src/providers/local_inference.rs @@ -2,6 +2,7 @@ mod backend; pub mod hf_models; mod llamacpp; pub mod local_model_registry; +pub mod management; mod mlx; pub(crate) mod multimodal; #[cfg(feature = "mlx")] @@ -67,10 +68,11 @@ pub fn builtin_chat_template_names() -> Vec { } /// Global weak reference used to share a single `InferenceRuntime` across -/// all providers and server routes. Only a `Weak` is stored — strong `Arc`s -/// live in providers and `AppState`. When all strong refs drop (normal -/// shutdown), the runtime is deallocated and the backend freed. The `Weak` -/// left behind is inert during `__cxa_finalize`, so no ggml statics race. +/// all providers and management APIs. Only a `Weak` is stored here — strong +/// `Arc`s live in providers and the local-inference management layer. When all +/// strong refs drop (normal shutdown), the runtime is deallocated and the +/// backend freed. The `Weak` left behind is inert during `__cxa_finalize`, so no +/// ggml statics race. static RUNTIME: StdMutex> = StdMutex::new(Weak::new()); impl InferenceRuntime { diff --git a/crates/goose/src/providers/local_inference/management.rs b/crates/goose/src/providers/local_inference/management.rs new file mode 100644 index 0000000000..3b602921d1 --- /dev/null +++ b/crates/goose/src/providers/local_inference/management.rs @@ -0,0 +1,837 @@ +use super::hf_models::{ + self, register_resolved_model, resolve_local_model_selection, resolve_local_model_spec, + resolve_model_spec, HfGgufFile, HfModelInfo, HfModelVariant, +}; +use super::local_model_registry::{ + default_settings_for_model, featured_mmproj_spec, get_registry, model_id_from_repo, + ChatTemplate, LocalModelEntry, LocalModelStorage, ModelDownloadStatus, ModelSettings, + SamplingConfig, ToolCallingMode, FEATURED_MODELS, +}; +use super::{ + available_inference_memory_bytes, builtin_chat_template_names, recommend_local_model, + InferenceRuntime, +}; +use crate::config::paths::Paths; +use crate::download_manager::{get_download_manager, DownloadProgress, DownloadStatus}; +use crate::providers::huggingface_auth; +use anyhow::{anyhow, Result}; +use futures::future::join_all; +use goose_sdk_types::custom_requests::{ + LocalInferenceBuiltinChatTemplatesListResponse, LocalInferenceChatTemplate, + LocalInferenceDownloadProgressDto, LocalInferenceDownloadState, LocalInferenceHfGgufFileDto, + LocalInferenceHfModelInfoDto, LocalInferenceHfModelVariantDto, + LocalInferenceHuggingFaceRepoVariantsResponse, LocalInferenceHuggingFaceSearchResponse, + LocalInferenceModelDownloadRequest, LocalInferenceModelDownloadResponse, + LocalInferenceModelDownloadStatusDto, LocalInferenceModelDto, LocalInferenceModelSettingsDto, + LocalInferenceModelSettingsReadResponse, LocalInferenceModelSettingsUpdateResponse, + LocalInferenceModelsListResponse, LocalInferenceSamplingConfig, LocalInferenceToolCallingMode, +}; +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; + +static MANAGEMENT_RUNTIME: OnceLock> = OnceLock::new(); + +#[derive(Clone)] +struct LocalModelSelection { + repo_id: String, + backend_id: String, + variant_id: Option, +} + +pub async fn list_models() -> Result { + ensure_featured_models_current().await?; + + let runtime = management_runtime()?; + let recommended_id = recommend_local_model(&runtime); + + let registry = get_registry() + .lock() + .map_err(|_| anyhow!("Failed to acquire registry lock"))?; + let mut models: Vec = registry + .list_models() + .iter() + .map(|entry| local_model_to_dto(entry, &recommended_id)) + .collect(); + + models.sort_by(|a, b| { + let a_downloaded = a.status.state == LocalInferenceDownloadState::Downloaded; + let b_downloaded = b.status.state == LocalInferenceDownloadState::Downloaded; + match (b_downloaded, a_downloaded) { + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + _ => a.id.cmp(&b.id), + } + }); + + Ok(LocalInferenceModelsListResponse { models }) +} + +pub async fn search_huggingface_models( + query: String, + limit: Option, +) -> Result { + let limit = limit.unwrap_or(20).min(50); + let models = hf_models::search_local_models(&query, limit) + .await? + .into_iter() + .map(hf_model_info_to_dto) + .collect(); + Ok(LocalInferenceHuggingFaceSearchResponse { models }) +} + +pub async fn huggingface_repo_variants( + repo_id: String, +) -> Result { + let variants = hf_models::get_repo_local_variants(&repo_id).await?; + + let runtime = management_runtime()?; + let available_memory = available_inference_memory_bytes(&runtime); + let gguf_variants: Vec<_> = variants + .iter() + .filter(|variant| variant.backend_id == "llamacpp") + .map(|variant| hf_models::HfQuantVariant { + quantization: variant.variant_id.clone(), + size_bytes: variant.size_bytes, + filename: variant.filename.clone().unwrap_or_default(), + download_url: variant.download_url.clone().unwrap_or_default(), + description: "", + quality_rank: variant.quality_rank, + sharded: variant.sharded, + }) + .collect(); + let recommended_index = hf_models::recommend_variant(&gguf_variants, available_memory); + + let (downloaded_quants, downloaded_variants) = { + let registry = get_registry() + .lock() + .map_err(|_| anyhow!("Failed to acquire registry lock"))?; + let models: Vec<_> = registry + .list_models() + .iter() + .filter(|m| m.repo_id == repo_id && m.is_downloaded()) + .collect(); + ( + models.iter().map(|m| m.quantization.clone()).collect(), + models.iter().map(|m| m.id.clone()).collect(), + ) + }; + + Ok(LocalInferenceHuggingFaceRepoVariantsResponse { + variants: variants.into_iter().map(hf_model_variant_to_dto).collect(), + recommended_index, + available_memory_bytes: available_memory, + downloaded_quants, + downloaded_variants, + }) +} + +pub async fn download_model( + req: LocalInferenceModelDownloadRequest, +) -> Result { + let selection = explicit_model_selection(&req)?; + let model_id = local_model_id_from_request(&req, selection.as_ref()).await?; + let download_id = format!("{}-model", model_id); + let download_reserved = get_download_manager().reserve_download(DownloadProgress { + model_id: download_id, + status: DownloadStatus::Downloading, + bytes_downloaded: 0, + total_bytes: 0, + progress_percent: 0.0, + speed_bps: None, + eta_seconds: None, + error: None, + task_exited: false, + })?; + if !download_reserved { + return Ok(LocalInferenceModelDownloadResponse { model_id }); + } + + if let Err(error) = register_pending_download_model(&model_id, &req, selection.as_ref()) { + mark_download_failed(&model_id, &error); + return Err(error.context("Failed to register download")); + } + + let spec = req.spec.clone(); + let selection_for_task = selection.clone(); + let model_id_for_task = model_id.clone(); + tokio::spawn(async move { + let resolved = if let Some(selection) = selection_for_task { + resolve_local_model_selection( + &selection.repo_id, + &selection.backend_id, + selection.variant_id.as_deref(), + ) + .await + } else { + resolve_local_model_spec(&spec).await + }; + match resolved { + Ok(resolved) => { + if !model_download_completed(&model_id_for_task) { + return; + } + if let Err(error) = register_resolved_model(resolved, &spec) { + mark_download_failed(&model_id_for_task, error); + } + } + Err(error) => mark_download_failed(&model_id_for_task, error), + } + }); + + Ok(LocalInferenceModelDownloadResponse { model_id }) +} + +pub fn download_progress(model_id: &str) -> Result> { + Ok(get_download_manager() + .get_progress(&format!("{}-model", model_id)) + .map(download_progress_to_dto)) +} + +pub fn cancel_download(model_id: &str) -> Result<()> { + let manager = get_download_manager(); + manager.cancel_download(&format!("{}-model", model_id))?; + let _ = manager.cancel_download(&format!("{}-mmproj", model_id)); + Ok(()) +} + +pub fn delete_model(model_id: &str) -> Result<()> { + let mut registry = get_registry() + .lock() + .map_err(|_| anyhow!("Failed to acquire registry lock"))?; + if registry.get_model(model_id).is_none() { + anyhow::bail!("Model not found"); + } + registry.delete_model(model_id) +} + +pub fn get_model_settings(model_id: &str) -> Result { + let registry = get_registry() + .lock() + .map_err(|_| anyhow!("Failed to acquire registry lock"))?; + let settings = registry + .get_model_settings(model_id) + .ok_or_else(|| anyhow!("Model not found"))?; + Ok(LocalInferenceModelSettingsReadResponse { + settings: model_settings_to_dto(settings), + }) +} + +pub fn update_model_settings( + model_id: &str, + settings: LocalInferenceModelSettingsDto, +) -> Result { + let settings = model_settings_from_dto(settings); + let mut registry = get_registry() + .lock() + .map_err(|_| anyhow!("Failed to acquire registry lock"))?; + registry.update_model_settings(model_id, settings.clone())?; + Ok(LocalInferenceModelSettingsUpdateResponse { + settings: model_settings_to_dto(&settings), + }) +} + +pub fn list_builtin_chat_templates() -> LocalInferenceBuiltinChatTemplatesListResponse { + LocalInferenceBuiltinChatTemplatesListResponse { + templates: builtin_chat_template_names(), + } +} + +fn management_runtime() -> Result> { + if let Some(runtime) = MANAGEMENT_RUNTIME.get() { + return Ok(runtime.clone()); + } + + let runtime = InferenceRuntime::get_or_init()?; + match MANAGEMENT_RUNTIME.set(runtime.clone()) { + Ok(()) => Ok(runtime), + Err(_) => Ok(MANAGEMENT_RUNTIME + .get() + .expect("local inference management runtime initialized by another thread") + .clone()), + } +} + +pub async fn ensure_featured_models_current() -> Result<()> { + let mut mmproj_downloads_needed: Vec<(String, String, PathBuf)> = Vec::new(); + + struct PendingResolve { + spec: &'static str, + repo_id: String, + quantization: String, + model_id: String, + } + let mut to_resolve = Vec::new(); + + for featured in FEATURED_MODELS { + let (repo_id, quantization) = match hf_models::parse_model_spec(featured.spec) { + Ok(parts) => parts, + Err(_) => continue, + }; + + let model_id = model_id_from_repo(&repo_id, &quantization); + + { + let registry = get_registry() + .lock() + .map_err(|_| anyhow!("Failed to acquire registry lock"))?; + if let Some(existing) = registry.get_model(&model_id) { + let needs_backfill = existing.mmproj_path.is_none() && featured.mmproj.is_some(); + let needs_download = existing.is_downloaded() + && featured.mmproj.is_some() + && !existing.mmproj_path.as_ref().is_some_and(|p| p.exists()); + + if needs_download { + if let Some(mmproj) = featured.mmproj.as_ref() { + let path = mmproj.local_path(); + let url = format!( + "https://huggingface.co/{}/resolve/main/{}", + mmproj.repo, mmproj.filename + ); + mmproj_downloads_needed.push((model_id.clone(), url, path)); + } + } + + if !needs_backfill { + continue; + } + } + } + + to_resolve.push(PendingResolve { + spec: featured.spec, + repo_id, + quantization, + model_id, + }); + } + + let resolved: Vec<(PendingResolve, HfGgufFile)> = + join_all(to_resolve.into_iter().map(|pending| async move { + let hf_file = match resolve_model_spec(pending.spec).await { + Ok((_repo, file)) => file, + Err(_) => { + let filename = format!( + "{}-{}.gguf", + pending.repo_id.split('/').next_back().unwrap_or("model"), + pending.quantization + ); + HfGgufFile { + filename: filename.clone(), + size_bytes: 0, + quantization: pending.quantization.to_string(), + download_url: format!( + "https://huggingface.co/{}/resolve/main/{}", + pending.repo_id, filename + ), + } + } + }; + (pending, hf_file) + })) + .await; + + let entries_to_add: Vec = resolved + .into_iter() + .map(|(pending, hf_file)| { + let local_path = Paths::in_data_dir("models").join(&hf_file.filename); + let settings = default_settings_for_model(&pending.model_id); + LocalModelEntry { + id: pending.model_id, + repo_id: pending.repo_id, + filename: hf_file.filename, + quantization: pending.quantization, + local_path, + source_url: hf_file.download_url, + backend_id: settings.backend_id.clone(), + storage: LocalModelStorage::GooseManaged, + settings, + size_bytes: hf_file.size_bytes, + mmproj_path: None, + mmproj_source_url: None, + mmproj_size_bytes: 0, + mmproj_checked: false, + shard_files: vec![], + } + }) + .collect(); + + { + let mut registry = get_registry() + .lock() + .map_err(|_| anyhow!("Failed to acquire registry lock"))?; + + if !entries_to_add.is_empty() { + registry.sync_with_featured(entries_to_add); + } + + for model in registry.list_models_mut() { + model.enrich_with_featured_mmproj(); + if model.is_downloaded() { + if let Some(mmproj) = featured_mmproj_spec(&model.id) { + let path = mmproj.local_path(); + if !path.exists() { + let url = format!( + "https://huggingface.co/{}/resolve/main/{}", + mmproj.repo, mmproj.filename + ); + mmproj_downloads_needed.push((model.id.clone(), url, path)); + } + } + } + } + let _ = registry.save(); + } + + let dm = get_download_manager(); + let hf_token = huggingface_auth::resolve_token_async().await.ok().flatten(); + let mut started_paths = std::collections::HashSet::new(); + for (model_id, url, path) in mmproj_downloads_needed { + if !path.exists() && started_paths.insert(path.clone()) { + let download_id = format!("{}-mmproj", model_id); + let dominated_by_active = dm + .get_progress(&download_id) + .is_some_and(|p| p.status == DownloadStatus::Downloading); + if !dominated_by_active { + tracing::info!(model_id = %model_id, "Auto-downloading vision encoder for existing model"); + if let Err(e) = dm + .download_model_with_bearer_token( + download_id, + url, + path, + hf_token.clone(), + None, + ) + .await + { + tracing::warn!(model_id = %model_id, error = %e, "Failed to start mmproj download"); + } + } + } + } + + Ok(()) +} + +fn local_model_to_dto(entry: &LocalModelEntry, recommended_id: &str) -> LocalInferenceModelDto { + let vision_capable = entry.settings.vision_capable; + LocalInferenceModelDto { + id: entry.id.clone(), + repo_id: entry.repo_id.clone(), + filename: entry.filename.clone(), + quantization: entry.quantization.clone(), + size_bytes: entry.file_size(), + status: model_download_status_to_dto(entry.download_status()), + recommended: recommended_id == entry.id, + settings: model_settings_to_dto(&entry.settings), + vision_capable, + mmproj_status: vision_capable + .then(|| model_download_status_to_dto(entry.mmproj_download_status())), + } +} + +fn model_download_status_to_dto( + status: ModelDownloadStatus, +) -> LocalInferenceModelDownloadStatusDto { + match status { + ModelDownloadStatus::NotDownloaded => LocalInferenceModelDownloadStatusDto { + state: LocalInferenceDownloadState::NotDownloaded, + ..Default::default() + }, + ModelDownloadStatus::Downloading { + progress_percent, + bytes_downloaded, + total_bytes, + speed_bps, + } => LocalInferenceModelDownloadStatusDto { + state: LocalInferenceDownloadState::Downloading, + progress_percent: Some(progress_percent), + bytes_downloaded: Some(bytes_downloaded), + total_bytes: Some(total_bytes), + speed_bps: Some(speed_bps), + }, + ModelDownloadStatus::Downloaded => LocalInferenceModelDownloadStatusDto { + state: LocalInferenceDownloadState::Downloaded, + ..Default::default() + }, + } +} + +fn download_progress_to_dto(progress: DownloadProgress) -> LocalInferenceDownloadProgressDto { + LocalInferenceDownloadProgressDto { + model_id: progress.model_id, + status: serde_json::to_value(progress.status) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "unknown".to_string()), + bytes_downloaded: progress.bytes_downloaded, + total_bytes: progress.total_bytes, + progress_percent: progress.progress_percent, + speed_bps: progress.speed_bps, + eta_seconds: progress.eta_seconds, + error: progress.error, + task_exited: progress.task_exited, + } +} + +fn hf_model_info_to_dto(model: HfModelInfo) -> LocalInferenceHfModelInfoDto { + LocalInferenceHfModelInfoDto { + repo_id: model.repo_id, + author: model.author, + model_name: model.model_name, + downloads: model.downloads, + gguf_files: model + .gguf_files + .into_iter() + .map(|file| LocalInferenceHfGgufFileDto { + filename: file.filename, + size_bytes: file.size_bytes, + quantization: file.quantization, + download_url: file.download_url, + }) + .collect(), + variants: model + .variants + .into_iter() + .map(hf_model_variant_to_dto) + .collect(), + } +} + +fn hf_model_variant_to_dto(variant: HfModelVariant) -> LocalInferenceHfModelVariantDto { + LocalInferenceHfModelVariantDto { + variant_id: variant.variant_id, + label: variant.label, + backend_id: variant.backend_id, + format: variant.format, + model_id: variant.model_id, + download_id: variant.download_id, + size_bytes: variant.size_bytes, + filename: variant.filename, + download_url: variant.download_url, + description: variant.description, + quality_rank: variant.quality_rank, + sharded: variant.sharded, + supported: variant.supported, + unsupported_reason: variant.unsupported_reason, + } +} + +pub fn model_settings_to_dto(settings: &ModelSettings) -> LocalInferenceModelSettingsDto { + LocalInferenceModelSettingsDto { + backend_id: settings.backend_id.clone(), + context_size: settings.context_size, + max_output_tokens: settings.max_output_tokens, + draft_model: settings.draft_model.clone(), + sampling: sampling_to_dto(&settings.sampling), + repeat_penalty: settings.repeat_penalty, + repeat_last_n: settings.repeat_last_n, + frequency_penalty: settings.frequency_penalty, + presence_penalty: settings.presence_penalty, + n_batch: settings.n_batch, + n_gpu_layers: settings.n_gpu_layers, + use_mlock: settings.use_mlock, + flash_attention: settings.flash_attention, + n_threads: settings.n_threads, + tool_calling: tool_calling_to_dto(settings.tool_calling), + chat_template: chat_template_to_dto(&settings.chat_template), + enable_thinking: settings.enable_thinking, + vision_capable: settings.vision_capable, + image_token_estimate: settings.image_token_estimate, + mmproj_size_bytes: settings.mmproj_size_bytes, + } +} + +pub fn model_settings_from_dto(settings: LocalInferenceModelSettingsDto) -> ModelSettings { + ModelSettings { + backend_id: settings.backend_id, + context_size: settings.context_size, + max_output_tokens: settings.max_output_tokens, + draft_model: settings.draft_model, + sampling: sampling_from_dto(settings.sampling), + repeat_penalty: settings.repeat_penalty, + repeat_last_n: settings.repeat_last_n, + frequency_penalty: settings.frequency_penalty, + presence_penalty: settings.presence_penalty, + n_batch: settings.n_batch, + n_gpu_layers: settings.n_gpu_layers, + use_mlock: settings.use_mlock, + flash_attention: settings.flash_attention, + n_threads: settings.n_threads, + tool_calling: tool_calling_from_dto(settings.tool_calling), + chat_template: chat_template_from_dto(settings.chat_template), + enable_thinking: settings.enable_thinking, + vision_capable: settings.vision_capable, + image_token_estimate: settings.image_token_estimate, + mmproj_size_bytes: settings.mmproj_size_bytes, + } +} + +fn sampling_to_dto(sampling: &SamplingConfig) -> LocalInferenceSamplingConfig { + match sampling { + SamplingConfig::Greedy => LocalInferenceSamplingConfig::Greedy, + SamplingConfig::Temperature { + temperature, + top_k, + top_p, + min_p, + seed, + } => LocalInferenceSamplingConfig::Temperature { + temperature: *temperature, + top_k: *top_k, + top_p: *top_p, + min_p: *min_p, + seed: *seed, + }, + SamplingConfig::MirostatV2 { tau, eta, seed } => LocalInferenceSamplingConfig::MirostatV2 { + tau: *tau, + eta: *eta, + seed: *seed, + }, + } +} + +fn sampling_from_dto(sampling: LocalInferenceSamplingConfig) -> SamplingConfig { + match sampling { + LocalInferenceSamplingConfig::Greedy => SamplingConfig::Greedy, + LocalInferenceSamplingConfig::Temperature { + temperature, + top_k, + top_p, + min_p, + seed, + } => SamplingConfig::Temperature { + temperature, + top_k, + top_p, + min_p, + seed, + }, + LocalInferenceSamplingConfig::MirostatV2 { tau, eta, seed } => { + SamplingConfig::MirostatV2 { tau, eta, seed } + } + } +} + +fn tool_calling_to_dto(mode: ToolCallingMode) -> LocalInferenceToolCallingMode { + match mode { + ToolCallingMode::Auto => LocalInferenceToolCallingMode::Auto, + ToolCallingMode::ForceNative => LocalInferenceToolCallingMode::ForceNative, + ToolCallingMode::ForceEmulated => LocalInferenceToolCallingMode::ForceEmulated, + } +} + +fn tool_calling_from_dto(mode: LocalInferenceToolCallingMode) -> ToolCallingMode { + match mode { + LocalInferenceToolCallingMode::Auto => ToolCallingMode::Auto, + LocalInferenceToolCallingMode::ForceNative => ToolCallingMode::ForceNative, + LocalInferenceToolCallingMode::ForceEmulated => ToolCallingMode::ForceEmulated, + } +} + +fn chat_template_to_dto(template: &ChatTemplate) -> LocalInferenceChatTemplate { + match template { + ChatTemplate::Embedded => LocalInferenceChatTemplate::Embedded, + ChatTemplate::Builtin { name } => { + LocalInferenceChatTemplate::Builtin { name: name.clone() } + } + ChatTemplate::CustomInline { template } => LocalInferenceChatTemplate::CustomInline { + template: template.clone(), + }, + } +} + +fn chat_template_from_dto(template: LocalInferenceChatTemplate) -> ChatTemplate { + match template { + LocalInferenceChatTemplate::Embedded => ChatTemplate::Embedded, + LocalInferenceChatTemplate::Builtin { name } => ChatTemplate::Builtin { name }, + LocalInferenceChatTemplate::CustomInline { template } => { + ChatTemplate::CustomInline { template } + } + } +} + +fn explicit_model_selection( + req: &LocalInferenceModelDownloadRequest, +) -> Result> { + if let Some(backend_id) = req.backend_id.as_deref() { + let (repo_id, parsed_variant_id) = hf_models::parse_model_spec(&req.spec) + .map(|(repo_id, quantization)| (repo_id, Some(quantization))) + .unwrap_or_else(|_| (req.spec.clone(), None)); + let variant_id = req.variant_id.clone().or(parsed_variant_id); + match backend_id { + "mlx" | "llamacpp" => Ok(Some(LocalModelSelection { + repo_id, + backend_id: backend_id.to_string(), + variant_id, + })), + _ => anyhow::bail!("Unknown local inference backend '{}'", backend_id), + } + } else { + Ok(None) + } +} + +async fn local_model_id_from_request( + req: &LocalInferenceModelDownloadRequest, + selection: Option<&LocalModelSelection>, +) -> Result { + if let Some(selection) = selection { + return match selection.backend_id.as_str() { + "mlx" => Ok(selection.repo_id.clone()), + "llamacpp" => { + let quantization = selection.variant_id.as_deref().ok_or_else(|| { + anyhow!( + "llama.cpp model '{}' is missing a quantization", + selection.repo_id + ) + })?; + Ok(model_id_from_repo(&selection.repo_id, quantization)) + } + _ => anyhow::bail!("Unknown local inference backend '{}'", selection.backend_id), + }; + } + + if let Ok((repo_id, quantization)) = hf_models::parse_model_spec(&req.spec) { + return Ok(model_id_from_repo(&repo_id, &quantization)); + } + + let variants = hf_models::get_repo_local_variants(&req.spec).await?; + let has_llamacpp = variants + .iter() + .any(|variant| variant.backend_id == "llamacpp"); + let mlx_variants: Vec<_> = variants + .iter() + .filter(|variant| variant.backend_id == "mlx") + .collect(); + if mlx_variants.len() == 1 && !has_llamacpp { + Ok(req.spec.clone()) + } else { + anyhow::bail!( + "Model spec '{}' is ambiguous; choose one of: {}", + req.spec, + variants + .iter() + .map(|variant| variant.download_id.as_str()) + .collect::>() + .join(", ") + ) + } +} + +fn mark_download_failed(model_id: &str, error: impl std::fmt::Display) { + let manager = get_download_manager(); + let download_id = format!("{}-model", model_id); + if manager.get_progress(&download_id).is_none() { + manager.set_progress(DownloadProgress { + model_id: download_id.clone(), + status: DownloadStatus::Failed, + bytes_downloaded: 0, + total_bytes: 0, + progress_percent: 0.0, + speed_bps: None, + eta_seconds: None, + error: Some(error.to_string()), + task_exited: true, + }); + return; + } + + manager.update_progress(&download_id, |progress| { + if progress.status != DownloadStatus::Cancelled { + progress.status = DownloadStatus::Failed; + progress.error = Some(error.to_string()); + } + progress.task_exited = true; + }); +} + +fn model_download_completed(model_id: &str) -> bool { + get_download_manager() + .get_progress(&format!("{}-model", model_id)) + .is_some_and(|progress| progress.status == DownloadStatus::Completed) +} + +fn register_pending_download_model( + model_id: &str, + req: &LocalInferenceModelDownloadRequest, + selection: Option<&LocalModelSelection>, +) -> Result<()> { + let (repo_id, backend_id, variant_id) = if let Some(selection) = selection { + ( + selection.repo_id.clone(), + selection.backend_id.clone(), + selection + .variant_id + .clone() + .unwrap_or_else(|| "default".to_string()), + ) + } else if let Ok((repo_id, quantization)) = hf_models::parse_model_spec(&req.spec) { + (repo_id, "llamacpp".to_string(), quantization) + } else { + (req.spec.clone(), "mlx".to_string(), "default".to_string()) + }; + + let mut registry = get_registry() + .lock() + .map_err(|_| anyhow!("Failed to acquire registry lock"))?; + if registry.has_model(model_id) { + return Ok(()); + } + + let mut settings = default_settings_for_model(model_id); + if backend_id != "llamacpp" { + settings.backend_id = Some(backend_id.clone()); + } + + let filename = variant_id.clone(); + registry.add_model(LocalModelEntry { + id: model_id.to_string(), + repo_id, + filename: filename.clone(), + quantization: variant_id, + local_path: Paths::in_data_dir("models").join(filename), + source_url: req.spec.clone(), + backend_id: settings.backend_id.clone(), + storage: LocalModelStorage::HuggingFaceCache, + settings, + size_bytes: 0, + mmproj_path: None, + mmproj_source_url: None, + mmproj_size_bytes: 0, + mmproj_checked: false, + shard_files: vec![], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_round_trip_preserves_defaults() { + let settings = ModelSettings::default(); + let dto = model_settings_to_dto(&settings); + let round_trip = model_settings_from_dto(dto); + assert_eq!(round_trip.repeat_penalty, settings.repeat_penalty); + assert_eq!(round_trip.repeat_last_n, settings.repeat_last_n); + assert_eq!(round_trip.enable_thinking, settings.enable_thinking); + assert_eq!( + round_trip.image_token_estimate, + settings.image_token_estimate + ); + } + + #[tokio::test] + async fn explicit_llamacpp_selection_derives_quantized_model_id() { + let req = LocalInferenceModelDownloadRequest { + spec: "test/repo".to_string(), + backend_id: Some("llamacpp".to_string()), + variant_id: Some("Q4_K_M".to_string()), + }; + let selection = explicit_model_selection(&req).unwrap(); + let model_id = local_model_id_from_request(&req, selection.as_ref()) + .await + .unwrap(); + assert_eq!(model_id, "test/repo:Q4_K_M"); + } +} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index bb6e4bcebe..e8ce9725a3 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1643,344 +1643,6 @@ } } }, - "/local-inference/chat-templates/builtin": { - "get": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "list_builtin_chat_templates", - "responses": { - "200": { - "description": "llama.cpp built-in chat template names", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - } - }, - "/local-inference/download": { - "post": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "download_hf_model", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DownloadModelRequest" - } - } - }, - "required": true - }, - "responses": { - "202": { - "description": "Download started", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Invalid request" - } - } - } - }, - "/local-inference/models": { - "get": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "list_local_models", - "responses": { - "200": { - "description": "List of available local LLM models", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LocalModelResponse" - } - } - } - } - } - } - } - }, - "/local-inference/models/{model_id}": { - "delete": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "delete_local_model", - "parameters": [ - { - "name": "model_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Model deleted" - }, - "404": { - "description": "Model not found" - } - } - } - }, - "/local-inference/models/{model_id}/download": { - "get": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "get_local_model_download_progress", - "parameters": [ - { - "name": "model_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Download progress", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DownloadProgress" - } - } - } - }, - "404": { - "description": "No active download" - } - } - }, - "delete": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "cancel_local_model_download", - "parameters": [ - { - "name": "model_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Download cancelled" - }, - "404": { - "description": "No active download" - } - } - } - }, - "/local-inference/models/{model_id}/settings": { - "get": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "get_model_settings", - "parameters": [ - { - "name": "model_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Model settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModelSettings" - } - } - } - }, - "404": { - "description": "Model not found" - } - } - }, - "put": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "update_model_settings", - "parameters": [ - { - "name": "model_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModelSettings" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Settings updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModelSettings" - } - } - } - }, - "404": { - "description": "Model not found" - }, - "500": { - "description": "Failed to save settings" - } - } - } - }, - "/local-inference/repo/{author}/{repo}/files": { - "get": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "get_repo_files", - "parameters": [ - { - "name": "author", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "repo", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "GGUF files in the repo", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepoVariantsResponse" - } - } - } - } - } - } - }, - "/local-inference/search": { - "get": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "search_hf_models", - "parameters": [ - { - "name": "q", - "in": "query", - "description": "Search query", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "description": "Max results", - "required": false, - "schema": { - "type": "integer", - "nullable": true, - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "Search results", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/HfModelInfo" - } - } - } - } - }, - "500": { - "description": "Search failed" - } - } - } - }, - "/local-inference/sync-featured": { - "post": { - "tags": [ - "super::routes::local_inference" - ], - "operationId": "sync_featured_models", - "responses": { - "200": { - "description": "Featured models synced to registry" - } - } - } - }, "/recipes/decode": { "post": { "tags": [ @@ -3365,63 +3027,6 @@ } } }, - "ChatTemplate": { - "oneOf": [ - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "embedded" - ] - } - } - }, - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "builtin" - ] - } - } - }, - { - "type": "object", - "required": [ - "template", - "type" - ], - "properties": { - "template": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "custom_inline" - ] - } - } - } - ], - "discriminator": { - "propertyName": "type" - } - }, "CheckProviderRequest": { "type": "object", "required": [ @@ -4194,28 +3799,6 @@ } } }, - "DownloadModelRequest": { - "type": "object", - "required": [ - "spec" - ], - "properties": { - "backend_id": { - "type": "string", - "description": "Optional backend id for callers selecting a concrete variant row.", - "nullable": true - }, - "spec": { - "type": "string", - "description": "Model spec/download id like \"bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M\" or \"google/gemma-4-31B-it\"" - }, - "variant_id": { - "type": "string", - "description": "Optional backend-specific variant id, such as a GGUF quantization or MLX dtype.", - "nullable": true - } - } - }, "DownloadProgress": { "type": "object", "required": [ @@ -4898,174 +4481,6 @@ "chat" ] }, - "HfGgufFile": { - "type": "object", - "description": "A single downloadable GGUF file (used internally and for downloads).", - "required": [ - "filename", - "size_bytes", - "quantization", - "download_url" - ], - "properties": { - "download_url": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "quantization": { - "type": "string" - }, - "size_bytes": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - }, - "HfModelInfo": { - "type": "object", - "required": [ - "repo_id", - "author", - "model_name", - "downloads", - "gguf_files" - ], - "properties": { - "author": { - "type": "string" - }, - "downloads": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "gguf_files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/HfGgufFile" - } - }, - "model_name": { - "type": "string" - }, - "repo_id": { - "type": "string" - }, - "variants": { - "type": "array", - "items": { - "$ref": "#/components/schemas/HfModelVariant" - } - } - } - }, - "HfModelVariant": { - "type": "object", - "required": [ - "variant_id", - "label", - "backend_id", - "format", - "model_id", - "download_id", - "size_bytes", - "description", - "quality_rank" - ], - "properties": { - "backend_id": { - "type": "string" - }, - "description": { - "type": "string" - }, - "download_id": { - "type": "string" - }, - "download_url": { - "type": "string", - "nullable": true - }, - "filename": { - "type": "string", - "nullable": true - }, - "format": { - "type": "string" - }, - "label": { - "type": "string" - }, - "model_id": { - "type": "string" - }, - "quality_rank": { - "type": "integer", - "format": "int32", - "minimum": 0 - }, - "sharded": { - "type": "boolean" - }, - "size_bytes": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "supported": { - "type": "boolean" - }, - "unsupported_reason": { - "type": "string", - "nullable": true - }, - "variant_id": { - "type": "string" - } - } - }, - "HfQuantVariant": { - "type": "object", - "description": "A quantization variant — groups sharded files into one logical entry.", - "required": [ - "quantization", - "size_bytes", - "filename", - "download_url", - "description", - "quality_rank" - ], - "properties": { - "description": { - "type": "string" - }, - "download_url": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "quality_rank": { - "type": "integer", - "format": "int32", - "minimum": 0 - }, - "quantization": { - "type": "string" - }, - "sharded": { - "type": "boolean" - }, - "size_bytes": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - }, "Icon": { "type": "object", "required": [ @@ -5236,59 +4651,6 @@ } } }, - "LocalModelResponse": { - "type": "object", - "required": [ - "id", - "repo_id", - "filename", - "quantization", - "size_bytes", - "status", - "recommended", - "settings", - "vision_capable" - ], - "properties": { - "filename": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mmproj_status": { - "allOf": [ - { - "$ref": "#/components/schemas/ModelDownloadStatus" - } - ], - "nullable": true - }, - "quantization": { - "type": "string" - }, - "recommended": { - "type": "boolean" - }, - "repo_id": { - "type": "string" - }, - "settings": { - "$ref": "#/components/schemas/ModelSettings" - }, - "size_bytes": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "status": { - "$ref": "#/components/schemas/ModelDownloadStatus" - }, - "vision_capable": { - "type": "boolean" - } - } - }, "McpAppResource": { "type": "object", "description": "MCP App Resource\nRepresents a UI resource that can be rendered in an MCP App", @@ -5827,78 +5189,6 @@ } } }, - "ModelDownloadStatus": { - "oneOf": [ - { - "type": "object", - "required": [ - "state" - ], - "properties": { - "state": { - "type": "string", - "enum": [ - "NotDownloaded" - ] - } - } - }, - { - "type": "object", - "required": [ - "progress_percent", - "bytes_downloaded", - "total_bytes", - "state" - ], - "properties": { - "bytes_downloaded": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "progress_percent": { - "type": "number", - "format": "float" - }, - "speed_bps": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - }, - "state": { - "type": "string", - "enum": [ - "Downloading" - ] - }, - "total_bytes": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - }, - { - "type": "object", - "required": [ - "state" - ], - "properties": { - "state": { - "type": "string", - "enum": [ - "Downloaded" - ] - } - } - } - ], - "discriminator": { - "propertyName": "state" - } - }, "ModelInfo": { "type": "object", "description": "Information about a model's capabilities", @@ -6036,98 +5326,6 @@ } } }, - "ModelSettings": { - "type": "object", - "properties": { - "backend_id": { - "type": "string", - "description": "Backend implementation to use for this model. Defaults to llama.cpp.", - "nullable": true - }, - "chat_template": { - "$ref": "#/components/schemas/ChatTemplate" - }, - "context_size": { - "type": "integer", - "format": "int32", - "nullable": true, - "minimum": 0 - }, - "draft_model": { - "type": "string", - "nullable": true - }, - "enable_thinking": { - "type": "boolean" - }, - "flash_attention": { - "type": "boolean", - "nullable": true - }, - "frequency_penalty": { - "type": "number", - "format": "float" - }, - "image_token_estimate": { - "type": "integer", - "description": "Estimated tokens per image for budget planning before mtmd tokenization.\nThe actual count is determined after tokenization via `chunks.total_tokens()`.", - "minimum": 0 - }, - "max_output_tokens": { - "type": "integer", - "nullable": true, - "minimum": 0 - }, - "mmproj_size_bytes": { - "type": "integer", - "format": "int64", - "description": "Size of the mmproj file in bytes, used for memory accounting.", - "minimum": 0 - }, - "n_batch": { - "type": "integer", - "format": "int32", - "nullable": true, - "minimum": 0 - }, - "n_gpu_layers": { - "type": "integer", - "format": "int32", - "nullable": true, - "minimum": 0 - }, - "n_threads": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "presence_penalty": { - "type": "number", - "format": "float" - }, - "repeat_last_n": { - "type": "integer", - "format": "int32" - }, - "repeat_penalty": { - "type": "number", - "format": "float" - }, - "sampling": { - "$ref": "#/components/schemas/SamplingConfig" - }, - "tool_calling": { - "$ref": "#/components/schemas/ToolCallingMode" - }, - "use_mlock": { - "type": "boolean" - }, - "vision_capable": { - "type": "boolean", - "description": "Whether this model architecture supports vision input.\nDerived from associated mmproj metadata, not user-configurable." - } - } - }, "ModelTemplate": { "type": "object", "required": [ @@ -6881,45 +6079,6 @@ } } }, - "RepoVariantsResponse": { - "type": "object", - "required": [ - "variants", - "available_memory_bytes", - "downloaded_quants", - "downloaded_variants" - ], - "properties": { - "available_memory_bytes": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "downloaded_quants": { - "type": "array", - "items": { - "type": "string" - } - }, - "downloaded_variants": { - "type": "array", - "items": { - "type": "string" - } - }, - "recommended_index": { - "type": "integer", - "nullable": true, - "minimum": 0 - }, - "variants": { - "type": "array", - "items": { - "$ref": "#/components/schemas/HfModelVariant" - } - } - } - }, "ResourceContents": { "anyOf": [ { @@ -7117,97 +6276,6 @@ } } }, - "SamplingConfig": { - "oneOf": [ - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "Greedy" - ] - } - } - }, - { - "type": "object", - "required": [ - "temperature", - "top_k", - "top_p", - "min_p", - "type" - ], - "properties": { - "min_p": { - "type": "number", - "format": "float" - }, - "seed": { - "type": "integer", - "format": "int32", - "nullable": true, - "minimum": 0 - }, - "temperature": { - "type": "number", - "format": "float" - }, - "top_k": { - "type": "integer", - "format": "int32" - }, - "top_p": { - "type": "number", - "format": "float" - }, - "type": { - "type": "string", - "enum": [ - "Temperature" - ] - } - } - }, - { - "type": "object", - "required": [ - "tau", - "eta", - "type" - ], - "properties": { - "eta": { - "type": "number", - "format": "float" - }, - "seed": { - "type": "integer", - "format": "int32", - "nullable": true, - "minimum": 0 - }, - "tau": { - "type": "number", - "format": "float" - }, - "type": { - "type": "string", - "enum": [ - "MirostatV2" - ] - } - } - } - ], - "discriminator": { - "propertyName": "type" - } - }, "SavePromptRequest": { "type": "object", "required": [ @@ -8100,14 +7168,6 @@ } } }, - "ToolCallingMode": { - "type": "string", - "enum": [ - "auto", - "force_native", - "force_emulated" - ] - }, "ToolConfirmationRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/acp/local-inference.ts b/ui/desktop/src/acp/local-inference.ts new file mode 100644 index 0000000000..9003a4580d --- /dev/null +++ b/ui/desktop/src/acp/local-inference.ts @@ -0,0 +1,99 @@ +import type { + LocalInferenceDownloadProgressDto, + LocalInferenceHfModelInfoDto, + LocalInferenceHfModelVariantDto, + LocalInferenceModelDownloadRequest_unstable, + LocalInferenceModelDto, + LocalInferenceModelSettingsDto, +} from '@aaif/goose-sdk'; +import { getAcpClient } from './acpConnection'; + +export type LocalModelResponse = LocalInferenceModelDto; +export type DownloadProgress = LocalInferenceDownloadProgressDto; +export type DownloadModelRequest = LocalInferenceModelDownloadRequest_unstable; +export type HfModelInfo = LocalInferenceHfModelInfoDto; +export type HfModelVariant = LocalInferenceHfModelVariantDto; +export type ModelSettings = LocalInferenceModelSettingsDto; +export type SamplingConfig = NonNullable; +export type ToolCallingMode = NonNullable; +export type ChatTemplate = NonNullable; + +export type RepoVariantsResponse = { + variants: HfModelVariant[]; + recommendedIndex: number | null; + availableMemoryBytes: number; + downloadedQuants: string[]; + downloadedVariants: string[]; +}; + +export async function listLocalModels(): Promise { + const client = await getAcpClient(); + const response = await client.goose.localInferenceModelsList_unstable({}); + return response.models; +} + +export async function downloadHfModel(request: DownloadModelRequest): Promise { + const client = await getAcpClient(); + const response = await client.goose.localInferenceModelsDownload_unstable(request); + return response.modelId; +} + +export async function getLocalModelDownloadProgress( + modelId: string +): Promise { + const client = await getAcpClient(); + const response = await client.goose.localInferenceModelsDownloadProgress_unstable({ modelId }); + return response.progress ?? null; +} + +export async function cancelLocalModelDownload(modelId: string): Promise { + const client = await getAcpClient(); + await client.goose.localInferenceModelsDownloadCancel_unstable({ modelId }); +} + +export async function deleteLocalModel(modelId: string): Promise { + const client = await getAcpClient(); + await client.goose.localInferenceModelsDelete_unstable({ modelId }); +} + +export async function getModelSettings(modelId: string): Promise { + const client = await getAcpClient(); + const response = await client.goose.localInferenceModelsSettingsRead_unstable({ modelId }); + return response.settings; +} + +export async function updateModelSettings( + modelId: string, + settings: ModelSettings +): Promise { + const client = await getAcpClient(); + const response = await client.goose.localInferenceModelsSettingsUpdate_unstable({ + modelId, + settings, + }); + return response.settings; +} + +export async function searchHfModels(query: string, limit?: number): Promise { + const client = await getAcpClient(); + const response = await client.goose.localInferenceHuggingfaceSearch_unstable({ query, limit }); + return response.models; +} + +export async function getRepoFiles(repoId: string): Promise { + const client = await getAcpClient(); + const response = await client.goose.localInferenceHuggingfaceRepoVariants_unstable({ repoId }); + return { + variants: response.variants, + recommendedIndex: response.recommendedIndex ?? null, + availableMemoryBytes: response.availableMemoryBytes, + downloadedQuants: response.downloadedQuants, + downloadedVariants: response.downloadedVariants, + }; +} + +export async function listBuiltinChatTemplates(): Promise { + const client = await getAcpClient(); + const response = await client.goose.localInferenceChatTemplatesBuiltinList_unstable({}); + return response.templates; +} diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index 00453e033c..afe1f82543 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addExtension, agentAddExtension, agentRemoveExtension, cancelDownload, cancelLocalModelDownload, checkProvider, cleanupProviderCache, confirmToolAction, createCustomProvider, createSchedule, decodeRecipe, deleteLocalModel, deleteModel, deleteProviderSecret, deleteRecipe, deleteSchedule, diagnostics, downloadHfModel, downloadModel, encodeRecipe, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getLocalModelDownloadProgress, getModelSettings, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModelInfo, getProviderModels, getRepoFiles, getSession, getSessionExtensions, getSlashCommands, getTools, inspectRunningJob, killRunningJob, listBuiltinChatTemplates, listLocalModels, listModels, listProviderSecrets, listRecipes, listSchedules, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, recipeToYaml, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, searchHfModels, sendTelemetryEvent, sessionCancel, sessionEvents, sessionReply, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, status, stopAgent, syncFeaturedModels, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateModelSettings, updateSchedule, updateSession, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CancelRequest, ChatRequest, ChatTemplate, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponse, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DiagnosticsConfig, DiagnosticsData, DiagnosticsError, DiagnosticsErrors, DiagnosticsExtensions, DiagnosticsLevel, DiagnosticsLogs, DiagnosticsPrompt, DiagnosticsReport, DiagnosticsResponse, DiagnosticsResponses, DiagnosticsScheduledRecipe, DiagnosticsTextFile, DictationProvider, DictationProviderStatus, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponse, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelRequest, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponse, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponse, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponse, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GooseApp, GooseMode, HfGgufFile, HfModelInfo, HfModelVariant, HfQuantVariant, Icon, IconTheme, ImageContent, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListBuiltinChatTemplatesData, ListBuiltinChatTemplatesResponse, ListBuiltinChatTemplatesResponses, ListLocalModelsData, ListLocalModelsResponse, ListLocalModelsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponse, ListProviderSecretsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, LoadedProvider, LocalModelResponse, McpAppResource, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelDownloadStatus, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelSettings, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProviderSecret, ProviderSecretsResponse, ProviderSecretStatus, ProviderSecretStorage, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, RepoVariantsResponse, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SamplingConfig, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponse, SearchHfModelsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, SubRecipe, SuccessCheck, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolCallingMode, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponse, UpdateModelSettingsResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, Usage, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; +export { addExtension, agentAddExtension, agentRemoveExtension, cancelDownload, checkProvider, cleanupProviderCache, confirmToolAction, createCustomProvider, createSchedule, decodeRecipe, deleteModel, deleteProviderSecret, deleteRecipe, deleteSchedule, diagnostics, downloadModel, encodeRecipe, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModelInfo, getProviderModels, getSession, getSessionExtensions, getSlashCommands, getTools, inspectRunningJob, killRunningJob, listModels, listProviderSecrets, listRecipes, listSchedules, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, recipeToYaml, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, sendTelemetryEvent, sessionCancel, sessionEvents, sessionReply, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, status, stopAgent, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSession, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, validateConfig } from './sdk.gen'; +export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelRequest, ChatRequest, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponse, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DiagnosticsConfig, DiagnosticsData, DiagnosticsError, DiagnosticsErrors, DiagnosticsExtensions, DiagnosticsLevel, DiagnosticsLogs, DiagnosticsPrompt, DiagnosticsReport, DiagnosticsResponse, DiagnosticsResponses, DiagnosticsScheduledRecipe, DiagnosticsTextFile, DictationProvider, DictationProviderStatus, DownloadModelData, DownloadModelErrors, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GooseApp, GooseMode, Icon, IconTheme, ImageContent, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponse, ListProviderSecretsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, LoadedProvider, McpAppResource, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProviderSecret, ProviderSecretsResponse, ProviderSecretStatus, ProviderSecretStorage, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, Usage, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index f952f1d788..4786dcf270 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CheckProviderData, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListBuiltinChatTemplatesData, ListBuiltinChatTemplatesResponses, ListLocalModelsData, ListLocalModelsResponses, ListModelsData, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionCancelData, SessionCancelResponses, SessionEventsData, SessionEventsErrors, SessionEventsResponses, SessionReplyData, SessionReplyErrors, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CheckProviderData, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListModelsData, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionCancelData, SessionCancelResponses, SessionEventsData, SessionEventsErrors, SessionEventsResponses, SessionReplyData, SessionReplyErrors, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -275,42 +275,6 @@ export const transcribeDictation = (option } }); -export const listBuiltinChatTemplates = (options?: Options) => (options?.client ?? client).get({ url: '/local-inference/chat-templates/builtin', ...options }); - -export const downloadHfModel = (options: Options) => (options.client ?? client).post({ - url: '/local-inference/download', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const listLocalModels = (options?: Options) => (options?.client ?? client).get({ url: '/local-inference/models', ...options }); - -export const deleteLocalModel = (options: Options) => (options.client ?? client).delete({ url: '/local-inference/models/{model_id}', ...options }); - -export const cancelLocalModelDownload = (options: Options) => (options.client ?? client).delete({ url: '/local-inference/models/{model_id}/download', ...options }); - -export const getLocalModelDownloadProgress = (options: Options) => (options.client ?? client).get({ url: '/local-inference/models/{model_id}/download', ...options }); - -export const getModelSettings = (options: Options) => (options.client ?? client).get({ url: '/local-inference/models/{model_id}/settings', ...options }); - -export const updateModelSettings = (options: Options) => (options.client ?? client).put({ - url: '/local-inference/models/{model_id}/settings', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getRepoFiles = (options: Options) => (options.client ?? client).get({ url: '/local-inference/repo/{author}/{repo}/files', ...options }); - -export const searchHfModels = (options: Options) => (options.client ?? client).get({ url: '/local-inference/search', ...options }); - -export const syncFeaturedModels = (options?: Options) => (options?.client ?? client).post({ url: '/local-inference/sync-featured', ...options }); - export const decodeRecipe = (options: Options) => (options.client ?? client).post({ url: '/recipes/decode', ...options, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 4cc8816820..83c68e99e5 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -59,16 +59,6 @@ export type ChatRequest = { user_message: Message; }; -export type ChatTemplate = { - type: 'embedded'; -} | { - name: string; - type: 'builtin'; -} | { - template: string; - type: 'custom_inline'; -}; - export type CheckProviderRequest = { provider: string; }; @@ -316,21 +306,6 @@ export type DictationProviderStatus = { uses_provider_config: boolean; }; -export type DownloadModelRequest = { - /** - * Optional backend id for callers selecting a concrete variant row. - */ - backend_id?: string | null; - /** - * Model spec/download id like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" or "google/gemma-4-31B-it" - */ - spec: string; - /** - * Optional backend-specific variant id, such as a GGUF quantization or MLX dtype. - */ - variant_id?: string | null; -}; - export type DownloadProgress = { /** * Bytes downloaded so far @@ -568,55 +543,6 @@ export type GooseApp = McpAppResource & (WindowProps | null) & { export type GooseMode = 'auto' | 'approve' | 'smart_approve' | 'chat'; -/** - * A single downloadable GGUF file (used internally and for downloads). - */ -export type HfGgufFile = { - download_url: string; - filename: string; - quantization: string; - size_bytes: number; -}; - -export type HfModelInfo = { - author: string; - downloads: number; - gguf_files: Array; - model_name: string; - repo_id: string; - variants?: Array; -}; - -export type HfModelVariant = { - backend_id: string; - description: string; - download_id: string; - download_url?: string | null; - filename?: string | null; - format: string; - label: string; - model_id: string; - quality_rank: number; - sharded?: boolean; - size_bytes: number; - supported?: boolean; - unsupported_reason?: string | null; - variant_id: string; -}; - -/** - * A quantization variant — groups sharded files into one logical entry. - */ -export type HfQuantVariant = { - description: string; - download_url: string; - filename: string; - quality_rank: number; - quantization: string; - sharded?: boolean; - size_bytes: number; -}; - export type Icon = { mimeType?: string; sizes?: Array; @@ -672,19 +598,6 @@ export type LoadedProvider = { is_editable: boolean; }; -export type LocalModelResponse = { - filename: string; - id: string; - mmproj_status?: ModelDownloadStatus | null; - quantization: string; - recommended: boolean; - repo_id: string; - settings: ModelSettings; - size_bytes: number; - status: ModelDownloadStatus; - vision_capable: boolean; -}; - /** * MCP App Resource * Represents a UI resource that can be rendered in an MCP App @@ -824,18 +737,6 @@ export type ModelConfig = { toolshim_model?: string | null; }; -export type ModelDownloadStatus = { - state: 'NotDownloaded'; -} | { - bytes_downloaded: number; - progress_percent: number; - speed_bps?: number | null; - state: 'Downloading'; - total_bytes: number; -} | { - state: 'Downloaded'; -}; - /** * Information about a model's capabilities */ @@ -897,43 +798,6 @@ export type ModelInfoResponse = { source: string; }; -export type ModelSettings = { - /** - * Backend implementation to use for this model. Defaults to llama.cpp. - */ - backend_id?: string | null; - chat_template?: ChatTemplate; - context_size?: number | null; - draft_model?: string | null; - enable_thinking?: boolean; - flash_attention?: boolean | null; - frequency_penalty?: number; - /** - * Estimated tokens per image for budget planning before mtmd tokenization. - * The actual count is determined after tokenization via `chunks.total_tokens()`. - */ - image_token_estimate?: number; - max_output_tokens?: number | null; - /** - * Size of the mmproj file in bytes, used for memory accounting. - */ - mmproj_size_bytes?: number; - n_batch?: number | null; - n_gpu_layers?: number | null; - n_threads?: number | null; - presence_penalty?: number; - repeat_last_n?: number; - repeat_penalty?: number; - sampling?: SamplingConfig; - tool_calling?: ToolCallingMode; - use_mlock?: boolean; - /** - * Whether this model architecture supports vision input. - * Derived from associated mmproj metadata, not user-configurable. - */ - vision_capable?: boolean; -}; - export type ModelTemplate = { capabilities: ModelCapabilities; context_limit: number; @@ -1200,14 +1064,6 @@ export type RemoveExtensionRequest = { session_id: string; }; -export type RepoVariantsResponse = { - available_memory_bytes: number; - downloaded_quants: Array; - downloaded_variants: Array; - recommended_index?: number | null; - variants: Array; -}; - export type ResourceContents = { _meta?: { [key: string]: unknown; @@ -1285,22 +1141,6 @@ export type RunNowResponse = { session_id: string; }; -export type SamplingConfig = { - type: 'Greedy'; -} | { - min_p: number; - seed?: number | null; - temperature: number; - top_k: number; - top_p: number; - type: 'Temperature'; -} | { - eta: number; - seed?: number | null; - tau: number; - type: 'MirostatV2'; -}; - export type SavePromptRequest = { content: string; }; @@ -1572,8 +1412,6 @@ export type ToolAnnotations = { title?: string; }; -export type ToolCallingMode = 'auto' | 'force_native' | 'force_emulated'; - export type ToolConfirmationRequest = { arguments: JsonObject; id: string; @@ -3054,251 +2892,6 @@ export type TranscribeDictationResponses = { export type TranscribeDictationResponse = TranscribeDictationResponses[keyof TranscribeDictationResponses]; -export type ListBuiltinChatTemplatesData = { - body?: never; - path?: never; - query?: never; - url: '/local-inference/chat-templates/builtin'; -}; - -export type ListBuiltinChatTemplatesResponses = { - /** - * llama.cpp built-in chat template names - */ - 200: Array; -}; - -export type ListBuiltinChatTemplatesResponse = ListBuiltinChatTemplatesResponses[keyof ListBuiltinChatTemplatesResponses]; - -export type DownloadHfModelData = { - body: DownloadModelRequest; - path?: never; - query?: never; - url: '/local-inference/download'; -}; - -export type DownloadHfModelErrors = { - /** - * Invalid request - */ - 400: unknown; -}; - -export type DownloadHfModelResponses = { - /** - * Download started - */ - 202: string; -}; - -export type DownloadHfModelResponse = DownloadHfModelResponses[keyof DownloadHfModelResponses]; - -export type ListLocalModelsData = { - body?: never; - path?: never; - query?: never; - url: '/local-inference/models'; -}; - -export type ListLocalModelsResponses = { - /** - * List of available local LLM models - */ - 200: Array; -}; - -export type ListLocalModelsResponse = ListLocalModelsResponses[keyof ListLocalModelsResponses]; - -export type DeleteLocalModelData = { - body?: never; - path: { - model_id: string; - }; - query?: never; - url: '/local-inference/models/{model_id}'; -}; - -export type DeleteLocalModelErrors = { - /** - * Model not found - */ - 404: unknown; -}; - -export type DeleteLocalModelResponses = { - /** - * Model deleted - */ - 200: unknown; -}; - -export type CancelLocalModelDownloadData = { - body?: never; - path: { - model_id: string; - }; - query?: never; - url: '/local-inference/models/{model_id}/download'; -}; - -export type CancelLocalModelDownloadErrors = { - /** - * No active download - */ - 404: unknown; -}; - -export type CancelLocalModelDownloadResponses = { - /** - * Download cancelled - */ - 200: unknown; -}; - -export type GetLocalModelDownloadProgressData = { - body?: never; - path: { - model_id: string; - }; - query?: never; - url: '/local-inference/models/{model_id}/download'; -}; - -export type GetLocalModelDownloadProgressErrors = { - /** - * No active download - */ - 404: unknown; -}; - -export type GetLocalModelDownloadProgressResponses = { - /** - * Download progress - */ - 200: DownloadProgress; -}; - -export type GetLocalModelDownloadProgressResponse = GetLocalModelDownloadProgressResponses[keyof GetLocalModelDownloadProgressResponses]; - -export type GetModelSettingsData = { - body?: never; - path: { - model_id: string; - }; - query?: never; - url: '/local-inference/models/{model_id}/settings'; -}; - -export type GetModelSettingsErrors = { - /** - * Model not found - */ - 404: unknown; -}; - -export type GetModelSettingsResponses = { - /** - * Model settings - */ - 200: ModelSettings; -}; - -export type GetModelSettingsResponse = GetModelSettingsResponses[keyof GetModelSettingsResponses]; - -export type UpdateModelSettingsData = { - body: ModelSettings; - path: { - model_id: string; - }; - query?: never; - url: '/local-inference/models/{model_id}/settings'; -}; - -export type UpdateModelSettingsErrors = { - /** - * Model not found - */ - 404: unknown; - /** - * Failed to save settings - */ - 500: unknown; -}; - -export type UpdateModelSettingsResponses = { - /** - * Settings updated - */ - 200: ModelSettings; -}; - -export type UpdateModelSettingsResponse = UpdateModelSettingsResponses[keyof UpdateModelSettingsResponses]; - -export type GetRepoFilesData = { - body?: never; - path: { - author: string; - repo: string; - }; - query?: never; - url: '/local-inference/repo/{author}/{repo}/files'; -}; - -export type GetRepoFilesResponses = { - /** - * GGUF files in the repo - */ - 200: RepoVariantsResponse; -}; - -export type GetRepoFilesResponse = GetRepoFilesResponses[keyof GetRepoFilesResponses]; - -export type SearchHfModelsData = { - body?: never; - path?: never; - query: { - /** - * Search query - */ - q: string; - /** - * Max results - */ - limit?: number | null; - }; - url: '/local-inference/search'; -}; - -export type SearchHfModelsErrors = { - /** - * Search failed - */ - 500: unknown; -}; - -export type SearchHfModelsResponses = { - /** - * Search results - */ - 200: Array; -}; - -export type SearchHfModelsResponse = SearchHfModelsResponses[keyof SearchHfModelsResponses]; - -export type SyncFeaturedModelsData = { - body?: never; - path?: never; - query?: never; - url: '/local-inference/sync-featured'; -}; - -export type SyncFeaturedModelsResponses = { - /** - * Featured models synced to registry - */ - 200: unknown; -}; - export type DecodeRecipeData = { body: DecodeRecipeRequest; path?: never; diff --git a/ui/desktop/src/components/onboarding/LocalModelPicker.tsx b/ui/desktop/src/components/onboarding/LocalModelPicker.tsx index 6a27076e09..17a3c7441d 100644 --- a/ui/desktop/src/components/onboarding/LocalModelPicker.tsx +++ b/ui/desktop/src/components/onboarding/LocalModelPicker.tsx @@ -1,13 +1,12 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { listLocalModels, - syncFeaturedModels, downloadHfModel, getLocalModelDownloadProgress, cancelLocalModelDownload, type DownloadProgress, type LocalModelResponse, -} from '../../api'; +} from '../../acp/local-inference'; import { trackOnboardingSetupFailed } from '../../utils/analytics'; import { defineMessages, useIntl } from '../../i18n'; @@ -124,16 +123,15 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps useEffect(() => { const load = async () => { try { - await syncFeaturedModels(); - const response = await listLocalModels({ throwOnError: true }); - if (response.data) { - setModels(response.data); + const models = await listLocalModels(); + if (models) { + setModels(models); - const alreadyDownloaded = response.data.find((m) => m.status.state === 'Downloaded'); + const alreadyDownloaded = models.find((m) => m.status.state === 'Downloaded'); if (alreadyDownloaded) { setSelectedModelId(alreadyDownloaded.id); } else { - const recommended = response.data.find((m: LocalModelResponse) => m.recommended); + const recommended = models.find((m: LocalModelResponse) => m.recommended); if (recommended) setSelectedModelId(recommended.id); } } @@ -165,7 +163,7 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps } try { - await downloadHfModel({ body: { spec: model.id }, throwOnError: true }); + await downloadHfModel({ spec: model.id }); } catch (error) { console.error('Failed to start download:', error); setErrorMessage(intl.formatMessage(i18n.failedToStartDownload)); @@ -176,24 +174,27 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps pollRef.current = setInterval(async () => { try { - const response = await getLocalModelDownloadProgress({ - path: { model_id: modelId }, - throwOnError: true, - }); - if (response.data) { - setDownloadProgress(response.data); - if (response.data.status === 'completed') { - cleanup(); - finishSetup(modelId); - } else if (response.data.status === 'failed') { - cleanup(); - setErrorMessage(response.data.error || 'Download failed.'); - trackOnboardingSetupFailed(LOCAL_PROVIDER, response.data.error || 'download_failed'); - setPhase('error'); - } else if (response.data.status === 'cancelled') { - cleanup(); - setPhase('select'); - } + const progress = await getLocalModelDownloadProgress(modelId); + if (!progress) { + cleanup(); + setErrorMessage(intl.formatMessage(i18n.lostConnection)); + trackOnboardingSetupFailed(LOCAL_PROVIDER, 'progress_missing'); + setPhase('error'); + return; + } + + setDownloadProgress(progress); + if (progress.status === 'completed') { + cleanup(); + finishSetup(modelId); + } else if (progress.status === 'failed') { + cleanup(); + setErrorMessage(progress.error || 'Download failed.'); + trackOnboardingSetupFailed(LOCAL_PROVIDER, progress.error || 'download_failed'); + setPhase('error'); + } else if (progress.status === 'cancelled') { + cleanup(); + setPhase('select'); } } catch { cleanup(); @@ -208,7 +209,7 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps if (phase === 'downloading' && selectedModelId) { cleanup(); try { - await cancelLocalModelDownload({ path: { model_id: selectedModelId } }); + await cancelLocalModelDownload(selectedModelId); } catch { // best-effort } @@ -296,7 +297,7 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps )}

- {formatSize(recommended.size_bytes)} + {formatSize(recommended.sizeBytes)}

@@ -350,7 +351,7 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps {model.id} - {formatSize(model.size_bytes)} + {formatSize(model.sizeBytes)} {model.status.state === 'Downloaded' && ( @@ -375,7 +376,7 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps {selectedModel?.status.state === 'Downloaded' ? intl.formatMessage(i18n.useModel, { modelId: selectedModel.id }) : selectedModel - ? intl.formatMessage(i18n.downloadModel, { modelId: selectedModel.id, size: formatSize(selectedModel.size_bytes) }) + ? intl.formatMessage(i18n.downloadModel, { modelId: selectedModel.id, size: formatSize(selectedModel.sizeBytes) }) : intl.formatMessage(i18n.selectModel)} @@ -394,30 +395,30 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps
- {formatBytes(downloadProgress.bytes_downloaded)} of{' '} - {formatBytes(downloadProgress.total_bytes)} + {formatBytes(downloadProgress.bytesDownloaded)} of{' '} + {formatBytes(downloadProgress.totalBytes)} - {downloadProgress.progress_percent.toFixed(0)}% + {downloadProgress.progressPercent.toFixed(0)}%
- {downloadProgress.speed_bps ? ( - {formatBytes(downloadProgress.speed_bps)}/s + {downloadProgress.speedBps ? ( + {formatBytes(downloadProgress.speedBps)}/s ) : ( )} - {downloadProgress.eta_seconds != null && downloadProgress.eta_seconds > 0 && ( + {downloadProgress.etaSeconds != null && downloadProgress.etaSeconds > 0 && ( ~ - {downloadProgress.eta_seconds < 60 - ? `${Math.round(downloadProgress.eta_seconds)}s` - : `${Math.round(downloadProgress.eta_seconds / 60)}m`}{' '} + {downloadProgress.etaSeconds < 60 + ? `${Math.round(downloadProgress.etaSeconds)}s` + : `${Math.round(downloadProgress.etaSeconds / 60)}m`}{' '} remaining )} diff --git a/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx b/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx index e37f4f2606..466da9401a 100644 --- a/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx +++ b/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx @@ -17,7 +17,8 @@ import { type DownloadModelRequest, type HfModelInfo, type HfModelVariant, -} from '../../../api'; + type RepoVariantsResponse, +} from '../../../acp/local-inference'; import { defineMessages, useIntl } from '../../../i18n'; const i18n = defineMessages({ @@ -127,61 +128,43 @@ export const HuggingFaceModelSearch = ({ setSearching(true); setError(null); try { - const response = await searchHfModels({ - query: { q, limit: 20 }, - }); - if (response.data) { - // Pre-fetch variants for all results and filter out repos with no compatible local variants - const modelsWithVariants = await Promise.all( - response.data.map(async (model) => { - try { - const [author, repo] = model.repo_id.split('/'); - const filesResponse = await getRepoFiles({ path: { author, repo } }); - if (filesResponse.data && filesResponse.data.variants.length > 0) { - return { model, data: filesResponse.data }; - } - } catch { - // Skip repos we can't fetch + const models = await searchHfModels(q, 20); + const modelsWithVariants = await Promise.all( + models.map(async (model) => { + try { + const repoData = await getRepoFiles(model.repoId); + if (repoData.variants.length > 0) { + return { model, data: repoData }; } - return null; - }) - ); - - const validResults = modelsWithVariants.filter(Boolean) as { - model: HfModelInfo; - data: { - variants: HfModelVariant[]; - recommended_index?: number | null; - available_memory_bytes: number; - downloaded_quants: string[]; - downloaded_variants: string[]; - }; - }[]; - - setResults(validResults.map((r) => r.model)); - setRepoData((prev) => { - const next = { ...prev }; - for (const r of validResults) { - next[r.model.repo_id] = { - variants: r.data.variants, - recommendedIndex: r.data.recommended_index ?? null, - availableMemoryBytes: r.data.available_memory_bytes, - downloadedQuants: new Set(r.data.downloaded_quants), - downloadedVariants: new Set(r.data.downloaded_variants), - }; + } catch { + // Skip repos we can't fetch } - return next; - }); + return null; + }) + ); - if (validResults.length === 0) { - setError(intl.formatMessage(i18n.noGgufModels)); + const validResults = modelsWithVariants.filter(Boolean) as { + model: HfModelInfo; + data: RepoVariantsResponse; + }[]; + + setResults(validResults.map((r) => r.model)); + setRepoData((prev) => { + const next = { ...prev }; + for (const r of validResults) { + next[r.model.repoId] = { + variants: r.data.variants, + recommendedIndex: r.data.recommendedIndex ?? null, + availableMemoryBytes: r.data.availableMemoryBytes, + downloadedQuants: new Set(r.data.downloadedQuants), + downloadedVariants: new Set(r.data.downloadedVariants), + }; } - } else { - console.error('Search response:', response); - const errMsg = response.error - ? intl.formatMessage(i18n.searchError, { details: JSON.stringify(response.error) }) - : intl.formatMessage(i18n.searchNoData); - setError(errMsg); + return next; + }); + + if (validResults.length === 0) { + setError(intl.formatMessage(i18n.noGgufModels)); } } catch (e) { console.error('Search failed:', e); @@ -209,22 +192,17 @@ export const HuggingFaceModelSearch = ({ if (!repoData[repoId]?.variants.length) { setLoadingFiles((prev) => new Set(prev).add(repoId)); try { - const [author, repo] = repoId.split('/'); - const response = await getRepoFiles({ - path: { author, repo }, - }); - if (response.data) { - setRepoData((prev) => ({ - ...prev, - [repoId]: { - variants: response.data!.variants, - recommendedIndex: response.data!.recommended_index ?? null, - availableMemoryBytes: response.data!.available_memory_bytes, - downloadedQuants: new Set(response.data!.downloaded_quants), - downloadedVariants: new Set(response.data!.downloaded_variants), - }, - })); - } + const response = await getRepoFiles(repoId); + setRepoData((prev) => ({ + ...prev, + [repoId]: { + variants: response.variants, + recommendedIndex: response.recommendedIndex ?? null, + availableMemoryBytes: response.availableMemoryBytes, + downloadedQuants: new Set(response.downloadedQuants), + downloadedVariants: new Set(response.downloadedVariants), + }, + })); } catch (e) { console.error('Failed to fetch repo files:', e); } finally { @@ -238,20 +216,16 @@ export const HuggingFaceModelSearch = ({ }; const startDownload = async (repoId: string, variant: HfModelVariant) => { - const downloadKey = variant.download_id; + const downloadKey = variant.downloadId; const request: DownloadModelRequest = { spec: repoId, - backend_id: variant.backend_id, - variant_id: variant.variant_id, + backendId: variant.backendId, + variantId: variant.variantId, }; setDownloading((prev) => new Set(prev).add(downloadKey)); try { - const response = await downloadHfModel({ - body: request, - }); - if (response.data) { - onDownloadStarted(response.data, request); - } + const modelId = await downloadHfModel(request); + onDownloadStarted(modelId, request); } catch (e) { console.error('Download failed:', e); } finally { @@ -289,8 +263,8 @@ export const HuggingFaceModelSearch = ({ {results.length > 0 && (
{results.map((model) => { - const isExpanded = expandedRepo === model.repo_id; - const data = repoData[model.repo_id]; + const isExpanded = expandedRepo === model.repoId; + const data = repoData[model.repoId]; const variants = data?.variants || []; const recommendedIndex = data?.recommendedIndex ?? null; const availableMemory = data?.availableMemoryBytes ?? 0; @@ -298,15 +272,15 @@ export const HuggingFaceModelSearch = ({ const downloadedVariants = data?.downloadedVariants ?? new Set(); return ( -
+
-
- - - - diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx index c4681f1a89..cb8daba8a2 100644 --- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -51,8 +51,6 @@ import { McpAppToolInputPartial, DimensionLayout, OnDisplayModeChange, - SamplingCreateMessageParams, - SamplingCreateMessageResponse, } from './types'; import { useDisplayMode, @@ -676,13 +674,6 @@ export default function McpAppRenderer({ const [containerWidth, setContainerWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); - const [apiHost, setApiHost] = useState(null); - const [secretKey, setSecretKey] = useState(null); - - useEffect(() => { - window.electron.getGoosedHostPort().then(setApiHost); - window.electron.getSecretKey().then(setSecretKey); - }, []); // Fetch the resource from the extension to get HTML and metadata (CSP, permissions, etc.). // If cachedHtml is provided we show it immediately; the fetch updates metadata and @@ -933,38 +924,12 @@ export default function McpAppRenderer({ const handleFallbackRequest = useCallback( async (request: JSONRPCRequest, _extra: RequestHandlerExtra) => { - if (request.method === 'sampling/createMessage') { - if (!sessionId || !apiHost || !secretKey) { - throw new Error('Session not initialized for sampling request'); - } - const { messages, systemPrompt, maxTokens } = - request.params as unknown as SamplingCreateMessageParams; - const response = await fetch(`${apiHost}/sessions/${sessionId}/sampling/message`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': secretKey, - }, - body: JSON.stringify({ - messages: messages.map((m) => ({ - role: m.role, - content: m.content, - })), - systemPrompt, - maxTokens, - }), - }); - if (!response.ok) { - throw new Error(`Sampling request failed: ${response.statusText}`); - } - return (await response.json()) as SamplingCreateMessageResponse; - } return { status: 'error' as const, message: `Unhandled JSON-RPC method: ${request.method ?? ''}`, }; }, - [sessionId, apiHost, secretKey] + [] ); const handleError = useCallback((err: Error) => { diff --git a/ui/desktop/src/components/McpApps/types.ts b/ui/desktop/src/components/McpApps/types.ts index 44fdd42a72..d61d00fbe3 100644 --- a/ui/desktop/src/components/McpApps/types.ts +++ b/ui/desktop/src/components/McpApps/types.ts @@ -41,21 +41,3 @@ export type McpAppToolCancelled = McpUiToolCancelledNotification['params']; * host-side controls or app-initiated `ui/request-display-mode` changes. */ export type OnDisplayModeChange = (mode: GooseDisplayMode) => void; - -export type SamplingMessage = { - role: 'user' | 'assistant'; - content: { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }; -}; - -export type SamplingCreateMessageParams = { - messages: SamplingMessage[]; - systemPrompt?: string; - maxTokens?: number; -}; - -export type SamplingCreateMessageResponse = { - model: string; - stopReason: string; - role: 'assistant'; - content: { type: 'text'; text: string }; -}; diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index c3cc353fda..df0a8138b7 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { Button } from '../ui/button'; -import { Download, Play, Upload } from 'lucide-react'; +import { AlertTriangle, Download, Play, Upload } from 'lucide-react'; import type { GooseApp } from '../../types/apps'; import { exportMcpApp, importMcpApp, listMcpApps } from '../../acp/mcp-apps'; import { useChatContext } from '../../contexts/ChatContext'; import { formatAppName } from '../../utils/conversionUtils'; import { errorMessage } from '../../utils/conversionUtils'; +import { isRetiredGooseChatApp } from '../../utils/retiredApps'; import { defineMessages, useIntl } from '../../i18n'; const i18n = defineMessages({ @@ -52,6 +53,14 @@ const i18n = defineMessages({ id: 'appsView.launch', defaultMessage: 'Launch', }, + retiredChatApp: { + id: 'appsView.retiredChatApp', + defaultMessage: 'Chat app retired', + }, + retiredChatAppDetail: { + id: 'appsView.retiredChatAppDetail', + defaultMessage: 'We removed this feature because MCP sampling is no longer supported.', + }, }); const GridLayout = ({ children }: { children: React.ReactNode }) => { @@ -272,11 +281,25 @@ export default function AppsView() { {apps.map((app) => { const isCustomApp = app.mcpServers?.includes('apps') ?? false; + const retiredChatApp = isRetiredGooseChatApp(app); return (
+ {retiredChatApp && ( +
+ +
+
+ {intl.formatMessage(i18n.retiredChatApp)} +
+

+ {intl.formatMessage(i18n.retiredChatAppDetail)} +

+
+
+ )}

{formatAppName(app.name)} @@ -297,6 +320,7 @@ export default function AppsView() { variant="default" size="sm" onClick={() => handleLaunchApp(app)} + disabled={retiredChatApp} className="flex items-center gap-2 flex-1" > diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index 52640c837e..19dc36913b 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "Erneut versuchen" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "Apps" }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index af7e0e216c..85085508b2 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -41,6 +41,12 @@ "appsView.noAppsTitle": { "defaultMessage": "No apps available" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.retry": { "defaultMessage": "Retry" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index 25e7a307fa..8aba39ef76 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "Reintentar" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "Apps" }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index d73f192eb5..41fc303d15 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "Réessayer" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "Applications" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 61db2c6d63..906fcb129b 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "पुनः प्रयास करें" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "ऐप्स" }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index 37090e4852..bdd7de7128 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "Coba lagi" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "Aplikasi" }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index c25c659f0f..c19fa48e16 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "Riprova" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "App" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index 79e3e4f8cd..75863c6d3b 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "再試行" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "アプリ" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index 45c8220bea..09aa513621 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "다시 시도" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "앱" }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index ca44ad01ee..68fedd6c24 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "Cuba semula" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "Apl" }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index a1e7110520..b715b37b65 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "Tentar novamente" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "Aplicações" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index fba6151910..fb890d3153 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "Повторить" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "Приложения" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index 398b902dc1..c69394cd8d 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "Yeniden dene" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "Uygulamalar" }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index cc28a0b44b..8f94847441 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "Thử lại" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "Ứng dụng" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index e45d274c9e..ca094060af 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "重试" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "应用" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index f8f1acb731..002bae4570 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -44,6 +44,12 @@ "appsView.retry": { "defaultMessage": "重試" }, + "appsView.retiredChatApp": { + "defaultMessage": "Chat app retired" + }, + "appsView.retiredChatAppDetail": { + "defaultMessage": "We removed this feature because MCP sampling is no longer supported." + }, "appsView.title": { "defaultMessage": "應用程式" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 95d326c559..f444410f17 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -33,6 +33,7 @@ import log from './utils/logger'; import { ensureWinShims } from './utils/winShims'; import { addRecentDir, loadRecentDirs } from './utils/recentDirs'; import { formatAppName, errorMessage, formatErrorForLogging } from './utils/conversionUtils'; +import { isRetiredGooseChatApp } from './utils/retiredApps'; import type { Settings, SettingKey } from './utils/settings'; import { defaultSettings, getKeyboardShortcuts } from './utils/settings'; import * as crypto from 'crypto'; @@ -2877,6 +2878,10 @@ async function appMain() { ipcMain.handle('launch-app', async (event, gooseApp: GooseApp) => { try { + if (isRetiredGooseChatApp(gooseApp)) { + throw new Error('This built-in Chat app is no longer supported.'); + } + const launchingWindow = BrowserWindow.fromWebContents(event.sender); if (!launchingWindow) { throw new Error('Could not find launching window'); diff --git a/ui/desktop/src/utils/retiredApps.ts b/ui/desktop/src/utils/retiredApps.ts new file mode 100644 index 0000000000..14fbc72e17 --- /dev/null +++ b/ui/desktop/src/utils/retiredApps.ts @@ -0,0 +1,13 @@ +import type { GooseApp } from '../types/apps'; + +export function isRetiredGooseChatApp(app: GooseApp) { + return ( + app.mcpServers?.includes('apps') && + app.uri === 'ui://apps/chat' && + app.name === 'chat' && + app.description === 'Simple Chat UI' && + app.width === 400 && + app.height === 500 && + app.resizable === true + ); +} From 82c0b0dfd90c8ec883241b625d808d7c7f3181e6 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 1 Jul 2026 12:44:08 +1000 Subject: [PATCH 020/139] UI acp migratoin: Decouple desktop UI types from generated OpenAPI types (Part 2) (#10149) --- ui/desktop/src/acp/providers.ts | 3 +- ui/desktop/src/components/ConfigContext.tsx | 8 ++-- .../components/ModelAndProviderContext.tsx | 2 +- .../onboarding/ProviderConfigForm.tsx | 2 +- .../onboarding/ProviderSelector.tsx | 3 +- .../settings/dictation/DictationSettings.tsx | 2 +- .../settings/models/modelInterface.ts | 2 +- .../models/subcomponents/SwitchModelModal.tsx | 6 +-- .../settings/providers/ProviderGrid.tsx | 3 +- .../providers/ProviderSettingsPage.tsx | 2 +- .../modal/ProviderConfigurationModal.tsx | 2 +- .../subcomponents/ProviderSetupActions.tsx | 2 +- .../forms/DefaultProviderSetupForm.tsx | 2 +- .../providers/subcomponents/ProviderCard.tsx | 2 +- .../buttons/DefaultCardButtons.tsx | 2 +- ui/desktop/src/hooks/useAudioRecorder.ts | 2 +- ui/desktop/src/types/dictation.ts | 1 + ui/desktop/src/types/providers.ts | 46 +++++++++++++++++++ ui/desktop/src/vite-env.d.ts | 2 +- 19 files changed, 69 insertions(+), 25 deletions(-) create mode 100644 ui/desktop/src/types/dictation.ts diff --git a/ui/desktop/src/acp/providers.ts b/ui/desktop/src/acp/providers.ts index c7be5b885c..e9e967f405 100644 --- a/ui/desktop/src/acp/providers.ts +++ b/ui/desktop/src/acp/providers.ts @@ -6,8 +6,7 @@ import type { ProviderTemplateCatalogEntryDto, ProviderTemplateDto, } from '@aaif/goose-sdk'; -import type { ProviderDetails, ThinkingEffort } from '../api'; -import type { UpdateCustomProviderRequest } from '../types/providers'; +import type { ProviderDetails, ThinkingEffort, UpdateCustomProviderRequest } from '../types/providers'; import { getAcpClient } from './acpConnection'; export type { CanonicalModelInfoDto, ProviderSecretDto }; diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index adf0bb36c3..4cb6236b1b 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -9,8 +9,8 @@ import { } from '../acp/extensions'; import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions'; import { nameToKey } from './settings/extensions/utils'; -import type { ConfigResponse, ProviderDetails } from '../api'; import type { ExtensionConfig } from '../types/extensions'; +import type { ProviderDetails } from '../types/providers'; export type { ExtensionConfig } from '../types/extensions'; @@ -20,8 +20,10 @@ export type FixedExtensionEntry = ExtensionConfig & { configKey?: string; }; +type ConfigMap = Record; + interface ConfigContextType { - config: ConfigResponse['config']; + config: ConfigMap; providersList: ProviderDetails[]; extensionsList: FixedExtensionEntry[]; extensionWarnings: string[]; @@ -42,7 +44,7 @@ interface ConfigProviderProps { const ConfigContext = createContext(undefined); export const ConfigProvider: React.FC = ({ children }) => { - const [config, setConfig] = useState({}); + const [config, setConfig] = useState({}); const [providersList, setProvidersList] = useState([]); const [extensionsList, setExtensionsList] = useState([]); const [extensionWarnings, setExtensionWarnings] = useState([]); diff --git a/ui/desktop/src/components/ModelAndProviderContext.tsx b/ui/desktop/src/components/ModelAndProviderContext.tsx index 81b41baa48..5bd3f9226a 100644 --- a/ui/desktop/src/components/ModelAndProviderContext.tsx +++ b/ui/desktop/src/components/ModelAndProviderContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; import { toastError, toastSuccess } from '../toasts'; import Model, { getProviderMetadata } from './settings/models/modelInterface'; -import { ProviderMetadata } from '../api'; +import type { ProviderMetadata } from '../types/providers'; import { acpChatSessionActions, acpChatSessionStore } from '../acp/chatSessionStore'; import { acpReadDefaults, diff --git a/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx b/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx index dde92a8a50..a926c45f23 100644 --- a/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx +++ b/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { ProviderDetails } from '../../api'; import { acpAuthenticateProvider } from '../../acp/providers'; +import type { ProviderDetails } from '../../types/providers'; import DefaultProviderSetupForm, { ConfigInput, } from '../settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm'; diff --git a/ui/desktop/src/components/onboarding/ProviderSelector.tsx b/ui/desktop/src/components/onboarding/ProviderSelector.tsx index ec202cde08..449a24b244 100644 --- a/ui/desktop/src/components/onboarding/ProviderSelector.tsx +++ b/ui/desktop/src/components/onboarding/ProviderSelector.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; -import type { ProviderDetails } from '../../api'; import { acpCreateCustomProviderFromRequest, acpListProviderDetails } from '../../acp/providers'; -import type { UpdateCustomProviderRequest } from '../../types/providers'; +import type { ProviderDetails, UpdateCustomProviderRequest } from '../../types/providers'; import { Select } from '../ui/Select'; import ProviderConfigForm from './ProviderConfigForm'; import LocalModelPicker from './LocalModelPicker'; diff --git a/ui/desktop/src/components/settings/dictation/DictationSettings.tsx b/ui/desktop/src/components/settings/dictation/DictationSettings.tsx index 31025ca3da..9b317ef46e 100644 --- a/ui/desktop/src/components/settings/dictation/DictationSettings.tsx +++ b/ui/desktop/src/components/settings/dictation/DictationSettings.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react'; import { ChevronDown } from 'lucide-react'; -import { DictationProvider } from '../../../api'; import { getDictationConfig, DictationProviderStatusEntry } from '../../../acp/dictation'; import { useConfig } from '../../ConfigContext'; import { Input } from '../../ui/input'; @@ -10,6 +9,7 @@ import { LocalModelManager } from './LocalModelManager'; import { MicrophoneSelector } from './MicrophoneSelector'; import { DICTATION_ALLOWED_PROVIDERS } from '../../../updates'; import { useFeatures } from '../../../contexts/FeaturesContext'; +import type { DictationProvider } from '../../../types/dictation'; import { DropdownMenu, DropdownMenuContent, diff --git a/ui/desktop/src/components/settings/models/modelInterface.ts b/ui/desktop/src/components/settings/models/modelInterface.ts index a97b5d2d2a..b16f82ea85 100644 --- a/ui/desktop/src/components/settings/models/modelInterface.ts +++ b/ui/desktop/src/components/settings/models/modelInterface.ts @@ -1,6 +1,6 @@ -import { ProviderDetails, ThinkingEffort } from '../../../api'; import { listLocalModels } from '../../../acp/local-inference'; import { acpListProviderDetails, acpListProviderModels } from '../../../acp/providers'; +import type { ProviderDetails, ThinkingEffort } from '../../../types/providers'; import { errorMessage as getErrorMessage } from '../../../utils/conversionUtils'; export default interface Model { diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index f45d124e0c..326ca8e21b 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -27,7 +27,7 @@ import Model, { getProviderMetadata, } from '../modelInterface'; import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils'; -import type { ProviderType, ThinkingEffort } from '../../../../api'; +import type { ProviderDetails, ProviderType, ThinkingEffort } from '../../../../types/providers'; import { trackModelChanged } from '../../../../utils/analytics'; const i18n = defineMessages({ @@ -305,9 +305,7 @@ export const SwitchModelModal = ({ const [userClearedModel, setUserClearedModel] = useState(false); const [providerErrors, setProviderErrors] = useState>({}); const [providerWarnings, setProviderWarnings] = useState>({}); - const [activeProvidersList, setActiveProvidersList] = useState< - import('../../../../api').ProviderDetails[] - >([]); + const [activeProvidersList, setActiveProvidersList] = useState([]); const fetchedProviders = useRef>(new Set()); const reasoningRequestId = useRef(0); const [thinkingEffort, setThinkingEffort] = useState(null); diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index 4175b375d3..6efe13bbc8 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -2,9 +2,8 @@ import React, { memo, useMemo, useCallback, useState } from 'react'; import { ProviderCard } from './subcomponents/ProviderCard'; import CardContainer from './subcomponents/CardContainer'; import ProviderConfigurationModal from './modal/ProviderConfigurationModal'; -import type { ProviderDetails } from '../../../api'; import type { CustomProviderConfigDto } from '@aaif/goose-sdk'; -import type { UpdateCustomProviderRequest } from '../../../types/providers'; +import type { ProviderDetails, UpdateCustomProviderRequest } from '../../../types/providers'; import { acpCreateCustomProviderFromRequest, acpGetCustomProvider, diff --git a/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx b/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx index 3049ea5021..b15dff19bc 100644 --- a/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx @@ -4,7 +4,7 @@ import { ScrollArea } from '../../ui/scroll-area'; import BackButton from '../../ui/BackButton'; import ProviderGrid from './ProviderGrid'; import { acpListProviderDetails } from '../../../acp/providers'; -import { ProviderDetails } from '../../../api'; +import type { ProviderDetails } from '../../../types/providers'; import { createNavigationHandler } from '../../../utils/navigationUtils'; import { defineMessages, useIntl } from '../../../i18n'; diff --git a/ui/desktop/src/components/settings/providers/modal/ProviderConfigurationModal.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderConfigurationModal.tsx index aa3d4cb50b..4269a9c176 100644 --- a/ui/desktop/src/components/settings/providers/modal/ProviderConfigurationModal.tsx +++ b/ui/desktop/src/components/settings/providers/modal/ProviderConfigurationModal.tsx @@ -22,7 +22,7 @@ import { } from '../../../../acp/providers'; import { useModelAndProvider } from '../../../ModelAndProviderContext'; import { AlertTriangle, LogIn } from 'lucide-react'; -import { ProviderDetails } from '../../../../api'; +import type { ProviderDetails } from '../../../../types/providers'; import { Button } from '../../../../components/ui/button'; import { errorMessage } from '../../../../utils/conversionUtils'; import { defineMessages, useIntl } from '../../../../i18n'; diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderSetupActions.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderSetupActions.tsx index ae3816c3fe..62bfe75663 100644 --- a/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderSetupActions.tsx +++ b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderSetupActions.tsx @@ -1,7 +1,7 @@ import { SyntheticEvent } from 'react'; import { Button } from '../../../../ui/button'; import { Trash2, AlertTriangle } from 'lucide-react'; -import { ConfigKey } from '../../../../../api'; +import type { ConfigKey } from '../../../../../types/providers'; import { defineMessages, useIntl } from '../../../../../i18n'; const i18n = defineMessages({ diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx index 9ebb333f3e..dd6a155c7d 100644 --- a/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx +++ b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react'; import { Input } from '../../../../../ui/input'; import { acpReadProviderConfig } from '../../../../../../acp/providers'; -import { ProviderDetails, ConfigKey } from '../../../../../../api'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../../../../ui/collapsible'; +import type { ConfigKey, ProviderDetails } from '../../../../../../types/providers'; import { configLabels, configPlaceholders } from '../../../../../../utils/configUtils'; import { defineMessages, useIntl } from '../../../../../../i18n'; diff --git a/ui/desktop/src/components/settings/providers/subcomponents/ProviderCard.tsx b/ui/desktop/src/components/settings/providers/subcomponents/ProviderCard.tsx index 4b9baf5edb..66d374344f 100644 --- a/ui/desktop/src/components/settings/providers/subcomponents/ProviderCard.tsx +++ b/ui/desktop/src/components/settings/providers/subcomponents/ProviderCard.tsx @@ -3,7 +3,7 @@ import CardContainer from './CardContainer'; import CardHeader from './CardHeader'; import CardBody from './CardBody'; import DefaultCardButtons from './buttons/DefaultCardButtons'; -import { ProviderDetails, ProviderMetadata } from '../../../../api'; +import type { ProviderDetails, ProviderMetadata } from '../../../../types/providers'; import { defineMessages, useIntl } from '../../../../i18n'; const i18n = defineMessages({ diff --git a/ui/desktop/src/components/settings/providers/subcomponents/buttons/DefaultCardButtons.tsx b/ui/desktop/src/components/settings/providers/subcomponents/buttons/DefaultCardButtons.tsx index 7bd7033fe0..d101701fbf 100644 --- a/ui/desktop/src/components/settings/providers/subcomponents/buttons/DefaultCardButtons.tsx +++ b/ui/desktop/src/components/settings/providers/subcomponents/buttons/DefaultCardButtons.tsx @@ -1,5 +1,5 @@ import { ConfigureSettingsButton, RocketButton } from './CardButtons'; -import { ProviderDetails } from '../../../../../api'; +import type { ProviderDetails } from '../../../../../types/providers'; import { defineMessages, useIntl } from '../../../../../i18n'; const i18n = defineMessages({ diff --git a/ui/desktop/src/hooks/useAudioRecorder.ts b/ui/desktop/src/hooks/useAudioRecorder.ts index 4886529b7d..4e4fe68c29 100644 --- a/ui/desktop/src/hooks/useAudioRecorder.ts +++ b/ui/desktop/src/hooks/useAudioRecorder.ts @@ -1,7 +1,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'; -import { DictationProvider } from '../api'; import { getDictationConfig, transcribeDictation } from '../acp/dictation'; import { useConfig } from '../components/ConfigContext'; +import type { DictationProvider } from '../types/dictation'; import { errorMessage } from '../utils/conversionUtils'; interface UseAudioRecorderOptions { diff --git a/ui/desktop/src/types/dictation.ts b/ui/desktop/src/types/dictation.ts new file mode 100644 index 0000000000..35efe3fcef --- /dev/null +++ b/ui/desktop/src/types/dictation.ts @@ -0,0 +1 @@ +export type DictationProvider = 'openai' | 'elevenlabs' | 'groq' | 'local'; diff --git a/ui/desktop/src/types/providers.ts b/ui/desktop/src/types/providers.ts index 904191e031..ca38d7ba70 100644 --- a/ui/desktop/src/types/providers.ts +++ b/ui/desktop/src/types/providers.ts @@ -1,3 +1,49 @@ +export type ProviderType = 'Preferred' | 'Builtin' | 'Declarative' | 'Custom'; + +export type ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'max'; + +export type ConfigKey = { + default?: string | null; + device_code_flow?: boolean; + name: string; + oauth_flow: boolean; + primary?: boolean; + required: boolean; + secret: boolean; +}; + +export type ModelInfo = { + context_limit: number; + currency?: string | null; + input_token_cost?: number | null; + name: string; + output_token_cost?: number | null; + reasoning?: boolean; + resolved_model?: string | null; + supports_cache_control?: boolean | null; +}; + +export type ProviderMetadata = { + config_keys: ConfigKey[]; + default_model: string; + description: string; + display_name: string; + fast_model?: string | null; + known_models: ModelInfo[]; + model_doc_link: string; + model_selection_hint?: string | null; + name: string; + setup_steps?: string[]; +}; + +export type ProviderDetails = { + is_configured: boolean; + metadata: ProviderMetadata; + name: string; + provider_type: ProviderType; + saved_model?: string | null; +}; + export type UpdateCustomProviderRequest = { api_key: string; api_url: string; diff --git a/ui/desktop/src/vite-env.d.ts b/ui/desktop/src/vite-env.d.ts index d7c35b6a52..574828ce86 100644 --- a/ui/desktop/src/vite-env.d.ts +++ b/ui/desktop/src/vite-env.d.ts @@ -59,7 +59,7 @@ declare global { sessionId: string; }>; responseStyleChanged: CustomEvent; - 'session-created': CustomEvent<{ session?: import('./api').Session }>; + 'session-created': CustomEvent<{ session?: import('./types/session').Session }>; 'session-deleted': CustomEvent<{ sessionId: string }>; 'session-renamed': CustomEvent<{ sessionId: string; From f150672e23b633c0a0485a7cc90913b391c5c33a Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 30 Jun 2026 23:13:10 -0400 Subject: [PATCH 021/139] test: generic validator for declarative providers (#10010) Co-authored-by: Douwe M Osinga --- .../goose/src/config/declarative_providers.rs | 369 ++++++------------ crates/goose/src/providers/init.rs | 146 ------- 2 files changed, 121 insertions(+), 394 deletions(-) diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 524d8501a2..7eabb0cdbc 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -776,88 +776,6 @@ mod tests { )); } - #[test] - fn test_tanzu_json_deserializes() { - let json = include_str!("../providers/declarative/tanzu.json"); - let config: DeclarativeProviderConfig = - serde_json::from_str(json).expect("tanzu.json should parse"); - assert_eq!(config.name, "tanzu_ai"); - assert_eq!(config.display_name, "VMware Tanzu Platform"); - assert!(matches!(config.engine, ProviderEngine::OpenAI)); - assert_eq!(config.api_key_env, "TANZU_AI_API_KEY"); - assert_eq!( - config.base_url, - "${TANZU_AI_ENDPOINT}/openai/v1/chat/completions" - ); - assert_eq!(config.dynamic_models, Some(true)); - assert_eq!(config.supports_streaming, Some(true)); - - let env_vars = config.env_vars.as_ref().expect("env_vars should be set"); - assert_eq!(env_vars.len(), 2); - assert_eq!(env_vars[0].name, "TANZU_AI_ENDPOINT"); - assert!(env_vars[0].required); - assert!(!env_vars[0].secret); - assert_eq!(env_vars[1].name, "TANZU_AI_STREAMING"); - assert!(!env_vars[1].required); - assert_eq!(env_vars[1].default, Some("true".to_string())); - - assert_eq!(config.models.len(), 1); - assert_eq!(config.models[0].name, "openai/gpt-oss-120b"); - } - - #[test] - fn test_llama_swap_json_deserializes() { - let json = include_str!("../providers/declarative/llama_swap.json"); - let config: DeclarativeProviderConfig = - serde_json::from_str(json).expect("llama_swap.json should parse"); - assert_eq!(config.name, "llama_swap"); - assert_eq!(config.display_name, "Llama Swap"); - assert!(matches!(config.engine, ProviderEngine::OpenAI)); - assert_eq!(config.api_key_env, "LLAMA_SWAP_API_KEY"); - assert!(!config.requires_auth); - assert!(config.skip_canonical_filtering); - assert_eq!(config.dynamic_models, Some(true)); - assert_eq!(config.supports_streaming, Some(true)); - assert_eq!(config.base_url, "${LLAMA_SWAP_HOST}/v1/chat/completions"); - assert!(config.models.is_empty()); - - let env_vars = config.env_vars.as_ref().expect("env_vars should be set"); - assert_eq!(env_vars.len(), 1); - assert_eq!(env_vars[0].name, "LLAMA_SWAP_HOST"); - assert!(!env_vars[0].required); - assert!(!env_vars[0].secret); - assert_eq!(env_vars[0].primary, Some(true)); - assert_eq!( - env_vars[0].default, - Some("http://localhost:8080".to_string()) - ); - } - - #[test] - fn test_all_bundled_providers_deserialize() { - // `load_fixed_providers` silently skips any bundled JSON that fails to - // deserialize (it only emits a `warn!`), so a malformed provider file would - // ship as a missing provider rather than a build/test failure. Assert every - // bundled file parses through the same path the loader uses. - let mut failures = Vec::new(); - for file in FIXED_PROVIDERS.files() { - if file.path().extension().and_then(|s| s.to_str()) != Some("json") { - continue; - } - let content = file - .contents_utf8() - .unwrap_or_else(|| panic!("bundled provider {:?} is not valid UTF-8", file.path())); - if let Err(e) = deserialize_provider_config(content) { - failures.push(format!("{:?}: {e}", file.path())); - } - } - assert!( - failures.is_empty(), - "bundled declarative providers failed to deserialize:\n{}", - failures.join("\n") - ); - } - #[test] fn test_existing_json_files_still_deserialize_without_new_fields() { let json = include_str!("../providers/declarative/groq.json"); @@ -870,6 +788,127 @@ mod tests { assert!(config.preserves_thinking); } + fn placeholder_var_names(template: &str) -> Vec { + template + .split("${") + .skip(1) + .filter_map(|chunk| chunk.split_once('}')) + .map(|(name, _)| name.to_string()) + .collect() + } + + #[test] + fn test_all_bundled_providers_are_valid() { + let mut seen_ids = std::collections::HashSet::new(); + + for file in FIXED_PROVIDERS.files() { + if file.path().extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let path = file.path().display().to_string(); + let content = file + .contents_utf8() + .unwrap_or_else(|| panic!("{path} is not valid UTF-8")); + let config = deserialize_provider_config(content) + .unwrap_or_else(|e| panic!("{path} failed to parse: {e}")); + + validate_provider_id(config.id()) + .unwrap_or_else(|e| panic!("{path} has an invalid provider id: {e}")); + assert!( + seen_ids.insert(config.id().to_string()), + "{path} has a duplicate provider id: {}", + config.id() + ); + assert!(!config.base_url.is_empty(), "{path} has an empty base_url"); + + if config.dynamic_models == Some(false) { + assert!( + !config.models.is_empty(), + "{path} disables dynamic_models but lists no static models" + ); + } + + let declared: std::collections::HashSet<&str> = config + .env_vars + .iter() + .flatten() + .map(|v| v.name.as_str()) + .collect(); + let templates = std::iter::once(config.base_url.as_str()) + .chain(config.base_path.as_deref()) + .chain( + config + .headers + .iter() + .flat_map(|h| h.values()) + .map(String::as_str), + ); + for template in templates { + for var in placeholder_var_names(template) { + assert!( + declared.contains(var.as_str()), + "{path} references ${{{var}}} but declares no matching env_var" + ); + } + } + } + + assert!(!seen_ids.is_empty(), "no bundled providers were found"); + } + + #[test] + fn test_bundled_providers_wire_into_registry_metadata() { + let configs = load_fixed_providers().expect("bundled providers should load"); + assert!(!configs.is_empty(), "no bundled providers were found"); + + for config in configs { + let id = config.id().to_string(); + let api_key_env = config.api_key_env.clone(); + let requires_auth = config.requires_auth; + let env_vars = config.env_vars.clone().unwrap_or_default(); + + let mut registry = crate::providers::provider_registry::ProviderRegistry::new(None); + register_declarative_provider(&mut registry, config, ProviderType::Declarative); + + let (meta, provider_type) = registry + .all_metadata_with_types() + .into_iter() + .find(|(m, _)| m.name == id) + .unwrap_or_else(|| panic!("{id} should register")); + + assert_eq!(provider_type, ProviderType::Declarative, "{id}"); + assert!(!meta.display_name.is_empty(), "{id} has empty display_name"); + + assert!( + !meta + .config_keys + .iter() + .any(|k| k.name == "OPENAI_HOST" || k.name == "OPENAI_BASE_PATH"), + "{id} leaks OpenAI engine config keys" + ); + + if !api_key_env.is_empty() { + let key = meta + .config_keys + .iter() + .find(|k| k.name == api_key_env) + .unwrap_or_else(|| panic!("{id} should expose {api_key_env} config key")); + assert!(key.secret, "{id}: {api_key_env} should be secret"); + assert_eq!(key.required, requires_auth, "{id}: {api_key_env} required"); + } + + for ev in &env_vars { + let key = meta + .config_keys + .iter() + .find(|k| k.name == ev.name) + .unwrap_or_else(|| panic!("{id} should expose {} config key", ev.name)); + assert_eq!(key.required, ev.required, "{id}: {} required", ev.name); + assert_eq!(key.secret, ev.secret, "{id}: {} secret", ev.name); + } + } + } + #[test] fn test_custom_openai_provider_missing_preserves_thinking_defaults_true() { let json = r#"{ @@ -915,91 +954,6 @@ mod tests { assert!(!config.preserves_thinking); } - #[test] - fn test_openai_reasoning_provider_json_preserves_thinking() { - for (name, json) in [ - ( - "custom_deepseek", - include_str!("../providers/declarative/deepseek.json"), - ), - ( - "moonshot", - include_str!("../providers/declarative/moonshot.json"), - ), - ( - "novita", - include_str!("../providers/declarative/novita.json"), - ), - ( - "nvidia", - include_str!("../providers/declarative/nvidia.json"), - ), - ( - "custom_tensorix", - include_str!("../providers/declarative/tensorix.json"), - ), - ("zhipu", include_str!("../providers/declarative/zhipu.json")), - ] { - let config: DeclarativeProviderConfig = - serde_json::from_str(json).expect("provider json should parse"); - assert_eq!(config.name, name); - assert!(matches!(config.engine, ProviderEngine::OpenAI)); - assert!(config.preserves_thinking); - } - } - - #[test] - fn test_nvidia_json_deserializes() { - let json = include_str!("../providers/declarative/nvidia.json"); - let config: DeclarativeProviderConfig = - serde_json::from_str(json).expect("nvidia.json should parse"); - assert_eq!(config.name, "nvidia"); - assert_eq!(config.display_name, "NVIDIA"); - assert!(matches!(config.engine, ProviderEngine::OpenAI)); - assert_eq!(config.api_key_env, "NVIDIA_API_KEY"); - assert_eq!(config.base_url, "https://integrate.api.nvidia.com/v1"); - assert_eq!(config.catalog_provider_id, Some("nvidia".to_string())); - assert_eq!(config.dynamic_models, Some(true)); - assert_eq!(config.supports_streaming, Some(true)); - assert!(!config.skip_canonical_filtering); - assert_eq!( - config.model_doc_link, - Some("https://build.nvidia.com/models".to_string()) - ); - assert_eq!(config.setup_steps.len(), 4); - - assert_eq!(config.models.len(), 1); - assert_eq!(config.models[0].name, "z-ai/glm-4.7"); - assert_eq!(config.models[0].context_limit, 131072); - } - - #[test] - fn test_vercel_ai_gateway_json_deserializes() { - let json = include_str!("../providers/declarative/vercel_ai_gateway.json"); - let config: DeclarativeProviderConfig = - serde_json::from_str(json).expect("vercel_ai_gateway.json should parse"); - assert_eq!(config.name, "vercel_ai_gateway"); - assert_eq!(config.display_name, "Vercel AI Gateway"); - assert!(matches!(config.engine, ProviderEngine::OpenAI)); - assert_eq!(config.api_key_env, "AI_GATEWAY_API_KEY"); - assert_eq!( - config.base_url, - "https://ai-gateway.vercel.sh/v1/chat/completions" - ); - assert_eq!(config.supports_streaming, Some(true)); - assert!(!config.models.is_empty()); - - let headers = config - .headers - .as_ref() - .expect("vercel_ai_gateway should set attribution headers"); - assert_eq!( - headers.get("http-referer").map(String::as_str), - Some("https://goose-docs.ai") - ); - assert_eq!(headers.get("x-title").map(String::as_str), Some("goose")); - } - #[test] fn test_validate_provider_id_rejects_legacy_punctuation_for_new_ids() { assert!(validate_provider_id("custom_z.ai").is_err()); @@ -1074,22 +1028,6 @@ mod tests { assert!(load_provider("custom_..\\secret").is_err()); } - #[test] - fn test_opencode_go_json_deserializes() { - let json = include_str!("../providers/declarative/opencode_go.json"); - let config: DeclarativeProviderConfig = - serde_json::from_str(json).expect("opencode_go.json should parse"); - assert_eq!(config.name, "opencode_go"); - assert_eq!(config.display_name, "OpenCode Go"); - assert!(matches!(config.engine, ProviderEngine::OpenAI)); - assert_eq!(config.api_key_env, "OPENCODE_API_KEY"); - assert_eq!(config.base_url, "https://opencode.ai/zen/go/v1"); - assert_eq!(config.catalog_provider_id, Some("opencode-go".to_string())); - assert_eq!(config.dynamic_models, Some(true)); - assert!(config.preserves_thinking); - assert_eq!(config.models[0].name, "kimi-k2.6"); - } - #[test] fn test_expand_env_vars_replaces_placeholder() { let _guard = env_lock::lock_env([("TEST_EXPAND_HOST", Some("https://example.com/api"))]); @@ -1184,69 +1122,4 @@ mod tests { let result = expand_env_vars("${TEST_EXPAND_OVERRIDE}/path", &env_vars).unwrap(); assert_eq!(result, "https://from-env.com/path"); } - - #[test] - fn test_atomic_chat_json_deserializes() { - let json = include_str!("../providers/declarative/atomic_chat.json"); - let config: DeclarativeProviderConfig = - serde_json::from_str(json).expect("atomic_chat.json should parse"); - assert_eq!(config.name, "atomic_chat"); - assert_eq!(config.display_name, "Atomic Chat"); - assert_eq!( - config.description.as_deref(), - Some("Local models through Atomic Chat\u{2019}s OpenAI-compatible server") - ); - assert!(matches!(config.engine, ProviderEngine::OpenAI)); - assert_eq!(config.api_key_env, ""); - assert!(!config.requires_auth); - assert!(config.skip_canonical_filtering); - assert_eq!(config.dynamic_models, Some(true)); - assert_eq!(config.supports_streaming, Some(true)); - assert_eq!(config.base_url, "${ATOMIC_CHAT_HOST}/v1/chat/completions"); - assert!(config.models.is_empty()); - assert!(config.model_doc_link.is_none()); - assert!(config.setup_steps.is_empty()); - - let env_vars = config.env_vars.as_ref().expect("env_vars should be set"); - assert_eq!(env_vars.len(), 1); - assert_eq!(env_vars[0].name, "ATOMIC_CHAT_HOST"); - assert!(!env_vars[0].required); - assert!(!env_vars[0].secret); - assert_eq!(env_vars[0].primary, Some(true)); - assert_eq!( - env_vars[0].default, - Some("http://localhost:1337".to_string()) - ); - assert_eq!( - env_vars[0].description.as_deref(), - Some("Base URL of the Atomic Chat server (default: http://localhost:1337)") - ); - } - - #[test] - fn test_routstr_json_deserializes() { - let json = include_str!("../providers/declarative/routstr.json"); - let config: DeclarativeProviderConfig = - serde_json::from_str(json).expect("routstr.json should parse"); - assert_eq!(config.name, "routstr"); - assert_eq!(config.display_name, "Routstr"); - assert!(matches!(config.engine, ProviderEngine::OpenAI)); - assert_eq!(config.api_key_env, "ROUTSTR_API_KEY"); - assert_eq!(config.base_url, "${ROUTSTR_HOST}/v1"); - assert_eq!(config.dynamic_models, Some(true)); - assert_eq!(config.supports_streaming, Some(true)); - assert!(config.skip_canonical_filtering); - assert_eq!(config.models.len(), 6); - - let env_vars = config.env_vars.as_ref().expect("env_vars should be set"); - assert_eq!(env_vars.len(), 1); - assert_eq!(env_vars[0].name, "ROUTSTR_HOST"); - assert!(!env_vars[0].required); - assert!(!env_vars[0].secret); - assert_eq!(env_vars[0].primary, Some(true)); - assert_eq!( - env_vars[0].default, - Some("https://api.routstr.com".to_string()) - ); - } } diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index e258f26667..3752715fbf 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -271,43 +271,6 @@ mod tests { use crate::config::paths::Paths; use std::fs; - #[tokio::test] - async fn test_tanzu_declarative_provider_registry_wiring() { - let providers_list = providers().await; - let tanzu = providers_list - .iter() - .find(|(m, _)| m.name == "tanzu_ai") - .expect("tanzu_ai provider should be registered"); - let (meta, provider_type) = tanzu; - - // Should be a Declarative (fixed) provider - assert_eq!(*provider_type, ProviderType::Declarative); - - assert_eq!(meta.display_name, "VMware Tanzu Platform"); - assert_eq!(meta.default_model, "openai/gpt-oss-120b"); - - // First config key should be TANZU_AI_API_KEY (secret, required) - let api_key = meta - .config_keys - .iter() - .find(|k| k.name == "TANZU_AI_API_KEY") - .expect("TANZU_AI_API_KEY config key should exist"); - assert!( - api_key.required, - "API key should be required for fixed declarative provider" - ); - assert!(api_key.secret, "API key should be secret"); - - // Should have TANZU_AI_ENDPOINT config key (not secret, required) - let endpoint = meta - .config_keys - .iter() - .find(|k| k.name == "TANZU_AI_ENDPOINT") - .expect("TANZU_AI_ENDPOINT config key should exist"); - assert!(endpoint.required, "Endpoint should be required"); - assert!(!endpoint.secret, "Endpoint should not be secret"); - } - #[tokio::test] async fn test_huggingface_provider_registry_wiring() { let huggingface = get_from_registry("huggingface") @@ -324,115 +287,6 @@ mod tests { .any(|key| key.name == "HF_TOKEN" && key.secret)); } - #[tokio::test] - async fn test_nvidia_declarative_provider_registry_wiring() { - let nvidia = get_from_registry("nvidia") - .await - .expect("nvidia provider should be registered"); - let meta = nvidia.metadata(); - - assert_eq!(nvidia.provider_type(), ProviderType::Declarative); - assert!(nvidia.supports_inventory_refresh()); - assert_eq!(meta.display_name, "NVIDIA"); - assert_eq!(meta.default_model, "z-ai/glm-4.7"); - assert_eq!(meta.model_doc_link, "https://build.nvidia.com/models"); - assert!(!meta.setup_steps.is_empty()); - - let api_key = meta - .config_keys - .iter() - .find(|k| k.name == "NVIDIA_API_KEY") - .expect("NVIDIA_API_KEY config key should exist"); - assert!(api_key.required, "NVIDIA_API_KEY should be required"); - assert!(api_key.secret, "NVIDIA_API_KEY should be secret"); - assert!(api_key.primary, "NVIDIA_API_KEY should be primary"); - assert!( - !meta.config_keys.iter().any(|k| k.name == "OPENAI_HOST"), - "NVIDIA should not expose OpenAI host configuration" - ); - assert!( - !meta - .config_keys - .iter() - .any(|k| k.name == "OPENAI_BASE_PATH"), - "NVIDIA should not expose OpenAI base path configuration" - ); - } - - #[tokio::test] - async fn test_nearai_declarative_provider_registry_wiring() { - let nearai = get_from_registry("nearai") - .await - .expect("nearai provider should be registered"); - let meta = nearai.metadata(); - - assert_eq!(nearai.provider_type(), ProviderType::Declarative); - assert!(nearai.supports_inventory_refresh()); - assert_eq!(meta.display_name, "NEAR AI Cloud"); - assert_eq!(meta.model_doc_link, "https://docs.near.ai/"); - assert!(!meta.setup_steps.is_empty()); - - let api_key = meta - .config_keys - .iter() - .find(|k| k.name == "NEARAI_API_KEY") - .expect("NEARAI_API_KEY config key should exist"); - assert!(api_key.required, "NEARAI_API_KEY should be required"); - assert!(api_key.secret, "NEARAI_API_KEY should be secret"); - assert!(api_key.primary, "NEARAI_API_KEY should be primary"); - } - - #[tokio::test] - async fn test_alibaba_declarative_provider_registry_wiring() { - let alibaba = get_from_registry("alibaba") - .await - .expect("alibaba provider should be registered"); - let meta = alibaba.metadata(); - - assert_eq!(alibaba.provider_type(), ProviderType::Declarative); - assert!(alibaba.supports_inventory_refresh()); - assert_eq!(meta.display_name, "Alibaba (Qwen)"); - assert_eq!(meta.default_model, "qwen3.7-max"); - assert_eq!( - meta.model_doc_link, - "https://www.alibabacloud.com/help/en/model-studio/models" - ); - assert!(!meta.setup_steps.is_empty()); - - let api_key = meta - .config_keys - .iter() - .find(|k| k.name == "DASHSCOPE_API_KEY") - .expect("DASHSCOPE_API_KEY config key should exist"); - assert!(api_key.required, "DASHSCOPE_API_KEY should be required"); - assert!(api_key.secret, "DASHSCOPE_API_KEY should be secret"); - assert!(api_key.primary, "DASHSCOPE_API_KEY should be primary"); - } - - #[tokio::test] - async fn test_fireworks_declarative_provider_registry_wiring() { - let fireworks = get_from_registry("fireworks-ai") - .await - .expect("fireworks-ai provider should be registered"); - let meta = fireworks.metadata(); - - assert_eq!(fireworks.provider_type(), ProviderType::Declarative); - assert_eq!(meta.display_name, "Fireworks AI"); - assert_eq!( - meta.default_model, - "accounts/fireworks/models/kimi-k2p7-code" - ); - assert_eq!(meta.model_doc_link, "https://fireworks.ai/models"); - - let api_key = meta - .config_keys - .iter() - .find(|k| k.name == "FIREWORKS_API_KEY") - .expect("FIREWORKS_API_KEY config key should exist"); - assert!(api_key.required, "FIREWORKS_API_KEY should be required"); - assert!(api_key.secret, "FIREWORKS_API_KEY should be secret"); - } - #[tokio::test] async fn test_openai_compatible_providers_config_keys() { let providers_list = providers().await; From 363b309861dbfa306c21b9e589af406f1aa183b9 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 1 Jul 2026 07:18:06 -0700 Subject: [PATCH 022/139] Update release testing instructions (#10122) --- .../pr_body_template.txt | 2 +- .github/workflows/create-version-bump-pr.yaml | 2 +- RELEASE_CHECKLIST.md | 131 ++---------------- goose-self-test.yaml | 10 +- 4 files changed, 20 insertions(+), 125 deletions(-) diff --git a/.github/actions/generate-release-pr-body/pr_body_template.txt b/.github/actions/generate-release-pr-body/pr_body_template.txt index e0c7e4d15d..48ffe753ab 100644 --- a/.github/actions/generate-release-pr-body/pr_body_template.txt +++ b/.github/actions/generate-release-pr-body/pr_body_template.txt @@ -2,7 +2,7 @@ ## Test before Release -1. Close and reopen this PR to trigger CI and produce the Desktop bundle for testing. Reason: workflows don't run on PRs opened by `GITHUB_TOKEN` ([docs](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)). +1. Approve the workflows for this PR (scroll to bottom) to trigger CI and produce the Desktop bundle for testing. 2. Make sure all check workflows pass. 3. Install the Desktop bundle from the download links (posted as a comment below when it is ready). 4. Complete the goose Release Manual Testing Checklist (posted as a comment below). diff --git a/.github/workflows/create-version-bump-pr.yaml b/.github/workflows/create-version-bump-pr.yaml index 9f1403ff57..b278da8105 100644 --- a/.github/workflows/create-version-bump-pr.yaml +++ b/.github/workflows/create-version-bump-pr.yaml @@ -85,7 +85,7 @@ jobs: **Please follow these steps:** - 1. Close and reopen this PR to trigger CI checks. Reason: workflows don't run on PRs opened by `GITHUB_TOKEN` ([docs](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)). + 1. Approve workflows for this PR to trigger CI checks. 2. Review and resolve any merge conflicts. 3. Approve and merge this PR. 4. The `release/${{ env.version }}` PR will be created automatically. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index d25d7c00bc..cb87c076c6 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,134 +1,23 @@ # goose Release Manual Testing Checklist -## Version: {{VERSION}} - -### Identify the high risk changes in this Release +Download the release builds from this PR. Once a build is ready, the actions bot will post a comment on this PR +with instructions on how to download and sign. +## Use the following script to create a risk assessment and testing plan: ``` ./workflow_recipes/release_risk_check/run.sh {{VERSION}} ``` -It will generate an analysis report in `/tmp/release_report_final.md` and perform testing is necessary for high risk pr changes. +It will generate an analysis report in `/tmp/release_report_final.md` and perform testing is necessary for high risk pr changes. +## Run the goose self-test recipe -## Regression Testing +goose run --recipe goose-self-test.yaml -Make a copy of this document for each version and check off as steps are verified. +## Have goose produce a test plan -### Provider Testing +Open the release candidate desktop app and have goose produce a test plan by pointing it at this PR. Use a prompt like -- [ ] Run `cd ui/desktop && pnpm run test:integration:providers` locally from the release branch and verify all providers/models work -- [ ] Launch goose, click reset providers, choose databricks and a model +> Look at the notes in PR and the report at `/tmp/release_report_final.md` and investigate potential risks in this release. After familiarizing yourself with the scope of each change, produce a suggested test plan that I should follow before publishing the release. -### Starting Conversations - -Test various ways to start a conversation: - -- [ ] Open home and start a new conversation with "Hello" - - [ ] Agent responds - - [ ] Token count is updated after agent finishes - - [ ] Go to history and see there is a new entry -- [ ] Go back to the main screen, start a new conversation from the hub and see that it opens a new conversation -- [ ] Open history and click the Hello conversation - verify it loads -- [ ] Add a new message to this conversation and see that it is added -- [ ] Change the working directory of an existing conversation - - [ ] Ask "what is your working directory?" - - [ ] Response should match the new directory -- [ ] Open a new window, click chat in left side for new chat -- [ ] Type "create a tamagotchi game" in the chat input to test developer extension - -### Recipes - -#### Create Recipe from Session - -- [ ] Start a simple chat conversation like "hi" -- [ ] Click "create a recipe from this session" in the bottom chat bar - - [ ] Recipe title, description and instructions should be filled in with details from the chat - - [ ] Add a few activities and params (params unused indicator should update if added to instructions/prompts or activities) - - [ ] Can launch create and run recipe - launches in a new window showing as a recipe agent chat with parameters filled in and interact with it - - [ ] Recipe should be saved in recipe library - -#### Use Existing Recipe - -- [ ] Pick trip planner from recipe hub (go/gooserecipes) - - [ ] See the warning whether to trust this recipe (only on fresh install) - - [ ] See the form pop up - - [ ] Fill in the form with "Africa" and "14 days" - - [ ] Check results are reasonable - - [ ] Ask how many days the trip is for - should say 14 - -#### Recipe Management - -- [ ] Go to recipe manager and enter a new recipe to generate a joke - - [ ] See that it works if you run it - - [ ] Edit the recipe by bottom bar and click "View/Edit Recipe" - - [ ] Make it generate a limerick instead - - [ ] Check that the updated recipe works - - [ ] Delete the recipe from the recipe manager - - [ ] Verify recipe is actually deleted - -#### Recipe from File - -- [ ] Create a file `~/.config/goose/recipes/test-recipe.yaml` with the following content: - -```yaml -recipe: - title: test recipe again - description: testing recipe again - instructions: The value of test_param is {{test_param}} - prompt: What is the value of test_param? - parameters: - - key: test_param - input_type: string - requirement: required - description: Enter value for test_param -``` - -- [ ] See that it shows up in the list of installed recipes -- [ ] Launch the recipe, see that it asks for test_param -- [ ] Enter a number, see that it pre-fills the prompt and tells you the value after you hit submit -- [ ] Go to hub and enter "what is the value of test_param" -- [ ] See a new chat that says it has no idea (recipe is no longer active) - -### Extensions - -#### Manual Extension Addition - -- [ ] Can manually add an extension using random quotes from project - - [ ] Add new custom stdio extension with the following command and save: - - [ ] `node /ABSOLUTE/PATH/TO/goose/ui/desktop/tests/e2e/basic-mcp.ts` (use your actual project path) - - [ ] Should add and can chat to ask for a random quote - -#### Playwright Extension - -- [ ] Install the playwright extension from the extensions hub - - [ ] Tell it to open a browser and search on Google for cats - - [ ] Verify that the browser opens and navigates - -#### Extension with Environment Variables - -- [ ] Install an extension from deeplink that needs env variables: - - [ ] Use: `goose://extension?cmd=npx&arg=-y&arg=%40upstash%2Fcontext7-mcp&id=context7&name=Context7&description=Use%20up-to-date%20code%20and%20docs&env=TEST_ACCESS_TOKEN` - - [ ] Extension page should load with env variables modal showing - - [ ] Allow form input and saving extension - -### Speech-to-Text (Local Model) - -- [ ] Go to Settings > Chat > Voice dictation provider and select the small model -- [ ] Run a quick test that speech-to-text is working (click the mic button, speak, verify transcription) -- [ ] Also try OpenAI using your OpenAI key - -### Settings - -- [ ] Settings page loads and all tabs load -- [ ] Can change dark mode setting - -### Follow-up Issues - -Link any GitHub issues filed during testing: - ---- - -**Tested by:** _____ -**Date:** _____ -**Notes:** _____ +goose will produce a plan. Follow this plan to finish testing. diff --git a/goose-self-test.yaml b/goose-self-test.yaml index 96e713201b..cfcb042b80 100644 --- a/goose-self-test.yaml +++ b/goose-self-test.yaml @@ -114,6 +114,12 @@ extensions: timeout: 600 bundled: true description: Core tool for file operations, shell commands, and code analysis + - type: builtin + name: todo + - type: builtin + name: summon + - type: builtin + name: extensionmanager prompt: | Execute the Goose Self-Testing Integration Suite in {{ workspace_dir }}. @@ -171,7 +177,7 @@ prompt: | {% endif %} {% if test_phases == "all" or "delegation" in test_phases %} - ## 🤖 PHASE 3: Delegate & Load Testing + ## 🤖 PHASE 3: Summon Testing ### Load Tool - Discovery Mode Call `load()` with no arguments to discover all available sources: @@ -326,7 +332,7 @@ prompt: | ### Vision Smoke Test 1. Create a small test image: ``` - python3 -c "import struct, zlib; raw=b'\x00\xff\x00\x00'; d=zlib.compress(raw); ihdr=b'\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00'; print('Created test.png')" + python3 -c "import struct, zlib; raw=b'\x00\xff\x00\x00'; d=zlib.compress(raw); ihdr=b'\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00'; print('Created test.png')" ``` Or simply create a 1-pixel PNG test image using available tools. 2. Verify the test image file exists and is valid. From 006d585546e8a8c63a5655458720fa5250cae43f Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 1 Jul 2026 12:26:40 -0400 Subject: [PATCH 023/139] fix(desktop): seed mac auto-update config (#10132) Co-authored-by: Douwe M Osinga --- .github/workflows/bundle-desktop-intel.yml | 4 +++ .github/workflows/bundle-desktop.yml | 4 +++ .github/workflows/release.yml | 23 ++++++++++-- ui/desktop/forge.config.ts | 2 +- .../scripts/verify-mac-update-resources.js | 35 +++++++++++++++++++ ui/desktop/src/app-update.yml | 4 +++ 6 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 ui/desktop/scripts/verify-mac-update-resources.js create mode 100644 ui/desktop/src/app-update.yml diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index d8eecc71bc..3ac28f404d 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -152,6 +152,10 @@ jobs: fi working-directory: ui/desktop + - name: Verify macOS updater resources + run: node scripts/verify-mac-update-resources.js "out/Goose-darwin-x64/Goose.app" + working-directory: ui/desktop + - name: Clean up signing keychain if: always() run: | diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index 0437a76642..f892a8bb1e 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -184,6 +184,10 @@ jobs: fi working-directory: ui/desktop + - name: Verify macOS updater resources + run: node scripts/verify-mac-update-resources.js "out/Goose-darwin-arm64/Goose.app" + working-directory: ui/desktop + - name: Clean up signing keychain if: always() run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2af367473..158d940aec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,11 @@ permissions: pull-requests: write # Required for npm publish workflow attestations: write # Required for SLSA build provenance attestations +env: + # Set this repository Actions variable to "true" in GitHub Settings > Secrets and variables + # > Actions > Variables after a release containing desktop app-update.yml has shipped. + ENABLE_MAC_NATIVE_AUTO_UPDATE: ${{ vars.ENABLE_MAC_NATIVE_AUTO_UPDATE || 'false' }} + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -119,8 +124,15 @@ jobs: merge-multiple: true - name: Generate macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} run: node ui/desktop/scripts/generate-mac-update-manifest.js --version "${GITHUB_REF_NAME}" --directory . + - name: Attest macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: latest-mac.yml + - name: Attest build provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: @@ -132,7 +144,6 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh # Create/update the versioned release @@ -148,7 +159,6 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh allowUpdates: true omitBody: true @@ -169,8 +179,15 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh allowUpdates: true omitBody: true omitPrereleaseDuringUpdate: true + + - name: Upload macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${GITHUB_REF_NAME}" latest-mac.yml --clobber + gh release upload stable latest-mac.yml --clobber diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index c68ee433cd..6b88b98282 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -6,7 +6,7 @@ const isLinuxVulkanBuild = process.env.GOOSE_DESKTOP_LINUX_VARIANT === 'vulkan'; let cfg = { asar: true, - extraResource: ['src/bin', 'src/images'], + extraResource: ['src/bin', 'src/images', 'src/app-update.yml'], icon: 'src/images/icon', // Windows specific configuration win32: { diff --git a/ui/desktop/scripts/verify-mac-update-resources.js b/ui/desktop/scripts/verify-mac-update-resources.js new file mode 100644 index 0000000000..e75f9adc37 --- /dev/null +++ b/ui/desktop/scripts/verify-mac-update-resources.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); + +function fail(message) { + console.error(message); + process.exit(1); +} + +const appPath = process.argv[2]; +if (!appPath) { + fail('Usage: node scripts/verify-mac-update-resources.js '); +} + +const updateConfigPath = path.join(appPath, 'Contents', 'Resources', 'app-update.yml'); +if (!fs.existsSync(updateConfigPath)) { + fail(`Missing ${updateConfigPath}`); +} + +const updateConfig = fs.readFileSync(updateConfigPath, 'utf8'); +const requiredLines = [ + 'provider: github', + 'owner: aaif-goose', + 'repo: goose', + 'updaterCacheDirName: goose-updater', +]; + +for (const line of requiredLines) { + if (!updateConfig.split(/\r?\n/).includes(line)) { + fail(`${updateConfigPath} is missing "${line}"`); + } +} + +console.log(`${updateConfigPath} is present and valid`); diff --git a/ui/desktop/src/app-update.yml b/ui/desktop/src/app-update.yml new file mode 100644 index 0000000000..47fc62049f --- /dev/null +++ b/ui/desktop/src/app-update.yml @@ -0,0 +1,4 @@ +provider: github +owner: aaif-goose +repo: goose +updaterCacheDirName: goose-updater From b6349b5125cf49feb1e7d76741872173f6d54ca9 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 1 Jul 2026 09:40:49 -0700 Subject: [PATCH 024/139] add declarative provider support to goose-providers crate (#9992) --- Cargo.lock | 1 + crates/goose-providers/Cargo.toml | 1 + .../goose-providers/examples/declarative.rs | 33 ++ crates/goose-providers/examples/deepseek.json | 30 ++ crates/goose-providers/examples/zai.json | 25 ++ crates/goose-providers/src/anthropic.rs | 113 +++++ crates/goose-providers/src/declarative.rs | 388 ++++++++++++++++++ crates/goose-providers/src/lib.rs | 1 + crates/goose-providers/src/ollama.rs | 379 +++++++++++++++-- crates/goose-providers/src/openai.rs | 214 ++++++++++ .../goose/src/config/declarative_providers.rs | 107 +---- crates/goose/src/providers/anthropic_def.rs | 92 +---- .../src/providers/custom_provider_config.rs | 21 + crates/goose/src/providers/mod.rs | 1 + crates/goose/src/providers/ollama_def.rs | 83 +--- crates/goose/src/providers/openai_def.rs | 150 +------ 16 files changed, 1226 insertions(+), 413 deletions(-) create mode 100644 crates/goose-providers/examples/declarative.rs create mode 100644 crates/goose-providers/examples/deepseek.json create mode 100644 crates/goose-providers/examples/zai.json create mode 100644 crates/goose-providers/src/declarative.rs create mode 100644 crates/goose/src/providers/custom_provider_config.rs diff --git a/Cargo.lock b/Cargo.lock index bce322a502..c22c0c45ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5133,6 +5133,7 @@ dependencies = [ "url", "utoipa 4.2.3", "uuid", + "wiremock", ] [[package]] diff --git a/crates/goose-providers/Cargo.toml b/crates/goose-providers/Cargo.toml index 76f15b746a..0ef45601d2 100644 --- a/crates/goose-providers/Cargo.toml +++ b/crates/goose-providers/Cargo.toml @@ -62,6 +62,7 @@ tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } tokio-stream = { workspace = true } env-lock = { workspace = true } +wiremock.workspace = true [[example]] name = "streaming" diff --git a/crates/goose-providers/examples/declarative.rs b/crates/goose-providers/examples/declarative.rs new file mode 100644 index 0000000000..7d63ff0e1c --- /dev/null +++ b/crates/goose-providers/examples/declarative.rs @@ -0,0 +1,33 @@ +use anyhow::Result; +use futures::StreamExt; +use goose_providers::{ + base::Provider, conversation::message::Message, declarative::EnvKeyResolver, model::ModelConfig, +}; + +async fn complete(provider: &dyn Provider, model: ModelConfig) -> Result<()> { + let system = "You are a knowledgable geography expert"; + let messages = [Message::user().with_text("what is the capital of France?")]; + let mut stream = provider.stream(&model, system, &messages, &[]).await?; + + while let Some((Some(msg), _)) = stream.next().await.transpose()? { + print!("{}", msg.as_concat_text()); + } + println!(); + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + let deepseek = include_str!("deepseek.json"); + let deepseek_model = ModelConfig::new("deepseek-v4-flash"); + let zai = include_str!("zai.json"); + let zai_model = ModelConfig::new("glm-4.5-flash"); + + for (json, model) in [(deepseek, deepseek_model), (zai, zai_model)] { + let provider = goose_providers::declarative::from_json(json, None, EnvKeyResolver {})?; + println!("{}:", provider.get_name()); + complete(provider.as_ref(), model).await?; + } + Ok(()) +} diff --git a/crates/goose-providers/examples/deepseek.json b/crates/goose-providers/examples/deepseek.json new file mode 100644 index 0000000000..1d22074437 --- /dev/null +++ b/crates/goose-providers/examples/deepseek.json @@ -0,0 +1,30 @@ +{ + "name": "deepseek", + "engine": "openai", + "display_name": "DeepSeek", + "description": "Custom DeepSeek provider", + "api_key_env": "DEEPSEEK_API_KEY", + "base_url": "https://api.deepseek.com", + "models": [ + { + "name": "deepseek-chat", + "context_limit": 128000, + "input_token_cost": null, + "output_token_cost": null, + "currency": null, + "supports_cache_control": null + }, + { + "name": "deepseek-reasoner", + "context_limit": 128000, + "input_token_cost": null, + "output_token_cost": null, + "currency": null, + "supports_cache_control": null + } + ], + "headers": null, + "timeout_seconds": null, + "preserves_thinking": true, + "supports_streaming": true +} diff --git a/crates/goose-providers/examples/zai.json b/crates/goose-providers/examples/zai.json new file mode 100644 index 0000000000..afcb161662 --- /dev/null +++ b/crates/goose-providers/examples/zai.json @@ -0,0 +1,25 @@ +{ + "name": "zai", + "engine": "anthropic", + "display_name": "Z.AI", + "description": "Z.AI GLM models via Anthropic-compatible API.", + "api_key_env": "ZHIPU_API_KEY", + "base_url": "https://api.z.ai/api/anthropic", + "catalog_provider_id": "zai", + "model_doc_link": "https://docs.z.ai/devpack/tool/goose", + "fast_model": "glm-4.5-air", + "preserves_thinking": true, + "models": [ + { "name": "glm-5.1", "context_limit": 200000 }, + { "name": "glm-5", "context_limit": 204800 }, + { "name": "glm-5-turbo", "context_limit": 200000 }, + { "name": "glm-4.7", "context_limit": 204800 }, + { "name": "glm-4.7-flash", "context_limit": 200000 }, + { "name": "glm-4.7-flashx", "context_limit": 200000 }, + { "name": "glm-4.6", "context_limit": 204800 }, + { "name": "glm-4.5", "context_limit": 131072 }, + { "name": "glm-4.5-air", "context_limit": 131072 }, + { "name": "glm-4.5-flash", "context_limit": 131072 } + ], + "supports_streaming": true +} diff --git a/crates/goose-providers/src/anthropic.rs b/crates/goose-providers/src/anthropic.rs index a0c901721c..33755a6862 100644 --- a/crates/goose-providers/src/anthropic.rs +++ b/crates/goose-providers/src/anthropic.rs @@ -1,4 +1,6 @@ +use crate::api_client::{AuthMethod, TlsConfig}; use crate::base::ProviderDescriptor; +use crate::declarative::{DeclarativeProviderConfig, KeyResolver}; use crate::errors::ProviderError; use crate::request_log::{start_log, LoggerHandleExt}; use anyhow::Result; @@ -90,6 +92,24 @@ impl AnthropicProviderBuilder { } } + pub fn api_client(mut self, api_client: ApiClient) -> Self { + self.api_client = api_client; + self + } + + pub fn map_api_client(mut self, f: impl FnOnce(ApiClient) -> ApiClient) -> Self { + self.api_client = f(self.api_client); + self + } + + pub fn try_map_api_client( + mut self, + f: impl FnOnce(ApiClient) -> Result, + ) -> Result { + self.api_client = f(self.api_client)?; + Ok(self) + } + pub fn supports_streaming(mut self, supports_streaming: bool) -> Self { self.supports_streaming = supports_streaming; self @@ -287,3 +307,96 @@ impl Provider for AnthropicProvider { })) } } + +fn format_options_for_provider(preserves_thinking: bool) -> AnthropicFormatOptions { + AnthropicFormatOptions { + preserve_unsigned_thinking: preserves_thinking, + preserve_thinking_context: preserves_thinking, + thinking_disabled: false, + } +} + +pub fn from_declarative_config( + config: DeclarativeProviderConfig, + tls_config: Option, + key_resolver: impl KeyResolver, +) -> Result { + let custom_models = if !config.models.is_empty() { + Some( + config + .models + .iter() + .map(|m| m.name.clone()) + .collect::>(), + ) + } else { + None + }; + + if config.dynamic_models == Some(false) && custom_models.is_none() { + return Err(anyhow::anyhow!( + "Provider '{}' has dynamic_models: false but no static models listed; \ + at least one entry in `models` is required.", + config.name + )); + } + + let api_key = if config.api_key_env.is_empty() { + None + } else { + match key_resolver.resolve_key(config.api_key_env.as_str()) { + Ok(key) => Some(key), + Err(err) => { + if config.requires_auth { + anyhow::bail!("missing required key {}: {}", config.api_key_env, err); + } + None + } + } + }; + + let auth = match api_key { + Some(key) if !key.is_empty() => AuthMethod::ApiKey { + header_name: "x-api-key".to_string(), + key, + }, + _ => AuthMethod::NoAuth, + }; + + let format_options = format_options_for_provider(config.preserves_thinking); + + let mut api_client = ApiClient::new_with_tls(config.base_url, auth, tls_config)?; + + if let Some(headers) = &config.headers { + let mut header_map = reqwest::header::HeaderMap::new(); + header_map.insert( + reqwest::header::HeaderName::from_static("anthropic-version"), + reqwest::header::HeaderValue::from_static(ANTHROPIC_API_VERSION), + ); + for (key, value) in headers { + let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; + let header_value = reqwest::header::HeaderValue::from_str(value)?; + header_map.insert(header_name, header_value); + } + api_client = api_client.with_headers(header_map)?; + } else { + api_client = api_client.with_header("anthropic-version", ANTHROPIC_API_VERSION)?; + } + + let supports_streaming = config.supports_streaming.unwrap_or(true); + + if !supports_streaming { + return Err(anyhow::anyhow!( + "Anthropic provider does not support non-streaming mode. All Claude models support streaming. \ + Please remove 'supports_streaming: false' from your provider configuration." + )); + } + + Ok(AnthropicProviderBuilder::new(api_client) + .supports_streaming(supports_streaming) + .name(config.name.clone()) + .custom_models(custom_models) + .dynamic_models(config.dynamic_models) + .skip_canonical_filtering(config.skip_canonical_filtering) + .format_options(format_options)) +} diff --git a/crates/goose-providers/src/declarative.rs b/crates/goose-providers/src/declarative.rs new file mode 100644 index 0000000000..d3c39929c0 --- /dev/null +++ b/crates/goose-providers/src/declarative.rs @@ -0,0 +1,388 @@ +use std::{collections::HashMap, str::FromStr}; + +use anyhow::Result; +use serde::{Deserialize, Deserializer, Serialize}; +use utoipa::ToSchema; + +use crate::{ + anthropic, + api_client::TlsConfig, + base::{ModelInfo, Provider}, + ollama, openai, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct EnvVarConfig { + pub name: String, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub secret: bool, + /// Defaults to the value of `required` if not specified. + /// UIs may use this to feature this config value more prominently. + pub primary: Option, + pub description: Option, + pub default: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum ProviderEngine { + #[serde(alias = "openai_compatible")] + OpenAI, + #[serde(alias = "ollama_compatible")] + Ollama, + #[serde(alias = "anthropic_compatible")] + Anthropic, +} + +impl FromStr for ProviderEngine { + type Err = anyhow::Error; + + fn from_str(engine: &str) -> Result { + match engine.trim().to_lowercase().as_str() { + "openai" | "openai_compatible" => Ok(Self::OpenAI), + "anthropic" | "anthropic_compatible" => Ok(Self::Anthropic), + "ollama" | "ollama_compatible" => Ok(Self::Ollama), + _ => Err(anyhow::anyhow!("Invalid provider type: {}", engine)), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct DeclarativeProviderConfig { + pub name: String, + pub engine: ProviderEngine, + pub display_name: String, + pub description: Option, + #[serde(default)] + pub api_key_env: String, + pub base_url: String, + pub models: Vec, + pub headers: Option>, + pub timeout_seconds: Option, + pub supports_streaming: Option, + #[serde(default = "default_requires_auth")] + pub requires_auth: bool, + #[serde(default)] + pub catalog_provider_id: Option, + #[serde(default)] + pub base_path: Option, + #[serde(default)] + pub env_vars: Option>, + /// Controls whether `fetch_supported_models` calls the provider's `/v1/models` + /// endpoint or returns the static `models` list directly. + /// + /// - `Some(false)` + non-empty `models`: return the static list; no API call. + /// Construction fails if `models` is empty. + /// - `Some(true)` or `None`: try the API; fall back to `models` on 404. + #[serde(default)] + pub dynamic_models: Option, + #[serde(default)] + pub skip_canonical_filtering: bool, + #[serde(default, deserialize_with = "deserialize_non_empty_string")] + pub model_doc_link: Option, + #[serde(default)] + pub setup_steps: Vec, + #[serde(default, deserialize_with = "deserialize_non_empty_string")] + pub fast_model: Option, + #[serde(default)] + pub preserves_thinking: bool, +} + +fn default_requires_auth() -> bool { + true +} + +fn should_preserve_thinking_by_default(engine: &ProviderEngine) -> bool { + matches!(engine, ProviderEngine::OpenAI) +} + +/// Deserialize an optional string, treating empty/whitespace-only values as None. +fn deserialize_non_empty_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + Ok(opt.filter(|s| !s.trim().is_empty())) +} + +impl DeclarativeProviderConfig { + pub fn id(&self) -> &str { + &self.name + } + + pub fn display_name(&self) -> &str { + &self.display_name + } + + pub fn models(&self) -> &[ModelInfo] { + &self.models + } +} + +pub trait KeyResolver { + type Error: std::error::Error + Send + Sync + 'static; + + fn resolve_key(&self, key: &str) -> std::result::Result; +} + +pub struct EnvKeyResolver; + +impl EnvKeyResolver { + pub fn new() -> Self { + EnvKeyResolver {} + } +} + +impl Default for EnvKeyResolver { + fn default() -> Self { + Self::new() + } +} + +impl KeyResolver for EnvKeyResolver { + type Error = std::env::VarError; + + fn resolve_key(&self, key: &str) -> std::result::Result { + std::env::var(key) + } +} + +fn expand_env_vars(template: &str, env_vars: &[EnvVarConfig]) -> Result { + let mut result = template.to_string(); + + for var in env_vars { + let placeholder = format!("${{{}}}", var.name); + if !result.contains(&placeholder) { + continue; + } + + let value = match std::env::var(&var.name) { + Ok(value) => value, + Err(_) => match &var.default { + Some(default) => default.clone(), + None if var.required => { + anyhow::bail!("Required environment variable {} is not set", var.name) + } + None => continue, + }, + }; + + result = result.replace(&placeholder, &value); + } + + Ok(result) +} + +fn resolve_config(config: &mut DeclarativeProviderConfig) -> Result<()> { + if let Some(env_vars) = &config.env_vars { + config.base_url = expand_env_vars(&config.base_url, env_vars)?; + + for var in env_vars { + if var.name.ends_with("_STREAMING") { + let value = std::env::var(&var.name) + .ok() + .or_else(|| var.default.clone()) + .map(|value| value.eq_ignore_ascii_case("true")); + if let Some(value) = value { + config.supports_streaming = Some(value); + } + } + } + } + + Ok(()) +} + +fn config_from_json(json: &str) -> Result { + let raw: serde_json::Value = serde_json::from_str(json)?; + let preserves_thinking_was_set = raw.get("preserves_thinking").is_some(); + let mut config: DeclarativeProviderConfig = serde_json::from_value(raw)?; + + if !preserves_thinking_was_set { + config.preserves_thinking = should_preserve_thinking_by_default(&config.engine); + } + + resolve_config(&mut config)?; + Ok(config) +} + +pub fn from_json( + json: &str, + tls_config: Option, + key_resolver: impl KeyResolver, +) -> Result> { + let config = config_from_json(json)?; + + match config.engine { + ProviderEngine::OpenAI => openai::from_declarative_config(config, tls_config, key_resolver) + .map(|provider| Box::new(provider.build()) as Box), + ProviderEngine::Ollama => ollama::from_declarative_config(config, tls_config, key_resolver) + .map(|provider| Box::new(provider.build()) as Box), + ProviderEngine::Anthropic => { + anthropic::from_declarative_config(config, tls_config, key_resolver) + .map(|provider| Box::new(provider.build()) as Box) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn model_json() -> serde_json::Value { + json!({ + "name": "test-model", + "context_limit": 4096, + "input_token_cost": null, + "output_token_cost": null, + "currency": null, + "supports_cache_control": null, + "reasoning": false + }) + } + + #[test] + fn provider_engine_deserializes_compatible_aliases() { + let openai: DeclarativeProviderConfig = serde_json::from_value(json!({ + "name": "test-openai", + "engine": "openai_compatible", + "display_name": "Test OpenAI", + "base_url": "http://localhost:1234", + "models": [model_json()] + })) + .unwrap(); + assert_eq!(openai.engine, ProviderEngine::OpenAI); + + let anthropic: DeclarativeProviderConfig = serde_json::from_value(json!({ + "name": "test-anthropic", + "engine": "anthropic_compatible", + "display_name": "Test Anthropic", + "base_url": "http://localhost:1234", + "models": [model_json()] + })) + .unwrap(); + assert_eq!(anthropic.engine, ProviderEngine::Anthropic); + + let ollama: DeclarativeProviderConfig = serde_json::from_value(json!({ + "name": "test-ollama", + "engine": "ollama_compatible", + "display_name": "Test Ollama", + "base_url": "http://localhost:11434", + "models": [model_json()] + })) + .unwrap(); + assert_eq!(ollama.engine, ProviderEngine::Ollama); + } + + #[test] + fn from_json_defaults_openai_preserves_thinking_to_true() { + let json = json!({ + "name": "test-provider", + "engine": "openai", + "display_name": "Test Provider", + "base_url": "http://localhost:1234/v1/chat/completions", + "models": [model_json()], + "requires_auth": false, + "dynamic_models": false + }) + .to_string(); + + let config = config_from_json(&json).unwrap(); + + assert!(config.preserves_thinking); + } + + #[test] + fn from_json_preserves_explicit_openai_preserves_thinking_false() { + let json = json!({ + "name": "test-provider", + "engine": "openai", + "display_name": "Test Provider", + "base_url": "http://localhost:1234/v1/chat/completions", + "models": [model_json()], + "requires_auth": false, + "dynamic_models": false, + "preserves_thinking": false + }) + .to_string(); + + let config = config_from_json(&json).unwrap(); + + assert!(!config.preserves_thinking); + } + + #[test] + fn from_json_expands_base_url_from_env_var_default() { + let _guard = env_lock::lock_env([("TEST_PROVIDER_HOST", None::<&str>)]); + let json = json!({ + "name": "test-provider", + "engine": "openai", + "display_name": "Test Provider", + "base_url": "${TEST_PROVIDER_HOST}/v1/chat/completions", + "models": [model_json()], + "requires_auth": false, + "dynamic_models": false, + "env_vars": [{ + "name": "TEST_PROVIDER_HOST", + "default": "http://localhost:1234" + }] + }) + .to_string(); + + let provider = from_json(&json, None, EnvKeyResolver).unwrap(); + + assert_eq!(provider.get_name(), "test-provider"); + } + + #[tokio::test] + async fn from_json_ollama_returns_static_models_when_dynamic_models_false() { + let json = json!({ + "name": "test-ollama", + "engine": "ollama", + "display_name": "Test Ollama", + "base_url": "http://localhost:11434", + "models": [model_json()], + "requires_auth": false, + "dynamic_models": false + }) + .to_string(); + + let provider = from_json(&json, None, EnvKeyResolver).unwrap(); + + assert_eq!( + provider.fetch_supported_models().await.unwrap(), + vec!["test-model".to_string()] + ); + } + + #[test] + fn from_json_errors_when_required_env_var_is_missing() { + let _guard = env_lock::lock_env([("TEST_PROVIDER_REQUIRED_HOST", None::<&str>)]); + let json = json!({ + "name": "test-provider", + "engine": "openai", + "display_name": "Test Provider", + "base_url": "${TEST_PROVIDER_REQUIRED_HOST}/v1/chat/completions", + "models": [model_json()], + "requires_auth": false, + "dynamic_models": false, + "env_vars": [{ + "name": "TEST_PROVIDER_REQUIRED_HOST", + "required": true + }] + }) + .to_string(); + + let err = match from_json(&json, None, EnvKeyResolver) { + Ok(_) => panic!("expected missing required env var error"), + Err(err) => err, + }; + + assert!(err + .to_string() + .contains("Required environment variable TEST_PROVIDER_REQUIRED_HOST is not set")); + } +} diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs index 99944f92d9..f84486bdc0 100644 --- a/crates/goose-providers/src/lib.rs +++ b/crates/goose-providers/src/lib.rs @@ -3,6 +3,7 @@ pub mod api_client; pub mod base; pub mod canonical; pub mod conversation; +pub mod declarative; pub mod errors; pub mod formats; pub mod goose_mode; diff --git a/crates/goose-providers/src/ollama.rs b/crates/goose-providers/src/ollama.rs index 7db1fb3742..9d7fb35cf1 100644 --- a/crates/goose-providers/src/ollama.rs +++ b/crates/goose-providers/src/ollama.rs @@ -2,8 +2,10 @@ use super::api_client::ApiClient; use super::base::{ConfigKey, MessageStream, Provider, ProviderMetadata}; use super::openai_compatible::handle_status; use super::retry::{ProviderRetry, RetryConfig}; +use crate::api_client::{AuthMethod, TlsConfig}; use crate::base::ProviderDescriptor; use crate::conversation::message::Message; +use crate::declarative::{DeclarativeProviderConfig, KeyResolver}; use crate::errors::ProviderError; use crate::formats::ollama::{create_request, response_to_streaming_message_ollama}; use crate::images::ImageFormat; @@ -13,7 +15,7 @@ use anyhow::{Error, Result}; use async_stream::try_stream; use async_trait::async_trait; use futures::TryStreamExt; -use reqwest::Response; +use reqwest::{Response, StatusCode}; use rmcp::model::Tool; use serde_json::{json, Value}; use std::time::Duration; @@ -21,6 +23,7 @@ use tokio::pin; use tokio_stream::StreamExt; use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::io::StreamReader; +use url::Url; pub const OLLAMA_PROVIDER_NAME: &str = "ollama"; pub const OLLAMA_HOST: &str = "localhost"; @@ -79,10 +82,88 @@ pub struct OllamaProvider { #[serde(skip)] api_client: ApiClient, name: String, + custom_models: Option>, + dynamic_models: Option, skip_canonical_filtering: bool, options: OllamaOptions, } +pub struct OllamaProviderBuilder { + api_client: ApiClient, + name: String, + custom_models: Option>, + dynamic_models: Option, + skip_canonical_filtering: bool, + options: OllamaOptions, +} + +impl OllamaProviderBuilder { + pub fn new(api_client: ApiClient) -> Self { + Self { + api_client, + name: OLLAMA_PROVIDER_NAME.to_string(), + custom_models: None, + dynamic_models: None, + skip_canonical_filtering: false, + options: OllamaOptions::default(), + } + } + + pub fn api_client(mut self, api_client: ApiClient) -> Self { + self.api_client = api_client; + self + } + + pub fn map_api_client(mut self, f: impl FnOnce(ApiClient) -> ApiClient) -> Self { + self.api_client = f(self.api_client); + self + } + + pub fn try_map_api_client( + mut self, + f: impl FnOnce(ApiClient) -> Result, + ) -> Result { + self.api_client = f(self.api_client)?; + Ok(self) + } + + pub fn name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + pub fn custom_models(mut self, custom_models: Option>) -> Self { + self.custom_models = custom_models; + self + } + + pub fn dynamic_models(mut self, dynamic_models: Option) -> Self { + self.dynamic_models = dynamic_models; + self + } + + pub fn skip_canonical_filtering(mut self, skip_canonical_filtering: bool) -> Self { + self.skip_canonical_filtering = skip_canonical_filtering; + self + } + + pub fn options(mut self, options: OllamaOptions) -> Self { + self.options = options; + self + } + + pub fn build(self) -> OllamaProvider { + OllamaProvider { + api_client: self.api_client, + name: self.name, + custom_models: self.custom_models, + dynamic_models: self.dynamic_models, + skip_canonical_filtering: self.skip_canonical_filtering, + options: self.options, + } + } +} + impl OllamaProvider { pub fn new( api_client: ApiClient, @@ -90,12 +171,58 @@ impl OllamaProvider { skip_canonical_filtering: bool, options: OllamaOptions, ) -> Self { - Self { - api_client, - name, - skip_canonical_filtering, - options, + OllamaProviderBuilder::new(api_client) + .name(name) + .skip_canonical_filtering(skip_canonical_filtering) + .options(options) + .build() + } + + pub fn with_options(mut self, options: OllamaOptions) -> Self { + self.options = options; + self + } + + async fn fetch_models_from_api(&self) -> Result, ProviderError> { + let response = self + .api_client + .request("api/tags") + .response_get() + .await + .map_err(|e| ProviderError::RequestFailed(format!("Failed to fetch models: {}", e)))?; + + if response.status() == StatusCode::NOT_FOUND { + return Err(ProviderError::EndpointNotFound( + "Ollama models endpoint not found".to_string(), + )); } + + if !response.status().is_success() { + return Err(ProviderError::RequestFailed(format!( + "Failed to fetch models: HTTP {}", + response.status() + ))); + } + + let json_response = response.json::().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse response: {}", e)) + })?; + + let models = json_response + .get("models") + .and_then(|m| m.as_array()) + .ok_or_else(|| { + ProviderError::RequestFailed("No models array in response".to_string()) + })?; + + let mut model_names: Vec = models + .iter() + .filter_map(|model| model.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect(); + + model_names.sort(); + + Ok(model_names) } } @@ -136,6 +263,100 @@ fn apply_ollama_options(payload: &mut Value, options: &OllamaOptions, model_conf } } +pub fn from_declarative_config( + config: DeclarativeProviderConfig, + tls_config: Option, + key_resolver: impl KeyResolver, +) -> Result { + let custom_models = if !config.models.is_empty() { + Some( + config + .models + .iter() + .map(|m| m.name.clone()) + .collect::>(), + ) + } else { + None + }; + + if config.dynamic_models == Some(false) && custom_models.is_none() { + return Err(anyhow::anyhow!( + "Provider '{}' has dynamic_models: false but no static models listed; \ + at least one entry in `models` is required.", + config.name + )); + } + + let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT)); + + let base_has_scheme = + config.base_url.starts_with("http://") || config.base_url.starts_with("https://"); + let base = if base_has_scheme { + config.base_url.clone() + } else { + format!("http://{}", config.base_url) + }; + + let mut base_url = Url::parse(&base) + .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; + + let is_localhost = matches!(base_url.host_str(), Some("localhost" | "127.0.0.1" | "::1")); + + if base_url.port().is_none() && !base_has_scheme && is_localhost { + base_url + .set_port(Some(OLLAMA_DEFAULT_PORT)) + .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; + } + + let api_key = if config.api_key_env.is_empty() { + None + } else { + match key_resolver.resolve_key(config.api_key_env.as_str()) { + Ok(key) => Some(key), + Err(err) => { + if config.requires_auth { + anyhow::bail!("missing required key {}: {}", config.api_key_env, err); + } + None + } + } + }; + + let auth = match api_key { + Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), + _ => AuthMethod::NoAuth, + }; + + let mut api_client = + ApiClient::with_timeout_and_tls(base_url.to_string(), auth, timeout, tls_config)?; + + if let Some(headers) = &config.headers { + let mut header_map = reqwest::header::HeaderMap::new(); + for (key, value) in headers { + let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; + let header_value = reqwest::header::HeaderValue::from_str(value)?; + header_map.insert(header_name, header_value); + } + api_client = api_client.with_headers(header_map)?; + } + + let supports_streaming = config.supports_streaming.unwrap_or(true); + + if !supports_streaming { + return Err(anyhow::anyhow!( + "Ollama provider does not support non-streaming mode. All Ollama models support streaming. \ + Please remove 'supports_streaming: false' from your provider configuration." + )); + } + + Ok(OllamaProviderBuilder::new(api_client) + .name(config.name.clone()) + .custom_models(custom_models) + .dynamic_models(config.dynamic_models) + .skip_canonical_filtering(config.skip_canonical_filtering)) +} + impl ProviderDescriptor for OllamaProvider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( @@ -213,39 +434,26 @@ impl Provider for OllamaProvider { } async fn fetch_supported_models(&self) -> Result, ProviderError> { - let response = self - .api_client - .request("api/tags") - .response_get() - .await - .map_err(|e| ProviderError::RequestFailed(format!("Failed to fetch models: {}", e)))?; + if let Some(custom_models) = &self.custom_models { + if self.dynamic_models == Some(false) { + return Ok(custom_models.clone()); + } - if !response.status().is_success() { - return Err(ProviderError::RequestFailed(format!( - "Failed to fetch models: HTTP {}", - response.status() - ))); + match self.fetch_models_from_api().await { + Ok(models) => return Ok(models), + Err(e) if e.is_endpoint_not_found() => { + tracing::debug!( + "Models endpoint not implemented for provider '{}' ({}), using predefined list", + self.name, + e + ); + return Ok(custom_models.clone()); + } + Err(e) => return Err(e), + } } - let json_response = response.json::().await.map_err(|e| { - ProviderError::RequestFailed(format!("Failed to parse response: {}", e)) - })?; - - let models = json_response - .get("models") - .and_then(|m| m.as_array()) - .ok_or_else(|| { - ProviderError::RequestFailed("No models array in response".to_string()) - })?; - - let mut model_names: Vec = models - .iter() - .filter_map(|model| model.get("name").and_then(|n| n.as_str()).map(String::from)) - .collect(); - - model_names.sort(); - - Ok(model_names) + self.fetch_models_from_api().await } } @@ -323,6 +531,105 @@ fn stream_ollama( #[cfg(test)] mod tests { use super::*; + use crate::base::ModelInfo; + + fn ollama_config( + dynamic_models: Option, + models: Vec, + ) -> DeclarativeProviderConfig { + ollama_config_with_base_url(dynamic_models, models, "http://localhost:11434") + } + + fn ollama_config_with_base_url( + dynamic_models: Option, + models: Vec, + base_url: &str, + ) -> DeclarativeProviderConfig { + DeclarativeProviderConfig { + name: "test-ollama".to_string(), + engine: crate::declarative::ProviderEngine::Ollama, + display_name: "Test Ollama".to_string(), + description: None, + api_key_env: String::new(), + base_url: base_url.to_string(), + models, + headers: None, + timeout_seconds: None, + supports_streaming: None, + requires_auth: false, + catalog_provider_id: None, + base_path: None, + env_vars: None, + dynamic_models, + skip_canonical_filtering: false, + model_doc_link: None, + setup_steps: vec![], + fast_model: None, + preserves_thinking: false, + } + } + + #[tokio::test] + async fn fetch_supported_models_uses_static_models_when_dynamic_models_false() { + let provider = from_declarative_config( + ollama_config(Some(false), vec![ModelInfo::new("static-model", 4096)]), + None, + crate::declarative::EnvKeyResolver, + ) + .unwrap() + .build(); + + assert_eq!( + provider.fetch_supported_models().await.unwrap(), + vec!["static-model".to_string()] + ); + } + + #[test] + fn from_custom_config_requires_static_models_when_dynamic_models_false() { + let err = from_declarative_config( + ollama_config(Some(false), vec![]), + None, + crate::declarative::EnvKeyResolver, + ) + .err() + .expect("expected static models validation error"); + + assert!(err + .to_string() + .contains("dynamic_models: false but no static models listed")); + } + + #[tokio::test] + async fn fetch_supported_models_falls_back_to_static_models_on_404() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/tags")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + + let provider = from_declarative_config( + ollama_config_with_base_url( + None, + vec![ModelInfo::new("static-model", 4096)], + &server.uri(), + ), + None, + crate::declarative::EnvKeyResolver, + ) + .unwrap() + .build(); + + assert_eq!( + provider.fetch_supported_models().await.unwrap(), + vec!["static-model".to_string()] + ); + } #[test] fn test_apply_ollama_options_uses_input_limit() { diff --git a/crates/goose-providers/src/openai.rs b/crates/goose-providers/src/openai.rs index e44acbb60a..a5e6e8630f 100644 --- a/crates/goose-providers/src/openai.rs +++ b/crates/goose-providers/src/openai.rs @@ -1,8 +1,10 @@ use super::api_client::ApiClient; use super::base::{ConfigKey, ModelInfo, Provider, ProviderMetadata}; use super::retry::ProviderRetry; +use crate::api_client::{AuthMethod, TlsConfig}; use crate::conversation::message::Message; use crate::conversation::token_usage::ProviderUsage; +use crate::declarative::{DeclarativeProviderConfig, KeyResolver}; use crate::errors::ProviderError; use crate::formats::openai::is_openai_responses_model; use crate::formats::openai::{ @@ -61,6 +63,7 @@ pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[ ]; pub const OPEN_AI_DOC_URL: &str = "https://platform.openai.com/docs/models"; +const DEFAULT_TIMEOUT_SECONDS: u64 = 600; type OpenAiBaseUrlParts = (String, Vec<(String, String)>, bool); @@ -178,6 +181,19 @@ impl OpenAiProviderBuilder { self } + pub fn map_api_client(mut self, f: impl FnOnce(ApiClient) -> ApiClient) -> Self { + self.api_client = f(self.api_client); + self + } + + pub fn try_map_api_client( + mut self, + f: impl FnOnce(ApiClient) -> Result, + ) -> Result { + self.api_client = f(self.api_client)?; + Ok(self) + } + pub fn base_path(mut self, base_path: impl Into) -> Self { self.base_path = base_path.into(); self @@ -687,6 +703,97 @@ impl Provider for OpenAiProvider { } } +pub fn from_declarative_config( + config: DeclarativeProviderConfig, + tls_config: Option, + key_resolver: impl KeyResolver, +) -> Result { + let custom_models = if !config.models.is_empty() { + Some( + config + .models + .iter() + .map(|m| m.name.clone()) + .collect::>(), + ) + } else { + None + }; + + if config.dynamic_models == Some(false) && custom_models.is_none() { + return Err(anyhow::anyhow!( + "Provider '{}' has dynamic_models: false but no static models listed; \ + at least one entry in `models` is required.", + config.name + )); + } + + let api_key = if config.api_key_env.is_empty() { + None + } else { + match key_resolver.resolve_key(config.api_key_env.as_str()) { + Ok(key) => Some(key), + Err(err) => { + if config.requires_auth { + anyhow::bail!("missing required key {}: {}", config.api_key_env, err); + } + None + } + } + }; + + let normalized_base_url = ensure_url_scheme(&config.base_url); + let url = url::Url::parse(&normalized_base_url) + .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; + + let host = url[..url::Position::BeforePath].to_string(); + let base_path = if let Some(ref explicit_path) = config.base_path { + explicit_path.trim_start_matches('/').to_string() + } else { + derive_base_path(url.path()) + }; + + let timeout_secs = config.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS); + + let auth = match api_key { + Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), + _ => AuthMethod::NoAuth, + }; + let mut api_client = ApiClient::with_timeout_and_tls( + host, + auth, + std::time::Duration::from_secs(timeout_secs), + tls_config, + )?; + + if let Some(query) = url.query() { + let query_params = url::form_urlencoded::parse(query.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + api_client = api_client.with_query(query_params); + } + + if let Some(headers) = &config.headers { + let mut header_map = reqwest::header::HeaderMap::new(); + for (key, value) in headers { + let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; + let header_value = reqwest::header::HeaderValue::from_str(value)?; + header_map.insert(header_name, header_value); + } + api_client = api_client.with_headers(header_map)?; + } + + Ok(OpenAiProviderBuilder::new(api_client) + .base_path(base_path) + .custom_headers(config.headers) + .supports_streaming(config.supports_streaming.unwrap_or(true)) + .name(config.name.clone()) + .custom_models(custom_models) + .dynamic_models(config.dynamic_models) + .skip_canonical_filtering(config.skip_canonical_filtering) + .preserve_thinking_context(config.preserves_thinking)) +} + pub fn parse_custom_headers(s: String) -> HashMap { s.split(',') .filter_map(|header| { @@ -698,6 +805,26 @@ pub fn parse_custom_headers(s: String) -> HashMap { .collect() } +pub fn derive_base_path(url_path: &str) -> String { + let stripped = url_path.trim_start_matches('/'); + let normalized = stripped.trim_end_matches('/'); + if normalized.is_empty() { + "v1/chat/completions".to_string() + } else if normalized.ends_with("chat/completions") { + stripped.to_string() + } else if ends_with_version_segment(normalized) { + format!("{}/chat/completions", normalized) + } else { + format!("{}/v1/chat/completions", normalized) + } +} + +fn ends_with_version_segment(path: &str) -> bool { + let last = path.rsplit('/').next().unwrap_or(path); + last.strip_prefix('v') + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) +} + #[cfg(test)] mod tests { use super::*; @@ -950,6 +1077,60 @@ mod tests { ); } + fn custom_config(base_url: &str) -> DeclarativeProviderConfig { + DeclarativeProviderConfig { + name: "test-openai".to_string(), + engine: crate::declarative::ProviderEngine::OpenAI, + display_name: "Test OpenAI".to_string(), + description: None, + api_key_env: String::new(), + base_url: base_url.to_string(), + models: vec![crate::base::ModelInfo::new("test-model", 4096)], + headers: None, + timeout_seconds: None, + supports_streaming: None, + requires_auth: false, + catalog_provider_id: None, + base_path: None, + env_vars: None, + dynamic_models: Some(false), + skip_canonical_filtering: false, + model_doc_link: None, + setup_steps: vec![], + fast_model: None, + preserves_thinking: false, + } + } + + #[test] + fn from_custom_config_preserves_ipv6_authority() { + let provider = from_declarative_config( + custom_config("http://[::1]:1234/v1"), + None, + crate::declarative::EnvKeyResolver, + ) + .unwrap() + .build(); + + assert_eq!(provider.api_client.host(), "http://[::1]:1234"); + } + + #[test] + fn from_custom_config_preserves_userinfo_authority() { + let provider = from_declarative_config( + custom_config("https://user:pass@gateway.example/v1"), + None, + crate::declarative::EnvKeyResolver, + ) + .unwrap() + .build(); + + assert_eq!( + provider.api_client.host(), + "https://user:pass@gateway.example" + ); + } + #[test] fn parse_n_ctx_falls_back_to_sole_entry_when_id_differs() { let body = json!({ @@ -970,4 +1151,37 @@ mod tests { }); assert_eq!(parse_n_ctx_from_models(&body, "model-c"), None); } + + #[test] + fn derive_base_path_not_removing_api_path() { + let r = derive_base_path("https://opencode.ai/zen/go"); + assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); + } + + #[test] + fn derive_base_path_should_support_v1() { + let r = derive_base_path("https://opencode.ai/zen/go/v1"); + assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); + } + + #[test] + fn derive_base_path_should_support_no_base_path() { + let r = derive_base_path("https://opencode.ai/"); + assert_eq!(r, "https://opencode.ai/v1/chat/completions"); + } + + #[test] + fn derive_base_path_preserves_non_v1_version_prefix() { + // Zhipu's default base_url is https://open.bigmodel.cn/api/paas/v4 and + // from_custom_config passes url.path() ("/api/paas/v4") here. The + // existing /api/paas/v4 version must not gain an extra /v1 segment. + let r = derive_base_path("/api/paas/v4"); + assert_eq!(r, "api/paas/v4/chat/completions"); + } + + #[test] + fn derive_base_path_does_not_treat_v_word_as_version() { + let r = derive_base_path("/api/voice"); + assert_eq!(r, "api/voice/v1/chat/completions"); + } } diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 7eabb0cdbc..64cf0bd5ea 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -10,126 +10,26 @@ use crate::providers::openai_def::OpenAiProviderDef; use anyhow::Result; use include_dir::{include_dir, Dir}; use once_cell::sync::Lazy; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use std::str::FromStr; -/// Deserialize an optional string, treating empty/whitespace-only values as None. -fn deserialize_non_empty_string<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let opt: Option = Option::deserialize(deserializer)?; - Ok(opt.filter(|s| !s.trim().is_empty())) -} use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Mutex; use utoipa::ToSchema; +pub use goose_providers::declarative::*; + static FIXED_PROVIDERS: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/providers/declarative"); pub fn custom_providers_dir() -> std::path::PathBuf { Paths::config_dir().join("custom_providers") } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum ProviderEngine { - OpenAI, - Ollama, - Anthropic, -} - -impl FromStr for ProviderEngine { - type Err = anyhow::Error; - - fn from_str(engine: &str) -> Result { - match engine.trim().to_lowercase().as_str() { - "openai" | "openai_compatible" => Ok(Self::OpenAI), - "anthropic" | "anthropic_compatible" => Ok(Self::Anthropic), - "ollama" | "ollama_compatible" => Ok(Self::Ollama), - _ => Err(anyhow::anyhow!("Invalid provider type: {}", engine)), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct EnvVarConfig { - pub name: String, - #[serde(default)] - pub required: bool, - #[serde(default)] - pub secret: bool, - /// When true, the field is shown prominently in the UI (not collapsed). - /// Defaults to the value of `required` if not specified. - pub primary: Option, - pub description: Option, - pub default: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct DeclarativeProviderConfig { - pub name: String, - pub engine: ProviderEngine, - pub display_name: String, - pub description: Option, - #[serde(default)] - pub api_key_env: String, - pub base_url: String, - pub models: Vec, - pub headers: Option>, - pub timeout_seconds: Option, - pub supports_streaming: Option, - #[serde(default = "default_requires_auth")] - pub requires_auth: bool, - #[serde(default)] - pub catalog_provider_id: Option, - #[serde(default)] - pub base_path: Option, - #[serde(default)] - pub env_vars: Option>, - /// Controls whether `fetch_supported_models` calls the provider's `/v1/models` - /// endpoint or returns the static `models` list directly. - /// - /// - `Some(false)` + non-empty `models`: return the static list; no API call. - /// Construction fails if `models` is empty. - /// - `Some(true)` or `None`: try the API; fall back to `models` on 404. - #[serde(default)] - pub dynamic_models: Option, - #[serde(default)] - pub skip_canonical_filtering: bool, - #[serde(default, deserialize_with = "deserialize_non_empty_string")] - pub model_doc_link: Option, - #[serde(default)] - pub setup_steps: Vec, - #[serde(default, deserialize_with = "deserialize_non_empty_string")] - pub fast_model: Option, - #[serde(default)] - pub preserves_thinking: bool, -} - -fn default_requires_auth() -> bool { - true -} - fn should_preserve_thinking_by_default(engine: &ProviderEngine) -> bool { matches!(engine, ProviderEngine::OpenAI) } -impl DeclarativeProviderConfig { - pub fn id(&self) -> &str { - &self.name - } - - pub fn display_name(&self) -> &str { - &self.display_name - } - - pub fn models(&self) -> &[ModelInfo] { - &self.models - } -} - /// Expand `${VAR_NAME}` placeholders in a template string using the given env var configs. /// Resolves values via Config (secret if `secret`, param otherwise), falls back to `default`. /// Returns an error if a `required` var is missing. @@ -464,6 +364,7 @@ pub fn load_provider(id: &str) -> Result { Err(anyhow::anyhow!("Provider not found: {}", id)) } + pub fn load_custom_providers(dir: &Path) -> Result> { if !dir.exists() { return Ok(Vec::new()); diff --git a/crates/goose/src/providers/anthropic_def.rs b/crates/goose/src/providers/anthropic_def.rs index b0e695365c..cadbb9a85f 100644 --- a/crates/goose/src/providers/anthropic_def.rs +++ b/crates/goose/src/providers/anthropic_def.rs @@ -1,12 +1,14 @@ use anyhow::Result; use futures::future::BoxFuture; -use crate::{config::DeclarativeProviderConfig, providers::base::ProviderDef}; +use crate::{ + config::{Config, DeclarativeProviderConfig}, + providers::{base::ProviderDef, custom_provider_config::ConfigKeyResolver}, +}; use goose_providers::{ - anthropic::{AnthropicProvider, AnthropicProviderBuilder, ANTHROPIC_API_VERSION}, - api_client::{ApiClient, AuthMethod}, + anthropic::{self, AnthropicProvider, AnthropicProviderBuilder, ANTHROPIC_API_VERSION}, + api_client::{ApiClient, AuthMethod, TlsConfig}, base::ProviderDescriptor, - formats::anthropic::AnthropicFormatOptions, }; pub struct AnthropicProviderDef; @@ -51,79 +53,17 @@ async fn from_env( pub fn from_custom_config( config: DeclarativeProviderConfig, - tls_config: Option, + tls_config: Option, ) -> Result { - let custom_models = if !config.models.is_empty() { - Some( - config - .models - .iter() - .map(|m| m.name.clone()) - .collect::>(), - ) - } else { - None - }; - - if config.dynamic_models == Some(false) && custom_models.is_none() { - return Err(anyhow::anyhow!( - "Provider '{}' has dynamic_models: false but no static models listed; \ - at least one entry in `models` is required.", - config.name - )); - } - - let global_config = crate::config::Config::global(); - let api_key: String = global_config - .get_secret(&config.api_key_env) - .map_err(|_| anyhow::anyhow!("Missing API key: {}", config.api_key_env))?; - - let auth = AuthMethod::ApiKey { - header_name: "x-api-key".to_string(), - key: api_key, - }; - - let format_options = format_options_for_provider(config.preserves_thinking); - - let mut api_client = ApiClient::new_with_tls(config.base_url, auth, tls_config)? - .with_request_builder(crate::session_context::session_id_request_builder()) - .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; - - if let Some(headers) = &config.headers { - let mut header_map = reqwest::header::HeaderMap::new(); - for (key, value) in headers { - let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; - let header_value = reqwest::header::HeaderValue::from_str(value)?; - header_map.insert(header_name, header_value); - } - api_client = api_client.with_headers(header_map)?; - } - - let supports_streaming = config.supports_streaming.unwrap_or(true); - - if !supports_streaming { - return Err(anyhow::anyhow!( - "Anthropic provider does not support non-streaming mode. All Claude models support streaming. \ - Please remove 'supports_streaming: false' from your provider configuration." - )); - } - - Ok(AnthropicProviderBuilder::new(api_client) - .supports_streaming(supports_streaming) - .name(config.name.clone()) - .custom_models(custom_models) - .dynamic_models(config.dynamic_models) - .skip_canonical_filtering(config.skip_canonical_filtering) - .format_options(format_options) - .build()) -} - -fn format_options_for_provider(preserves_thinking: bool) -> AnthropicFormatOptions { - AnthropicFormatOptions { - preserve_unsigned_thinking: preserves_thinking, - preserve_thinking_context: preserves_thinking, - thinking_disabled: false, - } + anthropic::from_declarative_config(config, tls_config, ConfigKeyResolver::new(Config::global())) + .map(|builder| { + builder + .map_api_client(|api_client| { + api_client + .with_request_builder(crate::session_context::session_id_request_builder()) + }) + .build() + }) } #[cfg(test)] diff --git a/crates/goose/src/providers/custom_provider_config.rs b/crates/goose/src/providers/custom_provider_config.rs new file mode 100644 index 0000000000..aaf1d56f33 --- /dev/null +++ b/crates/goose/src/providers/custom_provider_config.rs @@ -0,0 +1,21 @@ +use goose_providers::declarative::KeyResolver; + +use crate::config::{Config, ConfigError}; + +pub struct ConfigKeyResolver<'a> { + config: &'a Config, +} + +impl<'a> ConfigKeyResolver<'a> { + pub fn new(config: &'a Config) -> Self { + Self { config } + } +} + +impl<'a> KeyResolver for ConfigKeyResolver<'a> { + type Error = ConfigError; + + fn resolve_key(&self, key: &str) -> std::result::Result { + self.config.get_secret(key) + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 3b9cb867f3..dd91b1d945 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -28,6 +28,7 @@ pub mod codex; pub mod codex_acp; pub mod copilot_acp; pub mod cursor_agent; +pub mod custom_provider_config; pub mod databricks; pub mod databricks_auth; pub mod databricks_v2; diff --git a/crates/goose/src/providers/ollama_def.rs b/crates/goose/src/providers/ollama_def.rs index 24a15e055f..9022c81a5f 100644 --- a/crates/goose/src/providers/ollama_def.rs +++ b/crates/goose/src/providers/ollama_def.rs @@ -5,14 +5,16 @@ use futures::future::BoxFuture; use url::Url; use crate::{ - config::declarative_providers::DeclarativeProviderConfig, providers::base::ProviderDef, + config::{declarative_providers::DeclarativeProviderConfig, Config}, + providers::{base::ProviderDef, custom_provider_config::ConfigKeyResolver}, }; use goose_providers::{ api_client::{ApiClient, AuthMethod}, base::ProviderDescriptor, ollama::{ - OllamaOptions, OllamaProvider, OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS, OLLAMA_DEFAULT_PORT, - OLLAMA_HOST, OLLAMA_PROVIDER_NAME, OLLAMA_TIMEOUT, + self, OllamaOptions, OllamaProvider, OllamaProviderBuilder, + OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS, OLLAMA_DEFAULT_PORT, OLLAMA_HOST, OLLAMA_PROVIDER_NAME, + OLLAMA_TIMEOUT, }, }; @@ -71,73 +73,26 @@ pub async fn from_env( )? .with_request_builder(crate::session_context::session_id_request_builder()); - Ok(OllamaProvider::new( - api_client, - OLLAMA_PROVIDER_NAME.to_string(), - false, - options_from_config(), - )) + Ok(OllamaProviderBuilder::new(api_client) + .name(OLLAMA_PROVIDER_NAME) + .options(options_from_config()) + .build()) } pub fn from_custom_config( config: DeclarativeProviderConfig, tls_config: Option, ) -> Result { - let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT)); - - let base = if config.base_url.starts_with("http://") || config.base_url.starts_with("https://") - { - config.base_url.clone() - } else { - format!("http://{}", config.base_url) - }; - - let mut base_url = Url::parse(&base) - .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; - - let explicit_default_port = - config.base_url.ends_with(":80") || config.base_url.ends_with(":443"); - let is_https = base_url.scheme() == "https"; - - if base_url.port().is_none() && !explicit_default_port && !is_https { - base_url - .set_port(Some(OLLAMA_DEFAULT_PORT)) - .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; - } - - let mut api_client = ApiClient::with_timeout_and_tls( - base_url.to_string(), - AuthMethod::NoAuth, - timeout, - tls_config, - )? - .with_request_builder(crate::session_context::session_id_request_builder()); - - if let Some(headers) = &config.headers { - let mut header_map = reqwest::header::HeaderMap::new(); - for (key, value) in headers { - let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; - let header_value = reqwest::header::HeaderValue::from_str(value)?; - header_map.insert(header_name, header_value); - } - api_client = api_client.with_headers(header_map)?; - } - - let supports_streaming = config.supports_streaming.unwrap_or(true); - - if !supports_streaming { - return Err(anyhow::anyhow!( - "Ollama provider does not support non-streaming mode. All Ollama models support streaming. \ - Please remove 'supports_streaming: false' from your provider configuration." - )); - } - - Ok(OllamaProvider::new( - api_client, - config.name.clone(), - config.skip_canonical_filtering, - options_from_config(), - )) + ollama::from_declarative_config(config, tls_config, ConfigKeyResolver::new(Config::global())) + .map(|builder| { + builder + .map_api_client(|api_client| { + api_client + .with_request_builder(crate::session_context::session_id_request_builder()) + }) + .options(options_from_config()) + .build() + }) } pub fn options_from_config() -> OllamaOptions { diff --git a/crates/goose/src/providers/openai_def.rs b/crates/goose/src/providers/openai_def.rs index 208d2cc757..679a32cbb7 100644 --- a/crates/goose/src/providers/openai_def.rs +++ b/crates/goose/src/providers/openai_def.rs @@ -4,12 +4,13 @@ use goose_providers::base::ProviderDescriptor; use std::collections::HashMap; use crate::config::declarative_providers::DeclarativeProviderConfig; +use crate::config::Config; use crate::providers::base::{ProviderDef, DEFAULT_PROVIDER_TIMEOUT_SECS}; +use crate::providers::custom_provider_config::ConfigKeyResolver; use goose_providers::api_client::{ApiClient, AuthMethod}; use goose_providers::openai::{ - ensure_url_scheme, parse_custom_headers, parse_openai_base_url, OpenAiProvider, - OpenAiProviderBuilder, OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_DEFAULT_FAST_MODEL, - OPEN_AI_VERSIONLESS_BASE_PATH, + parse_custom_headers, parse_openai_base_url, OpenAiProvider, OpenAiProviderBuilder, + OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_DEFAULT_FAST_MODEL, OPEN_AI_VERSIONLESS_BASE_PATH, }; pub struct OpenAiProviderDef; @@ -203,85 +204,19 @@ pub fn from_custom_config( config: DeclarativeProviderConfig, tls_config: Option, ) -> Result { - let custom_models = if !config.models.is_empty() { - Some( - config - .models - .iter() - .map(|m| m.name.clone()) - .collect::>(), - ) - } else { - None - }; - - if config.dynamic_models == Some(false) && custom_models.is_none() { - return Err(anyhow::anyhow!( - "Provider '{}' has dynamic_models: false but no static models listed; \ - at least one entry in `models` is required.", - config.name - )); - } - - let global_config = crate::config::Config::global(); - let api_key = resolve_api_key(&config, &|key| global_config.get_secret(key))?; - - let normalized_base_url = ensure_url_scheme(&config.base_url); - let url = url::Url::parse(&normalized_base_url) - .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; - - let host = if let Some(port) = url.port() { - format!( - "{}://{}:{}", - url.scheme(), - url.host_str().unwrap_or(""), - port - ) - } else { - format!("{}://{}", url.scheme(), url.host_str().unwrap_or("")) - }; - let base_path = if let Some(ref explicit_path) = config.base_path { - explicit_path.trim_start_matches('/').to_string() - } else { - derive_base_path(url.path()) - }; - - let timeout_secs = config - .timeout_seconds - .unwrap_or(DEFAULT_PROVIDER_TIMEOUT_SECS); - - let auth = match api_key { - Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), - _ => AuthMethod::NoAuth, - }; - let mut api_client = ApiClient::with_timeout_and_tls( - host, - auth, - std::time::Duration::from_secs(timeout_secs), + goose_providers::openai::from_declarative_config( + config, tls_config, - )? - .with_request_builder(crate::session_context::session_id_request_builder()); - - if let Some(headers) = &config.headers { - let mut header_map = reqwest::header::HeaderMap::new(); - for (key, value) in headers { - let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; - let header_value = reqwest::header::HeaderValue::from_str(value)?; - header_map.insert(header_name, header_value); - } - api_client = api_client.with_headers(header_map)?; - } - - Ok(OpenAiProviderBuilder::new(api_client) - .base_path(base_path) - .custom_headers(config.headers) - .supports_streaming(config.supports_streaming.unwrap_or(true)) - .name(config.name.clone()) - .custom_models(custom_models) - .dynamic_models(config.dynamic_models) - .skip_canonical_filtering(config.skip_canonical_filtering) - .preserve_thinking_context(config.preserves_thinking) - .build()) + ConfigKeyResolver::new(Config::global()), + ) + .map(|builder| { + builder + .map_api_client(|api_client| { + api_client + .with_request_builder(crate::session_context::session_id_request_builder()) + }) + .build() + }) } /// Components extracted from an `OPENAI_BASE_URL` value. @@ -359,26 +294,6 @@ fn is_direct_openai_host(host: &str) -> bool { .unwrap_or(false) } -fn derive_base_path(url_path: &str) -> String { - let stripped = url_path.trim_start_matches('/'); - let normalized = stripped.trim_end_matches('/'); - if normalized.is_empty() { - "v1/chat/completions".to_string() - } else if normalized.ends_with("chat/completions") { - stripped.to_string() - } else if ends_with_version_segment(normalized) { - format!("{}/chat/completions", normalized) - } else { - format!("{}/v1/chat/completions", normalized) - } -} - -fn ends_with_version_segment(path: &str) -> bool { - let last = path.rsplit('/').next().unwrap_or(path); - last.strip_prefix('v') - .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) -} - #[cfg(test)] mod tests { use super::*; @@ -426,12 +341,6 @@ mod tests { assert!(!r.has_v1); } - #[test] - fn derive_base_path_not_removing_api_path() { - let r = derive_base_path("https://opencode.ai/zen/go"); - assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); - } - #[test] fn is_direct_openai_host_matches_only_openai() { assert!(is_direct_openai_host("https://api.openai.com")); @@ -442,33 +351,6 @@ mod tests { assert!(!is_direct_openai_host("https://router.huggingface.co/v1")); } - #[test] - fn derive_base_path_should_support_v1() { - let r = derive_base_path("https://opencode.ai/zen/go/v1"); - assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); - } - - #[test] - fn derive_base_path_should_support_no_base_path() { - let r = derive_base_path("https://opencode.ai/"); - assert_eq!(r, "https://opencode.ai/v1/chat/completions"); - } - - #[test] - fn derive_base_path_preserves_non_v1_version_prefix() { - // Zhipu's default base_url is https://open.bigmodel.cn/api/paas/v4 and - // from_custom_config passes url.path() ("/api/paas/v4") here. The - // existing /api/paas/v4 version must not gain an extra /v1 segment. - let r = derive_base_path("/api/paas/v4"); - assert_eq!(r, "api/paas/v4/chat/completions"); - } - - #[test] - fn derive_base_path_does_not_treat_v_word_as_version() { - let r = derive_base_path("/api/voice"); - assert_eq!(r, "api/voice/v1/chat/completions"); - } - #[test] fn parse_base_url_preserves_query_params() { let r = parse_base_url("https://gw.example.com/v1?api-version=2024-02-01").unwrap(); From a3b4166d4ede51ab1a47952f1208db4fa3451474 Mon Sep 17 00:00:00 2001 From: Adam Dalloul Date: Wed, 1 Jul 2026 10:06:38 -0700 Subject: [PATCH 025/139] Update EmpirioLabs provider display name to EmpirioLabs AI (#10126) --- .../goose/src/providers/declarative/empiriolabs.json | 2 +- documentation/docs/getting-started/providers.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/goose/src/providers/declarative/empiriolabs.json b/crates/goose/src/providers/declarative/empiriolabs.json index 5357e73972..f0d6f536df 100644 --- a/crates/goose/src/providers/declarative/empiriolabs.json +++ b/crates/goose/src/providers/declarative/empiriolabs.json @@ -1,7 +1,7 @@ { "name": "empiriolabs", "engine": "openai", - "display_name": "EmpirioLabs", + "display_name": "EmpirioLabs AI", "description": "Frontier open and proprietary chat models through one OpenAI-compatible API with streaming support", "api_key_env": "EMPIRIOLABS_API_KEY", "base_url": "https://api.empiriolabs.ai/v1/chat/completions", diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 60dbdd152d..1b723d3701 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -31,7 +31,7 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a | [ChatGPT Codex](https://chatgpt.com/codex) | Access GPT-5 Codex models optimized for code generation and understanding. **Requires a ChatGPT Plus/Pro subscription.** | No manual key. Uses browser-based OAuth authentication for both CLI and Desktop. | | [Databricks](https://www.databricks.com/) | Unified data analytics and AI platform for building and deploying models. | `DATABRICKS_HOST`, `DATABRICKS_TOKEN` | | [Docker Model Runner](https://docs.docker.com/ai/model-runner/) | Local models running in Docker Desktop or Docker CE with OpenAI-compatible API endpoints. **Because this provider runs locally, you must first [download a model](#local-llms).** | `OPENAI_HOST`, `OPENAI_BASE_PATH` | -| [EmpirioLabs](https://empiriolabs.ai/) | Frontier open and proprietary chat models (Qwen, DeepSeek, GLM, Kimi, MiniMax) through one OpenAI-compatible API with streaming. Catalog available at `https://api.empiriolabs.ai/v1/models`. | `EMPIRIOLABS_API_KEY` | +| [EmpirioLabs AI](https://empiriolabs.ai/) | Frontier open and proprietary chat models (Qwen, DeepSeek, GLM, Kimi, MiniMax) through one OpenAI-compatible API with streaming. Catalog available at `https://api.empiriolabs.ai/v1/models`. | `EMPIRIOLABS_API_KEY` | | [FuturMix](https://futurmix.ai/) | Unified AI gateway providing access to models from Anthropic, Google, OpenAI, and DeepSeek through an OpenAI-compatible API. | `FUTURMIX_API_KEY` | | [Gemini](https://ai.google.dev/gemini-api/docs) | Advanced LLMs by Google with multimodal capabilities (text, images). Gemini 3 models support configurable [thinking levels](#gemini-3-thinking-levels). | `GOOGLE_API_KEY`, `GEMINI3_THINKING_LEVEL` (optional) | | [GCP Vertex AI](https://cloud.google.com/vertex-ai) | Google Cloud's Vertex AI platform, supporting Gemini and Claude models. **Credentials must be [configured in advance](https://cloud.google.com/vertex-ai/docs/authentication).** Filters for allowed models by organization policy (if configured). | `GCP_PROJECT_ID`, `GCP_LOCATION` and optionally `GCP_MAX_RATE_LIMIT_RETRIES` (5), `GCP_MAX_OVERLOADED_RETRIES` (5), `GCP_INITIAL_RETRY_INTERVAL_MS` (5000), `GCP_BACKOFF_MULTIPLIER` (2.0), `GCP_MAX_RETRY_INTERVAL_MS` (320_000). | @@ -705,8 +705,8 @@ To set up Groq with goose, follow these steps: -### EmpirioLabs -[EmpirioLabs](https://empiriolabs.ai/) provides access to frontier open and proprietary chat models through a single OpenAI-compatible API with streaming. To use EmpirioLabs with goose, you need an API key from [EmpirioLabs](https://platform.empiriolabs.ai/dashboard/api-keys). +### EmpirioLabs AI +[EmpirioLabs AI](https://empiriolabs.ai/) provides access to frontier open and proprietary chat models through a single OpenAI-compatible API with streaming. To use EmpirioLabs with goose, you need an API key from [EmpirioLabs](https://platform.empiriolabs.ai/dashboard/api-keys). EmpirioLabs offers models that support tool calling, including: - **qwen3-7-plus** - Qwen3.7 Plus with a 1M context window @@ -729,7 +729,7 @@ To set up EmpirioLabs with goose, follow these steps: 2. Click the `Settings` button on the sidebar. 3. Click the `Models` tab. 4. Click `Configure Providers` - 5. Choose `EmpirioLabs` as provider from the list. + 5. Choose `EmpirioLabs AI` as provider from the list. 6. Click `Configure`, enter your API key, and click `Submit`. 7. Select the EmpirioLabs model of your choice. @@ -740,7 +740,7 @@ To set up EmpirioLabs with goose, follow these steps: goose configure ``` 2. Select `Configure Providers` from the menu. - 3. Follow the prompts to choose `EmpirioLabs` as the provider. + 3. Follow the prompts to choose `EmpirioLabs AI` as the provider. 4. Enter your API key when prompted. 5. Select the EmpirioLabs model of your choice. From c595a4ab03f4a683b53feee4c4d973ee879cc60d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:05:38 -0400 Subject: [PATCH 026/139] chore(deps): bump pnpm/action-setup from 6.0.8 to 6.0.9 (#9892) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Douwe Osinga --- .github/workflows/publish-npm.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index d3a7e1a42d..1653b1dc30 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -32,7 +32,7 @@ jobs: always-auth: true - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 10.30.3 @@ -164,7 +164,7 @@ jobs: always-auth: true - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 10.30.3 From d9d3fce9b7b8d78cf8bd816f3b7a197d5c17af26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:05:44 +0000 Subject: [PATCH 027/139] chore(deps): bump actions/attest-build-provenance from 4.1.0 to 4.1.1 (#10049) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/canary.yml | 2 +- .github/workflows/publish-docker.yml | 2 +- .github/workflows/release.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 58ac9fb5a8..a0273f286e 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -138,7 +138,7 @@ jobs: merge-multiple: true - name: Attest build provenance - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: | goose-*.tar.bz2 diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index f163c8c153..bc704ed517 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -65,7 +65,7 @@ jobs: platforms: linux/amd64,linux/arm64 - name: Attest Docker image - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-name: ghcr.io/${{ github.repository_owner }}/goose subject-digest: ${{ steps.docker-push.outputs.digest }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 158d940aec..6057f8bc0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,12 +129,12 @@ jobs: - name: Attest macOS update manifest if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: latest-mac.yml - name: Attest build provenance - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: | goose-*.tar.bz2 From 613f66b9f2bfa7a48411f5e8a6874ff23d122424 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:05:45 -0400 Subject: [PATCH 028/139] chore(deps): bump actions/stale from 10.1.1 to 10.3.0 (#10050) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index dbe008a54e..35b79315df 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -29,7 +29,7 @@ jobs: steps: # Use the official stale action from GitHub - name: 'Close Stale PRs' - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: # Authentication token with required permissions repo-token: ${{ secrets.GITHUB_TOKEN }} From fc5353a39cb44cf694bc404b3acd3418e0881d67 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 1 Jul 2026 11:57:45 -0700 Subject: [PATCH 029/139] regenerate schema (#10166) --- ui/desktop/openapi.json | 2 +- ui/desktop/src/api/types.gen.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index e8ce9725a3..306d833e48 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3929,7 +3929,7 @@ }, "primary": { "type": "boolean", - "description": "When true, the field is shown prominently in the UI (not collapsed).\nDefaults to the value of `required` if not specified.", + "description": "Defaults to the value of `required` if not specified.\nUIs may use this to feature this config value more prominently.", "nullable": true }, "required": { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 83c68e99e5..4e1d84d9e5 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -363,8 +363,8 @@ export type EnvVarConfig = { description?: string | null; name: string; /** - * When true, the field is shown prominently in the UI (not collapsed). * Defaults to the value of `required` if not specified. + * UIs may use this to feature this config value more prominently. */ primary?: boolean | null; required?: boolean; From bb1bd2667feed882809c399568e782c2e03b2e53 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:51:26 -0400 Subject: [PATCH 030/139] chore(deps-dev): bump js-yaml from 4.1.1 to 4.2.0 in /documentation (#10107) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- documentation/package-lock.json | 18 ++++++++++++++---- documentation/package.json | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/documentation/package-lock.json b/documentation/package-lock.json index ffec5c1dc5..14d7f45bbd 100644 --- a/documentation/package-lock.json +++ b/documentation/package-lock.json @@ -36,7 +36,7 @@ "@docusaurus/types": "3.7.0", "globby": "^13.2.2", "gray-matter": "^4.0.3", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "typescript": "~5.6.2", "yaml-loader": "^0.8.1" }, @@ -10957,9 +10957,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/documentation/package.json b/documentation/package.json index eebb397df9..c8c53288d6 100644 --- a/documentation/package.json +++ b/documentation/package.json @@ -45,7 +45,7 @@ "@docusaurus/types": "3.7.0", "gray-matter": "^4.0.3", "globby": "^13.2.2", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "typescript": "~5.6.2", "yaml-loader": "^0.8.1" }, From 2d47d1feea30d1220197d08bfc67a0d2bcc44056 Mon Sep 17 00:00:00 2001 From: Osraka <98612432+Osraka@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:56:52 +0300 Subject: [PATCH 031/139] fix(providers): unescape shell-escaped image paths (#10098) --- crates/goose-providers/src/formats/openai.rs | 2 +- crates/goose-providers/src/images.rs | 130 +++++++++++++++--- .../goose/src/providers/formats/databricks.rs | 2 +- 3 files changed, 110 insertions(+), 24 deletions(-) diff --git a/crates/goose-providers/src/formats/openai.rs b/crates/goose-providers/src/formats/openai.rs index 0dd54b7077..496d3da54b 100644 --- a/crates/goose-providers/src/formats/openai.rs +++ b/crates/goose-providers/src/formats/openai.rs @@ -239,7 +239,7 @@ pub fn format_messages_with_options( if !text.text.is_empty() { if message.role == Role::User { if let Some(image_path) = detect_image_path(&text.text) { - if let Ok(image) = load_image_file(image_path) { + if let Ok(image) = load_image_file(image_path.as_ref()) { has_non_text_content = true; content_array.push(json!({"type": "text", "text": text.text})); content_array.push(convert_image(&image, image_format)); diff --git a/crates/goose-providers/src/images.rs b/crates/goose-providers/src/images.rs index 1f194862fc..5a1a11b846 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-providers/src/images.rs @@ -1,4 +1,4 @@ -use std::{io::Read as _, path::Path}; +use std::{borrow::Cow, io::Read as _, path::Path}; use base64::Engine as _; use rmcp::model::{AnnotateAble as _, ImageContent, RawImageContent}; @@ -33,11 +33,11 @@ pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value } } -pub fn detect_image_path(text: &str) -> Option<&str> { +pub fn detect_image_path(text: &str) -> Option> { const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; const MAX_PATH_LEN: usize = 4096; - let mut best: Option<(usize, &str)> = None; + let mut best: Option<(usize, Cow<'_, str>)> = None; let mut from = 0; while from < text.len() { let Some(end) = EXTENSIONS @@ -70,17 +70,16 @@ pub fn detect_image_path(text: &str) -> Option<&str> { let Some(candidate) = text.get(start..end) else { continue; }; - let path = Path::new(candidate); - if path.is_absolute() && path.is_file() && is_image_file(path) { + if let Some(candidate_path) = image_path_candidate(candidate) { // Keep the first referenced path, but allow a longer // match anchored at the same start to extend it (a // whitespace-terminated extension may be a prefix of a // spaced filename ending in a later extension). match best { Some((best_start, _)) if start == best_start => { - best = Some((start, candidate)); + best = Some((start, candidate_path)); } - None => best = Some((start, candidate)), + None => best = Some((start, candidate_path)), Some(_) => {} } break; @@ -93,6 +92,54 @@ pub fn detect_image_path(text: &str) -> Option<&str> { best.map(|(_, candidate)| candidate) } +fn clean_path(path: &str) -> Cow<'_, str> { + if !path.contains('\\') { + return Cow::Borrowed(path); + } + + let mut cleaned = String::with_capacity(path.len()); + let mut chars = path.chars().peekable(); + let mut changed = false; + + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(&next) = chars.peek() { + if !next.is_alphanumeric() { + cleaned.push(next); + chars.next(); + changed = true; + continue; + } + } + } + cleaned.push(c); + } + + if changed { + Cow::Owned(cleaned) + } else { + Cow::Borrowed(path) + } +} + +fn image_path_candidate(candidate: &str) -> Option> { + if is_existing_image_path(candidate) { + return Some(Cow::Borrowed(candidate)); + } + + let cleaned = clean_path(candidate); + if cleaned.as_ref() != candidate && is_existing_image_path(cleaned.as_ref()) { + return Some(cleaned); + } + + None +} + +fn is_existing_image_path(candidate: &str) -> bool { + let path = Path::new(candidate); + path.is_absolute() && path.is_file() && is_image_file(path) +} + /// Case-insensitive ASCII substring search returning a byte index into /// `haystack` (no allocation, so the index stays valid for slicing). fn find_ascii_ci(haystack: &str, needle: &str, from: usize) -> Option { @@ -196,23 +243,23 @@ mod tests { // Test with valid PNG file using absolute path let text = format!("Here is an image {}", png_path_str); - assert_eq!(detect_image_path(&text), Some(png_path_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); // Test with non-image file that has .png extension let text = format!("Here is a fake image {}", fake_png_path.to_str().unwrap()); - assert_eq!(detect_image_path(&text), None); + assert_eq!(detect_image_path(&text).as_deref(), None); // Test with nonexistent file let text = "Here is a fake.png that doesn't exist"; - assert_eq!(detect_image_path(text), None); + assert_eq!(detect_image_path(text).as_deref(), None); // Test with non-image file let text = "Here is a file.txt"; - assert_eq!(detect_image_path(text), None); + assert_eq!(detect_image_path(text).as_deref(), None); // Test with relative path (should not match) let text = "Here is a relative/path/image.png"; - assert_eq!(detect_image_path(text), None); + assert_eq!(detect_image_path(text).as_deref(), None); } #[test] @@ -225,25 +272,25 @@ mod tests { let png_path_str = png_path.to_str().unwrap(); let text = format!("please describe {} for me", png_path_str); - assert_eq!(detect_image_path(&text), Some(png_path_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); // Case-insensitive extension also matches. let upper = temp_dir.path().join("Another Shot.PNG"); std::fs::write(&upper, png_data).unwrap(); let upper_str = upper.to_str().unwrap(); let text = format!("see {}", upper_str); - assert_eq!(detect_image_path(&text), Some(upper_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(upper_str)); // Quoted path with spaces: the closing quote terminates the candidate. let text = format!("describe \"{}\" please", png_path_str); - assert_eq!(detect_image_path(&text), Some(png_path_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); let text = format!("describe '{}'", png_path_str); - assert_eq!(detect_image_path(&text), Some(png_path_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); // A stray closing quote in prose must not act as a terminator for an // unquoted path. let text = format!("here {}\" trailing", png_path_str); - assert_eq!(detect_image_path(&text), Some(png_path_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); // When a spaced filename contains an earlier image extension, prefer // the longer existing candidate over the embedded prefix. @@ -253,7 +300,7 @@ mod tests { let prefix = temp_dir.path().join("Screen Shot.png"); std::fs::write(&prefix, png_data).unwrap(); let text = format!("look at {}", edited_str); - assert_eq!(detect_image_path(&text), Some(edited_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(edited_str)); // With multiple distinct images, the first referenced one wins even if // a later one has a longer path. @@ -266,7 +313,46 @@ mod tests { a.to_str().unwrap(), longer.to_str().unwrap() ); - assert_eq!(detect_image_path(&text), Some(a.to_str().unwrap())); + assert_eq!( + detect_image_path(&text).as_deref(), + Some(a.to_str().unwrap()) + ); + } + + #[test] + fn test_detect_image_path_with_shell_escaped_metacharacters() { + let temp_dir = tempfile::tempdir().unwrap(); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + let png_path = temp_dir + .path() + .join("Bob's Project (v2) & $draft [final].png"); + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + + let escaped_path = png_path_str + .replace(' ', "\\ ") + .replace('(', "\\(") + .replace(')', "\\)") + .replace('&', "\\&") + .replace('$', "\\$") + .replace('\'', "\\'") + .replace('[', "\\[") + .replace(']', "\\]"); + let text = format!("please describe {}", escaped_path); + + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + } + + #[test] + fn test_detect_image_path_prefers_existing_literal_backslash_path() { + let temp_dir = tempfile::tempdir().unwrap(); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + let png_path = temp_dir.path().join("literal\\&name.png"); + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + let text = format!("please describe {}", png_path_str); + + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); } #[test] @@ -280,14 +366,14 @@ mod tests { let png_path = temp_dir.path().join("photo.png"); std::fs::write(&png_path, png_data).unwrap(); let url = format!("https:/{}/photo.png", dir); - assert_eq!(detect_image_path(&url), None); + assert_eq!(detect_image_path(&url).as_deref(), None); // A backup file sharing the image extension prefix must not be // truncated to the bare image path. let real = temp_dir.path().join("shot.png"); std::fs::write(&real, png_data).unwrap(); let backup = format!("{}.backup", real.to_str().unwrap()); - assert_eq!(detect_image_path(&backup), None); + assert_eq!(detect_image_path(&backup).as_deref(), None); } #[test] @@ -295,7 +381,7 @@ mod tests { // Many extension-like tokens but no real absolute path: must scan // cheaply (bounded) and find nothing. let text = "see foo.png and bar.jpg and baz.jpeg ".repeat(500); - assert_eq!(detect_image_path(&text), None); + assert_eq!(detect_image_path(&text).as_deref(), None); } #[test] diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index 8c675f521e..65907104bb 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -34,7 +34,7 @@ struct DatabricksMessage { fn format_text_content(text: &str, image_format: &ImageFormat) -> (Vec, bool) { let mut items = vec![json!({"type": "text", "text": text})]; let has_image = if let Some(path) = detect_image_path(text) { - if let Ok(image) = load_image_file(path) { + if let Ok(image) = load_image_file(path.as_ref()) { items.push(convert_image(&image, image_format)); } true From 44869db2d945920918d51b5cd50e71c3a962e5e5 Mon Sep 17 00:00:00 2001 From: Kyle E DeFreitas Date: Wed, 1 Jul 2026 15:56:54 -0400 Subject: [PATCH 032/139] fix(databricks): always use OpenAI-compatible "parameters" for tools (#10101) --- .../goose/src/providers/formats/databricks.rs | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index 65907104bb..7ba1141a0b 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -303,12 +303,10 @@ fn apply_claude_thinking_config( } } -pub fn format_tools(tools: &[Tool], model_name: &str) -> anyhow::Result> { +pub fn format_tools(tools: &[Tool], _model_name: &str) -> anyhow::Result> { let mut tool_names = std::collections::HashSet::new(); let mut result = Vec::new(); - let is_gemini = model_name.contains("gemini"); - for tool in tools { if !tool_names.insert(&tool.name) { return Err(anyhow!("Duplicate tool name: {}", tool.name)); @@ -320,25 +318,17 @@ pub fn format_tools(tools: &[Tool], model_name: &str) -> anyhow::Result Date: Wed, 1 Jul 2026 19:56:57 +0000 Subject: [PATCH 033/139] fix: tolerate partial Responses output items (#10108) --- .../src/formats/openai_responses.rs | 144 +++++++++++++++--- 1 file changed, 126 insertions(+), 18 deletions(-) diff --git a/crates/goose-providers/src/formats/openai_responses.rs b/crates/goose-providers/src/formats/openai_responses.rs index d6daef7853..05aed70ff1 100644 --- a/crates/goose-providers/src/formats/openai_responses.rs +++ b/crates/goose-providers/src/formats/openai_responses.rs @@ -6,7 +6,7 @@ use crate::formats::openai::{ }; use crate::mcp_utils::extract_text_from_resource; use crate::model::ModelConfig; -use anyhow::Error; +use anyhow::{anyhow, Error}; use async_stream::try_stream; use chrono; use futures::Stream; @@ -317,19 +317,24 @@ pub struct ResponseMetadata { #[serde(rename_all = "snake_case")] pub enum ResponseOutputItemInfo { Reasoning { - id: String, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, #[serde(default)] summary: Vec, }, Message { - id: String, - status: String, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, role: String, content: Vec, }, FunctionCall { - id: String, - status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status: Option, #[serde(skip_serializing_if = "Option::is_none")] call_id: Option, name: String, @@ -691,7 +696,9 @@ pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Resu arguments, .. } => { - let request_id = call_id.clone().or_else(|| id.clone()).unwrap_or_default(); + let request_id = call_id.clone().or_else(|| id.clone()).ok_or_else(|| { + anyhow!("Responses function_call output missing call_id and id") + })?; let parsed_args = if arguments.is_empty() { json!({}) } else { @@ -724,7 +731,7 @@ pub fn get_responses_usage(response: &ResponsesApiResponse) -> Usage { fn process_streaming_output_items( output_items: Vec, is_text_response: bool, -) -> Vec { +) -> anyhow::Result> { let mut content = Vec::new(); for item in output_items { @@ -772,7 +779,9 @@ fn process_streaming_output_items( arguments, .. } => { - let request_id = call_id.unwrap_or(id); + let request_id = call_id.or(id).ok_or_else(|| { + anyhow!("Responses function_call output missing call_id and id") + })?; let parsed_args = if arguments.is_empty() { json!({}) } else { @@ -787,7 +796,7 @@ fn process_streaming_output_items( } } - content + Ok(content) } pub fn responses_api_to_streaming_message( @@ -945,7 +954,7 @@ where } // Process final output items and yield usage data - let content = process_streaming_output_items(output_items, is_text_response); + let content = process_streaming_output_items(output_items, is_text_response)?; if !content.is_empty() { let mut message = Message::new(Role::Assistant, chrono::Utc::now().timestamp(), content); @@ -1053,6 +1062,84 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_responses_stream_allows_message_output_without_id_status() -> anyhow::Result<()> { + let lines = vec![ + r#"data: {"type":"response.created","sequence_number":1,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"in_progress","model":"gpt-5.2-pro","output":[]}}"#.to_string(), + r#"data: {"type":"response.output_text.delta","sequence_number":2,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello"}"#.to_string(), + r#"data: {"type":"response.output_text.delta","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"delta":" world"}"#.to_string(), + r#"data: {"type":"response.completed","sequence_number":4,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"completed","model":"gpt-5.2-pro","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello world"}]}],"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}}"#.to_string(), + "data: [DONE]".to_string(), + ]; + + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let messages = responses_api_to_streaming_message(response_stream); + futures::pin_mut!(messages); + + let mut text_parts = Vec::new(); + let mut usage: Option = None; + + while let Some(item) = messages.next().await { + let (message, maybe_usage) = item?; + if let Some(msg) = message { + for content in msg.content { + if let MessageContent::Text(text) = content { + text_parts.push(text.text.clone()); + } + } + } + if let Some(final_usage) = maybe_usage { + usage = Some(final_usage); + } + } + + assert_eq!(text_parts.concat(), "Hello world"); + let usage = usage.expect("usage should be present at completion"); + assert_eq!(usage.model, "gpt-5.2-pro"); + assert_eq!(usage.usage.input_tokens, Some(10)); + assert_eq!(usage.usage.output_tokens, Some(4)); + assert_eq!(usage.usage.total_tokens, Some(14)); + + Ok(()) + } + + #[tokio::test] + async fn test_responses_stream_allows_function_call_without_id_status() -> anyhow::Result<()> { + let lines = vec![ + r#"data: {"type":"response.created","sequence_number":1,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"in_progress","model":"gpt-5.2-pro","output":[]}}"#.to_string(), + r#"data: {"type":"response.completed","sequence_number":2,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"completed","model":"gpt-5.2-pro","output":[{"type":"reasoning","summary":[]},{"type":"function_call","call_id":"call_abc","name":"shell","arguments":"{\"command\":\"pwd\"}"}],"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}}"#.to_string(), + "data: [DONE]".to_string(), + ]; + + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let messages = responses_api_to_streaming_message(response_stream); + futures::pin_mut!(messages); + + let mut tool_request_id = None; + let mut usage: Option = None; + + while let Some(item) = messages.next().await { + let (message, maybe_usage) = item?; + if let Some(msg) = message { + for content in msg.content { + if let MessageContent::ToolRequest(request) = content { + tool_request_id = Some(request.id); + } + } + } + if let Some(final_usage) = maybe_usage { + usage = Some(final_usage); + } + } + + assert_eq!(tool_request_id.as_deref(), Some("call_abc")); + let usage = usage.expect("usage should be present at completion"); + assert_eq!(usage.model, "gpt-5.2-pro"); + assert_eq!(usage.usage.total_tokens, Some(14)); + + Ok(()) + } + #[test] fn test_responses_api_to_message_captures_reasoning_summary() -> anyhow::Result<()> { let response: ResponsesApiResponse = serde_json::from_value(serde_json::json!({ @@ -1789,7 +1876,7 @@ mod tests { } #[test] - fn test_refusal_content_part_deserializes_in_streaming_output() { + fn test_refusal_content_part_deserializes_in_streaming_output() -> anyhow::Result<()> { let json = r#"{ "type": "message", "id": "msg_1", @@ -1799,13 +1886,15 @@ mod tests { }"#; let item: ResponseOutputItemInfo = serde_json::from_str(json).unwrap(); - let content = process_streaming_output_items(vec![item], false); + let content = process_streaming_output_items(vec![item], false)?; assert_eq!(content.len(), 1); if let MessageContent::Text(t) = &content[0] { assert_eq!(t.text, "I'm unable to assist."); } else { panic!("expected text content from refusal"); } + + Ok(()) } #[test] @@ -1822,28 +1911,47 @@ mod tests { } #[test] - fn test_streamed_refusal_not_duplicated_in_output_items() { + fn test_streamed_refusal_not_duplicated_in_output_items() -> anyhow::Result<()> { let output_items = vec![ResponseOutputItemInfo::Message { - id: "msg_1".to_string(), - status: "completed".to_string(), + id: Some("msg_1".to_string()), + status: Some("completed".to_string()), role: "assistant".to_string(), content: vec![ContentPart::Refusal { refusal: "I cannot help with that.".to_string(), }], }]; - let content = process_streaming_output_items(output_items.clone(), true); + let content = process_streaming_output_items(output_items.clone(), true)?; assert!( content.is_empty(), "refusal should be suppressed when already streamed" ); - let content = process_streaming_output_items(output_items, false); + let content = process_streaming_output_items(output_items, false)?; assert_eq!( content.len(), 1, "refusal should appear in non-streaming path" ); + + Ok(()) + } + + #[test] + fn test_function_call_output_requires_call_id_or_id() { + let output_items = vec![ResponseOutputItemInfo::FunctionCall { + id: None, + status: None, + call_id: None, + name: "shell".to_string(), + arguments: "{}".to_string(), + }]; + + let error = process_streaming_output_items(output_items, false).unwrap_err(); + assert!( + error.to_string().contains("missing call_id and id"), + "unexpected error: {error}" + ); } #[test] From e5a0162513499bc15850cd43f7d39c79a1c0e499 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:48:09 +0530 Subject: [PATCH 034/139] chore(deps): bump actions-rust-lang/setup-rust-toolchain from 1.16.1 to 1.17.0 (#10052) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/docs-update-cli-ref.yml | 2 +- .github/workflows/pr-smoke-test.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6106534c79..bb062d22dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout Code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Run cargo fmt run: cargo fmt --check @@ -55,7 +55,7 @@ jobs: - name: Checkout Code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Install Dependencies run: | @@ -119,7 +119,7 @@ jobs: echo "msrv=$msrv" >> "$GITHUB_OUTPUT" echo "MSRV: $msrv" - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 with: toolchain: ${{ steps.msrv.outputs.msrv }} @@ -145,7 +145,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 @@ -167,7 +167,7 @@ jobs: - name: Checkout Code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Install Dependencies run: | diff --git a/.github/workflows/docs-update-cli-ref.yml b/.github/workflows/docs-update-cli-ref.yml index cdb4b42534..9ac5cfca7d 100644 --- a/.github/workflows/docs-update-cli-ref.yml +++ b/.github/workflows/docs-update-cli-ref.yml @@ -63,7 +63,7 @@ jobs: sudo apt-get install -y jq ripgrep - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 with: toolchain: stable diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 405cb8d3ec..a0f2634753 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -55,7 +55,7 @@ jobs: with: ref: ${{ github.event.inputs.branch || github.ref }} - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Install Dependencies run: | From 7026e14585ac9b29fda04cbfb621d68ebde53f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gell=C3=A9rt=20Stadler?= Date: Wed, 1 Jul 2026 23:19:28 +0200 Subject: [PATCH 035/139] Bedrock model discovery and validation (#9997) --- Cargo.lock | 26 ++ crates/goose-cli/src/commands/configure.rs | 6 +- crates/goose-server/src/routes/agent.rs | 6 + .../src/routes/config_management.rs | 32 +- crates/goose/Cargo.toml | 2 + crates/goose/src/acp/server/config.rs | 3 +- crates/goose/src/providers/bedrock.rs | 399 ++++++++++++++++-- crates/goose/src/providers/init.rs | 5 +- .../src/providers/inventory/registrations.rs | 16 + crates/goose/src/providers/provider_test.rs | 85 +++- .../goose/tests/acp_custom_requests_test.rs | 17 +- crates/goose/tests/acp_fixtures/mod.rs | 1 + crates/goose/tests/acp_fixtures/server.rs | 11 + ui/desktop/openapi.json | 4 + ui/desktop/src/acp/providers.ts | 80 +++- ui/desktop/src/api/types.gen.ts | 1 + .../components/ModelAndProviderContext.tsx | 12 +- .../settings/models/modelInterface.ts | 33 +- .../models/subcomponents/SwitchModelModal.tsx | 68 +-- ui/desktop/src/i18n/messages/de.json | 3 + ui/desktop/src/i18n/messages/en.json | 3 + ui/desktop/src/i18n/messages/es.json | 3 + ui/desktop/src/i18n/messages/fr.json | 3 + ui/desktop/src/i18n/messages/hi.json | 3 + ui/desktop/src/i18n/messages/id.json | 3 + ui/desktop/src/i18n/messages/it.json | 3 + ui/desktop/src/i18n/messages/ja.json | 3 + ui/desktop/src/i18n/messages/ko.json | 3 + ui/desktop/src/i18n/messages/ms.json | 3 + ui/desktop/src/i18n/messages/pt.json | 3 + ui/desktop/src/i18n/messages/ru.json | 3 + ui/desktop/src/i18n/messages/tr.json | 3 + ui/desktop/src/i18n/messages/vi.json | 3 + ui/desktop/src/i18n/messages/zh-CN.json | 3 + ui/desktop/src/i18n/messages/zh-TW.json | 3 + 35 files changed, 720 insertions(+), 135 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c22c0c45ee..80f80739d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -678,6 +678,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-bedrock" +version = "1.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd3d870793928e6c18de8273dcdceed08af8cc9083be9d7de3e52efc7ac01e9e" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-bedrockruntime" version = "1.133.0" @@ -4876,6 +4901,7 @@ dependencies = [ "async-trait", "aws-config", "aws-lc-rs", + "aws-sdk-bedrock", "aws-sdk-bedrockruntime", "aws-sdk-sagemakerruntime", "aws-smithy-types", diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index bb51673d1a..d6a0cd6ba7 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -811,10 +811,8 @@ pub async fn configure_provider_dialog() -> anyhow::Result { let spin = spinner(); spin.start("Checking your configuration..."); - let toolshim_enabled = std::env::var("GOOSE_TOOLSHIM") - .map(|val| val == "1" || val.to_lowercase() == "true") - .unwrap_or(false); - let toolshim_model = std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL").ok(); + let (toolshim_enabled, toolshim_model) = + goose::providers::provider_test::toolshim_settings_from_env(); match test_provider_configuration(provider_name, &model, toolshim_enabled, toolshim_model).await { diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 20c91d0db6..a7ea390f89 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -17,6 +17,7 @@ use goose::agents::ExtensionConfig; use goose::config::resolve_extensions_for_new_session; use goose::config::{Config, GooseMode}; use goose::providers::create; +use goose::providers::provider_test::test_provider_model; use goose::recipe::Recipe; use goose::recipe_deeplink; use goose::session::session_manager::SessionType; @@ -591,6 +592,11 @@ async fn update_agent_provider( if let Some(request_params) = payload.request_params { model_config = model_config.with_merged_request_params(request_params); } + + test_provider_model(&payload.provider, &model) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + let model_info = resolve_provider_model_info(&payload.provider, &model) .await .map_err(|e| (e.status, e.message))?; diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 0f65ef7aa8..bb7a354533 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -19,6 +19,7 @@ use goose::providers::catalog::{ ProviderTemplate, }; use goose::providers::create_with_default_model; +use goose::providers::provider_test::test_provider_model; use goose::providers::providers as get_providers; use goose::{ agents::execute_commands, agents::ExtensionConfig, slash_commands::recipe_slash_command, @@ -108,6 +109,8 @@ fn normalize_custom_provider_api_key(api_key: String) -> Option { #[derive(Deserialize, ToSchema)] pub struct CheckProviderRequest { pub provider: String, + #[serde(default)] + pub model: Option, } #[derive(Deserialize, ToSchema)] @@ -827,8 +830,20 @@ pub async fn update_custom_provider( request_body = CheckProviderRequest, )] pub async fn check_provider( - Json(CheckProviderRequest { provider }): Json, + Json(CheckProviderRequest { provider, model }): Json, ) -> Result<(), ErrorResponse> { + if let Some(model) = model.filter(|model| !model.trim().is_empty()) { + test_provider_model(&provider, &model) + .await + .map_err(|err| { + ErrorResponse::bad_request(format!( + "Provider '{}' with model '{}' check failed: {}", + provider, model, err + )) + })?; + return Ok(()); + } + create_with_default_model(&provider, Vec::new()) .await .map_err(|err| { @@ -845,19 +860,22 @@ pub async fn check_provider( pub async fn set_config_provider( Json(SetProviderRequest { provider, model }): Json, ) -> Result<(), ErrorResponse> { - create_with_default_model(&provider, Vec::new()) + test_provider_model(&provider, &model) .await - .and_then(|_| { - let config = Config::global(); - goose::config::set_active_provider(config, &provider, &model) - .map_err(|e| anyhow::anyhow!(e)) - }) .map_err(|err| { ErrorResponse::bad_request(format!( "Failed to set provider to '{}' with model '{}': {}", provider, model, err )) })?; + + let config = Config::global(); + goose::config::set_active_provider(config, &provider, &model).map_err(|err| { + ErrorResponse::bad_request(format!( + "Failed to set provider to '{}' with model '{}': {}", + provider, model, err + )) + })?; Ok(()) } diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 0f0fdd25fc..aaccc2efb5 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -35,6 +35,7 @@ local-inference = [ aws-providers = [ "dep:aws-config", "dep:aws-smithy-types", + "dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-sdk-sagemakerruntime", "dep:smithy-transport-reqwest", @@ -234,6 +235,7 @@ llama-cpp-sys-2 = { workspace = true, optional = true } image = { version = "0.24.9", default-features = false, features = ["png", "jpeg", "gif", "webp"] } subtle = { version = "2.5", default-features = false, features = ["std"] } gethostname = "1.1.0" +aws-sdk-bedrock = { version = "1.132", default-features = false, features = ["rt-tokio"], optional = true } [target.'cfg(target_os = "windows")'.dependencies] winapi = { workspace = true } diff --git a/crates/goose/src/acp/server/config.rs b/crates/goose/src/acp/server/config.rs index e70ddf98df..69c81d2455 100644 --- a/crates/goose/src/acp/server/config.rs +++ b/crates/goose/src/acp/server/config.rs @@ -215,7 +215,8 @@ impl GooseAcpAgent { } if let Some(model_id) = model_id.as_deref() { - let model_exists = entry.default_model == model_id + let model_exists = entry.supports_refresh + || entry.default_model == model_id || entry.models.iter().any(|model| model.id == model_id); if !model_exists { return Err(agent_client_protocol::Error::invalid_params().data(format!( diff --git a/crates/goose/src/providers/bedrock.rs b/crates/goose/src/providers/bedrock.rs index a9ffd0b930..dfc68e6494 100644 --- a/crates/goose/src/providers/bedrock.rs +++ b/crates/goose/src/providers/bedrock.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::openai_compatible::{handle_status, stream_responses_compat}; @@ -8,11 +8,16 @@ use crate::session_context::SESSION_ID_HEADER; use anyhow::Result; use async_stream::try_stream; use async_trait::async_trait; +use aws_sdk_bedrock::types::{ + FoundationModelLifecycleStatus, FoundationModelSummary, InferenceProfileStatus, + InferenceProfileType, InferenceType, ModelModality, +}; +use aws_sdk_bedrock::Client as BedrockControlClient; use aws_sdk_bedrockruntime::config::ProvideCredentials; use aws_sdk_bedrockruntime::operation::converse::ConverseError; use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamError; use aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError; -use aws_sdk_bedrockruntime::{types as bedrock, Client}; +use aws_sdk_bedrockruntime::{types as bedrock, Client as BedrockRuntimeClient}; use base64::Engine; use futures::future::BoxFuture; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; @@ -36,15 +41,8 @@ pub const BEDROCK_DOC_LINK: &str = "https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html"; pub const BEDROCK_DEFAULT_MODEL: &str = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"; -pub const BEDROCK_KNOWN_MODELS: &[&str] = &[ - "us.anthropic.claude-sonnet-4-5-20250929-v1:0", - "us.anthropic.claude-sonnet-4-20250514-v1:0", - "us.anthropic.claude-3-7-sonnet-20250219-v1:0", - "us.anthropic.claude-opus-4-20250514-v1:0", - "us.anthropic.claude-opus-4-1-20250805-v1:0", - "openai.gpt-5.5", - "openai.gpt-5.4", -]; +pub const BEDROCK_MANTLE_MODELS: &[&str] = &["openai.gpt-5.5", "openai.gpt-5.4"]; +pub const BEDROCK_BOOTSTRAP_MODELS: &[&str] = &[BEDROCK_DEFAULT_MODEL]; pub const BEDROCK_DEFAULT_MAX_RETRIES: usize = 6; pub const BEDROCK_DEFAULT_INITIAL_RETRY_INTERVAL_MS: u64 = 2000; @@ -54,7 +52,9 @@ pub const BEDROCK_DEFAULT_MAX_RETRY_INTERVAL_MS: u64 = 120_000; #[derive(Debug, serde::Serialize)] pub struct BedrockProvider { #[serde(skip)] - client: Client, + client: BedrockRuntimeClient, + #[serde(skip)] + control_plane_client: BedrockControlClient, #[serde(skip)] retry_config: RetryConfig, #[serde(skip)] @@ -163,15 +163,26 @@ impl BedrockProvider { )) .build(); - Client::from_conf(bedrock_config) + BedrockRuntimeClient::from_conf(bedrock_config) } else { - Self::create_client_with_credentials(&sdk_config).await? + Self::create_runtime_client_with_credentials(&sdk_config).await? + }; + + let control_plane_client = if let Some(ref token) = bearer_token { + let bedrock_config = aws_sdk_bedrock::Config::new(&sdk_config) + .to_builder() + .bearer_token(aws_sdk_bedrock::config::Token::new(token.clone(), None)) + .build(); + BedrockControlClient::from_conf(bedrock_config) + } else { + BedrockControlClient::new(&sdk_config) }; let retry_config = Self::load_retry_config(config); Ok(Self { client, + control_plane_client, retry_config, name: BEDROCK_PROVIDER_NAME.to_string(), region: resolved_region, @@ -181,7 +192,9 @@ impl BedrockProvider { }) } - async fn create_client_with_credentials(sdk_config: &aws_config::SdkConfig) -> Result { + async fn create_runtime_client_with_credentials( + sdk_config: &aws_config::SdkConfig, + ) -> Result { sdk_config .credentials_provider() .ok_or_else(|| anyhow::anyhow!("No AWS credentials provider configured"))? @@ -194,7 +207,152 @@ impl BedrockProvider { ) })?; - Ok(Client::new(sdk_config)) + Ok(BedrockRuntimeClient::new(sdk_config)) + } + + fn bootstrap_models(include_mantle: bool) -> Vec { + let mut models: Vec = BEDROCK_BOOTSTRAP_MODELS + .iter() + .map(|model| model.to_string()) + .collect(); + if include_mantle { + models.extend(BEDROCK_MANTLE_MODELS.iter().map(|model| model.to_string())); + } + models + } + + fn merge_discovered_model_ids( + inference_profiles: impl IntoIterator, + foundation_models: impl IntoIterator, + extra_models: impl IntoIterator, + ) -> Vec { + let mut models = BTreeSet::new(); + models.extend(inference_profiles); + models.extend(foundation_models); + models.extend(extra_models); + models.into_iter().collect() + } + + fn is_excluded_non_chat_model_id(model_id: &str) -> bool { + let id = model_id.to_lowercase(); + id.contains(".embed") + || id.contains("-embed-") + || id.ends_with("-embed") + || id.contains(".rerank") + || id.contains("-rerank-") + || id.ends_with("-rerank") + } + + fn is_chat_capable_model_id(model_id: &str) -> bool { + !Self::is_excluded_non_chat_model_id(model_id) + } + + fn is_chat_capable_foundation_model(summary: &FoundationModelSummary) -> bool { + if !Self::is_chat_capable_model_id(summary.model_id()) { + return false; + } + + let has_text_input = summary.input_modalities().contains(&ModelModality::Text); + let has_text_output = summary.output_modalities().contains(&ModelModality::Text); + let has_embedding_output = summary + .output_modalities() + .contains(&ModelModality::Embedding); + let streaming = summary.response_streaming_supported().unwrap_or(false); + let not_legacy = summary + .model_lifecycle() + .map(|lifecycle| lifecycle.status() != &FoundationModelLifecycleStatus::Legacy) + .unwrap_or(true); + + has_text_input && has_text_output && !has_embedding_output && streaming && not_legacy + } + + fn is_mantle_model_id(model_id: &str) -> bool { + BEDROCK_MANTLE_MODELS.contains(&model_id) + } + + async fn fetch_inference_profile_ids(&self) -> Result, ProviderError> { + let mut ids = BTreeSet::new(); + + for profile_type in [ + InferenceProfileType::SystemDefined, + InferenceProfileType::Application, + ] { + let mut next_token = None; + loop { + let mut request = self + .control_plane_client + .list_inference_profiles() + .type_equals(profile_type.clone()); + if let Some(token) = &next_token { + request = request.next_token(token); + } + + let response = request.send().await.map_err(|err| { + ProviderError::ExecutionError(format!( + "Failed to list Bedrock inference profiles: {}", + err + )) + })?; + + for summary in response.inference_profile_summaries() { + if summary.status() == &InferenceProfileStatus::Active { + let id = summary.inference_profile_id(); + if Self::is_chat_capable_model_id(id) { + ids.insert(id.to_string()); + } + } + } + + next_token = response.next_token().map(|token| token.to_string()); + if next_token.is_none() { + break; + } + } + } + + Ok(ids.into_iter().collect()) + } + + async fn fetch_foundation_model_ids(&self) -> Result, ProviderError> { + let response = self + .control_plane_client + .list_foundation_models() + .by_inference_type(InferenceType::OnDemand) + .by_output_modality(ModelModality::Text) + .send() + .await + .map_err(|err| { + ProviderError::ExecutionError(format!( + "Failed to list Bedrock foundation models: {}", + err + )) + })?; + + Ok(response + .model_summaries() + .iter() + .filter(|summary| Self::is_chat_capable_foundation_model(summary)) + .map(|summary| summary.model_id().to_string()) + .collect()) + } + + async fn fetch_models_from_aws(&self) -> Result, ProviderError> { + let inference_profiles = self.fetch_inference_profile_ids().await?; + let foundation_models = self.fetch_foundation_model_ids().await?; + let extra_models = if self.bearer_token.is_some() { + BEDROCK_MANTLE_MODELS + .iter() + .map(|model| model.to_string()) + .collect() + } else { + Vec::new() + }; + + Ok(Self::merge_discovered_model_ids( + inference_profiles, + foundation_models, + extra_models, + )) } fn load_retry_config(config: &crate::config::Config) -> RetryConfig { @@ -402,6 +560,12 @@ impl BedrockProvider { "Bedrock validation error: {}", err.message().unwrap_or("unknown validation error") )), + ConverseError::ResourceNotFoundException(err) => { + ProviderError::ExecutionError(format!( + "Bedrock model not found or not accessible: {}", + err.message().unwrap_or("unknown resource error") + )) + } ConverseError::ModelErrorException(err) => { ProviderError::ExecutionError(format!("Failed to call Bedrock: {:?}", err)) } @@ -494,6 +658,18 @@ impl BedrockProvider { err )) } + ConverseStreamError::ValidationException(err) => { + ProviderError::ExecutionError(format!( + "Bedrock validation error: {}", + err.message().unwrap_or("unknown validation error") + )) + } + ConverseStreamError::ResourceNotFoundException(err) => { + ProviderError::ExecutionError(format!( + "Bedrock model not found or not accessible: {}", + err.message().unwrap_or("unknown resource error") + )) + } ConverseStreamError::ModelErrorException(err) => { ProviderError::ExecutionError(format!("Failed to call Bedrock: {:?}", err)) } @@ -689,9 +865,9 @@ impl goose_providers::base::ProviderDescriptor for BedrockProvider { ProviderMetadata::new( BEDROCK_PROVIDER_NAME, "Amazon Bedrock", - "Run models through Amazon Bedrock. Supports AWS SSO profiles - run 'aws sso login --profile ' before using. Configure with AWS_PROFILE and AWS_REGION, use environment variables/credentials, or use AWS_BEARER_TOKEN_BEDROCK for bearer token authentication. Region is required for bearer token auth (can be set via AWS_REGION, AWS_DEFAULT_REGION, or AWS profile). Prompt caching can be enabled for Anthropic Claude models by setting BEDROCK_ENABLE_CACHING=true. Responses stream via the ConverseStream API; set BEDROCK_DISABLE_STREAMING=true to fall back to blocking Converse calls.", + "Run models through Amazon Bedrock. Supports AWS SSO profiles - run 'aws sso login --profile ' before using. Configure with AWS_PROFILE and AWS_REGION, use environment variables/credentials, or use AWS_BEARER_TOKEN_BEDROCK for bearer token authentication. Region is required for bearer token auth (can be set via AWS_REGION, AWS_DEFAULT_REGION, or AWS profile). Model discovery requires bedrock:ListFoundationModels and bedrock:ListInferenceProfiles permissions. Prompt caching can be enabled for Anthropic Claude models by setting BEDROCK_ENABLE_CACHING=true. Responses stream via the ConverseStream API; set BEDROCK_DISABLE_STREAMING=true to fall back to blocking Converse calls.", BEDROCK_DEFAULT_MODEL, - BEDROCK_KNOWN_MODELS.to_vec(), + BEDROCK_BOOTSTRAP_MODELS.to_vec(), BEDROCK_DOC_LINK, vec![ ConfigKey::new("AWS_PROFILE", false, false, Some("default"), true), @@ -731,8 +907,25 @@ impl Provider for BedrockProvider { self.retry_config.clone() } + fn skip_canonical_filtering(&self) -> bool { + true + } + async fn fetch_supported_models(&self) -> Result, ProviderError> { - Ok(BEDROCK_KNOWN_MODELS.iter().map(|s| s.to_string()).collect()) + match self.fetch_models_from_aws().await { + Ok(models) if !models.is_empty() => Ok(models), + Ok(_) => { + tracing::debug!("Bedrock model discovery returned no models, using bootstrap list"); + Ok(Self::bootstrap_models(self.bearer_token.is_some())) + } + Err(err) => { + tracing::warn!( + "Bedrock model discovery failed ({}), using bootstrap list", + err + ); + Ok(Self::bootstrap_models(self.bearer_token.is_some())) + } + } } async fn stream( @@ -756,7 +949,7 @@ impl Provider for BedrockProvider { let (base_name, effort) = extract_reasoning_effort(without_prefix); let bedrock_model_id = format!("openai.{}", base_name); - let is_mantle_model = BEDROCK_KNOWN_MODELS.contains(&bedrock_model_id.as_str()); + let is_mantle_model = Self::is_mantle_model_id(&bedrock_model_id); if is_mantle_model { let mut normalized_config = ModelConfig { @@ -919,11 +1112,23 @@ mod tests { .behavior_version(aws_config::BehaviorVersion::latest()) .region(aws_config::Region::new("us-east-1")) .build(); - let client = Client::new(&sdk_config); + let client = BedrockRuntimeClient::new(&sdk_config); + let control_plane_client = BedrockControlClient::new(&sdk_config); + let model = ModelConfig { + model_name: model_name.to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + request_params: None, + reasoning: None, + }; ( BedrockProvider { client, + control_plane_client, retry_config: RetryConfig::default(), name: "aws_bedrock".to_string(), region: None, @@ -931,16 +1136,7 @@ mod tests { http_client: reqwest::Client::new(), mantle_base_url: None, }, - ModelConfig { - model_name: model_name.to_string(), - context_limit: None, - temperature: None, - max_tokens: None, - toolshim: false, - toolshim_model: None, - request_params: None, - reasoning: None, - }, + model, ) } @@ -1006,6 +1202,21 @@ mod tests { ); } + #[test] + #[serial] + fn test_caching_disabled_by_default() { + std::env::set_var("BEDROCK_ENABLE_CACHING", "false"); + + let (provider, model) = + create_mock_provider_and_model("us.anthropic.claude-sonnet-4-5-20250929-v1:0"); + assert!( + !provider.should_enable_caching(&model), + "Caching should be disabled by default" + ); + + std::env::remove_var("BEDROCK_ENABLE_CACHING"); + } + #[test] fn test_caching_disabled_for_non_claude_models() { let (provider, model) = create_mock_provider_and_model("amazon.titan-text-express-v1"); @@ -1093,9 +1304,19 @@ mod tests { .region(aws_config::Region::new("us-east-1")) .build(); - let model = ModelConfig::new("openai.gpt-5.5"); + let model = ModelConfig { + model_name: "openai.gpt-5.5".to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + request_params: None, + reasoning: None, + }; let provider = BedrockProvider { - client: Client::new(&sdk_config), + client: BedrockRuntimeClient::new(&sdk_config), + control_plane_client: BedrockControlClient::new(&sdk_config), retry_config: RetryConfig::default(), name: "aws_bedrock".to_string(), region: Some("us-east-1".to_string()), @@ -1497,4 +1718,116 @@ mod tests { other => panic!("expected RedactedThinking, got {:?}", other), } } + + #[test] + fn test_merge_discovered_model_ids_dedupes_and_sorts() { + let models = BedrockProvider::merge_discovered_model_ids( + [ + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), + "us.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), + ], + [ + "anthropic.claude-3-5-sonnet-20240620-v1:0".to_string(), + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), + ], + ["openai.gpt-5.5".to_string()], + ); + + assert_eq!( + models, + vec![ + "anthropic.claude-3-5-sonnet-20240620-v1:0".to_string(), + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), + "openai.gpt-5.5".to_string(), + "us.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), + ] + ); + } + + #[test] + fn test_bootstrap_models_includes_mantle_with_bearer_token() { + let without_mantle = BedrockProvider::bootstrap_models(false); + assert_eq!(without_mantle, vec![BEDROCK_DEFAULT_MODEL.to_string()]); + + let with_mantle = BedrockProvider::bootstrap_models(true); + assert!(with_mantle.contains(&"openai.gpt-5.5".to_string())); + assert!(with_mantle.contains(&BEDROCK_DEFAULT_MODEL.to_string())); + } + + #[test] + fn test_is_mantle_model_id() { + assert!(BedrockProvider::is_mantle_model_id("openai.gpt-5.5")); + assert!(BedrockProvider::is_mantle_model_id("openai.gpt-5.4")); + assert!(!BedrockProvider::is_mantle_model_id( + "openai.gpt-oss-120b-1:0" + )); + assert!(!BedrockProvider::is_mantle_model_id( + "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + )); + } + + #[test] + fn test_skip_canonical_filtering_enabled() { + let (provider, _) = create_mock_provider_and_model("test"); + assert!(provider.skip_canonical_filtering()); + } + + #[test] + fn test_is_chat_capable_foundation_model_filters_embeddings() { + let chat_model = FoundationModelSummary::builder() + .model_arn("arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20240620-v1:0") + .model_id("anthropic.claude-3-5-sonnet-20240620-v1:0") + .input_modalities(ModelModality::Text) + .output_modalities(ModelModality::Text) + .response_streaming_supported(true) + .build() + .unwrap(); + assert!(BedrockProvider::is_chat_capable_foundation_model( + &chat_model + )); + + let embedding_model = FoundationModelSummary::builder() + .model_arn("arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0") + .model_id("amazon.titan-embed-text-v2:0") + .input_modalities(ModelModality::Text) + .output_modalities(ModelModality::Embedding) + .response_streaming_supported(false) + .build() + .unwrap(); + assert!(!BedrockProvider::is_chat_capable_foundation_model( + &embedding_model + )); + + let cohere_embed_v4 = FoundationModelSummary::builder() + .model_arn("arn:aws:bedrock:us-east-1::foundation-model/cohere.embed-v4:0") + .model_id("cohere.embed-v4:0") + .input_modalities(ModelModality::Text) + .output_modalities(ModelModality::Text) + .output_modalities(ModelModality::Embedding) + .response_streaming_supported(true) + .build() + .unwrap(); + assert!(!BedrockProvider::is_chat_capable_foundation_model( + &cohere_embed_v4 + )); + } + + #[test] + fn test_is_chat_capable_model_id_excludes_embed_and_rerank_profiles() { + assert!(!BedrockProvider::is_chat_capable_model_id( + "cohere.embed-v4:0" + )); + assert!(!BedrockProvider::is_chat_capable_model_id( + "us.cohere.embed-v4:0" + )); + assert!(!BedrockProvider::is_chat_capable_model_id( + "amazon.titan-embed-text-v2:0" + )); + assert!(!BedrockProvider::is_chat_capable_model_id( + "cohere.rerank-v3-5:0" + )); + assert!(BedrockProvider::is_chat_capable_model_id( + "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + )); + } } diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 3752715fbf..c9bfe3e983 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -70,7 +70,10 @@ async fn init_registry() -> RwLock { registry.register::(false); registry.register::(false); #[cfg(feature = "aws-providers")] - registry.register::(false); + registry.register_with_inventory::( + false, + Some(registrations::bedrock_inventory()), + ); #[cfg(feature = "local-inference")] registry.register::(false); registry.register_with_inventory::( diff --git a/crates/goose/src/providers/inventory/registrations.rs b/crates/goose/src/providers/inventory/registrations.rs index b3852cf506..dda04b62af 100644 --- a/crates/goose/src/providers/inventory/registrations.rs +++ b/crates/goose/src/providers/inventory/registrations.rs @@ -6,6 +6,8 @@ use crate::config::{self, Config}; use crate::providers::acp_tooling::{acp_adapter_installed, resolved_acp_command}; use crate::providers::amp_acp::{AMP_ACP_BINARY, AMP_ACP_PROVIDER_NAME}; use crate::providers::base::ProviderDescriptor; +#[cfg(feature = "aws-providers")] +use crate::providers::bedrock::{BedrockProvider, BEDROCK_PROVIDER_NAME}; use crate::providers::chatgpt_codex::TokenCache as ChatGptCodexTokenCache; use crate::providers::claude_acp::{CLAUDE_ACP_BINARY, CLAUDE_ACP_PROVIDER_NAME}; use crate::providers::codex_acp::CODEX_ACP_PROVIDER_NAME; @@ -125,6 +127,20 @@ pub fn ollama_inventory() -> InventoryRegistration { }) } +#[cfg(feature = "aws-providers")] +pub fn bedrock_inventory() -> InventoryRegistration { + InventoryRegistration::new(true, || { + let config = Config::global(); + let metadata = BedrockProvider::metadata(); + Ok(default_inventory_identity( + BEDROCK_PROVIDER_NAME, + BEDROCK_PROVIDER_NAME, + &metadata.config_keys, + config, + )) + }) +} + pub fn huggingface_inventory() -> InventoryRegistration { InventoryRegistration::new(false, || { let metadata = HuggingFaceProvider::metadata(); diff --git a/crates/goose/src/providers/provider_test.rs b/crates/goose/src/providers/provider_test.rs index b7ee3cad8a..e0b66c19de 100644 --- a/crates/goose/src/providers/provider_test.rs +++ b/crates/goose/src/providers/provider_test.rs @@ -3,45 +3,88 @@ use anyhow::Result; use futures::StreamExt; use rmcp::model::ToolAnnotations; use rmcp::{model::Tool, object}; +use std::time::Duration; +use tokio::time::timeout; + +const PROVIDER_TEST_TIMEOUT: Duration = Duration::from_secs(60); + +pub fn provider_model_validation_enabled() -> bool { + !matches!( + std::env::var("GOOSE_SKIP_PROVIDER_MODEL_VALIDATION"), + Ok(value) if value == "1" || value.eq_ignore_ascii_case("true") + ) +} + +pub fn toolshim_settings_from_env() -> (Option, Option) { + let toolshim_enabled = std::env::var("GOOSE_TOOLSHIM") + .map(|val| val == "1" || val.to_lowercase() == "true") + .ok(); + let toolshim_model = std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL").ok(); + (toolshim_enabled, toolshim_model) +} + +pub async fn test_provider_model(provider_name: &str, model: &str) -> Result<()> { + if !provider_model_validation_enabled() { + return Ok(()); + } + test_provider_configuration(provider_name, model, None, None).await +} pub async fn test_provider_configuration( provider_name: &str, model: &str, - toolshim_enabled: bool, + toolshim_enabled: Option, toolshim_model: Option, ) -> Result<()> { - let model_config = crate::model_config::model_config_from_user_config(provider_name, model)? - .with_max_tokens(Some(50)) - .with_toolshim(toolshim_enabled) - .with_toolshim_model(toolshim_model); + let mut model_config = + crate::model_config::model_config_from_user_config(provider_name, model)? + .with_max_tokens(Some(50)); + + if let Some(toolshim_enabled) = toolshim_enabled { + model_config = model_config.with_toolshim(toolshim_enabled); + } + if toolshim_model.is_some() { + model_config = model_config.with_toolshim_model(toolshim_model); + } let provider = create(provider_name, Vec::new()).await?; let messages = vec![Message::user().with_text("What is the weather like in San Francisco today?")]; - let tools = if !toolshim_enabled { + let tools = if !model_config.toolshim { vec![create_sample_weather_tool()] } else { vec![] }; - let mut stream = crate::session_context::with_session_id( - Some("test-session-id".to_string()), - provider.stream( - &model_config, - "You are an AI agent called goose. You use tools of connected extensions to solve problems.", - &messages, - &tools.into_iter().collect::>(), - ), - ) - .await?; + timeout(PROVIDER_TEST_TIMEOUT, async { + let mut stream = crate::session_context::with_session_id( + Some("test-session-id".to_string()), + provider.stream( + &model_config, + "You are an AI agent called goose. You use tools of connected extensions to solve problems.", + &messages, + &tools.into_iter().collect::>(), + ), + ) + .await?; - let first_chunk = stream - .next() - .await - .ok_or_else(|| anyhow::anyhow!("Provider test stream returned no events"))?; - first_chunk?; + let first_chunk = stream + .next() + .await + .ok_or_else(|| anyhow::anyhow!("Provider test stream returned no events"))?; + first_chunk?; + + Ok::<(), anyhow::Error>(()) + }) + .await + .map_err(|_| { + anyhow::anyhow!( + "Provider configuration test timed out after {}s", + PROVIDER_TEST_TIMEOUT.as_secs() + ) + })??; Ok(()) } diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 36c13e8aa7..11e4ab2e6c 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -574,16 +574,13 @@ fn test_steer_session_adds_input_to_active_prompt() { let mut steer_sent = false; let mut steer_message_id: Option = None; let mut final_response = None; + let mut observed_updates = Vec::new(); let deadline = tokio::time::Instant::now() + Duration::from_secs(3); while tokio::time::Instant::now() < deadline { tokio::select! { - response = &mut prompt => { - final_response = Some(response.unwrap()); - break; - } - _ = tokio::time::sleep(Duration::from_millis(10)), if !steer_sent => { - let updates = session.session_updates(); + biased; + updates = session.wait_for_session_updates(), if !steer_sent => { if let Some(run_id) = updates.iter().find_map(active_run_id_from_update) { let response = send_custom( conn.cx(), @@ -607,6 +604,11 @@ fn test_steer_session_adds_input_to_active_prompt() { steer_message_id = mid.map(ToString::to_string); steer_sent = true; } + observed_updates.extend(updates); + } + response = &mut prompt => { + final_response = Some(response.unwrap()); + break; } } } @@ -615,7 +617,8 @@ fn test_steer_session_adds_input_to_active_prompt() { assert_eq!(response.stop_reason, StopReason::EndTurn); assert!(steer_sent, "test never observed an active run id"); - let updates = session.session_updates(); + let mut updates = observed_updates; + updates.extend(session.session_updates()); let agent_text = collect_agent_text(&updates); assert!( agent_text.contains("saw steer"), diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index 5290bbc882..c1b287cdad 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -770,6 +770,7 @@ where if std::env::var_os("GOOSE_PATH_ROOT").is_none() { std::env::set_var("GOOSE_PATH_ROOT", ACP_CONFIG_ROOT.path()); } + std::env::set_var("GOOSE_SKIP_PROVIDER_MODEL_VALIDATION", "1"); register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone()); let handle = std::thread::Builder::new() diff --git a/crates/goose/tests/acp_fixtures/server.rs b/crates/goose/tests/acp_fixtures/server.rs index dc86a8ac5e..b2a7b77357 100644 --- a/crates/goose/tests/acp_fixtures/server.rs +++ b/crates/goose/tests/acp_fixtures/server.rs @@ -62,6 +62,17 @@ impl AcpServerSession { .collect() } + pub async fn wait_for_session_updates(&self) -> Vec { + loop { + let notified = self.notify.notified(); + let updates = self.session_updates(); + if !updates.is_empty() { + return updates; + } + notified.await; + } + } + async fn send_prompt( &mut self, content: Vec, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 306d833e48..402e216ce7 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3033,6 +3033,10 @@ "provider" ], "properties": { + "model": { + "type": "string", + "nullable": true + }, "provider": { "type": "string" } diff --git a/ui/desktop/src/acp/providers.ts b/ui/desktop/src/acp/providers.ts index e9e967f405..d3e63e3964 100644 --- a/ui/desktop/src/acp/providers.ts +++ b/ui/desktop/src/acp/providers.ts @@ -11,6 +11,36 @@ import { getAcpClient } from './acpConnection'; export type { CanonicalModelInfoDto, ProviderSecretDto }; +function acpErrorMessage(error: unknown): string | null { + if (typeof error !== 'object' || error === null) { + return null; + } + + const candidate = 'error' in error && isRecord(error.error) ? error.error : error; + if (!isRecord(candidate)) { + return null; + } + if (typeof candidate.data === 'string') { + return candidate.data; + } + return typeof candidate.message === 'string' ? candidate.message : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function normalizeAcpError(error: unknown, fallback: string): Error { + const message = acpErrorMessage(error); + if (message) { + return new Error(message); + } + if (error instanceof Error) { + return error; + } + return new Error(fallback); +} + function updateRequestToCreate( request: UpdateCustomProviderRequest ): CustomProviderCreateRequest_unstable { @@ -175,8 +205,12 @@ export async function acpReadDefaults(): Promise<{ } export async function acpSaveDefaults(providerId: string, modelId?: string | null): Promise { - const client = await getAcpClient(); - await client.goose.defaultsSave_unstable({ providerId, modelId: modelId ?? null }); + try { + const client = await getAcpClient(); + await client.goose.defaultsSave_unstable({ providerId, modelId: modelId ?? null }); + } catch (error) { + throw normalizeAcpError(error, 'Failed to save default provider/model'); + } } export async function acpClearDefaults(): Promise { @@ -259,26 +293,30 @@ export async function acpSetSessionProviderModel( modelId?: string | null, thinkingEffort?: ThinkingEffort | null ): Promise { - const client = await getAcpClient(); - let response = await client.setSessionConfigOption({ - sessionId, - configId: 'provider', - value: providerId, - }); - if (modelId) { - response = await client.setSessionConfigOption({ + try { + const client = await getAcpClient(); + let response = await client.setSessionConfigOption({ sessionId, - configId: 'model', - value: modelId, + configId: 'provider', + value: providerId, }); - } - if (thinkingEffort != null) { - response = await client.setSessionConfigOption({ - sessionId, - configId: 'thinking_effort', - value: thinkingEffort, - }); - } + if (modelId) { + response = await client.setSessionConfigOption({ + sessionId, + configId: 'model', + value: modelId, + }); + } + if (thinkingEffort != null) { + response = await client.setSessionConfigOption({ + sessionId, + configId: 'thinking_effort', + value: thinkingEffort, + }); + } - return extractAppliedSessionProviderModel(response.configOptions); + return extractAppliedSessionProviderModel(response.configOptions); + } catch (error) { + throw normalizeAcpError(error, 'Failed to update session provider/model'); + } } diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 4e1d84d9e5..e354f2f9a6 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -60,6 +60,7 @@ export type ChatRequest = { }; export type CheckProviderRequest = { + model?: string | null; provider: string; }; diff --git a/ui/desktop/src/components/ModelAndProviderContext.tsx b/ui/desktop/src/components/ModelAndProviderContext.tsx index 5bd3f9226a..dc9bc40e9e 100644 --- a/ui/desktop/src/components/ModelAndProviderContext.tsx +++ b/ui/desktop/src/components/ModelAndProviderContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; import { toastError, toastSuccess } from '../toasts'; -import Model, { getProviderMetadata } from './settings/models/modelInterface'; +import Model, { getProviderMetadata, validateProviderModel } from './settings/models/modelInterface'; import type { ProviderMetadata } from '../types/providers'; import { acpChatSessionActions, acpChatSessionStore } from '../acp/chatSessionStore'; import { @@ -93,10 +93,13 @@ export const ModelAndProviderProvider: React.FC = async (sessionId: string | null, model: Model) => { const modelName = model.name; const providerName = model.provider; - let phase = 'agent'; + let phase = 'validation'; try { + await validateProviderModel(providerName, modelName); + if (sessionId) { + phase = 'agent'; const applied = await acpSetSessionProviderModel( sessionId, providerName, @@ -128,13 +131,14 @@ export const ModelAndProviderProvider: React.FC = return true; } catch (error) { console.error(`Failed to change model at ${phase} step -- ${modelName} ${providerName}`); + const message = errorMessage(error); toastError({ title: intl.formatMessage(i18n.modelChangeFailed, { provider: providerName, model: modelName, }), - msg: `${error}`, - traceback: errorMessage(error), + msg: message, + traceback: message, }); return false; } diff --git a/ui/desktop/src/components/settings/models/modelInterface.ts b/ui/desktop/src/components/settings/models/modelInterface.ts index b16f82ea85..c06f50c482 100644 --- a/ui/desktop/src/components/settings/models/modelInterface.ts +++ b/ui/desktop/src/components/settings/models/modelInterface.ts @@ -1,5 +1,10 @@ +import { + checkProvider, + getProviderModelInfo, + getProviderModels, +} from '../../../api'; import { listLocalModels } from '../../../acp/local-inference'; -import { acpListProviderDetails, acpListProviderModels } from '../../../acp/providers'; +import { acpListProviderDetails } from '../../../acp/providers'; import type { ProviderDetails, ThinkingEffort } from '../../../types/providers'; import { errorMessage as getErrorMessage } from '../../../utils/conversionUtils'; @@ -43,6 +48,13 @@ export async function getProviderMetadata(providerName: string) { return matches.metadata; } +export async function validateProviderModel(provider: string, model: string): Promise { + await checkProvider({ + body: { provider, model }, + throwOnError: true, + }); +} + export interface ProviderModelsResult { provider: ProviderDetails; models: Model[] | null; @@ -64,13 +76,16 @@ export async function fetchModelsForProviders( return { provider: p, models: downloadedModels, error: null, warning: null }; } - const providerModels = await acpListProviderModels(p.name); - const models = providerModels.map( + const response = await getProviderModels({ + path: { name: p.name }, + throwOnError: true, + }); + const models = (response.data || []).map( (m) => ({ - name: m.id, + name: m.name, provider: p.name, - context_limit: m.contextLimit ?? undefined, + context_limit: m.context_limit, reasoning: m.reasoning ?? undefined, }) as Model ); @@ -118,9 +133,11 @@ export async function fetchModelReasoning( fallback?: boolean ): Promise { try { - const models = await acpListProviderModels(provider); - const match = models.find((m) => m.id === model); - return match?.reasoning ?? fallback ?? null; + const response = await getProviderModelInfo({ + path: { name: provider }, + body: { model }, + }); + return response.data?.reasoning ?? fallback ?? null; } catch { return fallback ?? null; } diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index 326ca8e21b..df21822bea 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -132,6 +132,10 @@ const i18n = defineMessages({ id: 'switchModelModal.loadingModels', defaultMessage: 'Loading models…', }, + checkingModel: { + id: 'switchModelModal.checkingModel', + defaultMessage: 'Checking model…', + }, selectModelPlaceholder: { id: 'switchModelModal.selectModelPlaceholder', defaultMessage: 'Select a model, type to search', @@ -302,6 +306,7 @@ export const SwitchModelModal = ({ const [selectedPredefinedModel, setSelectedPredefinedModel] = useState(null); const [predefinedModels, setPredefinedModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const [userClearedModel, setUserClearedModel] = useState(false); const [providerErrors, setProviderErrors] = useState>({}); const [providerWarnings, setProviderWarnings] = useState>({}); @@ -395,41 +400,46 @@ export const SwitchModelModal = ({ setAttemptedSubmit(true); const isFormValid = validateForm(); - if (isFormValid) { - let modelObj: Model; + if (!isFormValid) { + return; + } - if (usePredefinedModels && selectedPredefinedModel) { - modelObj = selectedPredefinedModel; - } else { - const providerMetaData = await getProviderMetadata(provider || ''); - const providerDisplayName = providerMetaData.display_name; - modelObj = { - name: model, - provider: provider, - subtext: providerDisplayName, - } as Model; - } + let modelObj: Model; + + if (usePredefinedModels && selectedPredefinedModel) { + modelObj = selectedPredefinedModel; + } else { + const providerMetaData = await getProviderMetadata(provider || ''); + modelObj = { + name: model, + provider: provider, + subtext: providerMetaData.display_name, + } as Model; + } + modelObj = { + ...modelObj, + reasoning: selectedModelReasoning ?? modelObj.reasoning, + }; + + if (showThinkingControl) { + const effort = thinkingEffort ?? modelObj.request_params?.thinking_effort ?? 'off'; modelObj = { ...modelObj, - reasoning: selectedModelReasoning ?? modelObj.reasoning, + request_params: { ...modelObj.request_params, thinking_effort: effort }, }; + acpSaveThinkingEffort(effort).catch(console.warn); + } - if (showThinkingControl) { - const effort = thinkingEffort ?? modelObj.request_params?.thinking_effort ?? 'off'; - modelObj = { - ...modelObj, - request_params: { ...modelObj.request_params, thinking_effort: effort }, - }; - acpSaveThinkingEffort(effort).catch(console.warn); - } - + setIsSubmitting(true); + try { const success = await changeModel(sessionId, modelObj); if (success) { onModelSelected?.(modelObj.name, modelObj.provider || ''); trackModelChanged(modelObj.provider || '', modelObj.name); + onClose(); } - - onClose(); + } finally { + setIsSubmitting(false); } }; @@ -978,11 +988,13 @@ export const SwitchModelModal = ({ {intl.formatMessage(i18n.quickStartGuide)}
- -
diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index 19dc36913b..f75b3aeb16 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -4175,6 +4175,9 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Überprüfen Sie Ihre Anbieterkonfiguration unter Einstellungen → Anbieter" }, + "switchModelModal.checkingModel": { + "defaultMessage": "Modell wird überprüft…" + }, "switchModelModal.chooseModel": { "defaultMessage": "Wählen Sie ein Modell:" }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 85085508b2..efb6a746ed 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -4175,6 +4175,9 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Check your provider configuration in Settings → Providers" }, + "switchModelModal.checkingModel": { + "defaultMessage": "Checking model…" + }, "switchModelModal.chooseModel": { "defaultMessage": "Choose a model:" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index 8aba39ef76..3dfa347ff5 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -4223,6 +4223,9 @@ "switchModelModal.loadingModels": { "defaultMessage": "Cargando modelos…" }, + "switchModelModal.checkingModel": { + "defaultMessage": "Comprobando modelo…" + }, "switchModelModal.localModelsDescription": { "defaultMessage": "Para usar inferencia local, primero debes descargar un modelo a tu computadora. Ve a Ajustes → Modelos para gestionar los modelos locales." }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index 41fc303d15..163736ccd2 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -4175,6 +4175,9 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Vérifiez la configuration de votre fournisseur dans Paramètres → Fournisseurs" }, + "switchModelModal.checkingModel": { + "defaultMessage": "Vérification du modèle…" + }, "switchModelModal.chooseModel": { "defaultMessage": "Choisissez un modèle :" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 906fcb129b..4a5bc9b716 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -4223,6 +4223,9 @@ "switchModelModal.loadingModels": { "defaultMessage": "मॉडल लोड हो रहे हैं…" }, + "switchModelModal.checkingModel": { + "defaultMessage": "मॉडल की जाँच की जा रही है…" + }, "switchModelModal.localModelsDescription": { "defaultMessage": "स्थानीय अनुमान का उपयोग करने के लिए, आपको पहले अपने कंप्यूटर पर एक मॉडल डाउनलोड करना होगा। स्थानीय मॉडल प्रबंधित करने के लिए Settings → मॉडल पर जाएं।" }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index bdd7de7128..256146d366 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -4175,6 +4175,9 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Periksa konfigurasi penyedia Anda di Pengaturan → Penyedia" }, + "switchModelModal.checkingModel": { + "defaultMessage": "Memeriksa model…" + }, "switchModelModal.chooseModel": { "defaultMessage": "Pilih model:" }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index c19fa48e16..22ee6c22a7 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -4175,6 +4175,9 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Controlla la configurazione del provider in Impostazioni → Provider" }, + "switchModelModal.checkingModel": { + "defaultMessage": "Verifica del modello…" + }, "switchModelModal.chooseModel": { "defaultMessage": "Scegli un modello:" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index 75863c6d3b..8793cab3bc 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -4223,6 +4223,9 @@ "switchModelModal.loadingModels": { "defaultMessage": "モデルを読み込み中…" }, + "switchModelModal.checkingModel": { + "defaultMessage": "モデルを確認中…" + }, "switchModelModal.localModelsDescription": { "defaultMessage": "ローカル推論を使用するには、先にモデルをコンピューターにダウンロードする必要があります。設定 → モデルでローカルモデルを管理できます。" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index 09aa513621..82d8af68d6 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -4223,6 +4223,9 @@ "switchModelModal.loadingModels": { "defaultMessage": "모델 로드 중…" }, + "switchModelModal.checkingModel": { + "defaultMessage": "모델 확인 중…" + }, "switchModelModal.localModelsDescription": { "defaultMessage": "로컬 추론을 사용하려면 먼저 모델을 컴퓨터에 다운로드해야 합니다. 로컬 모델을 관리하려면 설정 → 모델로 이동하세요." }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index 68fedd6c24..4c8586927a 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -4175,6 +4175,9 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Semak konfigurasi penyedia anda dalam Tetapan → Penyedia" }, + "switchModelModal.checkingModel": { + "defaultMessage": "Menyemak model…" + }, "switchModelModal.chooseModel": { "defaultMessage": "Pilih model:" }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index b715b37b65..609a5afdf0 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -4175,6 +4175,9 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Verifique a configuração do seu fornecedor em Definições → Fornecedores" }, + "switchModelModal.checkingModel": { + "defaultMessage": "A verificar modelo…" + }, "switchModelModal.chooseModel": { "defaultMessage": "Escolha um modelo:" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index fb890d3153..421ff67a8e 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -4223,6 +4223,9 @@ "switchModelModal.loadingModels": { "defaultMessage": "Загрузка моделей…" }, + "switchModelModal.checkingModel": { + "defaultMessage": "Проверка модели…" + }, "switchModelModal.localModelsDescription": { "defaultMessage": "Чтобы использовать локальный инференс, сначала скачайте модель на компьютер. Перейдите в Настройки → Модели для управления локальными моделями." }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index c69394cd8d..95629166b9 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -4223,6 +4223,9 @@ "switchModelModal.loadingModels": { "defaultMessage": "Modeller yükleniyor…" }, + "switchModelModal.checkingModel": { + "defaultMessage": "Model kontrol ediliyor…" + }, "switchModelModal.localModelsDescription": { "defaultMessage": "Yerel çıkarımı kullanmak için öncelikle bilgisayarınıza bir model indirmeniz gerekir. Yerel modelleri yönetmek için Ayarlar → Modeller'e gidin." }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index 8f94847441..7e3eea6f8c 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -4175,6 +4175,9 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Kiểm tra cấu hình nhà cung cấp của bạn trong Cài đặt → Nhà cung cấp" }, + "switchModelModal.checkingModel": { + "defaultMessage": "Đang kiểm tra mô hình…" + }, "switchModelModal.chooseModel": { "defaultMessage": "Chọn một mô hình:" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index ca094060af..4f3f88b020 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -4223,6 +4223,9 @@ "switchModelModal.loadingModels": { "defaultMessage": "正在加载模型…" }, + "switchModelModal.checkingModel": { + "defaultMessage": "正在检查模型…" + }, "switchModelModal.localModelsDescription": { "defaultMessage": "要使用本地推理,你需要先下载一个模型到电脑上。前往 设置 → 模型 管理本地模型。" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index 002bae4570..e96cd7c792 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -4175,6 +4175,9 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "請在「設定」→「提供者」中檢查您的提供者設定" }, + "switchModelModal.checkingModel": { + "defaultMessage": "正在檢查模型…" + }, "switchModelModal.chooseModel": { "defaultMessage": "選擇模型:" }, From a162a7f7783d778ae6f8ac41198f13009cba4eb8 Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 2 Jul 2026 00:08:46 +0200 Subject: [PATCH 036/139] Improve ACP auth and origin defaults (#9886) --- crates/goose-cli/src/cli.rs | 109 +++- crates/goose-server/src/commands/agent.rs | 20 +- crates/goose/src/acp/transport/mod.rs | 203 ++++++- crates/goose/tests/acp_transport_auth_test.rs | 499 +++++++++++++++++- documentation/docs/guides/acp-clients.md | 15 +- .../docs/guides/environment-variables.md | 2 +- 6 files changed, 806 insertions(+), 42 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index ce581370a7..e586de9efe 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -849,6 +849,20 @@ enum Command { action = clap::ArgAction::Append )] builtins: Vec, + + #[arg( + long = "dangerously-unauthenticated", + help = "Start the ACP endpoint without requiring GOOSE_SERVER__SECRET_KEY" + )] + dangerously_unauthenticated: bool, + + #[arg( + long = "allowed-origin", + value_name = "ORIGIN", + action = clap::ArgAction::Append, + help = "Allow an exact Origin value for ACP CORS; may be specified multiple times and replaces the default loopback origins" + )] + allowed_origins: Vec, }, /// Start or resume interactive chat sessions @@ -1342,14 +1356,19 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> { Ok(()) } -async fn handle_serve_command( +struct ServeCommandArgs { host: String, port: u16, tls: bool, tls_cert_path: Option, tls_key_path: Option, builtins: Vec, -) -> Result<()> { + dangerously_unauthenticated: bool, + allowed_origins: Vec, +} + +async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { + use axum::http::HeaderValue; use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; use goose::acp::transport::create_router; use goose::config::paths::Paths; @@ -1357,6 +1376,17 @@ async fn handle_serve_command( use std::sync::Arc; use tracing::{info, warn}; + let ServeCommandArgs { + host, + port, + tls, + tls_cert_path, + tls_key_path, + builtins, + dangerously_unauthenticated, + allowed_origins, + } = args; + let builtins = if builtins.is_empty() { vec!["developer".to_string()] } else { @@ -1388,13 +1418,35 @@ async fn handle_serve_command( .map(|secret| secret.trim().to_string()) .filter(|secret| !secret.is_empty()); let require_token = env_secret.is_some(); - if !require_token { - warn!( - "{GOOSE_SERVER_SECRET_KEY_ENV} is not set; the ACP endpoint will accept unauthenticated connections" + if !require_token && !dangerously_unauthenticated { + anyhow::bail!( + "{GOOSE_SERVER_SECRET_KEY_ENV} must be set to start `goose serve`; pass --dangerously-unauthenticated to run without ACP authentication" ); } + if dangerously_unauthenticated && !require_token { + warn!( + "{GOOSE_SERVER_SECRET_KEY_ENV} is not set and --dangerously-unauthenticated was passed; the ACP endpoint will accept unauthenticated connections" + ); + } + let additional_allowed_origins = allowed_origins + .into_iter() + .map(|origin| { + let origin = origin.trim(); + if origin.is_empty() || origin == "*" { + anyhow::bail!("--allowed-origin must be a non-wildcard Origin value"); + } + HeaderValue::from_str(origin).map_err(|error| { + anyhow::anyhow!("invalid --allowed-origin value `{origin}`: {error}") + }) + }) + .collect::>>()?; let secret_key = env_secret.unwrap_or_else(generate_serve_secret_key); - let router = create_router(server, secret_key, require_token); + let router = create_router( + server, + secret_key, + require_token, + additional_allowed_origins, + ); let config = Config::global(); let tls_cert_path = @@ -2159,7 +2211,21 @@ pub async fn cli() -> anyhow::Result<()> { tls_cert_path, tls_key_path, builtins, - }) => handle_serve_command(host, port, tls, tls_cert_path, tls_key_path, builtins).await, + dangerously_unauthenticated, + allowed_origins, + }) => { + handle_serve_command(ServeCommandArgs { + host, + port, + tls, + tls_cert_path, + tls_key_path, + builtins, + dangerously_unauthenticated, + allowed_origins, + }) + .await + } Some(Command::Session { command: Some(cmd), .. }) => handle_session_subcommand(cmd).await, @@ -2356,6 +2422,35 @@ mod tests { } } + #[test] + fn serve_command_accepts_dangerously_unauthenticated_flag() { + let cli = Cli::try_parse_from([ + "goose", + "serve", + "--dangerously-unauthenticated", + "--allowed-origin", + "app://localhost", + "--allowed-origin", + "https://app.example", + ]) + .expect("parse failed"); + + match cli.command { + Some(Command::Serve { + dangerously_unauthenticated, + allowed_origins, + .. + }) => { + assert!(dangerously_unauthenticated); + assert_eq!( + allowed_origins, + vec!["app://localhost", "https://app.example"] + ); + } + _ => panic!("expected serve command"), + } + } + #[test] fn review_command_accepts_options() { let cli = Cli::try_parse_from([ diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 0845fef61c..e5c597fb39 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -4,10 +4,10 @@ use anyhow::Result; use axum::middleware; use axum_server::Handle; use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; -use goose::acp::transport::create_acp_router; +use goose::acp::transport::create_authenticated_acp_router; use goose::agents::GoosePlatform; use goose::config::paths::Paths; -use goose_server::auth::{check_acp_token, check_token}; +use goose_server::auth::check_token; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use tracing::info; @@ -72,15 +72,15 @@ pub async fn run() -> Result<()> { scheduler: Some(app_state.scheduler()), })); - let rest_router = crate::routes::configure(app_state.clone(), secret_key.clone()).layer( - middleware::from_fn_with_state(secret_key.clone(), check_token), - ); - let acp_router = create_acp_router(acp_server).layer(middleware::from_fn_with_state( - secret_key.clone(), - check_acp_token, - )); + let rest_router = crate::routes::configure(app_state.clone(), secret_key.clone()) + .layer(middleware::from_fn_with_state( + secret_key.clone(), + check_token, + )) + .layer(cors); + let acp_router = create_authenticated_acp_router(acp_server, secret_key.clone()); - let app = rest_router.merge(acp_router).layer(cors); + let app = rest_router.merge(acp_router); let addr = settings.socket_addr(); diff --git a/crates/goose/src/acp/transport/mod.rs b/crates/goose/src/acp/transport/mod.rs index 39d5d6eb97..6c2f1d980b 100644 --- a/crates/goose/src/acp/transport/mod.rs +++ b/crates/goose/src/acp/transport/mod.rs @@ -6,26 +6,161 @@ use std::sync::Arc; use agent_client_protocol_http::{AcpHttpServer, CorsOptions, ServerOptions}; use axum::{ - http::{header, HeaderName, Method}, + extract::{Request, State}, + http::{header, HeaderName, HeaderValue, Method, StatusCode}, + middleware::Next, + response::Response, routing::get, Router, }; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::{AllowOrigin, Any, CorsLayer}; use crate::acp::server::GooseAgentConnection; use crate::acp::server_factory::AcpServer; +// The upstream ACP HTTP server only supports exact origin allowlists for +// WebSocket upgrades; Goose applies its richer loopback predicate before this. +const UPSTREAM_WS_ALLOWED_ORIGIN: &str = "http://goose.local"; +const DESKTOP_FILE_ORIGIN: &str = "null"; + +#[derive(Clone)] +struct AcpOriginPolicy { + exact_origins: Arc<[HeaderValue]>, + allow_loopback: bool, +} + +impl AcpOriginPolicy { + fn loopback() -> Self { + Self { + exact_origins: Vec::new().into(), + allow_loopback: true, + } + } + + fn exact(origins: Vec) -> Self { + Self { + exact_origins: origins.into(), + allow_loopback: false, + } + } + + fn loopback_and(origins: Vec) -> Self { + Self { + exact_origins: origins.into(), + allow_loopback: true, + } + } + + fn origin_allowed(&self, origin: &HeaderValue) -> bool { + if self + .exact_origins + .iter() + .any(|allowed_origin| allowed_origin == origin) + { + return true; + } + + if !self.allow_loopback { + return false; + } + + let Ok(origin) = origin.to_str() else { + return false; + }; + + let Ok(url) = url::Url::parse(origin) else { + return false; + }; + + if !matches!(url.scheme(), "http" | "https") { + return false; + } + + match url.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(addr)) => addr.is_loopback(), + Some(url::Host::Ipv6(addr)) => addr.is_loopback(), + None => false, + } + } +} + fn acp_http_options() -> ServerOptions { ServerOptions { path: "/acp".to_string(), - cors: CorsOptions::allow_any_origin(), + cors: CorsOptions::allow_origins([UPSTREAM_WS_ALLOWED_ORIGIN]) + .expect("static origin is valid"), health_endpoint: false, } } +fn header_contains_token(value: Option<&HeaderValue>, token: &str) -> bool { + value + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|part| part.trim().eq_ignore_ascii_case(token)) + }) +} + +fn is_websocket_upgrade(request: &Request) -> bool { + request.method() == Method::GET + && header_contains_token(request.headers().get(header::CONNECTION), "upgrade") + && request + .headers() + .get(header::UPGRADE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("websocket")) +} + +async fn enforce_websocket_origin( + State(policy): State, + mut request: Request, + next: Next, +) -> Result { + if is_websocket_upgrade(&request) { + if let Some(origin) = request.headers().get(header::ORIGIN) { + if !policy.origin_allowed(origin) { + return Err(StatusCode::FORBIDDEN); + } + } + + request.headers_mut().insert( + header::ORIGIN, + HeaderValue::from_static(UPSTREAM_WS_ALLOWED_ORIGIN), + ); + } + + Ok(next.run(request).await) +} + +fn acp_cors_layer(policy: AcpOriginPolicy) -> CorsLayer { + CorsLayer::new() + .allow_origin(AllowOrigin::predicate(move |origin, _request_parts| { + policy.origin_allowed(origin) + })) + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) + .allow_headers([ + header::CONTENT_TYPE, + header::ACCEPT, + HeaderName::from_static("x-secret-key"), + HeaderName::from_static("acp-connection-id"), + HeaderName::from_static("acp-session-id"), + header::SEC_WEBSOCKET_VERSION, + header::SEC_WEBSOCKET_KEY, + header::CONNECTION, + header::UPGRADE, + ]) + .expose_headers([ + HeaderName::from_static("acp-connection-id"), + HeaderName::from_static("acp-session-id"), + ]) +} + /// CORS for the auxiliary routes (`/health`, `/status`, MCP app proxy) served by -/// `goose serve`. The ACP routes get their CORS from `AcpHttpServer`; this also -/// allows the `x-secret-key` auth header the proxy routes rely on. +/// `goose serve`. This allows the `x-secret-key` auth header the proxy routes +/// rely on. fn aux_cors_layer() -> CorsLayer { CorsLayer::new() .allow_origin(Any) @@ -37,12 +172,43 @@ fn aux_cors_layer() -> CorsLayer { ]) } -/// The bare ACP HTTP/WebSocket router (POST/GET/DELETE on `/acp`), without auth -/// or goose-specific auxiliary routes. -pub fn create_acp_router(server: Arc) -> Router { +fn create_acp_router_inner(server: Arc, policy: AcpOriginPolicy) -> Router { AcpHttpServer::new(move || GooseAgentConnection::new(server.clone())) .with_options(acp_http_options()) .into_router() + .layer(axum::middleware::from_fn_with_state( + policy, + enforce_websocket_origin, + )) +} + +fn create_acp_router_with_policy( + server: Arc, + policy: AcpOriginPolicy, + secret_key: Option, +) -> Router { + let mut acp_routes = create_acp_router_inner(server, policy.clone()); + if let Some(secret_key) = secret_key { + acp_routes = acp_routes.layer(axum::middleware::from_fn_with_state( + secret_key, + auth::check_acp_token, + )); + } + acp_routes.layer(acp_cors_layer(policy)) +} + +/// The bare ACP HTTP/WebSocket router (POST/GET/DELETE on `/acp`), without auth +/// or goose-specific auxiliary routes. +pub fn create_acp_router(server: Arc) -> Router { + create_acp_router_with_policy(server, AcpOriginPolicy::loopback(), None) +} + +pub fn create_authenticated_acp_router(server: Arc, secret_key: String) -> Router { + create_acp_router_with_policy( + server, + AcpOriginPolicy::loopback_and(vec![HeaderValue::from_static(DESKTOP_FILE_ORIGIN)]), + Some(secret_key), + ) } async fn health() -> &'static str { @@ -51,14 +217,19 @@ async fn health() -> &'static str { /// The full standalone ACP server router used by `goose serve`: ACP transport, /// optional token auth, health/status endpoints, and the MCP app proxy. -pub fn create_router(server: Arc, secret_key: String, require_token: bool) -> Router { - let mut acp_routes = create_acp_router(server); - if require_token { - acp_routes = acp_routes.layer(axum::middleware::from_fn_with_state( - secret_key.clone(), - auth::check_acp_token, - )); - } +pub fn create_router( + server: Arc, + secret_key: String, + require_token: bool, + additional_allowed_origins: Vec, +) -> Router { + let policy = if additional_allowed_origins.is_empty() { + AcpOriginPolicy::loopback() + } else { + AcpOriginPolicy::exact(additional_allowed_origins) + }; + let acp_routes = + create_acp_router_with_policy(server, policy, require_token.then_some(secret_key.clone())); let aux_routes = Router::new() .route("/health", get(health)) diff --git a/crates/goose/tests/acp_transport_auth_test.rs b/crates/goose/tests/acp_transport_auth_test.rs index a3107f5402..32198be829 100644 --- a/crates/goose/tests/acp_transport_auth_test.rs +++ b/crates/goose/tests/acp_transport_auth_test.rs @@ -1,16 +1,20 @@ use std::sync::Arc; use axum::body::Body; -use axum::http::{Method, Request, StatusCode}; +use axum::http::{HeaderValue, Method, Request, Response, StatusCode}; use axum::Router; use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; -use goose::acp::transport::create_router; +use goose::acp::transport::{create_acp_router, create_authenticated_acp_router, create_router}; use goose::agents::GoosePlatform; use tower::ServiceExt; const SECRET: &str = "test-secret-token"; fn test_router(require_token: bool, dir: &tempfile::TempDir) -> Router { + test_router_with_origins(require_token, dir, Vec::new()) +} + +fn test_acp_router(dir: &tempfile::TempDir) -> Router { let server = Arc::new(AcpServer::new(AcpServerFactoryConfig { builtins: vec![], data_dir: dir.path().join("data"), @@ -19,16 +23,58 @@ fn test_router(require_token: bool, dir: &tempfile::TempDir) -> Router { additional_source_roots: Vec::new(), scheduler: None, })); - create_router(server, SECRET.to_string(), require_token) + create_acp_router(server) +} + +fn test_authenticated_acp_router(dir: &tempfile::TempDir) -> Router { + let server = Arc::new(AcpServer::new(AcpServerFactoryConfig { + builtins: vec![], + data_dir: dir.path().join("data"), + config_dir: dir.path().join("config"), + goose_platform: GoosePlatform::GooseCli, + additional_source_roots: Vec::new(), + scheduler: None, + })); + create_authenticated_acp_router(server, SECRET.to_string()) +} + +fn test_router_with_origins( + require_token: bool, + dir: &tempfile::TempDir, + additional_allowed_origins: Vec, +) -> Router { + let server = Arc::new(AcpServer::new(AcpServerFactoryConfig { + builtins: vec![], + data_dir: dir.path().join("data"), + config_dir: dir.path().join("config"), + goose_platform: GoosePlatform::GooseCli, + additional_source_roots: Vec::new(), + scheduler: None, + })); + create_router( + server, + SECRET.to_string(), + require_token, + additional_allowed_origins, + ) } async fn send(router: &Router, method: Method, uri: &str, headers: &[(&str, &str)]) -> StatusCode { + send_response(router, method, uri, headers).await.status() +} + +async fn send_response( + router: &Router, + method: Method, + uri: &str, + headers: &[(&str, &str)], +) -> Response { let mut builder = Request::builder().method(method).uri(uri); for (name, value) in headers { builder = builder.header(*name, *value); } let request = builder.body(Body::empty()).unwrap(); - router.clone().oneshot(request).await.unwrap().status() + router.clone().oneshot(request).await.unwrap() } #[tokio::test] @@ -62,6 +108,190 @@ async fn websocket_handshake_without_token_is_unauthorized() { assert_eq!(status, StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn websocket_handshake_rejects_arbitrary_web_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "https://evil.example"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn acp_router_websocket_handshake_rejects_arbitrary_web_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_acp_router(&dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "https://evil.example"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn authenticated_acp_router_allows_packaged_desktop_null_websocket_origin() { + let dir = tempfile::tempdir().unwrap(); + let router = test_authenticated_acp_router(&dir); + + let status = send( + &router, + Method::GET, + &format!("/acp?token={SECRET}"), + &[ + ("origin", "null"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn serve_router_rejects_null_websocket_origin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "null"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn websocket_handshake_allows_loopback_web_origins_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "http://localhost:5173"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn websocket_handshake_allows_ipv6_loopback_web_origins_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "http://[::1]:5173"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn websocket_handshake_explicit_origins_replace_loopback_defaults() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router_with_origins( + false, + &dir, + vec![HeaderValue::from_static("app://localhost")], + ); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "http://localhost:5173"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn websocket_handshake_allows_configured_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router_with_origins( + false, + &dir, + vec![HeaderValue::from_static("app://localhost")], + ); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "app://localhost"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + #[tokio::test] async fn header_token_is_accepted() { let dir = tempfile::tempdir().unwrap(); @@ -106,10 +336,269 @@ async fn health_endpoints_skip_token_check() { } #[tokio::test] -async fn acp_open_when_no_secret_configured() { +async fn acp_open_when_auth_disabled() { let dir = tempfile::tempdir().unwrap(); let router = test_router(false, &dir); let status = send(&router, Method::GET, "/acp", &[]).await; assert_eq!(status, StatusCode::NOT_ACCEPTABLE); } + +#[tokio::test] +async fn acp_cors_rejects_arbitrary_web_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "https://evil.example"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert!(response + .headers() + .get("access-control-allow-origin") + .is_none()); +} + +#[tokio::test] +async fn acp_cors_rejects_custom_app_origins_unless_configured() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "app://localhost"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert!(response + .headers() + .get("access-control-allow-origin") + .is_none()); +} + +#[tokio::test] +async fn acp_cors_allows_loopback_web_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "http://localhost:5173"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("http://localhost:5173") + ); +} + +#[tokio::test] +async fn acp_cors_allows_ipv6_loopback_web_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "http://[::1]:5173"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("http://[::1]:5173") + ); +} + +#[tokio::test] +async fn authenticated_acp_cors_preflight_skips_token_check() { + let dir = tempfile::tempdir().unwrap(); + let router = test_authenticated_acp_router(&dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "http://localhost:5173"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,x-secret-key,acp-connection-id", + ), + ], + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("http://localhost:5173") + ); +} + +#[tokio::test] +async fn authenticated_acp_cors_allows_packaged_desktop_null_origin() { + let dir = tempfile::tempdir().unwrap(); + let router = test_authenticated_acp_router(&dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "null"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,x-secret-key,acp-connection-id", + ), + ], + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("null") + ); +} + +#[tokio::test] +async fn serve_cors_rejects_null_origin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "null"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,x-secret-key,acp-connection-id", + ), + ], + ) + .await; + + assert!(response + .headers() + .get("access-control-allow-origin") + .is_none()); +} + +#[tokio::test] +async fn acp_cors_explicit_origins_replace_loopback_defaults() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router_with_origins( + false, + &dir, + vec![HeaderValue::from_static("app://localhost")], + ); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "http://localhost:5173"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert!(response + .headers() + .get("access-control-allow-origin") + .is_none()); +} + +#[tokio::test] +async fn acp_cors_allows_additional_configured_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router_with_origins( + false, + &dir, + vec![HeaderValue::from_static("app://localhost")], + ); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "app://localhost"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("app://localhost") + ); +} diff --git a/documentation/docs/guides/acp-clients.md b/documentation/docs/guides/acp-clients.md index 003907c3fd..44c68faf5d 100644 --- a/documentation/docs/guides/acp-clients.md +++ b/documentation/docs/guides/acp-clients.md @@ -204,12 +204,12 @@ For servers that support the draft standard ACP over Streamable HTTP https://git npm start -- --server http://HOST:PORT # example server -cargo run -p goose-cli --bin goose -- serve +GOOSE_SERVER__SECRET_KEY='a-long-random-secret' cargo run -p goose-cli --bin goose -- serve ``` ### Server Authentication -Set the `GOOSE_SERVER__SECRET_KEY` environment variable to require authentication on the ACP endpoint. When it is set, `goose serve` rejects any request that doesn't present a matching token: +Set the `GOOSE_SERVER__SECRET_KEY` environment variable to authenticate the ACP endpoint. `goose serve` refuses to start without this secret unless you explicitly pass `--dangerously-unauthenticated`: ```bash GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve @@ -217,7 +217,16 @@ GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve Clients authenticate by sending the token in the `X-Secret-Key` header, or as a `?token=` query parameter for WebSocket connections (the browser WebSocket API can't set custom headers). Requests without a matching token receive `401 Unauthorized`, including WebSocket handshakes. -When `GOOSE_SERVER__SECRET_KEY` is not set, the endpoint accepts unauthenticated connections and `goose serve` logs a warning at startup. +ACP WebSocket Origin validation allows loopback web origins by default. For `goose serve`, ACP CORS follows the same policy. If you pass any `--allowed-origin` values, that explicit list replaces the default loopback origins, so include every origin the client needs: + +```bash +GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve \ + --allowed-origin 'http://localhost:5173' \ + --allowed-origin 'app://localhost' \ + --allowed-origin 'https://app.example' +``` + +For local development only, `goose serve --dangerously-unauthenticated` starts without a secret and logs a warning. Do not use this mode with shell-capable builtins enabled unless the server is isolated from untrusted browser traffic. ### Single Prompt Mode diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index 144cbfeba1..ce6c62b9f8 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -514,7 +514,7 @@ These variables configure the `goosed` server process. They are most often used | `GOOSE_HOST` | Interface the server binds to. Use `0.0.0.0` to accept connections from other machines; `localhost` or `127.0.0.1` restricts to the local machine. | Hostname or IP | `127.0.0.1` | | `GOOSE_PORT` | TCP port the server listens on | Port number | `3000` | | `GOOSE_TLS` | Enable TLS with a self-signed certificate. Required when connecting goose Desktop to a remote `goosed`. | `true`, `false` | `true` | -| `GOOSE_SERVER__SECRET_KEY` | Shared secret required in the `X-Secret-Key` header on all client requests. When set, it is also enforced on the `goose serve` ACP endpoint. | Secret string | Random (auto-generated) | +| `GOOSE_SERVER__SECRET_KEY` | Shared secret required in the `X-Secret-Key` header on all client requests. `goosed` auto-generates one when unset; `goose serve` requires this variable unless started with `--dangerously-unauthenticated`. | Secret string | Random for `goosed`; required for `goose serve` | **Examples** From 9081cbd1d7c1856199383abb667ac7276d1794d5 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 1 Jul 2026 12:26:40 -0400 Subject: [PATCH 037/139] fix(desktop): seed mac auto-update config (#10132) Co-authored-by: Douwe M Osinga --- .github/workflows/bundle-desktop-intel.yml | 4 +++ .github/workflows/bundle-desktop.yml | 4 +++ .github/workflows/release.yml | 23 ++++++++++-- ui/desktop/forge.config.ts | 2 +- .../scripts/verify-mac-update-resources.js | 35 +++++++++++++++++++ ui/desktop/src/app-update.yml | 4 +++ 6 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 ui/desktop/scripts/verify-mac-update-resources.js create mode 100644 ui/desktop/src/app-update.yml diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index 54d76434ed..311ec965da 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -152,6 +152,10 @@ jobs: fi working-directory: ui/desktop + - name: Verify macOS updater resources + run: node scripts/verify-mac-update-resources.js "out/Goose-darwin-x64/Goose.app" + working-directory: ui/desktop + - name: Clean up signing keychain if: always() run: | diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index 4b0073198d..c60df16a96 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -184,6 +184,10 @@ jobs: fi working-directory: ui/desktop + - name: Verify macOS updater resources + run: node scripts/verify-mac-update-resources.js "out/Goose-darwin-arm64/Goose.app" + working-directory: ui/desktop + - name: Clean up signing keychain if: always() run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2af367473..158d940aec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,11 @@ permissions: pull-requests: write # Required for npm publish workflow attestations: write # Required for SLSA build provenance attestations +env: + # Set this repository Actions variable to "true" in GitHub Settings > Secrets and variables + # > Actions > Variables after a release containing desktop app-update.yml has shipped. + ENABLE_MAC_NATIVE_AUTO_UPDATE: ${{ vars.ENABLE_MAC_NATIVE_AUTO_UPDATE || 'false' }} + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -119,8 +124,15 @@ jobs: merge-multiple: true - name: Generate macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} run: node ui/desktop/scripts/generate-mac-update-manifest.js --version "${GITHUB_REF_NAME}" --directory . + - name: Attest macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: latest-mac.yml + - name: Attest build provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: @@ -132,7 +144,6 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh # Create/update the versioned release @@ -148,7 +159,6 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh allowUpdates: true omitBody: true @@ -169,8 +179,15 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh allowUpdates: true omitBody: true omitPrereleaseDuringUpdate: true + + - name: Upload macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${GITHUB_REF_NAME}" latest-mac.yml --clobber + gh release upload stable latest-mac.yml --clobber diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index c68ee433cd..6b88b98282 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -6,7 +6,7 @@ const isLinuxVulkanBuild = process.env.GOOSE_DESKTOP_LINUX_VARIANT === 'vulkan'; let cfg = { asar: true, - extraResource: ['src/bin', 'src/images'], + extraResource: ['src/bin', 'src/images', 'src/app-update.yml'], icon: 'src/images/icon', // Windows specific configuration win32: { diff --git a/ui/desktop/scripts/verify-mac-update-resources.js b/ui/desktop/scripts/verify-mac-update-resources.js new file mode 100644 index 0000000000..e75f9adc37 --- /dev/null +++ b/ui/desktop/scripts/verify-mac-update-resources.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); + +function fail(message) { + console.error(message); + process.exit(1); +} + +const appPath = process.argv[2]; +if (!appPath) { + fail('Usage: node scripts/verify-mac-update-resources.js '); +} + +const updateConfigPath = path.join(appPath, 'Contents', 'Resources', 'app-update.yml'); +if (!fs.existsSync(updateConfigPath)) { + fail(`Missing ${updateConfigPath}`); +} + +const updateConfig = fs.readFileSync(updateConfigPath, 'utf8'); +const requiredLines = [ + 'provider: github', + 'owner: aaif-goose', + 'repo: goose', + 'updaterCacheDirName: goose-updater', +]; + +for (const line of requiredLines) { + if (!updateConfig.split(/\r?\n/).includes(line)) { + fail(`${updateConfigPath} is missing "${line}"`); + } +} + +console.log(`${updateConfigPath} is present and valid`); diff --git a/ui/desktop/src/app-update.yml b/ui/desktop/src/app-update.yml new file mode 100644 index 0000000000..47fc62049f --- /dev/null +++ b/ui/desktop/src/app-update.yml @@ -0,0 +1,4 @@ +provider: github +owner: aaif-goose +repo: goose +updaterCacheDirName: goose-updater From 0d4b6924b9425043082468242b871c17d9c10806 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Thu, 2 Jul 2026 09:39:04 +1000 Subject: [PATCH 038/139] Revert "Bedrock model discovery and validation" (#10176) --- Cargo.lock | 26 -- crates/goose-cli/src/commands/configure.rs | 6 +- crates/goose-server/src/routes/agent.rs | 6 - .../src/routes/config_management.rs | 32 +- crates/goose/Cargo.toml | 2 - crates/goose/src/acp/server/config.rs | 3 +- crates/goose/src/providers/bedrock.rs | 399 ++---------------- crates/goose/src/providers/init.rs | 5 +- .../src/providers/inventory/registrations.rs | 16 - crates/goose/src/providers/provider_test.rs | 85 +--- .../goose/tests/acp_custom_requests_test.rs | 17 +- crates/goose/tests/acp_fixtures/mod.rs | 1 - crates/goose/tests/acp_fixtures/server.rs | 11 - ui/desktop/openapi.json | 4 - ui/desktop/src/acp/providers.ts | 80 +--- ui/desktop/src/api/types.gen.ts | 1 - .../components/ModelAndProviderContext.tsx | 12 +- .../settings/models/modelInterface.ts | 33 +- .../models/subcomponents/SwitchModelModal.tsx | 68 ++- ui/desktop/src/i18n/messages/de.json | 3 - ui/desktop/src/i18n/messages/en.json | 3 - ui/desktop/src/i18n/messages/es.json | 3 - ui/desktop/src/i18n/messages/fr.json | 3 - ui/desktop/src/i18n/messages/hi.json | 3 - ui/desktop/src/i18n/messages/id.json | 3 - ui/desktop/src/i18n/messages/it.json | 3 - ui/desktop/src/i18n/messages/ja.json | 3 - ui/desktop/src/i18n/messages/ko.json | 3 - ui/desktop/src/i18n/messages/ms.json | 3 - ui/desktop/src/i18n/messages/pt.json | 3 - ui/desktop/src/i18n/messages/ru.json | 3 - ui/desktop/src/i18n/messages/tr.json | 3 - ui/desktop/src/i18n/messages/vi.json | 3 - ui/desktop/src/i18n/messages/zh-CN.json | 3 - ui/desktop/src/i18n/messages/zh-TW.json | 3 - 35 files changed, 135 insertions(+), 720 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80f80739d9..c22c0c45ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -678,31 +678,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "aws-sdk-bedrock" -version = "1.146.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd3d870793928e6c18de8273dcdceed08af8cc9083be9d7de3e52efc7ac01e9e" -dependencies = [ - "arc-swap", - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 0.2.12", - "http 1.4.2", - "regex-lite", - "tracing", -] - [[package]] name = "aws-sdk-bedrockruntime" version = "1.133.0" @@ -4901,7 +4876,6 @@ dependencies = [ "async-trait", "aws-config", "aws-lc-rs", - "aws-sdk-bedrock", "aws-sdk-bedrockruntime", "aws-sdk-sagemakerruntime", "aws-smithy-types", diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index d6a0cd6ba7..bb51673d1a 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -811,8 +811,10 @@ pub async fn configure_provider_dialog() -> anyhow::Result { let spin = spinner(); spin.start("Checking your configuration..."); - let (toolshim_enabled, toolshim_model) = - goose::providers::provider_test::toolshim_settings_from_env(); + let toolshim_enabled = std::env::var("GOOSE_TOOLSHIM") + .map(|val| val == "1" || val.to_lowercase() == "true") + .unwrap_or(false); + let toolshim_model = std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL").ok(); match test_provider_configuration(provider_name, &model, toolshim_enabled, toolshim_model).await { diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index a7ea390f89..20c91d0db6 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -17,7 +17,6 @@ use goose::agents::ExtensionConfig; use goose::config::resolve_extensions_for_new_session; use goose::config::{Config, GooseMode}; use goose::providers::create; -use goose::providers::provider_test::test_provider_model; use goose::recipe::Recipe; use goose::recipe_deeplink; use goose::session::session_manager::SessionType; @@ -592,11 +591,6 @@ async fn update_agent_provider( if let Some(request_params) = payload.request_params { model_config = model_config.with_merged_request_params(request_params); } - - test_provider_model(&payload.provider, &model) - .await - .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; - let model_info = resolve_provider_model_info(&payload.provider, &model) .await .map_err(|e| (e.status, e.message))?; diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index bb7a354533..0f65ef7aa8 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -19,7 +19,6 @@ use goose::providers::catalog::{ ProviderTemplate, }; use goose::providers::create_with_default_model; -use goose::providers::provider_test::test_provider_model; use goose::providers::providers as get_providers; use goose::{ agents::execute_commands, agents::ExtensionConfig, slash_commands::recipe_slash_command, @@ -109,8 +108,6 @@ fn normalize_custom_provider_api_key(api_key: String) -> Option { #[derive(Deserialize, ToSchema)] pub struct CheckProviderRequest { pub provider: String, - #[serde(default)] - pub model: Option, } #[derive(Deserialize, ToSchema)] @@ -830,20 +827,8 @@ pub async fn update_custom_provider( request_body = CheckProviderRequest, )] pub async fn check_provider( - Json(CheckProviderRequest { provider, model }): Json, + Json(CheckProviderRequest { provider }): Json, ) -> Result<(), ErrorResponse> { - if let Some(model) = model.filter(|model| !model.trim().is_empty()) { - test_provider_model(&provider, &model) - .await - .map_err(|err| { - ErrorResponse::bad_request(format!( - "Provider '{}' with model '{}' check failed: {}", - provider, model, err - )) - })?; - return Ok(()); - } - create_with_default_model(&provider, Vec::new()) .await .map_err(|err| { @@ -860,22 +845,19 @@ pub async fn check_provider( pub async fn set_config_provider( Json(SetProviderRequest { provider, model }): Json, ) -> Result<(), ErrorResponse> { - test_provider_model(&provider, &model) + create_with_default_model(&provider, Vec::new()) .await + .and_then(|_| { + let config = Config::global(); + goose::config::set_active_provider(config, &provider, &model) + .map_err(|e| anyhow::anyhow!(e)) + }) .map_err(|err| { ErrorResponse::bad_request(format!( "Failed to set provider to '{}' with model '{}': {}", provider, model, err )) })?; - - let config = Config::global(); - goose::config::set_active_provider(config, &provider, &model).map_err(|err| { - ErrorResponse::bad_request(format!( - "Failed to set provider to '{}' with model '{}': {}", - provider, model, err - )) - })?; Ok(()) } diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index aaccc2efb5..0f0fdd25fc 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -35,7 +35,6 @@ local-inference = [ aws-providers = [ "dep:aws-config", "dep:aws-smithy-types", - "dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-sdk-sagemakerruntime", "dep:smithy-transport-reqwest", @@ -235,7 +234,6 @@ llama-cpp-sys-2 = { workspace = true, optional = true } image = { version = "0.24.9", default-features = false, features = ["png", "jpeg", "gif", "webp"] } subtle = { version = "2.5", default-features = false, features = ["std"] } gethostname = "1.1.0" -aws-sdk-bedrock = { version = "1.132", default-features = false, features = ["rt-tokio"], optional = true } [target.'cfg(target_os = "windows")'.dependencies] winapi = { workspace = true } diff --git a/crates/goose/src/acp/server/config.rs b/crates/goose/src/acp/server/config.rs index 69c81d2455..e70ddf98df 100644 --- a/crates/goose/src/acp/server/config.rs +++ b/crates/goose/src/acp/server/config.rs @@ -215,8 +215,7 @@ impl GooseAcpAgent { } if let Some(model_id) = model_id.as_deref() { - let model_exists = entry.supports_refresh - || entry.default_model == model_id + let model_exists = entry.default_model == model_id || entry.models.iter().any(|model| model.id == model_id); if !model_exists { return Err(agent_client_protocol::Error::invalid_params().data(format!( diff --git a/crates/goose/src/providers/bedrock.rs b/crates/goose/src/providers/bedrock.rs index dfc68e6494..a9ffd0b930 100644 --- a/crates/goose/src/providers/bedrock.rs +++ b/crates/goose/src/providers/bedrock.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, HashMap}; +use std::collections::HashMap; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::openai_compatible::{handle_status, stream_responses_compat}; @@ -8,16 +8,11 @@ use crate::session_context::SESSION_ID_HEADER; use anyhow::Result; use async_stream::try_stream; use async_trait::async_trait; -use aws_sdk_bedrock::types::{ - FoundationModelLifecycleStatus, FoundationModelSummary, InferenceProfileStatus, - InferenceProfileType, InferenceType, ModelModality, -}; -use aws_sdk_bedrock::Client as BedrockControlClient; use aws_sdk_bedrockruntime::config::ProvideCredentials; use aws_sdk_bedrockruntime::operation::converse::ConverseError; use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamError; use aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError; -use aws_sdk_bedrockruntime::{types as bedrock, Client as BedrockRuntimeClient}; +use aws_sdk_bedrockruntime::{types as bedrock, Client}; use base64::Engine; use futures::future::BoxFuture; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; @@ -41,8 +36,15 @@ pub const BEDROCK_DOC_LINK: &str = "https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html"; pub const BEDROCK_DEFAULT_MODEL: &str = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"; -pub const BEDROCK_MANTLE_MODELS: &[&str] = &["openai.gpt-5.5", "openai.gpt-5.4"]; -pub const BEDROCK_BOOTSTRAP_MODELS: &[&str] = &[BEDROCK_DEFAULT_MODEL]; +pub const BEDROCK_KNOWN_MODELS: &[&str] = &[ + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us.anthropic.claude-sonnet-4-20250514-v1:0", + "us.anthropic.claude-3-7-sonnet-20250219-v1:0", + "us.anthropic.claude-opus-4-20250514-v1:0", + "us.anthropic.claude-opus-4-1-20250805-v1:0", + "openai.gpt-5.5", + "openai.gpt-5.4", +]; pub const BEDROCK_DEFAULT_MAX_RETRIES: usize = 6; pub const BEDROCK_DEFAULT_INITIAL_RETRY_INTERVAL_MS: u64 = 2000; @@ -52,9 +54,7 @@ pub const BEDROCK_DEFAULT_MAX_RETRY_INTERVAL_MS: u64 = 120_000; #[derive(Debug, serde::Serialize)] pub struct BedrockProvider { #[serde(skip)] - client: BedrockRuntimeClient, - #[serde(skip)] - control_plane_client: BedrockControlClient, + client: Client, #[serde(skip)] retry_config: RetryConfig, #[serde(skip)] @@ -163,26 +163,15 @@ impl BedrockProvider { )) .build(); - BedrockRuntimeClient::from_conf(bedrock_config) + Client::from_conf(bedrock_config) } else { - Self::create_runtime_client_with_credentials(&sdk_config).await? - }; - - let control_plane_client = if let Some(ref token) = bearer_token { - let bedrock_config = aws_sdk_bedrock::Config::new(&sdk_config) - .to_builder() - .bearer_token(aws_sdk_bedrock::config::Token::new(token.clone(), None)) - .build(); - BedrockControlClient::from_conf(bedrock_config) - } else { - BedrockControlClient::new(&sdk_config) + Self::create_client_with_credentials(&sdk_config).await? }; let retry_config = Self::load_retry_config(config); Ok(Self { client, - control_plane_client, retry_config, name: BEDROCK_PROVIDER_NAME.to_string(), region: resolved_region, @@ -192,9 +181,7 @@ impl BedrockProvider { }) } - async fn create_runtime_client_with_credentials( - sdk_config: &aws_config::SdkConfig, - ) -> Result { + async fn create_client_with_credentials(sdk_config: &aws_config::SdkConfig) -> Result { sdk_config .credentials_provider() .ok_or_else(|| anyhow::anyhow!("No AWS credentials provider configured"))? @@ -207,152 +194,7 @@ impl BedrockProvider { ) })?; - Ok(BedrockRuntimeClient::new(sdk_config)) - } - - fn bootstrap_models(include_mantle: bool) -> Vec { - let mut models: Vec = BEDROCK_BOOTSTRAP_MODELS - .iter() - .map(|model| model.to_string()) - .collect(); - if include_mantle { - models.extend(BEDROCK_MANTLE_MODELS.iter().map(|model| model.to_string())); - } - models - } - - fn merge_discovered_model_ids( - inference_profiles: impl IntoIterator, - foundation_models: impl IntoIterator, - extra_models: impl IntoIterator, - ) -> Vec { - let mut models = BTreeSet::new(); - models.extend(inference_profiles); - models.extend(foundation_models); - models.extend(extra_models); - models.into_iter().collect() - } - - fn is_excluded_non_chat_model_id(model_id: &str) -> bool { - let id = model_id.to_lowercase(); - id.contains(".embed") - || id.contains("-embed-") - || id.ends_with("-embed") - || id.contains(".rerank") - || id.contains("-rerank-") - || id.ends_with("-rerank") - } - - fn is_chat_capable_model_id(model_id: &str) -> bool { - !Self::is_excluded_non_chat_model_id(model_id) - } - - fn is_chat_capable_foundation_model(summary: &FoundationModelSummary) -> bool { - if !Self::is_chat_capable_model_id(summary.model_id()) { - return false; - } - - let has_text_input = summary.input_modalities().contains(&ModelModality::Text); - let has_text_output = summary.output_modalities().contains(&ModelModality::Text); - let has_embedding_output = summary - .output_modalities() - .contains(&ModelModality::Embedding); - let streaming = summary.response_streaming_supported().unwrap_or(false); - let not_legacy = summary - .model_lifecycle() - .map(|lifecycle| lifecycle.status() != &FoundationModelLifecycleStatus::Legacy) - .unwrap_or(true); - - has_text_input && has_text_output && !has_embedding_output && streaming && not_legacy - } - - fn is_mantle_model_id(model_id: &str) -> bool { - BEDROCK_MANTLE_MODELS.contains(&model_id) - } - - async fn fetch_inference_profile_ids(&self) -> Result, ProviderError> { - let mut ids = BTreeSet::new(); - - for profile_type in [ - InferenceProfileType::SystemDefined, - InferenceProfileType::Application, - ] { - let mut next_token = None; - loop { - let mut request = self - .control_plane_client - .list_inference_profiles() - .type_equals(profile_type.clone()); - if let Some(token) = &next_token { - request = request.next_token(token); - } - - let response = request.send().await.map_err(|err| { - ProviderError::ExecutionError(format!( - "Failed to list Bedrock inference profiles: {}", - err - )) - })?; - - for summary in response.inference_profile_summaries() { - if summary.status() == &InferenceProfileStatus::Active { - let id = summary.inference_profile_id(); - if Self::is_chat_capable_model_id(id) { - ids.insert(id.to_string()); - } - } - } - - next_token = response.next_token().map(|token| token.to_string()); - if next_token.is_none() { - break; - } - } - } - - Ok(ids.into_iter().collect()) - } - - async fn fetch_foundation_model_ids(&self) -> Result, ProviderError> { - let response = self - .control_plane_client - .list_foundation_models() - .by_inference_type(InferenceType::OnDemand) - .by_output_modality(ModelModality::Text) - .send() - .await - .map_err(|err| { - ProviderError::ExecutionError(format!( - "Failed to list Bedrock foundation models: {}", - err - )) - })?; - - Ok(response - .model_summaries() - .iter() - .filter(|summary| Self::is_chat_capable_foundation_model(summary)) - .map(|summary| summary.model_id().to_string()) - .collect()) - } - - async fn fetch_models_from_aws(&self) -> Result, ProviderError> { - let inference_profiles = self.fetch_inference_profile_ids().await?; - let foundation_models = self.fetch_foundation_model_ids().await?; - let extra_models = if self.bearer_token.is_some() { - BEDROCK_MANTLE_MODELS - .iter() - .map(|model| model.to_string()) - .collect() - } else { - Vec::new() - }; - - Ok(Self::merge_discovered_model_ids( - inference_profiles, - foundation_models, - extra_models, - )) + Ok(Client::new(sdk_config)) } fn load_retry_config(config: &crate::config::Config) -> RetryConfig { @@ -560,12 +402,6 @@ impl BedrockProvider { "Bedrock validation error: {}", err.message().unwrap_or("unknown validation error") )), - ConverseError::ResourceNotFoundException(err) => { - ProviderError::ExecutionError(format!( - "Bedrock model not found or not accessible: {}", - err.message().unwrap_or("unknown resource error") - )) - } ConverseError::ModelErrorException(err) => { ProviderError::ExecutionError(format!("Failed to call Bedrock: {:?}", err)) } @@ -658,18 +494,6 @@ impl BedrockProvider { err )) } - ConverseStreamError::ValidationException(err) => { - ProviderError::ExecutionError(format!( - "Bedrock validation error: {}", - err.message().unwrap_or("unknown validation error") - )) - } - ConverseStreamError::ResourceNotFoundException(err) => { - ProviderError::ExecutionError(format!( - "Bedrock model not found or not accessible: {}", - err.message().unwrap_or("unknown resource error") - )) - } ConverseStreamError::ModelErrorException(err) => { ProviderError::ExecutionError(format!("Failed to call Bedrock: {:?}", err)) } @@ -865,9 +689,9 @@ impl goose_providers::base::ProviderDescriptor for BedrockProvider { ProviderMetadata::new( BEDROCK_PROVIDER_NAME, "Amazon Bedrock", - "Run models through Amazon Bedrock. Supports AWS SSO profiles - run 'aws sso login --profile ' before using. Configure with AWS_PROFILE and AWS_REGION, use environment variables/credentials, or use AWS_BEARER_TOKEN_BEDROCK for bearer token authentication. Region is required for bearer token auth (can be set via AWS_REGION, AWS_DEFAULT_REGION, or AWS profile). Model discovery requires bedrock:ListFoundationModels and bedrock:ListInferenceProfiles permissions. Prompt caching can be enabled for Anthropic Claude models by setting BEDROCK_ENABLE_CACHING=true. Responses stream via the ConverseStream API; set BEDROCK_DISABLE_STREAMING=true to fall back to blocking Converse calls.", + "Run models through Amazon Bedrock. Supports AWS SSO profiles - run 'aws sso login --profile ' before using. Configure with AWS_PROFILE and AWS_REGION, use environment variables/credentials, or use AWS_BEARER_TOKEN_BEDROCK for bearer token authentication. Region is required for bearer token auth (can be set via AWS_REGION, AWS_DEFAULT_REGION, or AWS profile). Prompt caching can be enabled for Anthropic Claude models by setting BEDROCK_ENABLE_CACHING=true. Responses stream via the ConverseStream API; set BEDROCK_DISABLE_STREAMING=true to fall back to blocking Converse calls.", BEDROCK_DEFAULT_MODEL, - BEDROCK_BOOTSTRAP_MODELS.to_vec(), + BEDROCK_KNOWN_MODELS.to_vec(), BEDROCK_DOC_LINK, vec![ ConfigKey::new("AWS_PROFILE", false, false, Some("default"), true), @@ -907,25 +731,8 @@ impl Provider for BedrockProvider { self.retry_config.clone() } - fn skip_canonical_filtering(&self) -> bool { - true - } - async fn fetch_supported_models(&self) -> Result, ProviderError> { - match self.fetch_models_from_aws().await { - Ok(models) if !models.is_empty() => Ok(models), - Ok(_) => { - tracing::debug!("Bedrock model discovery returned no models, using bootstrap list"); - Ok(Self::bootstrap_models(self.bearer_token.is_some())) - } - Err(err) => { - tracing::warn!( - "Bedrock model discovery failed ({}), using bootstrap list", - err - ); - Ok(Self::bootstrap_models(self.bearer_token.is_some())) - } - } + Ok(BEDROCK_KNOWN_MODELS.iter().map(|s| s.to_string()).collect()) } async fn stream( @@ -949,7 +756,7 @@ impl Provider for BedrockProvider { let (base_name, effort) = extract_reasoning_effort(without_prefix); let bedrock_model_id = format!("openai.{}", base_name); - let is_mantle_model = Self::is_mantle_model_id(&bedrock_model_id); + let is_mantle_model = BEDROCK_KNOWN_MODELS.contains(&bedrock_model_id.as_str()); if is_mantle_model { let mut normalized_config = ModelConfig { @@ -1112,23 +919,11 @@ mod tests { .behavior_version(aws_config::BehaviorVersion::latest()) .region(aws_config::Region::new("us-east-1")) .build(); - let client = BedrockRuntimeClient::new(&sdk_config); - let control_plane_client = BedrockControlClient::new(&sdk_config); - let model = ModelConfig { - model_name: model_name.to_string(), - context_limit: None, - temperature: None, - max_tokens: None, - toolshim: false, - toolshim_model: None, - request_params: None, - reasoning: None, - }; + let client = Client::new(&sdk_config); ( BedrockProvider { client, - control_plane_client, retry_config: RetryConfig::default(), name: "aws_bedrock".to_string(), region: None, @@ -1136,7 +931,16 @@ mod tests { http_client: reqwest::Client::new(), mantle_base_url: None, }, - model, + ModelConfig { + model_name: model_name.to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + request_params: None, + reasoning: None, + }, ) } @@ -1202,21 +1006,6 @@ mod tests { ); } - #[test] - #[serial] - fn test_caching_disabled_by_default() { - std::env::set_var("BEDROCK_ENABLE_CACHING", "false"); - - let (provider, model) = - create_mock_provider_and_model("us.anthropic.claude-sonnet-4-5-20250929-v1:0"); - assert!( - !provider.should_enable_caching(&model), - "Caching should be disabled by default" - ); - - std::env::remove_var("BEDROCK_ENABLE_CACHING"); - } - #[test] fn test_caching_disabled_for_non_claude_models() { let (provider, model) = create_mock_provider_and_model("amazon.titan-text-express-v1"); @@ -1304,19 +1093,9 @@ mod tests { .region(aws_config::Region::new("us-east-1")) .build(); - let model = ModelConfig { - model_name: "openai.gpt-5.5".to_string(), - context_limit: None, - temperature: None, - max_tokens: None, - toolshim: false, - toolshim_model: None, - request_params: None, - reasoning: None, - }; + let model = ModelConfig::new("openai.gpt-5.5"); let provider = BedrockProvider { - client: BedrockRuntimeClient::new(&sdk_config), - control_plane_client: BedrockControlClient::new(&sdk_config), + client: Client::new(&sdk_config), retry_config: RetryConfig::default(), name: "aws_bedrock".to_string(), region: Some("us-east-1".to_string()), @@ -1718,116 +1497,4 @@ mod tests { other => panic!("expected RedactedThinking, got {:?}", other), } } - - #[test] - fn test_merge_discovered_model_ids_dedupes_and_sorts() { - let models = BedrockProvider::merge_discovered_model_ids( - [ - "eu.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), - "us.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), - ], - [ - "anthropic.claude-3-5-sonnet-20240620-v1:0".to_string(), - "eu.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), - ], - ["openai.gpt-5.5".to_string()], - ); - - assert_eq!( - models, - vec![ - "anthropic.claude-3-5-sonnet-20240620-v1:0".to_string(), - "eu.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), - "openai.gpt-5.5".to_string(), - "us.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), - ] - ); - } - - #[test] - fn test_bootstrap_models_includes_mantle_with_bearer_token() { - let without_mantle = BedrockProvider::bootstrap_models(false); - assert_eq!(without_mantle, vec![BEDROCK_DEFAULT_MODEL.to_string()]); - - let with_mantle = BedrockProvider::bootstrap_models(true); - assert!(with_mantle.contains(&"openai.gpt-5.5".to_string())); - assert!(with_mantle.contains(&BEDROCK_DEFAULT_MODEL.to_string())); - } - - #[test] - fn test_is_mantle_model_id() { - assert!(BedrockProvider::is_mantle_model_id("openai.gpt-5.5")); - assert!(BedrockProvider::is_mantle_model_id("openai.gpt-5.4")); - assert!(!BedrockProvider::is_mantle_model_id( - "openai.gpt-oss-120b-1:0" - )); - assert!(!BedrockProvider::is_mantle_model_id( - "us.anthropic.claude-sonnet-4-5-20250929-v1:0" - )); - } - - #[test] - fn test_skip_canonical_filtering_enabled() { - let (provider, _) = create_mock_provider_and_model("test"); - assert!(provider.skip_canonical_filtering()); - } - - #[test] - fn test_is_chat_capable_foundation_model_filters_embeddings() { - let chat_model = FoundationModelSummary::builder() - .model_arn("arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20240620-v1:0") - .model_id("anthropic.claude-3-5-sonnet-20240620-v1:0") - .input_modalities(ModelModality::Text) - .output_modalities(ModelModality::Text) - .response_streaming_supported(true) - .build() - .unwrap(); - assert!(BedrockProvider::is_chat_capable_foundation_model( - &chat_model - )); - - let embedding_model = FoundationModelSummary::builder() - .model_arn("arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0") - .model_id("amazon.titan-embed-text-v2:0") - .input_modalities(ModelModality::Text) - .output_modalities(ModelModality::Embedding) - .response_streaming_supported(false) - .build() - .unwrap(); - assert!(!BedrockProvider::is_chat_capable_foundation_model( - &embedding_model - )); - - let cohere_embed_v4 = FoundationModelSummary::builder() - .model_arn("arn:aws:bedrock:us-east-1::foundation-model/cohere.embed-v4:0") - .model_id("cohere.embed-v4:0") - .input_modalities(ModelModality::Text) - .output_modalities(ModelModality::Text) - .output_modalities(ModelModality::Embedding) - .response_streaming_supported(true) - .build() - .unwrap(); - assert!(!BedrockProvider::is_chat_capable_foundation_model( - &cohere_embed_v4 - )); - } - - #[test] - fn test_is_chat_capable_model_id_excludes_embed_and_rerank_profiles() { - assert!(!BedrockProvider::is_chat_capable_model_id( - "cohere.embed-v4:0" - )); - assert!(!BedrockProvider::is_chat_capable_model_id( - "us.cohere.embed-v4:0" - )); - assert!(!BedrockProvider::is_chat_capable_model_id( - "amazon.titan-embed-text-v2:0" - )); - assert!(!BedrockProvider::is_chat_capable_model_id( - "cohere.rerank-v3-5:0" - )); - assert!(BedrockProvider::is_chat_capable_model_id( - "us.anthropic.claude-sonnet-4-5-20250929-v1:0" - )); - } } diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index c9bfe3e983..3752715fbf 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -70,10 +70,7 @@ async fn init_registry() -> RwLock { registry.register::(false); registry.register::(false); #[cfg(feature = "aws-providers")] - registry.register_with_inventory::( - false, - Some(registrations::bedrock_inventory()), - ); + registry.register::(false); #[cfg(feature = "local-inference")] registry.register::(false); registry.register_with_inventory::( diff --git a/crates/goose/src/providers/inventory/registrations.rs b/crates/goose/src/providers/inventory/registrations.rs index dda04b62af..b3852cf506 100644 --- a/crates/goose/src/providers/inventory/registrations.rs +++ b/crates/goose/src/providers/inventory/registrations.rs @@ -6,8 +6,6 @@ use crate::config::{self, Config}; use crate::providers::acp_tooling::{acp_adapter_installed, resolved_acp_command}; use crate::providers::amp_acp::{AMP_ACP_BINARY, AMP_ACP_PROVIDER_NAME}; use crate::providers::base::ProviderDescriptor; -#[cfg(feature = "aws-providers")] -use crate::providers::bedrock::{BedrockProvider, BEDROCK_PROVIDER_NAME}; use crate::providers::chatgpt_codex::TokenCache as ChatGptCodexTokenCache; use crate::providers::claude_acp::{CLAUDE_ACP_BINARY, CLAUDE_ACP_PROVIDER_NAME}; use crate::providers::codex_acp::CODEX_ACP_PROVIDER_NAME; @@ -127,20 +125,6 @@ pub fn ollama_inventory() -> InventoryRegistration { }) } -#[cfg(feature = "aws-providers")] -pub fn bedrock_inventory() -> InventoryRegistration { - InventoryRegistration::new(true, || { - let config = Config::global(); - let metadata = BedrockProvider::metadata(); - Ok(default_inventory_identity( - BEDROCK_PROVIDER_NAME, - BEDROCK_PROVIDER_NAME, - &metadata.config_keys, - config, - )) - }) -} - pub fn huggingface_inventory() -> InventoryRegistration { InventoryRegistration::new(false, || { let metadata = HuggingFaceProvider::metadata(); diff --git a/crates/goose/src/providers/provider_test.rs b/crates/goose/src/providers/provider_test.rs index e0b66c19de..b7ee3cad8a 100644 --- a/crates/goose/src/providers/provider_test.rs +++ b/crates/goose/src/providers/provider_test.rs @@ -3,88 +3,45 @@ use anyhow::Result; use futures::StreamExt; use rmcp::model::ToolAnnotations; use rmcp::{model::Tool, object}; -use std::time::Duration; -use tokio::time::timeout; - -const PROVIDER_TEST_TIMEOUT: Duration = Duration::from_secs(60); - -pub fn provider_model_validation_enabled() -> bool { - !matches!( - std::env::var("GOOSE_SKIP_PROVIDER_MODEL_VALIDATION"), - Ok(value) if value == "1" || value.eq_ignore_ascii_case("true") - ) -} - -pub fn toolshim_settings_from_env() -> (Option, Option) { - let toolshim_enabled = std::env::var("GOOSE_TOOLSHIM") - .map(|val| val == "1" || val.to_lowercase() == "true") - .ok(); - let toolshim_model = std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL").ok(); - (toolshim_enabled, toolshim_model) -} - -pub async fn test_provider_model(provider_name: &str, model: &str) -> Result<()> { - if !provider_model_validation_enabled() { - return Ok(()); - } - test_provider_configuration(provider_name, model, None, None).await -} pub async fn test_provider_configuration( provider_name: &str, model: &str, - toolshim_enabled: Option, + toolshim_enabled: bool, toolshim_model: Option, ) -> Result<()> { - let mut model_config = - crate::model_config::model_config_from_user_config(provider_name, model)? - .with_max_tokens(Some(50)); - - if let Some(toolshim_enabled) = toolshim_enabled { - model_config = model_config.with_toolshim(toolshim_enabled); - } - if toolshim_model.is_some() { - model_config = model_config.with_toolshim_model(toolshim_model); - } + let model_config = crate::model_config::model_config_from_user_config(provider_name, model)? + .with_max_tokens(Some(50)) + .with_toolshim(toolshim_enabled) + .with_toolshim_model(toolshim_model); let provider = create(provider_name, Vec::new()).await?; let messages = vec![Message::user().with_text("What is the weather like in San Francisco today?")]; - let tools = if !model_config.toolshim { + let tools = if !toolshim_enabled { vec![create_sample_weather_tool()] } else { vec![] }; - timeout(PROVIDER_TEST_TIMEOUT, async { - let mut stream = crate::session_context::with_session_id( - Some("test-session-id".to_string()), - provider.stream( - &model_config, - "You are an AI agent called goose. You use tools of connected extensions to solve problems.", - &messages, - &tools.into_iter().collect::>(), - ), - ) - .await?; + let mut stream = crate::session_context::with_session_id( + Some("test-session-id".to_string()), + provider.stream( + &model_config, + "You are an AI agent called goose. You use tools of connected extensions to solve problems.", + &messages, + &tools.into_iter().collect::>(), + ), + ) + .await?; - let first_chunk = stream - .next() - .await - .ok_or_else(|| anyhow::anyhow!("Provider test stream returned no events"))?; - first_chunk?; - - Ok::<(), anyhow::Error>(()) - }) - .await - .map_err(|_| { - anyhow::anyhow!( - "Provider configuration test timed out after {}s", - PROVIDER_TEST_TIMEOUT.as_secs() - ) - })??; + let first_chunk = stream + .next() + .await + .ok_or_else(|| anyhow::anyhow!("Provider test stream returned no events"))?; + first_chunk?; Ok(()) } diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 11e4ab2e6c..36c13e8aa7 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -574,13 +574,16 @@ fn test_steer_session_adds_input_to_active_prompt() { let mut steer_sent = false; let mut steer_message_id: Option = None; let mut final_response = None; - let mut observed_updates = Vec::new(); let deadline = tokio::time::Instant::now() + Duration::from_secs(3); while tokio::time::Instant::now() < deadline { tokio::select! { - biased; - updates = session.wait_for_session_updates(), if !steer_sent => { + response = &mut prompt => { + final_response = Some(response.unwrap()); + break; + } + _ = tokio::time::sleep(Duration::from_millis(10)), if !steer_sent => { + let updates = session.session_updates(); if let Some(run_id) = updates.iter().find_map(active_run_id_from_update) { let response = send_custom( conn.cx(), @@ -604,11 +607,6 @@ fn test_steer_session_adds_input_to_active_prompt() { steer_message_id = mid.map(ToString::to_string); steer_sent = true; } - observed_updates.extend(updates); - } - response = &mut prompt => { - final_response = Some(response.unwrap()); - break; } } } @@ -617,8 +615,7 @@ fn test_steer_session_adds_input_to_active_prompt() { assert_eq!(response.stop_reason, StopReason::EndTurn); assert!(steer_sent, "test never observed an active run id"); - let mut updates = observed_updates; - updates.extend(session.session_updates()); + let updates = session.session_updates(); let agent_text = collect_agent_text(&updates); assert!( agent_text.contains("saw steer"), diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index c1b287cdad..5290bbc882 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -770,7 +770,6 @@ where if std::env::var_os("GOOSE_PATH_ROOT").is_none() { std::env::set_var("GOOSE_PATH_ROOT", ACP_CONFIG_ROOT.path()); } - std::env::set_var("GOOSE_SKIP_PROVIDER_MODEL_VALIDATION", "1"); register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone()); let handle = std::thread::Builder::new() diff --git a/crates/goose/tests/acp_fixtures/server.rs b/crates/goose/tests/acp_fixtures/server.rs index b2a7b77357..dc86a8ac5e 100644 --- a/crates/goose/tests/acp_fixtures/server.rs +++ b/crates/goose/tests/acp_fixtures/server.rs @@ -62,17 +62,6 @@ impl AcpServerSession { .collect() } - pub async fn wait_for_session_updates(&self) -> Vec { - loop { - let notified = self.notify.notified(); - let updates = self.session_updates(); - if !updates.is_empty() { - return updates; - } - notified.await; - } - } - async fn send_prompt( &mut self, content: Vec, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 402e216ce7..306d833e48 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3033,10 +3033,6 @@ "provider" ], "properties": { - "model": { - "type": "string", - "nullable": true - }, "provider": { "type": "string" } diff --git a/ui/desktop/src/acp/providers.ts b/ui/desktop/src/acp/providers.ts index d3e63e3964..e9e967f405 100644 --- a/ui/desktop/src/acp/providers.ts +++ b/ui/desktop/src/acp/providers.ts @@ -11,36 +11,6 @@ import { getAcpClient } from './acpConnection'; export type { CanonicalModelInfoDto, ProviderSecretDto }; -function acpErrorMessage(error: unknown): string | null { - if (typeof error !== 'object' || error === null) { - return null; - } - - const candidate = 'error' in error && isRecord(error.error) ? error.error : error; - if (!isRecord(candidate)) { - return null; - } - if (typeof candidate.data === 'string') { - return candidate.data; - } - return typeof candidate.message === 'string' ? candidate.message : null; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function normalizeAcpError(error: unknown, fallback: string): Error { - const message = acpErrorMessage(error); - if (message) { - return new Error(message); - } - if (error instanceof Error) { - return error; - } - return new Error(fallback); -} - function updateRequestToCreate( request: UpdateCustomProviderRequest ): CustomProviderCreateRequest_unstable { @@ -205,12 +175,8 @@ export async function acpReadDefaults(): Promise<{ } export async function acpSaveDefaults(providerId: string, modelId?: string | null): Promise { - try { - const client = await getAcpClient(); - await client.goose.defaultsSave_unstable({ providerId, modelId: modelId ?? null }); - } catch (error) { - throw normalizeAcpError(error, 'Failed to save default provider/model'); - } + const client = await getAcpClient(); + await client.goose.defaultsSave_unstable({ providerId, modelId: modelId ?? null }); } export async function acpClearDefaults(): Promise { @@ -293,30 +259,26 @@ export async function acpSetSessionProviderModel( modelId?: string | null, thinkingEffort?: ThinkingEffort | null ): Promise { - try { - const client = await getAcpClient(); - let response = await client.setSessionConfigOption({ + const client = await getAcpClient(); + let response = await client.setSessionConfigOption({ + sessionId, + configId: 'provider', + value: providerId, + }); + if (modelId) { + response = await client.setSessionConfigOption({ sessionId, - configId: 'provider', - value: providerId, + configId: 'model', + value: modelId, }); - if (modelId) { - response = await client.setSessionConfigOption({ - sessionId, - configId: 'model', - value: modelId, - }); - } - if (thinkingEffort != null) { - response = await client.setSessionConfigOption({ - sessionId, - configId: 'thinking_effort', - value: thinkingEffort, - }); - } - - return extractAppliedSessionProviderModel(response.configOptions); - } catch (error) { - throw normalizeAcpError(error, 'Failed to update session provider/model'); } + if (thinkingEffort != null) { + response = await client.setSessionConfigOption({ + sessionId, + configId: 'thinking_effort', + value: thinkingEffort, + }); + } + + return extractAppliedSessionProviderModel(response.configOptions); } diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index e354f2f9a6..4e1d84d9e5 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -60,7 +60,6 @@ export type ChatRequest = { }; export type CheckProviderRequest = { - model?: string | null; provider: string; }; diff --git a/ui/desktop/src/components/ModelAndProviderContext.tsx b/ui/desktop/src/components/ModelAndProviderContext.tsx index dc9bc40e9e..5bd3f9226a 100644 --- a/ui/desktop/src/components/ModelAndProviderContext.tsx +++ b/ui/desktop/src/components/ModelAndProviderContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; import { toastError, toastSuccess } from '../toasts'; -import Model, { getProviderMetadata, validateProviderModel } from './settings/models/modelInterface'; +import Model, { getProviderMetadata } from './settings/models/modelInterface'; import type { ProviderMetadata } from '../types/providers'; import { acpChatSessionActions, acpChatSessionStore } from '../acp/chatSessionStore'; import { @@ -93,13 +93,10 @@ export const ModelAndProviderProvider: React.FC = async (sessionId: string | null, model: Model) => { const modelName = model.name; const providerName = model.provider; - let phase = 'validation'; + let phase = 'agent'; try { - await validateProviderModel(providerName, modelName); - if (sessionId) { - phase = 'agent'; const applied = await acpSetSessionProviderModel( sessionId, providerName, @@ -131,14 +128,13 @@ export const ModelAndProviderProvider: React.FC = return true; } catch (error) { console.error(`Failed to change model at ${phase} step -- ${modelName} ${providerName}`); - const message = errorMessage(error); toastError({ title: intl.formatMessage(i18n.modelChangeFailed, { provider: providerName, model: modelName, }), - msg: message, - traceback: message, + msg: `${error}`, + traceback: errorMessage(error), }); return false; } diff --git a/ui/desktop/src/components/settings/models/modelInterface.ts b/ui/desktop/src/components/settings/models/modelInterface.ts index c06f50c482..b16f82ea85 100644 --- a/ui/desktop/src/components/settings/models/modelInterface.ts +++ b/ui/desktop/src/components/settings/models/modelInterface.ts @@ -1,10 +1,5 @@ -import { - checkProvider, - getProviderModelInfo, - getProviderModels, -} from '../../../api'; import { listLocalModels } from '../../../acp/local-inference'; -import { acpListProviderDetails } from '../../../acp/providers'; +import { acpListProviderDetails, acpListProviderModels } from '../../../acp/providers'; import type { ProviderDetails, ThinkingEffort } from '../../../types/providers'; import { errorMessage as getErrorMessage } from '../../../utils/conversionUtils'; @@ -48,13 +43,6 @@ export async function getProviderMetadata(providerName: string) { return matches.metadata; } -export async function validateProviderModel(provider: string, model: string): Promise { - await checkProvider({ - body: { provider, model }, - throwOnError: true, - }); -} - export interface ProviderModelsResult { provider: ProviderDetails; models: Model[] | null; @@ -76,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 ); @@ -133,11 +118,9 @@ export async function fetchModelReasoning( fallback?: boolean ): Promise { 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; } diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index df21822bea..326ca8e21b 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -132,10 +132,6 @@ const i18n = defineMessages({ id: 'switchModelModal.loadingModels', defaultMessage: 'Loading models…', }, - checkingModel: { - id: 'switchModelModal.checkingModel', - defaultMessage: 'Checking model…', - }, selectModelPlaceholder: { id: 'switchModelModal.selectModelPlaceholder', defaultMessage: 'Select a model, type to search', @@ -306,7 +302,6 @@ export const SwitchModelModal = ({ const [selectedPredefinedModel, setSelectedPredefinedModel] = useState(null); const [predefinedModels, setPredefinedModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); const [userClearedModel, setUserClearedModel] = useState(false); const [providerErrors, setProviderErrors] = useState>({}); const [providerWarnings, setProviderWarnings] = useState>({}); @@ -400,46 +395,41 @@ export const SwitchModelModal = ({ setAttemptedSubmit(true); const isFormValid = validateForm(); - if (!isFormValid) { - return; - } + if (isFormValid) { + let modelObj: Model; - let modelObj: Model; - - if (usePredefinedModels && selectedPredefinedModel) { - modelObj = selectedPredefinedModel; - } else { - const providerMetaData = await getProviderMetadata(provider || ''); - modelObj = { - name: model, - provider: provider, - subtext: providerMetaData.display_name, - } as Model; - } - modelObj = { - ...modelObj, - reasoning: selectedModelReasoning ?? modelObj.reasoning, - }; - - if (showThinkingControl) { - const effort = thinkingEffort ?? modelObj.request_params?.thinking_effort ?? 'off'; + if (usePredefinedModels && selectedPredefinedModel) { + modelObj = selectedPredefinedModel; + } else { + const providerMetaData = await getProviderMetadata(provider || ''); + const providerDisplayName = providerMetaData.display_name; + modelObj = { + name: model, + provider: provider, + subtext: providerDisplayName, + } as Model; + } modelObj = { ...modelObj, - request_params: { ...modelObj.request_params, thinking_effort: effort }, + reasoning: selectedModelReasoning ?? modelObj.reasoning, }; - acpSaveThinkingEffort(effort).catch(console.warn); - } - setIsSubmitting(true); - try { + if (showThinkingControl) { + const effort = thinkingEffort ?? modelObj.request_params?.thinking_effort ?? 'off'; + modelObj = { + ...modelObj, + request_params: { ...modelObj.request_params, thinking_effort: effort }, + }; + acpSaveThinkingEffort(effort).catch(console.warn); + } + const success = await changeModel(sessionId, modelObj); if (success) { onModelSelected?.(modelObj.name, modelObj.provider || ''); trackModelChanged(modelObj.provider || '', modelObj.name); - onClose(); } - } finally { - setIsSubmitting(false); + + onClose(); } }; @@ -988,13 +978,11 @@ export const SwitchModelModal = ({ {intl.formatMessage(i18n.quickStartGuide)}
- -
diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index f75b3aeb16..19dc36913b 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -4175,9 +4175,6 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Überprüfen Sie Ihre Anbieterkonfiguration unter Einstellungen → Anbieter" }, - "switchModelModal.checkingModel": { - "defaultMessage": "Modell wird überprüft…" - }, "switchModelModal.chooseModel": { "defaultMessage": "Wählen Sie ein Modell:" }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index efb6a746ed..85085508b2 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -4175,9 +4175,6 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Check your provider configuration in Settings → Providers" }, - "switchModelModal.checkingModel": { - "defaultMessage": "Checking model…" - }, "switchModelModal.chooseModel": { "defaultMessage": "Choose a model:" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index 3dfa347ff5..8aba39ef76 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -4223,9 +4223,6 @@ "switchModelModal.loadingModels": { "defaultMessage": "Cargando modelos…" }, - "switchModelModal.checkingModel": { - "defaultMessage": "Comprobando modelo…" - }, "switchModelModal.localModelsDescription": { "defaultMessage": "Para usar inferencia local, primero debes descargar un modelo a tu computadora. Ve a Ajustes → Modelos para gestionar los modelos locales." }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index 163736ccd2..41fc303d15 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -4175,9 +4175,6 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Vérifiez la configuration de votre fournisseur dans Paramètres → Fournisseurs" }, - "switchModelModal.checkingModel": { - "defaultMessage": "Vérification du modèle…" - }, "switchModelModal.chooseModel": { "defaultMessage": "Choisissez un modèle :" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 4a5bc9b716..906fcb129b 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -4223,9 +4223,6 @@ "switchModelModal.loadingModels": { "defaultMessage": "मॉडल लोड हो रहे हैं…" }, - "switchModelModal.checkingModel": { - "defaultMessage": "मॉडल की जाँच की जा रही है…" - }, "switchModelModal.localModelsDescription": { "defaultMessage": "स्थानीय अनुमान का उपयोग करने के लिए, आपको पहले अपने कंप्यूटर पर एक मॉडल डाउनलोड करना होगा। स्थानीय मॉडल प्रबंधित करने के लिए Settings → मॉडल पर जाएं।" }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index 256146d366..bdd7de7128 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -4175,9 +4175,6 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Periksa konfigurasi penyedia Anda di Pengaturan → Penyedia" }, - "switchModelModal.checkingModel": { - "defaultMessage": "Memeriksa model…" - }, "switchModelModal.chooseModel": { "defaultMessage": "Pilih model:" }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index 22ee6c22a7..c19fa48e16 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -4175,9 +4175,6 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Controlla la configurazione del provider in Impostazioni → Provider" }, - "switchModelModal.checkingModel": { - "defaultMessage": "Verifica del modello…" - }, "switchModelModal.chooseModel": { "defaultMessage": "Scegli un modello:" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index 8793cab3bc..75863c6d3b 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -4223,9 +4223,6 @@ "switchModelModal.loadingModels": { "defaultMessage": "モデルを読み込み中…" }, - "switchModelModal.checkingModel": { - "defaultMessage": "モデルを確認中…" - }, "switchModelModal.localModelsDescription": { "defaultMessage": "ローカル推論を使用するには、先にモデルをコンピューターにダウンロードする必要があります。設定 → モデルでローカルモデルを管理できます。" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index 82d8af68d6..09aa513621 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -4223,9 +4223,6 @@ "switchModelModal.loadingModels": { "defaultMessage": "모델 로드 중…" }, - "switchModelModal.checkingModel": { - "defaultMessage": "모델 확인 중…" - }, "switchModelModal.localModelsDescription": { "defaultMessage": "로컬 추론을 사용하려면 먼저 모델을 컴퓨터에 다운로드해야 합니다. 로컬 모델을 관리하려면 설정 → 모델로 이동하세요." }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index 4c8586927a..68fedd6c24 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -4175,9 +4175,6 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Semak konfigurasi penyedia anda dalam Tetapan → Penyedia" }, - "switchModelModal.checkingModel": { - "defaultMessage": "Menyemak model…" - }, "switchModelModal.chooseModel": { "defaultMessage": "Pilih model:" }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index 609a5afdf0..b715b37b65 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -4175,9 +4175,6 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Verifique a configuração do seu fornecedor em Definições → Fornecedores" }, - "switchModelModal.checkingModel": { - "defaultMessage": "A verificar modelo…" - }, "switchModelModal.chooseModel": { "defaultMessage": "Escolha um modelo:" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 421ff67a8e..fb890d3153 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -4223,9 +4223,6 @@ "switchModelModal.loadingModels": { "defaultMessage": "Загрузка моделей…" }, - "switchModelModal.checkingModel": { - "defaultMessage": "Проверка модели…" - }, "switchModelModal.localModelsDescription": { "defaultMessage": "Чтобы использовать локальный инференс, сначала скачайте модель на компьютер. Перейдите в Настройки → Модели для управления локальными моделями." }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index 95629166b9..c69394cd8d 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -4223,9 +4223,6 @@ "switchModelModal.loadingModels": { "defaultMessage": "Modeller yükleniyor…" }, - "switchModelModal.checkingModel": { - "defaultMessage": "Model kontrol ediliyor…" - }, "switchModelModal.localModelsDescription": { "defaultMessage": "Yerel çıkarımı kullanmak için öncelikle bilgisayarınıza bir model indirmeniz gerekir. Yerel modelleri yönetmek için Ayarlar → Modeller'e gidin." }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index 7e3eea6f8c..8f94847441 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -4175,9 +4175,6 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "Kiểm tra cấu hình nhà cung cấp của bạn trong Cài đặt → Nhà cung cấp" }, - "switchModelModal.checkingModel": { - "defaultMessage": "Đang kiểm tra mô hình…" - }, "switchModelModal.chooseModel": { "defaultMessage": "Chọn một mô hình:" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index 4f3f88b020..ca094060af 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -4223,9 +4223,6 @@ "switchModelModal.loadingModels": { "defaultMessage": "正在加载模型…" }, - "switchModelModal.checkingModel": { - "defaultMessage": "正在检查模型…" - }, "switchModelModal.localModelsDescription": { "defaultMessage": "要使用本地推理,你需要先下载一个模型到电脑上。前往 设置 → 模型 管理本地模型。" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index e96cd7c792..002bae4570 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -4175,9 +4175,6 @@ "switchModelModal.checkProviderConfig": { "defaultMessage": "請在「設定」→「提供者」中檢查您的提供者設定" }, - "switchModelModal.checkingModel": { - "defaultMessage": "正在檢查模型…" - }, "switchModelModal.chooseModel": { "defaultMessage": "選擇模型:" }, From b83b194dad3307404ee1b87fc3e233176cd5ad28 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Thu, 2 Jul 2026 10:29:29 +1000 Subject: [PATCH 039/139] acp migration (ui) : ui connect to acp directly instead of goosed (#10081) --- .github/copilot-instructions.md | 1 - .github/workflows/bundle-desktop-intel.yml | 12 +- .github/workflows/bundle-desktop-linux.yml | 13 +- .github/workflows/bundle-desktop-windows.yml | 36 +- .github/workflows/bundle-desktop.yml | 14 +- .github/workflows/canary.yml | 8 +- .github/workflows/ci.yml | 6 - .github/workflows/pr-smoke-test.yml | 45 +- AGENTS.md | 11 +- CONTRIBUTING.md | 30 +- Justfile | 42 +- crates/goose-cli/src/cli.rs | 26 +- .../src/pages/deeplink-generator.tsx | 4 +- documentation/src/utils/install-links.ts | 4 +- scripts/build-windows.ps1 | 32 +- scripts/check-openapi-schema.sh | 28 - ui/desktop/openapi-ts.config.ts | 13 - ui/desktop/package.json | 25 +- ui/desktop/scripts/clean-vite-cache.js | 19 + .../scripts/prepare-platform-binaries.js | 18 +- ui/desktop/src/App.test.tsx | 9 - ui/desktop/src/acp/__tests__/url.test.ts | 133 + ui/desktop/src/acp/acpConnection.ts | 62 +- ui/desktop/src/acp/createWebSocketStream.ts | 12 +- ui/desktop/src/acp/url.ts | 87 + ui/desktop/src/api/client.gen.ts | 16 - ui/desktop/src/api/client/client.gen.ts | 288 -- ui/desktop/src/api/client/index.ts | 25 - ui/desktop/src/api/client/types.gen.ts | 214 - ui/desktop/src/api/client/utils.gen.ts | 316 -- ui/desktop/src/api/core/auth.gen.ts | 41 - ui/desktop/src/api/core/bodySerializer.gen.ts | 84 - ui/desktop/src/api/core/params.gen.ts | 169 - ui/desktop/src/api/core/pathSerializer.gen.ts | 171 - .../src/api/core/queryKeySerializer.gen.ts | 117 - .../src/api/core/serverSentEvents.gen.ts | 243 -- ui/desktop/src/api/core/types.gen.ts | 104 - ui/desktop/src/api/core/utils.gen.ts | 140 - ui/desktop/src/api/index.ts | 4 - ui/desktop/src/api/sdk.gen.ts | 466 -- ui/desktop/src/api/types.gen.ts | 3798 ----------------- ui/desktop/src/backendStatus.test.ts | 123 + ui/desktop/src/backendStatus.ts | 95 + .../src/components/McpApps/McpAppRenderer.tsx | 40 +- .../settings/app/ExternalBackendSection.tsx | 49 +- .../settings/extensions/deeplink.test.ts | 40 + .../settings/extensions/deeplink.ts | 2 +- ui/desktop/src/config.ts | 5 - ui/desktop/src/gooseServe.test.ts | 281 ++ ui/desktop/src/gooseServe.ts | 600 +++ .../src/gooseServeLeaseRegistry.test.ts | 127 + ui/desktop/src/gooseServeLeaseRegistry.ts | 164 + ui/desktop/src/goosed.ts | 450 -- ui/desktop/src/i18n/messages/de.json | 6 + ui/desktop/src/i18n/messages/en.json | 20 +- ui/desktop/src/i18n/messages/es.json | 6 + ui/desktop/src/i18n/messages/fr.json | 6 + ui/desktop/src/i18n/messages/hi.json | 6 + ui/desktop/src/i18n/messages/id.json | 6 + ui/desktop/src/i18n/messages/it.json | 6 + ui/desktop/src/i18n/messages/ja.json | 6 + ui/desktop/src/i18n/messages/ko.json | 6 + ui/desktop/src/i18n/messages/ms.json | 6 + ui/desktop/src/i18n/messages/pt.json | 6 + ui/desktop/src/i18n/messages/ru.json | 6 + ui/desktop/src/i18n/messages/tr.json | 6 + ui/desktop/src/i18n/messages/vi.json | 6 + ui/desktop/src/i18n/messages/zh-CN.json | 6 + ui/desktop/src/i18n/messages/zh-TW.json | 6 + ui/desktop/src/main.ts | 721 ++-- ui/desktop/src/preload.ts | 4 +- ui/desktop/src/renderer.tsx | 31 - ui/desktop/src/startupDiagnostics.test.ts | 91 + ui/desktop/src/startupDiagnostics.ts | 108 + ui/desktop/tests/integration/goosed.test.ts | 401 -- ui/desktop/tests/integration/setup.ts | 139 - ui/desktop/tests/integration/vitest.d.ts | 10 - ui/pnpm-lock.yaml | 52 - 78 files changed, 2630 insertions(+), 7899 deletions(-) delete mode 100755 scripts/check-openapi-schema.sh delete mode 100644 ui/desktop/openapi-ts.config.ts create mode 100644 ui/desktop/scripts/clean-vite-cache.js create mode 100644 ui/desktop/src/acp/__tests__/url.test.ts create mode 100644 ui/desktop/src/acp/url.ts delete mode 100644 ui/desktop/src/api/client.gen.ts delete mode 100644 ui/desktop/src/api/client/client.gen.ts delete mode 100644 ui/desktop/src/api/client/index.ts delete mode 100644 ui/desktop/src/api/client/types.gen.ts delete mode 100644 ui/desktop/src/api/client/utils.gen.ts delete mode 100644 ui/desktop/src/api/core/auth.gen.ts delete mode 100644 ui/desktop/src/api/core/bodySerializer.gen.ts delete mode 100644 ui/desktop/src/api/core/params.gen.ts delete mode 100644 ui/desktop/src/api/core/pathSerializer.gen.ts delete mode 100644 ui/desktop/src/api/core/queryKeySerializer.gen.ts delete mode 100644 ui/desktop/src/api/core/serverSentEvents.gen.ts delete mode 100644 ui/desktop/src/api/core/types.gen.ts delete mode 100644 ui/desktop/src/api/core/utils.gen.ts delete mode 100644 ui/desktop/src/api/index.ts delete mode 100644 ui/desktop/src/api/sdk.gen.ts delete mode 100644 ui/desktop/src/api/types.gen.ts create mode 100644 ui/desktop/src/backendStatus.test.ts create mode 100644 ui/desktop/src/backendStatus.ts delete mode 100644 ui/desktop/src/config.ts create mode 100644 ui/desktop/src/gooseServe.test.ts create mode 100644 ui/desktop/src/gooseServe.ts create mode 100644 ui/desktop/src/gooseServeLeaseRegistry.test.ts create mode 100644 ui/desktop/src/gooseServeLeaseRegistry.ts delete mode 100644 ui/desktop/src/goosed.ts create mode 100644 ui/desktop/src/startupDiagnostics.test.ts delete mode 100644 ui/desktop/tests/integration/goosed.test.ts delete mode 100644 ui/desktop/tests/integration/setup.ts delete mode 100644 ui/desktop/tests/integration/vitest.d.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 51d3018cbb..00ba2fc866 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -56,7 +56,6 @@ - `cargo fmt --check` - Code formatting (rustfmt) - `cargo test --jobs 2` - All tests - `cargo clippy --all-targets -- -D warnings` - Linting (clippy) -- `just check-openapi-schema` - OpenAPI schema validation **Desktop app checks:** - `pnpm install --frozen-lockfile` - Fresh dependency install (in `ui/desktop/`) diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index 3ac28f404d..c2a26bf352 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -73,11 +73,11 @@ jobs: key: intel-macos-deployment-target-12 - - name: Build goose-server for Intel macOS (x86_64) + - name: Build desktop backend for Intel macOS (x86_64) run: | source ./bin/activate-hermit rustup target add x86_64-apple-darwin - cargo build --release -p goose-server --target x86_64-apple-darwin + cargo build --release -p goose-cli --bin goose --target x86_64-apple-darwin @@ -95,9 +95,13 @@ jobs: # Check disk space after cleanup df -h - - name: Copy binaries into Electron folder + - name: Copy backend binary into Electron folder run: | - cp target/x86_64-apple-darwin/release/goosed ui/desktop/src/bin/goosed + mkdir -p ui/desktop/src/bin + rm -f ui/desktop/src/bin/goose + cp target/x86_64-apple-darwin/release/goose ui/desktop/src/bin/goose + chmod +x ui/desktop/src/bin/goose + ls -la ui/desktop/src/bin/ - name: Cache pnpm dependencies uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml index 18ba931a6c..ad3b84f194 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -124,7 +124,7 @@ jobs: with: key: linux-${{ matrix.build-on }}-${{ matrix.variant }} - - name: Build goosed binary + - name: Build desktop backend binary env: RUST_LOG: debug RUST_BACKTRACE: 1 @@ -137,15 +137,16 @@ jobs: FEATURE_ARGS=(--features vulkan) fi - cargo build --release --target ${TARGET} -p goose-server "${FEATURE_ARGS[@]}" + cargo build --release --target ${TARGET} -p goose-cli --bin goose "${FEATURE_ARGS[@]}" - - name: Copy binaries into Electron folder + - name: Copy backend binary into Electron folder run: | - echo "Copying binaries to ui/desktop/src/bin/" + echo "Copying backend binary to ui/desktop/src/bin/" export TARGET="x86_64-unknown-linux-gnu" mkdir -p ui/desktop/src/bin - cp target/$TARGET/release/goosed ui/desktop/src/bin/ - chmod +x ui/desktop/src/bin/goosed + rm -f ui/desktop/src/bin/goose + cp target/$TARGET/release/goose ui/desktop/src/bin/ + chmod +x ui/desktop/src/bin/goose ls -la ui/desktop/src/bin/ - name: Free Rust build artifacts before packaging diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index 7990a39d32..6ceb29b82f 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -113,28 +113,33 @@ jobs: env: CUDA_COMPUTE_CAP: ${{ inputs.windows_variant == 'cuda' && '80' || '' }} run: | - Write-Output "Building Windows executable..." - if ("${{ inputs.windows_variant }}" -eq "cuda") { - cargo build --release --target x86_64-pc-windows-msvc -p goose-server --features cuda + $isCuda = "${{ inputs.windows_variant }}" -eq "cuda" + + Write-Output "Building Windows ACP backend" + if ($isCuda) { + cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose --features cuda } else { - cargo build --release --target x86_64-pc-windows-msvc -p goose-server + cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose } + $binaryPath = "./target/x86_64-pc-windows-msvc/release/goose.exe" # Verify build succeeded - if (-not (Test-Path "./target/x86_64-pc-windows-msvc/release/goosed.exe")) { - Write-Error "Windows binary not found." + if (-not (Test-Path $binaryPath)) { + Write-Error "Windows backend binary not found: $binaryPath" Get-ChildItem ./target/x86_64-pc-windows-msvc/release/ -ErrorAction SilentlyContinue exit 1 } - Write-Output "Windows binary found." - Get-Item ./target/x86_64-pc-windows-msvc/release/goosed.exe + Write-Output "Windows backend binary found." + Get-Item $binaryPath - name: Prepare Windows binary shell: bash run: | - if [ ! -f "./target/x86_64-pc-windows-msvc/release/goosed.exe" ]; then - echo "Windows binary not found." + BACKEND_BINARY="./target/x86_64-pc-windows-msvc/release/goose.exe" + + if [ ! -f "$BACKEND_BINARY" ]; then + echo "Windows backend binary not found: $BACKEND_BINARY" exit 1 fi @@ -142,13 +147,14 @@ jobs: rm -rf ./ui/desktop/src/bin mkdir -p ./ui/desktop/src/bin - echo "Copying Windows binary..." - cp -f ./target/x86_64-pc-windows-msvc/release/goosed.exe ./ui/desktop/src/bin/ + echo "Copying Windows backend binary..." + cp -f "$BACKEND_BINARY" ./ui/desktop/src/bin/ if [ -d "./ui/desktop/src/platform/windows/bin" ]; then echo "Copying Windows platform files..." for file in ./ui/desktop/src/platform/windows/bin/*.{exe,dll,cmd}; do - if [ -f "$file" ] && [ "$(basename "$file")" != "goosed.exe" ]; then + filename="$(basename "$file")" + if [ -f "$file" ] && [ "$filename" != "goose.exe" ]; then cp -f "$file" ./ui/desktop/src/bin/ fi done @@ -229,14 +235,14 @@ jobs: certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }} files: | ${{ github.workspace }}/dist-windows/Goose.exe - ${{ github.workspace }}/dist-windows/resources/bin/goosed.exe + ${{ github.workspace }}/dist-windows/resources/bin/goose.exe - name: Verify signed executables shell: pwsh run: | $files = @( "dist-windows/Goose.exe", - "dist-windows/resources/bin/goosed.exe" + "dist-windows/resources/bin/goose.exe" ) foreach ($file in $files) { Write-Output "Verifying signature: $file" diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index f892a8bb1e..f431a34bc7 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -118,8 +118,10 @@ jobs: key: macos-deployment-target-12 # Build the project - - name: Build goosed - run: source ./bin/activate-hermit && cargo build --release -p goose-server + - name: Build desktop backend + run: | + source ./bin/activate-hermit + cargo build --release -p goose-cli --bin goose # Post-build cleanup to free space - name: Post-build cleanup @@ -134,9 +136,13 @@ jobs: # Check disk space after cleanup df -h - - name: Copy binaries into Electron folder + - name: Copy backend binary into Electron folder run: | - cp target/release/goosed ui/desktop/src/bin/goosed + mkdir -p ui/desktop/src/bin + rm -f ui/desktop/src/bin/goose + cp target/release/goose ui/desktop/src/bin/goose + chmod +x ui/desktop/src/bin/goose + ls -la ui/desktop/src/bin/ - name: Cache pnpm dependencies uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index a0273f286e..f93599880c 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -67,7 +67,7 @@ jobs: path: download_cli.sh # ------------------------------------------------------------ - # 4) Bundle Desktop App (macOS only) - builds goosed and Electron app + # 4) Bundle Desktop App (macOS only) # ------------------------------------------------------------ bundle-desktop: needs: [prepare-version] @@ -80,7 +80,7 @@ jobs: signing: false # ------------------------------------------------------------ - # 5) Bundle Desktop App (macOS Intel) - builds goosed and Electron app + # 5) Bundle Desktop App (macOS Intel) # ------------------------------------------------------------ bundle-desktop-intel: needs: [prepare-version] @@ -93,7 +93,7 @@ jobs: signing: false # ------------------------------------------------------------ - # 6) Bundle Desktop App (Linux) - builds goosed and Electron app + # 6) Bundle Desktop App (Linux) # ------------------------------------------------------------ bundle-desktop-linux: needs: [prepare-version] @@ -102,7 +102,7 @@ jobs: version: ${{ needs.prepare-version.outputs.version }} # ------------------------------------------------------------ - # 6) Bundle Desktop App (Windows) - builds goosed and Electron app + # 6) Bundle Desktop App (Windows) # ------------------------------------------------------------ bundle-desktop-windows: needs: [prepare-version] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb062d22dd..1216340871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,12 +183,6 @@ jobs: cd ui/desktop && pnpm install --frozen-lockfile cd ../sdk && pnpm install --frozen-lockfile - - name: Check OpenAPI Schema is Up-to-Date - run: | - source ./bin/activate-hermit - hermit uninstall rustup - just check-openapi-schema - - name: Check ACP Schema is Up-to-Date run: | source ./bin/activate-hermit diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index a0f2634753..ae83ad2afe 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -67,7 +67,7 @@ jobs: - name: Build Binary for Smoke Tests run: | - cargo build --bin goose --bin goosed + cargo build --bin goose - name: Upload goose binary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -76,13 +76,6 @@ jobs: path: target/debug/goose retention-days: 1 - - name: Upload goosed binary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: goosed-binary - path: target/debug/goosed - retention-days: 1 - smoke-tests: name: Smoke Tests runs-on: ubuntu-latest @@ -253,39 +246,3 @@ jobs: mkdir -p $HOME/.local/share/goose/sessions mkdir -p $HOME/.config/goose bash scripts/test_compaction.sh - - goosed-integration-tests: - name: goose server HTTP integration tests - runs-on: ubuntu-latest - needs: build-binary - steps: - - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.inputs.branch || github.ref }} - - - name: Download Binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: goosed-binary - path: target/debug - - - name: Make Binary Executable - run: chmod +x target/debug/goosed - - - name: Install Node.js Dependencies - run: source ../../bin/activate-hermit && pnpm install --frozen-lockfile - working-directory: ui/desktop - - - name: Run Integration Tests - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GOOSED_BINARY: ../../target/debug/goosed - GOOSE_PROVIDER: anthropic - GOOSE_MODEL: claude-sonnet-4-5-20250929 - SHELL: /bin/bash - SKIP_BUILD: 1 - run: | - echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bash_profile - source ../../bin/activate-hermit && pnpm run test:integration:goosed - working-directory: ui/desktop diff --git a/AGENTS.md b/AGENTS.md index e0fda6d5e0..e3ab069bd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ cargo build ```bash cargo build # debug cargo build --release # release -just release-binary # release + openapi +just release-binary # release binary ``` ### Test @@ -33,8 +33,8 @@ cargo clippy --all-targets -- -D warnings ### UI ```bash -just generate-openapi # after server changes just run-ui # start desktop +cd ui/desktop && pnpm run typecheck cd ui/desktop && pnpm test # test UI ``` @@ -44,7 +44,6 @@ crates/ ├── goose # core logic ├── goose-acp-macros # ACP proc macros ├── goose-cli # CLI entry -├── goose-server # backend (binary: goosed) ├── goose-mcp # MCP extensions ├── goose-test # test utilities └── goose-test-support # test helpers @@ -65,7 +64,6 @@ ui/desktop/ # Electron app # 1. cargo build # 2. cargo test -p # 3. cargo clippy --all-targets -- -D warnings -# 4. [if server] just generate-openapi ``` ## Rules @@ -75,7 +73,7 @@ ui/desktop/ # Electron app - Error: Use anyhow::Result - Provider: Implement Provider trait see providers/base.rs - MCP: Extensions in crates/goose-mcp/ -- Server: Changes need just generate-openapi +- UI Desktop: Use ACP SDK types or local `src/types/*` types. Do not import generated OpenAPI types/client code from `ui/desktop/src/api` ## Code Quality @@ -107,7 +105,7 @@ remaining space for dynamic text. ## Never -- Never: Edit ui/desktop/openapi.json manually +- Never: Recreate `ui/desktop/src/api` or add `@hey-api/openapi-ts` to `ui/desktop` - Cargo.toml: For human-authored dependency changes, use `cargo add` instead of manually editing dependency entries unless there is a specific reason not to. - Cargo.toml: Automated dependency bump PRs are exempt; when manual edits are necessary, keep `Cargo.lock` consistent. - Never: Skip cargo fmt @@ -116,6 +114,5 @@ remaining space for dynamic text. ## Entry Points - CLI: crates/goose-cli/src/main.rs -- Server: crates/goose-server/src/main.rs - UI: ui/desktop/src/main.ts - Agent: crates/goose/src/agents/agent.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49e3dcd56e..66b96b14ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -184,41 +184,27 @@ cd ui && pnpm install See #8757. -### Regenerating the OpenAPI schema - -The file `ui/desktop/openapi.json` is automatically generated during the build. -It is written by the `generate_schema` binary in `crates/goose-server`. -To update the spec without starting the UI, run: - -``` -just generate-openapi -``` - -This command regenerates `ui/desktop/openapi.json` and then runs the UI's -`generate-api` script to rebuild the TypeScript client from that spec. - -API changes should be made in the Rust source under `crates/goose-server/src/`. - ### Debugging -To debug the Goose server, run it from an IDE. The configuration will depend on the IDE. The command to run is: +To debug the external ACP backend, run it from an IDE. The configuration will depend on the IDE. The command to run is: ``` export GOOSE_SERVER__SECRET_KEY=test -cargo run --package goose-server --bin goosed -- agent # or: `just run-server` +cargo run --package goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000 ``` -The server listens on port `3000` by default; this can be changed by setting the -`GOOSE_PORT` environment variable. +The `debug-ui` recipe connects to `http://127.0.0.1:3000` by default. If the +backend uses another port, set `GOOSE_PORT` when starting the UI, or set +`GOOSE_EXTERNAL_BACKEND_URL` to the backend's HTTP base URL. -Once the server is running, start a UI and connect it to the server by running: +Once the backend is running, start a UI and connect it to the backend by running: ``` just debug-ui ``` -The UI connects to the server started in the IDE, allowing breakpoints -and stepping through the server code while interacting with the UI. +The UI connects to the backend started in the IDE, allowing breakpoints +and stepping through the backend code while interacting with the UI. ## Creating a fork diff --git a/Justfile b/Justfile index 56f0fe3302..c08148357f 100644 --- a/Justfile +++ b/Justfile @@ -13,15 +13,13 @@ check-everything: cargo clippy --all-targets -- -D warnings @echo " → Checking UI code formatting..." cd ui/desktop && pnpm run lint:check - @echo " → Validating OpenAPI schema..." - ./scripts/check-openapi-schema.sh @echo "" @echo "✅ All style checks passed!" # Default release command release-binary: @echo "Building release version..." - cargo build --release + cargo build --release -p goose-cli --bin goose @just copy-binary @echo "Generating OpenAPI schema..." cargo run -p goose-server --bin generate_schema @@ -34,7 +32,7 @@ release-windows: [windows] release-windows: - @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'rustup target add x86_64-pc-windows-msvc; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; cargo build --release --target x86_64-pc-windows-msvc -p goose-server; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goosed.exe"' + @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'rustup target add x86_64-pc-windows-msvc; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goose.exe"' # Build for Intel Mac release-intel: @@ -43,14 +41,7 @@ release-intel: @just copy-binary-intel copy-binary BUILD_MODE="release": - @if [ -f ./target/{{BUILD_MODE}}/goosed ]; then \ - echo "Copying goosed binary from target/{{BUILD_MODE}}..."; \ - rm -f ./ui/desktop/src/bin/goosed; \ - cp -p ./target/{{BUILD_MODE}}/goosed ./ui/desktop/src/bin/; \ - else \ - echo "Binary not found in target/{{BUILD_MODE}}"; \ - exit 1; \ - fi + @rm -f ./ui/desktop/src/bin/goosed @if [ -f ./target/{{BUILD_MODE}}/goose ]; then \ echo "Copying goose CLI binary from target/{{BUILD_MODE}}..."; \ rm -f ./ui/desktop/src/bin/goose; \ @@ -62,14 +53,7 @@ copy-binary BUILD_MODE="release": # Copy binary command for Intel build copy-binary-intel: - @if [ -f ./target/x86_64-apple-darwin/release/goosed ]; then \ - echo "Copying Intel goosed binary to ui/desktop/src/bin with permissions preserved..."; \ - rm -f ./ui/desktop/src/bin/goosed; \ - cp -p ./target/x86_64-apple-darwin/release/goosed ./ui/desktop/src/bin/; \ - else \ - echo "Intel release binary not found."; \ - exit 1; \ - fi + @rm -f ./ui/desktop/src/bin/goosed @if [ -f ./target/x86_64-apple-darwin/release/goose ]; then \ echo "Copying Intel goose CLI binary to ui/desktop/src/bin..."; \ rm -f ./ui/desktop/src/bin/goose; \ @@ -87,10 +71,11 @@ copy-binary-windows: [windows] copy-binary-windows: - @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goosed.exe) { \ + @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goose.exe) { \ Write-Host "Copying Windows binary to ui/desktop/src/bin..."; \ New-Item -ItemType Directory -Force "./ui/desktop/src/bin" | Out-Null; \ - Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goosed.exe" -Destination "./ui/desktop/src/bin/" -Force; \ + Remove-Item -Path "./ui/desktop/src/bin/goosed.exe" -Force -ErrorAction SilentlyContinue; \ + Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goose.exe" -Destination "./ui/desktop/src/bin/" -Force; \ } else { \ Write-Host "Windows binary not found." -ForegroundColor Red; \ exit 1; \ @@ -116,7 +101,7 @@ run-ui-only: cd ui/desktop && pnpm install && pnpm run start-gui debug-ui: - @echo "🚀 Starting goose frontend in external backend mode" + @echo "🚀 Starting goose frontend in external ACP backend mode" cd ui/desktop && \ export GOOSE_EXTERNAL_BACKEND=true && \ export GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" && \ @@ -161,19 +146,13 @@ run-docs: # Run server run-server: - @echo "Running server..." - cargo run -p goose-server --bin goosed agent - -# Check if OpenAPI schema is up-to-date -check-openapi-schema: generate-openapi - ./scripts/check-openapi-schema.sh + @echo "Running external ACP backend..." + GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" cargo run -p goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000 # Generate OpenAPI specification without starting the UI generate-openapi: @echo "Generating OpenAPI schema..." cargo run -p goose-server --bin generate_schema - @echo "Generating frontend API..." - cd ui/desktop && npx @hey-api/openapi-ts # Check if generated ACP schema and TypeScript types are up-to-date check-acp-schema: generate-acp-types @@ -404,6 +383,7 @@ win-app-deps: win-copy-win profile: copy target{{s}}{{profile}}{{s}}*.exe ui{{s}}desktop{{s}}src{{s}}bin copy target{{s}}{{profile}}{{s}}*.dll ui{{s}}desktop{{s}}src{{s}}bin + if exist ui{{s}}desktop{{s}}src{{s}}bin{{s}}goosed.exe del /f /q ui{{s}}desktop{{s}}src{{s}}bin{{s}}goosed.exe ### "Other" copy {release|debug} files to ui/desktop/src/bin ### s = os dependent file separator diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index e586de9efe..20eab63033 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -51,6 +51,22 @@ fn generate_serve_secret_key() -> String { ) } +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ServePlatform { + #[default] + Cli, + Desktop, +} + +impl From for GoosePlatform { + fn from(platform: ServePlatform) -> Self { + match platform { + ServePlatform::Cli => GoosePlatform::GooseCli, + ServePlatform::Desktop => GoosePlatform::GooseDesktop, + } + } +} + #[derive(Parser)] #[command(name = "goose", author, version, display_name = "", about, long_about = None)] pub struct Cli { @@ -840,6 +856,9 @@ enum Command { #[arg(long = "tls-key-path", value_name = "PATH")] tls_key_path: Option, + #[arg(long, value_enum, default_value_t = ServePlatform::Cli)] + platform: ServePlatform, + #[arg( long = "with-builtin", value_name = "NAME", @@ -1358,10 +1377,12 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> { struct ServeCommandArgs { host: String, + port: u16, tls: bool, tls_cert_path: Option, tls_key_path: Option, + platform: ServePlatform, builtins: Vec, dangerously_unauthenticated: bool, allowed_origins: Vec, @@ -1382,6 +1403,7 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { tls, tls_cert_path, tls_key_path, + platform, builtins, dangerously_unauthenticated, allowed_origins, @@ -1409,7 +1431,7 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { builtins, data_dir: Paths::data_dir(), config_dir: Paths::config_dir(), - goose_platform: GoosePlatform::GooseCli, + goose_platform: platform.into(), additional_source_roots, scheduler: None, })); @@ -2210,6 +2232,7 @@ pub async fn cli() -> anyhow::Result<()> { tls, tls_cert_path, tls_key_path, + platform, builtins, dangerously_unauthenticated, allowed_origins, @@ -2220,6 +2243,7 @@ pub async fn cli() -> anyhow::Result<()> { tls, tls_cert_path, tls_key_path, + platform, builtins, dangerously_unauthenticated, allowed_origins, diff --git a/documentation/src/pages/deeplink-generator.tsx b/documentation/src/pages/deeplink-generator.tsx index e1ab46beb3..350fe5d50c 100644 --- a/documentation/src/pages/deeplink-generator.tsx +++ b/documentation/src/pages/deeplink-generator.tsx @@ -68,7 +68,7 @@ export default function DeeplinkGenerator() { const urlParams = new URLSearchParams(window.location.search); if (urlParams.toString()) { try { - if (urlParams.get('cmd') === 'goosed' && urlParams.getAll('arg').includes('mcp')) { + if (urlParams.get('cmd') === 'goose' && urlParams.getAll('arg').includes('mcp')) { const args = urlParams.getAll('arg'); const extensionId = args[args.indexOf('mcp') + 1]; if (!extensionId) { @@ -187,7 +187,7 @@ export default function DeeplinkGenerator() { const generateDeeplink = (server: ServerConfig): string => { if (server.is_builtin) { const queryParams = [ - 'cmd=goosed', + 'cmd=goose', 'arg=mcp', `arg=${encodeURIComponent(server.id)}`, `description=${encodeURIComponent(server.id)}` diff --git a/documentation/src/utils/install-links.ts b/documentation/src/utils/install-links.ts index a704677485..73a235e029 100644 --- a/documentation/src/utils/install-links.ts +++ b/documentation/src/utils/install-links.ts @@ -3,7 +3,7 @@ import type { MCPServer } from "../types/server"; export function getGooseInstallLink(server: MCPServer): string { if (server.is_builtin) { const queryParams = [ - 'cmd=goosed', + 'cmd=goose', 'arg=mcp', `arg=${encodeURIComponent(server.id)}`, `description=${encodeURIComponent(server.id)}` @@ -53,4 +53,4 @@ export function getGooseInstallLink(server: MCPServer): string { ].join("&"); return `goose://extension?${queryParams}`; -} \ No newline at end of file +} diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 4807e53e72..25438ae555 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -42,7 +42,7 @@ Write-Host "" # Step 1: Clone or update repo Write-Host "[2/7] Building Rust backend (release)..." -ForegroundColor Yellow Write-Host " This may take 5-15 minutes on first build..." -cargo build --release -p goose-server +cargo build --release -p goose-cli --bin goose if ($LASTEXITCODE -ne 0) { Write-Host "Rust build failed!" -ForegroundColor Red exit 1 @@ -55,10 +55,12 @@ Write-Host "[3/7] Copying binaries to desktop app..." -ForegroundColor Yellow $binDir = "ui\desktop\src\bin" if (-not (Test-Path $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null } -Copy-Item "target\release\goosed.exe" "$binDir\" -Force -if (Test-Path "target\release\goose.exe") { - Copy-Item "target\release\goose.exe" "$binDir\" -Force +$gooseBinary = "target\release\goose.exe" +if (-not (Test-Path $gooseBinary)) { + Write-Host "Backend binary not found: $gooseBinary" -ForegroundColor Red + exit 1 } +Copy-Item $gooseBinary "$binDir\" -Force # Copy required DLLs if they exist (from cross-compilation) Get-ChildItem "target\release\*.dll" -ErrorAction SilentlyContinue | ForEach-Object { Copy-Item $_.FullName "$binDir\" -Force @@ -78,20 +80,26 @@ if ($LASTEXITCODE -ne 0) { Write-Host " Dependencies installed." -ForegroundColor Green Write-Host "" -# Step 4: Generate API types -Write-Host "[5/7] Generating API types..." -ForegroundColor Yellow -pnpm run generate-api +# Step 4: Build desktop assets +Write-Host "[5/7] Building Goose SDK, clearing Vite cache, and compiling i18n messages..." -ForegroundColor Yellow +pnpm run build-goose-sdk if ($LASTEXITCODE -ne 0) { - Write-Host "API type generation failed!" -ForegroundColor Red + Write-Host "Goose SDK build or Vite cache cleanup failed!" -ForegroundColor Red Pop-Location exit 1 } -Write-Host " API types generated." -ForegroundColor Green +pnpm run i18n:compile +if ($LASTEXITCODE -ne 0) { + Write-Host "i18n compilation failed!" -ForegroundColor Red + Pop-Location + exit 1 +} +Write-Host " Desktop assets built." -ForegroundColor Green Write-Host "" # Step 5: Package Write-Host "[6/7] Packaging Goose Desktop..." -ForegroundColor Yellow -npx electron-forge package +pnpm exec electron-forge package if ($LASTEXITCODE -ne 0) { Write-Host "Packaging failed!" -ForegroundColor Red Pop-Location @@ -102,10 +110,10 @@ Write-Host "" # Step 6: Make installer Write-Host "[7/7] Creating Windows installer..." -ForegroundColor Yellow -npx electron-forge make +pnpm exec electron-forge make if ($LASTEXITCODE -ne 0) { Write-Host "Make failed! Trying with squirrel only..." -ForegroundColor Yellow - npx electron-forge make --targets=@electron-forge/maker-squirrel + pnpm exec electron-forge make --targets=@electron-forge/maker-squirrel if ($LASTEXITCODE -ne 0) { Write-Host "Fallback installer build also failed!" -ForegroundColor Red Pop-Location diff --git a/scripts/check-openapi-schema.sh b/scripts/check-openapi-schema.sh deleted file mode 100755 index d45f733f25..0000000000 --- a/scripts/check-openapi-schema.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -e - -# Check if OpenAPI schema is up-to-date -# This script generates the OpenAPI schema and compares it with the committed version - -echo "🔍 Checking OpenAPI schema is up-to-date..." - -# Check if the generated schema differs from the committed version -echo "🔍 Comparing generated schema with committed version..." -if ! git diff --ignore-space-change --exit-code ui/desktop/openapi.json ui/desktop/src/api/; then - echo "" - echo "❌ OpenAPI schema is out of date!" - echo "" - echo "The generated OpenAPI schema differs from the committed version." - echo "This usually means that API types were added or modified without updating the schema." - echo "" - echo "To fix this issue:" - echo "1. Run 'just generate-openapi' locally" - echo "2. Commit the changes to ui/desktop/openapi.json and ui/desktop/src/api/" - echo "3. Push your changes" - echo "" - echo "Changes detected:" - git diff ui/desktop/openapi.json ui/desktop/src/api/ - exit 1 -fi - -echo "✅ OpenAPI schema is up-to-date" diff --git a/ui/desktop/openapi-ts.config.ts b/ui/desktop/openapi-ts.config.ts deleted file mode 100644 index 992c5a4a0f..0000000000 --- a/ui/desktop/openapi-ts.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from '@hey-api/openapi-ts'; - -export default defineConfig({ - input: './openapi.json', - output: './src/api', - plugins: [ - { - name: '@hey-api/client-fetch', - // Disable SSE support to avoid requiring SSE options on all requests - sse: false, - }, - ], -}); diff --git a/ui/desktop/package.json b/ui/desktop/package.json index f5ed7ebc01..90940947ce 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -9,24 +9,25 @@ }, "main": ".vite/build/main.js", "scripts": { - "postinstall": "pnpm --filter @aaif/goose-sdk run build", + "postinstall": "pnpm run build-goose-sdk", "typecheck": "tsc --noEmit", - "generate-api": "openapi-ts", - "start-gui": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start", - "start-gui-debug": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start -- --inspect=9229", + "build-goose-sdk": "pnpm --filter @aaif/goose-sdk run build && pnpm run clean-vite-cache", + "clean-vite-cache": "node scripts/clean-vite-cache.js", + "start-gui": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge start", + "start-gui-debug": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge start -- --inspect=9229", "start": "cd ../.. && just run-ui", "start:test-error": "GOOSE_TEST_ERROR=true electron-forge start", - "package": "pnpm run i18n:compile && electron-forge package", - "make": "pnpm run i18n:compile && electron-forge make", + "package": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge package", + "make": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge make", "bundle:default": "node scripts/prepare-platform-binaries.js && pnpm run make && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-arm64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}.zip\")", "bundle:intel": "node scripts/prepare-platform-binaries.js && pnpm run make --arch=x64 && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-x64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}_intel_mac.zip\")", "debug": "echo 'run --remote-debugging-port=8315' && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && lldb \"out/${BUNDLE_NAME}-darwin-arm64/${BUNDLE_NAME}.app\"", - "test-e2e": "pnpm run generate-api && playwright test", - "test-e2e:dev": "pnpm run generate-api && playwright test --reporter=list --retries=0 --max-failures=1", - "test-e2e:ui": "pnpm run generate-api && playwright test --ui", - "test-e2e:debug": "pnpm run generate-api && playwright test --debug", + "test-e2e": "playwright test", + "test-e2e:dev": "playwright test --reporter=list --retries=0 --max-failures=1", + "test-e2e:ui": "playwright test --ui", + "test-e2e:debug": "playwright test --debug", "test-e2e:report": "playwright show-report", - "test-e2e:single": "pnpm run generate-api && playwright test -g", + "test-e2e:single": "playwright test -g", "lint": "eslint \"src/**/*.{ts,tsx}\" --fix --no-warn-ignored", "lint:check": "pnpm run typecheck && eslint \"src/**/*.{ts,tsx}\" --max-warnings 0 --no-warn-ignored && pnpm run i18n:check", "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", @@ -36,7 +37,6 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", - "test:integration:goosed": "vitest run --config vitest.integration.config.ts tests/integration/goosed.test.ts", "test:integration:providers": "vitest run --config vitest.integration.config.ts tests/integration/test_providers.test.ts", "test:integration:providers-code-exec": "vitest run --config vitest.integration.config.ts tests/integration/test_providers_code_exec.test.ts", "test:integration:watch": "vitest --config vitest.integration.config.ts", @@ -121,7 +121,6 @@ "@eslint/js": "^9.39.2", "@formatjs/cli": "^6.14.0", "@formatjs/icu-messageformat-parser": "3.5.3", - "@hey-api/openapi-ts": "^0.93.0", "@modelcontextprotocol/sdk": "^1.27.0", "@playwright/test": "^1.58.2", "@tailwindcss/line-clamp": "^0.4.4", diff --git a/ui/desktop/scripts/clean-vite-cache.js b/ui/desktop/scripts/clean-vite-cache.js new file mode 100644 index 0000000000..a0438396e4 --- /dev/null +++ b/ui/desktop/scripts/clean-vite-cache.js @@ -0,0 +1,19 @@ +const fs = require('fs'); +const path = require('path'); + +const desktopRoot = path.resolve(__dirname, '..'); + +const pathsToRemove = [ + path.join(desktopRoot, 'node_modules', '.vite'), + path.join(desktopRoot, 'node_modules', '.vite-temp'), + path.join(desktopRoot, '.vite'), +]; + +for (const targetPath of pathsToRemove) { + if (!fs.existsSync(targetPath)) { + continue; + } + + fs.rmSync(targetPath, { recursive: true, force: true }); + console.log(`Removed ${path.relative(desktopRoot, targetPath)}`); +} diff --git a/ui/desktop/scripts/prepare-platform-binaries.js b/ui/desktop/scripts/prepare-platform-binaries.js index 9679d908b7..5f698bcc96 100644 --- a/ui/desktop/scripts/prepare-platform-binaries.js +++ b/ui/desktop/scripts/prepare-platform-binaries.js @@ -23,17 +23,6 @@ const windowsFiles = [ 'goose-npm/**/*' ]; -const macosFiles = [ - 'goosed', - 'goose', - 'jbang', - 'npx', - 'uvx', - '*.db', - '*.log', - '.gitkeep' -]; - // Helper function to check if file matches patterns function matchesPattern(filename, patterns) { return patterns.some(pattern => { @@ -174,9 +163,10 @@ function cleanBinDirectory(targetPlatform) { const filePath = path.join(srcBinDir, file.name); if (targetPlatform === 'darwin' || targetPlatform === 'linux') { - // For macOS/Linux, remove Windows-specific files - if (matchesPattern(file.name, windowsFiles)) { - console.log(`Removing Windows file: ${file.name}`); + const isLegacyBackendBinary = file.name === 'goosed'; + if (isLegacyBackendBinary || matchesPattern(file.name, windowsFiles)) { + const fileType = isLegacyBackendBinary ? 'legacy backend binary' : 'Windows file'; + console.log(`Removing ${fileType}: ${file.name}`); if (file.isDirectory()) { fs.rmSync(filePath, { recursive: true, force: true }); } else { diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index d820ac62db..2da21b72b4 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -34,15 +34,6 @@ vi.mock('./utils/costDatabase', () => ({ initializeCostDatabase: vi.fn().mockResolvedValue(undefined), })); -vi.mock('./api', () => { - return { - initConfig: vi.fn().mockResolvedValue(undefined), - backupConfig: vi.fn().mockResolvedValue(undefined), - recoverConfig: vi.fn().mockResolvedValue(undefined), - validateConfig: vi.fn().mockResolvedValue(undefined), - }; -}); - vi.mock('./sessions', () => ({ fetchSessionDetails: vi .fn() diff --git a/ui/desktop/src/acp/__tests__/url.test.ts b/ui/desktop/src/acp/__tests__/url.test.ts new file mode 100644 index 0000000000..b131401afd --- /dev/null +++ b/ui/desktop/src/acp/__tests__/url.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { + acpHttpUrlFromHttpBase, + acpWebSocketUrlFromHttpBase, + httpBaseFromAcpWebSocketUrl, + isLoopbackAcpWebSocketUrl, + normalizeAcpHttpBaseUrl, + statusHttpUrlFromHttpBase, +} from '../url'; + +describe('httpBaseFromAcpWebSocketUrl', () => { + it('converts ws ACP URLs to HTTP bases', () => { + expect(httpBaseFromAcpWebSocketUrl('ws://127.0.0.1:64027/acp?token=secret')).toBe( + 'http://127.0.0.1:64027' + ); + }); + + it('converts wss ACP URLs to HTTPS bases', () => { + expect(httpBaseFromAcpWebSocketUrl('wss://example.com/acp?token=secret')).toBe( + 'https://example.com' + ); + }); + + it('preserves path prefixes before the ACP endpoint', () => { + expect(httpBaseFromAcpWebSocketUrl('wss://example.com/goose/acp?token=secret')).toBe( + 'https://example.com/goose' + ); + }); + + it('rejects non-WebSocket URLs', () => { + expect(() => httpBaseFromAcpWebSocketUrl('http://127.0.0.1:64027/acp')).toThrow( + 'ACP URL must use ws: or wss:' + ); + }); +}); + +describe('isLoopbackAcpWebSocketUrl', () => { + it('accepts IPv4 loopback ACP URLs', () => { + expect(isLoopbackAcpWebSocketUrl('ws://127.0.0.1:64027/acp?token=secret')).toBe(true); + expect(isLoopbackAcpWebSocketUrl('wss://127.12.0.1:64027/acp?token=secret')).toBe(true); + }); + + it('accepts localhost ACP URLs', () => { + expect(isLoopbackAcpWebSocketUrl('ws://localhost:64027/acp?token=secret')).toBe(true); + }); + + it('accepts IPv6 loopback ACP URLs', () => { + expect(isLoopbackAcpWebSocketUrl('ws://[::1]:64027/acp?token=secret')).toBe(true); + }); + + it('rejects remote ACP URLs', () => { + expect(isLoopbackAcpWebSocketUrl('wss://example.com/acp?token=secret')).toBe(false); + expect(isLoopbackAcpWebSocketUrl('ws://192.168.1.10:3284/acp?token=secret')).toBe(false); + }); + + it('rejects DNS hostnames that start with 127', () => { + expect(isLoopbackAcpWebSocketUrl('wss://127.evil.com/acp?token=secret')).toBe(false); + expect(isLoopbackAcpWebSocketUrl('wss://127.0.0.1.example.com/acp?token=secret')).toBe(false); + }); + + it('rejects non-WebSocket URLs', () => { + expect(() => isLoopbackAcpWebSocketUrl('http://127.0.0.1:64027/acp')).toThrow( + 'ACP URL must use ws: or wss:' + ); + }); +}); + +describe('normalizeAcpHttpBaseUrl', () => { + it('normalizes root HTTPS base URLs', () => { + expect(normalizeAcpHttpBaseUrl('https://example.com/')).toBe('https://example.com'); + }); + + it('normalizes prefixed HTTPS base URLs', () => { + expect(normalizeAcpHttpBaseUrl('https://example.com/goose/')).toBe('https://example.com/goose'); + }); + + it('rejects WebSocket URLs', () => { + expect(() => normalizeAcpHttpBaseUrl('wss://example.com/acp')).toThrow( + 'External ACP backend URL must use http: or https:' + ); + }); + + it('rejects direct ACP endpoint URLs', () => { + expect(() => normalizeAcpHttpBaseUrl('https://example.com/acp')).toThrow( + 'External ACP backend URL must be the base URL before /acp' + ); + }); + + it('rejects query parameters and fragments', () => { + expect(() => normalizeAcpHttpBaseUrl('https://example.com?token=secret')).toThrow( + 'External ACP backend URL must not include query parameters or fragments' + ); + expect(() => normalizeAcpHttpBaseUrl('https://example.com#section')).toThrow( + 'External ACP backend URL must not include query parameters or fragments' + ); + }); +}); + +describe('HTTP endpoint URLs from ACP HTTP base URLs', () => { + it('builds status URLs from root and prefixed bases', () => { + expect(statusHttpUrlFromHttpBase('https://example.com/')).toBe('https://example.com/status'); + expect(statusHttpUrlFromHttpBase('https://example.com/goose/')).toBe( + 'https://example.com/goose/status' + ); + }); + + it('builds ACP URLs from root and prefixed bases', () => { + expect(acpHttpUrlFromHttpBase('https://example.com/')).toBe('https://example.com/acp'); + expect(acpHttpUrlFromHttpBase('https://example.com/goose/')).toBe( + 'https://example.com/goose/acp' + ); + }); + + it('adds ACP query tokens when provided', () => { + expect(acpHttpUrlFromHttpBase('https://example.com/goose', 'test secret')).toBe( + 'https://example.com/goose/acp?token=test+secret' + ); + }); +}); + +describe('acpWebSocketUrlFromHttpBase', () => { + it('derives WSS ACP URLs from HTTPS base URLs', () => { + expect(acpWebSocketUrlFromHttpBase('https://example.com/goose', 'secret')).toBe( + 'wss://example.com/goose/acp?token=secret' + ); + }); + + it('derives WS ACP URLs from HTTP base URLs', () => { + expect(acpWebSocketUrlFromHttpBase('http://127.0.0.1:1234', 'secret')).toBe( + 'ws://127.0.0.1:1234/acp?token=secret' + ); + }); +}); diff --git a/ui/desktop/src/acp/acpConnection.ts b/ui/desktop/src/acp/acpConnection.ts index b7b6d5561e..fd40944ea4 100644 --- a/ui/desktop/src/acp/acpConnection.ts +++ b/ui/desktop/src/acp/acpConnection.ts @@ -19,6 +19,8 @@ type InitializedAcpClient = { initializeResponse: InitializeResponse; }; +const ACP_INITIALIZE_TIMEOUT_MS = 10_000; + let clientPromise: Promise | null = null; let resolvedClient: InitializedAcpClient | null = null; @@ -44,6 +46,21 @@ function monitorConnection(client: GooseClient): void { }); } +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timeoutId: ReturnType | null = null; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeout]); + } finally { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + } +} + async function initializeConnection(): Promise { const wsUrl = await window.electron.getAcpUrl(); if (!wsUrl) { @@ -53,26 +70,35 @@ async function initializeConnection(): Promise { const stream = createWebSocketStream(wsUrl); const client = new GooseClient(createClientCallbacks(), stream); - const initializeResponse = await client.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { - elicitation: { form: {} }, - _meta: { - goose: { - mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, - customNotifications: true, - recipeParameterRequests: true, + try { + const initializeResponse = await withTimeout( + client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + elicitation: { form: {} }, + _meta: { + goose: { + mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, + customNotifications: true, + recipeParameterRequests: true, + }, + }, }, - }, - }, - clientInfo: { - name: packageJson.name, - version: packageJson.version, - }, - }); + clientInfo: { + name: packageJson.name, + version: packageJson.version, + }, + }), + ACP_INITIALIZE_TIMEOUT_MS, + `ACP initialize timed out after ${ACP_INITIALIZE_TIMEOUT_MS}ms` + ); - monitorConnection(client); - return { client, initializeResponse }; + monitorConnection(client); + return { client, initializeResponse }; + } catch (error) { + stream.close(); + throw error; + } } export async function getAcpClient(): Promise { diff --git a/ui/desktop/src/acp/createWebSocketStream.ts b/ui/desktop/src/acp/createWebSocketStream.ts index 61b21c558f..73d7448161 100644 --- a/ui/desktop/src/acp/createWebSocketStream.ts +++ b/ui/desktop/src/acp/createWebSocketStream.ts @@ -1,6 +1,10 @@ import type { Stream } from '@aaif/goose-sdk'; -export function createWebSocketStream(wsUrl: string): Stream { +export type ClosableAcpStream = Stream & { + close: () => void; +}; + +export function createWebSocketStream(wsUrl: string): ClosableAcpStream { const ws = new window.WebSocket(wsUrl); const incoming: unknown[] = []; @@ -73,5 +77,9 @@ export function createWebSocketStream(wsUrl: string): Stream { }, }); - return { readable, writable } as Stream; + return { + readable, + writable, + close: () => ws.close(), + } as ClosableAcpStream; } diff --git a/ui/desktop/src/acp/url.ts b/ui/desktop/src/acp/url.ts new file mode 100644 index 0000000000..b3a2719a0f --- /dev/null +++ b/ui/desktop/src/acp/url.ts @@ -0,0 +1,87 @@ +export function httpBaseFromAcpWebSocketUrl(acpUrl: string): string { + const url = new URL(acpUrl); + + if (url.protocol === 'ws:') { + url.protocol = 'http:'; + } else if (url.protocol === 'wss:') { + url.protocol = 'https:'; + } else { + throw new Error(`ACP URL must use ws: or wss:, got ${url.protocol}`); + } + + const pathname = url.pathname.replace(/\/+$/, ''); + const pathPrefix = pathname.endsWith('/acp') ? pathname.slice(0, -'/acp'.length) : pathname; + + return `${url.origin}${pathPrefix}`; +} + +export function isLoopbackAcpWebSocketUrl(acpUrl: string): boolean { + const url = new URL(acpUrl); + + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new Error(`ACP URL must use ws: or wss:, got ${url.protocol}`); + } + + const hostname = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, '$1'); + return hostname === 'localhost' || hostname === '::1' || isIpv4LoopbackLiteral(hostname); +} + +function isIpv4LoopbackLiteral(hostname: string): boolean { + const octets = hostname.split('.'); + if (octets.length !== 4 || octets.some((octet) => !/^\d+$/.test(octet))) { + return false; + } + + return octets.every((octet) => Number(octet) <= 255) && Number(octets[0]) === 127; +} + +export function normalizeAcpHttpBaseUrl(rawBaseUrl: string): string { + const trimmed = rawBaseUrl.trim(); + if (!trimmed) { + throw new Error('External ACP backend URL is required'); + } + + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`External ACP backend URL must use http: or https:, got ${url.protocol}`); + } + + if (url.search || url.hash) { + throw new Error('External ACP backend URL must not include query parameters or fragments'); + } + + const pathname = url.pathname.replace(/\/+$/, ''); + if (pathname.endsWith('/acp')) { + throw new Error('External ACP backend URL must be the base URL before /acp'); + } + + return `${url.origin}${pathname}`; +} + +function httpEndpointUrlFromHttpBase(rawBaseUrl: string, endpoint: 'status' | 'acp'): string { + const baseUrl = normalizeAcpHttpBaseUrl(rawBaseUrl); + const url = new URL(baseUrl); + url.pathname = `${url.pathname.replace(/\/+$/, '')}/${endpoint}`; + return url.toString(); +} + +export function statusHttpUrlFromHttpBase(rawBaseUrl: string): string { + return httpEndpointUrlFromHttpBase(rawBaseUrl, 'status'); +} + +export function acpHttpUrlFromHttpBase(rawBaseUrl: string, token?: string): string { + const url = new URL(httpEndpointUrlFromHttpBase(rawBaseUrl, 'acp')); + if (token) { + url.searchParams.set('token', token); + } + return url.toString(); +} + +export function acpWebSocketUrlFromHttpBase(rawBaseUrl: string, token: string): string { + const baseUrl = normalizeAcpHttpBaseUrl(rawBaseUrl); + const url = new URL(baseUrl); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.pathname = `${url.pathname.replace(/\/+$/, '')}/acp`; + url.searchParams.set('token', token); + return url.toString(); +} diff --git a/ui/desktop/src/api/client.gen.ts b/ui/desktop/src/api/client.gen.ts deleted file mode 100644 index d81ce3f8f7..0000000000 --- a/ui/desktop/src/api/client.gen.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { type ClientOptions, type Config, createClient, createConfig } from './client'; -import type { ClientOptions as ClientOptions2 } from './types.gen'; - -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ -export type CreateClientConfig = (override?: Config) => Config & T> | Promise & T>>; - -export const client = createClient(createConfig()); diff --git a/ui/desktop/src/api/client/client.gen.ts b/ui/desktop/src/api/client/client.gen.ts deleted file mode 100644 index d2e55a1449..0000000000 --- a/ui/desktop/src/api/client/client.gen.ts +++ /dev/null @@ -1,288 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { createSseClient } from '../core/serverSentEvents.gen'; -import type { HttpMethod } from '../core/types.gen'; -import { getValidRequestBody } from '../core/utils.gen'; -import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; -import { - buildUrl, - createConfig, - createInterceptors, - getParseAs, - mergeConfigs, - mergeHeaders, - setAuthParams, -} from './utils.gen'; - -type ReqInit = Omit & { - body?: any; - headers: ReturnType; -}; - -export const createClient = (config: Config = {}): Client => { - let _config = mergeConfigs(createConfig(), config); - - const getConfig = (): Config => ({ ..._config }); - - const setConfig = (config: Config): Config => { - _config = mergeConfigs(_config, config); - return getConfig(); - }; - - const interceptors = createInterceptors(); - - const beforeRequest = async (options: RequestOptions) => { - const opts = { - ..._config, - ...options, - fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, - headers: mergeHeaders(_config.headers, options.headers), - serializedBody: undefined, - }; - - if (opts.security) { - await setAuthParams({ - ...opts, - security: opts.security, - }); - } - - if (opts.requestValidator) { - await opts.requestValidator(opts); - } - - if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body); - } - - // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.serializedBody === '') { - opts.headers.delete('Content-Type'); - } - - const url = buildUrl(opts); - - return { opts, url }; - }; - - const request: Client['request'] = async (options) => { - // @ts-expect-error - const { opts, url } = await beforeRequest(options); - const requestInit: ReqInit = { - redirect: 'follow', - ...opts, - body: getValidRequestBody(opts), - }; - - let request = new Request(url, requestInit); - - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = opts.fetch!; - let response: Response; - - try { - response = await _fetch(request); - } catch (error) { - // Handle fetch exceptions (AbortError, network errors, etc.) - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = (await fn(error, undefined as any, request, opts)) as unknown; - } - } - - finalError = finalError || ({} as unknown); - - if (opts.throwOnError) { - throw finalError; - } - - // Return error response - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - request, - response: undefined as any, - }; - } - - for (const fn of interceptors.response.fns) { - if (fn) { - response = await fn(response, request, opts); - } - } - - const result = { - request, - response, - }; - - if (response.ok) { - const parseAs = - (opts.parseAs === 'auto' - ? getParseAs(response.headers.get('Content-Type')) - : opts.parseAs) ?? 'json'; - - if (response.status === 204 || response.headers.get('Content-Length') === '0') { - let emptyData: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'text': - emptyData = await response[parseAs](); - break; - case 'formData': - emptyData = new FormData(); - break; - case 'stream': - emptyData = response.body; - break; - case 'json': - default: - emptyData = {}; - break; - } - return opts.responseStyle === 'data' - ? emptyData - : { - data: emptyData, - ...result, - }; - } - - let data: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'formData': - case 'text': - data = await response[parseAs](); - break; - case 'json': { - // Some servers return 200 with no Content-Length and empty body. - // response.json() would throw; read as text and parse if non-empty. - const text = await response.text(); - data = text ? JSON.parse(text) : {}; - break; - } - case 'stream': - return opts.responseStyle === 'data' - ? response.body - : { - data: response.body, - ...result, - }; - } - - if (parseAs === 'json') { - if (opts.responseValidator) { - await opts.responseValidator(data); - } - - if (opts.responseTransformer) { - data = await opts.responseTransformer(data); - } - } - - return opts.responseStyle === 'data' - ? data - : { - data, - ...result, - }; - } - - const textError = await response.text(); - let jsonError: unknown; - - try { - jsonError = JSON.parse(textError); - } catch { - // noop - } - - const error = jsonError ?? textError; - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = (await fn(error, response, request, opts)) as string; - } - } - - finalError = finalError || ({} as string); - - if (opts.throwOnError) { - throw finalError; - } - - // TODO: we probably want to return error and improve types - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - ...result, - }; - }; - - const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => - request({ ...options, method }); - - const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options); - return createSseClient({ - ...opts, - body: opts.body as BodyInit | null | undefined, - headers: opts.headers as unknown as Record, - method, - onRequest: async (url, init) => { - let request = new Request(url, init); - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - return request; - }, - serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, - url, - }); - }; - - return { - buildUrl, - connect: makeMethodFn('CONNECT'), - delete: makeMethodFn('DELETE'), - get: makeMethodFn('GET'), - getConfig, - head: makeMethodFn('HEAD'), - interceptors, - options: makeMethodFn('OPTIONS'), - patch: makeMethodFn('PATCH'), - post: makeMethodFn('POST'), - put: makeMethodFn('PUT'), - request, - setConfig, - sse: { - connect: makeSseFn('CONNECT'), - delete: makeSseFn('DELETE'), - get: makeSseFn('GET'), - head: makeSseFn('HEAD'), - options: makeSseFn('OPTIONS'), - patch: makeSseFn('PATCH'), - post: makeSseFn('POST'), - put: makeSseFn('PUT'), - trace: makeSseFn('TRACE'), - }, - trace: makeMethodFn('TRACE'), - } as Client; -}; diff --git a/ui/desktop/src/api/client/index.ts b/ui/desktop/src/api/client/index.ts deleted file mode 100644 index b295edeca0..0000000000 --- a/ui/desktop/src/api/client/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type { Auth } from '../core/auth.gen'; -export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -export { - formDataBodySerializer, - jsonBodySerializer, - urlSearchParamsBodySerializer, -} from '../core/bodySerializer.gen'; -export { buildClientParams } from '../core/params.gen'; -export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; -export { createClient } from './client.gen'; -export type { - Client, - ClientOptions, - Config, - CreateClientConfig, - Options, - RequestOptions, - RequestResult, - ResolvedRequestOptions, - ResponseStyle, - TDataShape, -} from './types.gen'; -export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/ui/desktop/src/api/client/types.gen.ts b/ui/desktop/src/api/client/types.gen.ts deleted file mode 100644 index 8c0df2321e..0000000000 --- a/ui/desktop/src/api/client/types.gen.ts +++ /dev/null @@ -1,214 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth } from '../core/auth.gen'; -import type { - ServerSentEventsOptions, - ServerSentEventsResult, -} from '../core/serverSentEvents.gen'; -import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; -import type { Middleware } from './utils.gen'; - -export type ResponseStyle = 'data' | 'fields'; - -export interface Config - extends Omit, CoreConfig { - /** - * Base URL for all requests made by this client. - */ - baseUrl?: T['baseUrl']; - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Please don't use the Fetch client for Next.js applications. The `next` - * options won't have any effect. - * - * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. - */ - next?: never; - /** - * Return the response data parsed in a specified format. By default, `auto` - * will infer the appropriate method from the `Content-Type` response header. - * You can override this behavior with any of the {@link Body} methods. - * Select `stream` if you don't want to parse response data at all. - * - * @default 'auto' - */ - parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; - /** - * Should we return only data or multiple fields (data, error, response, etc.)? - * - * @default 'fields' - */ - responseStyle?: ResponseStyle; - /** - * Throw an error instead of returning it in the response? - * - * @default false - */ - throwOnError?: T['throwOnError']; -} - -export interface RequestOptions< - TData = unknown, - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> - extends - Config<{ - responseStyle: TResponseStyle; - throwOnError: ThrowOnError; - }>, - Pick< - ServerSentEventsOptions, - | 'onRequest' - | 'onSseError' - | 'onSseEvent' - | 'sseDefaultRetryDelay' - | 'sseMaxRetryAttempts' - | 'sseMaxRetryDelay' - > { - /** - * Any body that you want to add to your request. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} - */ - body?: unknown; - path?: Record; - query?: Record; - /** - * Security mechanism(s) to use for the request. - */ - security?: ReadonlyArray; - url: Url; -} - -export interface ResolvedRequestOptions< - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> extends RequestOptions { - serializedBody?: string; -} - -export type RequestResult< - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = 'fields', -> = ThrowOnError extends true - ? Promise< - TResponseStyle extends 'data' - ? TData extends Record - ? TData[keyof TData] - : TData - : { - data: TData extends Record ? TData[keyof TData] : TData; - request: Request; - response: Response; - } - > - : Promise< - TResponseStyle extends 'data' - ? (TData extends Record ? TData[keyof TData] : TData) | undefined - : ( - | { - data: TData extends Record ? TData[keyof TData] : TData; - error: undefined; - } - | { - data: undefined; - error: TError extends Record ? TError[keyof TError] : TError; - } - ) & { - request: Request; - response: Response; - } - >; - -export interface ClientOptions { - baseUrl?: string; - responseStyle?: ResponseStyle; - throwOnError?: boolean; -} - -type MethodFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => RequestResult; - -type SseFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => Promise>; - -type RequestFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'> & - Pick>, 'method'>, -) => RequestResult; - -type BuildUrlFn = < - TData extends { - body?: unknown; - path?: Record; - query?: Record; - url: string; - }, ->( - options: TData & Options, -) => string; - -export type Client = CoreClient & { - interceptors: Middleware; -}; - -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ -export type CreateClientConfig = ( - override?: Config, -) => Config & T> | Promise & T>>; - -export interface TDataShape { - body?: unknown; - headers?: unknown; - path?: unknown; - query?: unknown; - url: string; -} - -type OmitKeys = Pick>; - -export type Options< - TData extends TDataShape = TDataShape, - ThrowOnError extends boolean = boolean, - TResponse = unknown, - TResponseStyle extends ResponseStyle = 'fields', -> = OmitKeys< - RequestOptions, - 'body' | 'path' | 'query' | 'url' -> & - ([TData] extends [never] ? unknown : Omit); diff --git a/ui/desktop/src/api/client/utils.gen.ts b/ui/desktop/src/api/client/utils.gen.ts deleted file mode 100644 index b4bd2435ce..0000000000 --- a/ui/desktop/src/api/client/utils.gen.ts +++ /dev/null @@ -1,316 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { getAuthToken } from '../core/auth.gen'; -import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -import { jsonBodySerializer } from '../core/bodySerializer.gen'; -import { - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from '../core/pathSerializer.gen'; -import { getUrl } from '../core/utils.gen'; -import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; - -export const createQuerySerializer = ({ - parameters = {}, - ...args -}: QuerySerializerOptions = {}) => { - const querySerializer = (queryParams: T) => { - const search: string[] = []; - if (queryParams && typeof queryParams === 'object') { - for (const name in queryParams) { - const value = queryParams[name]; - - if (value === undefined || value === null) { - continue; - } - - const options = parameters[name] || args; - - if (Array.isArray(value)) { - const serializedArray = serializeArrayParam({ - allowReserved: options.allowReserved, - explode: true, - name, - style: 'form', - value, - ...options.array, - }); - if (serializedArray) search.push(serializedArray); - } else if (typeof value === 'object') { - const serializedObject = serializeObjectParam({ - allowReserved: options.allowReserved, - explode: true, - name, - style: 'deepObject', - value: value as Record, - ...options.object, - }); - if (serializedObject) search.push(serializedObject); - } else { - const serializedPrimitive = serializePrimitiveParam({ - allowReserved: options.allowReserved, - name, - value: value as string, - }); - if (serializedPrimitive) search.push(serializedPrimitive); - } - } - } - return search.join('&'); - }; - return querySerializer; -}; - -/** - * Infers parseAs value from provided Content-Type header. - */ -export const getParseAs = (contentType: string | null): Exclude => { - if (!contentType) { - // If no Content-Type header is provided, the best we can do is return the raw response body, - // which is effectively the same as the 'stream' option. - return 'stream'; - } - - const cleanContent = contentType.split(';')[0]?.trim(); - - if (!cleanContent) { - return; - } - - if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { - return 'json'; - } - - if (cleanContent === 'multipart/form-data') { - return 'formData'; - } - - if ( - ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) - ) { - return 'blob'; - } - - if (cleanContent.startsWith('text/')) { - return 'text'; - } - - return; -}; - -const checkForExistence = ( - options: Pick & { - headers: Headers; - }, - name?: string, -): boolean => { - if (!name) { - return false; - } - if ( - options.headers.has(name) || - options.query?.[name] || - options.headers.get('Cookie')?.includes(`${name}=`) - ) { - return true; - } - return false; -}; - -export const setAuthParams = async ({ - security, - ...options -}: Pick, 'security'> & - Pick & { - headers: Headers; - }) => { - for (const auth of security) { - if (checkForExistence(options, auth.name)) { - continue; - } - - const token = await getAuthToken(auth, options.auth); - - if (!token) { - continue; - } - - const name = auth.name ?? 'Authorization'; - - switch (auth.in) { - case 'query': - if (!options.query) { - options.query = {}; - } - options.query[name] = token; - break; - case 'cookie': - options.headers.append('Cookie', `${name}=${token}`); - break; - case 'header': - default: - options.headers.set(name, token); - break; - } - } -}; - -export const buildUrl: Client['buildUrl'] = (options) => - getUrl({ - baseUrl: options.baseUrl as string, - path: options.path, - query: options.query, - querySerializer: - typeof options.querySerializer === 'function' - ? options.querySerializer - : createQuerySerializer(options.querySerializer), - url: options.url, - }); - -export const mergeConfigs = (a: Config, b: Config): Config => { - const config = { ...a, ...b }; - if (config.baseUrl?.endsWith('/')) { - config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); - } - config.headers = mergeHeaders(a.headers, b.headers); - return config; -}; - -const headersEntries = (headers: Headers): Array<[string, string]> => { - const entries: Array<[string, string]> = []; - headers.forEach((value, key) => { - entries.push([key, value]); - }); - return entries; -}; - -export const mergeHeaders = ( - ...headers: Array['headers'] | undefined> -): Headers => { - const mergedHeaders = new Headers(); - for (const header of headers) { - if (!header) { - continue; - } - - const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); - - for (const [key, value] of iterator) { - if (value === null) { - mergedHeaders.delete(key); - } else if (Array.isArray(value)) { - for (const v of value) { - mergedHeaders.append(key, v as string); - } - } else if (value !== undefined) { - // assume object headers are meant to be JSON stringified, i.e. their - // content value in OpenAPI specification is 'application/json' - mergedHeaders.set( - key, - typeof value === 'object' ? JSON.stringify(value) : (value as string), - ); - } - } - } - return mergedHeaders; -}; - -type ErrInterceptor = ( - error: Err, - response: Res, - request: Req, - options: Options, -) => Err | Promise; - -type ReqInterceptor = (request: Req, options: Options) => Req | Promise; - -type ResInterceptor = ( - response: Res, - request: Req, - options: Options, -) => Res | Promise; - -class Interceptors { - fns: Array = []; - - clear(): void { - this.fns = []; - } - - eject(id: number | Interceptor): void { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = null; - } - } - - exists(id: number | Interceptor): boolean { - const index = this.getInterceptorIndex(id); - return Boolean(this.fns[index]); - } - - getInterceptorIndex(id: number | Interceptor): number { - if (typeof id === 'number') { - return this.fns[id] ? id : -1; - } - return this.fns.indexOf(id); - } - - update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = fn; - return id; - } - return false; - } - - use(fn: Interceptor): number { - this.fns.push(fn); - return this.fns.length - 1; - } -} - -export interface Middleware { - error: Interceptors>; - request: Interceptors>; - response: Interceptors>; -} - -export const createInterceptors = (): Middleware< - Req, - Res, - Err, - Options -> => ({ - error: new Interceptors>(), - request: new Interceptors>(), - response: new Interceptors>(), -}); - -const defaultQuerySerializer = createQuerySerializer({ - allowReserved: false, - array: { - explode: true, - style: 'form', - }, - object: { - explode: true, - style: 'deepObject', - }, -}); - -const defaultHeaders = { - 'Content-Type': 'application/json', -}; - -export const createConfig = ( - override: Config & T> = {}, -): Config & T> => ({ - ...jsonBodySerializer, - headers: defaultHeaders, - parseAs: 'auto', - querySerializer: defaultQuerySerializer, - ...override, -}); diff --git a/ui/desktop/src/api/core/auth.gen.ts b/ui/desktop/src/api/core/auth.gen.ts deleted file mode 100644 index 3ebf994788..0000000000 --- a/ui/desktop/src/api/core/auth.gen.ts +++ /dev/null @@ -1,41 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type AuthToken = string | undefined; - -export interface Auth { - /** - * Which part of the request do we use to send the auth? - * - * @default 'header' - */ - in?: 'header' | 'query' | 'cookie'; - /** - * Header or query parameter name. - * - * @default 'Authorization' - */ - name?: string; - scheme?: 'basic' | 'bearer'; - type: 'apiKey' | 'http'; -} - -export const getAuthToken = async ( - auth: Auth, - callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, -): Promise => { - const token = typeof callback === 'function' ? await callback(auth) : callback; - - if (!token) { - return; - } - - if (auth.scheme === 'bearer') { - return `Bearer ${token}`; - } - - if (auth.scheme === 'basic') { - return `Basic ${btoa(token)}`; - } - - return token; -}; diff --git a/ui/desktop/src/api/core/bodySerializer.gen.ts b/ui/desktop/src/api/core/bodySerializer.gen.ts deleted file mode 100644 index 8ad92c9ffd..0000000000 --- a/ui/desktop/src/api/core/bodySerializer.gen.ts +++ /dev/null @@ -1,84 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; - -export type QuerySerializer = (query: Record) => string; - -export type BodySerializer = (body: any) => any; - -type QuerySerializerOptionsObject = { - allowReserved?: boolean; - array?: Partial>; - object?: Partial>; -}; - -export type QuerySerializerOptions = QuerySerializerOptionsObject & { - /** - * Per-parameter serialization overrides. When provided, these settings - * override the global array/object settings for specific parameter names. - */ - parameters?: Record; -}; - -const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { - if (typeof value === 'string' || value instanceof Blob) { - data.append(key, value); - } else if (value instanceof Date) { - data.append(key, value.toISOString()); - } else { - data.append(key, JSON.stringify(value)); - } -}; - -const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { - if (typeof value === 'string') { - data.append(key, value); - } else { - data.append(key, JSON.stringify(value)); - } -}; - -export const formDataBodySerializer = { - bodySerializer: | Array>>( - body: T, - ): FormData => { - const data = new FormData(); - - Object.entries(body).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)); - } else { - serializeFormDataPair(data, key, value); - } - }); - - return data; - }, -}; - -export const jsonBodySerializer = { - bodySerializer: (body: T): string => - JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), -}; - -export const urlSearchParamsBodySerializer = { - bodySerializer: | Array>>(body: T): string => { - const data = new URLSearchParams(); - - Object.entries(body).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); - } else { - serializeUrlSearchParamsPair(data, key, value); - } - }); - - return data.toString(); - }, -}; diff --git a/ui/desktop/src/api/core/params.gen.ts b/ui/desktop/src/api/core/params.gen.ts deleted file mode 100644 index 7955601a5c..0000000000 --- a/ui/desktop/src/api/core/params.gen.ts +++ /dev/null @@ -1,169 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -type Slot = 'body' | 'headers' | 'path' | 'query'; - -export type Field = - | { - in: Exclude; - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If omitted, we use the same value as `key`. - */ - map?: string; - } - | { - in: Extract; - /** - * Key isn't required for bodies. - */ - key?: string; - map?: string; - } - | { - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If `in` is omitted, `map` aliases `key` to the transport layer. - */ - map: Slot; - }; - -export interface Fields { - allowExtra?: Partial>; - args?: ReadonlyArray; -} - -export type FieldsConfig = ReadonlyArray; - -const extraPrefixesMap: Record = { - $body_: 'body', - $headers_: 'headers', - $path_: 'path', - $query_: 'query', -}; -const extraPrefixes = Object.entries(extraPrefixesMap); - -type KeyMap = Map< - string, - | { - in: Slot; - map?: string; - } - | { - in?: never; - map: Slot; - } ->; - -const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { - if (!map) { - map = new Map(); - } - - for (const config of fields) { - if ('in' in config) { - if (config.key) { - map.set(config.key, { - in: config.in, - map: config.map, - }); - } - } else if ('key' in config) { - map.set(config.key, { - map: config.map, - }); - } else if (config.args) { - buildKeyMap(config.args, map); - } - } - - return map; -}; - -interface Params { - body: unknown; - headers: Record; - path: Record; - query: Record; -} - -const stripEmptySlots = (params: Params) => { - for (const [slot, value] of Object.entries(params)) { - if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { - delete params[slot as Slot]; - } - } -}; - -export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { - const params: Params = { - body: {}, - headers: {}, - path: {}, - query: {}, - }; - - const map = buildKeyMap(fields); - - let config: FieldsConfig[number] | undefined; - - for (const [index, arg] of args.entries()) { - if (fields[index]) { - config = fields[index]; - } - - if (!config) { - continue; - } - - if ('in' in config) { - if (config.key) { - const field = map.get(config.key)!; - const name = field.map || config.key; - if (field.in) { - (params[field.in] as Record)[name] = arg; - } - } else { - params.body = arg; - } - } else { - for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key); - - if (field) { - if (field.in) { - const name = field.map || key; - (params[field.in] as Record)[name] = value; - } else { - params[field.map] = value; - } - } else { - const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); - - if (extra) { - const [prefix, slot] = extra; - (params[slot] as Record)[key.slice(prefix.length)] = value; - } else if ('allowExtra' in config && config.allowExtra) { - for (const [slot, allowed] of Object.entries(config.allowExtra)) { - if (allowed) { - (params[slot as Slot] as Record)[key] = value; - break; - } - } - } - } - } - } - } - - stripEmptySlots(params); - - return params; -}; diff --git a/ui/desktop/src/api/core/pathSerializer.gen.ts b/ui/desktop/src/api/core/pathSerializer.gen.ts deleted file mode 100644 index 994b2848c6..0000000000 --- a/ui/desktop/src/api/core/pathSerializer.gen.ts +++ /dev/null @@ -1,171 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} - -interface SerializePrimitiveOptions { - allowReserved?: boolean; - name: string; -} - -export interface SerializerOptions { - /** - * @default true - */ - explode: boolean; - style: T; -} - -export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; -export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; -type MatrixStyle = 'label' | 'matrix' | 'simple'; -export type ObjectStyle = 'form' | 'deepObject'; -type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; - -interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string; -} - -export const separatorArrayExplode = (style: ArraySeparatorStyle) => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } -}; - -export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { - switch (style) { - case 'form': - return ','; - case 'pipeDelimited': - return '|'; - case 'spaceDelimited': - return '%20'; - default: - return ','; - } -}; - -export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } -}; - -export const serializeArrayParam = ({ - allowReserved, - explode, - name, - style, - value, -}: SerializeOptions & { - value: unknown[]; -}) => { - if (!explode) { - const joinedValues = ( - allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) - ).join(separatorArrayNoExplode(style)); - switch (style) { - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - case 'simple': - return joinedValues; - default: - return `${name}=${joinedValues}`; - } - } - - const separator = separatorArrayExplode(style); - const joinedValues = value - .map((v) => { - if (style === 'label' || style === 'simple') { - return allowReserved ? v : encodeURIComponent(v as string); - } - - return serializePrimitiveParam({ - allowReserved, - name, - value: v as string, - }); - }) - .join(separator); - return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; -}; - -export const serializePrimitiveParam = ({ - allowReserved, - name, - value, -}: SerializePrimitiveParam) => { - if (value === undefined || value === null) { - return ''; - } - - if (typeof value === 'object') { - throw new Error( - 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', - ); - } - - return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; -}; - -export const serializeObjectParam = ({ - allowReserved, - explode, - name, - style, - value, - valueOnly, -}: SerializeOptions & { - value: Record | Date; - valueOnly?: boolean; -}) => { - if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; - } - - if (style !== 'deepObject' && !explode) { - let values: string[] = []; - Object.entries(value).forEach(([key, v]) => { - values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; - }); - const joinedValues = values.join(','); - switch (style) { - case 'form': - return `${name}=${joinedValues}`; - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - default: - return joinedValues; - } - } - - const separator = separatorObjectExplode(style); - const joinedValues = Object.entries(value) - .map(([key, v]) => - serializePrimitiveParam({ - allowReserved, - name: style === 'deepObject' ? `${name}[${key}]` : key, - value: v as string, - }), - ) - .join(separator); - return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; -}; diff --git a/ui/desktop/src/api/core/queryKeySerializer.gen.ts b/ui/desktop/src/api/core/queryKeySerializer.gen.ts deleted file mode 100644 index 5000df606f..0000000000 --- a/ui/desktop/src/api/core/queryKeySerializer.gen.ts +++ /dev/null @@ -1,117 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -/** - * JSON-friendly union that mirrors what Pinia Colada can hash. - */ -export type JsonValue = - | null - | string - | number - | boolean - | JsonValue[] - | { [key: string]: JsonValue }; - -/** - * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. - */ -export const queryKeyJsonReplacer = (_key: string, value: unknown) => { - if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { - return undefined; - } - if (typeof value === 'bigint') { - return value.toString(); - } - if (value instanceof Date) { - return value.toISOString(); - } - return value; -}; - -/** - * Safely stringifies a value and parses it back into a JsonValue. - */ -export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { - try { - const json = JSON.stringify(input, queryKeyJsonReplacer); - if (json === undefined) { - return undefined; - } - return JSON.parse(json) as JsonValue; - } catch { - return undefined; - } -}; - -/** - * Detects plain objects (including objects with a null prototype). - */ -const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value as object); - return prototype === Object.prototype || prototype === null; -}; - -/** - * Turns URLSearchParams into a sorted JSON object for deterministic keys. - */ -const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); - const result: Record = {}; - - for (const [key, value] of entries) { - const existing = result[key]; - if (existing === undefined) { - result[key] = value; - continue; - } - - if (Array.isArray(existing)) { - (existing as string[]).push(value); - } else { - result[key] = [existing, value]; - } - } - - return result; -}; - -/** - * Normalizes any accepted value into a JSON-friendly shape for query keys. - */ -export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { - if (value === null) { - return null; - } - - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value; - } - - if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { - return undefined; - } - - if (typeof value === 'bigint') { - return value.toString(); - } - - if (value instanceof Date) { - return value.toISOString(); - } - - if (Array.isArray(value)) { - return stringifyToJsonValue(value); - } - - if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { - return serializeSearchParams(value); - } - - if (isPlainObject(value)) { - return stringifyToJsonValue(value); - } - - return undefined; -}; diff --git a/ui/desktop/src/api/core/serverSentEvents.gen.ts b/ui/desktop/src/api/core/serverSentEvents.gen.ts deleted file mode 100644 index 6aa6cf02a4..0000000000 --- a/ui/desktop/src/api/core/serverSentEvents.gen.ts +++ /dev/null @@ -1,243 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Config } from './types.gen'; - -export type ServerSentEventsOptions = Omit & - Pick & { - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Implementing clients can call request interceptors inside this hook. - */ - onRequest?: (url: string, init: RequestInit) => Promise; - /** - * Callback invoked when a network or parsing error occurs during streaming. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param error The error that occurred. - */ - onSseError?: (error: unknown) => void; - /** - * Callback invoked when an event is streamed from the server. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param event Event streamed from the server. - * @returns Nothing (void). - */ - onSseEvent?: (event: StreamEvent) => void; - serializedBody?: RequestInit['body']; - /** - * Default retry delay in milliseconds. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 3000 - */ - sseDefaultRetryDelay?: number; - /** - * Maximum number of retry attempts before giving up. - */ - sseMaxRetryAttempts?: number; - /** - * Maximum retry delay in milliseconds. - * - * Applies only when exponential backoff is used. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 30000 - */ - sseMaxRetryDelay?: number; - /** - * Optional sleep function for retry backoff. - * - * Defaults to using `setTimeout`. - */ - sseSleepFn?: (ms: number) => Promise; - url: string; - }; - -export interface StreamEvent { - data: TData; - event?: string; - id?: string; - retry?: number; -} - -export type ServerSentEventsResult = { - stream: AsyncGenerator< - TData extends Record ? TData[keyof TData] : TData, - TReturn, - TNext - >; -}; - -export const createSseClient = ({ - onRequest, - onSseError, - onSseEvent, - responseTransformer, - responseValidator, - sseDefaultRetryDelay, - sseMaxRetryAttempts, - sseMaxRetryDelay, - sseSleepFn, - url, - ...options -}: ServerSentEventsOptions): ServerSentEventsResult => { - let lastEventId: string | undefined; - - const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); - - const createStream = async function* () { - let retryDelay: number = sseDefaultRetryDelay ?? 3000; - let attempt = 0; - const signal = options.signal ?? new AbortController().signal; - - while (true) { - if (signal.aborted) break; - - attempt++; - - const headers = - options.headers instanceof Headers - ? options.headers - : new Headers(options.headers as Record | undefined); - - if (lastEventId !== undefined) { - headers.set('Last-Event-ID', lastEventId); - } - - try { - const requestInit: RequestInit = { - redirect: 'follow', - ...options, - body: options.serializedBody, - headers, - signal, - }; - let request = new Request(url, requestInit); - if (onRequest) { - request = await onRequest(url, requestInit); - } - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = options.fetch ?? globalThis.fetch; - const response = await _fetch(request); - - if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); - - if (!response.body) throw new Error('No body in SSE response'); - - const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); - - let buffer = ''; - - const abortHandler = () => { - try { - reader.cancel(); - } catch { - // noop - } - }; - - signal.addEventListener('abort', abortHandler); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += value; - // Normalize line endings: CRLF -> LF, then CR -> LF - buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - - const chunks = buffer.split('\n\n'); - buffer = chunks.pop() ?? ''; - - for (const chunk of chunks) { - const lines = chunk.split('\n'); - const dataLines: Array = []; - let eventName: string | undefined; - - for (const line of lines) { - if (line.startsWith('data:')) { - dataLines.push(line.replace(/^data:\s*/, '')); - } else if (line.startsWith('event:')) { - eventName = line.replace(/^event:\s*/, ''); - } else if (line.startsWith('id:')) { - lastEventId = line.replace(/^id:\s*/, ''); - } else if (line.startsWith('retry:')) { - const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); - if (!Number.isNaN(parsed)) { - retryDelay = parsed; - } - } - } - - let data: unknown; - let parsedJson = false; - - if (dataLines.length) { - const rawData = dataLines.join('\n'); - try { - data = JSON.parse(rawData); - parsedJson = true; - } catch { - data = rawData; - } - } - - if (parsedJson) { - if (responseValidator) { - await responseValidator(data); - } - - if (responseTransformer) { - data = await responseTransformer(data); - } - } - - onSseEvent?.({ - data, - event: eventName, - id: lastEventId, - retry: retryDelay, - }); - - if (dataLines.length) { - yield data as any; - } - } - } - } finally { - signal.removeEventListener('abort', abortHandler); - reader.releaseLock(); - } - - break; // exit loop on normal completion - } catch (error) { - // connection failed or aborted; retry after delay - onSseError?.(error); - - if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { - break; // stop after firing error - } - - // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); - await sleep(backoff); - } - } - }; - - const stream = createStream(); - - return { stream }; -}; diff --git a/ui/desktop/src/api/core/types.gen.ts b/ui/desktop/src/api/core/types.gen.ts deleted file mode 100644 index 97463257e4..0000000000 --- a/ui/desktop/src/api/core/types.gen.ts +++ /dev/null @@ -1,104 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth, AuthToken } from './auth.gen'; -import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; - -export type HttpMethod = - | 'connect' - | 'delete' - | 'get' - | 'head' - | 'options' - | 'patch' - | 'post' - | 'put' - | 'trace'; - -export type Client< - RequestFn = never, - Config = unknown, - MethodFn = never, - BuildUrlFn = never, - SseFn = never, -> = { - /** - * Returns the final request URL. - */ - buildUrl: BuildUrlFn; - getConfig: () => Config; - request: RequestFn; - setConfig: (config: Config) => Config; -} & { - [K in HttpMethod]: MethodFn; -} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); - -export interface Config { - /** - * Auth token or a function returning auth token. The resolved value will be - * added to the request payload as defined by its `security` array. - */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; - /** - * A function for serializing request body parameter. By default, - * {@link JSON.stringify()} will be used. - */ - bodySerializer?: BodySerializer | null; - /** - * An object containing any HTTP headers that you want to pre-populate your - * `Headers` object with. - * - * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} - */ - headers?: - | RequestInit['headers'] - | Record< - string, - string | number | boolean | (string | number | boolean)[] | null | undefined | unknown - >; - /** - * The request method. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} - */ - method?: Uppercase; - /** - * A function for serializing request query parameters. By default, arrays - * will be exploded in form style, objects will be exploded in deepObject - * style, and reserved characters are percent-encoded. - * - * This method will have no effect if the native `paramsSerializer()` Axios - * API function is used. - * - * {@link https://swagger.io/docs/specification/serialization/#query View examples} - */ - querySerializer?: QuerySerializer | QuerySerializerOptions; - /** - * A function validating request data. This is useful if you want to ensure - * the request conforms to the desired shape, so it can be safely sent to - * the server. - */ - requestValidator?: (data: unknown) => Promise; - /** - * A function transforming response data before it's returned. This is useful - * for post-processing data, e.g. converting ISO strings into Date objects. - */ - responseTransformer?: (data: unknown) => Promise; - /** - * A function validating response data. This is useful if you want to ensure - * the response conforms to the desired shape, so it can be safely passed to - * the transformers and returned to the user. - */ - responseValidator?: (data: unknown) => Promise; -} - -type IsExactlyNeverOrNeverUndefined = [T] extends [never] - ? true - : [T] extends [never | undefined] - ? [undefined] extends [T] - ? false - : true - : false; - -export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; -}; diff --git a/ui/desktop/src/api/core/utils.gen.ts b/ui/desktop/src/api/core/utils.gen.ts deleted file mode 100644 index e7ddbe3541..0000000000 --- a/ui/desktop/src/api/core/utils.gen.ts +++ /dev/null @@ -1,140 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; -import { - type ArraySeparatorStyle, - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from './pathSerializer.gen'; - -export interface PathSerializer { - path: Record; - url: string; -} - -export const PATH_PARAM_RE = /\{[^{}]+\}/g; - -export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url; - const matches = _url.match(PATH_PARAM_RE); - if (matches) { - for (const match of matches) { - let explode = false; - let name = match.substring(1, match.length - 1); - let style: ArraySeparatorStyle = 'simple'; - - if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); - } - - if (name.startsWith('.')) { - name = name.substring(1); - style = 'label'; - } else if (name.startsWith(';')) { - name = name.substring(1); - style = 'matrix'; - } - - const value = path[name]; - - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - url = url.replace(match, serializeArrayParam({ explode, name, style, value })); - continue; - } - - if (typeof value === 'object') { - url = url.replace( - match, - serializeObjectParam({ - explode, - name, - style, - value: value as Record, - valueOnly: true, - }), - ); - continue; - } - - if (style === 'matrix') { - url = url.replace( - match, - `;${serializePrimitiveParam({ - name, - value: value as string, - })}`, - ); - continue; - } - - const replaceValue = encodeURIComponent( - style === 'label' ? `.${value as string}` : (value as string), - ); - url = url.replace(match, replaceValue); - } - } - return url; -}; - -export const getUrl = ({ - baseUrl, - path, - query, - querySerializer, - url: _url, -}: { - baseUrl?: string; - path?: Record; - query?: Record; - querySerializer: QuerySerializer; - url: string; -}) => { - const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; - let url = (baseUrl ?? '') + pathUrl; - if (path) { - url = defaultPathSerializer({ path, url }); - } - let search = query ? querySerializer(query) : ''; - if (search.startsWith('?')) { - search = search.substring(1); - } - if (search) { - url += `?${search}`; - } - return url; -}; - -export function getValidRequestBody(options: { - body?: unknown; - bodySerializer?: BodySerializer | null; - serializedBody?: unknown; -}) { - const hasBody = options.body !== undefined; - const isSerializedBody = hasBody && options.bodySerializer; - - if (isSerializedBody) { - if ('serializedBody' in options) { - const hasSerializedBody = - options.serializedBody !== undefined && options.serializedBody !== ''; - - return hasSerializedBody ? options.serializedBody : null; - } - - // not all clients implement a serializedBody property (i.e. client-axios) - return options.body !== '' ? options.body : null; - } - - // plain/text body - if (hasBody) { - return options.body; - } - - // no body was provided - return undefined; -} diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts deleted file mode 100644 index afe1f82543..0000000000 --- a/ui/desktop/src/api/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export { addExtension, agentAddExtension, agentRemoveExtension, cancelDownload, checkProvider, cleanupProviderCache, confirmToolAction, createCustomProvider, createSchedule, decodeRecipe, deleteModel, deleteProviderSecret, deleteRecipe, deleteSchedule, diagnostics, downloadModel, encodeRecipe, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModelInfo, getProviderModels, getSession, getSessionExtensions, getSlashCommands, getTools, inspectRunningJob, killRunningJob, listModels, listProviderSecrets, listRecipes, listSchedules, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, recipeToYaml, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, sendTelemetryEvent, sessionCancel, sessionEvents, sessionReply, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, status, stopAgent, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSession, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelRequest, ChatRequest, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponse, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DiagnosticsConfig, DiagnosticsData, DiagnosticsError, DiagnosticsErrors, DiagnosticsExtensions, DiagnosticsLevel, DiagnosticsLogs, DiagnosticsPrompt, DiagnosticsReport, DiagnosticsResponse, DiagnosticsResponses, DiagnosticsScheduledRecipe, DiagnosticsTextFile, DictationProvider, DictationProviderStatus, DownloadModelData, DownloadModelErrors, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GooseApp, GooseMode, Icon, IconTheme, ImageContent, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponse, ListProviderSecretsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, LoadedProvider, McpAppResource, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProviderSecret, ProviderSecretsResponse, ProviderSecretStatus, ProviderSecretStorage, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, Usage, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts deleted file mode 100644 index 4786dcf270..0000000000 --- a/ui/desktop/src/api/sdk.gen.ts +++ /dev/null @@ -1,466 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Client, Options as Options2, TDataShape } from './client'; -import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CheckProviderData, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListModelsData, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionCancelData, SessionCancelResponses, SessionEventsData, SessionEventsErrors, SessionEventsResponses, SessionReplyData, SessionReplyErrors, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; - -export type Options = Options2 & { - /** - * You can provide a client instance returned by `createClient()` instead of - * individual options. This might be also useful if you want to implement a - * custom client. - */ - client?: Client; - /** - * You can pass arbitrary values through the `meta` object. This can be - * used to access values that aren't defined as part of the SDK function. - */ - meta?: Record; -}; - -export const confirmToolAction = (options: Options) => (options.client ?? client).post({ - url: '/action-required/tool-confirmation', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const agentAddExtension = (options: Options) => (options.client ?? client).post({ - url: '/agent/add_extension', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const agentRemoveExtension = (options: Options) => (options.client ?? client).post({ - url: '/agent/remove_extension', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const restartAgent = (options: Options) => (options.client ?? client).post({ - url: '/agent/restart', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const resumeAgent = (options: Options) => (options.client ?? client).post({ - url: '/agent/resume', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const startAgent = (options: Options) => (options.client ?? client).post({ - url: '/agent/start', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const stopAgent = (options: Options) => (options.client ?? client).post({ - url: '/agent/stop', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getTools = (options: Options) => (options.client ?? client).get({ url: '/agent/tools', ...options }); - -export const updateFromSession = (options: Options) => (options.client ?? client).post({ - url: '/agent/update_from_session', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const updateAgentProvider = (options: Options) => (options.client ?? client).post({ - url: '/agent/update_provider', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const updateSession = (options: Options) => (options.client ?? client).post({ - url: '/agent/update_session', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const updateWorkingDir = (options: Options) => (options.client ?? client).post({ - url: '/agent/update_working_dir', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const readAllConfig = (options?: Options) => (options?.client ?? client).get({ url: '/config', ...options }); - -export const getCanonicalModelInfo = (options: Options) => (options.client ?? client).post({ - url: '/config/canonical-model-info', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const checkProvider = (options: Options) => (options.client ?? client).post({ - url: '/config/check_provider', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const createCustomProvider = (options: Options) => (options.client ?? client).post({ - url: '/config/custom-providers', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const removeCustomProvider = (options: Options) => (options.client ?? client).delete({ url: '/config/custom-providers/{id}', ...options }); - -export const getCustomProvider = (options: Options) => (options.client ?? client).get({ url: '/config/custom-providers/{id}', ...options }); - -export const updateCustomProvider = (options: Options) => (options.client ?? client).put({ - url: '/config/custom-providers/{id}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getExtensions = (options?: Options) => (options?.client ?? client).get({ url: '/config/extensions', ...options }); - -export const addExtension = (options: Options) => (options.client ?? client).post({ - url: '/config/extensions', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const removeExtension = (options: Options) => (options.client ?? client).delete({ url: '/config/extensions/{name}', ...options }); - -export const getPrompts = (options?: Options) => (options?.client ?? client).get({ url: '/config/prompts', ...options }); - -export const resetPrompt = (options: Options) => (options.client ?? client).delete({ url: '/config/prompts/{name}', ...options }); - -export const getPrompt = (options: Options) => (options.client ?? client).get({ url: '/config/prompts/{name}', ...options }); - -export const savePrompt = (options: Options) => (options.client ?? client).put({ - url: '/config/prompts/{name}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getProviderCatalog = (options?: Options) => (options?.client ?? client).get({ url: '/config/provider-catalog', ...options }); - -export const getProviderCatalogTemplate = (options: Options) => (options.client ?? client).get({ url: '/config/provider-catalog/{id}', ...options }); - -export const listProviderSecrets = (options?: Options) => (options?.client ?? client).get({ url: '/config/provider-secrets', ...options }); - -export const deleteProviderSecret = (options: Options) => (options.client ?? client).delete({ url: '/config/provider-secrets/{id}', ...options }); - -export const providers = (options?: Options) => (options?.client ?? client).get({ url: '/config/providers', ...options }); - -export const cleanupProviderCache = (options: Options) => (options.client ?? client).post({ url: '/config/providers/{name}/cleanup', ...options }); - -export const getProviderModelInfo = (options: Options) => (options.client ?? client).post({ - url: '/config/providers/{name}/model-info', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getProviderModels = (options: Options) => (options.client ?? client).get({ url: '/config/providers/{name}/models', ...options }); - -export const readConfig = (options: Options) => (options.client ?? client).post({ - url: '/config/read', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const removeConfig = (options: Options) => (options.client ?? client).post({ - url: '/config/remove', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const setConfigProvider = (options: Options) => (options.client ?? client).post({ - url: '/config/set_provider', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getSlashCommands = (options?: Options) => (options?.client ?? client).get({ url: '/config/slash_commands', ...options }); - -export const upsertConfig = (options: Options) => (options.client ?? client).post({ - url: '/config/upsert', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const validateConfig = (options?: Options) => (options?.client ?? client).get({ url: '/config/validate', ...options }); - -export const diagnostics = (options: Options) => (options.client ?? client).get({ url: '/diagnostics/{session_id}', ...options }); - -export const getDictationConfig = (options?: Options) => (options?.client ?? client).get({ url: '/dictation/config', ...options }); - -export const listModels = (options?: Options) => (options?.client ?? client).get({ url: '/dictation/models', ...options }); - -export const deleteModel = (options: Options) => (options.client ?? client).delete({ url: '/dictation/models/{model_id}', ...options }); - -export const cancelDownload = (options: Options) => (options.client ?? client).delete({ url: '/dictation/models/{model_id}/download', ...options }); - -export const getDownloadProgress = (options: Options) => (options.client ?? client).get({ url: '/dictation/models/{model_id}/download', ...options }); - -export const downloadModel = (options: Options) => (options.client ?? client).post({ url: '/dictation/models/{model_id}/download', ...options }); - -export const transcribeDictation = (options: Options) => (options.client ?? client).post({ - url: '/dictation/transcribe', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const decodeRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/decode', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const deleteRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/delete', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const encodeRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/encode', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const listRecipes = (options?: Options) => (options?.client ?? client).get({ url: '/recipes/list', ...options }); - -export const parseRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/parse', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const saveRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/save', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const scanRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/scan', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const scheduleRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/schedule', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const setRecipeSlashCommand = (options: Options) => (options.client ?? client).post({ - url: '/recipes/slash-command', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const recipeToYaml = (options: Options) => (options.client ?? client).post({ - url: '/recipes/to-yaml', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const reply = (options: Options) => (options.client ?? client).sse.post({ - url: '/reply', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const createSchedule = (options: Options) => (options.client ?? client).post({ - url: '/schedule/create', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const deleteSchedule = (options: Options) => (options.client ?? client).delete({ url: '/schedule/delete/{id}', ...options }); - -export const listSchedules = (options?: Options) => (options?.client ?? client).get({ url: '/schedule/list', ...options }); - -export const updateSchedule = (options: Options) => (options.client ?? client).put({ - url: '/schedule/{id}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const inspectRunningJob = (options: Options) => (options.client ?? client).get({ url: '/schedule/{id}/inspect', ...options }); - -export const killRunningJob = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/kill', ...options }); - -export const pauseSchedule = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/pause', ...options }); - -export const runNowHandler = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/run_now', ...options }); - -export const sessionsHandler = (options: Options) => (options.client ?? client).get({ url: '/schedule/{id}/sessions', ...options }); - -export const unpauseSchedule = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/unpause', ...options }); - -export const sessionCancel = (options: Options) => (options.client ?? client).post({ - url: '/sessions/{id}/cancel', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const sessionEvents = (options: Options) => (options.client ?? client).sse.get({ url: '/sessions/{id}/events', ...options }); - -export const sessionReply = (options: Options) => (options.client ?? client).post({ - url: '/sessions/{id}/reply', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getSession = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}', ...options }); - -export const getSessionExtensions = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/extensions', ...options }); - -export const forkSession = (options: Options) => (options.client ?? client).post({ - url: '/sessions/{session_id}/fork', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const updateSessionName = (options: Options) => (options.client ?? client).put({ - url: '/sessions/{session_id}/name', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const updateSessionUserRecipeValues = (options: Options) => (options.client ?? client).put({ - url: '/sessions/{session_id}/user_recipe_values', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const status = (options?: Options) => (options?.client ?? client).get({ url: '/status', ...options }); - -export const systemInfo = (options?: Options) => (options?.client ?? client).get({ url: '/system_info', ...options }); - -export const sendTelemetryEvent = (options: Options) => (options.client ?? client).post({ - url: '/telemetry/event', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts deleted file mode 100644 index 4e1d84d9e5..0000000000 --- a/ui/desktop/src/api/types.gen.ts +++ /dev/null @@ -1,3798 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type ClientOptions = { - baseUrl: `${string}://${string}` | (string & {}); -}; - -export type ActionRequired = { - data: ActionRequiredData; -}; - -export type ActionRequiredData = { - actionType: 'toolConfirmation'; - arguments: JsonObject; - id: string; - prompt?: string | null; - toolName: string; -} | { - actionType: 'elicitation'; - id: string; - message: string; - requested_schema: unknown; -} | { - action?: string; - actionType: 'elicitationResponse'; - id: string; - user_data: unknown; -}; - -export type AddExtensionRequest = { - config: ExtensionConfig; - session_id: string; -}; - -export type Annotations = { - audience?: Array; - lastModified?: string; - priority?: number; -}; - -export type Author = { - contact?: string | null; - metadata?: string | null; -}; - -export type CancelRequest = { - request_id: string; -}; - -export type ChatRequest = { - /** - * Override the server's conversation history. Only use this when you need absolute control - * over the conversation state (e.g., administrative tools). For normal operations, the server - * is the source of truth - use truncate/fork endpoints to modify conversation history instead. - */ - override_conversation?: Array | null; - recipe_name?: string | null; - recipe_version?: string | null; - session_id: string; - user_message: Message; -}; - -export type CheckProviderRequest = { - provider: string; -}; - -export type CommandType = 'Builtin' | 'Recipe' | 'Skill' | 'Agent'; - -/** - * Configuration key metadata for provider setup - */ -export type ConfigKey = { - /** - * Optional default value for the key - */ - default?: string | null; - /** - * Whether this OAuth flow uses the device code grant (RFC 8628) - * When true, the user must enter a verification code in the browser - */ - device_code_flow?: boolean; - /** - * The name of the configuration key (e.g., "API_KEY") - */ - name: string; - /** - * Whether this key should be configured using an OAuth flow - * When true, the provider's configure_oauth() method will be called instead of prompting for manual input - */ - oauth_flow: boolean; - /** - * Whether this key should be shown prominently during provider setup - * (onboarding, settings modal, CLI configure) - */ - primary?: boolean; - /** - * Whether this key is required for the provider to function - */ - required: boolean; - /** - * Whether this key should be stored securely (e.g., in keychain) - */ - secret: boolean; -}; - -export type ConfigKeyQuery = { - is_secret: boolean; - key: string; -}; - -export type ConfigResponse = { - config: { - [key: string]: unknown; - }; -}; - -export type ConfirmToolActionRequest = { - action: Permission; - id: string; - principalType?: PrincipalType; - sessionId: string; -}; - -export type Content = ({ - type: 'text'; -} & RawTextContent) | ({ - type: 'image'; -} & RawImageContent) | ({ - type: 'resource'; -} & RawEmbeddedResource) | ({ - type: 'audio'; -} & RawAudioContent) | ({ - type: 'resource_link'; -} & RawResource); - -export type ContentBlock = ({ - type: 'text'; -} & RawTextContent) | ({ - type: 'image'; -} & RawImageContent) | ({ - type: 'resource'; -} & RawEmbeddedResource) | ({ - type: 'audio'; -} & RawAudioContent) | ({ - type: 'resource_link'; -} & RawResource); - -export type Conversation = Array; - -export type CreateCustomProviderResponse = { - provider_name: string; -}; - -export type CreateScheduleRequest = { - cron: string; - id: string; - recipe: Recipe; -}; - -/** - * Content Security Policy metadata for MCP Apps - * Specifies allowed domains for network connections and resource loading - */ -export type CspMetadata = { - /** - * Domains allowed for base-uri - */ - baseUriDomains?: Array | null; - /** - * Domains allowed for connect-src (fetch, XHR, WebSocket) - */ - connectDomains?: Array | null; - /** - * Domains allowed for frame-src (nested iframes) - */ - frameDomains?: Array | null; - /** - * Domains allowed for resource loading (scripts, styles, images, fonts, media) - */ - resourceDomains?: Array | null; -}; - -export type DeclarativeProviderConfig = { - api_key_env?: string; - base_path?: string | null; - base_url: string; - catalog_provider_id?: string | null; - description?: string | null; - display_name: string; - /** - * Controls whether `fetch_supported_models` calls the provider's `/v1/models` - * endpoint or returns the static `models` list directly. - * - * - `Some(false)` + non-empty `models`: return the static list; no API call. - * Construction fails if `models` is empty. - * - `Some(true)` or `None`: try the API; fall back to `models` on 404. - */ - dynamic_models?: boolean | null; - engine: ProviderEngine; - env_vars?: Array | null; - fast_model?: string | null; - headers?: { - [key: string]: string; - } | null; - model_doc_link?: string | null; - models: Array; - name: string; - preserves_thinking?: boolean; - requires_auth?: boolean; - setup_steps?: Array; - skip_canonical_filtering?: boolean; - supports_streaming?: boolean | null; - timeout_seconds?: number | null; -}; - -export type DecodeRecipeRequest = { - deeplink: string; -}; - -export type DecodeRecipeResponse = { - recipe: Recipe; -}; - -export type DeleteRecipeRequest = { - id: string; -}; - -export type DiagnosticsConfig = { - configPath: string; - configYaml?: string | null; - truncated: boolean; -}; - -export type DiagnosticsError = { - message: string; - path?: string | null; -}; - -export type DiagnosticsExtensions = { - enabled: Array; -}; - -export type DiagnosticsLevel = 'summary' | 'full'; - -export type DiagnosticsLogs = { - llm: Array; - server?: DiagnosticsTextFile | null; -}; - -export type DiagnosticsPrompt = { - content: string; - name: string; -}; - -export type DiagnosticsReport = { - config?: DiagnosticsConfig | null; - errors: Array; - extensions: DiagnosticsExtensions; - generatedAt: string; - level: DiagnosticsLevel; - logs: DiagnosticsLogs; - prompts: Array; - schedule?: unknown; - scheduledRecipes: Array; - schemaVersion: number; - session?: unknown; - system: SystemInfo; -}; - -export type DiagnosticsScheduledRecipe = { - content: string; - path: string; -}; - -export type DiagnosticsTextFile = { - content: string; - path: string; - truncated: boolean; -}; - -export type DictationProvider = 'openai' | 'elevenlabs' | 'groq' | 'local'; - -export type DictationProviderStatus = { - /** - * Config key name if uses_provider_config is false - */ - config_key?: string | null; - /** - * Whether the provider is fully configured and ready to use - */ - configured: boolean; - /** - * Description of what this provider does - */ - description: string; - /** - * Custom host URL if configured (only for providers that support it) - */ - host?: string | null; - /** - * Path to settings if uses_provider_config is true - */ - settings_path?: string | null; - /** - * Whether this provider uses the main provider config (true) or has its own key (false) - */ - uses_provider_config: boolean; -}; - -export type DownloadProgress = { - /** - * Bytes downloaded so far - */ - bytes_downloaded: number; - /** - * Error message if failed - */ - error?: string | null; - /** - * Estimated time remaining in seconds - */ - eta_seconds?: number | null; - /** - * Model ID being downloaded - */ - model_id: string; - /** - * Download progress percentage (0-100) - */ - progress_percent: number; - /** - * Download speed in bytes per second - */ - speed_bps?: number | null; - status: DownloadStatus; - /** - * Total bytes to download - */ - total_bytes: number; -}; - -export type DownloadStatus = 'downloading' | 'completed' | 'failed' | 'cancelled'; - -export type EmbeddedResource = { - _meta?: { - [key: string]: unknown; - }; - annotations?: Annotations | { - [key: string]: unknown; - }; - resource: ResourceContents; -}; - -export type EncodeRecipeRequest = { - recipe: Recipe; -}; - -export type EncodeRecipeResponse = { - deeplink: string; -}; - -export type EnvVarConfig = { - default?: string | null; - description?: string | null; - name: string; - /** - * Defaults to the value of `required` if not specified. - * UIs may use this to feature this config value more prominently. - */ - primary?: boolean | null; - required?: boolean; - secret?: boolean; -}; - -export type Envs = { - [key: string]: string; -}; - -export type ErrorResponse = { - message: string; -}; - -/** - * Represents the different types of MCP extensions that can be added to the manager - */ -export type ExtensionConfig = { - description: string; - name: string; - type: 'sse'; - uri?: string | null; -} | { - args: Array; - available_tools?: Array; - bundled?: boolean | null; - cmd: string; - cwd?: string | null; - description: string; - env_keys?: Array; - envs?: Envs; - /** - * The name used to identify this extension - */ - name: string; - timeout?: number | null; - type: 'stdio'; -} | { - available_tools?: Array; - bundled?: boolean | null; - description: string; - display_name?: string | null; - /** - * The name used to identify this extension - */ - name: string; - timeout?: number | null; - type: 'builtin'; -} | { - available_tools?: Array; - bundled?: boolean | null; - description: string; - display_name?: string | null; - /** - * The name used to identify this extension - */ - name: string; - type: 'platform'; -} | { - available_tools?: Array; - bundled?: boolean | null; - description: string; - env_keys?: Array; - envs?: Envs; - headers?: { - [key: string]: string; - }; - /** - * The name used to identify this extension - */ - name: string; - /** - * Optional Unix domain socket path for HTTP-over-UDS transport. - * When set, the HTTP connection is routed through this socket while - * `uri` is used for the Host header and path. - * Use `@name` for Linux abstract sockets. - */ - socket?: string | null; - timeout?: number | null; - type: 'streamable_http'; - uri: string; -} | { - available_tools?: Array; - bundled?: boolean | null; - description: string; - /** - * Instructions for how to use these tools - */ - instructions?: string | null; - /** - * The name used to identify this extension - */ - name: string; - /** - * The tools provided by the frontend - */ - tools: Array; - type: 'frontend'; -} | { - available_tools?: Array; - /** - * The Python code to execute - */ - code: string; - /** - * Python package dependencies required by this extension - */ - dependencies?: Array | null; - description: string; - /** - * The name used to identify this extension - */ - name: string; - /** - * Timeout in seconds - */ - timeout?: number | null; - type: 'inline_python'; -}; - -/** - * Extension data containing all extension states - * Keys are in format "extension_name.version" (e.g., "todo.v0") - */ -export type ExtensionData = { - [key: string]: unknown; -}; - -export type ExtensionEntry = ExtensionConfig & { - enabled: boolean; -}; - -export type ExtensionLoadResult = { - error?: string | null; - name: string; - success: boolean; -}; - -export type ExtensionQuery = { - config: ExtensionConfig; - enabled: boolean; - name: string; -}; - -export type ExtensionResponse = { - extensions: Array; - warnings?: Array; -}; - -export type ForkRequest = { - copy: boolean; - timestamp?: number | null; - truncate: boolean; -}; - -export type ForkResponse = { - sessionId: string; -}; - -export type FrontendToolRequest = { - id: string; - toolCall: { - [key: string]: unknown; - }; -}; - -export type GetToolsQuery = { - extension_name?: string | null; - session_id: string; -}; - -export type GooseApp = McpAppResource & (WindowProps | null) & { - mcpServers?: Array; - prd?: string | null; -}; - -export type GooseMode = 'auto' | 'approve' | 'smart_approve' | 'chat'; - -export type Icon = { - mimeType?: string; - sizes?: Array; - src: string; - theme?: IconTheme | { - [key: string]: unknown; - }; -}; - -export type IconTheme = 'light' | 'dark'; - -export type ImageContent = { - _meta?: { - [key: string]: unknown; - }; - annotations?: Annotations | { - [key: string]: unknown; - }; - data: string; - mimeType: string; -}; - -export type InferenceMetadata = { - provider: string; - requestedModel: string; - resolvedModel?: string | null; -}; - -export type InspectJobResponse = { - processStartTime?: string | null; - runningDurationSeconds?: number | null; - sessionId?: string | null; -}; - -export type JsonObject = { - [key: string]: unknown; -}; - -export type KillJobResponse = { - message: string; -}; - -export type ListRecipeResponse = { - manifests: Array; -}; - -export type ListSchedulesResponse = { - jobs: Array; -}; - -export type LoadedProvider = { - config: DeclarativeProviderConfig; - is_editable: boolean; -}; - -/** - * MCP App Resource - * Represents a UI resource that can be rendered in an MCP App - */ -export type McpAppResource = { - _meta?: ResourceMetadata | null; - /** - * Base64-encoded binary content (alternative to text) - */ - blob?: string | null; - /** - * Optional description of what this resource does - */ - description?: string | null; - /** - * MIME type (should be "text/html;profile=mcp-app" for MCP Apps) - */ - mimeType: string; - /** - * Human-readable name of the resource - */ - name: string; - /** - * Text content of the resource (HTML for MCP Apps) - */ - text?: string | null; - /** - * URI of the resource (must use ui:// scheme) - */ - uri: string; -}; - -/** - * A message to or from an LLM - */ -export type Message = { - content: Array; - created: number; - id?: string | null; - metadata: MessageMetadata; - role: Role; -}; - -/** - * Content passed inside a message, which can be both simple content and tool content - */ -export type MessageContent = (TextContent & { - type: 'text'; -}) | (ImageContent & { - type: 'image'; -}) | (ToolRequest & { - type: 'toolRequest'; -}) | (ToolResponse & { - type: 'toolResponse'; -}) | (ToolConfirmationRequest & { - type: 'toolConfirmationRequest'; -}) | (ActionRequired & { - type: 'actionRequired'; -}) | (FrontendToolRequest & { - type: 'frontendToolRequest'; -}) | (ThinkingContent & { - type: 'thinking'; -}) | (RedactedThinkingContent & { - type: 'redactedThinking'; -}) | (SystemNotificationContent & { - type: 'systemNotification'; -}); - -export type MessageEvent = { - message: Message; - token_state: TokenState; - type: 'Message'; -} | { - error: string; - type: 'Error'; -} | { - reason: string; - token_state: TokenState; - type: 'Finish'; -} | { - message: { - [key: string]: unknown; - }; - request_id: string; - type: 'Notification'; -} | { - conversation: Conversation; - type: 'UpdateConversation'; -} | { - request_ids: Array; - type: 'ActiveRequests'; -} | { - type: 'Ping'; -}; - -/** - * Metadata for message visibility and model inference details - */ -export type MessageMetadata = { - /** - * Whether the message should be included in the agent's context window - */ - agentVisible: boolean; - inference?: InferenceMetadata | null; - /** - * Whether this message is a steer injected into an active run. UI-only: - * surfaced as `_meta.goose.steer` so clients can mark the steer boundary - * without matching user-visible text. Never sent to providers. - */ - steer?: boolean; - /** - * Whether the message should be visible to the user in the UI - */ - userVisible: boolean; -}; - -export type ModelCapabilities = { - attachment: boolean; - reasoning: boolean; - temperature: boolean; - tool_call: boolean; -}; - -export type ModelConfig = { - context_limit?: number | null; - max_tokens?: number | null; - model_name: string; - reasoning?: boolean | null; - /** - * Provider-specific request parameters (e.g., anthropic_beta headers) - */ - request_params?: { - [key: string]: unknown; - } | null; - temperature?: number | null; - toolshim: boolean; - toolshim_model?: string | null; -}; - -/** - * Information about a model's capabilities - */ -export type ModelInfo = { - /** - * The maximum context length this model supports - */ - context_limit: number; - /** - * Currency for the costs (default: "$") - */ - currency?: string | null; - /** - * Cost per token for input in USD (optional) - */ - input_token_cost?: number | null; - /** - * The name of the model - */ - name: string; - /** - * Cost per token for output in USD (optional) - */ - output_token_cost?: number | null; - /** - * Whether this model supports reasoning/thinking controls - */ - reasoning?: boolean; - /** - * The underlying model resolved from provider metadata, when the configured model is an alias or endpoint. - */ - resolved_model?: string | null; - /** - * Whether this model supports cache control - */ - supports_cache_control?: boolean | null; -}; - -export type ModelInfoData = { - cache_read_token_cost?: number | null; - cache_write_token_cost?: number | null; - context_limit: number; - currency: string; - input_token_cost?: number | null; - max_output_tokens?: number | null; - model: string; - output_token_cost?: number | null; - provider: string; - reasoning: boolean; -}; - -export type ModelInfoQuery = { - model: string; - provider: string; -}; - -export type ModelInfoResponse = { - model_info?: ModelInfoData | null; - source: string; -}; - -export type ModelTemplate = { - capabilities: ModelCapabilities; - context_limit: number; - deprecated: boolean; - id: string; - name: string; -}; - -export type ParseRecipeRequest = { - content: string; -}; - -export type ParseRecipeResponse = { - recipe: Recipe; -}; - -export type Permission = 'always_allow' | 'allow_once' | 'cancel' | 'deny_once' | 'always_deny'; - -/** - * Enum representing the possible permission levels for a tool. - */ -export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow'; - -/** - * Sandbox permissions for MCP Apps - * Specifies which browser capabilities the UI needs access to. - * Maps to the iframe Permission Policy `allow` attribute. - */ -export type PermissionsMetadata = { - /** - * Request camera access (maps to Permission Policy `camera` feature) - */ - camera?: boolean; - /** - * Request clipboard write access (maps to Permission Policy `clipboard-write` feature) - */ - clipboardWrite?: boolean; - /** - * Request geolocation access (maps to Permission Policy `geolocation` feature) - */ - geolocation?: boolean; - /** - * Request microphone access (maps to Permission Policy `microphone` feature) - */ - microphone?: boolean; -}; - -export type PrincipalType = 'Extension' | 'Tool'; - -export type PromptContentResponse = { - content: string; - default_content: string; - is_customized: boolean; - name: string; -}; - -export type PromptsListResponse = { - prompts: Array