Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions crates/goose/src/config/declarative_providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
43 changes: 43 additions & 0 deletions crates/goose/src/providers/declarative/nearai.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
24 changes: 24 additions & 0 deletions crates/goose/src/providers/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
97 changes: 90 additions & 7 deletions crates/goose/src/providers/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")) {
Expand Down Expand Up @@ -748,7 +776,7 @@ impl Provider for OpenAiProvider {
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
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);

Expand Down Expand Up @@ -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 [
Expand Down
1 change: 1 addition & 0 deletions documentation/docs/getting-started/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down