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/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index 411290e803..c2a26bf352 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -156,6 +156,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 9d0f91aae7..f431a34bc7 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -190,6 +190,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/canary.yml b/.github/workflows/canary.yml index f6415e690d..f93599880c 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/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/.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/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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2af367473..6057f8bc0e 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,10 +124,17 @@ 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@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 @@ -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/.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 }} 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/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/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/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/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] 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-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/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/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index 8c675f521e..7ba1141a0b 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 @@ -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, ) -> 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(); 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. 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" }, 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. 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/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/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