From c434c84d23375cc33311886ab4fe454e75335c47 Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Fri, 22 May 2026 19:56:31 +0200 Subject: [PATCH] feat(providers): add NEAR AI Cloud provider (#9352) Signed-off-by: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> --- .../goose/src/config/declarative_providers.rs | 22 +++++ .../src/providers/declarative/nearai.json | 43 ++++++++ crates/goose/src/providers/init.rs | 24 +++++ crates/goose/src/providers/openai.rs | 97 +++++++++++++++++-- .../docs/getting-started/providers.md | 1 + 5 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 crates/goose/src/providers/declarative/nearai.json diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index b02da9f2..be5596cb 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -834,6 +834,28 @@ mod tests { assert_eq!(config.models[0].context_limit, 131072); } + #[test] + fn test_nearai_json_deserializes() { + let json = include_str!("../providers/declarative/nearai.json"); + let config: DeclarativeProviderConfig = + serde_json::from_str(json).expect("nearai.json should parse"); + assert_eq!(config.name, "nearai"); + assert_eq!(config.display_name, "NEAR AI Cloud"); + assert!(matches!(config.engine, ProviderEngine::OpenAI)); + assert_eq!(config.api_key_env, "NEARAI_API_KEY"); + assert_eq!(config.base_url, "https://cloud-api.near.ai/v1"); + assert_eq!(config.catalog_provider_id, Some("nearai".to_string())); + assert_eq!(config.dynamic_models, Some(true)); + assert_eq!(config.supports_streaming, Some(true)); + assert!(config.preserves_thinking); + assert_eq!( + config.model_doc_link, + Some("https://docs.near.ai/".to_string()) + ); + assert_eq!(config.models[0].name, "zai-org/GLM-5.1-FP8"); + assert!(config.models[0].reasoning); + } + #[test] fn test_vercel_ai_gateway_json_deserializes() { let json = include_str!("../providers/declarative/vercel_ai_gateway.json"); diff --git a/crates/goose/src/providers/declarative/nearai.json b/crates/goose/src/providers/declarative/nearai.json new file mode 100644 index 00000000..4386d04d --- /dev/null +++ b/crates/goose/src/providers/declarative/nearai.json @@ -0,0 +1,43 @@ +{ + "name": "nearai", + "engine": "openai", + "display_name": "NEAR AI Cloud", + "description": "TEE-backed private inference through NEAR AI Cloud's OpenAI-compatible API.", + "api_key_env": "NEARAI_API_KEY", + "base_url": "https://cloud-api.near.ai/v1", + "catalog_provider_id": "nearai", + "dynamic_models": true, + "models": [ + { + "name": "zai-org/GLM-5.1-FP8", + "context_limit": 202752, + "reasoning": true + }, + { + "name": "Qwen/Qwen3.6-35B-A3B-FP8", + "context_limit": 262144, + "reasoning": true + }, + { + "name": "Qwen/Qwen3.5-122B-A10B", + "context_limit": 131072, + "reasoning": true + }, + { + "name": "Qwen/Qwen3-VL-30B-A3B-Instruct", + "context_limit": 256000 + }, + { + "name": "google/gemma-4-31B-it", + "context_limit": 262144 + } + ], + "preserves_thinking": true, + "supports_streaming": true, + "model_doc_link": "https://docs.near.ai/", + "setup_steps": [ + "Create or sign in to your NEAR AI Cloud account at https://cloud.near.ai", + "Create an API key", + "Copy the key and paste it above" + ] +} diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 2061c5eb..24364399 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -284,6 +284,30 @@ mod tests { ); } + #[tokio::test] + async fn test_nearai_declarative_provider_registry_wiring() { + let nearai = get_from_registry("nearai") + .await + .expect("nearai provider should be registered"); + let meta = nearai.metadata(); + + assert_eq!(nearai.provider_type(), ProviderType::Declarative); + assert!(nearai.supports_inventory_refresh()); + assert_eq!(meta.display_name, "NEAR AI Cloud"); + assert_eq!(meta.default_model, "zai-org/GLM-5.1-FP8"); + assert_eq!(meta.model_doc_link, "https://docs.near.ai/"); + assert!(!meta.setup_steps.is_empty()); + + let api_key = meta + .config_keys + .iter() + .find(|k| k.name == "NEARAI_API_KEY") + .expect("NEARAI_API_KEY config key should exist"); + assert!(api_key.required, "NEARAI_API_KEY should be required"); + assert!(api_key.secret, "NEARAI_API_KEY should be secret"); + assert!(api_key.primary, "NEARAI_API_KEY should be primary"); + } + #[tokio::test] async fn test_openai_compatible_providers_config_keys() { let providers_list = providers().await; diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index b2914ec5..3ecc24ff 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -511,23 +511,51 @@ impl OpenAiProvider { "lmstudio", "mistral", "moonshot", + "nearai", "ovhcloud", ]; - fn sanitize_request_for_compat(&self, mut payload: serde_json::Value) -> serde_json::Value { - if !Self::PROVIDERS_NEEDING_MAX_TOKENS_REMAP.contains(&self.name.as_str()) { - return payload; - } + const PROVIDERS_NEEDING_STANDARD_CHAT_PARAMS: &[&str] = &["nearai"]; + fn sanitize_request_for_compat(&self, mut payload: serde_json::Value) -> serde_json::Value { if let Some(obj) = payload.as_object_mut() { - if let Some(value) = obj.remove("max_completion_tokens") { - obj.entry("max_tokens").or_insert(value); + if Self::PROVIDERS_NEEDING_MAX_TOKENS_REMAP.contains(&self.name.as_str()) { + if let Some(value) = obj.remove("max_completion_tokens") { + obj.entry("max_tokens").or_insert(value); + } + } + + if Self::PROVIDERS_NEEDING_STANDARD_CHAT_PARAMS.contains(&self.name.as_str()) { + let model_name = obj.get("model").and_then(|model| model.as_str()); + if !model_name.is_some_and(Self::is_responses_model) { + obj.remove("reasoning_effort"); + } + + if let Some(messages) = obj.get_mut("messages").and_then(|m| m.as_array_mut()) { + for message in messages { + if message + .get("role") + .and_then(|role| role.as_str()) + .is_some_and(|role| role == "developer") + { + message["role"] = serde_json::Value::String("system".to_string()); + } + } + } } } payload } + fn should_use_responses_api_for_provider(&self, model_name: &str) -> bool { + if Self::PROVIDERS_NEEDING_STANDARD_CHAT_PARAMS.contains(&self.name.as_str()) { + return false; + } + + Self::should_use_responses_api(model_name, &self.base_path) + } + fn map_base_path(base_path: &str, target: &str, fallback: &str) -> String { let normalized = Self::normalize_base_path(base_path); if normalized.ends_with(target) || normalized.contains(&format!("/{target}")) { @@ -748,7 +776,7 @@ impl Provider for OpenAiProvider { messages: &[Message], tools: &[Tool], ) -> Result { - if Self::should_use_responses_api(&model_config.model_name, &self.base_path) { + if self.should_use_responses_api_for_provider(&model_config.model_name) { let mut payload = create_responses_request(model_config, system, messages, tools)?; payload["stream"] = serde_json::Value::Bool(self.supports_streaming); @@ -1029,6 +1057,61 @@ mod tests { assert_eq!(result, payload); } + #[test] + fn sanitize_nearai_reasoning_chat_params() { + let provider = make_provider("nearai"); + let payload = json!({ + "model": "Qwen/Qwen3.6-35B-A3B-FP8", + "messages": [ + { + "role": "developer", + "content": "system instructions" + }, + { + "role": "user", + "content": "hello" + } + ], + "reasoning_effort": "medium", + "max_completion_tokens": 16384 + }); + + let result = provider.sanitize_request_for_compat(payload); + let obj = result.as_object().unwrap(); + + assert!(!obj.contains_key("reasoning_effort")); + assert!(!obj.contains_key("max_completion_tokens")); + assert_eq!(obj.get("max_tokens").unwrap(), &json!(16384)); + assert_eq!(obj["messages"][0]["role"], "system"); + assert_eq!(obj["messages"][1]["role"], "user"); + } + + #[test] + fn sanitize_nearai_preserves_openai_reasoning_effort() { + let provider = make_provider("nearai"); + let payload = json!({ + "model": "openai/gpt-5", + "messages": [], + "reasoning_effort": "medium", + "max_completion_tokens": 16384 + }); + + let result = provider.sanitize_request_for_compat(payload); + let obj = result.as_object().unwrap(); + + assert_eq!(obj.get("reasoning_effort"), Some(&json!("medium"))); + assert!(!obj.contains_key("max_completion_tokens")); + assert_eq!(obj.get("max_tokens").unwrap(), &json!(16384)); + } + + #[test] + fn nearai_uses_chat_completions_for_openai_reasoning_models() { + let provider = make_provider("nearai"); + + assert!(!provider.should_use_responses_api_for_provider("openai/gpt-5")); + assert!(!provider.should_use_responses_api_for_provider("openai/o3")); + } + #[test] fn responses_api_routing_uses_model_family_unless_path_forces_chat() { for (model_name, base_path, expected) in [ diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 91ae3885..90367355 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -39,6 +39,7 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a | [LiteLLM](https://docs.litellm.ai/docs/) | LiteLLM proxy supporting multiple models with automatic prompt caching and unified API access. | `LITELLM_HOST`, `LITELLM_BASE_PATH` (optional), `LITELLM_API_KEY` (optional), `LITELLM_CUSTOM_HEADERS` (optional), `LITELLM_TIMEOUT` (optional) | | [LM Studio](https://lmstudio.ai/) | Run local models with LM Studio's OpenAI-compatible server. **Because this provider runs locally, you must first [download a model](#local-llms).** | None required. Connects to local server at `localhost:1234` by default. | | [Mistral AI](https://mistral.ai/) | Provides access to Mistral models including general-purpose models, specialized coding models (Codestral), and multimodal models (Pixtral). | `MISTRAL_API_KEY` | +| [NEAR AI Cloud](https://cloud.near.ai/) | TEE-backed private inference through an OpenAI-compatible API with dynamic model discovery. | `NEARAI_API_KEY` | | [Novita AI](https://novita.ai/) | 90+ open-source models with OpenAI-compatible API and competitive pricing. Supports Kimi K2.5, DeepSeek, GLM, MiniMax, Qwen, and more. | `NOVITA_API_KEY` | | [Ollama](https://ollama.com/) | Local model runner supporting Qwen, Llama, DeepSeek, and other open-source models. **Because this provider runs locally, you must first [download and run a model](#local-llms).** | `OLLAMA_HOST` | | [Ollama Cloud](https://ollama.com/) | Access hosted models on ollama.com via OpenAI-compatible API. Requires an Ollama account and API key. | `OLLAMA_CLOUD_API_KEY` |