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
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:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user