From 2810ad96f0b7ba5421c4e76d86abde1d62a91986 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 3 Jul 2026 15:23:47 +0200 Subject: [PATCH] feat(providers): add AI/ML API preset (#1336) Approved by github-maintain. Clean implementation of AI/ML API as branded OpenAI-compatible provider. All CI green. Closes #1325. --- cmd/gateway_providers.go | 4 ++ cmd/gateway_providers_defaults_test.go | 9 ++++ cmd/onboard_managed.go | 2 + internal/http/openapi_spec.json | 2 +- internal/http/provider_models.go | 11 ++++ internal/http/provider_models_test.go | 42 +++++++++++++++ internal/http/providers.go | 4 ++ internal/providers/aimlapi.go | 32 +++++++++++ internal/providers/aimlapi_test.go | 53 +++++++++++++++++++ internal/store/provider_store.go | 2 + .../frontend/src/constants/providers.ts | 1 + ui/web/src/constants/providers.ts | 1 + 12 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 internal/providers/aimlapi.go create mode 100644 internal/providers/aimlapi_test.go diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 0e63a566..30a17cac 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -434,6 +434,10 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi "User-Agent": store.KimiCodingRequiredUserAgent, }) registry.RegisterForTenant(p.TenantID, prov) + case store.ProviderAIMLAPI: + prov := providers.NewAIMLAPIProvider(p.Name, p.APIKey, p.APIBase) + prov.WithProviderType(p.ProviderType) + registry.RegisterForTenant(p.TenantID, prov) default: base, model := openAIProviderDefaults(p.ProviderType, p.APIBase) prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, model) diff --git a/cmd/gateway_providers_defaults_test.go b/cmd/gateway_providers_defaults_test.go index 3f9b3474..ac25ddbe 100644 --- a/cmd/gateway_providers_defaults_test.go +++ b/cmd/gateway_providers_defaults_test.go @@ -70,6 +70,14 @@ func TestRegisterProvidersFromDBUsesCurrentMiniMaxAndZaiDefaults(t *testing.T) { tenantID := uuid.New() providerStore := gatewayProvidersStoreStub{ providers: []store.LLMProviderData{ + { + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "db-aimlapi", + ProviderType: store.ProviderAIMLAPI, + APIKey: "aimlapi-token", + Enabled: true, + }, { BaseModel: store.BaseModel{ID: uuid.New()}, TenantID: tenantID, @@ -100,6 +108,7 @@ func TestRegisterProvidersFromDBUsesCurrentMiniMaxAndZaiDefaults(t *testing.T) { registry := providers.NewRegistry(nil) registerProvidersFromDB(registry, providerStore, nil, "", "", nil, &config.Config{}, providers.NewInMemoryRegistry()) + assertProviderDefault(t, registry, tenantID, "db-aimlapi", providers.AIMLAPIDefaultModel, providers.AIMLAPIDefaultAPIBase) assertProviderDefault(t, registry, tenantID, "db-minimax", "MiniMax-M3", "https://api.minimax.io/v1") assertProviderDefault(t, registry, tenantID, "db-zai", "glm-5.2", "https://api.z.ai/api/paas/v4") assertProviderDefault(t, registry, tenantID, "db-zai-coding", "glm-5.2", "https://api.z.ai/api/coding/paas/v4") diff --git a/cmd/onboard_managed.go b/cmd/onboard_managed.go index 88ab8fd4..9bfdb27a 100644 --- a/cmd/onboard_managed.go +++ b/cmd/onboard_managed.go @@ -7,6 +7,7 @@ import ( "os" "time" + "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/store/pg" ) @@ -27,6 +28,7 @@ func testPostgresConnection(dsn string) error { // defaultPlaceholderProviders defines disabled placeholder providers seeded for // UI discoverability. Users can later enable and configure them via the dashboard. var defaultPlaceholderProviders = []store.LLMProviderData{ + {Name: "aimlapi", DisplayName: "AI/ML API", ProviderType: store.ProviderAIMLAPI, APIBase: providers.AIMLAPIDefaultAPIBase, Enabled: false}, {Name: "openrouter", DisplayName: "OpenRouter", ProviderType: store.ProviderOpenRouter, APIBase: "https://openrouter.ai/api/v1", Enabled: false}, {Name: "synthetic", DisplayName: "Synthetic", ProviderType: store.ProviderOpenAICompat, APIBase: "https://api.synthetic.new/openai/v1", Enabled: false}, {Name: "alicloud-api", DisplayName: "AliCloud API", ProviderType: store.ProviderDashScope, APIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", Enabled: false}, diff --git a/internal/http/openapi_spec.json b/internal/http/openapi_spec.json index ccb19a53..7dd0f29d 100644 --- a/internal/http/openapi_spec.json +++ b/internal/http/openapi_spec.json @@ -10660,7 +10660,7 @@ "properties": { "name": { "type": "string", "description": "Unique provider slug" }, "display_name": { "type": "string" }, - "provider_type": { "type": "string", "enum": ["anthropic_native", "openai_compat", "gemini_native", "openrouter", "groq", "deepseek", "mistral", "xai", "minimax_native", "cohere", "perplexity", "dashscope", "bailian", "chatgpt_oauth", "claude_cli", "yescale", "zai", "zai_coding", "ollama", "ollama_cloud", "acp"] }, + "provider_type": { "type": "string", "enum": ["aimlapi", "anthropic_native", "openai_compat", "gemini_native", "openrouter", "groq", "deepseek", "mistral", "xai", "minimax_native", "cohere", "perplexity", "dashscope", "bailian", "chatgpt_oauth", "claude_cli", "yescale", "zai", "zai_coding", "ollama", "ollama_cloud", "acp"] }, "api_base": { "type": "string" }, "api_key": { "type": "string" }, "enabled": { "type": "boolean", "default": true }, diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index 927f6eb0..aaca05a6 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -109,6 +109,8 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht models = minimaxModels() case store.ProviderZai, store.ProviderZaiCoding: models = zaiModels() + case store.ProviderAIMLAPI: + models = aimlapiModels() default: // All other types use OpenAI-compatible /models endpoint apiBase := openAIModelsAPIBase(p.ProviderType, h.resolveAPIBase(p)) @@ -125,6 +127,15 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht respond(withReasoningCapabilities(models)) } +func aimlapiModels() []ModelInfo { + models := providers.AIMLAPIChatModels() + result := make([]ModelInfo, 0, len(models)) + for _, model := range models { + result = append(result, ModelInfo{ID: model, Name: model}) + } + return result +} + func openAIModelsAPIBase(providerType, apiBase string) string { base := strings.TrimRight(apiBase, "/") if base != "" { diff --git a/internal/http/provider_models_test.go b/internal/http/provider_models_test.go index af53216a..18c79521 100644 --- a/internal/http/provider_models_test.go +++ b/internal/http/provider_models_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -388,6 +389,47 @@ func TestOpenAIModelsAPIBaseDefaultsKimiCoding(t *testing.T) { } } +func TestProvidersHandlerListProviderModelsAIMLAPIUsesCuratedCatalog(t *testing.T) { + token := setupProvidersAdminToken(t) + providerStore := newMockProviderStore() + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: "aimlapi", + ProviderType: store.ProviderAIMLAPI, + APIBase: "http://127.0.0.1:1", + APIKey: "aimlapi-key", + Enabled: true, + } + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + req := httptest.NewRequest(http.MethodGet, "/v1/providers/"+provider.ID.String()+"/models", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + var result ProviderModelsResponse + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Decode() error = %v", err) + } + want := providers.AIMLAPIChatModels() + if len(result.Models) != len(want) { + t.Fatalf("models = %#v, want %d curated models", result.Models, len(want)) + } + for index, model := range result.Models { + if model.ID != want[index] { + t.Errorf("models[%d].ID = %q, want %q", index, model.ID, want[index]) + } + } +} + // TestProvidersHandlerListProviderModelsOllamaRichMetadata verifies that the // handler fetches /api/tags from Ollama and maps rich details (family, // parameter_size, quantization_level) into the display name. diff --git a/internal/http/providers.go b/internal/http/providers.go index 542cd85e..546cebf3 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -342,6 +342,10 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) providerRu "User-Agent": store.KimiCodingRequiredUserAgent, }) h.providerReg.RegisterForTenant(p.TenantID, prov) + case store.ProviderAIMLAPI: + prov := providers.NewAIMLAPIProvider(p.Name, p.APIKey, apiBase) + prov.WithProviderType(p.ProviderType) + h.providerReg.RegisterForTenant(p.TenantID, prov) case store.ProviderOllamaCloud: base := apiBase if base == "" { diff --git a/internal/providers/aimlapi.go b/internal/providers/aimlapi.go new file mode 100644 index 00000000..bd9fb34c --- /dev/null +++ b/internal/providers/aimlapi.go @@ -0,0 +1,32 @@ +package providers + +const ( + AIMLAPIDefaultAPIBase = "https://api.aimlapi.com/v1" + AIMLAPIDefaultModel = "openai/gpt-5-chat" +) + +var aimlapiChatModels = []string{ + AIMLAPIDefaultModel, + "openai/gpt-4o-mini", + "anthropic/claude-sonnet-4.5", + "google/gemini-2.5-flash", +} + +// AIMLAPIChatModels returns the curated text model catalog shown in provider setup. +func AIMLAPIChatModels() []string { + return append([]string(nil), aimlapiChatModels...) +} + +// NewAIMLAPIProvider creates the branded OpenAI-compatible provider and applies +// the attribution headers required on every inference request. +func NewAIMLAPIProvider(name, apiKey, apiBase string) *OpenAIProvider { + if apiBase == "" { + apiBase = AIMLAPIDefaultAPIBase + } + return NewOpenAIProvider(name, apiKey, apiBase, AIMLAPIDefaultModel). + WithExtraHeaders(map[string]string{ + "X-AIMLAPI-Partner-ID": "nextlevelbuilder", + "X-AIMLAPI-Integration-Repo": "nextlevelbuilder/goclaw", + "X-AIMLAPI-Integration-Version": "1.0.0", + }) +} diff --git a/internal/providers/aimlapi_test.go b/internal/providers/aimlapi_test.go new file mode 100644 index 00000000..74106f96 --- /dev/null +++ b/internal/providers/aimlapi_test.go @@ -0,0 +1,53 @@ +package providers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAIMLAPIProviderSendsAttributionHeaders(t *testing.T) { + var got http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Clone() + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{{ + "message": map[string]string{"content": "ok"}, + "finish_reason": "stop", + }}, + }) + })) + t.Cleanup(server.Close) + + provider := NewAIMLAPIProvider("aimlapi", "test-key", server.URL) + if _, err := provider.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "hello"}}, + }); err != nil { + t.Fatalf("Chat() error = %v", err) + } + + checks := map[string]string{ + "Authorization": "Bearer test-key", + "X-AIMLAPI-Partner-ID": "nextlevelbuilder", + "X-AIMLAPI-Integration-Repo": "nextlevelbuilder/goclaw", + "X-AIMLAPI-Integration-Version": "1.0.0", + } + for header, want := range checks { + if value := got.Get(header); value != want { + t.Errorf("%s = %q, want %q", header, value, want) + } + } +} + +func TestAIMLAPIChatModelsReturnsCopy(t *testing.T) { + models := AIMLAPIChatModels() + if len(models) == 0 || models[0] != AIMLAPIDefaultModel { + t.Fatalf("models = %v, want default model first", models) + } + models[0] = "changed" + if got := AIMLAPIChatModels()[0]; got != AIMLAPIDefaultModel { + t.Fatalf("catalog mutated through returned slice: %q", got) + } +} diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index a88ea1d8..c8bd0788 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -13,6 +13,7 @@ const ( ProviderOpenAICompat = "openai_compat" ProviderGeminiNative = "gemini_native" ProviderOpenRouter = "openrouter" + ProviderAIMLAPI = "aimlapi" ProviderGroq = "groq" ProviderDeepSeek = "deepseek" ProviderMistral = "mistral" @@ -72,6 +73,7 @@ var ValidProviderTypes = map[string]bool{ ProviderOpenAICompat: true, ProviderGeminiNative: true, ProviderOpenRouter: true, + ProviderAIMLAPI: true, ProviderGroq: true, ProviderDeepSeek: true, ProviderMistral: true, diff --git a/ui/desktop/frontend/src/constants/providers.ts b/ui/desktop/frontend/src/constants/providers.ts index 8cc17084..d59a7516 100644 --- a/ui/desktop/frontend/src/constants/providers.ts +++ b/ui/desktop/frontend/src/constants/providers.ts @@ -6,6 +6,7 @@ export interface ProviderTypeInfo { } export const PROVIDER_TYPES: ProviderTypeInfo[] = [ + { value: 'aimlapi', label: 'AI/ML API', apiBase: 'https://api.aimlapi.com/v1', needsKey: true }, { value: 'anthropic_native', label: 'Anthropic (Native)', apiBase: '', needsKey: true }, { value: 'openai_compat', label: 'OpenAI Compatible', apiBase: '', needsKey: true }, { value: 'gemini_native', label: 'Google Gemini', apiBase: 'https://generativelanguage.googleapis.com/v1beta/openai', needsKey: true }, diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index d82aba46..0d5647a3 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -12,6 +12,7 @@ type ProviderAliasSource = string | { name?: string | null }; export const DEFAULT_CODEX_OAUTH_ALIAS = "openai-codex"; export const PROVIDER_TYPES: ProviderTypeInfo[] = [ + { value: "aimlapi", label: "AI/ML API", apiBase: "https://api.aimlapi.com/v1", placeholder: "" }, { value: "chatgpt_oauth", label: "ChatGPT Subscription (OAuth)", apiBase: "", placeholder: "" }, { value: "anthropic_native", label: "Anthropic (Native)", apiBase: "", placeholder: "https://api.anthropic.com" }, { value: "openai_compat", label: "OpenAI Compatible", apiBase: "", placeholder: "https://api.openai.com/v1" },