{% extends "admin/_layout.html" %} {% from "_macros.html" import icon, model_avatar %} {% set section = "models" %} {% block title %}{{ model.label }} - {{ t("Models") }} - {{ brand.name }}{% endblock %} {% block heading %}{{ model.label }}{% endblock %} {% block admin_content %} {% if saved %}
{{ icon("check", "icon--sm") }} {{ saved }}
{% endif %}
{{ model_avatar(model, cls="model-detail__avatar") }}
{{ model.label }}
{{ model.model_id }}
{{ t("via %(connection)s", connection=model.connection.name) }}
{% if model.model_id == default_model %} {{ icon("star", "icon--sm") }} {{ t("default model") }} {% else %}
{% endif %}
{% if model.image_path %}
{% endif %}

{{ t("PNG, JPEG, WEBP or GIF, under 2 MB. Without one, the model gets a generated badge whose colour is derived from its id.") }}

{# Empty, hidden, and outside every other form: the Detect button further down is associated with it by `form="detect-efforts"`. It carries no fields on purpose β€” detection asks the endpoint and needs nothing from this page. #}

{{ t("Presentation") }}

{{ t("Shown instead of the raw id. Empty uses the id.") }}

{{ t("Place in the picker, 1–%(total)s. Typing a number is the workable way to move a model a long distance.", total=total) }}

{% if detected_caps.context_length %}{{ t("The endpoint says %(n)s tokens (%(source)s). A number typed here wins over it; empty follows the endpoint.", n=detected_caps.context_length, source=detected_caps.source or "/models") }}{% endif %} {{ t("How many tokens this model can hold, filled in from the endpoint where it says. Leave it empty if you do not know: the context percentage and automatic compaction both stay off rather than working from a guess.") }}

{# What the model is for, as `/v1/models` says it -- and which of `/v1/chat/completions` and `/v1/embeddings` takes it. #}

{{ t("Chat models answer through the API's chat completions; embedding models only through its embeddings. A model saved before this existed answers both until it is set.") }}

{{ t("Reasoning efforts this model accepts") }}
{% for value in efforts %} {% endfor %}
{% if detected %}
{{ icon('sparkle' if detected == 'success' else 'warning', 'alert__icon') }} {{ detected_message }}
{% endif %} {# Reading the answer rather than asking somebody to know it. llama-server publishes the loaded model's Jinja chat template on `/props`, and that template is the thing that rejects an effort it does not recognise -- so the accepted set is written down in the one authoritative place. Endpoints without that route (OpenAI, vLLM) say so rather than pretending the model accepts nothing. Its own form, because this page's main form is a PUT of everything and a detect must not carry half-edited fields with it β€” and that form is declared before the main one rather than here, with this button reaching it by id. 🚨 It was written inline here, nested inside the main form, which HTML does not allow. Nothing complains: the parser *drops* the inner `form` start tag and then lets the matching end tag close the outer one β€” so from this point down the page was in no form at all. "Save changes" submitted nothing; the description, the system prompt, every capability and the whole availability card could not be saved. And this button submitted the main form's surviving half to the *save* route, where every field it did not carry took its default: description cleared, system prompt cleared, and the model disabled with all of its tools off. Found by asking a browser which form each control belonged to, which is the only thing that finds it β€” the markup reads correctly, and a test posting to the route bypasses the parser entirely. `tests/test_form_structure.py` is the guard. #}

{{ tm("The vocabulary is not the same for every model, and sending one a model does not know is not ignored β€” it is rendered into the model's chat template, which raises and fails the whole reply. gpt-oss takes low/medium/high; Bonsai takes low/medium/xhigh and refuses high; OpenAI has added minimal, xhigh and max at various points.") }}
{{ t("Tick none and the common three are offered, which is right for almost everything. If an endpoint ever refuses one anyway, that reply is retried without it and this list corrects itself β€” so this is worth setting by hand only to save that one round trip.") }}

{{ tm("A seed, not a per-request setting: it is copied onto a chat when the chat is created and when somebody switches to this model, and from then on the chat's own value is what is sent. Changing it here therefore does nothing to chats that already exist. The composer's picker shows whichever level is actually in force, so what somebody sees there is what goes out. Anyone can change it per chat with /effort, and the control only appears on a model marked Reasoning above.") }}
{{ tm("Sent two ways at once, because there is no one field that works: OpenAI and vLLM read reasoning_effort, while llama.cpp drops it silently and reads only chat_template_kwargs β€” which is the route by which it reaches gpt-oss. Both go out, and only on a chat that has an effort set, so an endpoint strict about unknown parameters is untouched until somebody chooses one.") }}

{{ t("Shown in the chat settings panel and your users' settings β€” and, if any model here may ask another one a question, read by those models too.") }}

{{ t("Never shown to a person. It goes into the list of the other models that a model sees when it is allowed to ask one of them a question, so write what would help it choose: size, what this one is good and bad at, a score you trust. Leave it empty and the description above carries that on its own.") }}

{{ t("System prompt") }}

{{ t("Used by chats on this model that have no prompt of their own. A chat's own prompt overrides this; this overrides the instance prompt.") }}

{% if instance_prompt %} {{ t("Leave empty to fall back to the instance prompt shown above.") }} {% else %} {{ t("Leave empty to send no system prompt.") }} {% endif %}

{# Detected where the endpoint says (services/llm/capabilities.py); a choice made here always wins, and "As detected" follows the endpoint. Every field emits its three slots, so the row lines up whatever the hints say. #}

{{ t("Capabilities") }}

{{ tm("What this model can do. reasoning shows the thinking block, vision lets images be sent, and tools is whether a tool list may be sent at all β€” turn it on for a model that does not support tool calling and every one of its replies fails. embeddings is the odd one out: it says this is not a chat model at all, but one that turns text into vectors, and it is what Extraction picks from.") }} {{ t("Where the endpoint says (llama.cpp, Ollama, LM Studio, OpenRouter and others do), it is filled in on every refresh; what you choose here always wins.") }}

{% for name in capabilities %}

{%- if name in detected_caps -%} {{ t("Detected: yes") if detected_caps[name] else t("Detected: no") }} {%- else -%} {{ t("Not detected") }} {%- endif -%}

{% endfor %}

{{ t("Built-in tools") }} {% if not (model.capabilities_json or {}).get("tools") %} {{ t("needs tools") }} {% endif %}

{{ tm("What this model is given, as opposed to what it is capable of. None of it applies unless tools is ticked above. Each one is also subject to the instance being configured for it and to the reader's own permissions β€” this only decides whether it is offered to this model.") }}

{# A model configured before these existed has no tool_* keys. Ticked by default when `tools` is on, matching what the tool registry does, so the form shows what will actually happen rather than a row of empty boxes that would take web search away on the next save. #} {% for key, label in tool_capabilities %} {% endfor %}

{{ t("Availability") }}

{{ t("Tick this for a model with a single slot. It then cannot be its own helper, because the helper would take that slot and push out the conversation the server has cached, so the waiting reply would have to read it all again. Other models can still be its helpers.") }}

{{ t("Uncheck to restrict this model to chosen groups. Administrators always have access.") }}

{{ t("Groups with access") }} {% if groups %}
{% for group in groups %} {% endfor %}

{{ t("Ignored while the model is available to everyone.") }}

{% else %}

{{ tm("No groups yet β€” create one to restrict access.") }}

{% endif %}
{# The price, in money in the connection's currency -- a provider's price list typed in as it is. Empty everywhere is free. A reply's cost is worked out when it is written and kept with it (services/pricing.py). #}

{{ t("Price") }}

{{ t("What a reply on this model costs, in %(currency)s. Per million tokens, except per reply. Each reply's cost is worked out when it is written and kept with it, so a new price does not change old replies. Leave everything empty to make it free β€” a local model, say.", currency=currency_sign) }}

{% for key, label, hint in price_fields %}

{{ hint }}

{% endfor %}
{#- Opt-in fallback (services/fallback.py). Put in order by dragging a row's handle, or by focusing it and pressing the arrow keys (js/dnd.js); added and removed with the buttons. The list has no URL of its own: the order is the order the hidden inputs post in, with the rest of this form. `fallback_submitted` posts even when the list is empty, so emptying it is a change the server sees. -#} {%- macro fallback_row(id, label) -%}
  • {{ label }}
  • {%- endmacro %}

    {{ t("Fallback models") }}

    {{ t("Tried in order when this model's connection cannot be reached, times out or answers with a server error, before anything of the reply was written β€” never on Stop or on a refused request. A reply from a fallback says which model answered, and keeps its cost. Only models in the same data group are tried. None listed: a failed reply fails, as it always has.") }}

      {% for item in fallback_items %}{{ fallback_row(item.id, item.label) }}{% endfor %}
    {#- What "Add" copies: the same row, `__ID__` and `__NAME__` filled in by the script from the chosen option (as text, never as markup). -#} {% set listed = fallback_items | map(attribute="id") | list %}
    {{ t("Back to all models") }}
    {% endblock %}