update-1.6.2
ci / test (services/knowledge) (push) Successful in 47s
ci / test (services/agent) (push) Successful in 55s
ci / test (platform/neuroforge) (push) Successful in 56s
ci / docker-build (push) Failing after 1m10s
ci / test (services/control) (push) Successful in 1m26s
release-tag / release-image (push) Successful in 6m43s

This commit is contained in:
2026-09-09 12:55:35 +02:00
parent e0bf42bf32
commit d18405ed2e
48 changed files with 8062 additions and 1111 deletions
+7
View File
@@ -1,4 +1,8 @@
# Generate strong random values, e.g. openssl rand -hex 32
# Optional: Bearer token sent as Authorization: Bearer <token> to Ollama-compatible endpoints.
OLLAMA_API_KEY=
# Optional NeuroForge GPU-worker override; falls back to OLLAMA_API_KEY.
NEUROFORGE_WORKER_OLLAMA_API_KEY=
NEUROFORGE_ADMIN_TOKEN=replace-with-random-admin-token
NEUROFORGE_APP_API_KEY=replace-with-random-app-key
NEUROFORGE_WORKER_TOKEN=replace-with-random-worker-token
@@ -7,3 +11,6 @@ NEUROFORGE_CLUSTER_TOKEN=replace-with-shared-random-cluster-token
# Optional. You can also set this through the web interface.
OPENAI_API_KEY=
# Optional NeuroForge-specific override; takes precedence over OLLAMA_API_KEY.
NEUROFORGE_OLLAMA_API_KEY=
+9
View File
@@ -234,6 +234,7 @@ func run() (retErr error) {
changed := false
for name, dst := range map[string]*string{
"OPENAI_API_KEY": &sec.OpenAIAPIKey,
"NEUROFORGE_OLLAMA_API_KEY": &sec.OllamaAPIKey,
"NEUROFORGE_ADMIN_TOKEN": &sec.AdminToken,
"NEUROFORGE_APP_API_KEY": &sec.AppAPIKey,
"NEUROFORGE_INTEGRATION_TOKEN": &sec.IntegrationToken,
@@ -247,6 +248,14 @@ func run() (retErr error) {
changed = true
}
}
// OLLAMA_API_KEY is a convenient shared alias. The NeuroForge-specific
// variable wins when both are set.
if strings.TrimSpace(os.Getenv("NEUROFORGE_OLLAMA_API_KEY")) == "" {
if v := strings.TrimSpace(os.Getenv("OLLAMA_API_KEY")); v != "" {
sec.OllamaAPIKey = v
changed = true
}
}
if changed {
if err := s.UpdateSecrets(sec); err != nil {
return err
+8 -3
View File
@@ -86,6 +86,7 @@ type workerConfig struct {
MaxConcurrency int
Hostname string
OllamaURL string
OllamaAPIKey string
OllamaChatModel string
OllamaEmbedModel string
OllamaNumCtx int
@@ -145,6 +146,7 @@ func main() {
Heartbeat: heartbeat, ResourceClass: resource, Capabilities: caps,
MaxConcurrency: maxConcurrency, Hostname: hostname(),
OllamaURL: strings.TrimRight(firstNonEmpty(os.Getenv("NEUROFORGE_WORKER_OLLAMA_URL"), os.Getenv("OLLAMA_BASE_URL"), os.Getenv("OLLAMA_URL")), "/"),
OllamaAPIKey: firstNonEmpty(os.Getenv("NEUROFORGE_WORKER_OLLAMA_API_KEY"), os.Getenv("OLLAMA_API_KEY")),
OllamaChatModel: firstNonEmpty(os.Getenv("NEUROFORGE_WORKER_OLLAMA_CHAT_MODEL"), os.Getenv("OLLAMA_MODEL")),
OllamaEmbedModel: firstNonEmpty(os.Getenv("NEUROFORGE_WORKER_OLLAMA_EMBEDDING_MODEL"), os.Getenv("OLLAMA_EMBEDDING_MODEL")),
OllamaNumCtx: envInt("NEUROFORGE_WORKER_OLLAMA_NUM_CTX", 8192),
@@ -342,7 +344,7 @@ func ollamaEmbed(ctx context.Context, c *http.Client, cfg workerConfig, p modelE
Embeddings [][]float32 `json:"embeddings"`
PromptEvalCount int64 `json:"prompt_eval_count"`
}
if err := postOllama(ctx, c, cfg.OllamaURL+"/api/embed", body, &resp); err != nil {
if err := postOllama(ctx, c, cfg.OllamaURL+"/api/embed", cfg.OllamaAPIKey, body, &resp); err != nil {
return modelResult{}, err
}
if len(resp.Embeddings) == 0 || len(resp.Embeddings[0]) == 0 {
@@ -385,19 +387,22 @@ func ollamaChat(ctx context.Context, c *http.Client, cfg workerConfig, p modelCh
PromptEvalCount int64 `json:"prompt_eval_count"`
EvalCount int64 `json:"eval_count"`
}
if err := postOllama(ctx, c, cfg.OllamaURL+"/api/chat", body, &resp); err != nil {
if err := postOllama(ctx, c, cfg.OllamaURL+"/api/chat", cfg.OllamaAPIKey, body, &resp); err != nil {
return modelResult{}, err
}
return modelResult{Text: strings.TrimSpace(resp.Message.Content), Usage: modelUsage{InputTokens: resp.PromptEvalCount, OutputTokens: resp.EvalCount}, Provider: "ollama", Model: model, NodeID: cfg.ID}, nil
}
func postOllama(ctx context.Context, c *http.Client, url string, body any, out any) error {
func postOllama(ctx context.Context, c *http.Client, url, apiKey string, body any, out any) error {
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if token := strings.TrimSpace(apiKey); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.Do(req)
if err != nil {
return err
@@ -0,0 +1,26 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestPostOllamaSendsBearerToken(t *testing.T) {
const token = "ollama-secret"
var auth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}))
defer srv.Close()
var out map[string]any
if err := postOllama(context.Background(), srv.Client(), srv.URL, token, map[string]any{"x": 1}, &out); err != nil {
t.Fatal(err)
}
if auth != "Bearer "+token {
t.Fatalf("authorization=%q", auth)
}
}
+4
View File
@@ -12,6 +12,8 @@ services:
NEUROFORGE_METRICS_TOKEN: ${NEUROFORGE_METRICS_TOKEN}
NEUROFORGE_CLUSTER_TOKEN: ${NEUROFORGE_CLUSTER_TOKEN:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
NEUROFORGE_OLLAMA_API_KEY: ${NEUROFORGE_OLLAMA_API_KEY:-}
OLLAMA_API_KEY: ${OLLAMA_API_KEY:-}
volumes:
- neuroforge-data:/app/data
restart: unless-stopped
@@ -59,6 +61,8 @@ services:
NEUROFORGE_WORKER_CAPABILITIES: gpu,model.chat,model.embed
NEUROFORGE_WORKER_MAX_CONCURRENCY: ${NEUROFORGE_GPU_WORKER_CONCURRENCY:-1}
NEUROFORGE_WORKER_OLLAMA_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
NEUROFORGE_WORKER_OLLAMA_API_KEY: ${NEUROFORGE_WORKER_OLLAMA_API_KEY:-}
OLLAMA_API_KEY: ${OLLAMA_API_KEY:-}
NEUROFORGE_WORKER_OLLAMA_CHAT_MODEL: ${OLLAMA_MODEL:-gemma3}
NEUROFORGE_WORKER_OLLAMA_EMBEDDING_MODEL: ${OLLAMA_EMBEDDING_MODEL:-embeddinggemma}
depends_on:
@@ -419,6 +419,7 @@ type Config struct {
type Secrets struct {
OpenAIAPIKey string `json:"openai_api_key"`
OllamaAPIKey string `json:"ollama_api_key,omitempty"`
AppAPIKey string `json:"app_api_key"`
IntegrationToken string `json:"integration_token,omitempty"`
ControlReadToken string `json:"control_read_token,omitempty"`
@@ -693,7 +693,7 @@ func (s *Server) adminPutModelRouting(w http.ResponseWriter, r *http.Request) {
func (s *Server) adminSecretsStatus(w http.ResponseWriter, r *http.Request) {
sec := s.store.Secrets()
s.json(w, 200, map[string]any{"openai_configured": sec.OpenAIAPIKey != "", "app_key_configured": sec.AppAPIKey != "", "integration_token_configured": sec.IntegrationToken != "", "control_read_token_configured": sec.ControlReadToken != "", "worker_token_configured": sec.WorkerToken != "", "metrics_token_configured": sec.MetricsToken != "", "shard_tokens": len(sec.ShardAPIToken), "cluster_token_configured": sec.ClusterToken != ""})
s.json(w, 200, map[string]any{"openai_configured": sec.OpenAIAPIKey != "", "ollama_api_key_configured": sec.OllamaAPIKey != "", "app_key_configured": sec.AppAPIKey != "", "integration_token_configured": sec.IntegrationToken != "", "control_read_token_configured": sec.ControlReadToken != "", "worker_token_configured": sec.WorkerToken != "", "metrics_token_configured": sec.MetricsToken != "", "shard_tokens": len(sec.ShardAPIToken), "cluster_token_configured": sec.ClusterToken != ""})
}
func maskedSecret(v string) string {
if v == "" {
@@ -708,18 +708,19 @@ func (s *Server) adminGetSecrets(w http.ResponseWriter, r *http.Request) {
sec := s.store.Secrets()
reveal := r.URL.Query().Get("reveal") == "1" && s.store.Config().Security.AllowSecretReveal
if reveal {
s.json(w, 200, map[string]any{"revealed": true, "app_api_key": sec.AppAPIKey, "integration_token": sec.IntegrationToken, "control_read_token": sec.ControlReadToken, "worker_token": sec.WorkerToken, "metrics_token": sec.MetricsToken, "shard_api_tokens": sec.ShardAPIToken, "cluster_token": sec.ClusterToken})
s.json(w, 200, map[string]any{"revealed": true, "ollama_api_key": sec.OllamaAPIKey, "app_api_key": sec.AppAPIKey, "integration_token": sec.IntegrationToken, "control_read_token": sec.ControlReadToken, "worker_token": sec.WorkerToken, "metrics_token": sec.MetricsToken, "shard_api_tokens": sec.ShardAPIToken, "cluster_token": sec.ClusterToken})
return
}
maskedShards := map[string]string{}
for k, v := range sec.ShardAPIToken {
maskedShards[k] = maskedSecret(v)
}
s.json(w, 200, map[string]any{"revealed": false, "reveal_allowed": s.store.Config().Security.AllowSecretReveal, "app_api_key": maskedSecret(sec.AppAPIKey), "integration_token": maskedSecret(sec.IntegrationToken), "control_read_token": maskedSecret(sec.ControlReadToken), "worker_token": maskedSecret(sec.WorkerToken), "metrics_token": maskedSecret(sec.MetricsToken), "shard_api_tokens": maskedShards, "cluster_token": maskedSecret(sec.ClusterToken)})
s.json(w, 200, map[string]any{"revealed": false, "reveal_allowed": s.store.Config().Security.AllowSecretReveal, "ollama_api_key": maskedSecret(sec.OllamaAPIKey), "app_api_key": maskedSecret(sec.AppAPIKey), "integration_token": maskedSecret(sec.IntegrationToken), "control_read_token": maskedSecret(sec.ControlReadToken), "worker_token": maskedSecret(sec.WorkerToken), "metrics_token": maskedSecret(sec.MetricsToken), "shard_api_tokens": maskedShards, "cluster_token": maskedSecret(sec.ClusterToken)})
}
func (s *Server) adminPutSecrets(w http.ResponseWriter, r *http.Request) {
var q struct {
OpenAIAPIKey string `json:"openai_api_key,omitempty"`
OllamaAPIKey string `json:"ollama_api_key,omitempty"`
AppAPIKey string `json:"app_api_key,omitempty"`
IntegrationToken string `json:"integration_token,omitempty"`
ControlReadToken string `json:"control_read_token,omitempty"`
@@ -737,8 +738,13 @@ func (s *Server) adminPutSecrets(w http.ResponseWriter, r *http.Request) {
_, ok := os.LookupEnv(name)
return ok && strings.TrimSpace(os.Getenv(name)) != ""
}
if q.OllamaAPIKey != "" && (envLocked("NEUROFORGE_OLLAMA_API_KEY") || envLocked("OLLAMA_API_KEY")) {
s.err(w, http.StatusConflict, fmt.Errorf("Ollama API key is environment-managed and cannot be changed through the admin API"))
return
}
for name, value := range map[string]string{
"OPENAI_API_KEY": q.OpenAIAPIKey,
"NEUROFORGE_OLLAMA_API_KEY": q.OllamaAPIKey,
"NEUROFORGE_APP_API_KEY": q.AppAPIKey,
"NEUROFORGE_INTEGRATION_TOKEN": q.IntegrationToken,
"NEUROFORGE_CONTROL_READ_TOKEN": q.ControlReadToken,
@@ -754,6 +760,9 @@ func (s *Server) adminPutSecrets(w http.ResponseWriter, r *http.Request) {
if q.OpenAIAPIKey != "" {
sec.OpenAIAPIKey = q.OpenAIAPIKey
}
if q.OllamaAPIKey != "" {
sec.OllamaAPIKey = q.OllamaAPIKey
}
if q.AppAPIKey != "" {
sec.AppAPIKey = q.AppAPIKey
}
@@ -899,7 +908,7 @@ func configuredModelAvailable(models map[string]bool, configured string) bool {
return false
}
func checkConfiguredOllamaModels(ctx context.Context, cfg core.Config) (bool, any) {
func checkConfiguredOllamaModels(ctx context.Context, cfg core.Config, apiKey string) (bool, any) {
type tagsResponse struct {
Models []struct {
Name string `json:"name"`
@@ -917,6 +926,9 @@ func checkConfiguredOllamaModels(ctx context.Context, cfg core.Config) (bool, an
details[node.ID] = err.Error()
continue
}
if token := strings.TrimSpace(apiKey); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
details[node.ID] = err.Error()
@@ -977,7 +989,7 @@ func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
if s.readinessOllamaLive {
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
var detail any
ollamaLiveReady, detail = checkConfiguredOllamaModels(ctx, cfg)
ollamaLiveReady, detail = checkConfiguredOllamaModels(ctx, cfg, s.store.Secrets().OllamaAPIKey)
cancel()
components["ollama_live_models"] = detail
}
@@ -307,7 +307,7 @@ func (r *Router) chatOllama(ctx context.Context, o core.OllamaServer, model, ins
}
requestCtx, cancel := optionalTimeout(ctx, o.RequestTimeoutSeconds)
defer cancel()
if err := r.doJSON(requestCtx, "POST", cleanBase(o.BaseURL)+"/api/chat", "", body, &out); err != nil {
if err := r.doJSON(requestCtx, "POST", cleanBase(o.BaseURL)+"/api/chat", r.store.Secrets().OllamaAPIKey, body, &out); err != nil {
return ChatResult{}, fmt.Errorf("ollama %s: %w", o.Name, err)
}
if strings.TrimSpace(out.Message.Content) == "" {
@@ -327,7 +327,7 @@ func (r *Router) embedOllama(ctx context.Context, o core.OllamaServer, model, te
}
requestCtx, cancel := optionalTimeout(ctx, o.RequestTimeoutSeconds)
defer cancel()
if err := r.doJSON(requestCtx, "POST", cleanBase(o.BaseURL)+"/api/embed", "", body, &out); err != nil {
if err := r.doJSON(requestCtx, "POST", cleanBase(o.BaseURL)+"/api/embed", r.store.Secrets().OllamaAPIKey, body, &out); err != nil {
return EmbedResult{}, fmt.Errorf("ollama %s: %w", o.Name, err)
}
if len(out.Embeddings) == 0 || len(out.Embeddings[0]) == 0 {
@@ -459,6 +459,9 @@ func (r *Router) Health(ctx context.Context) []map[string]any {
}
healthCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
req, _ := http.NewRequestWithContext(healthCtx, "GET", cleanBase(o.BaseURL)+"/api/tags", nil)
if token := strings.TrimSpace(r.store.Secrets().OllamaAPIKey); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := r.http.Do(req)
if err != nil {
cancel()
@@ -111,3 +111,48 @@ func TestOllamaExplicitRequestTimeoutStillWorks(t *testing.T) {
t.Fatalf("configured timeout was not enforced promptly: %v", time.Since(start))
}
}
func TestOllamaBearerTokenIsSentToChatAndHealth(t *testing.T) {
const token = "ollama-secret"
var chatAuth, tagsAuth string
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/chat":
chatAuth = r.Header.Get("Authorization")
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": "ok"}})
case "/api/tags":
tagsAuth = r.Header.Get("Authorization")
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "chat"}, {"name": "embed"}}})
default:
http.NotFound(w, r)
}
}))
defer fake.Close()
s, err := store.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.Close()
cfg := s.Config()
cfg.Ollama = []core.OllamaServer{{ID: "secure", Name: "secure", BaseURL: fake.URL, ChatModel: "chat", EmbeddingModel: "embed", Weight: 1, Enabled: true}}
cfg.Routing.ChatProvider = "ollama"
if err := s.UpdateConfig(cfg); err != nil {
t.Fatal(err)
}
sec := s.Secrets()
sec.OllamaAPIKey = token
if err := s.UpdateSecrets(sec); err != nil {
t.Fatal(err)
}
r := NewRouter(s)
if _, err := r.Chat(context.Background(), "ollama", "chat", "", "hello", 32); err != nil {
t.Fatal(err)
}
_ = r.Health(context.Background())
if chatAuth != "Bearer "+token {
t.Fatalf("chat authorization=%q", chatAuth)
}
if tagsAuth != "Bearer "+token {
t.Fatalf("tags authorization=%q", tagsAuth)
}
}