From 7de3d1f7e3a03acbf6bdc893e3b7815820aef541 Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Thu, 21 May 2026 12:31:44 +0000 Subject: [PATCH] feat(provider): add NEAR AI Cloud provider --- README.md | 1 + config/config.example.json | 5 ++ docs/guides/providers.md | 13 +++++ pkg/config/defaults.go | 8 +++ pkg/providers/factory_provider.go | 2 +- pkg/providers/factory_provider_test.go | 48 ++++++++++++++++++ pkg/providers/openai_compat/provider.go | 1 + pkg/providers/provider_metadata.go | 18 +++++++ web/backend/api/models.go | 53 +++++++++++++++++++- web/backend/api/models_test.go | 66 +++++++++++++++++++++++++ 10 files changed, 213 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 697af4182..7592fe4b3 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,7 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use | [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Required | Mistral Large, Codestral | | [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Required | NVIDIA hosted models | | [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Required | Fast inference | +| [NEAR AI Cloud](https://near.ai/) | `nearai/` | Required | TEE inference, OpenAI-compatible | | [Novita AI](https://novita.ai/) | `novita/` | Required | Various open models | | [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Required | MiMo models | | [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted | diff --git a/config/config.example.json b/config/config.example.json index 129b5bffd..a16845754 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -67,6 +67,11 @@ "model": "venice/venice-uncensored", "api_keys": ["your-venice-api-key"] }, + { + "model_name": "nearai-glm", + "model": "nearai/zai-org/GLM-5.1-FP8", + "api_keys": ["your-nearai-api-key"] + }, { "model_name": "lmstudio-local", "model": "lmstudio/openai/gpt-oss-20b" diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 40edb87ac..7541c00b7 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -17,6 +17,7 @@ | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) | +| `nearai` | LLM (NEAR AI Cloud TEE inference) | [near.ai](https://near.ai) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | @@ -50,6 +51,7 @@ This design also enables **multi-agent support** with flexible provider selectio | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | | **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | | **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) | +| **NEAR AI Cloud** | `nearai` | `https://cloud-api.near.ai/v1` | OpenAI | [Get Key](https://near.ai) | | **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) | @@ -218,6 +220,17 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to } ``` +**NEAR AI Cloud** + +```json +{ + "model_name": "nearai-glm", + "provider": "nearai", + "model": "zai-org/GLM-5.1-FP8", + "api_keys": ["your-nearai-api-key"] +} +``` + **VolcEngine (Doubao)** ```json diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index a7f9c759c..7d306c0f3 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -108,6 +108,14 @@ func DefaultConfig() *Config { APIBase: "https://api.venice.ai/api/v1", }, + // NEAR AI Cloud TEE inference - https://near.ai + { + ModelName: "nearai-glm", + Provider: "nearai", + Model: "zai-org/GLM-5.1-FP8", + APIBase: "https://cloud-api.near.ai/v1", + }, + // Google Gemini - https://ai.google.dev/ { ModelName: "gemini-2.0-flash", diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 5f0bfe88d..8a4af9f1d 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -201,7 +201,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return finalizeProviderFromConfig(provider, modelID, cfg) case "litellm", "lmstudio", "gpt4free", "openrouter", "groq", "zhipu", "nvidia", "venice", - "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras", + "nearai", "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral", "avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo": // All other OpenAI-compatible HTTP providers diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index e772d0a11..4f1db24eb 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -223,6 +223,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { }{ {"openai", "openai"}, {"venice", "venice"}, + {"nearai", "nearai"}, {"groq", "groq"}, {"novita", "novita"}, {"openrouter", "openrouter"}, @@ -288,6 +289,15 @@ func TestGetDefaultAPIBase_Venice(t *testing.T) { } } +func TestGetDefaultAPIBase_NearAI(t *testing.T) { + if got := getDefaultAPIBase("nearai"); got != "https://cloud-api.near.ai/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "nearai", got, "https://cloud-api.near.ai/v1") + } + if got := getDefaultAPIBase("near-ai"); got != "https://cloud-api.near.ai/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "near-ai", got, "https://cloud-api.near.ai/v1") + } +} + func TestGetDefaultAPIBase_SiliconFlow(t *testing.T) { if got := getDefaultAPIBase("siliconflow"); got != "https://api.siliconflow.cn/v1" { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "siliconflow", got, "https://api.siliconflow.cn/v1") @@ -525,6 +535,28 @@ func TestCreateProviderFromConfig_Venice(t *testing.T) { } } +func TestCreateProviderFromConfig_NearAI(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-nearai", + Model: "nearai/zai-org/GLM-5.1-FP8", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "zai-org/GLM-5.1-FP8" { + t.Errorf("modelID = %q, want %q", modelID, "zai-org/GLM-5.1-FP8") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + func TestCreateProviderFromConfig_SiliconFlow(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-siliconflow", @@ -1076,6 +1108,22 @@ func TestModelProviderOptions(t *testing.T) { "https://api.siliconflow.cn/v1", ) } + if option, ok := seen["nearai"]; !ok { + t.Fatal("nearai option missing") + } else { + if option.DisplayName != "NEAR AI Cloud" { + t.Fatalf("nearai display_name = %q, want %q", option.DisplayName, "NEAR AI Cloud") + } + if option.DefaultAPIBase != "https://cloud-api.near.ai/v1" { + t.Fatalf("nearai default_api_base = %q, want %q", option.DefaultAPIBase, "https://cloud-api.near.ai/v1") + } + if !option.SupportsFetch { + t.Fatal("nearai should support upstream model listing") + } + if len(option.CommonModels) == 0 { + t.Fatal("nearai common_models should not be empty") + } + } if option, ok := seen["anthropic"]; !ok { t.Fatal("anthropic option missing") } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index ff30d49c6..cbd17d988 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -54,6 +54,7 @@ const ( var stripModelPrefixProviders = map[string]struct{}{ "litellm": {}, + "nearai": {}, "venice": {}, "moonshot": {}, "nvidia": {}, diff --git a/pkg/providers/provider_metadata.go b/pkg/providers/provider_metadata.go index 636a22058..90a2206b8 100644 --- a/pkg/providers/provider_metadata.go +++ b/pkg/providers/provider_metadata.go @@ -398,6 +398,24 @@ var modelProviderOptionsByName = map[string]ModelProviderOption{ Priority: 45, httpAPI: true, }, + "nearai": { + ID: "nearai", + DisplayName: "NEAR AI Cloud", + Domain: "near.ai", + DefaultAPIBase: "https://cloud-api.near.ai/v1", + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Priority: 44.5, + CommonModels: []string{ + "zai-org/GLM-5.1-FP8", + "openai/gpt-oss-120b", + "Qwen/Qwen3-30B-A3B-Instruct-2507", + "Qwen/Qwen3.6-35B-A3B-FP8", + }, + Aliases: []string{"near-ai", "near-ai-cloud"}, + httpAPI: true, + }, "shengsuanyun": { ID: "shengsuanyun", DisplayName: "ShengsuanYun", diff --git a/web/backend/api/models.go b/web/backend/api/models.go index be594e3e4..941c95e78 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -734,9 +734,10 @@ type upstreamModel struct { func fetchUpstreamModels(ctx context.Context, provider, apiBase, apiKey string) ([]upstreamModel, error) { apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/") + provider = providers.NormalizeProvider(provider) var fetchURL string - switch strings.ToLower(provider) { + switch provider { case "ollama": // Strip /v1 suffix if present to get the Ollama root root := apiBase @@ -746,6 +747,9 @@ func fetchUpstreamModels(ctx context.Context, provider, apiBase, apiKey string) root = strings.TrimRight(root, "/") fetchURL = root + "/api/tags" return fetchOllamaModels(ctx, fetchURL) + case "nearai": + fetchURL = apiBase + "/model/list" + return fetchNearAIModels(ctx, fetchURL, apiKey) default: // OpenAI-compatible: /v1/models fetchURL = apiBase + "/models" @@ -753,6 +757,53 @@ func fetchUpstreamModels(ctx context.Context, provider, apiBase, apiKey string) } } +func fetchNearAIModels(ctx context.Context, fetchURL, apiKey string) ([]upstreamModel, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) + if err != nil { + return nil, err + } + if apiKey = strings.TrimSpace(apiKey); apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("nearai returned status %d", resp.StatusCode) + } + + var parsed struct { + Models []struct { + ModelID string `json:"modelId"` + OwnedBy string `json:"ownedBy"` + Metadata struct { + OwnedBy string `json:"ownedBy"` + } `json:"metadata"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + + models := make([]upstreamModel, 0, len(parsed.Models)) + for _, m := range parsed.Models { + id := strings.TrimSpace(m.ModelID) + if id == "" { + continue + } + ownedBy := strings.TrimSpace(m.OwnedBy) + if ownedBy == "" { + ownedBy = strings.TrimSpace(m.Metadata.OwnedBy) + } + models = append(models, upstreamModel{ID: id, OwnedBy: ownedBy}) + } + return models, nil +} + func fetchOpenAICompatibleModels(ctx context.Context, fetchURL, apiKey string) ([]upstreamModel, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) if err != nil { diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index f734d214d..cb088a0d9 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -1961,6 +1961,13 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration "https://api.siliconflow.cn/v1", ) } + if option, ok := optionsByID["nearai"]; !ok { + t.Fatal("nearai provider option missing") + } else if option.DefaultAPIBase != "https://cloud-api.near.ai/v1" { + t.Fatalf("nearai default_api_base = %q, want %q", option.DefaultAPIBase, "https://cloud-api.near.ai/v1") + } else if !option.SupportsFetch { + t.Fatal("nearai provider option should report supports_fetch") + } if option, ok := optionsByID["bedrock"]; !ok { t.Fatal("bedrock provider option missing") } else if !option.CreateAllowed { @@ -2592,6 +2599,65 @@ func TestHandleFetchModels_SiliconFlowUsesOpenAICompatibleEndpoint(t *testing.T) } } +func TestHandleFetchModels_NearAIUsesPublicModelListEndpoint(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + var gotPath string + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"models":[`+ + `{"modelId":"zai-org/GLM-5.1-FP8","metadata":{"ownedBy":"nearai"}},`+ + `{"modelId":"openai/gpt-oss-120b","metadata":{"ownedBy":"nearai"}},`+ + `{"modelId":""}]}`) + })) + defer srv.Close() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models/fetch", bytes.NewBufferString(fmt.Sprintf(`{ + "provider":"nearai", + "api_key":"nearai-key", + "api_base":"%s" + }`, srv.URL))) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if gotPath != "/model/list" { + t.Fatalf("path = %q, want %q", gotPath, "/model/list") + } + if gotAuth != "Bearer nearai-key" { + t.Fatalf("Authorization = %q, want %q", gotAuth, "Bearer nearai-key") + } + + var resp struct { + Models []upstreamModel `json:"models"` + Total int `json:"total"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Total != 2 || len(resp.Models) != 2 { + t.Fatalf("response = %+v, want two fetched models", resp) + } + if resp.Models[0].ID != "zai-org/GLM-5.1-FP8" || resp.Models[0].OwnedBy != "nearai" { + t.Fatalf("models[0] = %+v, want GLM model owned by nearai", resp.Models[0]) + } + if resp.Models[1].ID != "openai/gpt-oss-120b" || resp.Models[1].OwnedBy != "nearai" { + t.Fatalf("models[1] = %+v, want GPT OSS model owned by nearai", resp.Models[1]) + } +} + func TestHandleFetchModels_ModelIndexUsesStoredKey(t *testing.T) { var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {