diff --git a/.env.cloud.example b/.env.cloud.example index 0063c7c..1d1f31a 100644 --- a/.env.cloud.example +++ b/.env.cloud.example @@ -23,10 +23,13 @@ HEIMDALL_ADMIN_TOKEN= # Must match COORDINATOR_PRODUCT_KEYS["pagepiper"] in cf-orch.env on the coordinator COORDINATOR_PAGEPIPER_KEY= -# cf-orch coordinator URL — routes chat/embed calls through managed GPU allocation -# CF_LICENSE_KEY is the auth token sent to the coordinator (same value as COORDINATOR_PAGEPIPER_KEY) -# Leave CF_ORCH_URL blank to skip allocation and hit PAGEPIPER_OLLAMA_URL directly -CF_ORCH_URL= +# GPU server / cf-orch coordinator URL — routes chat/embed calls through managed +# GPU allocation. CF_LICENSE_KEY is the auth token sent to the coordinator (same +# value as COORDINATOR_PAGEPIPER_KEY). Leave blank to skip allocation and hit +# PAGEPIPER_OLLAMA_URL directly. CF_ORCH_URL is still honoured as a legacy alias +# if GPU_SERVER_URL is unset — Paid+ tiers auto-default to orch.circuitforge.tech +# when CF_LICENSE_KEY is present. +GPU_SERVER_URL= CF_LICENSE_KEY= CF_APP_NAME=pagepiper diff --git a/.env.example b/.env.example index 565cfc4..c975313 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,12 @@ PAGEPIPER_DATA_DIR=data PAGEPIPER_CHAT_MODEL=mistral:7b PAGEPIPER_EMBED_MODEL=nomic-embed-text +# Self-hosted GPU rig URL — set this instead of PAGEPIPER_OLLAMA_URL if you're +# running a cf-orch coordinator (e.g. a home GPU rack). The coordinator resolves +# the actual service URL at allocation time. (CF_ORCH_URL is still honoured as +# a legacy alias if you already have it set.) +# GPU_SERVER_URL=http://localhost:7700 + # Forgejo API token — enables the in-app feedback button (files Forgejo issues) # Create a token at https://git.opensourcesolarpunk.com/user/settings/applications # FORGEJO_API_TOKEN= diff --git a/app/config.py b/app/config.py index 88c0a71..a689541 100644 --- a/app/config.py +++ b/app/config.py @@ -14,6 +14,21 @@ VEC_DIMENSIONS = int(os.environ.get("PAGEPIPER_EMBED_DIMS", "1024")) LOCAL_USER_ID = "__local__" +CF_LICENSE_KEY = os.environ.get("CF_LICENSE_KEY") + +# Priority: GPU_SERVER_URL env var -> CF_ORCH_URL env var (backward compat) +# -> https://orch.circuitforge.tech when CF_LICENSE_KEY is present (Paid+) +# Resolved value is written back to os.environ["CF_ORCH_URL"] so every existing +# caller that reads os.environ.get("CF_ORCH_URL") directly (get_llm_config below, +# app/api/chat.py) sees the right URL without any further changes. +GPU_SERVER_URL = ( + os.environ.get("GPU_SERVER_URL") + or os.environ.get("CF_ORCH_URL") + or ("https://orch.circuitforge.tech" if CF_LICENSE_KEY else None) +) +if GPU_SERVER_URL: + os.environ["CF_ORCH_URL"] = GPU_SERVER_URL + def user_data_dir(user_id: str) -> Path: """Return (and create) the per-user data directory under DATA_DIR/users/.""" diff --git a/compose.cloud.yml b/compose.cloud.yml index 2708b71..93a4700 100644 --- a/compose.cloud.yml +++ b/compose.cloud.yml @@ -17,8 +17,10 @@ services: PAGEPIPER_BOOKS_DIR: /devl/pagepiper-cloud-data/books # PAGEPIPER_OLLAMA_URL — set in .env (BYOK gate for hybrid search + RAG) # HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env for license validation - # cf-orch: route LLM inference through coordinator for managed GPU access - CF_ORCH_URL: http://host.docker.internal:7700 + # GPU server: route LLM inference through the cf-orch coordinator for + # managed GPU access (app/config.py normalizes this into CF_ORCH_URL + # internally, so no other service code needs to change) + GPU_SERVER_URL: http://host.docker.internal:7700 CF_APP_NAME: pagepiper # CF_LICENSE_KEY is the auth token CFOrchClient sends to the coordinator CF_LICENSE_KEY: ${COORDINATOR_PAGEPIPER_KEY:-} diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index dab24ad..45e4bac 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -19,12 +19,13 @@ Copy `.env.example` to `.env` and configure as needed. | `PAGEPIPER_EMBED_DIMS` | `1024` | Embedding dimensions (must match the model) | | `PAGEPIPER_CHAT_MODEL` | `mistral:7b` | Ollama chat/completion model | -## cf-orch (managed deployments) +## GPU server / cf-orch (managed deployments) | Variable | Default | Description | |----------|---------|-------------| -| `CF_ORCH_URL` | _(unset)_ | cf-orch coordinator URL for GPU allocation | -| `CF_LICENSE_KEY` | _(unset)_ | License key for cf-orch authentication | +| `GPU_SERVER_URL` | _(unset)_ | Self-hosted GPU rig / cf-orch coordinator URL for GPU allocation. Preferred over `CF_ORCH_URL`. | +| `CF_ORCH_URL` | _(unset)_ | Legacy alias for `GPU_SERVER_URL` — still honoured if `GPU_SERVER_URL` is unset. | +| `CF_LICENSE_KEY` | _(unset)_ | License key for cf-orch authentication. When set and neither `GPU_SERVER_URL` nor `CF_ORCH_URL` is configured, defaults `GPU_SERVER_URL` to `https://orch.circuitforge.tech` (Paid+ tiers). | | `CF_APP_NAME` | `pagepiper` | Application identifier sent to cf-orch | ## License (cloud tier) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..1593817 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,94 @@ +# tests/test_config.py +"""Unit tests for the GPU_SERVER_URL / CF_ORCH_URL resolution in app/config.py. + +app/config.py resolves GPU_SERVER_URL at *module import time* (module-level +code, not a function), so each test reloads the module after adjusting env +vars via monkeypatch, then reloads it again afterward with the real +environment restored so later tests see normal module state. +""" +from __future__ import annotations + +import importlib +import os + +import pytest + +import app.config as config + + +@pytest.fixture(autouse=True) +def _restore_config_module(): + yield + # config.py's write-back (`os.environ["CF_ORCH_URL"] = GPU_SERVER_URL`) is a + # module-level side effect monkeypatch never sees, so it can't auto-revert + # it — without this explicit cleanup, a value set here leaks into every + # other test in the session (e.g. shelve tests' get_llm_config() checks). + os.environ.pop("CF_ORCH_URL", None) + os.environ.pop("GPU_SERVER_URL", None) + os.environ.pop("CF_LICENSE_KEY", None) + importlib.reload(config) + + +def _clear_orch_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GPU_SERVER_URL", raising=False) + monkeypatch.delenv("CF_ORCH_URL", raising=False) + monkeypatch.delenv("CF_LICENSE_KEY", raising=False) + + +def test_gpu_server_url_prefers_explicit_env_var(monkeypatch): + _clear_orch_env(monkeypatch) + monkeypatch.setenv("GPU_SERVER_URL", "http://my-rig:7700") + monkeypatch.setenv("CF_ORCH_URL", "http://should-be-ignored:7700") + + importlib.reload(config) + + assert config.GPU_SERVER_URL == "http://my-rig:7700" + + +def test_gpu_server_url_falls_back_to_cf_orch_url_legacy_alias(monkeypatch): + _clear_orch_env(monkeypatch) + monkeypatch.setenv("CF_ORCH_URL", "http://legacy-coordinator:7700") + + importlib.reload(config) + + assert config.GPU_SERVER_URL == "http://legacy-coordinator:7700" + + +def test_gpu_server_url_defaults_when_license_key_present(monkeypatch): + _clear_orch_env(monkeypatch) + monkeypatch.setenv("CF_LICENSE_KEY", "test-key-123") + + importlib.reload(config) + + assert config.GPU_SERVER_URL == "https://orch.circuitforge.tech" + + +def test_gpu_server_url_none_when_nothing_set(monkeypatch): + _clear_orch_env(monkeypatch) + + importlib.reload(config) + + assert config.GPU_SERVER_URL is None + + +def test_gpu_server_url_writes_back_to_cf_orch_url_for_legacy_callers(monkeypatch): + """get_llm_config() and app/api/chat.py read os.environ["CF_ORCH_URL"] directly — + the write-back must happen so they keep working without code changes.""" + import os + + _clear_orch_env(monkeypatch) + monkeypatch.setenv("GPU_SERVER_URL", "http://my-rig:7700") + + importlib.reload(config) + + assert os.environ.get("CF_ORCH_URL") == "http://my-rig:7700" + + +def test_gpu_server_url_unset_does_not_write_back(monkeypatch): + import os + + _clear_orch_env(monkeypatch) + + importlib.reload(config) + + assert "CF_ORCH_URL" not in os.environ