diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index 271ade08f..75a3c262e 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -487,6 +487,27 @@ var providers = []Provider{ {ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, }, }, + { + // Direct Ollama Cloud uses the hosted OpenAI-compatible /v1 API. + // Models are discovered dynamically because the Cloud catalog changes + // independently of NetBird releases. ParserID intentionally remains + // empty to preserve the routing behavior of Ollama, vLLM, and custom + // OpenAI-compatible providers. + ID: "ollama_cloud", + Kind: KindProvider, + AuthMode: AuthModeRequired, + Name: "Ollama Cloud", + Description: "Hosted Ollama models via the OpenAI-compatible API", + DefaultHost: "ollama.com", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#000000", + Models: []Model{}, + ModelDiscovery: &ModelDiscovery{ + OllamaFallback: true, + }, + }, { ID: "litellm_proxy", Kind: KindGateway, diff --git a/management/internals/modules/agentnetwork/catalog/catalog_test.go b/management/internals/modules/agentnetwork/catalog/catalog_test.go index b2cd5a8da..f2f44d539 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog_test.go +++ b/management/internals/modules/agentnetwork/catalog/catalog_test.go @@ -35,10 +35,41 @@ func TestOllamaCatalogEntry(t *testing.T) { assert.Empty(t, wire.Models) } -func TestOnlyOllamaSupportsModelDiscovery(t *testing.T) { +func TestOllamaCloudCatalogEntry(t *testing.T) { + entry, ok := Lookup("ollama_cloud") + require.True(t, ok, "Ollama Cloud must be available as a dedicated catalog provider") + + assert.Equal(t, KindProvider, entry.Kind) + assert.Equal(t, AuthModeRequired, entry.EffectiveAuthMode()) + assert.Equal(t, "Ollama Cloud", entry.Name) + assert.Equal(t, "Hosted Ollama models via the OpenAI-compatible API", entry.Description) + assert.Equal(t, "ollama.com", entry.DefaultHost) + assert.Equal(t, "Authorization", entry.AuthHeaderName) + assert.Equal(t, "Bearer ${API_KEY}", entry.AuthHeaderTemplate) + assert.Equal(t, "application/json", entry.DefaultContentType) + assert.Empty(t, entry.ParserID, "Ollama Cloud preserves the untagged Ollama/vLLM/custom routing behavior") + assert.Empty(t, entry.Models, "Ollama Cloud models are discovered dynamically") + require.NotNil(t, entry.ModelDiscovery) + assert.True(t, entry.ModelDiscovery.OllamaFallback) + + wire := entry.ToAPIResponse() + assert.Equal(t, "ollama_cloud", wire.Id) + assert.Equal(t, api.AgentNetworkCatalogProviderKindProvider, wire.Kind) + assert.Equal(t, api.AgentNetworkCatalogProviderAuthModeRequired, wire.AuthMode) + assert.Equal(t, "ollama.com", wire.DefaultHost) + assert.True(t, wire.SupportsModelDiscovery) + assert.NotNil(t, wire.Models) + assert.Empty(t, wire.Models) +} + +func TestOnlyOllamaProvidersSupportModelDiscovery(t *testing.T) { + discoverable := map[string]bool{ + "ollama": true, + "ollama_cloud": true, + } for _, entry := range All() { supportsDiscovery := entry.ModelDiscovery != nil - assert.Equal(t, entry.ID == "ollama", supportsDiscovery, entry.ID) + assert.Equal(t, discoverable[entry.ID], supportsDiscovery, entry.ID) assert.Equal(t, supportsDiscovery, entry.ToAPIResponse().SupportsModelDiscovery, entry.ID) } } diff --git a/management/internals/modules/agentnetwork/model_discovery_test.go b/management/internals/modules/agentnetwork/model_discovery_test.go index 274614c03..0d29c70e1 100644 --- a/management/internals/modules/agentnetwork/model_discovery_test.go +++ b/management/internals/modules/agentnetwork/model_discovery_test.go @@ -91,6 +91,74 @@ func TestDiscoverProviderModelsUsesPersistedProviderAndCluster(t *testing.T) { }, result.Models) } +func TestDiscoverProviderModelsSupportsOllamaCloud(t *testing.T) { + manager, mockStore, mockPermissions, mockProxy := newModelDiscoveryManager(t) + allowModelDiscovery(mockPermissions) + + provider := &types.Provider{ + ID: "provider-cloud", + AccountID: "account-1", + ProviderID: "ollama_cloud", + UpstreamURL: "https://ollama.com", + APIKey: "ollama-cloud-key", + } + mockStore.EXPECT(). + GetAgentNetworkProviderByID(gomock.Any(), store.LockingStrengthNone, "account-1", provider.ID). + Return(provider, nil) + mockStore.EXPECT(). + GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account-1"). + Return(&types.Settings{AccountID: "account-1", Cluster: "cloud-proxies.example.com"}, nil) + mockProxy.EXPECT(). + DiscoverModels(gomock.Any(), "account-1", "cloud-proxies.example.com", gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ string, request *proto.ModelDiscoveryRequest) (*proto.ModelDiscoveryResult, error) { + assert.Equal(t, "https://ollama.com", request.UpstreamUrl) + assert.Equal(t, "Authorization", request.AuthHeaderName) + assert.Equal(t, "Bearer ollama-cloud-key", request.AuthHeaderValue) + assert.False(t, request.SkipTlsVerify) + assert.True(t, request.OllamaFallback) + return &proto.ModelDiscoveryResult{ + RequestId: "probe-cloud", + Source: "openai_v1_models", + Models: []*proto.ModelDiscoveryModel{ + {Id: "gpt-oss:120b", Label: "gpt-oss:120b"}, + }, + }, nil + }) + + result, err := manager.DiscoverProviderModels(context.Background(), "account-1", "user-1", provider.ID) + require.NoError(t, err) + assert.Equal(t, "probe-cloud", result.RequestID) + assert.Equal(t, "cloud-proxies.example.com", result.ProxyCluster) + assert.Equal(t, []types.DiscoveredModel{ + {ID: "gpt-oss:120b", Label: "gpt-oss:120b"}, + }, result.Models) +} + +func TestDiscoverProviderModelsRejectsOllamaCloudWithoutKey(t *testing.T) { + manager, mockStore, mockPermissions, _ := newModelDiscoveryManager(t) + allowModelDiscovery(mockPermissions) + + provider := &types.Provider{ + ID: "provider-cloud", + AccountID: "account-1", + ProviderID: "ollama_cloud", + UpstreamURL: "https://ollama.com", + } + mockStore.EXPECT(). + GetAgentNetworkProviderByID(gomock.Any(), store.LockingStrengthNone, "account-1", provider.ID). + Return(provider, nil) + mockStore.EXPECT(). + GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account-1"). + Return(&types.Settings{AccountID: "account-1", Cluster: "cloud-proxies.example.com"}, nil) + + _, err := manager.DiscoverProviderModels(context.Background(), "account-1", "user-1", provider.ID) + require.Error(t, err) + statusErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.PreconditionFailed, statusErr.Type()) + assert.Contains(t, err.Error(), "authentication is not configured correctly") +} + func TestDiscoverProviderModelsRejectsUnsupportedProvider(t *testing.T) { manager, mockStore, mockPermissions, _ := newModelDiscoveryManager(t) allowModelDiscovery(mockPermissions) diff --git a/management/internals/modules/agentnetwork/provider_auth_test.go b/management/internals/modules/agentnetwork/provider_auth_test.go index 6e22f0dc3..1689a3285 100644 --- a/management/internals/modules/agentnetwork/provider_auth_test.go +++ b/management/internals/modules/agentnetwork/provider_auth_test.go @@ -18,6 +18,23 @@ func TestPrepareProviderAPIKey(t *testing.T) { assert.Contains(t, err.Error(), "api_key is required") }) + t.Run("Ollama Cloud requires a key", func(t *testing.T) { + provider := &types.Provider{ProviderID: "ollama_cloud"} + err := prepareProviderAPIKey(provider, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "api_key is required") + }) + + t.Run("Ollama Cloud accepts a key", func(t *testing.T) { + provider := &types.Provider{ + ProviderID: "ollama_cloud", + APIKey: "ollama-cloud-key", + APIKeyProvided: true, + } + require.NoError(t, prepareProviderAPIKey(provider, nil)) + assert.Equal(t, "ollama-cloud-key", provider.APIKey) + }) + t.Run("optional create accepts an empty key", func(t *testing.T) { provider := &types.Provider{ProviderID: "ollama"} require.NoError(t, prepareProviderAPIKey(provider, nil)) diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 50fa69652..b79eaaae4 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -1275,3 +1275,46 @@ func TestSynthesizeServices_OllamaOptionalAPIKey(t *testing.T) { }) } } + +func TestSynthesizeServices_OllamaCloudRoute(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.ProviderID = "ollama_cloud" + provider.Name = "Ollama Cloud" + provider.UpstreamURL = "https://ollama.com" + provider.APIKey = "ollama-cloud-key" + provider.Models = []types.ProviderModel{{ID: "gpt-oss:120b"}} + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + var routerCfg routerConfig + for _, middleware := range services[0].Targets[0].Options.Middlewares { + if middleware.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(middleware.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 1) + + route := routerCfg.Providers[0] + assert.Empty(t, route.Vendor, "Ollama Cloud preserves the untagged Ollama/vLLM/custom routing behavior") + assert.Equal(t, []string{"gpt-oss:120b"}, route.Models) + assert.Equal(t, "https", route.UpstreamScheme) + assert.Equal(t, "ollama.com", route.UpstreamHost) + assert.Empty(t, route.UpstreamPath) + assert.Equal(t, "Authorization", route.AuthHeaderName) + assert.Equal(t, "Bearer ollama-cloud-key", route.AuthHeaderValue) + assert.False(t, route.SkipTLSVerify) +} diff --git a/proxy/internal/modeldiscovery/discovery.go b/proxy/internal/modeldiscovery/discovery.go index 184a563b7..143bcf6eb 100644 --- a/proxy/internal/modeldiscovery/discovery.go +++ b/proxy/internal/modeldiscovery/discovery.go @@ -210,9 +210,9 @@ func (d *Discoverer) fetch(ctx context.Context, in Request, endpointPath string) } req.Header.Set("Accept", "application/json") if in.AuthHeaderName != "" { - // Phase 3 is Ollama-only. Canonicalizing the sole catalog-owned - // credential header keeps the control message from becoming a generic - // arbitrary-header primitive. + // Discovery is currently enabled only for Ollama-compatible providers. + // Canonicalizing the sole catalog-owned credential header keeps the + // control message from becoming a generic arbitrary-header primitive. req.Header.Set("Authorization", in.AuthHeaderValue) }