feat(providers): add NEAR AI Cloud provider (#9352)
Signed-off-by: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com>
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user