AI: Add support for OLLAMA_BASE_URL env expansion in vision.yml #5361

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-12-10 10:52:26 +01:00
parent d5c56d4e7e
commit 75f183aa25
46 changed files with 226 additions and 102 deletions

View File

@@ -38,6 +38,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
S6_KEEP_ENV=1 \
S6_VERBOSITY=0 \
S6_LOGGING=0

View File

@@ -32,7 +32,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_CPP_MIN_LOG_LEVEL=4 \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# Copy scripts and package sources config.
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -37,7 +37,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GOBIN="/usr/local/bin" \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# Copy scripts and package sources config.
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -32,7 +32,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_CPP_MIN_LOG_LEVEL=4 \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# copy scripts and debian backports sources list
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -37,7 +37,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GOBIN="/usr/local/bin" \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# copy scripts and debian backports sources list
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -37,7 +37,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GOBIN="/usr/local/bin" \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# copy scripts and debian backports sources list
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -37,7 +37,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GOBIN="/usr/local/bin" \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# copy scripts and debian backports sources list
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -33,6 +33,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
S6_KEEP_ENV=0 \
S6_VERBOSITY=0 \
S6_LOGGING=0

View File

@@ -38,6 +38,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
S6_KEEP_ENV=1 \
S6_VERBOSITY=0 \
S6_LOGGING=0

View File

@@ -32,7 +32,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_CPP_MIN_LOG_LEVEL=4 \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# Copy scripts and package sources config.
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -37,7 +37,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GOBIN="/usr/local/bin" \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# Copy scripts and package sources config.
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -32,7 +32,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_CPP_MIN_LOG_LEVEL=4 \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# Copy scripts and package sources config.
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -37,7 +37,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GOBIN="/usr/local/bin" \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# Copy scripts and package sources config.
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -32,7 +32,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_CPP_MIN_LOG_LEVEL=4 \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# Copy scripts and package sources config.
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -37,7 +37,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GOBIN="/usr/local/bin" \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism"
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
# Copy scripts and package sources config.
COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/

View File

@@ -33,6 +33,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
S6_KEEP_ENV=0 \
S6_VERBOSITY=0 \
S6_LOGGING=0

View File

@@ -38,6 +38,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
S6_KEEP_ENV=1 \
S6_VERBOSITY=0 \
S6_LOGGING=0

View File

@@ -33,6 +33,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
S6_KEEP_ENV=0 \
S6_VERBOSITY=0 \
S6_LOGGING=0

View File

@@ -38,6 +38,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
S6_KEEP_ENV=1 \
S6_VERBOSITY=0 \
S6_LOGGING=0

View File

@@ -33,6 +33,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
S6_KEEP_ENV=0 \
S6_VERBOSITY=0 \
S6_LOGGING=0

View File

@@ -38,6 +38,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
GO111MODULE="on" \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
S6_KEEP_ENV=1 \
S6_VERBOSITY=0 \
S6_LOGGING=0

View File

@@ -50,6 +50,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_CPP_MIN_LOG_LEVEL=4 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434"
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -45,6 +45,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -45,6 +45,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -48,6 +48,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -48,6 +48,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -46,6 +46,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -46,6 +46,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -46,6 +46,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -46,6 +46,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -46,6 +46,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -46,6 +46,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -46,6 +46,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=2 \
PROG="photoprism" \
OLLAMA_BASE_URL="http://ollama:11434" \
PHOTOPRISM_ASSETS_PATH="/opt/photoprism/assets" \
PHOTOPRISM_IMPORT_PATH="/photoprism/import" \
PHOTOPRISM_ORIGINALS_PATH="/photoprism/originals" \

View File

