Navigation
Assistant based on a language model
The LLM assistant: what it does, which models it connects to and what leaves your installation
Explains failed CI jobs from their log and explains pull request changes. The installation administrator chooses the model: a self-hosted server inside the perimeter, or a cloud provider with an OpenAI-compatible interface. Part of the Pro edition.
Settings: Administration → AI assistant (/admin/ai).
The perimeter boundary
This is the first thing to know when deploying inside an air-gapped perimeter.
An unconfigured or disabled assistant makes zero outbound requests. The rejection happens before a request to the model is even assembled: log content never goes anywhere. An installation with no provider configured behaves exactly as if the product had no assistant at all.
A configured assistant calls exactly one address — the one the
administrator set in the “API base URL” field. Automatic 3xx
redirects are forbidden: another server’s response cannot steer the
request’s content to a different address.
The provider type determines the network policy:
| Provider | Scheme | Address | Where content goes |
|---|---|---|---|
| Self-hosted | http or https |
any, including private ranges | inside the perimeter |
| Cloud | https only |
must point to a public address (SSRF protection) | to the external provider |
Private ranges are only allowed for the “Self-hosted node” provider — for
example, http://ollama:11434/v1 on a docker network. http is forbidden
for a cloud provider, so that source code never leaves in plain text.
Exactly where requests go is visible in the interface: the settings page and the explain button both show the provider’s address, and for a cloud provider, a warning that content goes to the external provider.
CI secrets never make it into the log: secret variable values are stripped
out when the log is written ([MASKED]), which happens before the log can
ever be handed to the model.
Providers
Any server with a POST {base_url}/chat/completions interface works:
- Ollama —
http://host:11434/v1, a model such asqwen2.5-coder:7b; - vLLM, llama.cpp server, LM Studio — their own address, model by name;
- cloud providers with the same interface —
https://…/v1and an access key.
The access key is stored encrypted (AES-256-GCM) and is never returned in
any API response: the interface only shows an “is set” flag. When editing
settings, an empty key field means “keep the current one,” and the “Remove
key” button next to the field means “clear it” (a self-hosted server
usually doesn’t need a key). After the installation’s jwt_secret is
replaced, the stored access key can no longer be read, and the assistant
won’t respond until the key is entered again.
The “Check connection” button queries GET {base_url}/models and, if the
provider returns a list, checks the model name against it, then makes a
trial message exchange — one word and up to sixteen response tokens. This
checks the address, the key, and the model all at once, including for a
reasoning model. The check can be run before settings are saved.
How long the model is allowed to take
“Response timeout” in settings ranges from 1 to 600 seconds, 180 by default; that’s how long the assistant waits for the provider’s response. A reasoning model, or a local model running on a CPU, can easily take minutes to explain a job’s log — the default allows for that.
If the model doesn’t make it in time, that’s exactly what the member sees — “the model didn’t respond within so many seconds” — not “provider unreachable”: the connection is fine, and the wait needs to be raised in the assistant’s settings.
Requests that wait on the model (explaining a job, explaining a pull
request, testing the connection) aren’t subject to the API’s general
30-second request limit: their limit is set by ai_http_timeout_secs
(GITRIVER_AI_HTTP_TIMEOUT_SECS, 660 seconds by default — the wait cap
plus a minute for assembling the request). Don’t set it below the
configured wait: the request would get cut off early, and instead of an
explanation the member would see 504.
A reasoning model: when there’s no answer at all
For reasoning models (GLM, Qwen with thinking enabled, DeepSeek-R1, and others), the response length limit gets spent on the thinking first. At the “maximum” reasoning level, a thousand tokens — the setting’s default — gets spent on that entirely, the response is cut off by length, and the assistant gets nothing back.
The failure in this case states the reason directly: “the model spent its response-length limit on thinking (so many tokens) and never got to an answer.” It’s fixed one of two ways:
- raise the response length in settings (the ceiling is 32,768 tokens);
- turn off thinking through extra request fields (below).
Extra request fields
A JSON object that gets merged into the request body sent to the model
as-is. This is where you set anything that has no dedicated setting
field: a thinking toggle, temperature, top_p, any extensions your
server supports.
There is no universal thinking toggle — the way to write it depends on the provider, which is why the field is free-form rather than a dropdown:
| Server | What to put in |
|---|---|
| OpenAI-compatible (and recent vLLM) | {"reasoning_effort": "low"} |
| vLLM, SGLang with GLM and Qwen | {"chat_template_kwargs": {"enable_thinking": false}} |
| gateways for the Anthropic interface | {"thinking": {"type": "disabled"}} |
| tuning output | {"temperature": 0.2, "top_p": 0.9} |
The fields model, messages, max_tokens, and stream are set by the
assistant itself and cannot be overridden here: the setting is rejected,
naming the field. An empty field means nothing gets added. Size limit —
up to 8 KiB.
Access keys don’t belong here. The provider’s key is stored encrypted in its own field; extra fields are stored as-is and returned to the administrator in the settings response — a key put here would end up stored and visible in plain text.
Thinking that arrives not as a separate field but right inside the
response text (between <think> and </think> — the way llama.cpp and
some GLM and Qwen templates deliver it) gets stripped by the assistant:
the member sees the explanation, not the draft that led to it. If the
length limit runs out in the middle of the thinking and there’s no
closing tag, there is no answer at all — and the assistant will say so,
rather than show a fragment.
“Check connection” sends its trial request with the same extra fields, so a field the provider’s server doesn’t recognize shows up during the check itself.
Explaining a failed CI job
On the pipeline page, a failed job gets an “Explain failure” button. The assistant receives the job’s context (repository, branch, name, stage, exit code, image) and the tail of the log, and returns an explanation: the cause, an explanation of it, and what to do.
- The response is marked machine-generated (
machine_generated) — the reader can see the text wasn’t written by a human, and which model wrote it. - The log is sent as a tail: the cause of failure is almost always at the end, while the beginning is taken up by installing dependencies. The tail’s size is set by the “Log sent for analysis” setting. If the whole log didn’t fit, both the model and the reader are told so.
- Explanations are available to anyone with write access to the repository; read access isn’t enough, because a call spends the repository owner’s monthly limit.
- A successful job is never sent for explanation.
Explaining pull request changes
On the pull request page, a member with write access clicks “Review changes.” The model receives the title, description, branches, and the text of the diff, and the answer is posted as an ordinary comment marked “Generated by a model.”
- As an ordinary comment, the explanation shows up in the discussion feed, in threads, and in the unresolved count. The mark is applied by the server; a client cannot declare its own comment machine-generated.
- The comment’s author is whoever requested the explanation: it’s clear at whose request it appeared, and edit/delete permissions stay the usual ones.
- The signature under the explanation names the model and how many files were reviewed.
- Files are handed to the model whole and in order, for as long as there’s room (“Changes sent for review” in settings); a file that doesn’t fit whole isn’t cut. Binary and non-fitting files are listed separately — both the model and the reader can see that not everything was reviewed.
- A request with no changes is never sent for explanation.
Usage tracking and limits
Usage is charged to the repository’s owner — a user or a group, the same party storage quotas are charged to. Not to whoever clicked the button: explaining a build on a team repository is the team’s usage.
The tracking window is a calendar month (from the first day, UTC): the limit resets on the first day of every month.
| Level | Where it’s set |
|---|---|
| Installation default | “Monthly limit per owner” in the assistant’s settings |
| Owner override | the usage table, “Set limit” button |
Zero means “unlimited” — both in the default and in an override. An override takes priority over the default, so a zero set for an owner removes the installation’s limit rather than forbidding calls.
If a provider doesn’t report usage in its response, it’s estimated from the length of the texts.
On top of the monthly limit there’s a rate limit: 10 requests per
minute per client — shared across every call that waits on the model
(explaining a failed job, explaining changes, and testing the provider
connection from settings), and separate from the limit on write calls.
Exceeding it gives 429 with a Retry-After header.
The limit is soft
The limit is checked before the call to the model, against usage already recorded in the ledger: nobody knows in advance how many tokens the next call will take, so the rule reads as “a new call may not be started” rather than “cut off mid-sentence.” A limit that’s been reached (spent ≥ limit) already counts as exhausted. Two consequences follow:
- sequential calls overrun the limit by at most one call’s worth (with a
limit of 300 and a call costing 120 tokens, 360 gets charged, then
403); - concurrent calls are checked against the same usage and may all go through: 10 parallel requests at the same limit of 300 will charge up to 1200. The rate limit bounds how big such an overrun can get — a client can’t make more than 10 calls a minute.
After an overrun, the next call gets 403 with the actual numbers. A
limit like this is built for a budget, not a ban: set it based on a
monthly allowance, not as a hard wall on every single call.
What’s visible in a rejection
The detail of a provider’s rejection (its message text) is seen only by the administrator — in the connection check and in the server log. Whoever clicked “Explain failure” gets back a rejection code with no detail: providers’ messages may contain the beginning of the access key and the model server’s address.
API
| Action | Method and path |
|---|---|
| Assistant settings (without the key) | GET /api/v1/admin/ai |
| Save settings | PUT /api/v1/admin/ai |
| Forget settings | DELETE /api/v1/admin/ai |
| Test connection to the provider | POST /api/v1/admin/ai/test |
| Usage by owner for the month | GET /api/v1/admin/ai/usage?limit=&offset= |
| User limit | PUT/DELETE /api/v1/admin/ai/limits/users/{id} |
| Group limit | PUT/DELETE /api/v1/admin/ai/limits/groups/{id} |
| Assistant status | GET /api/v1/ai/status |
| Explain a failed job | POST /api/v1/repos/{owner}/{name}/pipelines/{id}/jobs/{job_id}/explain |
| Explain pull request changes | POST /api/v1/repos/{owner}/{name}/pull_requests/{number}/ai-review |
Every path requires the Pro edition (or Max). The administrative paths
(/api/v1/admin/ai/**) aren’t metered by seat: an administrator manages
the assistant without a seat. Calls made on a member’s behalf — the
assistant’s status and both kinds of explanation — also require a
seat for the caller (see
licensing); an account without a
seat runs at Community edition scope. The usage report is paginated:
total shows the overall number of owners, so the tail of the list is
never lost.
Example: Ollama next to GitRiver
# docker-compose.yml — excerpt
services:
ollama:
image: ollama/ollama
volumes:
- ollama:/root/.ollama
volumes:
ollama:
docker compose exec ollama ollama pull qwen2.5-coder:7b
Then, in Administration → AI assistant:
- provider — “Self-hosted node”;
- API base URL —
http://ollama:11434/v1; - model —
qwen2.5-coder:7b; - no access key needed.
Log content never leaves the compose network.