{% extends "base.html" %} {% load static %} {% block title %}LLM Settings{% endblock %} {% block content %}
{# The page's job, stated first: this page observes and assigns — it does not swap models. #}

Observe and assign LLM routing and rotate the ERP's caller token — swapping which model a lane serves happens in the gateway's admin UI, not here.

{% if snap.key_undecryptable %} {% endif %} {% if snap.base_url_rejected %} {% endif %} {% if not is_gateway %}
DIRECT mode. ERP chat and purpose calls go straight to providers with the environment's keys. Direct mode restores ERP chat and research only — other services that call the gateway recover through their own break-glass flags (an ops action, not this page).
{% endif %} {% if not_configured %}

Not configured yet

Two ways to get a first completion:

  1. Direct keys — set ANTHROPIC_API_KEY (chat) and/or OPENAI_API_KEY (+ OPENAI_BASE_URL for any OpenAI-compatible provider) in the environment, restart, and run manage.py llmhub_check.
  2. Bring your own gateway — choose Gateway below, enter its base URL and your caller token, then assign a lane to each purpose.
{% endif %} {# ── Status header: is this thing working? ───────────────────────────── #}
Mode
{% if is_gateway %}GATEWAY{% else %}DIRECT{% endif %}
from {{ snap.mode_source }}
Last test
{% if latest_test %}
{{ latest_test.purpose }}: {% if latest_test.status == 'ok' %}OK{% else %}{{ latest_test.category }}{% endif %}
{{ latest_test.at|slice:":10" }} {{ latest_test.at|slice:"11:16" }} UTC
{% else %}
Never run
{% endif %}
Lane health
{% for p in purposes %} {{ p.spec.label }} {% endfor %}
{# ── Connection ─────────────────────────────────────────────────── #}

Connection

{% csrf_token %} {% if connection_form.non_field_errors %}
{{ connection_form.non_field_errors|join:" " }}
{% endif %}
Mode
{% for radio in connection_form.mode %} {% endfor %}
{{ connection_form.gateway_base_url }}
{{ connection_form.gateway_base_url.help_text }} {% if allowed_base_urls %}Allowed: {{ allowed_base_urls|join:", " }}.{% endif %}
{% for err in connection_form.gateway_base_url.errors %}
{{ err }}
{% endfor %}
{% if key_fingerprint %} {{ key_fingerprint }} from {{ key_source }} {% if key_source == 'env' %} ? {% endif %} {% else %} No key resolves {% endif %}
{{ connection_form.new_api_key }}
{{ connection_form.new_api_key.help_text }}
{% for err in connection_form.new_api_key.errors %}
{{ err }}
{% endfor %} {% if has_db_key %} {% endif %}
{# ── Purpose → lane ─────────────────────────────────────────────── #}

Purpose → {% if is_gateway %}lane{% else %}model{% endif %} assignment

{% if is_gateway %} Lanes are named pipes; what flows through them is set in the gateway's admin UI — see the gateway topology doc. "Last observed" is the concrete model a real call reported. {% else %} Direct mode: each purpose names a concrete provider model id. {% endif %}
{% if models_list and not models_list.ok %}
{% if models_403 %}{{ runbook_403 }}{% else %} Gateway unreachable ({{ models_list.error_category }}: {{ models_list.error_message }}) — showing last-known lane assignments{% if models_list.as_of %}; lane list as of {{ models_list.as_of|date:"M j, H:i" }}{% else %}; dropdowns disabled{% endif %}. {% endif %}
{% endif %}
{% csrf_token %}
{% for p in purposes %} {% endfor %}
Purpose{% if is_gateway %}Lane{% else %}Model{% endif %}Last observedTest
{{ p.spec.label }}
{{ p.spec.description }}
{% if is_gateway and vocabulary %} {% elif is_gateway %} {{ p.route.value|default:"—" }}
lane list unavailable — dropdown disabled
{% else %} {% endif %}
resolves to {{ p.route.value|default:"(nothing)" }} from {{ p.route.source }} {% if p.missing_on_gateway %}(missing on gateway){% endif %}
{% if p.observed %}{{ p.observed.model }}
{{ p.observed.at|date:"M j, H:i" }}
{% else %}not observed yet{% endif %}
{% if p.test %} {% if p.test.status == 'ok' %}OK {{ p.test.elapsed_ms }}ms{% elif p.test.status == 'skipped' %}{{ p.test.message }}{% else %}{{ p.test.category }}{% endif %}{% endif %}
{% if not is_gateway or vocabulary %} {% endif %}
{% if external_purposes %}
Owned by other services — changed in their code/env, shown here for the whole map (read-only).
{% for x in external_purposes %} {% endfor %}
PurposeLaneOwnerDoor
{{ x.label }}{{ x.route }}{{ x.owner }}{{ x.door }}
{% endif %}
{# ── Chat models ─────────────────────────────────────────────────── #}

Chat models

{% if is_gateway %}The chat drawer's picker: one row per lane. {% else %}Direct mode uses the code registry; these rows apply in gateway mode.{% endif %}
{% if chat_rows %}
{% for m in chat_rows %}
{{ m.label }} {% if m.is_default %}default{% endif %} {% if not m.enabled %}disabled{% endif %}
{{ m.route }}{% if m.missing_on_gateway %} (missing on gateway){% endif %}
{% if m.observed %}
last observed {{ m.observed.model }}
{% endif %}
{% csrf_token %}
{% csrf_token %}
{% if not m.is_default %}
{% csrf_token %}
{% endif %}
{% csrf_token %}
{% if not m.is_default %}
{% csrf_token %}
{% endif %}
Edit
{% csrf_token %} {% for fname, errs in m.edit_errors.items %}
{{ fname }}: {{ errs|join:" " }}
{% endfor %}
{% endfor %}
{% else %}
No models configured — the chat drawer uses the {{ purposes.0.spec.label }} lane ({{ purposes.0.route.value|default:"unassigned" }}). Add a row to offer a choice.
{% endif %}
{# ── Audit ─────────────────────────────────────────────────────────── #}

Audit log

latest {{ audit_rows|length }} of {{ audit_total }}
{% if audit_rows %}
{% for a in audit_rows %}
{{ a.get_action_display }} {{ a.created_at|date:"M j, H:i" }}
{{ a.field }} {% if a.old_masked or a.new_masked %}{{ a.old_masked }} → {{ a.new_masked }}{% endif %}
{{ a.actor.get_username|default:"system / shell" }}{% if a.detail %} · {{ a.detail }}{% endif %}
{% endfor %}
{% else %}
No changes recorded yet.
{% endif %}
{# Mode change confirm — a deliberately heavy control. #}
{% endblock %} {% block javascripts %} {% endblock %}