mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 14:16:30 +00:00
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.
This commit is contained in:
1 parent
8ea166f2e5
commit
2810ad96f0
12 files changed
+162
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user