@@ -1,13 +1,13 @@
## PhotoPrism — Vision Package
**Last Updated:** December 2, 2025
**Last Updated:** December 10, 2025
### Overview
`internal/ai/vision` provides the shared model registry, request builders, and parsers that power PhotoPrisms caption, label, face, NSFW, and future generate workflows. It reads `vision.yml`, normalizes models, and dispatches calls to one of three engines:
- **TensorFlow (builtin)** — default Nasnet / NSFW / Facenet models, no remote service required.
- **Ollama** — local or proxied multimodal LLMs. See [`ollama/README.md`](ollama/README.md) for tuning and schema details.
- **Ollama** — local or proxied multimodal LLMs. See [`ollama/README.md`](ollama/README.md) for tuning and schema details. The engine defaults to `${OLLAMA_BASE_URL:-http://ollama:11434}/api/generate`, trimming any trailing slash on the base URL; set `OLLAMA_BASE_URL=https://ollama.com` to opt into cloud defaults.
- **OpenAI** — cloud Responses API. See [`openai/README.md`](openai/README.md) for prompts, schema variants, and header requirements.
### Configuration
@@ -94,8 +94,8 @@ The model `Options` adjust model parameters such as temperature, top-p, and sche
Configures the endpoint URL, method, format, and authentication for [Ollama](ollama/README.md), [OpenAI](openai/README.md), and other engines that perform remote HTTP requests:
| Field | Default | Notes |
|------------------------------------|------------------------------------------|------------------------------------------------------------------------------------------|
| `Uri` | required for remote | Endpoint base. Empty keeps model local (TensorFlow). |
|------------------------------------|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
| `Uri` | required for remote | Endpoint base. Empty keeps model local (TensorFlow). Ollama alias fills `${OLLAMA_BASE_URL}/api/generate`, defaulting to `http://ollama:11434`. |
| `Method` | `POST` | Override verb if provider needs it. |
| `Key` | `""` | Bearer token; prefer env expansion (OpenAI: `OPENAI_API_KEY`, Ollama: `OLLAMA_API_KEY`). |
| `Username` / `Password` | `""` | Injected as basic auth when URI lacks userinfo. |
@@ -142,7 +142,7 @@ Models:
Engine: ollama
Run: newly-indexed
Service:
Uri: http://ollama:11434/api/generate
Uri: ${OLLAMA_BASE_URL}/api/generate
```
More Ollama guidance: [`internal/ai/vision/ollama/README.md`](ollama/README.md).

View File

@@ -28,25 +28,25 @@ func init() {
}
// registerOllamaEngineDefaults selects the default Ollama endpoint based on the
// available credentials and registers the engine alias accordingly. When an
// API key is configured, we default to the hosted Cloud endpoint; otherwise we
// assume a self-hosted instance reachable via the docker-compose default.
// This keeps the zero-config path fast for local dev while automatically using
// the cloud service when credentials are present.
// configured base URL and registers the engine alias accordingly. When
// OLLAMA_BASE_URL points at the cloud host we only switch the default model to
// the cloud preset; the actual base URL continues to come from
// OLLAMA_BASE_URL (or falls back to the local compose default) so we don't
// accidentally talk to the hosted service without an explicit endpoint.
func registerOllamaEngineDefaults() {
defaultModel := ollama.DefaultModel
defaultUri := ollama.DefaultUri
ensureEnv()
// Detect Ollama cloud API key.
if key := os.Getenv(ollama.APIKeyEnv); len(key) > 50 && strings.Contains(key, ".") {
defaultModel := ollama.DefaultModel
// Use different default model for the Ollama cloud service.
if baseUrl := os.Getenv(ollama.BaseUrlEnv); baseUrl == ollama.CloudBaseUrl {
defaultModel = ollama.CloudModel
defaultUri = ollama.CloudUri
}
// Register the human-friendly engine name so configuration can simply use
// `Engine: "ollama"` and inherit adapter defaults.
RegisterEngineAlias(ollama.EngineName, EngineInfo{
Uri: defaultUri,
Uri: ollama.DefaultUri,
RequestFormat: ApiFormatOllama,
ResponseFormat: ApiFormatOllama,
FileScheme: scheme.Base64,

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"os"
"sync"
"testing"
"github.com/photoprism/photoprism/internal/ai/vision/ollama"
@@ -29,6 +30,7 @@ func TestRegisterOllamaEngineDefaults(t *testing.T) {
})
t.Run("SelfHosted", func(t *testing.T) {
ensureEnvOnce = sync.Once{}
CaptionModel = testCaptionModel.Clone()
_ = os.Unsetenv(ollama.APIKeyEnv)
@@ -56,8 +58,9 @@ func TestRegisterOllamaEngineDefaults(t *testing.T) {
}
})
t.Run("Cloud", func(t *testing.T) {
ensureEnvOnce = sync.Once{}
CaptionModel = testCaptionModel.Clone()
t.Setenv(ollama.APIKeyEnv, cloudToken)
t.Setenv(ollama.BaseUrlEnv, ollama.CloudBaseUrl+"/")
registerOllamaEngineDefaults()
@@ -66,8 +69,8 @@ func TestRegisterOllamaEngineDefaults(t *testing.T) {
t.Fatalf("expected engine info for %s", ollama.EngineName)
}
if info.Uri != ollama.CloudUri {
t.Fatalf("expected cloud uri %s, got %s", ollama.CloudUri, info.Uri)
if info.Uri != ollama.DefaultUri {
t.Fatalf("expected default uri %s, got %s", ollama.DefaultUri, info.Uri)
}
if info.DefaultModel != ollama.CloudModel {
@@ -78,14 +81,31 @@ func TestRegisterOllamaEngineDefaults(t *testing.T) {
t.Fatalf("expected caption model %s, got %s", ollama.CloudModel, CaptionModel.Model)
}
if CaptionModel.Service.Uri != ollama.CloudUri {
t.Fatalf("expected caption model uri %s, got %s", ollama.CloudUri, CaptionModel.Service.Uri)
if CaptionModel.Service.Uri != ollama.DefaultUri {
t.Fatalf("expected caption model uri %s, got %s", ollama.DefaultUri, CaptionModel.Service.Uri)
}
})
t.Run("ApiKeyAloneKeepsLocalDefaults", func(t *testing.T) {
ensureEnvOnce = sync.Once{}
CaptionModel = testCaptionModel.Clone()
t.Setenv(ollama.APIKeyEnv, cloudToken)
registerOllamaEngineDefaults()
info, ok := EngineInfoFor(ollama.EngineName)
if !ok {
t.Fatalf("expected engine info for %s", ollama.EngineName)
}
if info.DefaultModel != ollama.DefaultModel {
t.Fatalf("expected default model %s, got %s", ollama.DefaultModel, info.DefaultModel)
}
})
t.Run("NewModels", func(t *testing.T) {
ensureEnvOnce = sync.Once{}
CaptionModel = testCaptionModel.Clone()
t.Setenv(ollama.APIKeyEnv, cloudToken)
t.Setenv(ollama.BaseUrlEnv, ollama.CloudBaseUrl)
registerOllamaEngineDefaults()
model := &Model{Type: ModelTypeCaption, Engine: ollama.EngineName}
@@ -95,8 +115,8 @@ func TestRegisterOllamaEngineDefaults(t *testing.T) {
t.Fatalf("expected model %s, got %s", ollama.CloudModel, model.Model)
}
if model.Service.Uri != ollama.CloudUri {
t.Fatalf("expected service uri %s, got %s", ollama.CloudUri, model.Service.Uri)
if model.Service.Uri != ollama.DefaultUri {
t.Fatalf("expected service uri %s, got %s", ollama.DefaultUri, model.Service.Uri)
}
if model.Service.RequestFormat != ApiFormatOllama || model.Service.ResponseFormat != ApiFormatOllama {

View File

@@ -1,14 +1,14 @@
## PhotoPrism — Ollama Engine Integration
**Last Updated:** November 14, 2025
**Last Updated:** December 10, 2025
### Overview
This package provides PhotoPrisms native adapter for Ollama-compatible multimodal models. It lets Caption, Labels, and future Generate workflows call locally hosted models without changing worker logic, reusing the shared API client (`internal/ai/vision/api_client.go`) and result types (`LabelResult`, `CaptionResult`). Requests stay inside your infrastructure, rely on base64 thumbnails, and honor the same ACL, timeout, and logging hooks as the default TensorFlow engines.
This package provides PhotoPrisms native adapter for Ollama-compatible multimodal models. It lets Caption, Labels, and future Generate workflows call locally hosted models without changing worker logic, reusing the shared API client (`internal/ai/vision/api_client.go`) and result types (`LabelResult`, `CaptionResult`). Requests stay inside your infrastructure, rely on base64 thumbnails, and honor the same ACL, timeout, and logging hooks as the default TensorFlow engines. The adapter resolves `${OLLAMA_BASE_URL}/api/generate`, trimming trailing slashes and defaulting to `http://ollama:11434`; set `OLLAMA_BASE_URL=https://ollama.com` to opt into cloud defaults.
#### Context & Constraints
- Engine defaults live in `internal/ai/vision/ollama` and are applied whenever a model sets `Engine: ollama`. Aliases map to `ApiFormatOllama`, `scheme.Base64`, and a default 720px thumbnail.
- Engine defaults live in `internal/ai/vision/ollama` and are applied whenever a model sets `Engine: ollama`. Aliases map to `ApiFormatOllama`, `scheme.Base64`, and a default 720px thumbnail. Cloud defaults are only selected when `OLLAMA_BASE_URL` equals `https://ollama.com`.
- Responses may arrive as newline-delimited JSON chunks. `decodeOllamaResponse` keeps the most recent chunk, while `parseOllamaLabels` replays plain JSON strings found in `response`.
- Structured JSON is optional for captions but enforced for labels when `Format: json` (default for label models targeting the Ollama engine).
- The adapter never overwrites TensorFlow defaults. If an Ollama call fails, downstream code still has Nasnet, NSFW, and Face models available.
@@ -73,6 +73,7 @@ This package provides PhotoPrisms native adapter for Ollama-compatible multim
- `PHOTOPRISM_VISION_YAML` — Custom `vision.yml` path. Keep it synced in Git if you automate deployments.
- `OLLAMA_HOST`, `OLLAMA_MODELS`, `OLLAMA_MAX_QUEUE`, `OLLAMA_NUM_PARALLEL`, etc. — Provided in `compose*.yaml` to tune the Ollama daemon. Adjust `OLLAMA_KEEP_ALIVE` if you want models to stay loaded between worker batches.
- `OLLAMA_API_KEY` / `OLLAMA_API_KEY_FILE` — Default bearer token picked up when `Service.Key` is empty; useful for hosted Ollama services (e.g., Ollama Cloud).
- `OLLAMA_BASE_URL` — Base URL for the Ollama API; defaults to `http://ollama:11434`, trailing slashes are trimmed. Set to `https://ollama.com` to enable cloud defaults.
- `PHOTOPRISM_LOG_LEVEL=trace` — Enables verbose request/response previews (truncated to avoid leaking images). Use temporarily when debugging parsing issues.
#### `vision.yml` Example
@@ -90,7 +91,7 @@ Models:
Stop: ["\n\n"]
ForceJson: true
Service:
Uri: http://ollama:11434/api/generate
Uri: ${OLLAMA_BASE_URL}/api/generate
RequestFormat: ollama
ResponseFormat: ollama
FileScheme: base64
@@ -102,7 +103,7 @@ Models:
Options:
Temperature: 0.2
Service:
Uri: http://ollama:11434/api/generate
Uri: ${OLLAMA_BASE_URL}/api/generate
```
Guidelines:

View File

@@ -11,10 +11,16 @@ const (
APIKeyFileEnv = "OLLAMA_API_KEY_FILE" //nolint:gosec // environment variable name, not a secret
// APIKeyPlaceholder is the `${VAR}` form injected when no explicit key is provided.
APIKeyPlaceholder = "${" + APIKeyEnv + "}"
// BaseUrlEnv defines the environment variable used for the Ollama base URL e.g. "https://ollama.com" or "http://ollama:11434".
BaseUrlEnv = "OLLAMA_BASE_URL" //nolint:gosec // environment variable name, not a secret
// BaseUrlPlaceholder is the `${VAR}` form injected when no explicit URL is provided.
BaseUrlPlaceholder = "${" + BaseUrlEnv + "}"
// DefaultBaseUrl is the local Ollama endpoint used when the environment variable is unset.
DefaultBaseUrl = "http://ollama:11434"
// CloudBaseUrl is the base URL for the Ollama Cloud service.
CloudBaseUrl = "https://ollama.com"
// DefaultUri is the default service URI for self-hosted Ollama instances.
DefaultUri = "http://ollama:11434/api/generate"
// CloudUri is the Ollama cloud service URI
CloudUri = "https://ollama.com/api/generate"
DefaultUri = BaseUrlPlaceholder + "/api/generate"
// DefaultModel names the default caption model bundled with our adapter defaults.
DefaultModel = "gemma3:latest"
// CloudModel names the default caption for the Ollama cloud service, see https://ollama.com/cloud.

View File

@@ -31,14 +31,18 @@ func (m *Service) Endpoint() (uri, method string) {
return "", ""
}
ensureEnv()
if uri = strings.TrimSpace(os.ExpandEnv(m.Uri)); strings.Contains(uri, "${") {
uri = ""
}
if m.Method != "" {
method = m.Method
} else {
method = ServiceMethod
}
uri = strings.TrimSpace(m.Uri)
if username, password := m.BasicAuth(); username != "" || password != "" {
if parsed, err := url.Parse(uri); err == nil {
if parsed.User == nil {

View File

@@ -33,10 +33,26 @@ func TestServiceEndpoint(t *testing.T) {
wantURI: "https://keep:me@vision.example.com",
wantMethod: ServiceMethod,
},
{
name: "ExpandsBaseUrlEnv",
svc: Service{Uri: "${OLLAMA_BASE_URL}/api/generate"},
wantURI: "http://custom:11434/api/generate",
wantMethod: ServiceMethod,
},
{
name: "FallbacksWhenEnvMissing",
svc: Service{Uri: "${OLLAMA_BASE_URL}/api/generate"},
wantURI: "http://ollama:11434/api/generate",
wantMethod: ServiceMethod,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.name == "ExpandsBaseUrlEnv" {
t.Setenv("OLLAMA_BASE_URL", "http://custom:11434")
}
uri, method := tt.svc.Endpoint()
if uri != tt.wantURI {
t.Fatalf("uri: got %q want %q", uri, tt.wantURI)

View File

@@ -70,7 +70,7 @@ Models:
Run: manual
Resolution: 720
Service:
Uri: http://ollama:11434/api/generate
Uri: ${OLLAMA_BASE_URL}/api/generate
Key: ${OLLAMA_API_KEY}
FileScheme: base64
RequestFormat: ollama

View File

@@ -25,49 +25,7 @@ Additional information can be found in our Developer Guide:
package vision
import (
"os"
"strings"
"sync"
"github.com/photoprism/photoprism/internal/ai/vision/ollama"
"github.com/photoprism/photoprism/internal/ai/vision/openai"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
var log = event.Log
var ensureEnvOnce sync.Once
// ensureEnv loads environment-backed credentials once so adapters can look up
// OPENAI_API_KEY / OLLAMA_API_KEY even when operators rely on *_FILE fallbacks.
// Future engine integrations can reuse this hook to normalise additional
// secrets.
func ensureEnv() {
ensureEnvOnce.Do(func() {
loadEnvKeyFromFile(openai.APIKeyEnv, openai.APIKeyFileEnv)
loadEnvKeyFromFile(ollama.APIKeyEnv, ollama.APIKeyFileEnv)
})
}
// loadEnvKeyFromFile populates envVar from fileVar when the environment value
// is empty and the referenced file exists and is non-empty.
func loadEnvKeyFromFile(envVar, fileVar string) {
if os.Getenv(envVar) != "" {
return
}
filePath := strings.TrimSpace(os.Getenv(fileVar))
if !fs.FileExistsNotEmpty(filePath) {
return
}
// #nosec G304 path provided via env
if data, err := os.ReadFile(filePath); err == nil {
if key := clean.Auth(string(data)); key != "" {
_ = os.Setenv(envVar, key)
}
}
}

View File

@@ -0,0 +1,61 @@
package vision
import (
"os"
"strings"
"sync"
"github.com/photoprism/photoprism/internal/ai/vision/ollama"
"github.com/photoprism/photoprism/internal/ai/vision/openai"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
var ensureEnvOnce sync.Once
// ensureEnv loads environment-backed credentials once so adapters can look up
// OPENAI_API_KEY / OLLAMA_API_KEY even when operators rely on *_FILE fallbacks.
// Future engine integrations can reuse this hook to normalize additional
// secrets.
func ensureEnv() {
ensureEnvOnce.Do(func() {
loadEnvKeyFromFile(openai.APIKeyEnv, openai.APIKeyFileEnv)
loadEnvKeyFromFile(ollama.APIKeyEnv, ollama.APIKeyFileEnv)
// Init the Ollama base URL by trimming trailing slashes or using the default.
initEnvUrl(ollama.BaseUrlEnv, ollama.DefaultBaseUrl)
})
}
// initEnvUrl ensures that the variable contains no trailing
// slashes and sets a default value if it is missing.
func initEnvUrl(envName, defaultUrl string) {
if base := strings.TrimSpace(os.Getenv(envName)); base != "" {
if normalized := strings.TrimRight(base, "/"); normalized != base {
_ = os.Setenv(envName, normalized)
}
} else if defaultUrl != "" {
_ = os.Setenv(envName, defaultUrl)
}
}
// loadEnvKeyFromFile populates envVar from fileVar when the environment value
// is empty and the referenced file exists and is non-empty.
func loadEnvKeyFromFile(envVar, fileVar string) {
if os.Getenv(envVar) != "" {
return
}
filePath := strings.TrimSpace(os.Getenv(fileVar))
if !fs.FileExistsNotEmpty(filePath) {
return
}
// #nosec G304 path provided via env
if data, err := os.ReadFile(filePath); err == nil {
if key := clean.Auth(string(data)); key != "" {
_ = os.Setenv(envVar, key)
}
}
}

View File

@@ -6,6 +6,31 @@ import (
"testing"
)
func TestInitEnvUrl(t *testing.T) {
const envName = "TEST_OLLAMA_BASE_URL"
// Case: trims trailing slash.
t.Setenv(envName, "http://example.com/")
initEnvUrl(envName, "")
if got := os.Getenv(envName); got != "http://example.com" {
t.Fatalf("trim: expected http://example.com, got %s", got)
}
// Case: sets default when unset.
t.Setenv(envName, "")
initEnvUrl(envName, "http://default.local")
if got := os.Getenv(envName); got != "http://default.local" {
t.Fatalf("default: expected http://default.local, got %s", got)
}
// Case: leaves already-normalized value untouched.
t.Setenv(envName, "http://kept.local")
initEnvUrl(envName, "http://ignored.local")
if got := os.Getenv(envName); got != "http://kept.local" {
t.Fatalf("preserve: expected http://kept.local, got %s", got)
}
}
// TestLoadEnvKeyFromFile verifies that loadEnvKeyFromFile reads API keys from
// *_FILE variables when the primary env var is empty.
func TestLoadEnvKeyFromFile(t *testing.T) {