diff --git a/Dockerfile.chroma-openshift b/Dockerfile.chroma-openshift new file mode 100644 index 0000000000..3a99764ffc --- /dev/null +++ b/Dockerfile.chroma-openshift @@ -0,0 +1,19 @@ +# Minimal ChromaDB server image that runs under OpenShift restricted SCC. + +FROM python:3.14-slim + +ENV HOME=/tmp/chromahome \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + IS_PERSISTENT=TRUE \ + PERSIST_DIRECTORY=/chroma/data + +RUN pip install --no-cache-dir chromadb \ + && mkdir -p /chroma/data /tmp/chromahome \ + && chgrp -R 0 /chroma /tmp/chromahome \ + && chmod -R g+rwX /chroma /tmp/chromahome + +WORKDIR /tmp/chromahome +EXPOSE 8000 + +CMD ["chroma", "run", "--host", "0.0.0.0", "--port", "8000", "--path", "/chroma/data"] diff --git a/Dockerfile.openshift b/Dockerfile.openshift new file mode 100644 index 0000000000..dc48f96dc2 --- /dev/null +++ b/Dockerfile.openshift @@ -0,0 +1,58 @@ +# OpenShift restricted-SCC build of Odysseus. +# +# This keeps the upstream application dependencies, but removes the Docker +# runtime assumptions that need root at container start: useradd/groupadd, gosu, +# docker socket plumbing, and recursive chown. + +FROM python:3.14-slim AS realesrgan-wheels +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh +RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels + +FROM python:3.14-slim + +ENV HOME=/app \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + curl \ + git \ + nodejs \ + npm \ + tmux \ + openssh-client \ + libgl1 \ + libglib2.0-0t64 \ + libxcb1 \ + libmagic1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +ARG INSTALL_OPTIONAL=false +COPY requirements.txt requirements-optional.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + && if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi \ + && pip install --no-cache-dir python-magic==0.4.27 + +COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/ +RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \ + && rm -rf /tmp/odysseus-wheels + +COPY . . +COPY docker/entrypoint.openshift.sh /usr/local/bin/entrypoint.sh + +RUN mkdir -p data logs services/cache/search .ssh .cache/huggingface .local \ + && chmod +x /usr/local/bin/entrypoint.sh \ + && chgrp -R 0 /app /usr/local/bin/entrypoint.sh \ + && chmod -R g+rwX /app \ + && chmod g+rx /usr/local/bin/entrypoint.sh + +EXPOSE 7000 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7000"] diff --git a/README.md b/README.md index 063efed3c7..670632c1ae 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ docker compose up -d --build Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`. Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md). +OpenShift deployment resources are available under [`deploy/openshift`](deploy/openshift/README.md). ## Features diff --git a/app.py b/app.py index 38c848e0bd..529a14988b 100644 --- a/app.py +++ b/app.py @@ -264,6 +264,7 @@ async def dispatch(self, request, call_next): "/api/auth/settings", "/api/auth/integrations/presets", "/api/health", + "/api/ready", "/api/version", "/login", } diff --git a/core/database.py b/core/database.py index a9ad90b8b7..1b3d5d0eed 100644 --- a/core/database.py +++ b/core/database.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Optional from urllib.parse import unquote, urlparse -from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text +from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, Float, ForeignKey, JSON, Index, func, text from sqlalchemy.engine import Engine, make_url from sqlalchemy.types import TypeDecorator from sqlalchemy.ext.declarative import declarative_base, declared_attr @@ -457,6 +457,11 @@ class ModelEndpoint(TimestampMixin, Base): # can be toggled per-endpoint in the UI. NULL = unknown, falls # back to the model-name keyword heuristic in agent_loop.py. supports_tools = Column(Boolean, nullable=True, default=None) + # Optional endpoint-level numeric reasoning control. Some model templates + # accept a continuous value rather than OpenAI's named effort levels. + # ``float`` means the value is passed through chat_template_kwargs. + reasoning_effort_type = Column(String, nullable=True, default=None) + reasoning_effort = Column(Float, nullable=True, default=None) # Per-user ownership. NULL = legacy/shared (visible to every user) — this # is the historical default. When non-null, the model picker only shows # the endpoint to that user (admins always see everything). @@ -1072,6 +1077,27 @@ def _migrate_add_supports_tools_column(): pass +def _migrate_add_reasoning_effort_columns(): + """Add endpoint-scoped numeric reasoning controls to existing databases.""" + db_path = DATABASE_URL.replace("sqlite:///", "") + if not os.path.exists(db_path): + return + conn = None + try: + conn = sqlite3.connect(db_path) + columns = {row[1] for row in conn.execute("PRAGMA table_info(model_endpoints)").fetchall()} + if columns and "reasoning_effort_type" not in columns: + conn.execute("ALTER TABLE model_endpoints ADD COLUMN reasoning_effort_type TEXT") + if columns and "reasoning_effort" not in columns: + conn.execute("ALTER TABLE model_endpoints ADD COLUMN reasoning_effort FLOAT") + conn.commit() + except Exception as e: + logging.getLogger(__name__).warning(f"reasoning effort migration failed: {e}") + finally: + if conn is not None: + conn.close() + + def _migrate_add_cached_models_column(): """Add cached_models column to model_endpoints if it doesn't exist.""" import sqlite3 @@ -1935,6 +1961,7 @@ def init_db(): _migrate_add_model_endpoint_owner_column() _migrate_add_provider_auth_id_column() _migrate_add_supports_tools_column() + _migrate_add_reasoning_effort_columns() _migrate_add_task_run_model_column() _migrate_add_owner_column() _migrate_add_document_archived_column() diff --git a/deploy/openshift/README.md b/deploy/openshift/README.md new file mode 100644 index 0000000000..78a25632a1 --- /dev/null +++ b/deploy/openshift/README.md @@ -0,0 +1,186 @@ +# Odysseus on OpenShift + +This directory contains an OpenShift-native deployment for Odysseus using +restricted-SCC-compatible images, OpenShift binary builds, ChromaDB, SearXNG, +persistent storage, and an edge-terminated Route. + +The default shape is: + +- `base/`: reusable resources for a namespace named `odysseus` +- `overlays/example/`: generic overlay showing namespace, route host, storage + class, and private CA customization + +## What Gets Deployed + +- Odysseus web app on port `7000` +- ChromaDB on port `8000` +- SearXNG on port `8080` +- PVCs for app data, app logs, and ChromaDB data +- ImageStreams and binary BuildConfigs for `Dockerfile.openshift` and + `Dockerfile.chroma-openshift` +- An OpenShift Route with edge TLS termination + +The Odysseus container runs without root at start time. Runtime writes go under +`/app/data`, `/app/logs`, `/app/.cache`, and `/app/.local`. + +## Important CA Rule + +Use `LLM_CA_BUNDLE` only when Odysseus needs to trust a private CA for LLM +provider endpoints. + +Do not set `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` in this deployment. Those +global variables also affect web fetch/search traffic and can break public +internet tooling when pointed at a private bundle. The application scopes +`LLM_CA_BUNDLE` to the LLM provider HTTP path. + +The example overlay projects the `router-ca.crt` key from `router-ca` to a +generic in-container path and sets: + +```text +LLM_CA_BUNDLE=/etc/odysseus/ca/router-ca.crt +``` + +Remove that patch or replace the ConfigMap name/path for clusters that do not +need private LLM route trust. + +## Prerequisites + +Install `oc` and log in to the target OpenShift cluster. + +Create the target project: + +```bash +oc new-project odysseus +``` + +Create the first-admin password secret: + +```bash +oc -n odysseus create secret generic odysseus-admin \ + --from-literal=ODYSSEUS_ADMIN_PASSWORD='change-me' +``` + +If your LLM endpoints use a private route CA, create a ConfigMap containing that +CA bundle and add an overlay patch that mounts it and sets `LLM_CA_BUNDLE`. + +## Deploy The Generic Base + +From the repository root: + +```bash +oc apply -k deploy/openshift/base +oc -n odysseus start-build odysseus-chromadb --from-dir=. --follow +oc -n odysseus start-build odysseus --from-dir=. --follow +oc -n odysseus rollout status deploy/odysseus-chromadb +oc -n odysseus rollout status deploy/searxng +oc -n odysseus rollout status deploy/odysseus +``` + +Get the generated route: + +```bash +oc -n odysseus get route odysseus -o jsonpath='https://{.spec.host}{"\n"}' +``` + +## Deploy The Example Overlay + +The example overlay expects these values to be changed before production use: + +- namespace `odysseus-example` +- storage class `replace-me-rwo-storage-class` +- ConfigMap `router-ca` with key `router-ca.crt`, projected as + `/etc/odysseus/ca/router-ca.crt` +- route host `odysseus.apps.example.com` + +Create or refresh the admin secret: + +```bash +oc apply -f deploy/openshift/overlays/example/namespace.yaml +oc -n odysseus-example create secret generic odysseus-admin \ + --from-literal=ODYSSEUS_ADMIN_PASSWORD='change-me' \ + --dry-run=client -o yaml | oc apply -f - +``` + +Apply, build, and wait: + +```bash +oc apply -k deploy/openshift/overlays/example +oc -n odysseus-example start-build odysseus-chromadb --from-dir=. --follow +oc -n odysseus-example start-build odysseus --from-dir=. --follow +oc -n odysseus-example rollout status deploy/odysseus-chromadb +oc -n odysseus-example rollout status deploy/searxng +oc -n odysseus-example rollout status deploy/odysseus +``` + +## Validate + +Set the namespace and route URL: + +```bash +ODYSSEUS_NAMESPACE=odysseus +ODYSSEUS_URL="$(oc -n odysseus get route odysseus -o jsonpath='https://{.spec.host}')" +``` + +For the example overlay: + +```bash +ODYSSEUS_NAMESPACE=odysseus-example +ODYSSEUS_URL=https://odysseus.apps.example.com +``` + +Run basic checks: + +```bash +curl -ksS "${ODYSSEUS_URL}/api/health" +curl -ksS "${ODYSSEUS_URL}/api/ready" +oc -n "${ODYSSEUS_NAMESPACE}" exec deploy/searxng -- \ + wget -qO- 'http://127.0.0.1:8080/search?q=openshift&format=json' +``` + +Retrieve the admin password when needed: + +```bash +oc -n "${ODYSSEUS_NAMESPACE}" extract secret/odysseus-admin \ + --keys=ODYSSEUS_ADMIN_PASSWORD --to=- +``` + +## Register Model Endpoints + +Register OpenAI-compatible LLM endpoints after the model route and model id are +verified. The base URL should be the provider `/v1` base, not the route root. + +Example form fields for an authenticated browser session or cookie jar: + +```bash +curl -ksS -b /tmp/odysseus.cookies \ + -F 'name=Example vLLM endpoint' \ + -F 'base_url=https://example-model-route.example.com/v1' \ + -F 'api_key=dummy' \ + -F 'endpoint_kind=proxy' \ + -F 'require_models=true' \ + -F 'model_refresh_mode=manual' \ + -F 'model_refresh_timeout=60' \ + -F 'supports_tools=false' \ + -F 'pinned_models=example-model-id' \ + -F 'shared=true' \ + "${ODYSSEUS_URL}/api/model-endpoints" +``` + +Do not copy historical lab model routes into a new deployment without checking +that the backing InferenceService still exists and that `/v1/models` returns the +expected id. + +## Troubleshooting + +- Pod cannot start because `odysseus-admin` is missing: create the secret in the + same namespace before rollout. +- Public web fetch/search fails with certificate errors: remove global + `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` from the Deployment. Keep only + scoped `LLM_CA_BUNDLE` for private LLM endpoints. +- SearXNG search fails: check `SEARXNG_INSTANCE=http://searxng:8080`, then test + the SearXNG service from inside the namespace. +- Readiness fails but health succeeds: inspect `/api/ready`; it checks database + connectivity, data directory writability, and local-first storage mode. +- Image pull fails: confirm the namespace in the Deployment image points at the + same namespace used by the ImageStream. Overlays should patch both Odysseus + image names when changing namespaces. diff --git a/deploy/openshift/base/buildconfigs.yaml b/deploy/openshift/base/buildconfigs.yaml new file mode 100644 index 0000000000..85a14d03a0 --- /dev/null +++ b/deploy/openshift/base/buildconfigs.yaml @@ -0,0 +1,53 @@ +apiVersion: image.openshift.io/v1 +kind: ImageStream +metadata: + name: odysseus + labels: + app.kubernetes.io/name: odysseus + app.kubernetes.io/part-of: odysseus +--- +apiVersion: image.openshift.io/v1 +kind: ImageStream +metadata: + name: odysseus-chromadb + labels: + app.kubernetes.io/name: odysseus-chromadb + app.kubernetes.io/part-of: odysseus +--- +apiVersion: build.openshift.io/v1 +kind: BuildConfig +metadata: + name: odysseus + labels: + app.kubernetes.io/name: odysseus + app.kubernetes.io/part-of: odysseus +spec: + source: + type: Binary + strategy: + type: Docker + dockerStrategy: + dockerfilePath: Dockerfile.openshift + output: + to: + kind: ImageStreamTag + name: odysseus:latest +--- +apiVersion: build.openshift.io/v1 +kind: BuildConfig +metadata: + name: odysseus-chromadb + labels: + app.kubernetes.io/name: odysseus-chromadb + app.kubernetes.io/part-of: odysseus +spec: + source: + type: Binary + strategy: + type: Docker + dockerStrategy: + dockerfilePath: Dockerfile.chroma-openshift + output: + to: + kind: ImageStreamTag + name: odysseus-chromadb:latest diff --git a/deploy/openshift/base/chromadb.yaml b/deploy/openshift/base/chromadb.yaml new file mode 100644 index 0000000000..a4ebaff10f --- /dev/null +++ b/deploy/openshift/base/chromadb.yaml @@ -0,0 +1,74 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: odysseus-chromadb + labels: + app.kubernetes.io/name: odysseus-chromadb + app.kubernetes.io/part-of: odysseus +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: odysseus-chromadb + template: + metadata: + labels: + app.kubernetes.io/name: odysseus-chromadb + app.kubernetes.io/part-of: odysseus + spec: + serviceAccountName: odysseus + containers: + - name: chromadb + image: image-registry.openshift-image-registry.svc:5000/odysseus/odysseus-chromadb:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + name: http + volumeMounts: + - name: chromadb-data + mountPath: /chroma + readinessProbe: + tcpSocket: + port: 8000 + periodSeconds: 10 + timeoutSeconds: 3 + livenessProbe: + tcpSocket: + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 3 + resources: + requests: + cpu: 500m + memory: 2Gi + limits: + cpu: "2" + memory: 6Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + volumes: + - name: chromadb-data + persistentVolumeClaim: + claimName: odysseus-chromadb-data +--- +apiVersion: v1 +kind: Service +metadata: + name: odysseus-chromadb + labels: + app.kubernetes.io/name: odysseus-chromadb + app.kubernetes.io/part-of: odysseus +spec: + selector: + app.kubernetes.io/name: odysseus-chromadb + ports: + - name: http + port: 8000 + targetPort: 8000 diff --git a/deploy/openshift/base/kustomization.yaml b/deploy/openshift/base/kustomization.yaml new file mode 100644 index 0000000000..0376e8f992 --- /dev/null +++ b/deploy/openshift/base/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: odysseus +resources: + - buildconfigs.yaml + - serviceaccount.yaml + - pvcs.yaml + - chromadb.yaml + - searxng.yaml + - odysseus.yaml diff --git a/deploy/openshift/base/odysseus.yaml b/deploy/openshift/base/odysseus.yaml new file mode 100644 index 0000000000..358a833246 --- /dev/null +++ b/deploy/openshift/base/odysseus.yaml @@ -0,0 +1,137 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: odysseus + labels: + app.kubernetes.io/name: odysseus + app.kubernetes.io/part-of: odysseus +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: odysseus + template: + metadata: + labels: + app.kubernetes.io/name: odysseus + app.kubernetes.io/part-of: odysseus + spec: + serviceAccountName: odysseus + containers: + - name: odysseus + image: image-registry.openshift-image-registry.svc:5000/odysseus/odysseus:latest + imagePullPolicy: Always + ports: + - containerPort: 7000 + name: http + env: + - name: AUTH_ENABLED + value: "true" + - name: LOCALHOST_BYPASS + value: "false" + - name: SECURE_COOKIES + value: "true" + - name: APP_PORT + value: "7000" + - name: ODYSSEUS_ADMIN_USER + value: admin + - name: ODYSSEUS_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: odysseus-admin + key: ODYSSEUS_ADMIN_PASSWORD + - name: ODYSSEUS_DATA_DIR + value: /app/data + - name: DATABASE_URL + value: sqlite:////app/data/app.db + - name: CHROMADB_HOST + value: odysseus-chromadb + - name: CHROMADB_PORT + value: "8000" + - name: SEARXNG_INSTANCE + value: http://searxng:8080 + - name: FASTEMBED_CACHE_PATH + value: /app/data/fastembed_cache + - name: ODYSSEUS_INPROCESS_POLLERS + value: "0" + - name: ODYSSEUS_INPROCESS_TASKS + value: "0" + volumeMounts: + - name: data + mountPath: /app/data + - name: logs + mountPath: /app/logs + startupProbe: + httpGet: + path: /api/health + port: 7000 + failureThreshold: 60 + periodSeconds: 10 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /api/ready + port: 7000 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /api/health + port: 7000 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + resources: + requests: + cpu: "1" + memory: 4Gi + limits: + cpu: "4" + memory: 12Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + volumes: + - name: data + persistentVolumeClaim: + claimName: odysseus-data + - name: logs + persistentVolumeClaim: + claimName: odysseus-logs +--- +apiVersion: v1 +kind: Service +metadata: + name: odysseus + labels: + app.kubernetes.io/name: odysseus + app.kubernetes.io/part-of: odysseus +spec: + selector: + app.kubernetes.io/name: odysseus + ports: + - name: http + port: 7000 + targetPort: 7000 +--- +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: odysseus + labels: + app.kubernetes.io/name: odysseus + app.kubernetes.io/part-of: odysseus +spec: + to: + kind: Service + name: odysseus + port: + targetPort: http + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect diff --git a/deploy/openshift/base/pvcs.yaml b/deploy/openshift/base/pvcs.yaml new file mode 100644 index 0000000000..69aa8407a5 --- /dev/null +++ b/deploy/openshift/base/pvcs.yaml @@ -0,0 +1,41 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: odysseus-data + labels: + app.kubernetes.io/name: odysseus + app.kubernetes.io/part-of: odysseus +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: odysseus-logs + labels: + app.kubernetes.io/name: odysseus + app.kubernetes.io/part-of: odysseus +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: odysseus-chromadb-data + labels: + app.kubernetes.io/name: odysseus-chromadb + app.kubernetes.io/part-of: odysseus +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi diff --git a/deploy/openshift/base/searxng.yaml b/deploy/openshift/base/searxng.yaml new file mode 100644 index 0000000000..6fb5ad4d93 --- /dev/null +++ b/deploy/openshift/base/searxng.yaml @@ -0,0 +1,141 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: searxng-settings-template + labels: + app.kubernetes.io/name: searxng + app.kubernetes.io/part-of: odysseus +data: + settings.yml: | + use_default_settings: true + + server: + secret_key: "__SEARXNG_SECRET__" + + search: + formats: + - html + - json +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: searxng + labels: + app.kubernetes.io/name: searxng + app.kubernetes.io/part-of: odysseus +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: searxng + template: + metadata: + labels: + app.kubernetes.io/name: searxng + app.kubernetes.io/part-of: odysseus + spec: + enableServiceLinks: false + serviceAccountName: odysseus + initContainers: + - name: render-settings + image: docker.io/searxng/searxng:2026.5.31-7159b8aed + imagePullPolicy: IfNotPresent + command: + - /usr/bin/sh + - -c + - | + set -eu + secret="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')" + sed "s|__SEARXNG_SECRET__|${secret}|g" /template/settings.yml > /generated/settings.yml + chmod 0444 /generated/settings.yml + volumeMounts: + - name: settings-template + mountPath: /template + readOnly: true + - name: settings-generated + mountPath: /generated + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: searxng + image: docker.io/searxng/searxng:2026.5.31-7159b8aed + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + env: + - name: SEARXNG_BASE_URL + value: http://searxng:8080/ + - name: SEARXNG_PORT + value: "8080" + - name: FORCE_OWNERSHIP + value: "false" + - name: GRANIAN_WORKERS + value: "1" + volumeMounts: + - name: settings-generated + mountPath: /etc/searxng + - name: cache + mountPath: /var/cache/searxng + startupProbe: + tcpSocket: + port: 8080 + failureThreshold: 30 + periodSeconds: 5 + timeoutSeconds: 5 + readinessProbe: + tcpSocket: + port: 8080 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 2Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + volumes: + - name: settings-template + configMap: + name: searxng-settings-template + - name: settings-generated + emptyDir: {} + - name: cache + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: searxng + labels: + app.kubernetes.io/name: searxng + app.kubernetes.io/part-of: odysseus +spec: + selector: + app.kubernetes.io/name: searxng + ports: + - name: http + port: 8080 + targetPort: 8080 diff --git a/deploy/openshift/base/serviceaccount.yaml b/deploy/openshift/base/serviceaccount.yaml new file mode 100644 index 0000000000..4e8b938d7c --- /dev/null +++ b/deploy/openshift/base/serviceaccount.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: odysseus + labels: + app.kubernetes.io/name: odysseus + app.kubernetes.io/part-of: odysseus diff --git a/deploy/openshift/overlays/example/kustomization.yaml b/deploy/openshift/overlays/example/kustomization.yaml new file mode 100644 index 0000000000..59e019a309 --- /dev/null +++ b/deploy/openshift/overlays/example/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: odysseus-example +resources: + - namespace.yaml + - ../../base +images: + - name: image-registry.openshift-image-registry.svc:5000/odysseus/odysseus + newName: image-registry.openshift-image-registry.svc:5000/odysseus-example/odysseus + - name: image-registry.openshift-image-registry.svc:5000/odysseus/odysseus-chromadb + newName: image-registry.openshift-image-registry.svc:5000/odysseus-example/odysseus-chromadb +patches: + - path: odysseus-example-env.patch.yaml + - path: route-host.patch.yaml + - path: storageclass.patch.yaml diff --git a/deploy/openshift/overlays/example/namespace.yaml b/deploy/openshift/overlays/example/namespace.yaml new file mode 100644 index 0000000000..8c9d61b58d --- /dev/null +++ b/deploy/openshift/overlays/example/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: odysseus-example + labels: + app.kubernetes.io/part-of: odysseus diff --git a/deploy/openshift/overlays/example/odysseus-example-env.patch.yaml b/deploy/openshift/overlays/example/odysseus-example-env.patch.yaml new file mode 100644 index 0000000000..6d2a72660c --- /dev/null +++ b/deploy/openshift/overlays/example/odysseus-example-env.patch.yaml @@ -0,0 +1,25 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: odysseus +spec: + template: + spec: + containers: + - name: odysseus + env: + - name: LLM_CA_BUNDLE + value: /etc/odysseus/ca/router-ca.crt + - name: ALLOWED_ORIGINS + value: https://odysseus.apps.example.com + volumeMounts: + - name: router-ca + mountPath: /etc/odysseus/ca + readOnly: true + volumes: + - name: router-ca + configMap: + name: router-ca + items: + - key: router-ca.crt + path: router-ca.crt diff --git a/deploy/openshift/overlays/example/route-host.patch.yaml b/deploy/openshift/overlays/example/route-host.patch.yaml new file mode 100644 index 0000000000..d334e5f424 --- /dev/null +++ b/deploy/openshift/overlays/example/route-host.patch.yaml @@ -0,0 +1,6 @@ +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: odysseus +spec: + host: odysseus.apps.example.com diff --git a/deploy/openshift/overlays/example/storageclass.patch.yaml b/deploy/openshift/overlays/example/storageclass.patch.yaml new file mode 100644 index 0000000000..91b4ef32e7 --- /dev/null +++ b/deploy/openshift/overlays/example/storageclass.patch.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: odysseus-data +spec: + storageClassName: replace-me-rwo-storage-class +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: odysseus-logs +spec: + storageClassName: replace-me-rwo-storage-class +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: odysseus-chromadb-data +spec: + storageClassName: replace-me-rwo-storage-class diff --git a/docker/entrypoint.openshift.sh b/docker/entrypoint.openshift.sh new file mode 100644 index 0000000000..5ba7d42945 --- /dev/null +++ b/docker/entrypoint.openshift.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -e + +export HOME="${HOME:-/app}" +export PATH="/app/.local/bin:$PATH" +export VLLM_USE_FLASHINFER_SAMPLER="${VLLM_USE_FLASHINFER_SAMPLER:-0}" + +mkdir -p \ + /app/data \ + /app/logs \ + /app/.ssh \ + /app/.cache/huggingface \ + /app/.local + +# setup.py is idempotent and creates the first admin account from env vars. +python /app/setup.py + +if [ "${AUTH_ENABLED:-true}" != "false" ] && [ ! -s "${ODYSSEUS_DATA_DIR:-/app/data}/auth.json" ]; then + echo "Odysseus auth is enabled, but setup did not create ${ODYSSEUS_DATA_DIR:-/app/data}/auth.json" >&2 + exit 1 +fi + +exec "$@" diff --git a/routes/model_routes.py b/routes/model_routes.py index 4054856f61..8b8df22854 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -51,6 +51,30 @@ "vision_model_fallbacks": "Vision Model Fallbacks", } +_REASONING_EFFORT_TYPE_FLOAT = "float" + + +def _normalize_reasoning_effort_type(value: Any) -> Optional[str]: + """Validate endpoint reasoning metadata without model-name inference.""" + if value is None or str(value).strip().lower() in ("", "none", "default"): + return None + normalized = str(value).strip().lower() + if normalized != _REASONING_EFFORT_TYPE_FLOAT: + raise HTTPException(400, "reasoning_effort_type must be 'float' or null") + return normalized + + +def _normalize_reasoning_effort(value: Any) -> Optional[float]: + if value is None or (isinstance(value, str) and not value.strip()): + return None + try: + normalized = float(value) + except (TypeError, ValueError): + raise HTTPException(400, "reasoning_effort must be a number in [0.0, 0.99]") + if not 0.0 <= normalized <= 0.99: + raise HTTPException(400, "reasoning_effort must be in [0.0, 0.99]") + return normalized + def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list: """Return speech settings that reference a model endpoint.""" @@ -1914,6 +1938,8 @@ def list_model_endpoints(request: Request) -> List[Dict[str, Any]]: "ping_error": (ping or {}).get("error") if ping else None, "model_type": getattr(r, "model_type", None) or "llm", "supports_tools": getattr(r, "supports_tools", None), + "reasoning_effort_type": getattr(r, "reasoning_effort_type", None), + "reasoning_effort": getattr(r, "reasoning_effort", None), "endpoint_kind": kind, "category": _classify_endpoint(base, kind), "model_refresh_mode": _endpoint_refresh_mode(r, kind), @@ -2459,6 +2485,15 @@ async def toggle_model_endpoint(ep_id: str, request: Request): if "supports_tools" in body: v = body["supports_tools"] ep.supports_tools = {True: True, False: False, 'true': True, 'false': False, 1: True, 0: False}.get(v) + if "reasoning_effort_type" in body: + ep.reasoning_effort_type = _normalize_reasoning_effort_type(body.get("reasoning_effort_type")) + if ep.reasoning_effort_type is None: + ep.reasoning_effort = None + if "reasoning_effort" in body: + effort = _normalize_reasoning_effort(body.get("reasoning_effort")) + if effort is not None and getattr(ep, "reasoning_effort_type", None) != _REASONING_EFFORT_TYPE_FLOAT: + raise HTTPException(400, "reasoning_effort_type='float' is required") + ep.reasoning_effort = effort if "is_enabled" in body: v_ie = body['is_enabled'] ep.is_enabled = v_ie.lower() in ('true', '1', 'yes') if isinstance(v_ie, str) else bool(v_ie) @@ -2508,6 +2543,8 @@ async def toggle_model_endpoint(ep_id: str, request: Request): "id": ep.id, "is_enabled": ep.is_enabled, "supports_tools": ep.supports_tools, + "reasoning_effort_type": getattr(ep, "reasoning_effort_type", None), + "reasoning_effort": getattr(ep, "reasoning_effort", None), "name": ep.name, "model_type": ep.model_type, "base_url": ep.base_url, diff --git a/src/llm_core.py b/src/llm_core.py index 4694df7c05..2a73f970d5 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -1628,6 +1628,51 @@ def _model_list_base(url: str) -> str: return base +def _configured_numeric_reasoning_effort(endpoint_url: str, model: str) -> Optional[float]: + """Read an explicit float reasoning capability from the matching endpoint.""" + target = _model_list_base(endpoint_url) + if not target: + return None + try: + from src.database import SessionLocal, ModelEndpoint + db = SessionLocal() + except Exception: + return None + try: + rows = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all() + for endpoint in rows: + if _model_list_base(getattr(endpoint, "base_url", "")) != target: + continue + if getattr(endpoint, "reasoning_effort_type", None) != "float": + return None + value = getattr(endpoint, "reasoning_effort", None) + if value is None: + return None + value = float(value) + return value if 0.0 <= value <= 0.99 else None + except Exception: + return None + finally: + try: + db.close() + except Exception: + pass + return None + + +def _apply_endpoint_reasoning_effort(payload: Dict, endpoint_url: str, model: str) -> None: + """Pass numeric effort through to model templates that support it.""" + effort = _configured_numeric_reasoning_effort(endpoint_url, model) + if effort is None: + return + payload.pop("reasoning_effort", None) + template_kwargs = payload.get("chat_template_kwargs") + if not isinstance(template_kwargs, dict): + template_kwargs = {} + payload["chat_template_kwargs"] = template_kwargs + template_kwargs["reasoning_effort"] = effort + + def _parse_model_cache(raw) -> List[str]: if not raw: return [] @@ -1825,6 +1870,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL _apply_local_generation_stability(payload, target_url, model) if provider == "mistral" and _supports_thinking(model): payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT + _apply_endpoint_reasoning_effort(payload, target_url, model) try: note_model_activity(target_url, model) r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout) @@ -2035,6 +2081,7 @@ async def llm_call_async( payload["think"] = False if provider == "mistral" and _supports_thinking(model): payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT + _apply_endpoint_reasoning_effort(payload, target_url, model) _apply_local_cache_affinity(payload, url, session_id) _apply_local_generation_stability(payload, target_url, model) @@ -2198,6 +2245,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat # (high / medium / low / none); default "high". if provider == "mistral" and _supports_thinking(model): payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT + _apply_endpoint_reasoning_effort(payload, target_url, model) # For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3, # gemma4, etc.), suppress thinking so tool calls aren't swallowed inside # blocks. Ollama /v1 accepts "think": false as a top-level param. diff --git a/static/js/admin.js b/static/js/admin.js index 7ea8458c8b..2444cfcaa0 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -517,6 +517,15 @@ async function loadEndpoints() { const keyLabel = ep.has_key ? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)') : ''; + const numericEffort = ep.reasoning_effort_type === 'float' + ? Math.max(0, Math.min(0.99, Number(ep.reasoning_effort ?? 0.9))) + : null; + const reasoningControl = numericEffort === null ? '' : ` +
+ + + ${numericEffort.toFixed(2)} +
`; return `
@@ -536,6 +545,7 @@ async function loadEndpoints() {
${esc(ep.base_url)}${category === 'local' ? `` : ''}${keyLabel}
+ ${reasoningControl} ${hasModels ? `` : ''} `; }); @@ -574,6 +584,22 @@ async function loadEndpoints() { queryAll('[data-adm-toggle-ep]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); }); }); + queryAll('[data-adm-reasoning-effort]').forEach(input => { + const output = queryAll(`[data-adm-reasoning-output="${input.dataset.admReasoningEffort}"]`)[0]; + input.addEventListener('input', () => { + if (output) output.value = Number(input.value).toFixed(2); + }); + input.addEventListener('change', async () => { + const value = Number(input.value); + const res = await fetch(`/api/model-endpoints/${input.dataset.admReasoningEffort}`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reasoning_effort_type: 'float', reasoning_effort: value }), + }); + if (!res.ok) loadEndpoints(); + }); + }); queryAll('[data-adm-copy-url]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); diff --git a/tests/test_llm_core_numeric_reasoning_effort.py b/tests/test_llm_core_numeric_reasoning_effort.py new file mode 100644 index 0000000000..2b108b0189 --- /dev/null +++ b/tests/test_llm_core_numeric_reasoning_effort.py @@ -0,0 +1,89 @@ +"""Endpoint-scoped continuous reasoning effort request contract.""" + +import asyncio + +import pytest +from fastapi import HTTPException + +from routes import model_routes +from src import llm_core + + +def test_reasoning_effort_route_validation(): + assert model_routes._normalize_reasoning_effort_type("float") == "float" + assert model_routes._normalize_reasoning_effort(0) == 0.0 + assert model_routes._normalize_reasoning_effort("0.99") == 0.99 + + +@pytest.mark.parametrize("value", [-0.01, 1, "not-a-number"]) +def test_reasoning_effort_route_rejects_invalid_values(value): + with pytest.raises(HTTPException) as exc_info: + model_routes._normalize_reasoning_effort(value) + assert exc_info.value.status_code == 400 + + +def test_numeric_effort_uses_chat_template_kwargs(monkeypatch): + payload = {"model": "inkling", "reasoning_effort": "high"} + monkeypatch.setattr(llm_core, "_configured_numeric_reasoning_effort", lambda *_: 0.37) + + llm_core._apply_endpoint_reasoning_effort(payload, "https://model.example/v1", "inkling") + + assert payload["chat_template_kwargs"] == {"reasoning_effort": 0.37} + assert "reasoning_effort" not in payload + + +def test_unconfigured_endpoint_does_not_change_payload(monkeypatch): + payload = {"model": "label-model", "reasoning_effort": "high"} + monkeypatch.setattr(llm_core, "_configured_numeric_reasoning_effort", lambda *_: None) + + llm_core._apply_endpoint_reasoning_effort(payload, "https://model.example/v1", "label-model") + + assert payload == {"model": "label-model", "reasoning_effort": "high"} + + +class _Response: + status_code = 200 + + async def aiter_lines(self): + yield 'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}' + yield "data: [DONE]" + + async def aread(self): + return b"" + + +class _Stream: + async def __aenter__(self): + return _Response() + + async def __aexit__(self, *_): + return False + + +class _Client: + payload = None + + def stream(self, _method, _url, **kwargs): + self.payload = kwargs["json"] + return _Stream() + + +def test_streaming_tool_round_sends_numeric_effort(monkeypatch): + client = _Client() + monkeypatch.setattr(llm_core, "_get_http_client", lambda: client) + monkeypatch.setattr(llm_core, "_configured_numeric_reasoning_effort", lambda *_: 0.99) + monkeypatch.setattr(llm_core, "_is_host_dead", lambda *_: False) + monkeypatch.setattr(llm_core, "note_model_activity", lambda *_: None) + monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *_: None) + + async def run(): + return [chunk async for chunk in llm_core.stream_llm( + "https://model.example/v1", + "inkling", + [{"role": "user", "content": "inspect"}], + tools=[{"type": "function", "function": {"name": "grep", "parameters": {}}}], + )] + + asyncio.run(run()) + assert client.payload["chat_template_kwargs"] == {"reasoning_effort": 0.99} + assert client.payload["tools"][0]["function"]["name"] == "grep" diff --git a/tests/test_openshift_rollout_manifests.py b/tests/test_openshift_rollout_manifests.py new file mode 100644 index 0000000000..1473e544b6 --- /dev/null +++ b/tests/test_openshift_rollout_manifests.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +OPENSHIFT = REPO / "deploy" / "openshift" + + +def _read(relative: str) -> str: + return (OPENSHIFT / relative).read_text(encoding="utf-8") + + +def test_openshift_manifests_keep_llm_ca_scoped(): + """OpenShift deploys must not make private LLM CA trust process-wide.""" + deploy_text = "\n".join(path.read_text(encoding="utf-8") for path in OPENSHIFT.rglob("*.yaml")) + + assert "SSL_CERT_FILE" not in deploy_text + assert "REQUESTS_CA_BUNDLE" not in deploy_text + + example_patch = _read("overlays/example/odysseus-example-env.patch.yaml") + assert "LLM_CA_BUNDLE" in example_patch + assert "value: /etc/odysseus/ca/router-ca.crt" in example_patch + assert "key: router-ca.crt" in example_patch + assert "path: router-ca.crt" in example_patch + + +def test_openshift_base_wires_readiness_and_search(): + odysseus = _read("base/odysseus.yaml") + searxng = _read("base/searxng.yaml") + + assert "path: /api/ready" in odysseus + assert "name: SEARXNG_INSTANCE" in odysseus + assert "value: http://searxng:8080" in odysseus + assert "docker.io/searxng/searxng:2026.5.31-7159b8aed" in searxng + assert "formats:" in searxng + assert "- json" in searxng + + +def test_openshift_example_overlay_uses_placeholders(): + rendered_inputs = "\n".join( + _read(path.relative_to(OPENSHIFT).as_posix()) + for path in (OPENSHIFT / "overlays" / "example").glob("*.yaml") + ) + + assert "odysseus-example" in rendered_inputs + assert "odysseus.apps.example.com" in rendered_inputs + assert "replace-me-rwo-storage-class" in rendered_inputs + assert "router-ca.crt" in rendered_inputs + + +def test_readiness_endpoint_is_auth_exempt(): + app_py = (REPO / "app.py").read_text(encoding="utf-8") + + exact_start = app_py.index("AUTH_EXEMPT_EXACT = {") + exact_end = app_py.index("}", exact_start) + exact_block = app_py[exact_start:exact_end] + assert '"/api/ready"' in exact_block