feat: feature-gate local inference dependencies (#7976)

Signed-off-by: DaeHee Lee <lee111dae11@proton.me>
Signed-off-by: jh-block <jhugo@block.xyz>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jh-block <jhugo@block.xyz>
This commit is contained in:
이대희
2026-03-24 00:49:24 +09:00
committed by GitHub
parent 79f539f8af
commit c493c6160c
23 changed files with 372 additions and 100 deletions
+3 -2
View File
@@ -68,9 +68,10 @@ sigstore-verify = { version = "0.6", default-features = false, features = ["rust
winapi = { version = "0.3", features = ["wincred"] }
[features]
default = ["code-mode"]
default = ["code-mode", "local-inference"]
code-mode = ["goose/code-mode", "goose-acp/code-mode"]
cuda = ["goose/cuda"]
local-inference = ["goose/local-inference"]
cuda = ["goose/cuda", "local-inference"]
# disables the update command
disable-update = []
+5
View File
@@ -865,6 +865,7 @@ enum Command {
command: TermCommand,
},
/// Manage local inference models
#[cfg(feature = "local-inference")]
#[command(about = "Manage local inference models", visible_alias = "lm")]
LocalModels {
#[command(subcommand)]
@@ -892,6 +893,7 @@ enum Command {
},
}
#[cfg(feature = "local-inference")]
#[derive(Subcommand)]
enum LocalModelsCommand {
/// Search HuggingFace for GGUF models
@@ -1013,6 +1015,7 @@ fn get_command_name(command: &Option<Command>) -> &'static str {
Some(Command::Update { .. }) => "update",
Some(Command::Recipe { .. }) => "recipe",
Some(Command::Term { .. }) => "term",
#[cfg(feature = "local-inference")]
Some(Command::LocalModels { .. }) => "local-models",
Some(Command::Completion { .. }) => "completion",
Some(Command::ValidateExtensions { .. }) => "validate-extensions",
@@ -1473,6 +1476,7 @@ async fn handle_term_subcommand(command: TermCommand) -> Result<()> {
}
}
#[cfg(feature = "local-inference")]
async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> {
use goose::providers::local_inference::hf_models;
use goose::providers::local_inference::local_model_registry::{
@@ -1759,6 +1763,7 @@ pub async fn cli() -> anyhow::Result<()> {
}
Some(Command::Recipe { command }) => handle_recipe_subcommand(command),
Some(Command::Term { command }) => handle_term_subcommand(command).await,
#[cfg(feature = "local-inference")]
Some(Command::LocalModels { command }) => handle_local_models_command(command).await,
Some(Command::ValidateExtensions { file }) => {
use goose::agents::validate_extensions::validate_bundled_extensions;
+3 -2
View File
@@ -11,9 +11,10 @@ description.workspace = true
workspace = true
[features]
default = ["code-mode"]
default = ["code-mode", "local-inference"]
code-mode = ["goose/code-mode"]
cuda = ["goose/cuda"]
local-inference = ["goose/local-inference"]
cuda = ["goose/cuda", "local-inference"]
[dependencies]
goose = { path = "../goose", default-features = false }
+1
View File
@@ -12,6 +12,7 @@ pub async fn check_token(
next: Next,
) -> Result<Response, StatusCode> {
if request.uri().path() == "/status"
|| request.uri().path() == "/features"
|| request.uri().path() == "/mcp-ui-proxy"
|| request.uri().path() == "/mcp-app-proxy"
|| request.uri().path() == "/mcp-app-guest"
+35 -18
View File
@@ -479,20 +479,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::telemetry::send_telemetry_event,
super::routes::dictation::transcribe_dictation,
super::routes::dictation::get_dictation_config,
super::routes::dictation::list_models,
super::routes::dictation::download_model,
super::routes::dictation::get_download_progress,
super::routes::dictation::cancel_download,
super::routes::dictation::delete_model,
super::routes::local_inference::list_local_models,
super::routes::local_inference::search_hf_models,
super::routes::local_inference::get_repo_files,
super::routes::local_inference::download_hf_model,
super::routes::local_inference::get_local_model_download_progress,
super::routes::local_inference::cancel_local_model_download,
super::routes::local_inference::delete_local_model,
super::routes::local_inference::get_model_settings,
super::routes::local_inference::update_model_settings,
super::routes::features::get_features,
),
components(schemas(
super::routes::config_management::UpsertConfigQuery,
@@ -671,6 +658,33 @@ derive_utoipa!(Icon as IconSchema);
super::routes::dictation::TranscribeResponse,
goose::dictation::providers::DictationProvider,
super::routes::dictation::DictationProviderStatus,
super::routes::features::FeaturesResponse,
DownloadProgress,
DownloadStatus,
))
)]
pub struct ApiDoc;
#[cfg(feature = "local-inference")]
#[derive(OpenApi)]
#[openapi(
paths(
super::routes::dictation::list_models,
super::routes::dictation::download_model,
super::routes::dictation::get_download_progress,
super::routes::dictation::cancel_download,
super::routes::dictation::delete_model,
super::routes::local_inference::list_local_models,
super::routes::local_inference::search_hf_models,
super::routes::local_inference::get_repo_files,
super::routes::local_inference::download_hf_model,
super::routes::local_inference::get_local_model_download_progress,
super::routes::local_inference::cancel_local_model_download,
super::routes::local_inference::delete_local_model,
super::routes::local_inference::get_model_settings,
super::routes::local_inference::update_model_settings,
),
components(schemas(
super::routes::dictation::WhisperModelResponse,
super::routes::local_inference::LocalModelResponse,
super::routes::local_inference::ModelDownloadStatus,
@@ -681,14 +695,17 @@ derive_utoipa!(Icon as IconSchema);
super::routes::local_inference::RepoVariantsResponse,
goose::providers::local_inference::local_model_registry::ModelSettings,
goose::providers::local_inference::local_model_registry::SamplingConfig,
DownloadProgress,
DownloadStatus,
))
)]
pub struct ApiDoc;
pub struct LocalInferenceApiDoc;
#[allow(dead_code)] // Used by generate_schema binary
pub fn generate_schema() -> String {
let api_doc = ApiDoc::openapi();
#[allow(unused_mut)]
let mut api_doc = ApiDoc::openapi();
#[cfg(feature = "local-inference")]
api_doc.merge(LocalInferenceApiDoc::openapi());
serde_json::to_string_pretty(&api_doc).unwrap()
}
+25 -7
View File
@@ -1,16 +1,22 @@
use crate::routes::errors::ErrorResponse;
use crate::state::AppState;
use axum::{
extract::{DefaultBodyLimit, Path},
extract::DefaultBodyLimit,
http::StatusCode,
routing::{delete, get, post},
routing::{get, post},
Json, Router,
};
#[cfg(feature = "local-inference")]
use axum::{extract::Path, routing::delete};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
#[cfg(feature = "local-inference")]
use goose::dictation::providers::transcribe_local;
use goose::dictation::providers::{
is_configured, transcribe_local, transcribe_with_provider, DictationProvider, PROVIDERS,
all_providers, is_configured, transcribe_with_provider, DictationProvider,
};
#[cfg(feature = "local-inference")]
use goose::dictation::whisper;
#[cfg(feature = "local-inference")]
use goose::download_manager::{get_download_manager, DownloadProgress};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -19,6 +25,7 @@ use utoipa::ToSchema;
const MAX_AUDIO_SIZE_BYTES: usize = 50 * 1024 * 1024;
#[cfg(feature = "local-inference")]
#[derive(Debug, Serialize, ToSchema)]
pub struct WhisperModelResponse {
#[serde(flatten)]
@@ -171,6 +178,7 @@ pub async fn transcribe_dictation(
)
.await
.map_err(convert_error)?,
#[cfg(feature = "local-inference")]
DictationProvider::Local => transcribe_local(audio_bytes).await.map_err(convert_error)?,
};
@@ -189,7 +197,7 @@ pub async fn get_dictation_config(
let config = goose::config::Config::global();
let mut providers = HashMap::new();
for def in PROVIDERS {
for def in all_providers() {
let provider = def.provider;
let configured = is_configured(provider);
@@ -222,6 +230,7 @@ pub async fn get_dictation_config(
Ok(Json(providers))
}
#[cfg(feature = "local-inference")]
#[utoipa::path(
get,
path = "/dictation/models",
@@ -243,6 +252,7 @@ pub async fn list_models() -> Result<Json<Vec<WhisperModelResponse>>, ErrorRespo
Ok(Json(models))
}
#[cfg(feature = "local-inference")]
#[utoipa::path(
post,
path = "/dictation/models/{model_id}/download",
@@ -274,6 +284,7 @@ pub async fn download_model(Path(model_id): Path<String>) -> Result<StatusCode,
Ok(StatusCode::ACCEPTED)
}
#[cfg(feature = "local-inference")]
#[utoipa::path(
get,
path = "/dictation/models/{model_id}/download",
@@ -293,6 +304,7 @@ pub async fn get_download_progress(
Ok(Json(progress))
}
#[cfg(feature = "local-inference")]
#[utoipa::path(
delete,
path = "/dictation/models/{model_id}/download",
@@ -307,6 +319,7 @@ pub async fn cancel_download(Path(model_id): Path<String>) -> Result<StatusCode,
Ok(StatusCode::OK)
}
#[cfg(feature = "local-inference")]
#[utoipa::path(
delete,
path = "/dictation/models/{model_id}",
@@ -334,9 +347,12 @@ pub async fn delete_model(Path(model_id): Path<String>) -> Result<StatusCode, Er
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
let router = Router::new()
.route("/dictation/transcribe", post(transcribe_dictation))
.route("/dictation/config", get(get_dictation_config))
.route("/dictation/config", get(get_dictation_config));
#[cfg(feature = "local-inference")]
let router = router
.route("/dictation/models", get(list_models))
.route(
"/dictation/models/{model_id}/download",
@@ -350,7 +366,9 @@ pub fn routes(state: Arc<AppState>) -> Router {
"/dictation/models/{model_id}/download",
delete(cancel_download),
)
.route("/dictation/models/{model_id}", delete(delete_model))
.route("/dictation/models/{model_id}", delete(delete_model));
router
.layer(DefaultBodyLimit::max(MAX_AUDIO_SIZE_BYTES))
.with_state(state)
}
@@ -0,0 +1,33 @@
use axum::{routing::get, Json, Router};
use serde::Serialize;
use std::collections::HashMap;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct FeaturesResponse {
/// Map of feature name to enabled status
pub features: HashMap<String, bool>,
}
#[utoipa::path(
get,
path = "/features",
responses(
(status = 200, description = "Compile-time feature flags", body = FeaturesResponse),
)
)]
pub async fn get_features() -> Json<FeaturesResponse> {
let mut features = HashMap::new();
features.insert(
"local-inference".to_string(),
cfg!(feature = "local-inference"),
);
features.insert("code-mode".to_string(), cfg!(feature = "code-mode"));
Json(FeaturesResponse { features })
}
pub fn routes() -> Router {
Router::new().route("/features", get(get_features))
}
+11 -4
View File
@@ -3,7 +3,9 @@ pub mod agent;
pub mod config_management;
pub mod dictation;
pub mod errors;
pub mod features;
pub mod gateway;
#[cfg(feature = "local-inference")]
pub mod local_inference;
pub mod mcp_app_proxy;
pub mod mcp_ui_proxy;
@@ -27,13 +29,11 @@ use axum::Router;
// Function to configure all routes
pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Router {
Router::new()
let router = Router::new()
.merge(status::routes(state.clone()))
.merge(reply::routes(state.clone()))
.merge(action_required::routes(state.clone()))
.merge(agent::routes(state.clone()))
.merge(dictation::routes(state.clone()))
.merge(local_inference::routes(state.clone()))
.merge(config_management::routes(state.clone()))
.merge(prompts::routes())
.merge(recipe::routes(state.clone()))
@@ -46,5 +46,12 @@ pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Rout
.merge(mcp_ui_proxy::routes(secret_key.clone()))
.merge(mcp_app_proxy::routes(secret_key))
.merge(session_events::routes(state.clone()))
.merge(sampling::routes(state))
.merge(sampling::routes(state.clone()))
.merge(dictation::routes(state.clone()))
.merge(features::routes());
#[cfg(feature = "local-inference")]
let router = router.merge(local_inference::routes(state));
router
}
+3
View File
@@ -13,6 +13,7 @@ use crate::session_event_bus::SessionEventBus;
use crate::tunnel::TunnelManager;
use goose::agents::ExtensionLoadResult;
use goose::gateway::manager::GatewayManager;
#[cfg(feature = "local-inference")]
use goose::providers::local_inference::InferenceRuntime;
type ExtensionLoadingTasks =
@@ -26,6 +27,7 @@ pub struct AppState {
pub tunnel_manager: Arc<TunnelManager>,
pub gateway_manager: Arc<GatewayManager>,
pub extension_loading_tasks: ExtensionLoadingTasks,
#[cfg(feature = "local-inference")]
pub inference_runtime: Arc<InferenceRuntime>,
session_buses: Arc<Mutex<HashMap<String, Arc<SessionEventBus>>>>,
}
@@ -45,6 +47,7 @@ impl AppState {
tunnel_manager,
gateway_manager,
extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())),
#[cfg(feature = "local-inference")]
inference_runtime: InferenceRuntime::get_or_init(),
session_buses: Arc::new(Mutex::new(HashMap::new())),
}))
+28 -14
View File
@@ -8,9 +8,19 @@ repository.workspace = true
description.workspace = true
[features]
default = ["code-mode"]
default = ["code-mode", "local-inference"]
code-mode = ["dep:pctx_code_mode"]
cuda = ["candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"]
local-inference = [
"dep:candle-core",
"dep:candle-nn",
"dep:candle-transformers",
"dep:llama-cpp-2",
"dep:tokenizers",
"dep:symphonia",
"dep:rubato",
"dep:byteorder",
]
cuda = ["local-inference", "candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"]
[lints]
workspace = true
@@ -108,14 +118,14 @@ agent-client-protocol-schema = { workspace = true }
sacp = { workspace = true, features = ["unstable"] }
unicode-normalization = "0.1"
# For local Whisper transcription
candle-core = { version = "0.9", default-features = false }
candle-nn = { version = "0.9", default-features = false }
candle-transformers = { version = "0.9", default-features = false }
byteorder = "1.5.0"
tokenizers = { version = "0.21.0", default-features = false, features = ["onig"] }
symphonia = { version = "0.5", features = ["all"] }
rubato = "0.16"
# For local Whisper transcription (optional, behind "local-inference" feature)
candle-core = { version = "0.9", default-features = false, optional = true }
candle-nn = { version = "0.9", default-features = false, optional = true }
candle-transformers = { version = "0.9", default-features = false, optional = true }
byteorder = { version = "1.5.0", optional = true }
tokenizers = { version = "0.21.0", default-features = false, features = ["onig"], optional = true }
symphonia = { version = "0.5", features = ["all"], optional = true }
rubato = { version = "0.16", optional = true }
zip = "0.6"
sys-info = "0.9"
@@ -140,7 +150,7 @@ tree-sitter-typescript = { workspace = true }
which = { workspace = true }
pctx_code_mode = { version = "^0.3.0", optional = true }
pulldown-cmark = "0.13.0"
llama-cpp-2 = { version = "0.1.137", features = ["sampler"] }
llama-cpp-2 = { version = "0.1.137", features = ["sampler"], optional = true }
encoding_rs = "0.8.35"
pastey = "0.2.1"
shell-words = "1.1.1"
@@ -150,9 +160,9 @@ winapi = { version = "0.3", features = ["wincred"] }
# Platform-specific GPU acceleration for Whisper and local inference
[target.'cfg(target_os = "macos")'.dependencies]
candle-core = { version = "0.9", default-features = false, features = ["metal"] }
candle-nn = { version = "0.9", default-features = false, features = ["metal"] }
llama-cpp-2 = { version = "0.1.137", features = ["sampler", "metal"] }
candle-core = { version = "0.9", default-features = false, features = ["metal"], optional = true }
candle-nn = { version = "0.9", default-features = false, features = ["metal"], optional = true }
llama-cpp-2 = { version = "0.1.137", features = ["sampler", "metal"], optional = true }
[dev-dependencies]
serial_test = { workspace = true }
@@ -176,6 +186,10 @@ path = "examples/agent.rs"
name = "databricks_oauth"
path = "examples/databricks_oauth.rs"
[[example]]
name = "test_whisper"
path = "examples/test_whisper.rs"
required-features = ["local-inference"]
[[bin]]
name = "analyze_cli"
+1
View File
@@ -1,2 +1,3 @@
pub mod providers;
#[cfg(feature = "local-inference")]
pub mod whisper;
+38 -10
View File
@@ -1,18 +1,22 @@
use crate::config::Config;
#[cfg(feature = "local-inference")]
use crate::dictation::whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY;
use crate::providers::api_client::{ApiClient, AuthMethod};
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[cfg(feature = "local-inference")]
use std::sync::Mutex;
use std::time::Duration;
use utoipa::ToSchema;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(feature = "local-inference")]
static LOCAL_TRANSCRIBER: once_cell::sync::Lazy<
Mutex<Option<(String, super::whisper::WhisperTranscriber)>>,
> = once_cell::sync::Lazy::new(|| Mutex::new(None));
#[cfg(feature = "local-inference")]
const WHISPER_TOKENIZER_JSON: &str = include_str!("whisper_data/tokens.json");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, ToSchema)]
@@ -21,6 +25,7 @@ pub enum DictationProvider {
OpenAI,
ElevenLabs,
Groq,
#[cfg(feature = "local-inference")]
Local,
}
@@ -66,19 +71,39 @@ pub const PROVIDERS: &[DictationProviderDef] = &[
uses_provider_config: false,
settings_path: None,
},
DictationProviderDef {
provider: DictationProvider::Local,
config_key: LOCAL_WHISPER_MODEL_CONFIG_KEY,
default_base_url: "",
endpoint_path: "",
host_key: None,
description: "Uses local Whisper model for transcription. No API key needed.",
uses_provider_config: false,
settings_path: None,
},
];
#[cfg(feature = "local-inference")]
pub const LOCAL_PROVIDER_DEF: DictationProviderDef = DictationProviderDef {
provider: DictationProvider::Local,
config_key: LOCAL_WHISPER_MODEL_CONFIG_KEY,
default_base_url: "",
endpoint_path: "",
host_key: None,
description: "Uses local Whisper model for transcription. No API key needed.",
uses_provider_config: false,
settings_path: None,
};
/// Returns all provider definitions, including Local when the `local-inference` feature is enabled.
pub fn all_providers() -> Vec<&'static DictationProviderDef> {
#[cfg(not(feature = "local-inference"))]
{
PROVIDERS.iter().collect()
}
#[cfg(feature = "local-inference")]
{
let mut all: Vec<&DictationProviderDef> = PROVIDERS.iter().collect();
all.push(&LOCAL_PROVIDER_DEF);
all
}
}
pub fn get_provider_def(provider: DictationProvider) -> &'static DictationProviderDef {
#[cfg(feature = "local-inference")]
if provider == DictationProvider::Local {
return &LOCAL_PROVIDER_DEF;
}
PROVIDERS
.iter()
.find(|def| def.provider == provider)
@@ -89,6 +114,7 @@ pub fn is_configured(provider: DictationProvider) -> bool {
let config = Config::global();
match provider {
#[cfg(feature = "local-inference")]
DictationProvider::Local => config
.get(LOCAL_WHISPER_MODEL_CONFIG_KEY, false)
.ok()
@@ -102,6 +128,7 @@ pub fn is_configured(provider: DictationProvider) -> bool {
}
}
#[cfg(feature = "local-inference")]
pub async fn transcribe_local(audio_bytes: Vec<u8>) -> Result<String> {
tokio::task::spawn_blocking(move || {
let config = Config::global();
@@ -178,6 +205,7 @@ fn build_api_client(provider: DictationProvider) -> Result<ApiClient> {
header_name: "xi-api-key".to_string(),
key: api_key,
},
#[cfg(feature = "local-inference")]
DictationProvider::Local => anyhow::bail!("Local provider should not use API client"),
};
+3 -1
View File
@@ -1,5 +1,7 @@
use std::sync::{Arc, RwLock};
#[cfg(feature = "local-inference")]
use super::local_inference::LocalInferenceProvider;
use super::{
anthropic::AnthropicProvider,
avian::AvianProvider,
@@ -19,7 +21,6 @@ use super::{
githubcopilot::GithubCopilotProvider,
google::GoogleProvider,
litellm::LiteLLMProvider,
local_inference::LocalInferenceProvider,
nanogpt::NanoGptProvider,
ollama::OllamaProvider,
openai::OpenAiProvider,
@@ -49,6 +50,7 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
registry.register::<AvianProvider>(false);
registry.register::<AzureProvider>(false);
registry.register::<BedrockProvider>(false);
#[cfg(feature = "local-inference")]
registry.register::<LocalInferenceProvider>(false);
registry.register::<ChatGptCodexProvider>(true);
registry.register::<ClaudeAcpProvider>(false);
+1
View File
@@ -27,6 +27,7 @@ pub mod githubcopilot;
pub mod google;
mod init;
pub mod litellm;
#[cfg(feature = "local-inference")]
pub mod local_inference;
pub mod nanogpt;
pub mod oauth;